1
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

railsで起こるN+1問題とはなにか?、その対処方法

Last updated at Posted at 2019-07-21

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対多になります。それぞれのモデルは以下のように関係づけられます。

user.rb
class User < ApplicationRecord
  has_many :posts
end
post.rb
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の対処方法としてここでは,preloadeager_loadincludesの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に対してjoinsreferencesも呼んでいる
  • 任意の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を実行します。

Gemfile
  group :development do
    gem 'bullet'
  end

次に、config/environments/development.rbに設定を追加します。

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問題のクエリが発行されるページへアクセスすると、下記のようなポップアップが表示されるようになります。

スクリーンショット 2019-07-21 21.28.42.png

上記を見ると、どこの処理で発生したか、どのように対処するのがいいのかを教えてくれます。 Gem bulletをインストールすることで、N+1問題は対処しやすくなるでしょう。

まとめ

N+1問題とは、
SQLクエリが「データ量N + 1回」走ってしまうことで、取得するデータが多くなる(Nの回数が多くなる)につれてパフォーマンスが低下してしまう問題であり、 対処方法としてpreloadeager_loadincludesの3つのメソッドを状況に合わせて選択し使用する

また、ログを見ていれば、N+1問題に気付けると言われる方は多いと思われますが、初めの頃はGem 「Bullet」を導入することをお勧めします。

1
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?