【Rails】 N+1問題の解消 & tips

[Atrae Advent Calendar 2019](https://qiita.com/advent-calendar/2019/atrae) 5日目を担当する新卒1年目の土屋です。
普段は、ビジネス版マッチングアプリ [yenta](https://yenta.talentbase.io/yenta) でサーバサイドエンジニアとしてRailsで開発をしています。

アトラエに入社してから半年間、度々立ち向かってきたN+1問題について書きます。
対象読者は、Ruby on Railsを使って開発をしている初級者〜中級者です、ご容赦ください。

# N+1問題とは
> ループ処理(each, mapなど)を用いてデータを取得してくる際に、
> 必要以上にSQL文(クエリ)が発行され、レスポンスが遅くなる(パフォーマンスが低下する)

ことです。例えば、
AさんというUserのPosts(投稿)を5件取得して、Aさんの投稿一覧のページを表示したいといった時には、

- AさんのUserデータを取得するために1回
- 5件のPostsデータを取得するために5回

の合計6回のクエリを発行し、表示したいデータを取得することになります。

5件だったら大した問題にはなりませんが、
これが10000件だったら大変です。
仮に1回のクエリで0.001秒しか時間がかからなかったとしても、
10001回もクエリを叩いたら、10秒もかかります。
普段使うアプリやWebサービスでそんなにローディングで待たされたことがあるでしょうか。

このように、ループ処理によって、
**N件**のデータを取得したい時に、**N+1回**もクエリを発行してしまうことによって発生するパフォーマンス低下を、**N+1問題**といいます。

この問題はコードの書き方次第で解消でき、
適切に書けば、仮に10000件のPostsであっても、2回のクエリで取得できます。
（あんまり大きいテーブルをjoinしたくないとかindex張ろとかそういう話は今回はなしで、、！）

# とりあえずpreloadかeager_load書こ
結論としては、ループ処理の前に `preload` か `eager_load` を書けばほぼ解決します。

無思考でも、この2つのどちらかを書いておけばひとまず解決することが多いです。
また、具体的にどう書いてなぜ解決されるのか？などは、ググるとたくさんの素晴らしい記事が出てきますので、そちらを見て頂ければいいかなと思いますw

参考：
[【Ruby on Rails】N+1問題ってなんだ？](https://qiita.com/massaaaaan/items/4eb770f20e636f7a1361)
[preloadとeager_loadで1000000億倍早くなったはなし](https://texta.pixta.jp/entry/2016/02/10/180244)

# けどincludesはやめとこ
「rails N+1問題」などでググると、
上述の`preload`や`eager_load`以外に、`includes`を用いた解決法もいくつか出てくるかと思います。
が、`includes`を用いるのは個人的にはあまりお勧めしません。
理由は、`includes`を用いると、Railsがよしなにやろうとしすぎて、自分が予期していない挙動になる可能性があるのと、
preloadとeager_loadの違いは明確に理解して使い分けた方が良いと思うためです。
が、詳しくは下記の素晴らしい参考記事達に譲りますw

参考：
[ActiveRecordのjoinsとpreloadとincludesとeager_loadの違い](https://qiita.com/k0kubun/items/80c5a5494f53bb88dc58)
[ActiveRecordのincludes, preload, eager_load の個人的な使い分け](https://moneyforward.com/engineers_blog/2019/04/02/activerecord-includes-preload-eagerload/)
[[Rails] そのincludesはpreloading？それともeager loading？](https://qiita.com/nagata03/items/b9a7ec6c218445d12c7d)

また、上記の `preload`, `eager_load`, `includes`, `joins` などの違いを考えるにあたって、
テーブル同士の内部結合、外部結合周りが怪しいと理解しづらいので、怪しい方は先にこっちから整理すると良いと思います。
（僕はそもそもこっちが怪しかったので、最初全然ピンとこなかったです。）

参考：
[SQL素人でも分かるテーブル結合(inner joinとouter join)](https://qiita.com/naoki_mochizuki/items/3fda1ad6594c11d7b43c)
[INNER JOINとOUTER JOINとは?](https://qiita.com/n_hirai/items/dd5797c0681cb5914c2c)

# tips
上記の通り、基本的にはググればわかりやすい記事がたくさんあるのですが、
その中でも僕が実際にN+1問題と戦った時に、
「知りたいけどあんまり出てこなかった」「先輩のコードを見て / 直接聞いて知った」ことを、少し書きます。


### 孫以下の要素の(多段)joinの仕方
UserのPostについたCommentのデータをpreloadしたい時、
CommentはUserの孫要素にあたりますが、以下のように書きます。

```
User.preload(posts: :comments).each.{~~
```
UserのPostについたCommentについたFavoriteのデータをpreloadしたい時、
FavoriteはUserの曽孫要素にあたりますが、以下のように書きます。

```
User.all.preload(posts: [comments: :favorites]).each.{~~
```
その次や次の次は、、

```
User.all.preload(posts: [comments: [favorites: :hoge]]).each.{~~
User.all.preload(posts: [comments: [favorites: [hoge: :fuga]]]).each.{~~
```
のようにどんどんネストして行くように書きます。

### 複数かつ多段のjoinの仕方
前節とほぼ同じですが、地味に書き方迷ったので。
Postの子として、CommentとFavoriteがある場合が以下です。

```
User.all.preload(posts: [comments, favorites]).each.{~~
```

### eager_load多すぎたらeager_loadだけまとめてscopeにしちゃう
の方がスッキリすると思います。

```
scope :eager_load_for_hogehoge, -> {
      eager_load(hoge: [:fuga, piyo: [abc: :def]]).merge(User.where(id: 111))
    }
```

### チェーンで書かないと、せっかくeager_loadしても意味ない
「完璧にeager_loadingしたはずなのになぜかクエリが繰り返される、、」という時は、
色々とメソッドを介した結果、せっかくeager_loadingしたのに、
また改めてモデルを呼んでる場合があります。


# おまけ
先日、検索機能を作っている時に、納期に焦って、このN+1問題の確認と解消をサボって雑に進めたら、
検索した際のクエリが重すぎて見事にstagingのDBが落ちました。
これが本番だったらと思うと、ぞっとします。
自分が発行するクエリには責任を持って開発していきたいですね。

また、Railsは全くの未経験で入社して8ヶ月程経ちましたが、流石に慣れてきたと同時に、
サーバサイドはデータを司る神になった気分()になれるので、好きになってきました。

次回は、同じく1年目の小倉です。よろしく！