データベース

N+1問題とは?発行クエリ数の実測と検出方法・ORM別の対策

N+1問題とは?発行クエリ数の実測と検出方法・ORM別の対策

N+1問題は、一覧を1回のクエリで取得したあと、その各行に紐づく関連データを1件ずつ取りに行ってしまい、クエリが親の件数だけ増えてしまう性能問題です。N+1クエリ問題、あるいは単にN+1クエリとも呼ばれます。ORMを使うと構文上は「ループの中でプロパティを読んでいるだけ」に見えるため、コードを読んだだけでは気づけないのが厄介なところ。この記事では、実際に発行されるクエリ数を計測しながら、開発時と本番での検出のしかた、Django・Rails・Laravel・Hibernateという主要ORM別の対策、ORMの外側で起きるGraphQLやAPI連鎖のN+1、そして「対策したのに直っていない」典型パターンまで整理します。

まとめ:N+1問題の正体と対策に着手する順序

N+1問題の要点は次のとおりです。数値はDjango 6.0.8とSQLite(インメモリ)で親レコード2,000件を実測したもので、素直にループを書くと2,001クエリ・約1.7秒、select_relatedを付けると1クエリ・約53ミリ秒でした。差は約32倍です。

  • 正体は「親を取る1回+子を取るN回」の合計N+1回のクエリ。1回あたりの往復が1ミリ秒でも、Nが数千になれば秒単位の遅延になります。
  • 検出は勘ではなく発行クエリ数の実測で行います。Djangoならdjango-debug-toolbar(2026年8月時点で7.1.1)、Railsならbullet(同8.1.3)が定番です。
  • 1本ずつのSQLは軽いためスロークエリログには残りません。本番側はAPMやデータベース監視で「1リクエスト内のクエリ本数」を見ます。
  • Djangoの対策は、外部キーと1対1にはselect_related(JOINで1クエリ)、多対多と逆参照にはprefetch_related(別クエリでPython側結合)という使い分けです。
  • Railsはincludespreloadeager_loadの3種類があり、発行されるクエリの形が違います。Laravelはwith、HibernateではJOIN FETCH@BatchSizeが該当します。
  • ORMを使わない場面でも起きます。GraphQLのリゾルバやREST APIの連鎖呼び出しは、DataLoaderやバッチ取得のエンドポイントで束ねます。
  • 事前ロードしても、そのあと関連にfilter()を掛けるとキャッシュを外れてクエリが復活します。対策の成否は必ずクエリ数で確認してください。
  • Rails 6.1以降のstrict_loadingとLaravelのpreventLazyLoadingは遅延ロードを例外にできます。Djangoは2026年8月5日に正式リリースされた6.1のfetch_mode()が部分的にこれを担います。

以下、それぞれを実測値と公式ドキュメントの記述で確認していきます。

「N+1問題とは」の定義と発行されるSQLの並び方・呼び名の揺れ

親を取る1回と子を取るN回でクエリが積み上がる流れと模式SQL

書籍一覧を表示し、各書籍の著者名を並べる画面を考えます。ORMで素直に書くと次のようになります。

for book in Book.objects.all():
    print(book.title, book.author.name)

このコードは書籍一覧を取る1回のクエリに加え、ループのたびにbook.authorを解決するクエリを発行します。流れるSQLは次の形です(テーブル名からアプリのラベル接頭辞を省いた模式で、実出力の遅延ロード側にはLIMIT 21が付きます)。

-- 1本目: 親の一覧
SELECT "book"."id", "book"."title", "book"."author_id" FROM "book";
-- 2本目以降: ループのたびに1本ずつ(これが N 本)
SELECT "author"."id", "author"."name" FROM "author" WHERE "author"."id" = 1;
SELECT "author"."id", "author"."name" FROM "author" WHERE "author"."id" = 2;
-- ...

-- select_related を付けた場合は、これ1本で完結する
SELECT "book"."id", "book"."title", "author"."id", "author"."name"
FROM "book" INNER JOIN "author" ON ("book"."author_id" = "author"."id");

同じ主キー検索が延々と並ぶこの形が、ログ上でN+1を見分ける最大の手がかりです。逆に言えば、SQLの1本1本はどれも単純な主キー検索でしかありません。個々のクエリを速くしても効果はなく、本数そのものを減らす以外に解決手段がない、という点がこの問題の性格を決めています。

書き方別の発行クエリ数と所要時間をDjango 6.0.8で実測

Django 6.0.8・SQLite(インメモリ)で書籍2,000件を対象に計測した結果です。関連名にはいずれもauthorを渡しています。

書き方 発行クエリ数 所要時間
素のループ 2,001 1,706-1,738ms
select_related 1 53-55ms
prefetch_related 2 115-181ms

時間はマシンとDB構成に強く依存するため、絶対値ではなく桁の差として読んでください。N+1問題が開発環境では気づかれず本番で顕在化するのは、1クエリあたりの往復コストが環境によって大きく変わるためです。

なお、クエリ数が少ないほど速いとは限りません。prefetch_relatedselect_relatedより1本多く、実測でも遅くなっています。select_relatedが使える場面でわざわざ選ぶ理由はありません。

遅延ロードがN+1を招く理由とコードを読んでも見えない仕組み

N+1問題はSQLを手書きしていれば起きにくく、ORMで頻発します。ORMは関連オブジェクトへのアクセスを遅延ロード(実際に参照された時点でクエリを発行する)として実装しているためです。book.authorという属性アクセスは、Pythonの文法上はただのプロパティ参照ですが、内部ではSQLが1本走ります。テンプレート側でbook.author.nameと書いた場合も同じで、ビューのコードをいくら読んでも原因が見つかりません。だからこそ、クエリ数を数える工程が対策の起点になります。

N+1クエリ問題や1+N問題という呼び名の揺れと指す範囲の違い

同じ現象が複数の名前で流通しています。日本語圏では「N+1問題」「N+1クエリ問題」「N+1クエリ」がほぼ同義で使われ、英語圏では「N+1 query problem」「N+1 selects problem」という表記が並びます。親の1回を先に数える立場から「1+N問題」と書く資料もありますが、指しているものは同一です。

範囲の解釈だけは資料によって差が出ます。狭義にはORMの遅延ロードが原因のものだけを指し、広義にはループの中で外部呼び出しを1件ずつ行う実装パターン全般を含む捉え方です。後述のGraphQLリゾルバやREST APIの連鎖呼び出しは後者に入り、原因も対策の考え方も共通しています。この記事では広義で扱い、どこまでがORM固有の話かを章ごとに区別します。

開発時と本番環境で分けるN+1問題の検出方法とツールの選定基準

発行クエリ数の数え方とconnection.queriesによる区間計測

Djangoではsettings.DEBUGが有効なとき、実行したSQLがdjango.db.connection.queriesに蓄積されます。処理の前後で件数を比較すれば、その区間で何本のクエリが走ったかがわかります。

from django.db import connection, reset_queries

reset_queries()
data = [b.author.name for b in Book.objects.all()]
print(len(connection.queries))  # 書籍2,000件なら 2001

Railsでは開発ログにSQLがそのまま出力されるため、同じ形のSELECTが連続していないかを見ます。前章で示した主キー1件指定の反復が並んでいれば、それがN+1です。

開発環境で使う検出ツールと更新が止まったライブラリの見分け方

毎回ログを目視するのは現実的ではありません。Djangoの定番はdjango-debug-toolbar(2026年8月時点の最新は7.1.1)で、画面ごとの発行クエリ数と重複クエリ数をブラウザ上のパネルに表示します。Railsはbullet(同8.1.3)で、N+1を検出するとUSE eager loading detectedという警告を出し、追加すべきincludesの記述まで提示します。

Django向けにはN+1を専門に検出するnplusoneもありますが、最終リリースは2018年5月のバージョン1.0.0で止まっています。同種のdjango-zen-queriesも2021年7月の2.1.0が最新です。新規導入するなら、更新が続いているdjango-debug-toolbarを軸に据えるほうが無難でしょう。判断材料は「最終リリース日」と「対応を明記しているフレームワークのバージョン」の2点だけで足ります。

本番でスロークエリログに出ないN+1をAPMとDBMで拾う手順

本番の検出は開発時と別の道具が必要になります。理由は単純で、N+1を構成する1本1本のSQLはどれも数ミリ秒で終わるため、しきい値で切るスロークエリログには1本も残らないからです。MySQLのlong_query_timeを下げれば記録はされますが、正常なクエリまで大量に混ざるので運用に乗りません。

本番側で見るべき指標は所要時間ではなく本数です。APM(アプリケーション性能監視)を入れておけば、1リクエストのトレースの中に同じSQLのスパンが数百並ぶ形で可視化されます。手順は、遅い画面をレイテンシの分布から特定し、そのトレースを1本開いてスパン数と内訳を数え、同一クエリの反復があればN+1として切り出す、という流れです。データベース監視の機能を併用すると、正規化済みSQLごとの実行回数で同じ判定ができます。

既存システムで発生源が特定できない場合や、計測の仕組みそのものを入れるところから始める場合は、保守運用・内製化支援で調査から改善、社内へのノウハウ移管までを引き受けています。

Djangoの対策:select_relatedとprefetch_relatedの境界

select_relatedが使える関係と多対多で例外になる理由

Django公式ドキュメントによるselect_relatedの説明は「SQLのJOINを作り、関連オブジェクトのフィールドをSELECT文に含めることで動作する」。対象にできるのは単一値のリレーション、つまり外部キー(ForeignKey)と1対1(OneToOneField)で、1対1については逆方向もたどれます。多対多フィールドを渡すと、実行時に例外になります。

>>> Book.objects.select_related("tags")
FieldError: Invalid field name(s) given in select_related: 'tags'.
Choices are: author

末尾の選択肢一覧はモデルの構成によって変わりますが、多対多を拒否する挙動は共通です。この制約は不便に見えて、実務では安全装置として働きます。多対多をJOINすると親行が子の件数だけ複製され、取得行数が跳ね上がるためです。Djangoはその形をそもそも書かせません。遅延評価やキャッシュを含むORM全体のクエリ設計は、Django ORMのクエリ設計と生SQLへ落とす判断で扱っています。

prefetch_relatedの動作と2本目のSQLに並ぶIN句の長さ

多対多や逆参照にはprefetch_relatedを使います。公式ドキュメントの説明では「リレーションごとに別のルックアップを行い、結合はPython側で行う」仕組みで、これによりselect_relatedでは扱えない多対多・多対1・GenericRelationを事前取得できます。

for book in Book.objects.prefetch_related("tags"):
    print(book.title, [t.name for t in book.tags.all()])

書籍20件で計測すると、素のループが21クエリだったのに対し、この書き方は2クエリで済みます。内訳は書籍一覧の1本と、タグをまとめて取る1本です。

ただし2本目のクエリは、対象のIDをすべてIN句に並べる形になります。書籍2,000件でprefetch_related("tags")を実測すると、2本目のSQLは長さ11,121文字・IN句に2,000個の書籍IDが並びました。順方向の外部キーをprefetch_related("author")で取る場合は、並ぶのが関連先の著者IDになり14,986文字です。親の件数が大きい場面では、この巨大なSQLそのものがコストになる点は頭に入れておいてください。

Prefetchオブジェクトとto_attrによる事前絞り込みの書き方

事前取得した関連データをさらに絞りたいときは、Prefetchオブジェクトで絞り込み済みのクエリセットを渡し、to_attrで別の属性に格納します。公式ドキュメントも「プリフェッチ結果を絞り込む場合はto_attrの使用が推奨される」としています。

from django.db.models import Prefetch

books = Book.objects.prefetch_related(
    Prefetch("tags", queryset=Tag.objects.filter(name="tag0"), to_attr="hit")
)
for book in books:
    print(book.title, [t.name for t in book.hit])

この書き方なら20件で2クエリのままです。なぜto_attrが必要なのかは、次章の失敗パターンで実測値とともに示します。

Rails・Hibernate・Laravelでの対策とORM別の記述の対応

関連の事前ロードと遅延ロードの禁止は、どのORMにも似た機能があります。名前だけを先に対応付けておくと、他言語の資料を読むときに迷いません。

ORM 事前ロードの記述 遅延ロードの禁止
Django select_related fetch_mode(FETCH_RAISE)
Rails includes strict_loading
Laravel with preventLazyLoading
Hibernate・JPA JOIN FETCH 標準機能なし
GraphQL DataLoader 標準機能なし

右列に「標準機能なし」が並ぶのは、禁止の仕組みが後発だからです。Rails・Laravel・Djangoはこの数年で相次いで導入しましたが、JavaやGraphQLでは今も検出と設計で防ぐ形が主流です。

includesとpreloadとeager_loadで変わるクエリの形

Railsガイドは3つのメソッドを明確に区別しています。includesは「指定されたすべての関連を、可能な最小のクエリ数で読み込む」もので、書籍10件の例では11クエリが2クエリに減ります。preloadは関連ごとに1クエリで読み込みますが、ガイドが明記するとおりプリロードした関連に条件を指定できません。eager_loadは「LEFT OUTER JOINを使ってすべての指定関連を読み込む」ため、合計1クエリになります。どのメソッドを書けるかは関連の宣言側にも依存するので、RailsのAssociation(関連付け)の種類とあわせて確認してください。Djangoの2つのメソッドとの対応は次のとおりです。

Rails クエリの形 関連への条件 Djangoの対応
eager_load LEFT OUTER JOINで1本 select_related
preload 関連ごとに別クエリ1本 不可 prefetch_related
includes 最小クエリ数(自動選択) 相当なし

対応が厳密に一致するのはpreloadprefetch_relatedだけです。eager_loadhas_manyにも使えて1本のLEFT OUTER JOINになりますが、Djangoのselect_relatedは複数関連には使えず、NOT NULLの外部キーではINNER JOINになります。単数の関連に限れば対応する、と読んでください。includesにいたっては対応物がありません。関連先テーブルを参照する条件があるときだけLEFT OUTER JOINへ切り替わり、無ければ関連ごとの別クエリになるためです。ただしこの切り替えは万能ではなく、SQL文字列で書いた条件はRailsが参照を検出できないためreferencesの明示が必要になります。ガイドは「SQL断片についてはJOINされたテーブルを強制するためにreferencesを使う必要がある」と述べ、絶版フラグをSQL断片で指定してreferencesを添える例を挙げています。同時に「推奨されるやり方は代わりにjoinsを使うことだ」とも書いており、条件付きの取得はjoinsに寄せるのが公式の立場です。

HibernateとJPAのJOIN FETCHとバッチフェッチの使い分け

JavaのHibernate ORM(7.4系、2026年8月時点の最新は7.4.6.Final)では、ユーザーガイドのFetching章に対策の解説があります。JPQLのJOIN FETCHで関連を明示的に同時取得する方法、@BatchSizeによる一括フェッチ(12.8節)、@Fetch(FetchMode.SUBSELECT)でサブクエリを使う方法(12.11節)が並びます。名前も文法も違いますが、関連を後から1件ずつ取りに行かせないという発想はDjangoやRailsと同じです。

使い分けの目安は関連の多重度です。単数の関連ならJOIN FETCHで1本にまとめ、コレクションを複数たどる場面では行の重複が跳ねるため@BatchSizeやサブクエリ方式に寄せます。なお同じJavaでもMyBatisやjOOQのようにSQLを明示的に書く系のライブラリでは、遅延ロードが既定でないぶんN+1の発生源が変わる点に注意が必要です。実装前の選定についてはJava ORM(O/Rマッパー)の種類と選び方が参考になります。

N+1問題はDjango固有でもRails固有でもなく、遅延ロードを備えたORM全般に共通する構造的な課題です。

LaravelのwithとpreventLazyLoadingによる遅延ロード禁止

PHPのLaravel(ドキュメントの既定は13.x系)では、Eloquentのwith()が事前ロードにあたります。公式ドキュメントは、関連を後から参照すると1件ずつSELECTが走る例と、with()を付けると親の一覧1本+関連をIN句でまとめる1本の計2クエリになる例を並べて示しています。取得済みのモデルに後から関連を足すload()、件数だけを別カラムとして取るwithCount()も同じ系列の道具です。これらを含むモデル定義とリレーションの前提はLaravel Eloquentとは|モデル定義・リレーション・eager loadingの基礎にまとめています。

# N+1が起きる書き方
$books = Book::all();
foreach ($books as $book) {
    echo $book->author->name;
}

# with() で2クエリに束ねる
$books = Book::with('author')->get();

# AppServiceProvider の boot() で遅延ロードを禁止する
Model::preventLazyLoading(! app()->isProduction());

最後の1行が予防側の仕掛けです。Model::preventLazyLoading()を有効にすると、事前ロードしていない関連へアクセスした時点でLazyLoadingViolationExceptionが送出されます。引数に本番判定の否定を渡す書き方が広く使われているのは、開発とテストでは落とし、本番では流したいという運用意図があるためです。既存アプリへ後から入れる場合は、違反時のハンドラを差し替えてログ記録だけに留め、検出された箇所を潰してから例外へ切り替えると移行が進みます。

OSIVの既定値trueがビュー描画中のクエリ発行を許す仕組み

Spring BootでJPAを使う場合、N+1が消えない原因が設定側にあることがあります。spring.jpa.open-in-viewが既定で有効なため、リクエストの終わりまで永続コンテキストが開いたままになり、ビューやシリアライズの段階で遅延ロードが成功してしまうからです。例外にならないので気づかず、テンプレートの描画中に関連ごとのSELECTが積み上がります。

この既定値を切るとビュー側の遅延ロードは失敗するようになり、事前取得の漏れがその場で表面化します。切り替えの影響範囲と警告ログの扱いはspring.jpa.open-in-viewの既定値trueと判断基準にまとめてあります。Javaでの実装では、まずこの設定を確認してからJOIN FETCHの追加に進むのが順序として無駄がありません。

ORMの外側で起きるN+1:GraphQLとAPI連鎖呼び出し

GraphQLのリゾルバで増えるクエリとDataLoaderの束ね方

GraphQLはフィールド単位でリゾルバが走るため、N+1がほぼ構造的に発生します。書籍20件を返すクエリで各書籍の著者を要求されると、書籍のリゾルバが1回、著者のリゾルバが20回呼ばれる構造です。GraphQLの仕組みとREST APIとの違いを踏まえると、クライアントが要求する形を先読みできないことが原因だとわかります。

解決の標準手段がDataLoaderです。JavaScript実装のdataloader(2026年8月時点の最新は2.2.3)は、同一イベントループ内で発生したload()の呼び出しをためて1回のバッチ取得にまとめ、リクエスト単位でキャッシュします。リゾルバ側の書き方は1件ずつのままで、実行だけが束ねられる点が扱いやすさにつながっています。

const authorLoader = new DataLoader(async (ids) => {
  const rows = await fetchAuthorsByIds(ids);
  return ids.map((id) => rows.find((r) => r.id === id));
});

# リゾルバは1件ずつ呼ぶが、実行は1クエリに束ねられる
const resolvers = {
  Book: { author: (b) => authorLoader.load(b.authorId) },
};

同じ仕組みは各言語に用意されています。graphql-ruby(2026年8月時点で2.6系)はGraphQL::Dataloaderを内蔵し、フィールドがデータ要件を登録してから実際の取得を始める二段構えです。Spring for GraphQLは@BatchMappingBatchLoaderRegistryを用意しており、GraphQL JavaのDataLoader機構の上で同じことを実現します。バッチ関数は入力IDの並び順に合わせて結果を返す必要があり、ここを崩すと値が入れ替わるので、実装時はその整合だけ丁寧に確認してください。

REST APIの連鎖呼び出しで起きるクライアント側のN+1

マイクロサービス構成では、DBではなくHTTPで同じ形が現れます。一覧APIを1回叩き、返ってきた各要素について詳細APIを1回ずつ叩く実装です。1回の往復が20ミリ秒なら、100件で2秒が加算されます。DBのN+1と違ってログの見た目はアクセスログの行数増加なので、原因として疑われにくいのも共通しています。

対策は3つに整理できます。第1に、IDの配列を受け取って複数件を返すバッチ取得のエンドポイントを提供側に用意する方法。第2に、一覧APIのレスポンスへ詳細側の必要フィールドを含めてしまう方法。第3に、変化の少ない参照データを呼び出し側でキャッシュする方法です。順序としては第2が最も安く、それで足りないときに第1へ進むのが実務的でしょう。呼び出し回数を減らせないまま並列化で押し切ると、提供側のスループットを削る形になるため避けます。

事前ロードしても直らないN+1の4類型と実測した発行クエリ数

ここが実務でいちばん時間を溶かす部分です。prefetch_relatedを付けても速くならないケースは、次のいずれかに当てはまります。いずれもDjango 6.0.8で書籍20件を対象に実測した数値です。

事前取得後のfilter()でプリフェッチキャッシュを外す失敗

prefetch_related("tags")で取得したあと、ループの中でbook.tags.filter(name="tag0")と書くと、キャッシュではなく新しいクエリが走ります。実測では2クエリのはずが22クエリになり、N+1が完全に復活しました。

# 22クエリ(N+1が復活)
[list(b.tags.filter(name="tag0")) for b in Book.objects.prefetch_related("tags")]

# 2クエリ(絞り込みを事前に済ませる)
[b.hit for b in Book.objects.prefetch_related(
    Prefetch("tags", queryset=Tag.objects.filter(name="tag0"), to_attr="hit"))]

プリフェッチのキャッシュは、その関連マネージャでall()を呼んだ結果に対して効きます。条件を変えれば別のクエリセットになるので、キャッシュは使われません。絞り込みが決まっているなら、前章のPrefetchto_attrで先に済ませるのが正解です。

author.idの参照で1クエリ増える外部キー値の正しい取り方

外部キーの値だけが必要なのに関連オブジェクト経由で取ると、そこでクエリが1本走ります。Django公式ドキュメントは「外部キーの値だけが必要なら、関連オブジェクト全体を取得して主キーを読むのではなく、手元のオブジェクトに既にある外部キーの値を使うこと」として、entry.blog.idではなくentry.blog_idを使うよう明示しています。

書籍2,000件で計測すると、b.author.idは2,001クエリ・1,439〜1,970ミリ秒、b.author_idは1クエリ・20〜39ミリ秒でした。アンダースコアの有無だけで37〜98倍の差です。テンプレートやシリアライザでIDだけを出力している箇所は、ここを疑う価値があります。Django REST FrameworkのSerializerでネストした関連を扱う場合も、同じ観点でフィールド定義を見直してください。

only()とselect_related()の併用で送出される例外の条件

取得列を絞るonly()select_related()を組み合わせるとき、関連先のフィールドをonly()に含め忘れると例外になります。

>>> Book.objects.select_related("author").only("title")
FieldError: Field Book.author cannot be both deferred and traversed
using select_related at the same time.

関連先まで含めてonly("title", "author__name")と書けば1クエリで通ります。古い記事では「この組み合わせは黙ってN+1になる」と書かれていることがありますが、Django 6.0.8では例外として弾かれます。エラーになるほうが安全なので、遭遇したら素直にフィールドを追加してください。

count()とlen()の使い分けと追加クエリ1本が走る境目

Django公式のデータベースアクセス高速化ガイドには「contains()count()exists()を使いすぎない」という節があります。趣旨は、結果を結局すべて使うのであれば先にクエリセットを評価し、以降はlen()inでキャッシュを使え、というものです。

実測すると、評価済みのクエリセットに対するcount()exists()len()はいずれも追加クエリ0本でした。追加の1本が走るのは未評価のクエリセットに対して呼んだ場合です。件数だけが必要なら未評価のままcount()を呼ぶほうが速く、一覧も件数も使うなら先に評価してからlen()を使うのが有利です。この切り分けで判断してください。

あえてN+1を残す3つの条件と親の件数から決める着手の優先順位

N+1は常に潰すべき、とは言い切れません。事前ロードにもコストがあるためです。次の条件に当てはまるなら、無理に手を入れないほうが速く、コードも読みやすくなります。

親が数万件でIN句が膨らむ場面とページネーションへの切り替え

最も注意すべきは、親の件数が非常に多い場合です。前述のとおり、書籍2,000件のprefetch_relatedは2本目のSQLが11,121文字・IN句2,000個になりました。親が数万件に達する処理では、この巨大なSQLの構築とパース自体が無視できないコストになります。

そもそもそんな件数を1リクエストで扱うべきかを先に見直し、バッチ分割やページネーション設計(オフセットとカーソル)の導入を検討するほうが本質的な解決です。1ページあたりの件数に上限が入れば、IN句の長さもそこで固定されます。

上限10件の一覧で事前ロードを入れない判断と実測した所要時間

逆に、親の件数が小さく上限が保証されている場合も対象外です。1画面に最大10件しか出ないリストで実測すると、発行クエリは11本、所要時間は6.2〜24.0ミリ秒でした。ここにPrefetchto_attrを持ち込むと、可読性を落として得るものがほとんどありません。

フラグメントキャッシュから返す関連で事前ロードが無駄になる条件

関連先をアプリケーションキャッシュやフラグメントキャッシュから返せている場合も同様で、事前ロードしたデータは使われず、DBアクセスだけが純増します。キャッシュのヒット率が高い画面ほど、事前ロードの追加は損になります。

判断の順序は明快です。まずクエリ数を計測し、次にその画面で親が最大何件になるかを確認したうえで、件数の上限が読めないループだけを潰します。この順で進めれば、意味のない手直しに時間を使わずに済みます。

再発を止めるガードレールとテストで発行クエリ数を固定する運用

Railsのstrict_loadingと導入バージョンごとの指定の粒度

Railsは6.1でstrict_loadingを導入しました。リリースノートは「関連がすべて事前読み込みされることを保証し、N+1が起きる前に止められる」と説明しています。有効にした関連で遅延ロードが起きるとActiveRecord::StrictLoadingViolationErrorが送出されるため、レビューをすり抜けたN+1をテストや開発環境で確実に落とせます。

導入の粒度はバージョンによって違い、ここを取り違えると存在しないオプションを探すことになります。アプリ全体に効く2つはconfig.active_record配下の設定項目です。

書き方 粒度 導入バージョン
strict_loading_by_default アプリ全体 6.1
strict_loading: true 関連の宣言 6.1
.strict_loading リレーション 6.1
strict_loading! レコード 6.1
mode: :n_plus_one_only レコード 7.0
strict_loading_mode アプリ全体 8.0

N+1につながる遅延ロードだけを例外にする:n_plus_one_onlyは7.0で追加されたもので、6.1では指定できません。それをアプリ全体の既定にするstrict_loading_modeはさらに遅く、8.0からです。2026年8月時点の最新は8.1系なので新規開発ではいずれも使えますが、段階的に導入したい既存アプリでは使っているRailsのバージョンを先に確認してください。

DjangoのassertNumQueriesでクエリ数をテストに書き込む手順

Djangoにstrict_loadingと同名の実装はありません。サードパーティ製の検出ライブラリも前述のとおり更新が止まっています。そこで現実的な手段が、Django本体のテストユーティリティassertNumQueriesです。対象の処理が発行するクエリ数をテストに書き込んでおけば、後日の変更でN+1が混入した瞬間にテストが落ちます。

from django.test import TestCase

class BookListTests(TestCase):
    def test_query_count(self):
        with self.assertNumQueries(2):
            self.client.get("/books/")

ツールによる検出は気づけば直せるに留まりますが、クエリ数をテストで固定すれば再発そのものを止められます。1リクエストで親が数百件に達する一覧画面には、この1行を入れておく価値があるでしょう。CIで走らせておけば、レビュー前に差分の影響が表に出ます。

Django 6.1のfetch_modeとFETCH_RAISEで止められる範囲

Django 6.1は2026年8月5日に正式リリースされ、同月中に6.1.1も公開されました。メインストリームのサポートは2027年4月まで、拡張サポートは2027年12月までです(LTSは5.2系で2028年4月まで)。この6.1でQuerySet.fetch_mode()が追加され、読み込まれていないフィールドへアクセスしたときの挙動を選べるようになりました。FETCH_RAISEを指定すると、遅延ロードが発生した時点でFieldFetchBlockedが送出されます。

from django.db import models

books = Book.objects.fetch_mode(models.FETCH_RAISE)
# FieldFetchBlocked: Fetching of Book.author blocked.

ただしRailsのstrict_loadingと同等ではありません。公式ドキュメントが挙げる適用対象は、外部キー、1対1フィールドとその逆アクセサ、defer()only()で遅延させたフィールド、generic relationsに限られます。ドキュメントは関連マネージャについて「フェッチモードはそうしたマネージャのクエリには影響しない」と明記しており、逆参照の外部キーや多対多は対象外です。リリース候補の段階で試した結果も同じで、外部キーへのアクセスはFieldFetchBlockedで止まる一方、多対多と逆参照の外部キーは素通りしました。なお定数名は開発中にRAISEからFETCH_RAISEへ変更されており、リリース候補を入れたままの環境では旧名が残ります。

同時に追加されたFETCH_PEERSは、同じクエリセットから取得した兄弟インスタンスをまとめて取得する動作で、後付けのprefetch_relatedのように働きます。書籍20件で計測すると、既定のFETCH_ONEが21クエリだったのに対しFETCH_PEERSは2クエリでした。多対多を含む完全な禁止はできないため、当面はassertNumQueriesとの併用が現実的です。Djangoは2026年8月に年次リリースサイクルへの移行を告知しており、この種の機能追加を追う間隔も今後は変わります。

counter_cacheと集計列で件数取得のクエリを設計から消す方法

実装で防ぐ以前に、設計で発生源を消せる場合があります。典型は「一覧の各行にコメント件数を出す」形で、行ごとにCOUNTを撃つとN+1と同じ構造です。Railsのcounter_cacheのように件数を親テーブルの列として持たせれば、一覧の1クエリで件数まで取れます。Djangoのannotateによる集計やLaravelのwithCount()はクエリを1本にまとめる手段で、これらは表示要件と更新頻度に応じた選び分けが必要です。

件数列の追加はデータベース正規化の観点では冗長化にあたるため、更新時のずれを許容できるかを先に決めます。判断を誤ると別の不具合に化けるので、DB設計のアンチパターン一覧で近い失敗例を確認しておくと安全です。読み取り回数が書き込み回数を大きく上回る画面に限って持ち込むのが、無理のない線引きになります。

よくある質問

N+1問題について検索されやすい疑問を、実測値と公式ドキュメントの記述に沿って整理します。

N+1問題とは何ですか?

親レコードを取得する1回のクエリに加えて、各親に紐づく関連データを取得するN回のクエリが発行され、合計N+1回になる性能問題です。名前はこのクエリ数の内訳に由来し、N+1クエリ問題や1+N問題とも呼ばれます。Django 6.0.8とSQLite(インメモリ)で親2,000件を扱うと2,001クエリが発行され、事前ロードした場合の1クエリと比べて約32倍の時間がかかりました。原因はORMの遅延ロードに限らず、ループの中で1件ずつ問い合わせる実装パターン全般に共通します。

N+1問題は何が問題なのですか?

1本あたりのSQLは軽くても、往復回数が親の件数に比例して増えるため、総所要時間がデータ量に比例して悪化する点です。ネットワーク往復がほぼゼロのインメモリDBという最も条件のよい環境ですら、親2,000件で約1.7秒かかりました。DBサーバーが別ホストにある本番環境では1クエリごとのレイテンシが上乗せされるため、差はさらに開きます。1本ずつは速いのでスロークエリログには残らず、発見が遅れる点も厄介です。同時アクセスが重なればコネクションプールも圧迫し、一覧画面1枚が全体の応答を引きずる状態になります。

select_relatedとprefetch_relatedはどちらを使うべきですか?

関係の種類で決まります。外部キーと1対1ならselect_relatedで、JOINによる1クエリにまとまります。多対多や逆参照には使えないためprefetch_relatedの出番です。両方を組み合わせることもでき、公式ドキュメントはプリフェッチが主クエリの後に実行されるため、select_relatedで取得済みのオブジェクトは再取得されないと説明しています。実測でもselect_relatedのほうが1本少なく速いので、使える関係では前者を選んでください。

SQLを直接書けばN+1問題は起きませんか?

SQLを手書きする場合でも、アプリケーション側のループの中でクエリを実行していれば同じことが起きます。主キー1件指定のSELECTをループで回せば、ORMを使ったときとまったく同じ本数のSQLが流れます。N+1はORM固有のバグではなく、1件ずつ問い合わせる実装パターンそのものが原因です。ORMで起きやすいのは、関連への属性アクセスがクエリ発行だと見た目でわからないためです。

Java・PHP・GraphQLでもN+1問題は起きますか?

いずれでも起きます。Hibernate ORM 7.4系のユーザーガイドは、Fetching章でJOIN FETCHによる同時取得、@BatchSizeによる一括フェッチ、@Fetch(FetchMode.SUBSELECT)によるサブクエリ取得を扱っています。Spring BootでJPAを使う場合はspring.jpa.open-in-viewが既定で有効なため、ビュー描画中の遅延ロードが例外にならず気づきにくい状態です。PHPのLaravelはEloquentのwith()が事前ロードにあたり、preventLazyLoading()で遅延ロードを例外にできます。GraphQLはフィールドごとにリゾルバが走るため構造的に発生し、DataLoaderでバッチ化するのが標準的な対処です。

関連記事

資料請求

RELATED POSTS 関連記事