【Rails】N+1カウントクエリを解決するための3種類の方法まとめ

Ruby

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)やってます。フォローしてもらえるとうれしいです!