初めに
こんにちは。ユニファのバックエンドエンジニア、シュウです。
N+1問題を検出するために、多くのRailsアプリケーションでgem bulletが導入されていると思います。弊社のRailsアプリケーションでもgem bulletを利用しています。そんな中、ある日bulletから見慣れない「Need Counter Cache」のアラートに遭遇しました。
今回は最小限の修正でこの「Need Counter Cache」を解消し、効率的にクエリを発行できるようにした方法を紹介したいと思います。
結論から
レコードをカウントする際、#includes
と#length
を活用することで、「Need Counter Cache」を解消できる場合があります。もしライトな方法で「Need Counter Cache」の問題を解決したい場合は、まずは#includes
と#length
メソッドを試してみても良いと思います。
本文
ここからは実際の例をもとに「Need Counter Cache」の発生から解決に至るまでの過程を説明します。
環境
- ruby 3.3.0
- rails 7.1.5
- bullet 8.0.0
ER図とモデル
投稿(posts)が0件または複数のコメント(comments)を持つという関連付けの構成です。
# app/models/post.rb class Post < ApplicationRecord has_many :comments end # app/models/comment.rb class Comment < ApplicationRecord belongs_to :post end
Need Counter Cacheとなったケース
投稿(posts)一覧ページに、投稿ごとのコメント(comments)数を表示する機能を実装する際、以下のようなコードを書いたとします。
controller
# app/controllers/posts_controller.rb class PostsController < ActionController::Base def index @posts = Post.all end end
view
# app/views/posts/index.html.erb <% @posts.each do |post| %> <h2><%= post.title %></h2> <p><%= post.body %></p> <p>Comments Count: <%= post.comments.size %> </p> <% end %>
この状態で投稿一覧ページにアクセスすると、#size
を使ってコメント数をカウントする際にRailsのターミナルで以下のような結果が確認されます。
- SELECT COUNT(*) が複数回実行される
- Bulletによる「Need Counter Cache with Active Record size」というアラートが表示される
Started GET "/posts" for ::1 at 2024-12-02 17:34:56 +0900 Processing by PostsController#index as HTML Rendering posts/index.html.erb Post Load (1.9ms) SELECT "posts".* FROM "posts" ↳ app/views/posts/index.html.erb:1 Comment Count (1.1ms) SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = $1 [["post_id", 2]] ↳ app/views/posts/index.html.erb:4 Comment Count (0.5ms) SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = $1 [["post_id", 1]] ↳ app/views/posts/index.html.erb:4 Comment Count (0.5ms) SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = $1 [["post_id", 3]] ↳ app/views/posts/index.html.erb:4 Rendered posts/index.html.erb (Duration: 38.7ms | Allocations: 7890) Completed 200 OK in 48ms (Views: 22.9ms | ActiveRecord: 18.3ms | Allocations: 10023) user: xxx GET /posts Need Counter Cache with Active Record size Post => [:comments]
まずはグーグルで検索してみよう
そのまま「Need Counter Cache」で検索すると、おそらく次のような解決方法が見つかるでしょう。
- counter_cacheを導入する (rails公式ドキュメント:Active Record の関連付け - Railsガイド)
- この方法では、テーブルに新しいカラムを追加する必要があります。既存のテーブルに対して適用する場合、影響範囲が大きくなる可能性があります。
- gem counter_cultureを導入して解決する。
- countを一つのクエリで取得できるようにSQLで工夫する。
- コメント数を表示するだけで複雑なクエリを避けたいです。
今回は特に複雑な仕様が求められるわけではなく、単純にコメント数を表示するだけなので、できるだけ影響範囲を少なく、コードもシンプルに保ちたいと考えました。
どう解決したか
レコードをカウントする時、三つメソッド#count
、#size
、#length
がよく使われます。
#count
#count
はレコードのキャッシュを使わずSELECT COUNT(*)を発行します。今回のケースだと投稿ごとに1つずつのSQLが発行されるので不採用としました。
#size
- キャッシュがない場合
#count
と同じ挙動です。SELECT COUNT(*)を発行してもキャッシュはしません。 - キャッシュがある場合rubyのメソッドでカウントして数を返す。(
#length
と同じ挙動になります。)
今回のケースでは、レコードをキャッシュするタイミングがないため、#size
も#count
と同じ動作となるので不採用としました。ただし、別の実装でキャッシュは既にロードしている場合は、#size
が使える場面もあります。
#length
- キャッシュがある場合rubyのメソッドでカウントしてカウントして数を返す。
- キャッシュがない場合レコードをキャッシュしてからrubyのメソッドでカウントして数を返す。
- 注意点:メモリにキャッシュを保存するのでデータ量が多い場合メモリが大量消費する可能性があります。
#length
を使う際にメモリとデータ量に注意する必要があります。
ここまで整理すると、今回のケースでデータ量もそう多くないので#length
を使うことにしました。以下のようにコードを変更します。
<% @posts.each do |post| %> <h2><%= post.title %></h2> <p><%= post.body %></p> - <p>Comments Count: <%= post.comments.size %> </p> + <p>Comments Count: <%= post.comments.length %> </p> <% end %>
でもbulletから別のUSE eager loading detected
のアラートが出ました。
Started GET "/posts" for ::1 at 2024-12-02 20:02:09 +0900 Processing by PostsController#index as HTML Rendering posts/index.html.erb Post Load (4.3ms) SELECT "posts".* FROM "posts" ↳ app/views/posts/index.html.erb:1 Comment Load (1.0ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = $1 [["post_id", 2]] ↳ app/views/posts/index.html.erb:4 Comment Load (1.4ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = $1 [["post_id", 1]] ↳ app/views/posts/index.html.erb:4 Comment Load (1.2ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = $1 [["post_id", 3]] ↳ app/views/posts/index.html.erb:4 Rendered posts/index.html.erb (Duration: 108.4ms | Allocations: 12703) Completed 200 OK in 121ms (Views: 73.6ms | ActiveRecord: 39.7ms | Allocations: 14825) user: xxx GET /posts USE eager loading detected Post => [:comments] Add to your query: .includes([:comments])
SELECT COUNT(*)の複数発行は回避できましたが、今回はComment Loadの複数発行でbulletにアラートされました。 ですのでbulletのご指摘通りにincludesを追加します。
# app/controllers/posts_controller.rb class PostsController < ActionController::Base def index - @posts = Post.all + @posts = Post.all.includes(:comments) end end
そして期待通りに「Need Counter Cache」も無駄なクエリもなくなりました。
Started GET "/posts" for ::1 at 2024-12-02 20:06:33 +0900 Processing by PostsController#index as HTML Rendering posts/index.html.erb Post Load (8.1ms) SELECT "posts".* FROM "posts" ↳ app/views/posts/index.html.erb:1 Comment Load (1.9ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN ($1, $2, $3) [["post_id", 2], ["post_id", 1], ["post_id", 3]] ↳ app/views/posts/index.html.erb:1 Rendered posts/index.html.erb (Duration: 89.6ms | Allocations: 11576) Completed 200 OK in 105ms (Views: 60.7ms | ActiveRecord: 32.8ms | Allocations: 13730)
補足ですが、今回の場合#includes
ではなく#preload
も使えます。
終わりに
「Need Counter Cache」を解決するにはcounter_cacheやcounter_cultureなどの方法もあります。場合にもよりますが、counter_cacheやcounter_cultureの方法よりもっとシンプルで解決したかったらまずは#includes
と#length
を試してみるのも良い選択肢だと思います。
ユニファでは、ともに保育の課題解決と向き合う仲間を募集しています。