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 }
を指定するとsize
やany?
メソッドの実行時に、キャッシュ用のカラムから値を参照するのではなく、以下のようなクエリが実行されるようになる。
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や自前でカウンタキャッシュ機構を実装する必要がある。