Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

RailsのActiveRecordカウンターキャッシュでN+1クエリ問題を解決する方法

ページが読み込まれるたびにデータベースで関連レコードを数える代わりに、ActiveRecordのカウンターキャッシュ機能を使えば、カウンターを保存しておき、関連オブジェクトが作成・削除されるたびに自動的に更新できます。この記事では、ActiveRecordでカウンターをキャッシュする方法について詳しく解説します。

基本となる例:ブログ記事とレスポンス

記事とレスポンスを持つブログを例に考えてみましょう。各記事にはレスポンスが紐づいており、ブログのインデックスページで各記事のタイトルの横にレスポンス数を表示して、人気度を示したいとします。

# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  def index
    @articles = Article.all
  end
 
  # ...
end

インデックスページではレスポンスのデータ自体は表示しないため、レスポンスを事前に読み込む必要はありません。表示するのはカウンターだけなので、各記事のレスポンス数さえわかれば十分です。コントローラーはすべての記事を取得し、@articles変数に格納してビューに渡します。

<!-- app/views/articles/index.html.erb -->
<h1>Articles</h1>
 
<% @articles.each do |article| %>
<article>
  <h1><%= article.title %></h1>
  <p><%= article.description %></p>
  <%= article.responses.size %> responses
</article>
<% end %>

ビューでは各記事をループ処理し、タイトル、説明、そして受け取ったレスポンス数をレンダリングします。article.responses.sizeをビュー内で呼び出すと、ActiveRecordはレスポンスレコード全体を読み込む代わりに、アソシエーションの件数を数えるべきだと判断します。

ヒント: レスポンス数を数えるなら#countの方が直感的に思えるかもしれませんが、この例ではあえて#sizeを使用しています。#countは常にCOUNTクエリを発行するのに対し、#sizeはレスポンスがすでに読み込まれている場合はクエリをスキップできるからです。

Started GET "/articles" for 127.0.0.1 at 2018-06-14 16:25:36 +0200
Processing by ArticlesController#index as HTML
  Rendering articles/index.html.erb within layouts/application
  Article Load (0.2ms)  SELECT "articles".* FROM "articles"
  ↳ app/views/articles/index.html.erb:3
  (0.2ms)  SELECT COUNT(*) FROM "responses" WHERE "responses"."article_id" = ?  [["article_id", 2]]
  ↳ app/views/articles/index.html.erb:7
  (0.3ms)  SELECT COUNT(*) FROM "responses" WHERE "responses"."article_id" = ?  [["article_id", 3]]
  ↳ app/views/articles/index.html.erb:7
  (0.1ms)  SELECT COUNT(*) FROM "responses" WHERE "responses"."article_id" = ?  [["article_id", 4]]
  ↳ app/views/articles/index.html.erb:7
  (0.1ms)  SELECT COUNT(*) FROM "responses" WHERE "responses"."article_id" = ?  [["article_id", 5]]
  ↳ app/views/articles/index.html.erb:7
  (0.1ms)  SELECT COUNT(*) FROM "responses" WHERE "responses"."article_id" = ?  [["article_id", 6]]
  ↳ app/views/articles/index.html.erb:7
  (0.1ms)  SELECT COUNT(*) FROM "responses" WHERE "responses"."article_id" = ?  [["article_id", 7]]
  ↳ app/views/articles/index.html.erb:7
  (0.1ms)  SELECT COUNT(*) FROM "responses" WHERE "responses"."article_id" = ?  [["article_id", 8]]
  ↳ app/views/articles/index.html.erb:7
  (0.1ms)  SELECT COUNT(*) FROM "responses" WHERE "responses"."article_id" = ?  [["article_id", 9]]
  ↳ app/views/articles/index.html.erb:7
  (0.1ms)  SELECT COUNT(*) FROM "responses" WHERE "responses"."article_id" = ?  [["article_id", 10]]
  ↳ app/views/articles/index.html.erb:7
  (0.1ms)  SELECT COUNT(*) FROM "responses" WHERE "responses"."article_id" = ?  [["article_id", 11]]
  ↳ app/views/articles/index.html.erb:7
  Rendered articles/index.html.erb within layouts/application (23.1ms)
Completed 200 OK in 52ms (Views: 45.7ms | ActiveRecord: 1.6ms)

ブログのインデックスページにアクセスするとN+1クエリが発生しています。ActiveRecordは各記事のレスポンス数を個別のクエリで遅延読み込みするため、記事が増えるほどCOUNTクエリも増加し、パフォーマンスが悪化します。

クエリ内でCOUNT()を使う方法

記事ごとに余分なクエリを実行しないようにするには、articlesテーブルとresponsesテーブルを結合し、単一のクエリで関連するレスポンスをカウントする方法があります。

# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  def index
    @articles = Article.
      joins(:responses).
      select("articles.*", 'COUNT("responses.id") AS responses_count').
      group('articles.id')
  end
 
  # ...
end

この例では、articlesのクエリにresponsesを結合し、COUNT("responses.id")でレスポンス数を数えています。記事IDでグループ化することで、記事ごとのレスポンス数を集計できます。ビュー側では、responsesアソシエーションに対してsizeを呼び出す代わりに、responses_countを使用する必要があります。

このソリューションは余分なクエリを防げますが、その代償として最初のクエリが遅く複雑になります。これはページのパフォーマンス最適化への良い第一歩ですが、さらに一歩進めてカウンター自体をキャッシュすれば、ページビューごとにレスポンスを数える必要がなくなります。

カウンターキャッシュの導入

ブログの記事は(うまくいけば)更新されるよりも読まれることが多いため、カウンターキャッシュはこのページのクエリを高速かつシンプルにする優れた最適化手法です。

記事を表示するたびにレスポンス数を数える代わりに、カウンターキャッシュは独立したレスポンスカウンターを保持し、それを各記事のデータベース行に保存します。このカウンターは、レスポンスが追加・削除されるたびに自動的に更新されます。

これにより、記事のインデックスは1つのデータベースクエリだけでレンダリングでき、クエリでresponsesを結合する必要もなくなります。設定するには、belongs_toの関連でcounter_cacheオプションを有効にします。

# app/models/response.rb
class Response
  belongs_to :article, counter_cache: true
end

これには、Articleモデルにresponses_countという名前のカラムが必要です。counter_cacheオプションにより、レスポンスが追加・削除されるたびに、そのカラムの数値が自動的に更新されます。

ヒント: counter_cacheオプションの値としてtrueの代わりにシンボルを指定すると、カラム名を任意のものに変更できます。

次に、カウントを保存するための新しいカラムをデータベースに作成します。

$ rails generate migration AddResponsesCountToArticles responses_count:integer
      invoke  active_record
      create    db/migrate/20180618093257_add_responses_count_to_articles.rb
$ rake db:migrate
== 20180618093257 AddResponsesCountToArticles: migrating ======================
-- add_column(:articles, :responses_count, :integer)
  -> 0.0016s
== 20180618093257 AddResponsesCountToArticles: migrated (0.0017s) =============

レスポンス数がarticlesテーブルにキャッシュされるようになったので、articlesのクエリでresponsesを結合する必要はありません。コントローラーではArticle.allですべての記事を取得します。

# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  def index
    @articles = Article.all
  end
 
  # ...
end

ビューは変更不要です。Railsは#sizeメソッドに対してカウンターキャッシュを使うべきことを理解しているからです。

<!-- app/views/articles/index.html.erb -->
<h1>Articles</h1>
 
<% @articles.each do |article| %>
<article>
  <h1><%= article.title %></h1>
  <p><%= article.description %></p>
  <%= article.responses.size %> responses
</article>
<% end %>

再度インデックスページにアクセスすると、実行されているのは1つのクエリだけであることがわかります。各記事が自分のレスポンス数を把握しているため、responsesテーブルへのクエリは一切不要になりました。

Started GET "/articles" for 127.0.0.1 at 2018-06-14 17:15:23 +0200
Processing by ArticlesController#index as HTML
  Rendering articles/index.html.erb within layouts/application
  Article Load (0.2ms)  SELECT "articles".* FROM "articles"
  ↳ app/views/articles/index.html.erb:3
  Rendered articles/index.html.erb within layouts/application (3.5ms)
Completed 200 OK in 42ms (Views: 36.5ms | ActiveRecord: 0.2ms)

スコープ付きアソシエーションのカウンターキャッシュ

ActiveRecordのカウンターキャッシュのコールバックは、レコードの作成時または削除時にのみ発火します。そのため、スコープ付きのアソシエーションにカウンターキャッシュを追加しても期待通りには動作しません。「公開済み」のレスポンスのみを数えたいといった高度なケースでは、counter_culture gemを検討してみてください。

既存データのカウンターを同期させる

カウンターカラムはデフォルトで0になるため、カウンターキャッシュ導入以前から存在する記事では、カウンターが実際のレスポンス数と同期していません。オブジェクトのIDと、更新対象の関連名を渡して.reset_countersメソッドを呼び出せば、カウンターを「リセット」できます。

Article.reset_counters(article.id, :responses)

デプロイ時に本番環境でも確実に実行されるよう、先ほどのマイグレーションでカラムを追加した直後に実行されるマイグレーションに記述しましょう。

$ rails generate migration PopulateArticleResponsesCount --force
      invoke  active_record
      create    db/migrate/20180618093443_populate_article_responses_count.rb

マイグレーション内では、各記事に対してArticle.reset_countersを呼び出し、記事のIDと関連名として:responsesを渡します。

# db/migrate/20180618093443_populate_article_responses_count.rb
class PopulateArticleResponsesCount < ActiveRecord::Migration[5.2]
  def up
    Article.find_each do |article|
      Article.reset_counters(article.id, :responses)
    end
  end
end

このマイグレーションにより、カウンターキャッシュ導入前に存在していた記事を含む、データベース内のすべての記事のカウントが更新されます。

コールバックに関する注意点

カウンターキャッシュはコールバックを使ってカウンターを更新する仕組みのため、SQLコマンドを直接実行するメソッド(#destroyではなく#deleteを使う場合など)では、カウンターは更新されません。

何らかの理由でそのような状況が発生する可能性がある場合は、定期的にカウントを同期させるRakeタスクやバックグラウンドジョブを追加しておくと安心です。

namespace :counters do
  task update: :environment do
    Article.find_each do |article|
      Article.reset_counters(article.id, :responses)
    end
  end
end

まとめ

クエリ内で関連オブジェクトをカウントすることでN+1クエリを防ぐことも有効ですが、ほとんどのアプリケーションにとって、カウンターをキャッシュする方がさらに高速な表示方法です。ActiveRecordの組み込みカウンターキャッシュは非常に強力で、より複雑な要件にはcounter_cultureのようなgemが活用できます。

ActiveRecordのカウンターキャッシュについて質問がある場合は、お気軽にお問い合わせください。この記事が役立ったかどうか、また他に知りたいトピックがあれば、ぜひフィードバックをお寄せください。

  1. エッジキャッシングで実現する、世界どこでも5ミリ秒のRedisレイテンシ

    Redisでは、データベースとクライアントが同じリージョン内にあれば、1ミリ秒のレイテンシは容易に実現できます。しかし、クライアントが世界中に分散している場合、レイテンシは100ミリ秒を超えてしまいます。この課題を解決するために開発されたのが「Edge Caching(エッジキャッシング)」です。 エッジキャッシングとは エッジキャッシングでは、CDNと同じようにREST APIのレスポンスが世界各地のエッジロケーションにキャッシュされます。エッジキャッシングを有効にすると、平均5ミリ秒というグローバルレイテンシを実現できます。実際に、10の異なるリージョンに配置したクライアントからレイテンシ

  2. RailsのActiveRecord enumで簡単に作れる、読みやすい属性の実装方法

    「保留中」「承認済み」「フラグ付き」のいずれかの状態を持ちうる質問。あるいは「自宅」「会社」「携帯」「FAX」(もし1982年なら)のどれかに分類される電話番号。 こうしたデータが必要になるモデルは少なくありません。いくつかの候補の中から必ず1つの値だけを持つ属性であり、しかもその値のセットはほとんど変わることがありません。 純粋なRubyで書くなら、ここは単純にシンボルを使う場面です。 PhoneNumberTypeやQuestionStatusといったモデルを作成し、belongs_toの関連でこれらの値を保持する方法もありますが、そこまでする価値はなさそうです。YAMLファイルに押