ActiveRecordにおけるN+1問題と（preload, eager_load, includes, joins）について

## はじめに
ActiveRecordで検索が複数テーブルにまたがる際にN+1問題を解決するために、よく`includes`が使われている記事を見てそれを参考にしていましたがあんまり良くないという話を聞き色々と調べた結果をまとめた備忘録です。

## N+1問題について
ループ処理の中で都度SQLを発行してしまい、大量のSQLが発行されてパフォーマンスが低下する問題のことを指します。
ActiveRecordなどOR Mapperを使用している際に発生しがちです。

### なぜ起こるのか：Lazy loading
遅延読み込みともいい ActiveRecord ではこれがデフォルトです。関連するテーブルが必要になった時にクエリを発行して必要な値を取り出します。
メモリを確保する量は少なくてすみますが、関連するテーブルを使うごとにSQLが発行されることになり動作が重くなります。

例えば何かの一覧を作成しているときに、

* 一覧に表示されるデータを取得する -> SELECTを一回実行（N個のレコードを取得）
* レコードそれぞれの関連データの取得 -> SELECTをN回実行

という動作で合計N+1のクエリを実行することになるので、Nが増えれば増えるほどパフォーマンスが悪くなります。

### 解決策：Eager loading
一括読み込みともいいます。
予め関連するテーブルを全てメモリ上に確保してしまうことを言います。
クエリが少なくて済むため描写が高速になりますが、関連するテーブルも全てメモリ上にあるのでその分のメモリを消費します。
件数絞り込みをした上で使用するのが良さそうです。
`preload`, `eager_load`, `includes`等のメソッドを使用します。

## 検証環境

### テーブル間のアソシエーション
<img width="631" alt="Screen Shot 2020-05-20 at 18.28.07.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359511/8fd9760d-5131-317e-8150-b9f707d200b5.png">

### 各バージョン

| Ruby | Rails(ActiveRecord) | MySQL |
| ---- | ---- | ---- |
| 2.7.1 | 6.0.3.1 | 5.6 |

## joins
```rb
User.joins(:posts).where(posts: { id: 1 })
# User Load (2.4ms)  SELECT `users`.* FROM `users` INNER JOIN `posts` ON `posts`.`user_id` = `users`.`id` WHERE `posts`.`id` = 1 LIMIT 11

Post.joins(:user).where(users: {name: 'user1' })
# Post Load (2.6ms)  SELECT `posts`.* FROM `posts` INNER JOIN `users` ON `users`.`id` = `posts`.`user_id` WHERE `users`.`name` = 'user1' LIMIT 11
```

INNER JOINでテーブルを結合します。LEFT OUTER JOINを行いたい場合は`left_joins`を使います。
単にJOIN句のクエリを作成するのでアソシエーションはキャッシュしないのでN+1問題が起こりますが、その分メモリ消費を抑えることができます。
なのでJOINしたテーブルのデータを使わない場合において、データの絞り込み（`where`, `order`）には`joins`が推奨されています。

## preload
```rb
User.preload(:posts)
# User Load (2.8ms)  SELECT `users`.* FROM `users` LIMIT 11
# Post Load (2.3ms)  SELECT `posts`.* FROM `posts` WHERE `posts`.`user_id` IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11)

Post.preload(:user)
# Post Load (3.0ms)  SELECT `posts`.* FROM `posts` LIMIT 11
# User Load (1.9ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` IN (28, 22, 40, 5, 41, 1, 23, 11, 34, 47, 15)
```
指定した関連テーブルごとにクエリを実行して取得してキャッシュします。
JOINしてテーブル結合をするわけではないので、`preload`したテーブルでの絞り込みは使えず例外を投げます。
複数のアソシエーションをEager loadingするときや大きなテーブル等を扱う等JOINしたくないときに使うのが良さそうです。
クエリの中でIN句がありますが、主テーブルにレコード数が多い場合IN句が膨らんでいきMySQLなどデータベース側でのエラーを引き起こす可能性があるのでページネーション等で件数の絞り込みをした方が良さそうです。

## eager_load
```rb
User.eager_load(:posts)
# SQL (2.5ms)  SELECT DISTINCT `users`.`id` FROM `users` LEFT OUTER JOIN `posts` ON `posts`.`user_id` = `users`.`id` LIMIT 11
# SQL (3.2ms)  SELECT `users`.`id` AS t0_r0, `users`.`name` AS t0_r1, `users`.`created_at` AS t0_r2, `users`.`updated_at` AS t0_r3, `posts`.`id` AS t1_r0, `posts`.`content` AS t1_r1, `posts`.`user_id` AS t1_r2, `posts`.`created_at` AS t1_r3, `posts`.`updated_at` AS t1_r4 FROM `users` LEFT OUTER JOIN `posts` ON `posts`.`user_id` = `users`.`id` WHERE `users`.`id` IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11)

User.eager_load(:posts).where(posts: { id: 1 })
# SQL (2.5ms)  SELECT DISTINCT `users`.`id` FROM `users` LEFT OUTER JOIN `posts` ON `posts`.`user_id` = `users`.`id` WHERE `posts`.`id` = 1 LIMIT 11
# SQL (1.7ms)  SELECT `users`.`id` AS t0_r0, `users`.`name` AS t0_r1, `users`.`created_at` AS t0_r2, `users`.`updated_at` AS t0_r3, `posts`.`id` AS t1_r0, `posts`.`content` AS t1_r1, `posts`.`user_id` AS t1_r2, `posts`.`created_at` AS t1_r3, `posts`.`updated_at` AS t1_r4 FROM `users` LEFT OUTER JOIN `posts` ON `posts`.`user_id` = `users`.`id` WHERE `posts`.`id` = 1 AND `users`.`id` = 10

Post.eager_load(:user)
# SQL (1.9ms)  SELECT `posts`.`id` AS t0_r0, `posts`.`content` AS t0_r1, `posts`.`user_id` AS t0_r2, `posts`.`created_at` AS t0_r3, `posts`.`updated_at` AS t0_r4, `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` LIMIT 11

Post.eager_load(:user).where(users: { name: 'user1' })
# SQL (2.3ms)  SELECT `posts`.`id` AS t0_r0, `posts`.`content` AS t0_r1, `posts`.`user_id` AS t0_r2, `posts`.`created_at` AS t0_r3, `posts`.`updated_at` AS t0_r4, `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` WHERE `users`.`name` = 'user1' LIMIT 11
```
LEFT OUTER JOINで指定したデータを結合して取得しキャッシュします。
テーブルを結合しているので`eager_load`したテーブルの要素でデータの絞り込みができます。
1対N関連のテーブルをLEFT JOINしたSQLが返すレコードは重複を含んだものになってくるため絞り込みが難しい形となり、それを防ぐためにdistinctをつけたクエリを発行することで、絞り込み対象のidリストを取得しています。しかしこのdistinctのSQLがスロークエリになりやすいようなので1対Nのアソシエーションに使うのは向きません。

## includes
```rb
User.includes(:posts)
# User Load (3.1ms)  SELECT `users`.* FROM `users` LIMIT 11
# Post Load (3.3ms)  SELECT `posts`.* FROM `posts` WHERE `posts`.`user_id` IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11)

User.includes(:posts).where(posts: { id: 1 })
# SQL (1.9ms)  SELECT DISTINCT `users`.`id` FROM `users` LEFT OUTER JOIN `posts` ON `posts`.`user_id` = `users`.`id` WHERE `posts`.`id` = 1 LIMIT 11
# SQL (3.0ms)  SELECT `users`.`id` AS t0_r0, `users`.`name` AS t0_r1, `users`.`created_at` AS t0_r2, `users`.`updated_at` AS t0_r3, `posts`.`id` AS t1_r0, `posts`.`content` AS t1_r1, `posts`.`user_id` AS t1_r2, `posts`.`created_at` AS t1_r3, `posts`.`updated_at` AS t1_r4 FROM `users` LEFT OUTER JOIN `posts` ON `posts`.`user_id` = `users`.`id` WHERE `posts`.`id` = 1 AND `users`.`id` = 28

Post.includes(:user)
# Post Load (2.6ms)  SELECT `posts`.* FROM `posts` LIMIT 11
# User Load (1.8ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` IN (28, 22, 40, 5, 41, 1, 23, 11, 34, 47, 15)

Post.includes(:user).where(users: { name: 'user1' })
# SQL (2.6ms)  SELECT `posts`.`id` AS t0_r0, `posts`.`content` AS t0_r1, `posts`.`user_id` AS t0_r2, `posts`.`created_at` AS t0_r3, `posts`.`updated_at` AS t0_r4, `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` WHERE `users`.`name` = 'user1' LIMIT 11
```
デフォルトで `preload`として機能し、`where`など絞り込みがある場合、`eager_load`と同じ挙動をします。
複数アソシエーションを指定した場合にはアソシエーションごとに別々の挙動を取ることはなく、必ず全てのアソシエーションが `preload`されるか`eager_load`されるかの挙動になります。

## まとめ
| メソッド | キャッシュ | クエリ | アソシエーション先のデータ参照 |
| ---- | ---- | ---- | ---- |
| joins | しない | INNER JOIN | できる |
| eager_load | する | LEFT OURTER JOIN | できない |
| preload | する | それぞれSELECT | できる |
| inclides| する | 場合による | できる |

基本的に`include`はクエリが制御しづらいのであまり使わないようにして、1対1 (belong_to), N対1 (has_one) アソシエーションについてはLEFT JOINでまとめて取得したほうが効率的だと思うので`eager_load`を使い、has_many (1対N) の場合のアソシエーションに関しては`preload`を使用するとのが良さそうです。

## 参考にした文献
[Active Record Query Interface — Ruby on Rails Guides](https://guides.rubyonrails.org/active_record_querying.html#joining-tables)
[Active Record Query Interface — Ruby on Rails Guides](https://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations)
[ActiveRecordのjoinsとpreloadとincludesとeager_loadの違い - Qiita](https://qiita.com/k0kubun/items/80c5a5494f53bb88dc58)
[Improving Database performance and overcoming common N+1 issues in Active Record using includes, preload, eager_load, pluck, select, exists? | Saeloun Blog] (https://blog.saeloun.com/2020/01/08/activerecord-database-performance-n-1-includes-preload-eager-load-pluck)
[ORM の eager loading と lazy loadingについて｜withnicのWebエンジニアな日々](https://blog.withnic.net/2015/08/orm-%E3%81%AE-eager-loading-%E3%81%A8-lazy-loading%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6/)

記事を書いてくださった方々に感謝です。
あくまで様々な記事の情報を手を動かしながらまとめただけなので、さらに理解が深めて追加・修正を入れたいと思います。