counter_cacheでN+1カウントクエリを解消する手順

Ruby

N+1カウントクエリが発生している状態とは

N+1カウントクエリとは、取得したN件のデータそれぞれに対してcountクエリが発行される現象のことを指します。

以下はN+1カウントクエリの例です。取得したN件のauthorに対してcountクエリが実行されていることがわかります。

コード

Author.all.each do |author|
  author.books.count
end

実行ログ

Author Load (0.5ms)  SELECT `authors`.* FROM `authors` ORDER BY `authors`.`id` ASC LIMIT 1000
  (0.6ms)  SELECT COUNT(*) FROM `books` WHERE `books`.`author_id` = 1
  (0.6ms)  SELECT COUNT(*) FROM `books` WHERE `books`.`author_id` = 2
  (0.5ms)  SELECT COUNT(*) FROM `books` WHERE `books`.`author_id` = 3
  (0.4ms)  SELECT COUNT(*) FROM `books` WHERE `books`.`author_id` = 4
  (0.4ms)  SELECT COUNT(*) FROM `books` WHERE `books`.`author_id` = 5
Author Load (0.6ms)  SELECT `authors`.* FROM `authors`

今回はRailsの標準機能であるcounter_cacheを利用してN+1カウントクエリを解消する方法について紹介します。

今回利用するサンプルについて

1:多で紐づいたAuthorモデルとBookモデルを例に、N+1カウントクエリの解消方法を紹介します。

counter_cacheについて

counter_cacheはRailsの標準機能です。
集計対象のレコード数を集計用カラムに保存することでN+1カウントクエリを解消します。

counter_cacheの導入手順

counter_cacheの導入手順について紹介します。

集計対象のモデルの修正

集計対象のモデルにcounter_cache: trueを追加します。
今回の場合、Authorモデルに紐づくBookモデルの数をキャッシュするためBookモデルを修正します。

book.rb

class Book < ApplicationRecord
  belongs_to :author, counter_cache: true
end

集計用カラムの追加

集計した数を保存するカラムを作成します。デフォルトのカラム名は[テーブル名]_countです。
counter_cache: trueの代わりにcounter_cache: [カラム名]とすることでカラム名を変更できます。

今回の場合はBookモデルにcounter_cache: trueを設定しているため、books_countカラムをAuthorモデルに追加します。

$ rails g migration AddBooksCountToAuthors

xxx_add_books_count_to_authors.rb

class AddBooksCountToAuthors < ActiveRecord::Migration[6.0]
  def change
    add_column :authors, :books_count, :integer, null: false, default: 0
  end
end

コードの修正

countクエリの代わりに新しく追加したbooks_countカラムを利用します。

Author.all.each do |author|
-  author.books.count
+  author.books_count
end

動作確認

ログを確認するとN+1カウントクエリが発生していないことがわかります。

Author Load (0.6ms)  SELECT `authors`.* FROM `authors` ORDER BY `authors`.`id` ASC LIMIT 1000
Author Load (0.6ms)  SELECT `authors`.* FROM `authors`

コンソールでレコードを作成すると、books_countカラムにbooks.countの値が保存されていることがわかります。

author = Author.new(name: "hoge")
author.save
author.books.count
=> 0
author.books_count
=> 0

author.books.create(title: "fuga")

author.books.count
=> 1
author.books_count
=> 1

counter_cache導入前のレコードは集計カラムに反映されないので注意

レコードが追加されるたびに集計用カラムの値が変更されます。
ただしcounter_cache導入前に集計対象のレコードがすでに存在していた場合、その数は集計用カラムに反映されません。

### counter_cache導入前に紐づいていた3つのbooksは反映されていない
author = Author.find(1)
author.books.count
=> 3
author.books_count
=> 0

### counter_cache導入後に追加されたレコードは反映される
author.books.create(title: "fuga")
author.books.count
=> 4
author.books_count
=> 1

counter_cache導入前のレコードを集計カラムに反映させるにはスクリプトを作ったりコンソールを実行したりする必要があります。

あるいはcounter_cultureの『集計カラムに集計数を反映させるメソッド』を利用する方法もあります。
counter_cultureを利用した集計数の反映方法は以下の通りです。

Gemfile

gem 'counter_culture'

book.rb

### counter_cacheの代わりにcounter_cultureでbooks_countカラムに集計数をキャッシュするように変更
class Book < ApplicationRecord
-  belongs_to :author, counter_cache: true
+  belongs_to :author
+  counter_culture :author
end
### 集計数の反映前
author = Author.find(1)
author.books.count
=> 3
author.books_count
=> 0

### 集計数の反映実行
Book.counter_culture_fix_counts


### 集計数の反映後
author = Author.find(1)
author.books.count
=> 3
author.books_count
=> 3

まとめ

counter_cacheの利用手順
  1. 集計対象の子モデルにcounter_cacheオプションを追加する
  2. 集計用のカラムを親モデルに追加する
  3. countクエリの代わりに追加したcountカラムを利用する

Twitter(@nishina555)やってます。フォローしてもらえるとうれしいです!

参考