Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails向け高機能カウンタキャッシュ gem「counter_culture」README(翻訳)

概要

MITライセンスに基づいて翻訳・公開します。

magnusvk/counter_culture - GitHub


  • 初版公開: 2017/08/03(counter_culture v1.7.0
  • 訳文更新: 2021/07/01
  • 訳文更新: 2024/02/28

counter_culture README(翻訳)

Railsアプリ向けの、ターボの効いたカウンタキャッシュです。Rails標準のカウンタキャッシュと比べて多くの点が改善されています。

  • カウンタキャッシュの更新を、値の作成や破棄のほか、値の変更時にも行える
  • 「多階層カウンタキャッシュ」をサポート(訳注: リレーション階層が離れていてもカウンタキャッシュの更新を直接指定できる)
  • 動的なカラム名をサポート: オブジェクトの種類ごとにカウンタキャッシュを分離
  • カウントの他に合計も出せる

Ruby 2.6、2.7、3.0、3.1、3.2、3.3、およびRails 5.2、6.0、6.1、7.0、7.1の最新パッチリリースでテストされています。

注意: Rails組み込みのカウンタキャッシュと異なり、counter_cultureはActive Record関連付けの.sizeの振舞いを変更しません。データベースへのクエリ発生を避けてキャッシュ値を読み込みたい場合は、カウンタキャッシュを含む属性名を直接お使いください。

product.categories.size  # => SELECT COUNT(*)クエリが発生
product.categories_count # => クエリを発生せずにカウンタキャッシュを使う

🔗 インストール

Gemfileにcounter_cultureを追加します。

gem 'counter_culture', '~> 3.2'

次にbundle installを実行します。

🔗 データベーススキーマ

必要なカラムをすべてのカウンタキャッシュについて作成しなければなりません。counter_cultureのジェネレータで、マイグレーション用のスケルトンを作成できます。

rails generate counter_culture Category products_count

上を実行すると、以下のようなコードを含むマイグレーションが生成されます。

add_column :categories, :products_count, :integer, null: false, default: 0

注意: gemが正常に機能するには、カラムは必ずNOT NULLに設定し、ゼロ値のデフォルトを設定する必要があります。

既存のデータにカウンタキャッシュを追加する場合は、生成されたマイグレーションに手動で値を設定する必要があります。

🔗 利用法

🔗 シンプルなカウンタキャッシュ

🔗 has_many関連付け
class Product < ActiveRecord::Base
  belongs_to :category
  counter_culture :category
end

class Category < ActiveRecord::Base
  has_many :products
end

Productモデルにcounter_culture :categoryと書くことで、Categoryモデルのcategoriesテーブルのproducts_countカラムのカウンタキャッシュが最新に保たれます。

🔗 多対多の関連付け
class User < ActiveRecord::Base
  has_many :group_memberships
  has_many :groups, through: :group_memberships
end

class Group < ActiveRecord::Base
  has_many :group_memberships
  has_many :members, through: :group_memberships, class: "User"
end

class GroupMembership < ActiveRecord::Base
  belongs_to :group
  belongs_to :member, class: "User"
  counter_culture :group, column_name: "members_count"
  # `members_count`の更新時にgroupでtouchも指定したい場合:
  # counter_culture :group, column_name: "members_count", touch: true
end

これで、Groupモデルのmembers_countカラムに最新のメンバー数が表示されます。

🔗 多階層カウンタキャッシュ
class Product < ActiveRecord::Base
  belongs_to :sub_category
  counter_culture [:sub_category, :category]
end

class SubCategory < ActiveRecord::Base
  has_many :products
  belongs_to :category
end

class Category < ActiveRecord::Base
  has_many :sub_categories
end

Productモデルにcounter_culture [:sub_category, :category]と書くことで、リレーション階層が離れたCategoryモデルのcategoriesテーブルのproducts_countのカウンタキャッシュを最新に保ちます。カウントキャッシュを指定できる階層レベル数に制限はありません。

カウンタキャッシュは、リレーションの階層レベルごとに指定する必要があります。上のコード例で、CategorySubCategoryのそれぞれにproductのカウントが必要な場合は、Productクラスを次のように変更します。

class Product < ActiveRecord::Base
  belongs_to :sub_category
  counter_culture [:sub_category, :category]
  counter_culture [:sub_category]
end

🔗 カラム名のカスタマイズ

class Product < ActiveRecord::Base
  belongs_to :category
  counter_culture :category, column_name: "products_counter_cache"
end

class Category < ActiveRecord::Base
  has_many :products
end

Productモデルにcounter_culture :category, column_name: "products_counter_cache"と書くことで、Categoryモデルのcategoriesテーブルのproducts_counter_cacheカラムのカウンタキャッシュが最新に保たれます。カウントキャッシュを指定できる階層レベル数に制限はありません。

🔗 動的なカラム名

class Product < ActiveRecord::Base
  belongs_to :category
  counter_culture :category, column_name: proc {|model| "#{model.product_type}_count" }
  # product_type属性は ['awesome', 'sucky'] のいずれか
end

class Category < ActiveRecord::Base
  has_many :products
end

🔗 増分(delta magnitude)の指定

class Product < ActiveRecord::Base
  belongs_to :category
  counter_culture :category, column_name: :weight, delta_magnitude: proc {|model| model.product_type == 'awesome' ? 2 : 1 }
end

class Category < ActiveRecord::Base
  has_many :products
end

Productモデルに上のように書くことで、Categoryモデルのweightカラムのカウンタキャッシュが最新に保たれます。productがawesomeなら増分は2、それ以外なら増分は1になります。

次のように、delta_magnitudeに固定の増分を指定することもできます。

class Product < ActiveRecord::Base
  belongs_to :category
  counter_culture :category, column_name: :weight, delta_magnitude: 3
end

class Category < ActiveRecord::Base
  has_many :products
end

Productに追加が1件あると、Categoryweightカラムが3増え、Productで削除が1件あると3減ります。

🔗 条件付きカウンタキャッシュ

class Product < ActiveRecord::Base
  belongs_to :category
  counter_culture :category, column_name: proc {|model| model.special? ? 'special_count' : nil }
end

class Category < ActiveRecord::Base
  has_many :products
end

Productモデルに上のように書くことで、Categoryモデルのspecial_countのカウンタキャッシュが最新に保たれます。productのspecial?trueの場合にのみspecial_countを更新します。

これをcounter_culture_fix_countsと併用したい場合は、column_namesの設定も指定してください。

カウンタキャッシュの更新を一時停止する

バックフィルスクリプトなどでcounter_cultureを一時停止したい場合は、以下のようにできます。

Review.skip_counter_culture_updates do
  user.reviews.create!
end

user.reviews_count # => 変更されない

🔗 カウントの代わりに合計を出す

カウントを実行する代わりに、合計を自動更新することもできます。
この場合、対象のカウンタを1ずつ増やす代わりに、フィールド値の合計で更新します。

カウントを行うオブジェクトの特定のフィールド値をカウンタの増分に使いたい場合は、:delta_columnオプションを使います。

たとえば、Productモデルのテーブルにweight_ouncesフィールドがあり、Categoryモデルのproduct_weight_ouncesにあるすべてのproductについてweightの合計を最新に保つ場合は、次のようにします。

class Product < ActiveRecord::Base
  belongs_to :category
  counter_culture :category, column_name: 'product_weight_ounces', delta_column: 'weight_ounces'
end

class Category < ActiveRecord::Base
  has_many :products
end

Productモデルに上のように書くことで、Categoryモデルのproduct_weight_ouncesのカウンタキャッシュが最新に保たれます。
このカウンタキャッシュの値は、Categoryに関連付けられているProductの各レコードのweight_ouncesを合計した値になります。

delta_columnオプションでは、:integerを含むすべての数値型カラムをサポートします。特に、:floatもサポート対象かつテスト済みです。

🔗 foreign_key_valuesによる外部キーの動的上書き

class Product < ActiveRecord::Base
  belongs_to :category
  counter_culture :category, foreign_key_values:
      proc {|category_id| [category_id, Category.find_by_id(category_id).try(:parent_category).try(:id)] }
end

class Category < ActiveRecord::Base
  belongs_to :parent_category, class_name: 'Category', foreign_key: 'parent_id'
  has_many :children, class_name: 'Category', foreign_key: 'parent_id'

  has_many :products
end

上のコードによって、Categoryモデルのcategoriesテーブルのproducts_countカラムのカウントキャッシュが最新に保たれます。各productは、直接のcategoryのカウンタと、そのcategoryの親のカウンタの両方に影響します。カウントキャッシュを指定できる階層レベル数に制限はありません。

🔗 カウンタ変更時にタイムスタンプを更新する

counter_culture gemは、カウンタキャッシュ更新時にモデルのタイムスタンプをデフォルトでは更新しません。カウンタキャッシュカラムの更新時にタイムスタンプも更新したい場合は、touchオプションにtrueを指定します。

  counter_culture :category, touch: true

このオプションは、カウンタキャッシュ変更時にキャッシュを無効にする必要がある場合に便利です。

🔗 カスタムのタイムスタンプカラム

特定のカウンタキャッシュが変更された場合にのみ更新されるタイムスタンプカラムを独自に指定することもできます。

  counter_culture :category, touch: 'category_count_changed'

上のようにオプションを指定すると、category_counter_cacheの更新時にcategory_count_changedカラムとupdated_atカラムが常に両方とも更新されます。

🔗 デッドロックの回避と、commit後のカウンタキャッシュ更新

アプリケーションによっては、このgemを使うとカウンタキャッシュの更新に伴ってデッドロックの問題が発生することがあります。この問題を回避するための情報や有用なリンクについては#263を参照してください。

もうひとつの方法は、単にカウンタキャッシュの更新をトランザクションの外に延期することです。これにより、カウンタキャッシュ更新のトランザクションが保証されなくなる代わりにデッドロックが解消されるはずです。この振舞いはデフォルトでは無効であり、以下のように影響を受けるカウンタキャッシュごとに有効にしてください。

  counter_culture :category, execute_after_commit: true

: この機能を利用するには、Gemfileで以下のように依存関係としてafter_commit_actionを手動で指定する必要があります。

...
gem "after_commit_action"
...

counter_cultureProcを渡して動的に制御することも可能です。これは、トランザクション内でカウンタキャッシュの更新を一時的に移動したい場合に便利です。

  counter_culture :category, execute_after_commit: proc { !Thread.current[:update_counter_cache_in_transaction] }

🔗 カウンタキャッシュ値を手動で展開する

主要なデータのカウンタキャッシュ値を他の場所で使いたい場合があります。これは、たとえばカウンタキャッシュを既存のデータに追加する場合に必要になります。カウンタキャッシュに含まれる無効な値を検出するために、カウンタキャッシュは定期的に実行することをおすすめします(BestVendor社の場合、週に1度実行しています)。

Product.counter_culture_fix_counts
# Productで定義済みの全カウントを自動で修正する

Product.counter_culture_fix_counts exclude: :category
# Productで定義済みの全カウントを自動で修正する
# ただし:categoryのリレーションについては除く

Product.counter_culture_fix_counts only: :category
# Productの:categoryリレーションについてのみカウントを自動で修正する
# :excludeと:onlyには、同じ階層レベルにあるリレーションの配列も指定できる
# カウントの自動修正を多階層にわたって行う場合は、これではなく、その次の[[ ]]書式が必要

Product.counter_culture_fix_counts only: [[:subcategory, :category]]
# Productの2つの階層レベルのリレーション([:subcategory, :category])についてのみカウントを自動で修正する

Product.counter_culture_fix_counts column_name: :reviews_count
# Productの:reviews_count columnでのみカウントを自動で修正する
# これにより、処理済みのカラムをスキップできる
# 1個のカウンタキャッシュカラムにのみ影響する大規模なDB変更で有用

# :exceptと:onlyには配列も指定できる

Product.counter_culture_fix_counts verbose: true
# ログをSTDOUTに出力する

Product.counter_culture_fix_counts only: :category, where: { categories: { id: 1 } }
# Productの「id 1リレーション」を持つ:categoryでのみカウントを自動で修正

カウント用のcounter_culture_fix_countsメソッドでは、レコードをバッチ処理することでメモリ消費を抑えています。デフォルトのバッチサイズは1000ですが、以下の方法で設定することもできます。

# initializerに追加
CounterCulture.config.batch_size = 100

メソッド呼び出しでも:batch_sizeオプションでサイズを指定できます。

Product.counter_culture_fix_counts batch_size: 100

counter_culture_fix_countsはデバッグ用に、すべての無効な値をハッシュの配列として返します。ハッシュの形式は次のとおりです。

{ entity: カウントを修正するモデル,
  id: カウントが誤っているモデルのid,
  what: 誤ったカウントがあるカラム名,
  wrong: 前回保存されている誤ったカウント,
  right: 修正された正しいカウント }

counter_culture_fix_countsの動作は高速で、クエリ数を最小限に抑えるよう最適化されています。

counter_cultureと同様に、カウント修正時にレコードのタイムスタンプを更新できます。デフォルトのタイムスタンプフィールドを更新したい場合は以下のようにtouch: trueオプションを渡します。

Product.counter_culture_fix_counts touch: true

カスタムのタイムスタンプカラムを指定している場合は、その名前を touch オプションの値として渡します。

Product.counter_culture_fix_counts touch: 'category_count_changed'

🔗 複数ワーカーでカウンタキャッシュ修正をパラレル化する

start:オプションとfinish:オプションは、特に複数ワーカーで同じ処理キューを扱いたい場合に有用です。ワーカーごとにstart:finish:を設定することで、たとえばワーカー1ではid 1〜9999までの全レコードを処理し、ワーカー2ではid 10000以上のレコードを処理できるようになります。

注意! ここでstartおよびfinishとして渡すIDは、実際にはProductのIDではなくCategoryのIDです!

Product.counter_culture_fix_counts start: 10_000
# Productで定義されたid 10000以上のレコードで全カウンタキャッシュを修正する

Product.counter_culture_fix_counts finish: 10_000
# レコード数10,000件まで処理する

Product.counter_culture_fix_counts start: 1000, finish: 2000
# ワーカー1では1000〜2000まで処理する

Product.counter_culture_fix_counts start: 2001, finish: 3000
# ワーカー2では2001〜3000まで処理する

🔗 replicaデータベースを用いるカウンタキャッシュを修正する

カウンタキャッシュの修正では、readの回数がwriteの回数を大幅に上回るのが普通です。このような場合は、readの負荷をreplicaデータベースに逃がすのが合理的です。

Rails 6からは、マルチプルデータベースによってreplicaデータベースがネイティブでサポートされました。replicaデータベースを利用することで、以下のようにdb_connection_builderオプションを指定してreadトラフィックをread-only replicaに送信できるようになります。

Product.counter_culture_fix_counts db_connection_builder: proc{|reading, block|
  if reading # count呼び出しでreadingコネクションをリクエストする
    Product.connected_to(role: :reading, &block)
  else # すべての呼び出しが非readingコネクションをリクエストする
    Product.connected_to(role: :writing, &block)
  end
}

🔗 動的なカラム名を扱う

動的なカラム名が使われているカウンタキャッシュを手動で流用する場合、以下の追加設定が必要です。

class Product < ActiveRecord::Base
  belongs_to :category
  counter_culture :category,
      column_name: proc {|model| "#{model.product_type}_count" },
      column_names: {
          ["products.product_type = ?", 'awesome'] => 'awesome_count',
          ["products.product_type = ?", 'sucky'] => 'sucky_count'
      }
  # product_type属性は ['awesome', 'sucky'] のいずれか
end

column_namesでは、条件文字列の代わりにスコープも指定できます。スコープにはハッシュを直接渡すのではなく、ハッシュを返すProcを渡すことが推奨されます。スコープを直接提供すると、起動時にスキーマキャッシュが読み込まれてrake db:migrateなどの機能が中断します。

class Product < ActiveRecord::Base
  belongs_to :category
  scope :awesomes, ->{ where "products.product_type = ?", 'awesome' }
  scope :suckys, ->{ where "products.product_type = ?", 'sucky' }

  counter_culture :category,
      column_name: proc {|model| "#{model.product_type}_count" },
      column_names: -> { {
          Product.awesomes => :awesome_count,
          Product.suckys => :sucky_count
      } }
end

この設定を避けて、動的なカラム名を持つカウンタキャッシュを単にスキップし、動的でないモデルのカウンタを修正したい場合は、以下のようにskip_unsupportedオプションを渡せます。

Product.counter_culture_fix_counts skip_unsupported: true

column_namesメソッドに渡したブロック内で、以下のようにコンテキストを利用することも可能です。

class Product < ActiveRecord::Base
  belongs_to :category
  scope :awesomes, -> (ids) { where(ids: ids, product_type: 'awesome') }

  counter_culture :category,
      column_name: 'awesome_count'
      column_names: -> (context) {
        { Product.awesomes(context[:ids]) => :awesome_count }
      }
end

Product.counter_culture_fix_counts(context: { ids: [1, 2] })

🔗 外部キー動的上書きの制限事項

:foreign_key_valuesオプションを使っている場合、「カウンタキャッシュ値を手動で流用する」に記載されている方法はサポートされません。独自のコードを書く必要があります。

🔗 paranoiadiscardによる論理削除

rubysherpas/paranoia - GitHub
jhawthorn/discard - GitHub

本gemは、論理削除(soft-delete)をサポートするparanoia gemやdiscardgemをRails 4.2以降で使う場合にカウンタを正しく更新します。ただし、リストア後にカウンタが正しく増加するには、モデル内でcounter_cultureを呼び出す前に論理削除を設定(acts_as_paranoidまたはinclude Discard::Model)する必要があります。

🔗 Paranoia

class SoftDelete < ActiveRecord::Base
  acts_as_paranoid

  belongs_to :company
  counter_culture :company
end

🔗 Discard

class SoftDelete < ActiveRecord::Base
  include Discard::Model

  belongs_to :company
  counter_culture :company
end

🔗 PaperTrailとの統合

paper-trail-gem/paper_trail - GitHub

paper_trail gemを利用していて、counter_cultureによってカウンタキャッシュカラムが変更されたときに新しいバージョンを作成したい場合は、以下のようにwith_papertrailオプションを指定できます。

class Review < ActiveRecord::Base
  counter_culture :product, with_papertrail: true
end

class Product < ActiveRecord::Base
  has_paper_trail
end

🔗 ポリモーフィック関連付け

counter_culture gemは、1階層レベルのみのポリモーフィック関連付けをサポートするようになりました。

counter_culture_fix_counts経由でどのモデルを更新する必要があるかを検出するため、counter_cultureはポリモーフィック関連付けに対してDISTINCTクエリを実行します。
このクエリは負荷が大きくなる可能性があるため、修正すべきモデルの個数を指定するためのpolymorphic_classesオプションを提供しています。

Image.counter_culture_fix_counts(polymorphic_classes: Product)
# または
Image.counter_culture_fix_counts(polymorphic_classes: [Product, Employee])

🔗 counter_culture gemに貢献するときの手順

  1. 常に最新のmasterブランチをチェックアウトし、機能が実装されていないかどうか、バグが修正されていることを確認します。

* GitHubのissue trackerで、同じissueがリクエスト済みかどうか、既に貢献済みかどうかを確認します。
* プロジェクトをforkします。
* feature/bugfixブランチを立てます。
* コードに修正や改良を加えたらcommit、pushします。
* 貢献の際は必ずテストコードも追加してください。貢献した機能が将来不意に動かなくならないようにするために重要です。
* Rakefileのバージョンや履歴で問題が発生しないようご注意ください。独自のバージョンを利用したい場合や必要な場合でも貢献は可能ですが、こちらでcherry-pickできるようにコミットを分けておくようお願いします。

🔗 Copyright

Copyright (c) 2012-2021 BestVendor, Magnus von Koeller. See LICENSE.txt for further details.

関連記事

Rails: Active Recordコールバックを使わずにカウンタキャッシュを更新する(翻訳)

Rails: render_async gemでレンダリングを高速化(翻訳)

Rails: データベースのパフォーマンスを損なう3つの書き方(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。