N+1 問題 — ORM ループの静かな殺し屋
ORM で頻発する N+1 問題の典型パターンと、eager loading・JOIN・IN バッチによる解消法。
N+1 とは
N+1 問題は、親を 1 件取得(1 クエリ)したあとに、子要素を取るために N 件分のクエリを追加で発行してしまう現象です。合計 1 + N 回のクエリが飛ぶので「N+1」と呼ばれます。
1 回 1 回のクエリは軽くても、N = 100 なら 100 往復、1 往復 2ms なら 200ms、ネットワーク越しなら 1 秒を超えることも。アプリのレイテンシの 9 割が N+1 由来、というのはよくある話です。
典型パターン(コード例)
ORM で書くとこうなりがちです:
- 親(users)をまず取得
- for ループの中で
user.ordersにアクセス → 1 件ごとに SELECT が走る
SQL に展開すると、N+1 クエリが発行されているのが明確に見えます。
-- 悪い例: ループでアクセス (ORM 擬似コード)
-- users = User.all # 1 クエリ
-- for u in users:
-- print(u.orders) # N 件分のクエリ
-- 実際に飛ぶ SQL
SELECT * FROM users; -- 1
SELECT * FROM orders WHERE user_id = 1; -- 2
SELECT * FROM orders WHERE user_id = 2; -- 3
SELECT * FROM orders WHERE user_id = 3; -- 4
-- ... N 回続く発見方法
見つけるコツは「実際に飛んだ SQL を全部見る」こと:
- ORM のクエリログを開発時は全出力(Rails なら development.log、Django なら
DEBUG=Trueでコンソール、Prisma ならlog: ['query']、Hibernate ならshow_sql=true) - 同じ形の SELECT が連番で並んでいたら N+1
- APM / トレース(Datadog, New Relic, Sentry)のスパン一覧でも一目瞭然
- PostgreSQL なら
pg_stat_statementsで calls が異常に多いクエリを特定 - 専用リンタ: Rails の
bullet、Django のnplusone
テストで N+1 を検出する「クエリ数アサーション」を書いておくと、リグレッション防止に有効です。
解消方法 1: eager loading / JOIN
ORM の eager loading(事前読み込み)を使えば、親子をまとめて 1〜2 クエリで取得できます。
- Rails:
User.includes(:orders) - Django:
User.objects.prefetch_related('orders')/select_related - Prisma:
prisma.user.findMany({ include: { orders: true }}) - SQLAlchemy:
selectinload(User.orders)
内部的には JOIN で一発取得 するか、IN (...) で 2 クエリにまとめる かのいずれかになります。
-- JOIN で一発
SELECT u.*, o.*
FROM users u
LEFT JOIN orders o ON o.user_id = u.id;
-- または IN(...) で 2 クエリ
SELECT * FROM users; -- 1
SELECT * FROM orders WHERE user_id IN (1,2,3); -- 2JOIN か IN(...) か — 使い分け
eager loading には 2 系統あります:
- JOIN 型(Rails
eager_load, Djangoselect_related): 1 クエリで完結。親カラムの値が子の数だけ重複するので、子のデータ量が大きいと重複転送が無駄 - IN(...) 型(Rails
preload, Djangoprefetch_related, SQLAlchemyselectinload): 2 クエリに分ける。重複なし。子がサブクエリ・別条件のときも対応しやすい
一般的には1 対多 / 多対多は IN(...) 型、多対 1 のようなフラットな結合は JOIN 型が良い、と覚えておくと無難です。どちらが速いかは ORM・データ量・N の大きさで変わるので、必ず実測を。
バッチ取得とデータローダ
GraphQL のようにリクエスト構造が動的な場合、ORM の eager loading だけでは対応しきれません。このとき役立つのが DataLoader パターンです。
- 1 イベントループ内で同じ種類のリクエストをキューに溜める
- まとめて
IN (...)で 1 クエリに束ねる - 結果を各呼び出し元に配る
Facebook が公開した dataloader ライブラリが起源で、Node.js / Python / Ruby / Go 等に実装があります。GraphQL リゾルバでの N+1 防止の事実上の標準です。
