ユニファ開発者ブログ

ユニファ株式会社プロダクトデベロップメント本部メンバーによるブログです。

Gem bulletのアラート「Need Counter Cache」の対処法

[Rails] Gem bulletのアラート「Need Counter Cache」の対処法

初めに

こんにちは。ユニファのバックエンドエンジニア、シュウです。

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が発行されるので不採用としました。

(#countのソースコード)

#size
  • キャッシュがない場合#countと同じ挙動です。SELECT COUNT(*)を発行してもキャッシュはしません。
  • キャッシュがある場合rubyのメソッドでカウントして数を返す。(#lengthと同じ挙動になります。)

今回のケースでは、レコードをキャッシュするタイミングがないため、#size#countと同じ動作となるので不採用としました。ただし、別の実装でキャッシュは既にロードしている場合は、#sizeが使える場面もあります。

(#sizeのソースコード)

#length

  • キャッシュがある場合rubyのメソッドでカウントしてカウントして数を返す。
  • キャッシュがない場合レコードをキャッシュしてからrubyのメソッドでカウントして数を返す。
  • 注意点:メモリにキャッシュを保存するのでデータ量が多い場合メモリが大量消費する可能性があります。#lengthを使う際にメモリとデータ量に注意する必要があります。

(#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を試してみるのも良い選択肢だと思います。


ユニファでは、ともに保育の課題解決と向き合う仲間を募集しています。

unifa-e.com