RailsのN+1問題におけるpreload, eager_load, includesの挙動の違いと使い分け

## はじめに
N+1問題の対策として以下の3つのメソッドが存在することは知っているものの、挙動の違いや使い分けが思い出せずいちいち調べてしまっています。

- `preload`メソッド
- `eager_load`メソッド
- `includes`メソッド

なんとか記憶に定着させたいのでこの記事を書くことにしました。

3つのメソッドの挙動や使い分けについてまとめます。

なおこの記事におけるRailsのバージョンは**7.1.3.4**です。

## N+1問題とは？
N+1問題とは、データベースからのデータ取得時に発生するパフォーマンス上の問題です。

あるテーブルとそれに関連する別のテーブルのデータを一度に取得する際、クエリが過剰に発行されてしまうことを指します。

RailsにおけるActiveRecordのように、ORMを使用するフレームワークでよく発生します。

## N+1問題の具体例
たとえば、記事の投稿者（`User`）とそれに紐づく記事（`Article`）があるとします。

```ruby
class User < ApplicationRecord
  has_many :articles
end

class Article < ApplicationRecord
  belongs_to :user
end
```

コントローラーとビューは以下のようになっています。
```ruby:controllers/users_controller.rb
class UsersController < ApplicationController
  def index
    @users = User.all
  end
end

```

```erb:views/users/index.html.erb
<% @users.each do |user| %>
  <h2><%= user.name %></h2>
  <ul>
    <% user.articles.each do |article| %>
      <li><%= article.title %></li>
    <% end %>
  </ul>
<% end %>
```
このコードは一見問題なさそうに見えますが、実際には`index.html.erb`を開いたタイミングで以下のようなクエリが発行されます。

1. すべてのユーザーを取得するクエリ（1回）
2. 各ユーザーの記事を取得するクエリ（ユーザーの数だけ実行、つまりN回）

したがって、合計でN＋1回のクエリが実行されることになります。

これがN＋1問題です。

### 実際のログと発行されるSQL
ローカル環境でユーザーが100人いる状況を再現してみました。

`views/users/index.html.erb`を開いた際のログを確認します（一部省略）

```bash
Started GET "/users" for 127.0.0.1 at 2024-07-29 21:08:53 +0900
Processing by UsersController#index as HTML
  Rendering layout layouts/application.html.erb
  Rendering users/index.html.erb within layouts/application
  User Load (0.4ms)  SELECT "users".* FROM "users"
  ↳ app/views/users/index.html.erb:1
  Article Load (0.1ms)  SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 1]]
  ↳ app/views/users/index.html.erb:4
  Article Load (0.0ms)  SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 2]]
  ↳ app/views/users/index.html.erb:4
  Article Load (0.0ms)  SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 3]]
  ↳ app/views/users/index.html.erb:4
  Article Load (0.0ms)  SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 4]]
  ↳ app/views/users/index.html.erb:4
  Article Load (0.0ms)  SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 5]]
  ↳ app/views/users/index.html.erb:4

...省略...

  Article Load (0.0ms)  SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 96]]
  ↳ app/views/users/index.html.erb:4
  Article Load (0.0ms)  SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 97]]
  ↳ app/views/users/index.html.erb:4
  Article Load (0.0ms)  SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 98]]
  ↳ app/views/users/index.html.erb:4
  Article Load (0.0ms)  SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 99]]
  ↳ app/views/users/index.html.erb:4
  Article Load (0.0ms)  SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 100]]
  ↳ app/views/users/index.html.erb:4
  Rendered users/index.html.erb within layouts/application (Duration: 55.1ms | Allocations: 58114)
  Rendered layout layouts/application.html.erb (Duration: 57.2ms | Allocations: 59466)
Completed 200 OK in 59ms (Views: 53.9ms | ActiveRecord: 4.3ms | Allocations: 59711)
```

見やすくするためにSQLの部分だけ抜粋します。
```sql
-- 全てのユーザーを取得するクエリ（1回）
SELECT "users".* FROM "users"

-- 各ユーザーの記事を取得するクエリ（ユーザーの数だけ実行、今回のケースだと100回）
SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 1]]
SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 2]]
SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 3]]
SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 4]]
SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 5]]
...
SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 96]]
SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 97]]
SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 98]]
SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 99]]
SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 100]]
```
このケースでは合計101回のクエリが発行されてしまっています。

これは大量のデータを扱う際にパフォーマンス上の問題を引き起こしてしまうため、回避しないといけません。

## N+1問題の解消方法
RailsではN+1問題を解消するために、以下の３つのメソッドが用意されています。

- `preload`メソッド
- `eager_load`メソッド
- `includes`メソッド

それぞれ順番に説明していきます。

### `preload`メソッド
`preload`メソッドはテーブル毎に1回ずつクエリを発行して関連データを取得するメソッドです。

言葉で説明してもわかりにくいため、実際の挙動で確認を進めます。

先ほどの`controllers/users_controller.rb`に`preload`メソッドを書き加えます。

```ruby:controllers/users_controller.rb
class UsersController < ApplicationController
  def index
    # preload でN+1問題を解消
    @users = User.preload(:articles)
  end
end
```

`views/users/index.html.erb`には変更を加えないまま、画面を表示した際のログです。

```bash
Started GET "/users" for 127.0.0.1 at 2024-07-30 20:28:16 +0900
Processing by UsersController#index as HTML
  Rendering layout layouts/application.html.erb
  Rendering users/index.html.erb within layouts/application
  User Load (0.4ms)  SELECT "users".* FROM "users"
  ↳ app/views/users/index.html.erb:1
  Article Load (0.3ms)  SELECT "articles".* FROM "articles" WHERE "articles"."user_id" IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)  [["user_id", 1], ["user_id", 2], ["user_id", 3], ["user_id", 4], ["user_id", 5], ["user_id", 6], ["user_id", 7], ["user_id", 8], ["user_id", 9], ["user_id", 10], ["user_id", 11], ["user_id", 12], ["user_id", 13], ["user_id", 14], ["user_id", 15], ["user_id", 16], ["user_id", 17], ["user_id", 18], ["user_id", 19], ["user_id", 20], ["user_id", 21], ["user_id", 22], ["user_id", 23], ["user_id", 24], ["user_id", 25], ["user_id", 26], ["user_id", 27], ["user_id", 28], ["user_id", 29], ["user_id", 30], ["user_id", 31], ["user_id", 32], ["user_id", 33], ["user_id", 34], ["user_id", 35], ["user_id", 36], ["user_id", 37], ["user_id", 38], ["user_id", 39], ["user_id", 40], ["user_id", 41], ["user_id", 42], ["user_id", 43], ["user_id", 44], ["user_id", 45], ["user_id", 46], ["user_id", 47], ["user_id", 48], ["user_id", 49], ["user_id", 50], ["user_id", 51], ["user_id", 52], ["user_id", 53], ["user_id", 54], ["user_id", 55], ["user_id", 56], ["user_id", 57], ["user_id", 58], ["user_id", 59], ["user_id", 60], ["user_id", 61], ["user_id", 62], ["user_id", 63], ["user_id", 64], ["user_id", 65], ["user_id", 66], ["user_id", 67], ["user_id", 68], ["user_id", 69], ["user_id", 70], ["user_id", 71], ["user_id", 72], ["user_id", 73], ["user_id", 74], ["user_id", 75], ["user_id", 76], ["user_id", 77], ["user_id", 78], ["user_id", 79], ["user_id", 80], ["user_id", 81], ["user_id", 82], ["user_id", 83], ["user_id", 84], ["user_id", 85], ["user_id", 86], ["user_id", 87], ["user_id", 88], ["user_id", 89], ["user_id", 90], ["user_id", 91], ["user_id", 92], ["user_id", 93], ["user_id", 94], ["user_id", 95], ["user_id", 96], ["user_id", 97], ["user_id", 98], ["user_id", 99], ["user_id", 100]]
  ↳ app/views/users/index.html.erb:1
  Rendered users/index.html.erb within layouts/application (Duration: 118.5ms | Allocations: 26853)
  Rendered layout layouts/application.html.erb (Duration: 269.3ms | Allocations: 60932)
Completed 200 OK in 364ms (Views: 271.9ms | ActiveRecord: 4.4ms | Allocations: 68551)
```

今回はログを一切省略していないため、これだけでも発行されるSQLが大幅に削減されていることがわかります。

先ほどと同様にSQLの部分だけを抜き出した上で、わかりやすさのためにプレースホルダーの部分のみ整形します。

```sql
-- 全てのユーザーを取得するクエリ（1回）
SELECT "users".* FROM "users"

-- 各ユーザーの記事をまとめて取得するクエリ（1回）
SELECT "articles".* FROM "articles" WHERE "articles"."user_id" IN (1, 2, 3, ... 98, 99, 100) 
```

このように関連するテーブル毎に1回ずつクエリを発行することでN+1問題を解消するのが`preload`メソッドです。

### `eager_load`メソッド
`eager_load`メソッドは`LEFT OUTER JOIN`を用いて関連テーブルを結合し、1回のクエリで関連データを全て取得するメソッドです。

こちらも実際の挙動で確認します。

先ほどの`controllers/users_controller.rb`に書いた`preload`の部分を`eager_load`メソッドに書き換えます。

```ruby:controllers/users_controller.rb
class UsersController < ApplicationController
  def index
    # eager_load でN+1問題を解消
    @users = User.eager_load(:articles)
  end
end
```

`views/users/index.html.erb`には変更を加えないまま、画面を表示した際のログです。

```bash
Started GET "/users" for 127.0.0.1 at 2024-07-30 20:44:10 +0900
Processing by UsersController#index as HTML
  Rendering layout layouts/application.html.erb
  Rendering users/index.html.erb within layouts/application
  SQL (0.2ms)  SELECT "users"."id" AS t0_r0, "users"."name" AS t0_r1, "users"."created_at" AS t0_r2, "users"."updated_at" AS t0_r3, "articles"."id" AS t1_r0, "articles"."user_id" AS t1_r1, "articles"."title" AS t1_r2, "articles"."content" AS t1_r3, "articles"."created_at" AS t1_r4, "articles"."updated_at" AS t1_r5 FROM "users" LEFT OUTER JOIN "articles" ON "articles"."user_id" = "users"."id"
  ↳ app/views/users/index.html.erb:1
  Rendered users/index.html.erb within layouts/application (Duration: 55.3ms | Allocations: 16854)
  Rendered layout layouts/application.html.erb (Duration: 69.6ms | Allocations: 32376)
Completed 200 OK in 75ms (Views: 68.6ms | ActiveRecord: 2.6ms | Allocations: 34290)
```

同じくSQL部分だけを抜粋します。
```sql
SELECT 
    "users"."id" AS t0_r0,
    "users"."name" AS t0_r1,
    "users"."created_at" AS t0_r2,
    "users"."updated_at" AS t0_r3,
    "articles"."id" AS t1_r0,
    "articles"."user_id" AS t1_r1,
    "articles"."title" AS t1_r2,
    "articles"."content" AS t1_r3,
    "articles"."created_at" AS t1_r4,
    "articles"."updated_at" AS t1_r5
FROM "users" 
LEFT OUTER JOIN "articles" ON "articles"."user_id" = "users"."id"
```

`LEFT OUTER JOIN`で`users`テーブルと`articles`テーブルを結合した上で必要なデータを1回のクエリで取得しています。

このようにテーブル結合を使って１回のクエリ発行のみでN+1問題を解消するのが`eager_load`メソッドです。

### `includes`メソッド
`includes`メソッドはRailsが内部的に`preload`または`eager_load`のどちらかを選択して実行してくれるメソッドです。

通常は`preload`を使用しますが、条件によっては`eager_load`を実行します。

これも動作確認をしてみましょう。

まずは先ほどの`eager_load`の部分を`includes`に書き換えます。

```ruby:controllers/users_controller.rb
class UsersController < ApplicationController
  def index
    # includes でN+1問題を解消
    @users = User.includes(:articles)
  end
end
```

`views/users/index.html.erb`表示した際のログを確認します。

```bash
Started GET "/users" for 127.0.0.1 at 2024-07-30 20:56:33 +0900
Processing by UsersController#index as HTML
  Rendering layout layouts/application.html.erb
  Rendering users/index.html.erb within layouts/application
  User Load (2.2ms)  SELECT "users".* FROM "users"
  ↳ app/views/users/index.html.erb:1
  Article Load (0.3ms)  SELECT "articles".* FROM "articles" WHERE "articles"."user_id" IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)  [["user_id", 1], ["user_id", 2], ["user_id", 3], ["user_id", 4], ["user_id", 5], ["user_id", 6], ["user_id", 7], ["user_id", 8], ["user_id", 9], ["user_id", 10], ["user_id", 11], ["user_id", 12], ["user_id", 13], ["user_id", 14], ["user_id", 15], ["user_id", 16], ["user_id", 17], ["user_id", 18], ["user_id", 19], ["user_id", 20], ["user_id", 21], ["user_id", 22], ["user_id", 23], ["user_id", 24], ["user_id", 25], ["user_id", 26], ["user_id", 27], ["user_id", 28], ["user_id", 29], ["user_id", 30], ["user_id", 31], ["user_id", 32], ["user_id", 33], ["user_id", 34], ["user_id", 35], ["user_id", 36], ["user_id", 37], ["user_id", 38], ["user_id", 39], ["user_id", 40], ["user_id", 41], ["user_id", 42], ["user_id", 43], ["user_id", 44], ["user_id", 45], ["user_id", 46], ["user_id", 47], ["user_id", 48], ["user_id", 49], ["user_id", 50], ["user_id", 51], ["user_id", 52], ["user_id", 53], ["user_id", 54], ["user_id", 55], ["user_id", 56], ["user_id", 57], ["user_id", 58], ["user_id", 59], ["user_id", 60], ["user_id", 61], ["user_id", 62], ["user_id", 63], ["user_id", 64], ["user_id", 65], ["user_id", 66], ["user_id", 67], ["user_id", 68], ["user_id", 69], ["user_id", 70], ["user_id", 71], ["user_id", 72], ["user_id", 73], ["user_id", 74], ["user_id", 75], ["user_id", 76], ["user_id", 77], ["user_id", 78], ["user_id", 79], ["user_id", 80], ["user_id", 81], ["user_id", 82], ["user_id", 83], ["user_id", 84], ["user_id", 85], ["user_id", 86], ["user_id", 87], ["user_id", 88], ["user_id", 89], ["user_id", 90], ["user_id", 91], ["user_id", 92], ["user_id", 93], ["user_id", 94], ["user_id", 95], ["user_id", 96], ["user_id", 97], ["user_id", 98], ["user_id", 99], ["user_id", 100]]
  ↳ app/views/users/index.html.erb:1
  Rendered users/index.html.erb within layouts/application (Duration: 13.6ms | Allocations: 14369)
  Rendered layout layouts/application.html.erb (Duration: 33.9ms | Allocations: 29716)
Completed 200 OK in 42ms (Views: 33.2ms | ActiveRecord: 3.0ms | Allocations: 31628)
```

`preload`の時と同じログが出力されました。

次に`includes`を使用した上で条件を指定してみます。

```ruby:controllers/users_controller.rb
class UsersController < ApplicationController
  def index
    # articles の id が50以上のデータを取得する
    @users = User.includes(:articles).where(articles: { id: 50..Float::INFINITY })
  end
end
```

この場合のログを確認します。

```bash
Started GET "/users" for 127.0.0.1 at 2024-07-30 21:00:42 +0900
Processing by UsersController#index as HTML
  Rendering layout layouts/application.html.erb
  Rendering users/index.html.erb within layouts/application
  SQL (0.2ms)  SELECT "users"."id" AS t0_r0, "users"."name" AS t0_r1, "users"."created_at" AS t0_r2, "users"."updated_at" AS t0_r3, "articles"."id" AS t1_r0, "articles"."user_id" AS t1_r1, "articles"."title" AS t1_r2, "articles"."content" AS t1_r3, "articles"."created_at" AS t1_r4, "articles"."updated_at" AS t1_r5 FROM "users" LEFT OUTER JOIN "articles" ON "articles"."user_id" = "users"."id" WHERE "articles"."id" >= ?  [["id", 50]]
  ↳ app/views/users/index.html.erb:1
  Rendered users/index.html.erb within layouts/application (Duration: 3.4ms | Allocations: 3950)
  Rendered layout layouts/application.html.erb (Duration: 16.1ms | Allocations: 5305)
Completed 200 OK in 17ms (Views: 16.2ms | ActiveRecord: 0.2ms | Allocations: 6037)
```

今度は`eager_load`と同様に`LEFT OUTER JOIN`を使ったクエリが発行されています。

以上のように、`includes`メソッドを使った場合、通常は`preload`が採用され、関連データに対して条件を指定する場合は`eager_load`を採用するのが一般的な挙動です。

## 3つのメソッドの使い分け
ここからは`preload`・`eager_load`・`includes`の使い分けについてまとめます。

### 基本は`preload`を使う
基本的には`preload`を使うのが良いと思われます。

`preload`は複数のクエリに分割して必要なデータを取得するため、メモリ使用量の点で`eager_load`よりも優れているからです。

特にデータ量やテーブルのカラム数が多かったりする場合、`eager_load`でテーブル結合してしまうと、すごく重いクエリを実行することになってしまいます。

また実装時点では`eager_load`の方が速くても、レコード数が増えてくるにつれて`preload`の方が速くなることも考えられます。

よって、不都合がなければ`preload`の採用を基本とした方が安全で、事故を引き起こす可能性が低いです。

### 関連テーブルに条件を指定する場合は`eager_load`を使う
とはいえ、関連テーブルに条件を指定したい場合は`eager_load`を用いて`LEFT OUTER JOIN`をするしかありません。

この場合は`eager_load`を使いましょう。

また、単に条件指定で絞り込みをしたいだけであれば`joins`メソッドを使えばメモリ使用量を節約できます。

関連テーブルのレコードのキャッシュが不要な場合は`eager_load`を避けて`joins`メソッドを使いましょう。

### `includes`は非推奨
`includes`は「Railsが内部的に`preload`または`eager_load`のどちらかを選択して実行してくれるメソッド」だと説明しました。

これだけ聞くと`includes`を使うのが最適なように思えるかもしれません。

しかし`includes`は非推奨とされるケースもそれなりにあります。

その理由は以下の2点です。

1. 関連テーブルが複数あるときに個別最適化できない
2. 内部挙動が明確ではない

`1. 関連テーブルが複数あるときに個別最適化できない`に関して、たとえば以下のコードがあったとします。

```ruby
def index
  @users = User.includes(:articles, :comments)
end
```

この場合、「`articles`は`eager_load`」・「`comments`は`preload`」といった個別の使い分けはできず、`LEFT OUTER JOIN`をすべきテーブルが１つでもあれば、すべて`eager_load`での読み込みがされてしまいます。

これがパフォーマンスの低下を招く恐れがあるのです。

`2. 内部挙動が明確ではない`に関して、`includes`が内部で`preload`と`eager_load`のどちらを採用しているかはクエリの文脈にも依存してしまいます。

そのためどちらを採用しているか常に明白とはかぎらず、予期せぬ挙動を取りかねません。

これはデバッグやパフォーマンスチューニングの際に問題の特定を困難にしてしまう可能性があります。

「便利だから」と安易に`includes`を使うのは避けて、明示的に`preload`か`eager_load`のどちらかを使った方がわかりやすいです。

もちろん小規模な開発や実装スピード重視の場合は`includes`でも問題ありませんが、その場合でもどちらのメソッドを採用しているかは把握しておいた方が良いでしょう。

## おわりに
最後にまとめです。

- `preload`は関連テーブル毎に個別にクエリを発行してデータを取得する
- `eager_load`は関連テーブルを結合して１つのクエリで必要なデータを全て取得する
- `includes`は`preload`と`eager_load`を内部で自動的に使い分ける
- 基本は`preload`を使っておくのが無難
- 関連テーブルに条件指定したい時は`eager_load`を使うが、関連テーブルのデータが不要であれば`joins`メソッドで条件指定を代替できる
- `includes`は一見便利そうだが非推奨とされることも多い

３つのメソッドの違いをしっかりと理解して最適な使い分けができるようになりましょう。

## 参考資料
https://techracho.bpsinc.jp/hachi8833/2023_09_27/89510

https://tech.smarthr.jp/entry/2021/11/11/151444

https://moneyforward-dev.jp/entry/2019/04/02/activerecord-includes-preload-eagerload/

https://tech.stmn.co.jp/entry/2020/11/30/145159