ブログ一覧

N+1問題とは?SQLログでの見つけ方とJOIN・IN句による解決方法

· 約10分で読めます

一覧APIだけが遅いとき、SQL単体の実行時間では見えないN+1問題を疑う。クエリ数の測り方、JOINと一括取得の使い分け、Rails・Djangoの対策、ページングとインデックスまで、投稿一覧の例で解説します。

N+1問題とは?投稿一覧50件でSQLが51回になる仕組み

投稿のタイトルと投稿者名を50件表示するAPIを考えます。最初に投稿を取得し、ループで投稿者名を読む。ORMが関連を使う時点で取得する遅延読み込み(lazy loading)なら、ここで追加のSQLが発行されます。

N+1が起きる処理の疑似コード
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ミリ秒です。これは同じ時間で直列に実行した計算であり、実際の改善倍率を保証するものではありません。

  1. 遅いリクエストのIDやトレースにSQLを結び付け、回数・合計時間・繰り返すSQLの形を調べる。値を除いた同じ形のSELECTが大量にないかを見る。
  2. 同じ処理を10件と50件で比較する。毎回関連を読む例なら11回から51回へ増える。一覧のシリアライズやテンプレートの処理まで測る。
  3. 取得件数が変わっても固定で走る認証・COUNTなどのSQLと、一覧に比例する関連取得を区別する。ログではトークンや個人情報を伏せる。
各投稿で投稿者を1回ずつ読む仮定のSQL回数。件数と一緒に回数が増える
  • N+1:10件: 11回
  • N+1:50件: 51回
  • 一括取得:50件: 2回

解決方法1:投稿者のような単一の関連はJOINで取得する

投稿1件に投稿者が最大1人なら、主キーへのJOINで投稿ごとの行数は増えません。関連が見つからない投稿も残したい場合はLEFT JOINを使います。画面で必要な列だけ選ぶと、転送する量も抑えられます。

投稿50件と投稿者名を1回で取得するSQL
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回です。コメントのない投稿には空の配列を割り当てます。

先に取得した投稿IDを使う。値は文字列連結せずバインドする
-- 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 Recordpreload(:author)通常の関連を別クエリでまとめて取得
Rails Active Recordeager_load(:author) / includes(:author)eager_loadはLEFT OUTER JOIN。includesは条件に応じて方式が変わる
Django 5.2select_related("author")外部キーなどの単一の関連をJOINで取得
Django 5.2prefetch_related("comments")別クエリで取得し、アプリ側で関連付ける

たとえばDjangoでcommentsを先読みしても、ループ内でcomments.filter(...)を呼ぶと別のSQLが走りえます。表示で使う条件に合わせて取得を設計します。Railsではstrict_loadingを使うと、意図しない遅延読み込みを検出できます。

N+1を直しても遅いときは、ページングと複合インデックスを見る

往復を減らしたあとも、親のSQLが大量の行を読み飛ばしているかもしれません。OFFSETが深くなる場合は、最後に表示した行の値から続きを取るカーソルページングを検討します。ここではcreated_atをNOT NULLかつ変更しない値、idを一意な値とします。

2ページ目以降:作成日時とIDを同じ降順にそろえる(PostgreSQL 17)
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件のテストデータだけで完了にすると、関連が多い本番データで再び遅くなることがあります。親の件数だけでなく、コメント数の偏り、キャッシュの状態、同時アクセスもそろえて比較します。

よくある疑問:インデックスや並列実行だけで解決できる?

面接で「一覧APIが遅い」と聞かれたら

「まず1リクエストのSQL回数と時間を測り、一覧件数を増やしたときの伸びを確認します。N+1なら単一の関連はJOIN、複数の関連は親をページングしてから一括取得を検討します。クエリ数だけでなく返却行数も比較し、残る重いSQLを実行計画で調べます」と説明すると、観測から対策、検証までの流れが伝わります。

実装を確認するための一次資料

この記事で深掘りしたTips