RailsのN+1対策ガイド

Railsでコードを書く人のために、N+1対策で重要な知識と考え方を簡単にまとめました。
[bullet](https://github.com/flyerhzm/bullet)を入れいていたのにスロークエリで障害発生、ということにならないように、知っておくべき知識かと思います。
内容に関してご意見やご指摘がありましたらコメントいただければ幸いです。

## 0. 目次

個人的に重要だと思う2点を挙げます。

[1. eager_load, preload を使い分ける](https://qiita.com/yokoto/items/983c1a63424915dcc34a#1-eager_load-preload-%E3%82%92%E4%BD%BF%E3%81%84%E5%88%86%E3%81%91%E3%82%8B)
[2. 計算ロジックによるN+1の発生](https://qiita.com/yokoto/items/983c1a63424915dcc34a#2-%E8%A8%88%E7%AE%97%E3%83%AD%E3%82%B8%E3%83%83%E3%82%AF%E3%81%AB%E3%82%88%E3%82%8Bn1%E3%81%AE%E7%99%BA%E7%94%9F)

## 1. eager_load, preload を使い分ける

### 1-1. includes は使わないの？

`includes` は `eager_load` と `preload` のどちらかを使用した挙動にしかならないため、基本的に使用しない。

https://qiita.com/k0kubun/items/80c5a5494f53bb88dc58#includes

### 1-2. eager_load と preload の共通点

複数のテーブルからデータを取得したいとき、
発行されるクエリをキャッシュすることで、
同じクエリが余分に発行（N+1）されることを防ぐために使用する。

### 1-3. eager_load と preload の違い

* `eager_load` → LEFT JOIN でキャッシュする。
    * イメージ例)
        * `SELECT * FROM users LEFT JOIN posts ON users.id = posts.user_id;`

* `preload` → 別クエリで IN 句を使用してキャッシュする。
    * イメージ例)
        * `SELECT * FROM users;`
        * `SELECT * FROM posts WHERE posts.user_id IN (1, 2, 3, 4, 5...);`

### 1-4. 使い分けの方針

以下のMFさんのテックブログでは、以下のように説明されている。

https://moneyforward.com/engineers_blog/2019/04/02/activerecord-includes-preload-eagerload/

* `has_one` , `belongs_to` のとき → `eager_load` を使う。
    * 1対1あるいはN対1関連なのでSQLを分割して取得するより、left joinでまとめて取得。
* `has_many` のとき → `preload` を使う。
    * `eager_load` するとスロークエリを踏みやすいため。

#### 解説

例として、
`users` のレコードが10件
`posts` のレコードが1000件
`reviews` のレコードが1000件
`teams` のレコードが1000件
である場合を考えてみる。

* `User.eager_load(posts: :reviews, :team)` とした場合、  10 * 1000 * 1000 + 10 * 1000 で 10,010,000 件のレコードを取得することになり、スロークエリになってしまう（要確認）。
* `User.preload(posts: :reviews).eager_load(:team)` としておけば大丈夫。

個人的には、`where` で関連先のテーブルを絞り込む必要があるケースなどでなければ、基本的に `preload` を使用した方が地雷を踏まないイメージ。（SmartHR Rails顧問の方の認識も同様の模様↓）

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

## 2. 計算ロジックによるN+1の発生

### 2-1. 発生しやすいケース

何かしらの一覧画面で、複数のテーブルの情報を表示したい場合がある。

```erb
<!-- app/views/users/index.html.erb -->
<table>
  <thead>
    <tr>
      <th>id</th>
      <th>ユーザー名</th>
      <th>メールアドレス</th>
      <th>所属チーム名</th>
      <th>投稿数</th>
      <th>被レビュー数</th>
    </tr>
  </thead>
  <tbody>
    <% @users.each do |user| %>
      <tr>
        <td><%= user.id %></td>
        <td><%= user.name %></td>
        <td><%= user.email %></td>
        <td><%= user.team.name %></td>
        <td><%= @post_counts[user.id] %></td>
        <td><%= @reviewed_count[user.id] %></td>
      </tr>
  </tbody>
</table>
```

複数のテーブルからデータを取得するロジックで、N+1が発生しがち。

### 2-2. データの取得方法

悪い例(N+1)と良い例を示しました。

```rb
# app/controllers/users_controller.rb
class UsersController < ApplicationController
  def index
    @users = User.preload(posts: :reviews).eager_load(:team).all

    # bad
    @post_counts =
      @users.each_with_object({}) { |user, result| result[user.id] = user.posts.count }
    @reviewed_count =
      @users.each_with_object({}) do |user, result|
        result[user.id] = user.posts.inject{ |res, post| res + post.reviews.count }
      end

    # good
    @post_counts = Post.group(:user_id).count
    @reviewed_counts = Review.joins(:post).group('posts.user_id').count
  end
end
```

`bad` の例では、**計算過程で何度もクエリが発行される**ため、N+1になってしまいます。
`good` の例では、**1つのクエリでデータを取得している**ため、効率の良いデータの取り方になっています。
（※ 余談ですが、例えば`@post_counts` をこの例のように `Hash` として定義するのではなく、 `User` の属性(attribute)として定義し、 `User#post_counts` のように呼べるようにしてあげると、コードとしてはオブジェクト指向っぽくなるかもしれませんがお好みです。）

ロジックの実装でも、できるだけクエリの発行数を抑えてコードを書くことを意識するのが重要かと思います。

以下類似ケースについて対処法を書かれている方がおり、大変参考になるためリンクします。

[【Rails】index_byとgroup_byを用いて取り回しのきくハッシュを作成する](https://blog.aiandrox.com/posts/tech/2021/03/31/)
[【Rails】countのN+1問題を解消する](https://blog.aiandrox.com/posts/tech/2021/12/03/)

## 3. おまけ: ActiveRecordのデータ処理について

N+1以外の観点を含めたパフォーマンス改善について、もっと知りたい方はこちらのスライドがおすすめです。

https://speakerdeck.com/toshimaru/active-record-anti-patterns