N+1問題とは
N+1問題とは、SQLクエリが「データ量N + 1回」走ってしまうことで、取得するデータ (Nの回数) が多くなるにつれてパフォーマンスが低下してしまう問題
のことです。
N+1問題については具体例を見ていく方がわかりやすいと思うので、次より具体例を紹介したいと思います。
N+1問題の具体例
ここからは、N+1問題をユーザー(users)とユーザーの投稿(posts)の例を用いて説明していきたいと思います。
各テーブルの中身は下記のようになります。
usersテーブル
|id |name |
|---|---|---|---|
|1 |山田太郎 |
|2 |長瀬来 |
|3 |立川裕美 |
|4 |前田達郎 |
|5 |細川修二 |
|6 |木村拓磨 |
postsテーブル
|id |user_id |title
|---|---|---|---|
|1 |5 |楽しい休日の過ごし方
|2 |1 |先日の旅行での話
|3 |3 |昨日の出来事
|4 |3 |山登りに行きました
|5 |4 |友人が結婚しました
|6 |2 |最近少し気になったこと
|7 |4 |ランニングのコツ
|8 |3 |Ruby on Railsの日
1人のユーザーに複数の投稿がある為、ユーザーと投稿のの関係は1対多になります。それぞれのモデルは以下のように関係づけられます。
class User < ApplicationRecord
has_many :posts
end
class Post < ApplicationRecord
belongs_to :user
end
全ての投稿に対するユーザーの名前を出力するプログラムを作成します。
# 全ての投稿に対するユーザーの名前を出力
Post.all.each do |post|
puts post.user.name
end
このコードでは、まず全ての投稿を取得し、その後に各投稿に対してユーザーの名前の情報を取得しています。
下記が実行時のログです。
Post Load (1.2ms) SELECT `posts`.* FROM `posts`
User Load (1.3ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 5 LIMIT 1
細川修二
User Load (0.2ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
山田太郎
User Load (3.2ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 3 LIMIT 1
立川裕美
User Load (1.2ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 3 LIMIT 1
立川裕美
User Load (3.5ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 4 LIMIT 1
前田達郎
User Load (0.6ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 2 LIMIT 1
長瀬来
User Load (0.3ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 4 LIMIT 1
前田達郎
User Load (0.8ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 3 LIMIT 1
立川裕美
上記ログを見ると、postsテーブルに1回、usersテーブルに対して8回のSQLクエリが走っているのがわかります。このままのコードでは、1万人のユーザーが存在した時、postsテーブルに1回、usersテーブルに対して1万回のSQLクエリを走らせてしまうことになり、パフォーマンスが低下してしまいます。
このように、
N+1問題とは、SQLクエリが「データ量N + 1回」 (上記の例だと データ量N = ユーザー数) 走ってしまい、パフォーマンスが低下してしまうことです。
次は、N+1の対処方法を説明していきます。
N+1の対処方法
N+1の対処方法としてここでは,preload
、eager_load
、includes
の3つのメソッドについて紹介していきたいと思います。
まずは、preload
を説明していきます。
先程と同様の処理をpreload
を使用してプログラムを作成しました。
# 全ての投稿に対するユーザーの名前を出力
Post.preload(:user).each do |post|
puts post.user.name
end
先程のコードと違う部分は、eachの前にPost
に対して.preload(:user)
を行っているだけです。下記が実行時のログです。
Post Load (0.8ms) SELECT `posts`.* FROM `posts`
User Load (0.4ms) SELECT `users`.* FROM `users` WHERE `users`.`id` IN (5, 1, 3, 4, 2)
細川修二
山田太郎
立川裕美
立川裕美
前田達郎
長瀬来
前田達郎
立川裕美
先程は、postsテーブルに対して1回、usersテーブルに対して8回のSQLクエリが走っていましたが、preload
を使用すると、postsテーブルに対して1回、usersテーブルに対して1回、計2回のSQLクエリに減っており、N+1問題は解決されているのがわかります。
このように、preload
は、指定したassociationを複数のクエリに分けてキャッシュしておくことができるのです。
次は、eager_load
を説明していきます。
同様の処理をeager_load
を使用してプログラムを作成しました。
# 全ての投稿に対するユーザーの名前を出力
Post.eager_load(:user).each do |post|
puts post.user.name
end
先程のコードと違う部分は、preloadをeager_load
に変更したのみです。実行時のログを見て見ましょう。
SQL (0.5ms) SELECT `posts`.`id` AS t0_r0, `posts`.`user_id` AS t0_r1, `posts`.`title` AS t0_r2, `posts`.`month` AS t0_r3, `posts`.`created_at` AS t0_r4, `posts`.`updated_at` AS t0_r5, `users`.`id` AS t1_r0, `users`.`name` AS t1_r1, `users`.`created_at` AS t1_r2, `users`.`updated_at` AS t1_r3 FROM `posts` LEFT OUTER JOIN `users` ON `users`.`id` = `posts`.`user_id`
細川修二
山田太郎
立川裕美
立川裕美
前田達郎
長瀬来
前田達郎
立川裕美
eager_load
の場合は1回のSQLクエリで全データを取得していることがわかります。
このように、eager_loadは、指定したassociationをLEFT OUTER JOINしキャッシュされており
、また、JOINしているので、preloadと違って、JOINしたテーブルで絞込をすることもできます。
最後に、includes
について説明していきます。
同様の処理をincludes
を使用してプログラムを作成しました。
# 全ての投稿に対するユーザーの名前を出力
Post.includes(:user).each do |post|
puts post.user.name
end
上記が、includes
を使用した場合のコードです。実行時のログを見て見ましょう。
Post Load (0.3ms) SELECT `posts`.* FROM `posts`
User Load (0.3ms) SELECT `users`.* FROM `users` WHERE `users`.`id` IN (5, 1, 3, 4, 2)
細川修二
山田太郎
立川裕美
立川裕美
前田達郎
長瀬来
前田達郎
立川裕美
includes
を使用した場合も、postsテーブルに対して1回、usersテーブルに対して1回、計2回のSQLクエリに減っており、N+1問題は解決されています。
includes
を使用した場合、
-
includes
したテーブルでwhereによる絞り込みを行っている -
includes
したassociationに対してjoins
かreferences
も呼んでいる - 任意のassociationに対して
eager_load
も呼んでいる
上記のいずれかの場合、eager_load
と同じ挙動を行い、そうでなければpreload
と同じ挙動をする。
今回の場合は、preload
と同じ挙動です。
このように、上記の3つのメソッドを使用するとN+1問題は解決されます。
これらのメソッドの使い分けに関しては、ActiveRecordのjoinsとpreloadとincludesとeager_loadの違いで詳しく説明されています。合わせて読んでいただくと理解が深まると思います。
上記の記事にも記述されていますが、まとめると、そのテーブルとのJOINを禁止したいケースではpreloadを指定し、JOINしても問題なくてとりあえずeager loadingしたい場合はincludesを使い、必ずJOINしたい場合はeager_loadを使いましょう。
N+1問題を検知する Gem 「Bullet」
ここまで、N+1問題の対処法について説明してきましたが、N+1問題が発生しているのに気づかず、見逃してしまっていることがあると思います。そんな時にN+1問題を検知してくれる Gem 「Bullet」
について紹介していきます。
Gemfileに以下を追加して、bundle install
を実行します。
group :development do
gem 'bullet'
end
次に、config/environments/development.rb
に設定を追加します。
config.after_initialize do
Bullet.enable = true # Bulletプラグインを有効
Bullet.alert = true # JavaScriptでの通知
Bullet.console = true # ブラウザのコンソールログに記録
Bullet.rails_logger = true # Railsログに出力
end
設定に関しては複数種類がありますので、Gem bulletこちらを参考に必要な部分を追加していきましょう。
Railsサーバを再起動
してN+1問題のクエリが発行されるページへアクセスすると、下記のようなポップアップが表示されるようになります。

上記を見ると、どこの処理で発生したか、どのように対処するのがいいのかを教えてくれます。 Gem bulletをインストールすることで、N+1問題は対処しやすくなるでしょう。
まとめ
N+1問題とは、
SQLクエリが「データ量N + 1回」走ってしまうことで、取得するデータが多くなる(Nの回数が多くなる)につれてパフォーマンスが低下してしまう問題
であり、 対処方法としてpreload
、eager_load
、includes
の3つのメソッドを状況に合わせて選択し使用する
。
また、ログを見ていれば、N+1問題に気付けると言われる方は多いと思われますが、初めの頃はGem 「Bullet」
を導入することをお勧めします。