N+1 クエリ問題を理解する
Rails、Django、Hibernate、LaravelでのN+1クエリ問題を解説し、eager loading、JOIN、検出方法まで整理します。
N+1 クエリ問題とは、アプリケーションが N 件のレコードのリストを取得するためにクエリを 1 回実行し、その後、関連エンティティを読み込むためにレコードごとに追加のクエリを 1 回ずつ実行してしまう状況を指します。つまり、1〜2 回で済むはずのところが N+1 回のクエリになってしまうのです。
多くの開発者は同じ経緯でこの問題に出会います。シードデータ入りの開発用データベースでは一瞬で表示されていたページが、実データを相手にすると極端に遅くなり、クエリログを見ると同じ SELECT が id だけを変えて 400 回繰り返されている、というものです。
これはオブジェクト関係マッパー(ORM)を使うコードで最も一般的なパフォーマンスバグであり、Rails、Django、Hibernate、Laravel のいずれでも同じ形で現れます。それらがすべて同じデフォルト動作、すなわち遅延ロード(lazy loading)を共有しているからです。本記事では、このパターンを定義し、4 つのスタックでの実例を示し、各フレームワークにおける正確な修正方法を整理し、eager fetching に関する根強い誤解を正し、さらに N+1 を本番環境に到達させる前に検出する方法を解説します。
重要なポイント
- N+1 クエリ問題とは、N 件の親行を読み込むクエリ 1 回に加えて、それぞれの関連レコードを読み込む後続クエリが N 回発生することです。クエリ数は N に対して線形に増加します。
- 大半の ORM がデフォルトでアソシエーションを遅延ロードするために発生します。ループ内でリレーションにアクセスすると、反復のたびに黙ってクエリが発行されます。
- 正しい修正方法は 2 つあり、いずれも N+1 を一定のクエリ数に収束させます。親と子をまとめて読み込む単一の JOIN か、
WHERE id IN (...)を使う 2 回目のバッチクエリです。 - JPA で
FetchType.EAGERを設定しても、JPQL 下では N+1 は解決しません。追加クエリが発行される タイミング が変わるだけで、バッチ化されるわけではありません。 - 開発環境のクエリログでは、たまたま実行したコードパス上の N+1 しか捕捉できません。ローカルのデータセットが小さすぎて表面化しなかったものは、本番環境の APM が捕捉します。
N+1 クエリ問題とは何か
posts/authors のリレーションシップを例に考えてみましょう。まず 1 回のクエリで全 posts を読み込み、それをループして各要素の post.author.name を読み取ります。このプロパティアクセスが 2 つ目のクエリとなり、post ごとに繰り返されます。post が 10 件なら 11 回のクエリ、1000 件なら 1001 回のクエリとなり、合計レスポンスタイムはレコード数に比例して増加します。
この線形増加こそが N+1 を危険にしています。N+1 バグは、シードされた 5 行を相手にする開発環境ではたいてい見えず、5000 行を相手にする本番環境で障害となって現れます。ノート PC 上では 40 ms で返っていたエンドポイントが、実データが入った途端に 4 秒かかる、あるいはタイムアウトするようになります。
N+1 はなぜ起こるのか
Discover how at OpenReplay.com.
N+1 が起こるのは、大半の ORM がデフォルトでアソシエーションを遅延ロードするからです。関連オブジェクトは親を読み込んだ時点ではなく、最初にアクセスされた時点で取得されます。Eloquent relationships はまさにこの挙動をします。プロパティとして読み取ると、親モデルの読み込み時ではなくアクセスの瞬間にクエリが発行され、eager loading はオプトインの代替手段という位置づけです。ActiveRecord のプロキシ、Django の related manager、Hibernate の遅延プロキシについても同様です。
ループの内側では、この遅延アクセスは静かに、反復ごとに発生します。ソースコード上にそれを示す手がかりはありません。N+1 というキーワードもなければ、警告も出ません。だからこそコードレビューをすり抜け、負荷がかかって初めて表面化するのです。
コード上ではどう見えるか
修正前後の形はどのスタックでも同じです。リレーションに触れるループを、そのリレーションを事前に読み込むように書き換えるだけです。
Rails (ActiveRecord):
# N+1: 1 query for books + 1 per book for the author
Book.limit(10).each { |book| puts book.author.last_name }
# Fixed: 2 queries total
Book.includes(:author).limit(10).each { |book| puts book.author.last_name }
Django ORM:
# N+1: 1 query for books + 1 per book for the author
for book in Book.objects.all():
print(book.title, book.author.name)
# Fixed: one JOIN
for book in Book.objects.select_related("author"):
print(book.title, book.author.name)
JPA / Hibernate (JPQL):
// N+1: findAll() loads transports, then one SELECT per driver on access
List<Transport> all = transportRepository.findAll();
// Fixed: a single fetch join
@Query("SELECT t FROM Transport t JOIN FETCH t.driver")
List<Transport> findAllWithDriver();
生の SQL: 行ごとのルックアップを 1 回の LEFT JOIN に置き換えます。子を持たない親も残すため LEFT を使います。
SELECT c.id, c.name, i.id AS item_id, i.name AS item_name
FROM categories c
LEFT JOIN items i ON i.category_id = c.id
ORDER BY c.name, i.name;
N+1 クエリ問題の修正方法
N+1 を修正する正しい方法は 2 つあり、いずれもクエリ数を一定に収束させます。親と子をまとめて読み込む単一の JOIN か、WHERE id IN (...) 1 回ですべての関連行を取得する 2 回目のバッチクエリです。JOIN はラウンドトリップが 1 回で済みますが、親行が重複する可能性があり(複数のコレクションにまたがるとデカルト積で爆発します)、バッチクエリはラウンドトリップが 2 回になりますが重複データは返しません。どのフレームワークも、この両方の戦略を異なる名前で提供しています。
| フレームワーク | JOIN 戦略(1 クエリ) | バッチクエリ戦略(WHERE id IN) |
|---|---|---|
| Rails | eager_load(:assoc) | preload(:assoc) |
| Rails(自動) | includes(:assoc)(Rails がいずれかを選択) | includes(:assoc) |
| Django | select_related("assoc") | prefetch_related("assoc") |
| JPA/Hibernate | JOIN FETCH / @EntityGraph / QueryDSL の fetchJoin() | バッチフェッチ(@BatchSize) |
| Laravel | — | with('assoc') |
| 生の SQL | LEFT JOIN | 2 回目の SELECT ... WHERE fk IN (...) |
Django の 2 つのメソッドは最も混同されやすいため、どちらが何をするのかを正確に押さえておく価値があります。Django QuerySet API リファレンス はリレーションシップの多重度で線を引いています。select_related は JOIN を構築し、同一ステートメントで関連行を取得します。これは親が関連レコードを高々 1 件しか持たない場合にのみ機能するため、ForeignKey と OneToOneField が対象です。prefetch_related はリレーションシップごとに独自のクエリを発行し、結果を Python 側で結合します。これにより ManyToManyField や逆方向の外部キーを扱えます。
Rails は同じ区別を 3 つのメソッドに分けています。Active Record クエリインターフェイスガイド では、preload は名前付きアソシエーションごとに追加クエリを 1 回発行するもの、eager_load は単一の LEFT OUTER JOIN ですべてを取得するものと説明されています。includes はその中間に位置します。API ドキュメント では、デフォルトではアソシエーションごとに別クエリを発行し、クエリの条件がジョインを必要とする場合にのみジョインへ切り替えると説明されています。要するに、preload は常に別クエリ、eager_load は常に JOIN、includes は ActiveRecord に選択を委ねる、ということです。
Laravel では with() が標準的な eager loading の修正手段であり、リレーションに対してバッチクエリを 1 回発行します。Laravel 12.8 では Model::automaticallyEagerLoadRelationships() が追加され、明示的な with() 呼び出しがなくてもコレクションがアクセスしたリレーションを自動的に eager load するようになりました。
FetchType.EAGER が N+1 を解決しない理由
FetchType.EAGER を設定しても、JPQL 下では N+1 は解決しません。eager fetching は追加クエリが発行される タイミング を変えるだけで、バッチ化されるわけではないため、依然として JOIN FETCH か @EntityGraph が必要です。これは JPA で最も一般的な誤解です。Hibernate ORM ユーザーガイド は明確に述べています。フェッチプランに eager なアソシエーションを含めない JPQL クエリを実行すると、Hibernate は eager アソシエーションごとに後続の select を 1 回ずつ実行します。これは名前を変えた N+1 に他なりません。同ガイド自身の推奨も、アソシエーションは遅延でマッピングし、クエリごとに eager に取得せよ、というものです。
この一般原則は ORM 全般に当てはまります。マッピング上でリレーションを eager に設定するのは タイミング の決定であって、バッチ化 の決定ではありません。実際にアソシエーションを 1 ステートメントで読み込み、親と子を単一のラウンドトリップにまとめるのは、フェッチジョインまたはエンティティグラフです。
N+1 クエリの検出方法
まずは ORM が発行する SQL を読むことから始めましょう。Rails の開発ログはすべてのクエリを出力します。Django は django-debug-toolbar を通じてクエリ数を可視化します。Hibernate は spring.jpa.show-sql=true でステートメントをログ出力します。Laravel は Laravel Debugbar で表示します。id だけが異なるほぼ同一の SELECT の繰り返しが、その典型的なシグネチャです。
フェイルファストなツールを使えば、開発中に N+1 をエラーとして扱えます。Bullet gem は最適化されていない Rails のアソシエーションを警告し(dev/test のみ)、Python の nplusone は違反をログ出力します。Laravel では Model::preventLazyLoading() が遅延アクセスを顕在化させます。これを有効にすると、事後的に解決されたリレーションは黙って別クエリを実行する代わりに LazyLoadingViolationException を投げます。取りこぼしたリレーションが本番リクエストをクラッシュさせないよう、非本番環境に限定して有効化してください。
ただし注意点があります。開発環境のクエリログでは、たまたま実行したコードパス上の N+1 しか捕捉できません。ローカルのデータセットが小さすぎて表面化しなかったものは、本番環境の APM が捕捉します。アプリケーションパフォーマンスモニターは、すべてのリクエストとバックグラウンドジョブにおけるすべてのクエリを監視し、繰り返しパターンを正確な呼び出し箇所とともに検出します。これは Bullet やデバッグツールバーのような開発専用ツールでは提供できないカバレッジです。
N+1 が許容できるのはどんなときか
すべての N+1 に修正が必要なわけではありません。N が小さく上限が決まっている場合、たとえば常にちょうど 3 件のアイテムを描画するページであれば、追加クエリのコストは prefetch のチェーンを維持するコストより安いかもしれません。関連レコードがすでにクエリキャッシュやアプリケーションキャッシュから提供されている場合、「追加の」クエリはデータベースに届かないこともあります。また、ネストした eager load よりも、コメント付きの明示的なループのほうが読みやすいケースもたまにあります。ただしこれらは例外です。意図的で文書化された選択として扱ってください。N は増えないと確信していても、時間とともに増える傾向があるからです。
このパターンは 1 つの概念にフレームワークごとの綴りが付いているだけなので、一度覚えれば十分です。ループの内側でアクセスされているリレーションを見つけ、JOIN かバッチクエリかを選び、次の N+1 がユーザーのマシンではなく自分のマシンで失敗するように検出の仕組みを組み込みましょう。
FAQ
JOIN ベースの eager loading とバッチクエリベースの eager loading の違いは何ですか?
JOIN ベースの修正(Rails の eager_load、Django の select_related、JPA の JOIN FETCH、生の SQL の LEFT JOIN)は親と子を 1 クエリで読み込みますが、親行が重複する可能性があり、複数のコレクションにまたがるとデカルト積の爆発を引き起こします。バッチクエリベースの修正(Rails の preload、Django の prefetch_related、Laravel の with())は WHERE id IN (...) を使う 2 回目のクエリを実行するため、ラウンドトリップが 1 回増えますが重複行は返しません。どちらも N+1 を一定のクエリ数に収束させます。
FetchType.EAGER を設定すれば Hibernate の N+1 問題は解決しますか?
いいえ。JPQL クエリの下では、FetchType.EAGER は関連エンティティをバッチ化しません。Hibernate は必要な eager アソシエーションごとに二次的な SELECT を発行するため、N+1 が再現されます。eager fetching は追加クエリが発行されるタイミングを変えるだけで、バッチ化されるわけではありません。実際にアソシエーションを 1 ステートメントで読み込むには、JOIN FETCH、@EntityGraph、または QueryDSL の fetchJoin() が必要です。この挙動は Hibernate 7 に至るまで変わっていません。
なぜ N+1 バグはコードレビューやローカルテストを通過し、本番環境で問題になるのですか?
遅延ロードはループ内のリレーションアクセス時に静かにクエリを発行し、それを示すキーワードも警告もないため、N+1 はソースコード上では見えません。クエリ数は N に対して線形に増加するので、シードされた 5 行なら開発環境では高速な 6 クエリで済む一方、5000 行の本番環境では 5001 クエリになります。また、開発環境のクエリログではたまたま実行したコードパス上の N+1 しか捕捉できません。だからこそ、ローカルのデータセットが小さすぎて表面化しなかったものを本番環境の APM が捕捉するのです。
Django では select_related と prefetch_related のどちらを使うべきですか?
ForeignKey と OneToOneField のリレーションシップには select_related を使ってください。SQL の JOIN を実行し、同じクエリで関連オブジェクトを読み込みます。ManyToManyField と逆方向の外部キーには prefetch_related を使ってください。リレーションシップごとに別のルックアップを実行し、結果を Python 側で結合します。間違ったほうを選ぶことが Django で最も多い N+1 のミスです。prefetch_related は、select_related が本来意図しているような単一値の順方向リレーションには使えません。