チューニング

N+1 問題 — ORM ループの静かな殺し屋

ORM で頻発する N+1 問題の典型パターンと、eager loading・JOIN・IN バッチによる解消法。

N+1 問題 — ORM ループの静かな殺し屋 diagram

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 クエリが発行されているのが明確に見えます。

sql
-- 悪い例: ループでアクセス (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_statementscalls が異常に多いクエリを特定
  • 専用リンタ: 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 クエリにまとめる かのいずれかになります。

sql
-- 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); -- 2

JOIN か IN(...) か — 使い分け

eager loading には 2 系統あります:

  • JOIN 型(Rails eager_load, Django select_related): 1 クエリで完結。親カラムの値が子の数だけ重複するので、子のデータ量が大きいと重複転送が無駄
  • IN(...) 型(Rails preload, Django prefetch_related, SQLAlchemy selectinload): 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 防止の事実上の標準です。