☔️

【Ruby on Rails】N+1問題の基本と対策【初級編】

2024/01/27に公開

はじめに

Ruby on Rails における N+1問題と対応方法の基本を解説します。

TL;DR

  • N+1問題の概要と基本の回避方法を解説します
  • N+1問題とは、余分なクエリが大量に発生し、パフォーマンスに影響が出る状態
  • preloadjoins などのレコードをキャッシュする関数やテーブルを結合する関数を用いて回避する

解説しないこと

ActiveRecord のロード関数や結合関数がテーマですが、ActiveRecord::Relation のキャッシュやロードなど、詳細な仕様については解説しません。

例示用のモデル

今回例示用に用いるデータモデルの関連性は下記の通り。

author.rb

class Author < ApplicationRecord
  has_many :books
end

book.rb

class Book < ApplicationRecord
  belongs_to :author
end

N+1問題とは

N+1問題とは、簡単にいうと「余分なクエリが大量に発生し、パフォーマンスに影響が出ている状態にあること」

例えばカレーを作る際にテーブルに材料を並べて行くとして、
必要な材料を集めるために、材料1つを手に入れる度にテーブルと冷蔵庫を毎回1往復する作業をするような非効率さを指す。
解決策としては、必要な材料を一括で把握し、冷蔵庫に一度アクセスして全部の材料を一度に取ってくればいい。

N+1問題の例

N+1問題は、あるレコードの関連レコードの値を参照する処理を繰り返し実行した際に、
関連レコードを取得するためのSQLクエリが繰り返し発行されることで発生する。

例えば下記の処理を実行する。

Book.limit(5).each do |book|
  book.author.id
end

発行されるSQLクエリは下記のようになる。

  Book Load (0.7ms)  SELECT `books`.* FROM `books` LIMIT 5
  Author Load (0.4ms)  SELECT `authors`.* FROM `authors` WHERE `authors`.`id` = 1 LIMIT 1
  Author Load (0.3ms)  SELECT `authors`.* FROM `authors` WHERE `authors`.`id` = 2 LIMIT 1
  Author Load (0.3ms)  SELECT `authors`.* FROM `authors` WHERE `authors`.`id` = 3 LIMIT 1
  Author Load (0.3ms)  SELECT `authors`.* FROM `authors` WHERE `authors`.`id` = 4 LIMIT 1
  Author Load (0.3ms)  SELECT `authors`.* FROM `authors` WHERE `authors`.`id` = 5 LIMIT 1

5つの Book を取得し、それぞれの Book にひもづく Author テーブルのレコードを一つずつ参照して処理している。
Book 取得のクエリ1回と、それぞれの Author 取得のクエリが5回呼ばれている(N+1回)。

N+1問題は、関連レコードの値を参照したり、値を元に絞り込みを行うタイミングで起きる。

基本の回避方法

ActiveRecord にはこのN+1問題を回避するためのメソッドが用意されている。

基本的には前述した通り、「必要な材料を一括で把握し、冷蔵庫に一度アクセスして全部の材料を一度に取って」くるために、「関連データを結合して一括で取得」したり、「先に読み込んで(キャッシュして)おく」関数である。

  • joins
  • left_outer_joins
  • preload
  • eager_load
  • includes

使い方と特徴を解説する。

joins

joins | Railsドキュメント

関連レコードのテーブルを INNER JOIN で結合して取得する。
INNER JOIN によってテーブルが結合されるので、 where (WHERE句)による絞り込みが可能。

例えば下記の処理を実行する。

Book.limit(5).joins(:author).where(author: { id: 1 })

発行されるクエリは下記のようになる。

  Book Load (2.6ms)  SELECT `books`.* FROM `books` INNER JOIN `authors` `author` ON `author`.`id` = `books`.`author_id` WHERE `author`.`id` = 1 LIMIT 5

結合したテーブルを利用した絞り込みが行われるので、効率よくデータにアクセスできている。

一方、取得したデータの保持は行わないため、テーブルを跨いだレコードの値の参照は効率化できない。

Book.limit(5).joins(:author).each {|book| puts book.author.id}
  Book Load (0.8ms)  SELECT `books`.* FROM `books` INNER JOIN `authors` ON `authors`.`id` = `books`.`author_id` LIMIT 5
  Author Load (0.7ms)  SELECT `authors`.* FROM `authors` WHERE `authors`.`id` = 1 LIMIT 1
1
  Author Load (0.6ms)  SELECT `authors`.* FROM `authors` WHERE `authors`.`id` = 2 LIMIT 1
2
  Author Load (0.5ms)  SELECT `authors`.* FROM `authors` WHERE `authors`.`id` = 3 LIMIT 1
3
  Author Load (0.6ms)  SELECT `authors`.* FROM `authors` WHERE `authors`.`id` = 4 LIMIT 1
4
  Author Load (0.8ms)  SELECT `authors`.* FROM `authors` WHERE `authors`.`id` = 5 LIMIT 1
5

left_outer_joins

left_outer_joins | Railsドキュメント

関連レコードのテーブルを LEFT OUTER JOIN で結合して取得する。

joins の 左外部結合バージョンなので、説明は割愛。

preload

preload | Railsドキュメント

関連レコードを事前に取得し、キャッシュする。

例えば下記の処理を実行する。

Book.limit(5).preload(:author).each { |book| puts book.author.id }

発行されるクエリは下記のようになる。

  Book Load (0.7ms)  SELECT `books`.* FROM `books` LIMIT 5
  Author Load (0.5ms)  SELECT `authors`.* FROM `authors` WHERE `authors`.`id` IN (1, 2, 3, 4, 5)
1
2
3
4
5

1回目のクエリで取得した Book 情報をもとに、 2回目のクエリで Author テーブルから必要な情報を取得している。
事前に取得してキャッシュした情報をもとに処理を行うので、効率よく値を参照できている。

一方、テーブルの結合は行わないので、 where による絞り込みは不可。

Book.limit(5).preload(:author).where(author: { id: 1 }).count
(0.7ms)  SELECT COUNT(*) FROM (SELECT 1 AS one FROM `books` WHERE `author`.`id` = 1 LIMIT 5) subquery_for_count
ActiveRecord::StatementInvalid: Mysql2::Error: Unknown column 'author.id' in 'where clause'

取得した値に対して実行される select (filter) を用いれば絞り込みが可能。

Book.limit(5).preload(:author).select { |book| book.author.id == 1 }.count
  Book Load (1.1ms)  SELECT `books`.* FROM `books` LIMIT 5
  Author Load (0.8ms)  SELECT `authors`.* FROM `authors` WHERE `authors`.`id` IN (1, 2, 3, 4, 5)
1

eager_load

eager_load | Railsドキュメント

関連レコードのテーブルを `LEFT OUTER JOIN`` で結合して取得し、キャッシュする。値参照処理と絞込み処理におけるN+1問題の回避に用いる。

例えば下記の処理を実行する。

Book.limit(5).eager_load(:author).where(author: { id: 1 })

発行されるクエリは下記のようになる。

  SQL (0.9ms)  SELECT `books`.`id` AS t0_r0, `books`.`author_id` AS t0_r1, `books`.`title` AS t0_r2, `books`.`created_at` AS t0_r3, `books`.`updated_at` AS t0_r4, `author`.`id` AS t1_r0, `author`.`name` AS t1_r1, `author`.`created_at` AS t1_r2, `author`.`updated_at` AS t1_r3 FROM `books` LEFT OUTER JOIN `authors` `author` ON `author`.`id` = `books`.`author_id` WHERE `author`.`id` = 1 LIMIT 5
1
2
3
4
5

各カラムにはエイリアスが割り当てられ、 LEFT OUTER JOIN によってテーブルが結合されていることがわかる。テーブルが結合されているので where による絞り込みが可能。

さらに、取得した情報がキャッシュされているので、関連レコードの値を参する照処理も効率化される。

Book.limit(5).eager_load(:author).each { |book| puts book.author.id }
  SQL (0.9ms)  SELECT `books`.`id` AS t0_r0, `books`.`author_id` AS t0_r1, `books`.`title` AS t0_r2, `books`.`created_at` AS t0_r3, `books`.`updated_at` AS t0_r4, `author`.`id` AS t1_r0, `author`.`name` AS t1_r1, `author`.`created_at` AS t1_r2, `author`.`updated_at` AS t1_r3 FROM `books` LEFT OUTER JOIN `authors` `author` ON `author`.`id` = `books`.`author_id` WHERE `author`.`id` = 1 LIMIT 5
1
2
3
4
5

includes

includes | Railsドキュメント

処理内容に応じて preloadeager_load の挙動を切り替える便利関数。

例えば単純な値参照処理の場合、

Book.limit(5).includes(:author).each { |book| puts book.author.id }

発行されるクエリは下記のようになる。

  Book Load (0.8ms)  SELECT `books`.* FROM `books` LIMIT 5
  Author Load (0.5ms)  SELECT `authors`.* FROM `authors` WHERE `authors`.`id` IN (1, 2, 3, 4, 5)
1
2
3
4
5

これは preload の挙動と同じである。データのキャッシュがされるが、テーブル結合はされない。

一方、 where による絞込みを行う場合、

Book.limit(5).includes(:author).where(author: { id: 1 })

発行されるクエリは下記のようになる。

  SQL (0.9ms)  SELECT `books`.`id` AS t0_r0, `books`.`author_id` AS t0_r1, `books`.`title` AS t0_r2, `books`.`created_at` AS t0_r3, `books`.`updated_at` AS t0_r4, `author`.`id` AS t1_r0, `author`.`name` AS t1_r1, `author`.`created_at` AS t1_r2, `author`.`updated_at` AS t1_r3 FROM `books` LEFT OUTER JOIN `authors` `author` ON `author`.`id` = `books`.`author_id` WHERE `author`.`id` = 1 LIMIT 5

これは eager_load の挙動と同じになり、テーブル結合とデータのキャッシュがされる。

比較と見極め

まとめると下記の通り。

メソッド名 発行SQL キャッシュ 結合
joins INNER JOIN ×
left_outer_joins LEFT OUTER JOIN ×
preload SELECT 句をモデル毎に1回ずつ ×
eager_load LEFT OUTER JOIN
includes 場合によって preload / eager_load 切り替え 場合による

キャッシュされるということはループ内で値を参照が効率化されるということ。
結合されるということはリレーション間の絞り込みをSQLクエリを発行する where 句が使えるということ。

joins vs left_outer_joins

内部結合か左外部結合か。
関連モデルのレコードが必ず存在するか、存在しない場合は結果から除外したい場合は、内部結合なので joins を用いる。
関連モデルのレコードが存在しないケースがあり得て、存在しないも結合元を含めたい場合は、左外部結合なので left_outer_joins を用いる。

joins (left_outer_joins) vs eager_load

単純な絞り込み用途であれば joins を使う。eager_load はクエリが長くなる。
絞り込み結果の関連レコードの値を参照する必要がある場合は eager_load を使う。

使用する関数の見極め

includes は状況に応じて eager_loadpreload を切り替えるものなので、次のように方針を決めておく。

  • 明確に目的を持って実装するスタンスに立って、基本的に使わない
  • 必要とする処理が変わる中で柔軟に挙動を変えていきたいので、積極的に使っていく

その上で、どの関数を使うかは、下記のフローチャートで考える。

関数見極めフローチャート

まとめ

  • N+1問題とは、余分なクエリが大量に発生し、パフォーマンスに影響が出る状態
  • preloadjoins などのレコードをキャッシュする関数やテーブルをJOINする関数を用いて回避する

次回は ActiveRecord:: Relation のソースコードを読みながら、キャッシュやロードについてより詳しく見ていく予定です!

参考

Active Record クエリインターフェイス - Railsガイド
Railsアプリケーションのパフォーマンス改善をしながらN+1問題を解決するスキルを身に付けよう! | Techpit

GitHubで編集を提案
株式会社Inner Resource

Discussion