RailsのN+1問題入門|includes / preload / eager_load の使い分けを1枚で解説

N+1問題の仕組みを示した図。対策なしはpostsの1クエリのあとuserを1件ずつ20回取りに行き合計21クエリ。includesは事前にまとめて読み込み2クエリで済む。
  • このエントリーをはてなブックマークに追加

スポンサーリンク

Railsで作った一覧ページ、記事数が増えてきたら急に表示が重くなった——そんな経験はありませんか? ログ(log/development.log)を眺めると、ほとんど同じ SELECT が何十行も並んでいる。これがいわゆるN+1問題です。

N+1の直し方を調べると includes という単語にたどり着きますが、そこで今度は「preload や eager_load とは何が違うの?」「結局どれを使えばいいの?」と迷子になりがちです。この記事では、3つのメソッドの挙動の違いと使い分けを、比較表とコード例で1枚に整理します。読み終わる頃には、遅い一覧ページを自信を持ってチューニングできるようになります。

まず結論:迷ったら preload、絞り込むなら eager_load

細かい仕組みは後で説明しますが、先に使い分けの結論だけ渡しておきます。

やりたいこと使うメソッド
関連先を「表示するだけ」(一覧に著者名を出す等)preload(別クエリで安全)
関連先の値で絞り込む・並べ替える(公開済みユーザーの投稿だけ等)eager_load(JOINで1クエリ)
どちらか迷う/Railsに任せたいincludes(文脈で自動切替)

ひとことで言えば、「表示するだけなら preload、関連先の条件で絞るなら eager_load」です。includes は賢く自動で切り替えてくれますが、どちらに化けるかが常に自明とは限りません。意図をコードに残したいときは preload / eager_load を直接使うのがおすすめです。

なぜこの使い分けになるのか、まずはN+1問題の正体から見ていきましょう。

N+1問題とは何か(なぜ一覧が遅くなるのか)

N+1問題とは、1回のクエリで一覧を取ってきたあと、その各行の関連データを取るために「行の数(N回)」だけ追加でクエリが飛んでしまう状態のことです。合計で「1 + N」回になるので、N+1問題と呼ばれます。

具体例を見てみましょう。投稿(Post)が著者(User)に属している(belongs_to :user)、Railsのブログ機能などでよく出てくる関連(アソシエーション)です。

# app/models/post.rb
class Post < ApplicationRecord
  belongs_to :user
end

# コントローラ
@posts = Post.all   # ← ここで1回クエリ(posts全件)

# ビュー(例: index.html.erb)
<% @posts.each do |post| %>
  <%= post.user.name %>   # ← 1件ごとに user を取りに行く(N回)
<% end %>

この状態で投稿が20件あると、ログにはこんなSQLが並びます。posts用の1回に加えて、users用のクエリが20回。まさに「1 + 20」です。

Post Load    SELECT * FROM posts
User Load    SELECT * FROM users WHERE id = 1
User Load    SELECT * FROM users WHERE id = 2
User Load    SELECT * FROM users WHERE id = 3
...(あと17行、同じSELECTが続く)
N+1問題の仕組みを示した図。左は対策なしでpostsの1クエリのあとuserを1件ずつ20回取りに行き合計21クエリ。右はincludesで事前にまとめて読み込み2クエリで済む様子。
対策なしは「1 + N」回。事前にまとめて読み込めば少ないクエリで済む

データが少ない開発初期は気づきにくいのが厄介なところです。本番でデータが育ってから急に遅くなる。だからこそ、まずはログを見て「同じSELECTが並んでいないか」を確認する習慣が第一歩になります。ログの読み方に不安がある方は、エンジニアの調べ方の記事もあわせてどうぞ。

解決策:関連を事前に読み込む(Eager Loading)

N+1の解決策はシンプルで、「1件ずつ取りに行く」のをやめて、関連データを最初にまとめて読み込むだけです。これをEager Loading(事前読み込み)と呼び、Railsには preload / eager_load / includes の3つのやり方があります。

まずは一番シンプルな includes で先ほどのコードを直してみます。

# Before: N+1が発生
@posts = Post.all

# After: user を事前読み込み
@posts = Post.includes(:user)

これだけで、クエリは「posts用の1回」と「users用の1回」の合計2回に減ります。20件あっても、200件あってもクエリ数は変わりません。ビュー側(post.user.name)は一切変更不要です。

では、なぜ同じEager Loadingに3つもメソッドがあるのか。ここからが本題です。

3つのメソッドの挙動を1枚で

3つの違いは「発行されるSQLの形」に集約されます。それぞれが投げるクエリを比べると、性格がはっきり分かれます。

メソッドSQLの形クエリ回数関連先で絞り込み
preload別クエリ(IN句)2回できない
eager_loadLEFT OUTER JOIN1回できる
includes基本はpreload、関連先条件を検知するとeager_loadに切替2回 or 1回できる(references併用)
preloadとeager_loadの発行SQLを比較した図。preloadは2本の別クエリ(IN句)でメモリに優しい、eager_loadは1本のLEFT OUTER JOINで関連先の条件で絞り込める。includesは文脈で両者を自動で切り替える。
preloadは別クエリ、eager_loadはJOIN。includesはその2つを文脈で自動選択する

preload:別クエリで安全に読み込む

preload は、親と関連先を別々のクエリで取得します。JOINしないぶん、行が重複したりJOINで結果が膨らんだりする事故が起きにくく、「表示するだけ」の用途では一番安全です。

Post.preload(:user)
# SELECT * FROM posts
# SELECT * FROM users WHERE id IN (1, 2, 3, ...)

1本目で取れた user_id をまとめて IN 句に渡し、2本目で必要なユーザーを一括取得する仕組みです。注意点は、別テーブルなので where で関連先を絞り込めないこと。次のコードはエラーになります。

# NG: preload では users テーブルの条件を書けない
Post.preload(:user).where(users: { active: true })
# → ActiveRecord::StatementInvalid(users を JOIN していないため)

eager_load:JOINで1クエリにまとめる

eager_load は、LEFT OUTER JOINで親と関連先を1本のクエリにまとめます。JOINしているので、関連先テーブルの列で絞り込んだり並べ替えたりできるのが最大のメリットです。

# 「アクティブな著者の投稿だけ」を1クエリで取得
Post.eager_load(:user).where(users: { active: true })
# SELECT posts.*, users.* FROM posts
#   LEFT OUTER JOIN users ON users.id = posts.user_id
#   WHERE users.active = true

「関連先の状態で一覧を絞りたい」場面ではこれが正解です。ただしJOINの結果、返る行数が多くなると重くなることがあるので、なんでもJOINにすればよいわけではありません。

includes:文脈で自動的に切り替える

includes は、基本は preload(別クエリ)として動きますが、クエリの中で関連先テーブルを参照していることを検知すると、自動で eager_load(JOIN)に切り替わります。「普段は安全に別クエリ、絞り込みが要るときだけJOIN」を自動でやってくれる便利屋です。

ただし、この自動判定には落とし穴があります。where で関連先を絞るときは、references でそのテーブルを使うことを明示しないとエラーになるのです。

# NG: includes だけでは users を JOIN してくれない
Post.includes(:user).where(users: { active: true })
# → ActiveRecord::StatementInvalid

# OK: references で「users を参照する」と明示 → eager_load 相当に
Post.includes(:user).references(:users).where(users: { active: true })

この「references 忘れ」がN+1対策で一番ハマりやすいポイントです。だったら最初から意図が明確な eager_load を書けばよい、という考え方も十分アリです。

N+1を「見つける」ツール

使い分けが分かっても、そもそもN+1がどこで起きているかを見つけられないと直せません。目視でログを追うほかに、開発を助けてくれる仕組みが2つあります。

bullet:N+1を自動で警告する

bullet は、N+1が発生している箇所を開発中に自動で検知して警告してくれる定番gemです。Gemfileに追加して開発環境で有効化します。

# Gemfile
gem "bullet", group: :development

# config/environments/development.rb
config.after_initialize do
  Bullet.enable = true
  Bullet.alert = true   # ブラウザにポップアップで通知
  Bullet.rails_logger = true
end

これで、N+1が起きている画面を開くと「ここに includes(:user) を足すといいよ」と具体的な提案まで出してくれます。逆に、使っていないeager loadingも指摘してくれるので、過剰な事前読み込みの削減にも使えます。

strict_loading:N+1を「起こさせない」

もう一歩進んだ方法が、Rails 6.1以降で使える strict_loading です。これは検知ではなく予防で、事前読み込みしていない関連に触ろうとした瞬間にエラーを発生させ、N+1のコードを本番に出す前に止めます。

# モデル単位で常に厳格にする
class Post < ApplicationRecord
  self.strict_loading_by_default = true
end

# クエリ単位で有効化することも可能
Post.strict_loading.each { |post| post.user }
# → ActiveRecord::StrictLoadingViolationError(preload/includes 忘れを検知)

本番でいきなりエラーにするのが怖ければ、例外ではなくログ出力に切り替える設定もあります。Rails 8での計測では1万件でもオーバーヘッドは1ミリ秒未満と報告されており、導入コストはほとんどありません。

# config/environments/production.rb
# 例外を投げる代わりにログに記録する
config.active_record.action_on_strict_loading_violation = :log

「bulletで見つけて直し、strict_loadingで再発を防ぐ」——この2段構えにすると、N+1をかなり仕組みで抑え込めます。

つまずきポイント逆引き

実務でN+1対策をしていると踏みやすい3つを、逆引きでまとめます。

症状原因と対処
includes したのに StatementInvalid エラー関連先を where で絞っているのに references が無い。references(:users) を足すか eager_load にする
JOINしたら件数(count)がおかしいeager_load / JOINで行が重複している。distinct を付けるか、絞り込みが不要なら preload に変える
preload に関連先の条件を付けたら効かない/エラーpreload は別クエリなので関連先で絞れない。絞り込むなら eager_load(+references)を使う

共通するのは、「絞り込み(関連先の where)が絡むかどうか」で preload と eager_load を選ぶという一点です。ここさえ押さえれば、3つのメソッドで迷うことはなくなります。

まとめ:使い分けの3行

RailsのN+1問題と、includes / preload / eager_load の使い分けを整理しました。最後に判断軸を3行で。

  • 関連先を表示するだけ → preload(別クエリ・事故が少ない)
  • 関連先の値で絞る・並べ替える → eager_load(JOIN・referencesは不要)
  • 迷う・Railsに任せたい → includes(絞るなら references を忘れずに)

そして、直す前に「見つける」こと。bullet で検知し、strict_loading で再発を防ぐ仕組みを入れておくと、一覧ページの速度をチームで守りやすくなります。

一覧に関連データを出す場面は、ActiveStorageでアバター機能を実装する記事のように実務でも頻繁に登場します。そうした画面を作ったら、今回の使い分けでN+1が出ていないか一度チェックしてみてください。DB設計や接続まわりが気になる方は、Rails 8のdatabase.ymlの記事もあわせてどうぞ。

スポンサーリンク

  • このエントリーをはてなブックマークに追加