【Rails】N+1問題を解決するメソッドについて

## はじめに
開発ではじめてActiveModelSerializerを使った時に、あるエラーがきっかけでincludesメソッドの理解が深まりました。
includesをはじめとした様々なメソッドについてまとめました。

## 前提知識
### ActiveModelSerializer
RailsでデータをJSON形式に変換するための機能です。
（ActiveModelSerializerに関しては、別途投稿します）

### includesメソッド
関連するテーブルをまとめて取得します。
```ruby
モデル名.includes(:関連名)
```

## 発生したエラー
きっかけは、以下のようなエラーが発生したことです。
```
Bullet::Notification::UnoptimizedQueryError: user: root
GET /api/models
USE eager loading detected Model => [:関連名]  
Add to your query: .includes([:関連名])
```

### エラーの意味
- gem「**[Bullet](https://github.com/flyerhzm/bullet)**」が、非効率なクエリを検出したというエラーです
- アソシエーションで定義した`[:関連名]`に対して、Eager Loadingしています

### 原因
主な原因は、**N+1問題**です。
N+1問題とは、あるレコードのクエリが1回発行されたら、関連するレコードのクエリが追加でN回発行される状況です。

### 解決方法
```
Add to your query: .includes([:関連名])
```
「**クエリに`.includes([:関連名])`を追加してください**」と書かれているので、includesメソッドを使用し、関連するテーブルのレコードを取得します。

## 具体例
ブログ記事とコメント、お気に入りの関係を例に考えてみます。

### アソシエーション
#### Articleモデル
```app/models/article.rb
class Article < ApplicationRecord
  has_many :comments
end
```
#### Commentモデル
```app/models/comment.rb
class Comment < ApplicationRecord
  belongs_to :article
  has_many :likes
end
```
#### Likeモデル
```app/models/like.rb
class Like < ApplicationRecord
  belongs_to :comment
end
```

１つの記事には複数のコメントがつきます。
１つのコメントには複数のいいね（お気に入り）がつきます。

### includesの実装例
#### includesを使用した場合
```ruby
posts = Post.includes(:comments, :likes)
```

```sql
SELECT "posts".* FROM "posts" LEFT OUTER JOIN "comments" ON
"comments"."post_id" = "posts"."id" LEFT OUTER JOIN "likes" ON
"likes"."comment_id" = "comments"."id"
```
Postモデルのレコードを取得する際に、関連するcommentsとlikesも一度に取得します。

##### includesのデフォルトの動作
基本的には後述の[preload](#preload)と同様の動作をし、関連レコードを個別に取得します。

##### eager_loadを行う条件
以下の条件を満たす場合は、後述の[eager_load](#eager_load)と同様の動作（JOIN）を実行します。
1. includesしたテーブルでwhere句などを使用して条件を絞った場合
2. includesしたassociationに対して、joinsかreferencesを呼んでいる場合
3. 任意のassociationに対してeager_loadメソッドを呼んでいる場合

#### includesを使用しない場合
```ruby
posts = Post.all
```

```ruby
posts.each do |post|
  comments = post.comments
  likes = post.likes
end
```

```sql
# 全てのPostを取得
SELECT * FROM posts;

# 1つ目のPostのコメントを取得
SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 1

# 1つ目のPostのいいねを取得
SELECT "likes".* FROM "likes" WHERE "likes"."comment_id" = 1

# 2つ目のPostのコメントを取得
SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 2
```
このように各Postに対して、CommentとLikeを個別に取得するためのクエリが発行されます。N+1問題が発生するため、パフォーマンスが低下しやすいです。

## その他のメソッド 
### eager_load
LEFTJOINを行い、モデルと関連付けられているモデルの両方を取得する1つのクエリを開始します。
ちなみにincludesとは、eager_loadのエイリアス（別名）です。

```ruby
posts = Post.eager_load(:comments, :likes)
```

```sql
SELECT posts.*, comments.*, likes.* FROM posts
LEFT OUTER JOIN comments ON comments.post_id = posts.id
LEFT OUTER JOIN likes ON likes.post_id = posts.id
```

#### includesとの違い
[includes](#includesを使用した場合)との大きな違いは、eager_loadでは**常にJOIN**を行うということです。すべての関連付けに対して、**LEFT OUTER JOIN**を明示的に強制します。

### preload
取得したすべてのPostに対して、関連付けされるコメントやいいね毎に、レコードがまとめて取得されます。取得したレコードはメモリ上で結合されます。
```ruby
posts = Post.preload(:comments, :likes)
```

```sql
SELECT * FROM posts;
SELECT * FROM comments WHERE post_id IN (1, 2, 3, ...);
SELECT * FROM likes WHERE post_id IN (1, 2, 3, ...);
```
preloadはJOINを使用せずに、個別のSELECT文で関連レコードを取得します。


## それぞれメソッドの動作の違いと使い分け
### 動作の違い
| メソッド名 | 特徴 |
| ---- | ---- |
| [includes](#includesを使用した場合) | 条件によってはJOINを行い、一度に関連レコードを取得する |
| [eager_load](#eager_load) | 常にJOINを行い、一度に関連レコードを取得。includesのエイリアス |
| [preload](#preload) | JOINせずに関連レコードを個別に取得する|

### 使い分け
要件によっては、使い分ける必要がありそうです。
| 比較項目 | includes/eager_load |preload|
| ---- | ---- |---- |
| JOINの使用 | 使用する |使用しない|
| パフォーマンス | 一般的には高速 |複雑な関連付けでは高速な場合もある|
| メモリ消費 | preloadより多い可能性あり|includes/eager_loadよりは少ない|
| N+1問題 | 発生する可能性がある|発生する可能性が低い|
| 柔軟性 | 低い|高い|

## まとめ
- `includes`: JOINを使用して関連レコードを一度に取得します。条件によってeager_loadと同様の動作をします
- `eager_load`: JOINを使用して、関連レコードを一度に取得します。シンプルで高速な場合が多いですが、データ量が多い場合や複雑な関連付けの場合、パフォーマンスが低下する可能性があります
- `preload`: 関連レコードを個別に取得し、メモリ上で結合します。JOINを使用しないため、N+1問題を回避しやすく、メモリ使用量も抑えられます

## 参考記事
- [ActiveModelSerializer](https://github.com/rails-api/active_model_serializers)
- [bullet](https://github.com/flyerhzm/bullet)
- [Railsガイド(includes)](https://railsguides.jp/active_record_querying.html#includes)
- [Rails: Active Recordメソッドのパフォーマンス改善とN+1問題の克服（翻訳）](https://techracho.bpsinc.jp/hachi8833/2023_09_27/89510)