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

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

```ruby:user.rb
class User < ApplicationRecord
  has_many :posts
end
```

```ruby:post.rb
class Post < ApplicationRecord
  belongs_to :user
end
```

全ての投稿に対するユーザーの名前を出力するプログラムを作成します。

```ruby
# 全ての投稿に対するユーザーの名前を出力
Post.all.each do |post|
  puts post.user.name
end
```
このコードでは、まず全ての投稿を取得し、その後に各投稿に対してユーザーの名前の情報を取得しています。
下記が実行時のログです。

```sql
  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`を使用してプログラムを作成しました。

```ruby
# 全ての投稿に対するユーザーの名前を出力
  Post.preload(:user).each do |post|
    puts post.user.name
  end
```

先程のコードと違う部分は、eachの前に`Post`に対して`.preload(:user)`を行っているだけです。下記が実行時のログです。

```sql
  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`を使用してプログラムを作成しました。

```ruby
# 全ての投稿に対するユーザーの名前を出力
  Post.eager_load(:user).each do |post|
    puts post.user.name
  end
```

先程のコードと違う部分は、preloadを`eager_load`に変更したのみです。実行時のログを見て見ましょう。

```sql
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`を使用してプログラムを作成しました。

```ruby
# 全ての投稿に対するユーザーの名前を出力
  Post.includes(:user).each do |post|
    puts post.user.name
  end
```
上記が、`includes`を使用した場合のコードです。実行時のログを見て見ましょう。

```sql
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の違い](https://qiita.com/k0kubun/items/80c5a5494f53bb88dc58)で詳しく説明されています。合わせて読んでいただくと理解が深まると思います。

上記の記事にも記述されていますが、まとめると、`そのテーブルとのJOINを禁止したいケースではpreloadを指定し、JOINしても問題なくてとりあえずeager loadingしたい場合はincludesを使い、必ずJOINしたい場合はeager_loadを使いましょう。`


# N+1問題を検知する Gem 「Bullet」

ここまで、N+1問題の対処法について説明してきましたが、N+1問題が発生しているのに気づかず、見逃してしまっていることがあると思います。そんな時に`N+1問題を検知してくれる Gem 「Bullet」`について紹介していきます。

Gemfileに以下を追加して、`bundle install`を実行します。

```ruby:Gemfile
  group :development do
    gem 'bullet'
  end
```
次に、`config/environments/development.rb`に設定を追加します。

```ruby: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](https://github.com/flyerhzm/bullet)こちらを参考に必要な部分を追加していきましょう。

`Railsサーバを再起動`してN+1問題のクエリが発行されるページへアクセスすると、下記のようなポップアップが表示されるようになります。

<img width="656" alt="スクリーンショット 2019-07-21 21.28.42.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/459450/4d390bd5-b575-56e2-ac3d-083fd6741209.png">

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


# まとめ

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

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