Railsでコードを書く人のために、N+1対策で重要な知識と考え方を簡単にまとめました。
bulletを入れいていたのにスロークエリで障害発生、ということにならないように、知っておくべき知識かと思います。
内容に関してご意見やご指摘がありましたらコメントいただければ幸いです。
0. 目次
個人的に重要だと思う2点を挙げます。
1. eager_load, preload を使い分ける
2. 計算ロジックによるN+1の発生
1. eager_load, preload を使い分ける
1-1. includes は使わないの?
includes
は eager_load
と preload
のどちらかを使用した挙動にしかならないため、基本的に使用しない。
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さんのテックブログでは、以下のように説明されている。
-
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顧問の方の認識も同様の模様↓)
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つのクエリでデータを取得しているため、効率の良いデータの取り方になっています。
(※ 余談ですが、例えば@post_counts
をこの例のように Hash
として定義するのではなく、 User
の属性(attribute)として定義し、 User#post_counts
のように呼べるようにしてあげると、コードとしてはオブジェクト指向っぽくなるかもしれませんがお好みです。)
ロジックの実装でも、できるだけクエリの発行数を抑えてコードを書くことを意識するのが重要かと思います。
以下類似ケースについて対処法を書かれている方がおり、大変参考になるためリンクします。
【Rails】index_byとgroup_byを用いて取り回しのきくハッシュを作成する
【Rails】countのN+1問題を解消する
3. おまけ: ActiveRecordのデータ処理について
N+1以外の観点を含めたパフォーマンス改善について、もっと知りたい方はこちらのスライドがおすすめです。