📚

RailsのN+1対策ガイド

2021/07/17に公開

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

0. 目次

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

1. eager_load, preload を使い分ける
2. 計算ロジックによるN+1の発生

1. eager_load, preload を使い分ける

1-1. includes は使わないの?

includeseager_loadpreload のどちらかを使用した挙動にしかならないため、基本的に使用しない。

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. 発生しやすいケース

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

<!-- 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)と良い例を示しました。

# 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つのクエリでデータを取得しているため、効率の良いデータの取り方になっています。

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

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

【Rails】index_byとgroup_byを用いて取り回しのきくハッシュを作成する
【Rails】countのN+1問題を解消する

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

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

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

Discussion