kakudooo docs

Counter Cache Part 1

Webアプリケーションを開発していると、特定のカテゴリに所属する記事や商品の件数を一覧で表示したいことがある。(例: ECサイトの商品カテゴリ毎の商品数表示) カテゴリ毎の要素を数えるSQLクエリはテーブルのフルスキャンが避られないので、必然的にコストの高いクエリになってしまう。

参考: カテゴリ毎の件数表示

のような方法を使うことでSQLクエリの性能面でのボトルネックに対して対策を行うことができる。

ActiveRecordにはCounter Cacheという機能があり、親モデルのオブジェクトに従属している子モデルのオブジェクトの数をキャッシュすることができる。 要求によっては今後活用することがありそうなので、ActiveRecordを使用したCounter Cache機能の実装についてまとめておくことにする。 Part 1ではまず、ActiveRecord標準搭載のCounter Cache機能の仕様と使用にあたっての制約や注意点をまとめておくことにする。

Part 2では、counter-culture、Part 3では、自前でCounter Cache機能の実装する場合についてそれぞれまとめる予定。

以下の記事も参考になる。

実装

どのように実現するかというと、親モデルに対応するテーブルのレコードに従属するテーブルのレコードの数を保持することで実現している。

Railsガイドの例を借りて以下のようなモデルを考える。

class Book < ApplicationRecord
  belongs_to :author
end

class Author < ApplicationRecord
  has_many :books
end

authorsテーブルにキャッシュ用のカラムを追加する。

class AddBooksCountToAuthors < ActiveRecord::Migration[6.0]
  def change
    add_column :authors, :books_count, :integer, default: 0, null: false
  end
end

従属する側(Book)のモデルにCounter Cacheを定義する。 ※ :counter_cacheオプションは、関連付けのbelongs_to側にだけ指定する必要がある。

class Book < ApplicationRecord
  belongs_to :author, counter_cache: true
end

これで、DBにクエリcount(*)を発行せずにキャッシュ保存用のカラムから値を返すことができる。(sizeメソッドなどを呼び出した場合など)

以下は、booksテーブルを更新・削除した場合のキャッシュ更新で発行されるクエリ

レコードの作成時

INSERT INTO "books" ("author_id", "title", "created_at", "updated_at") VALUES (1, '集合論入門', '2025-04-20 12:25:38.910376', '2025-04-20 12:25:38.921129', '2025-04-20 12:25:38.921129')

# キャッシュが加算される
UPDATE "authors" SET "books_count" = COALESCE("books_count", 0) + 1 WHERE "authors"."id" = 1

レコードの削除時

DELETE FROM "books" WHERE "books"."id" = 1

# キャッシュが減算される
UPDATE "authors" SET "books_count" = COALESCE("books_count", 0) - 1 WHERE "authors"."id" = 1

使用上の制約や注意点

このCounter Cacheという機能、便利なのだがいくつ制約がある。 以下は今わかっている制約。

カラムの追加とバックフィル作業の分離

カウンタキャッシュ用のカラムを追加する際に、同時に現在の関連レコードのカウント数をあらかじめ集計して保存しておく(バックフィル)必要がある。 マイグレーションと同時に集計&バックフィルを実行すると、長時間テーブルがロックされる可能性があるため、カラムの追加とバックフィル作業を分ける必要がある。

子レコードの作成や削除で発生するキャッシュ用のカラム更新を止めずに、安全にバックフィルしたい場合は、counter_cache: { active: false }を指定して、その間常にDBから結果を取得させることができる。

counter_cache: { active: false }を指定するとsizeany?メソッドの実行時に、キャッシュ用のカラムから値を参照するのではなく、以下のようなクエリが実行されるようになる。

SELECT COUNT(*) FROM "books" WHERE "books"."author_id" = 1

また、その場合も関連レコードが追加・削除する度にカウンタキャッシュの用カラムは更新され続ける。

キャッシュのリセット

reset_countersメソッドを使用することで、実際にSQLクエリを発行して最新のカウント数に修正することができる。

などに使う。

以下は例

Author.reset_counters(1, :books)

キャッシュ条件の定義

例えば、ある著者の書籍について、レコードは存在しているが公開していない場合はカウントはしないという要求があるとする。 ActiveRecordのCounter Cache機能だと「booksの公開フラグがoffなものだけカウントしない」ような条件を指定することができず、booksの状態にかかわらずキャッシュ用のカラムを更新してしまう。

従属するテーブルのレコードに対して条件を追加したい場合は

必要がある

参考

レコードの追加・削除時にしかキャッシュカラムが更新されない

前述した通り、ActiveRecordのCounter Cache機能は従属するテーブルのレコードの作成と削除時のみ、キャッシュ用のカラムの値が更新される。 レコードの更新をキャッシュ用のカラムの更新の対象としたい場合は、counter_cultureや自前でカウンタキャッシュ機構を実装する必要がある。