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`
今回はN+1カウントクエリを解決する方法として以下の3つの方法を紹介します。
- counter_cache
- counter_culture
- activerecord-precounter
目次
counter_cacheについて
counter_cacheはRailsの標準機能です。
集計対象のレコード数を集計用カラムに保存することでN+1カウントクエリを解消します。
メリット・デメリットは以下の通りです。
- Railsの標準機能なのでgemの追加が不要
- カウント時に集計対象のテーブルを参照しなくてよくなる
- カラムを追加する必要がある
- 既存のレコード数を反映する機能がない
counter_cacheの詳細解説はcounter_cacheでN+1カウントクエリを解消する手順で紹介しています。
counter_cacheの利用方法
集計対象のモデルにcounter_cache: true
を追加します。
class Author < ApplicationRecord
has_many :books
end
class Book < ApplicationRecord
belongs_to :author, counter_cache: true
end
集計した数を保存するカラムを作成します。
class AddBooksCountToAuthors < ActiveRecord::Migration[6.0]
def change
add_column :authors, :books_count, :integer, null: false, default: 0
end
end
### マイグレーション
$ rails db:migrate
上記の設定をすることでauthor.books.count
の代わりにauthor.books_count
が利用できるようになるため、N+1カウントクエリを解消できます。
counter_cultureについて
counter_cultureはRails標準のcounter_cacheの機能を改良したgemです。
counter_cacheと同様、集計対象のレコード数を集計用カラムに保存することでN+1カウントクエリを解消します。
- 既存のレコード数を反映する機能がある
- カウント時に集計対象のテーブルを参照しなくてよくなる
- カラムを追加する必要がある
- gemを追加する必要がある
counter_cultureの詳細解説はcounter_cultureでN+1カウントクエリを解消する手順で紹介しています。
counter_cultureの利用方法
gemを追加します。
gem 'counter_culture'
集計対象のモデルにcounter_culture: [集計元のモデル名]
を追加します。
class Author < ApplicationRecord
has_many :books
end
class Book < ApplicationRecord
belongs_to :author
counter_culture :author
end
集計した数を保存するカラムを作成します。
### マイグレーションファイルはcounter_cultureメソッドで作成できる
$ rails generate counter_culture Author books_count
class AddBooksCountToAuthors < ActiveRecord::Migration[6.0]
def self.up
add_column :authors, :books_count, :integer, null: false, default: 0
end
def self.down
remove_column :authors, :books_count
end
end
### マイグレーション
$ rails db:migrate
上記の設定をすることでauthor.books.count
の代わりにauthor.books_count
が利用できるようになるため、N+1カウントクエリを解消できます。
集計カラムに集計数を反映させる場合はcounter_culture_fix_counts
メソッドを利用します。
### 集計数の反映前
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
activerecord-precounterについて
activerecord-precounterはeager loadingによるキャッシュをcountクエリにも適用できるように拡張するgemです。
メリット・デメリットは以下の通りです。
- カラムを追加する必要がない
- カウント時に集計対象のテーブルを参照
- gemを追加する必要がある
activerecord-precounterの詳細解説はactiverecourd-precounterでN+1カウントクエリを解消する手順で紹介しています。
activerecord-precounterの利用方法
gemを追加します。
gem 'activerecord-precount'
コードを以下のように修正します。
authors = Author.all
+ ActiveRecord::Precounter.new(authors).precount(:books)
authors.each do |author|
- author.books.count
+ author.books_count
end
どの方法を採用するとよいか
Railsの標準で用意されているのでcounter_cacheを使うのがシンプルでよさそうなのですが、既存のレコード数を集計カラムに反映させる手段がないのは使いづらい印象です。また、counter_cacheではデッドロッグが多発するという報告もあります。1
ですのでcounter_cacheを利用するのであれば、counter_cacheの高機能版であるcounter_cultureのほうが良さそうだと感じています。
ただし、counter_cacheもcounter_cultureもカラムを追加する必要があるのがネックです。
activerecord-precounterはマイグレーション不要なので、サクッとN+1カウントクエリを解消したいのであれば有力な選択肢になりそうです。
個人的には、トリガでレコード更新をすると実装がブラックボックス化してしまうのでactiverecord-precounterがよさそうだと感じました。
まとめ
- Rails標準機能だけでN+1カウントクエリ解決するならcounter_cacheを利用
- counter_cacheを使うくらいなら、高機能版のcounter_cultureを使った方がいいかもしれない
- activerecord-precounterはマイグレーション不要なので、導入が簡単でよさそう
Twitter(@nishina555)やってます。フォローしてもらえるとうれしいです!