N+1クエリ問題

えぬぷらすわんくえり

N+1クエリ問題

N+1クエリ問題とは、一覧データを1回のクエリで取得した後、各レコードの関連データを1件ずつ個別にクエリする結果、合計 N+1 回のデータベースアクセスが発生してしまうパフォーマンス上のアンチパターンである。

N+1 の「N」と「1」

ORM(Object-Relational Mapping)やクエリビルダを使っていると、意識しないうちにこの問題を踏むことが多い。流れはこうだ。

  1. 親テーブルから一覧を取得する — これが「1」にあたるクエリ
  2. 取得した N 件それぞれについて、子テーブルへ関連データを問い合わせる — これが「N」回のクエリ

たとえば用語集の一覧ページを表示するケースを考える。まず glossaries テーブルから 50 件を SELECT し、続いて各用語の翻訳データを translations テーブルから 1 件ずつ取得すると、合計 51 回のクエリが走る。件数が 500 になれば 501 回だ。

なぜ深刻なのか

1 回あたりのクエリが数ミリ秒で返るとしても、回数が線形に増えるため、データ量に比例してレスポンスタイムが悪化する。ローカル開発では数十件しかないテーブルが本番では数千件に膨らみ、そこで初めて遅延が顕在化する——という事故は珍しくない。

さらに、DB サーバーとアプリケーションサーバーがネットワーク越しに分離されている環境では、ラウンドトリップのオーバーヘッドが回数分だけ積み重なる。クエリ自体は軽くても、ネットワークレイテンシが支配的になるパターンだ。

解消のアプローチ

代表的な解決策は eager loading(事前読み込み)と JOIN の 2 つに大別できる。

手法フレームワーク例概要
eager loadingRails の includes / Django の select_related / Prisma の includeORM が関連データをまとめて取得するクエリを自動生成する
明示的 JOIN生 SQL / Supabase の .select("*, children(*)")1 回のクエリで親子を結合して返す
DataLoader パターンGraphQL の DataLoader / Next.js の cache()リクエスト内でキーを集約し、バッチクエリに変換する

Supabase を使う場合、PostgREST の埋め込みクエリ(select("*, translations(*)"))を活用すれば、アプリ側でループを書かずに 1 リクエストで関連データまで取得できる。

検出と予防

開発段階で N+1 を見つけるには、クエリログの監視が有効だ。PostgreSQL なら log_min_duration_statement を 0 に設定して全クエリをログに出し、同一パターンのクエリが連続していないか確認する。Rails には bullet gem、Django には django-debug-toolbar といった検出ツールもある。

コードレビューの段階で「ループ内に await が入っていないか」を意識するだけでも、かなりの N+1 を未然に防げる。