ActiveRecord N+1問題の解決:includes・preload・eager_loadまとめ
N+1問題とは
関連を持つレコードをループで取得するとき、1回の一覧取得 + N件分の関連取得 = N+1回のSQLが発生する問題です。
posts = Post.all # SELECT * FROM posts (1回) posts.each do |post| puts post.user.name # SELECT * FROM users WHERE id = ? (Nレコード分) end
投稿が100件あれば101回のSQLが走ります。
includes:通常はこれを使う
includes はRailsが状況に応じてSELECT INかLEFT OUTER JOINを自動選択します。
posts = Post.includes(:user) posts.each do |post| puts post.user.name # 追加クエリなし end
-- 発行されるSQL(SELECT IN方式) SELECT * FROM posts; SELECT * FROM users WHERE id IN (1, 2, 3, ...);
関連テーブルで絞り込む場合はJOINに切り替わる
Post.includes(:comments).where(comments: { approved: true })
-- JOINに自動切り替え SELECT posts.*, comments.* FROM posts LEFT OUTER JOIN comments ON comments.post_id = posts.id WHERE comments.approved = true;
ネストした関連もまとめてロード
Post.includes(comments: :user) # コメントとそのユーザーを一括ロード Post.includes(:user, :tags) # 複数の関連を同時に指定
preload:常にSELECT IN(2クエリ)
preload は常にSELECT IN方式で関連をロードします。includes と異なり、関連テーブルへの条件指定があってもJOINに切り替わりません。
posts = Post.preload(:user)
SELECT * FROM posts; SELECT * FROM users WHERE id IN (1, 2, 3, ...);
関連テーブルに where を使いたい場合は preload では動作しないため、eager_load を使います。
eager_load:常にLEFT OUTER JOIN(1クエリ)
eager_load は常にLEFT OUTER JOINで1クエリにまとめます。関連テーブルの条件で絞り込む場合に使います。
Post.eager_load(:comments).where(comments: { approved: true })
SELECT posts.*, comments.* FROM posts LEFT OUTER JOIN comments ON comments.post_id = posts.id WHERE comments.approved = true;
データ件数が多いとSELECT INより遅くなる場合があります。
3つの比較
| メソッド | クエリ方式 | 関連テーブルへの条件 | 使いどころ |
|---|---|---|---|
includes |
SELECT IN / JOIN(自動選択) | 使えるがJOINに切り替わる | 通常はこれ |
preload |
常にSELECT IN | 使えない | 必ずSELECT INにしたい場合 |
eager_load |
常にLEFT OUTER JOIN | 使える | 関連テーブルで絞り込む場合 |
joins との違い
joins はSQLのJOINを発行しますが、関連データをロードしません。関連テーブルで絞り込みたいだけで、関連データは使わない場合に使います。
# JOINして絞り込むが、post.commentsはロードされない(N+1は解決しない) Post.joins(:comments).where(comments: { approved: true })
bullet gem でN+1を検出する
開発環境でN+1を自動検出するには bullet gemを使います。
# Gemfile gem 'bullet', group: :development
# config/environments/development.rb config.after_initialize do Bullet.enable = true Bullet.rails_logger = true Bullet.alert = true end
N+1が発生しているとログに警告が出るため、開発中に気づけます。
まとめ
| 状況 | 使うメソッド |
|---|---|
| 通常の関連ロード | includes |
| 関連テーブルで絞り込む | eager_load |
| 必ずSELECT INにしたい | preload |
| 絞り込みのみ(関連データは不要) | joins |
includesは状況に応じてSELECT INとJOINを自動選択するため、通常はこれだけ覚えておけば問題ないwhereで関連テーブルの列を参照するとincludesは自動的にJOIN方式(eager_load相当)に切り替わる- 発行されるSQLはRailsのログや「MySQL EXPLAINの読み方:実行計画でSQLのボトルネックを特定する」で確認できる