counter_cacheとは?Railsでの実装手順とカウントずれ・後付けの対処
counter_cacheは、関連する子レコードの件数を親テーブルのカラムに持たせ、COUNTクエリの発行そのものを止めるActive Recordの標準機能です。投稿一覧にコメント数を出す画面では、1行ごとに件数を数えるクエリが積み上がる構造になります。カラムに持たせておけば、その1行分のクエリは消えます。ここではRails 8.1系(8.1.3.1、2026年7月29日リリース)時点の挙動を、Rails本体のソースとテストコードで裏を取りながら、設定手順・既存テーブルへの後付け・カウントがずれたときの復旧まで整理しました。
まとめ:counter_cache導入の判断と手順の要点
- 設定は子モデルの
belongs_toにcounter_cache: trueを書き、親テーブルにnull: false, default: 0のカウンタカラムを追加する2手順です。 - キャッシュを読むのは
size・empty?・any?・none?で、countは毎回SQLを発行します。呼び分けを間違えるとカラムを足しても効きません。 - 既存の大きなテーブルには、Rails 7.2で追加された
counter_cache: { active: false }を挟むと、値を同期させながら安全にバックフィルできます。 - ずれるのはモデルを介さない一括操作です。
Comment.where(...).delete_allやupdate_all、直接SQLが該当します。関連経由のpost.comments.delete_allはカウンタを維持します。 - 同じ親レコードへ書き込みが集中する用途では採用しない判断が要ります。子の保存トランザクション内で親行のUPDATEが走るためです。
以下、設定の具体と、公式ドキュメントの記述だけでは読み取れない挙動の根拠を順に見ていきます。
counter_cacheが削るクエリと、値が更新される契機
一覧表示でCOUNTクエリが積み上がる経路と、外部キー索引で足りる場合の線引き
親30件の一覧で各件のコメント数を表示すると、親を引く1本に加えてCOUNTが30本走ります。ただしカウンタカラムを足す前に確認したいのが、子テーブルの外部キーに索引があるかどうかです。comments.post_idに索引が無ければ、まず索引を張るだけで各COUNTは十分速くなります。counter_cacheが効いてくるのは、索引があってもなお1行あたり1本のクエリを消したい一覧画面です。クエリ最適化の全体像はクエリとは?SQLとの違い・種類・実行の仕組みと高速化を実装目線で解説で扱っています。
includesで子を事前読み込みしておけば、sizeはクエリを打ちません。Rails 8.1のソースでは、読み込み済みの関連に対するsizeがメモリ上のtarget.sizeを返します。代償は、子レコードの実体を全件メモリへ載せることです。countを呼んでいる箇所では、事前読み込みしてもCOUNTが30本残ります。
件数でのソート・絞り込みを可能にするカウンタ列の索引
counter_cacheの実用価値は、COUNTを消すことだけではありません。カウンタは通常の整数カラムなので、索引を張ればORDER BYやWHEREの対象にできます。
# マイグレーション
add_index :posts, :comments_count
# アプリケーション側の呼び出し
Post.order(comments_count: :desc).limit(10)
Post.where(comments_count: 10..)
カウンタが無い状態で同じことをやるなら、サブクエリかLEFT JOIN+GROUP BY+HAVINGが必要になり、件数順のページングは重くなります。「コメントが多い順」「レビュー1件以上の商品だけ」といった要件が出た時点で、カウンタカラムの導入理由は読み取り速度より機能面に寄ります。
作成・破棄・外部キー付け替えで走る親行のUPDATE
更新の契機は3つです。Rails 8.1のActive Record本体では、レコード作成の内部処理_create_recordと削除処理destroy_rowから関連ごとのカウンタ増減が呼ばれます。加えてbelongs_toの関連定義がafter_updateコールバックを登録しており、外部キーを付け替えた更新では旧親を減算・新親を加算します。comment.update(post: other_post)で両方の件数が正しく動くのはこの仕組みによるものです。反応しないのは、外部キー以外の状態カラム(publishedなど)の更新だけになります。
発行されるSQLは加算・減算の形になります。
UPDATE posts
SET comments_count = COALESCE(comments_count, 0) + 1
WHERE id = 5
読み込んだ値に1を足して書き戻すのではなく、データベース側で加算します。同時に複数の子が作られても数え落としは起きません。ただしこのUPDATEは子の保存と同じトランザクションに入ります。コミットするまで親行のロックが残る点が、後述する採用可否の分かれ目です。
実装手順:カウンタカラムの追加とbelongs_toの設定
カラム追加のマイグレーションと、既定カラム名の導出元
Commentモデルを数えるならposts側にcomments_countを用意します。NULLを許すと未設定の親で表示が崩れるため、null: false, default: 0を付けます。
class AddCommentsCountToPosts < ActiveRecord::Migration[8.1]
def change
add_column :posts, :comments_count, :integer, null: false, default: 0
end
end
既定のカラム名について、公式APIドキュメントは#{table_name}_countと表記しています。実装は少し違います。Rails 8.1のreflection.rbは、宣言元のクラス名を名前空間なしのスネークケースへ直し、複数形にして_countを付ける形(BlogCommentならblog_comments_count)で導出しており、元になるのはテーブル名ではなくモデルクラス名です。self.table_nameでテーブル名を変えているモデルでは両者が食い違うので、その場合はカラム名を明示指定してください。スキーマ定義をマイグレーションで積み上げるか宣言的に管理するかは、MigrationとRidgepoleの違いを俯瞰して理解する:命令型と宣言型の対比で比較しています。
belongs_to側の指定と、カラム名を変える場合の宣言
オプションを書く場所は子モデルのbelongs_toです。親のhas_many側には何も足しません。
class Comment < ApplicationRecord
belongs_to :post, counter_cache: true
end
1つの親が複数種類の子を持ち、既定名では衝突する場合はシンボルで指定します。このときだけ、親のhas_manyにも同じ名前が必要です。公式APIドキュメントもhas_manyの:counter_cacheについて「You only need this option, when you customized the name of your :counter_cache on the belongs_to association.」と書いています。
class Comment < ApplicationRecord
belongs_to :post, counter_cache: :post_comments_count
end
class Post < ApplicationRecord
has_many :comments, counter_cache: :post_comments_count
end
指定できるのはカラム名だけです。「公開済みのものだけ数える」といった条件は付けられません。
既存データのバックフィルとreset_countersの実行単位
カラムを足しただけでは、すでに存在する子レコードの分は0のままです。数え直しにはreset_countersを使います。第1引数はIDの配列も受け付けます。
Post.in_batches(of: 200) do |batch|
Post.reset_counters(batch.ids, :comments)
end
Rails 8.1の実装は、渡したID群に対してJOINとGROUP BYを使ったCOUNTを1回だけ発行し、その結果をIDごとのUPDATEで書き戻します。集計は1クエリにまとまる一方、書き込みは対象IDの数だけ走ります。of: 200なら1バッチで200本のUPDATEです。行数の多いテーブルでは、この単位を基準にジョブを分割してください。
前提が1つあります。reset_countersは子側のbelongs_toからcounter_cache宣言を持つ関連を探す作りなので、カラムだけ足してモデルを書き換えていない状態では動きません。宣言が先、バックフィルが後という順序になります。
既存の大規模テーブルへ後付けする手順(Rails 7.2以降のactive: false)
運用中のテーブルへの後付けには順序の問題があります。カラム追加とバックフィルの間に子レコードが増減すると、その分がどこにも記録されません。かといって先にcounter_cache: trueを有効にすると、バックフィルが終わるまでsizeなどが不正確な値を返してしまいます。Rails 7.2は、この期間だけカウンタを「書くが読まない」状態にするハッシュ形式を追加しました(activerecord CHANGELOG、作者はfatkodima氏)。
class Comment < ApplicationRecord
belongs_to :post, counter_cache: { active: false }
end
この状態でも子の作成・破棄に応じてカラムは更新され続けます。一方でsize・count・empty?・any?・none?はカラムを読まず、すべてデータベースへ問い合わせます。根拠はRails本体のテスト「inactive counter cache」です。この2点をそのまま検証しています。手順は次の順に進めてください。
- カウンタカラムを追加する(
null: false, default: 0)。 counter_cache: { active: false }でデプロイし、以後の増減を取りこぼさない状態にする。reset_countersをバッチで流し、既存分を埋める。{ active: false }を外してtrueにし、読み取りをカラムへ切り替える。
カラム名の変更を伴うならcounter_cache: { active: false, column: :post_comments_count }のように:columnキーを併用します。Rails 7.2で何が変わったかはActiveRecord 7.2とは何か?Rails 7.2で進化したORMの概要と注目ポイントを解説にまとめました。バックフィルを業務時間外のジョブで流すならSidekiqとは?Rubyで非同期処理を簡単に実現するバックグラウンドジョブツールが使えます。
キャッシュを読むメソッドと、必ずクエリを打つメソッド
カラムを追加してもcountを呼び続けていれば、SQLの本数は1本も減りません。Rails 8.1のテスト「active counter cache」は、カウンタが有効な関連に対して次の呼び出しがクエリを発行しないこと、そしてcountだけが1クエリ発行することを確認しています。
| 呼び出し | active: true | active: false | 返す値の性質 |
|---|---|---|---|
post.comments.size |
クエリ0 | クエリ1 | カラム値 |
post.comments.empty? |
クエリ0 | クエリ1 | カラム値の判定 |
post.comments.any? |
クエリ0 | クエリ1 | カラム値の判定 |
post.comments.none? |
クエリ0 | クエリ1 | カラム値の判定 |
post.comments.count |
クエリ1 | クエリ1 | 実データの集計 |
post.comments.length |
全件ロード | 全件ロード | 配列の要素数 |
ビューやシリアライザでcountを使っている箇所をsizeへ置き換えるところまでが導入作業です。逆に、カウンタのずれを疑って実数を確かめたい場面ではcountを明示的に呼びます。
カウンタがずれる操作と、復旧までの手順
モデルを介さない一括操作で生じるずれ
カウンタの増減はActive Recordのコールバック経路に載っています。RailsのRelationのドキュメントも、update_allについて「It does not instantiate the involved models and it does not trigger Active Record callbacks or validations」と明記しています。delete_allの項も「Deletes the records without instantiating the records first, and hence not calling the #destroy method nor invoking callbacks」です。ずれるのは次の操作になります。
Comment.where(post_id: 5).delete_allのような、クラスやリレーション経由の一括削除。Comment.where(...).update_all(post_id: 9)による外部キーの一括付け替え。insert_all・upsert_allによる一括挿入。- データベースクライアントやマイグレーションからの直接SQL。
逆に、関連経由のpost.comments.delete_allはカウンタを維持します。Rails 8.1のhas_many_association.rbが、削除件数を数えたうえでupdate_counter(-count)を呼んでいるためです。親を削除する場合はさらに単純で、dependent: :destroyではRailsが「No point in executing the counter update since we’re going to destroy the parent anyway」とコメントを添えて減算そのものを省きます。dependent: :delete_allは関連経由のdelete_allを通るため減算は走りますが、いずれにせよ親行ごと消えるのでどちらを選んでもカウンタは崩れません。注意すべきなのは、あくまでモデルを迂回した一括操作だけです。
カウンタ列への代入が更新時に無視される挙動と、7.1での実装移動
ずれに気付いてカラムへ直接代入しても、値は反映されません。
post.update(comments_count: 42)
# UPDATE の SET 句に comments_count は含まれない(例外も出ない)
Active Recordは更新用の属性を組み立てるattributes_for_updateで、読み取り専用属性・仮想カラム・カウンタ列を除外します。この「更新時だけ落とされる」挙動自体はRails 7.0以前から同じで、7.1で変わったのは管理の置き場所です。7.0まではattr_readonlyにカウンタ列を登録していましたが、7.1以降は_counter_cache_columnsという専用リストへ移りました。
この移動には副作用があります。Rails 7.1で追加された読み取り専用属性への代入時の例外ActiveRecord::ReadonlyAttributeErrorは、attr_readonlyを対象にした仕組みです。カウンタ列は別リストで扱われるため例外が飛ばず、代入は静かに捨てられます。なお挿入時の属性を組み立てるattributes_for_createでは除外されないので、新規作成時に渡した初期値は書き込まれます。この非対称が、値を直接いじろうとしたときの混乱の元です。
reset_countersによる復旧と、touchの副作用
正規の直し方はreset_countersです。実データをCOUNTして書き戻すため、原因が何であれ正しい値に戻ります。
Post.reset_counters(5, :comments)
Post.reset_counters([10, 15], :comments, touch: true)
touch: trueを渡すとupdated_atも更新されます。キャッシュキーにupdated_atを使っているなら、これでフラグメントキャッシュを確実に破棄できます。問題は、更新順のソートや同期処理のカーソルにupdated_atを使っている場合です。touch:にカラム名を渡しても回避はできません。Rails 8.1のtimestamp.rbにあるtouch_attributes_with_timeが、モデルの更新時刻カラムに渡された名前を追加する実装なので、touch: :counted_atはcounted_atとupdated_atの両方を更新します。updated_atを動かしたくないならtouch:を付けずに再集計し、目印が必要ならPost.where(id: ids).update_all(counted_at: Time.current)で別途更新してください。一括削除を運用に残すなら、夜間バッチで対象範囲だけ流す設計にします。全件の再集計を毎晩流す構成は、テーブルが育つほど実行時間が伸びるので避けましょう。
counter_cacheを採用すべきでない場面
最も避けたいのは、同じ親レコードへ子の作成が集中する用途です。人気投稿への「いいね」やライブ配信のコメントのように、特定の1行へ書き込みが継続的に集まる形では、子の保存トランザクションが同じ親行のUPDATEを取り合います。行ロックの競合はトランザクションの保持時間と書き込み頻度で決まるため、子の保存に他の重い処理が同居しているほど待ちが伸びます。読み取りを速くした代わりに書き込みが詰まる交換になるので、この形では採用しません。カウントはRedisのインクリメントや非同期の集計テーブルへ逃がします。
集計の形が合わない場合も対象外です。「公開済みのコメントだけ」のような条件付きの件数は表現できません。反応するのが作成・破棄・外部キー付け替えだけで、publishedのような状態カラムの更新では動かないためです。has_many :throughの先も数えられません。counter_cacheはbelongs_toに対するオプションなので、2段以上離れた関連には張れないからです。モデルの責務が膨らんでカウンタ定義が散らかってきたら、RailsのFat Model対策|1500行model.rbを分割する4手法と判断基準の分割方針も併せて検討してください。
counter_cacheとcounter_cultureの選び分け
counter_culture(最新版3.14.0、2026年6月27日リリース)は、上で挙げた制約のうち条件付き集計と多段関連を埋めるgemです。
| 観点 | counter_cache | counter_culture 3.14.0 |
|---|---|---|
| 導入 | 標準機能 | gem追加 |
| 宣言場所 | belongs_to |
counter_culture |
| 条件付き集計 | 非対応 | 対応 |
| 多段の関連 | 非対応 | 対応 |
| 追従する変更 | 作成・破棄・外部キー変更 | 属性値の更新にも追従 |
| 合計値の保持 | 非対応 | delta_column |
sizeでのキャッシュ読み |
効く | 効かない |
| 再集計 | reset_counters |
counter_culture_fix_counts |
見落としやすいのが最後から2行目です。counter_cultureのREADMEは冒頭で「counter_culture does not currently change the behavior of the .size method on ActiveRecord associations」と明言しており、post.comments.sizeは再びCOUNTを打ちます。キャッシュ値を読むにはカラム名(comments_count)を直接参照する書き方へ変える必要があります。標準機能から移行する際は、この置き換えを作業に含めてください。
選び分けの基準は単純です。数えたいものが関連の全件で、宣言が1段で済むなら標準のcounter_cacheを使います。依存を1つ増やす理由がありません。条件を付けたい、または親の親を数えたい時点でcounter_cultureへ移ります。カラム要件はnull: false, default: 0で両者共通です。
よくある質問
counter_cacheとcounter_cultureはどちらを使うべきですか?
関連の全件数を1段で数えるだけならcounter_cacheです。標準機能なので依存が増えず、設定もbelongs_toの1行で済みます。「公開済みだけ」のような条件付き集計、has_many :through越しの集計、金額などの合計値を持ちたい場合はcounter_cultureを選びましょう。移行するときはbelongs_toのcounter_cacheを先に外し、子モデルにcounter_cultureを宣言してからcounter_culture_fix_countsを流します。両方を残すと、同じカラムを二経路で加算してしまいます。
counter_cacheが更新されないのはなぜですか?
モデルを迂回した一括操作が原因です。Comment.where(post_id: 5).delete_allやupdate_all、insert_all、直接SQLはコールバックを実行しないため、カウンタだけが取り残されます。一方、関連経由のpost.comments.delete_allやdependent: :delete_allではカウンタが維持されるので、ここは切り分けが必要です。復旧はPost.reset_counters(post_id, :comments)で行います。カラムへの直接代入は更新時に無視されるため、直し方としては使えません。
counter_cacheのカラム名は変更できますか?
できます。belongs_to :post, counter_cache: :post_comments_countのようにシンボルで指定してください。この場合に限り、親側のhas_many :comments, counter_cache: :post_comments_countにも同じ名前が必要です。公式APIドキュメントにも、has_manyの:counter_cacheはbelongs_to側で名前をカスタマイズしたときにだけ要ると明記されています。1つの親に複数種類の子を数えさせる場合は、この指定でカラムを分けます。
既存データが大量にあるテーブルにも後から追加できますか?
Rails 7.2以降ならcounter_cache: { active: false }を使います。この状態でカラムの更新だけを先に始め、sizeなどはデータベースへ問い合わせたまま保てるため、バックフィル中に不正確な値を返しません。埋め終わったら{ active: false }を外しましょう。7.1以前のバージョンには同等の機能がないので、データベーストリガーか子モデルのコールバックで暫定的に同期させる手当てが必要です。
size と count はどちらを呼べばキャッシュが効きますか?
sizeです。countは常にCOUNTクエリを発行するため、カラムを追加してもクエリ数は減りません。empty?・any?・none?もカウンタを読みます。lengthは関連を全件ロードしてから配列の要素数を返すので、件数だけが欲しい場面では最も重い選択になってしまいます。導入時はビューやシリアライザのcountを洗い出し、sizeへ置き換えるところまでを作業範囲に含めてください。