N+1問題とは?SQLログでの見つけ方とJOIN・IN句による解決方法
一覧APIだけが遅いとき、SQL単体の実行時間では見えないN+1問題を疑う。クエリ数の測り方、JOINと一括取得の使い分け、Rails・Djangoの対策、ページングとインデックスまで、投稿一覧の例で解説します。
N+1問題とは?投稿一覧50件でSQLが51回になる仕組み
投稿のタイトルと投稿者名を50件表示するAPIを考えます。最初に投稿を取得し、ループで投稿者名を読む。ORMが関連を使う時点で取得する遅延読み込み(lazy loading)なら、ここで追加のSQLが発行されます。
posts = fetchLatestPosts(limit=50) // 一覧を1回取得
for post in posts:
author = fetchAuthor(post.authorId) // 各行で1回取得
render(post.title, author.name)各行で毎回問い合わせるなら、SQLは1 + 50 = 51回です。同じ投稿者の再取得をORMのキャッシュが省けば回数は減るため、必ず51回になるわけではありません。一覧の件数を増やすと関連取得の回数も増えることが、調べるべき兆候です。手書きSQLのループでも同じ問題は起きます。
見つけ方:スロークエリより先に、1リクエストのSQL回数を見る
1本のSQLが速くても、逐次実行の往復が積み重なると遅くなります。仮に各問い合わせの完了まで2ミリ秒ずつかかるなら、51回で102ミリ秒、2回なら4ミリ秒です。これは同じ時間で直列に実行した計算であり、実際の改善倍率を保証するものではありません。
- 遅いリクエストのIDやトレースにSQLを結び付け、回数・合計時間・繰り返すSQLの形を調べる。値を除いた同じ形のSELECTが大量にないかを見る。
- 同じ処理を10件と50件で比較する。毎回関連を読む例なら11回から51回へ増える。一覧のシリアライズやテンプレートの処理まで測る。
- 取得件数が変わっても固定で走る認証・COUNTなどのSQLと、一覧に比例する関連取得を区別する。ログではトークンや個人情報を伏せる。
- N+1:10件: 11回
- N+1:50件: 51回
- 一括取得:50件: 2回
解決方法1:投稿者のような単一の関連はJOINで取得する
投稿1件に投稿者が最大1人なら、主キーへのJOINで投稿ごとの行数は増えません。関連が見つからない投稿も残したい場合はLEFT JOINを使います。画面で必要な列だけ選ぶと、転送する量も抑えられます。
SELECT p.id, p.title, a.name AS author_name
FROM posts AS p
LEFT JOIN authors AS a ON a.id = p.author_id
WHERE p.tenant_id = $1
ORDER BY p.created_at DESC, p.id DESC
LIMIT 50;取得対象は認可済みのテナントの投稿に絞ります。この例はauthor_idが参照してよい投稿者を指す前提です。複数テナントのデータを一括取得する実装では、関連側にも必要な認可条件を適用します。
解決方法2:コメントなどの複数の関連はIN句で一括取得する
投稿50件にそれぞれ複数のコメントがあるなら、投稿のページを先に取得し、その投稿IDだけでコメントをまとめて読みます。アプリでpost_idごとに振り分けると、投稿がN件でも関連取得は1回です。コメントのない投稿には空の配列を割り当てます。
-- 1回目:親のページを確定
SELECT id, title
FROM posts
WHERE tenant_id = $1
ORDER BY created_at DESC, id DESC
LIMIT 50;
-- 2回目:そのページの投稿に属するコメントを取得
-- $2以降は1回目で得たID。個数に合わせてプレースホルダーを用意
SELECT id, post_id, body
FROM comments
WHERE tenant_id = $1 AND post_id IN ($2, $3, $4)
ORDER BY post_id, id;このSQLはIDが3個の場合の例です。親が0件なら関連の問い合わせを省き、件数が大きいバッチではDBのパラメータ上限などに合わせて分割します。2回で済んでも、コメントが何十万行も返れば重いままです。件数だけなら集約を使い、本文を表示するなら子の件数にも上限を設けます。
JOINで1回にすれば解決?1対多の行数増加に注意する
投稿にコメント4件とタグ3件があり、両方を同時にJOINすると、1投稿につき4 × 3 = 12行になります。すべての投稿が同じ件数なら、50投稿で600行です。親の列も繰り返し転送されます。DISTINCTで親を重複排除しても、必要な子の集合をどう取得するかは残ります。
| 取り方 | 候補になる場面 | 確認すること |
|---|---|---|
| 単一の関連をJOIN | 投稿と投稿者名 | 結合キーの一意性、関連なしの行を残すか |
| 親を取得してからIN句 | 投稿と複数のコメント・タグ | 子の総件数、メモリ、バッチの上限 |
| 集約で取得 | 投稿ごとのコメント数だけ表示 | 必要な親IDに絞ること、実行計画 |
コメントとJOINした結果にそのままLIMIT 50を付けると、50投稿ではなく結合後の50行を数えます。コメントの多い投稿だけでページが埋まることもあります。親を先にページングするか、親のページをサブクエリなどで確定してから関連を結合します。
Rails・Djangoではeager loadingで必要な関連を先読みする
関連を先に読むeager loadingには、JOINと別クエリの両方の方式があります。メソッド名だけでSQLの本数を決めつけず、生成されたSQLを確認します。
| ORM | 主な指定 | 読み方 |
|---|---|---|
| Rails Active Record | preload(:author) | 通常の関連を別クエリでまとめて取得 |
| Rails Active Record | eager_load(:author) / includes(:author) | eager_loadはLEFT OUTER JOIN。includesは条件に応じて方式が変わる |
| Django 5.2 | select_related("author") | 外部キーなどの単一の関連をJOINで取得 |
| Django 5.2 | prefetch_related("comments") | 別クエリで取得し、アプリ側で関連付ける |
たとえばDjangoでcommentsを先読みしても、ループ内でcomments.filter(...)を呼ぶと別のSQLが走りえます。表示で使う条件に合わせて取得を設計します。Railsではstrict_loadingを使うと、意図しない遅延読み込みを検出できます。
N+1を直しても遅いときは、ページングと複合インデックスを見る
往復を減らしたあとも、親のSQLが大量の行を読み飛ばしているかもしれません。OFFSETが深くなる場合は、最後に表示した行の値から続きを取るカーソルページングを検討します。ここではcreated_atをNOT NULLかつ変更しない値、idを一意な値とします。
SELECT id, title, author_id, created_at
FROM posts
WHERE tenant_id = $1
AND (created_at, id) < ($2, $3)
ORDER BY created_at DESC, id DESC
LIMIT 50;
-- この検索パターンに対するインデックスの候補
CREATE INDEX posts_tenant_created_id_idx
ON posts (tenant_id, created_at DESC, id DESC);$2と$3には前ページの末尾の日時とIDを渡します。日時だけでは同じ時刻の投稿を飛ばすので、IDも順序に含めます。インデックスは等価条件のtenant_idを先頭に置き、続きを絞る列を続けています。これは候補であり、採用は実行計画と書き込みへの負担で判断します。カーソルでもページをまたぐ読み取り全体が同じスナップショットになるわけではありません。
改善の確かめ方:SQL回数・返却行数・APIのp95を測る
10件のテストデータだけで完了にすると、関連が多い本番データで再び遅くなることがあります。親の件数だけでなく、コメント数の偏り、キャッシュの状態、同時アクセスもそろえて比較します。
- 親を10件から50件に増やし、関連取得の回数が比例して増えないことを確認する。毎回異なる投稿者も用意し、キャッシュで問題が隠れない条件を含める。
- SQLの回数と合計時間に加え、返却行数・転送量・アプリのメモリを確認する。SQLが減っても巨大なJOINや先読みで悪化していないかを見る。
- API全体のp95・p99を同じ負荷で比較する。DB接続待ち、結果の組み立て、シリアライズも測定に含める。
- 残る重いSELECTはEXPLAIN (ANALYZE, BUFFERS)で調べる。ANALYZEは実際に実行するため、検証環境で負荷を考慮して行う。計測にはクライアントへの転送時間が含まれないのでAPIの測定も残す。
よくある疑問:インデックスや並列実行だけで解決できる?
- インデックスを追加すれば直る? 各SQLは速くなる場合がありますが、問い合わせの回数は残ります。取得方法を直したうえで、残るSQLの実行計画を確認します。
- 各行の取得を並列にすればよい? 待ち時間を縮められる場合はありますが、DBへのSQL回数は減りません。同時アクセス時に接続待ちを増やすため、一括取得を先に検討します。
- キャッシュを入れれば十分? ヒット時は軽くなりますが、初回・期限切れ・無効化の直後には元の問い合わせが残ります。キャッシュが冷えていても許容できる取得方法にします。
面接で「一覧APIが遅い」と聞かれたら
「まず1リクエストのSQL回数と時間を測り、一覧件数を増やしたときの伸びを確認します。N+1なら単一の関連はJOIN、複数の関連は親をページングしてから一括取得を検討します。クエリ数だけでなく返却行数も比較し、残る重いSQLを実行計画で調べます」と説明すると、観測から対策、検証までの流れが伝わります。
実装を確認するための一次資料
- Ruby on Rails Guides — Eager Loading Associations
- Django 5.2 — select_related / prefetch_related
- PostgreSQL 17 — LIMIT and OFFSET
- PostgreSQL 17 — Multicolumn Indexes
- PostgreSQL 17 — Row Constructor Comparison
- PostgreSQL 17 — Using EXPLAIN
- GitLab Docs — QueryRecorder