N+1 クエリ:ORM で最も典型的な性能の罠

100 件を取り出してから一件ずつ関連を引くと 101 回のクエリになります。直し方は三つだけです。先読み、一括取得、あるいは一回の JOIN。

N+1 の形は決まっています。親の行をまとめて取り、その後で一行ずつ関連を引く。親が N 件なら N+1 回の往復です。

const posts = await db.post.findMany();          // 1 回
for (const post of posts) {
  post.author = await db.user.findUnique({       // N 回
    where: { id: post.authorId },
  });
}

記事が 100 件なら 101 回です。一回は速くても、往復の遅延が積み上がれば画面は数秒になります。

三つの直し方

先読み(ORM の include / with)。 多くの ORM は一文で済ませます。

const posts = await db.post.findMany({ include: { author: true } });

内部では通常、記事を取る一文と WHERE id IN (...) で著者を取る一文の二回です。N+1 回ではありません。これが第一候補です。

手動の一括取得。 外部キーを集め、一度で取得し、メモリ上で結びます。

const posts = await db.post.findMany();
const ids = [...new Set(posts.map((p) => p.authorId))];
const authors = await db.user.findMany({ where: { id: { in: ids } } });
const byId = new Map(authors.map((a) => [a.id, a]));
for (const post of posts) post.author = byId.get(post.authorId);

JOIN。 関係が単純で入れ子構造が不要なら、一回のクエリが最も直接的です。代償は親の行が結合先の分だけ増えるため、重複除去が必要になることです。

見つけ方

N+1 は開発環境では見えません。手元のデータベースにネットワーク遅延は無く、100 回のクエリは数ミリ秒で終わります。本番で初めて現れます。

手段 方法
クエリログ SQL ログを有効にし、同じ形の文が繰り返されるか見る
APM リクエストあたりのクエリ数を数え、閾値で警告
ORM のフック テストで「クエリ数は K 以下」を表明する

三番目が最も効きます。クエリ数をテストの表明にすることで、本番ではなく CI で止められます。

無視してよい場合

  • 親の件数に上限がある(多くても五件など)
  • 関連はキャッシュから来てクエリを発行しない
  • そのエンドポイントが重要経路に無い

判断は実際のデータ規模です。ページ送りで一頁 20 件なら 21 回は通常許容できます。ただしその中でさらに関連を入れ子にしないことが条件です。

N+1 が遅いのは一回のクエリのせいではありません。往復回数のせいです。発見には計数、修正には一括取得が要ります。

← 記事一覧に戻る

コメント

…