📚

RailsのN+1対策ガイド

3 min read

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 でキャッシュする。

  • preload → 別クエリでキャッシュする。

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) としておけば大丈夫。

個人的には、基本的には eager_load
関連先のレコード数が多く、スロークエリが発生する可能性がある場合には preload を使用するのが良いと思う。

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

bad の例では、計算過程で何度もクエリが発行されるため、N+1になってしまいます。
good の例では、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

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

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

もっと知りたい方はこちらのスライドがおすすめです。

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