N+1 問題
1 回のクエリで取得したリストの各要素に対して追加クエリが発行され、クエリ数が爆発する性能問題
N+1 問題とは
N+1 問題は、1 回のクエリでリスト (N 件) を取得した後、各要素の関連データを取得するために N 回の追加クエリが発行される性能問題である。合計 N+1 回のクエリが実行され、N が大きいほどレイテンシが線形に悪化する。
この問題が厄介なのは、開発環境のテストデータ (数十件) では気づきにくく、本番環境のデータ量 (数千〜数万件) で突然レスポンスが数秒〜数十秒に劣化する点だ。コードレビューでも見落としやすく、ORM を使っている場合は SQL が隠蔽されるため発見がさらに遅れる。
具体例で理解する
ブログ記事の一覧ページで、各記事の著者名を表示するケースを考える。
-- 1回目: 記事一覧を取得 (100件)
SELECT * FROM articles ORDER BY created_at DESC LIMIT 100;
-- 2〜101回目: 各記事の著者を個別取得
SELECT * FROM users WHERE id = 1; -- 記事1の著者
SELECT * FROM users WHERE id = 5; -- 記事2の著者
SELECT * FROM users WHERE id = 3; -- 記事3の著者
... -- 合計101回のクエリ
1 クエリあたりの往復に 2ms かかるとすれば、101 回で 200ms を超える。ここで効くのはテーブル全体のデータ量ではなく、1 ページで取得する件数だ。同じコードで 1 ページ 1,000 件を返せば約 2 秒、10,000 件なら約 20 秒になる。ネットワーク越しの DB なら 1 往復が 2ms で収まらないことも多く、劣化幅はさらに広がる。
解決策
JOIN による統合
最もシンプルな解決策。1 回のクエリで関連データを含めて取得する。
SELECT a.*, u.name AS author_name
FROM articles a
JOIN users u ON a.author_id = u.id
ORDER BY a.created_at DESC
LIMIT 100;
-- 1回のクエリで完了
ただし JOIN は万能ではない。1 対多の関連 (記事とコメントなど) を JOIN で取ると、親側の列が子の件数だけ繰り返されて転送量が膨らみ、アプリ側で重複行をまとめ直す処理が必要になる。多対 1 や 1 対 1 の関連は JOIN、1 対多は次の IN 句によるバッチ取得、と使い分けるのが素直だ。
IN 句によるバッチ取得
JOIN が使えない場合や、関連データを別テーブルから取得する場合に有効。2 回のクエリで済む。
-- 1回目: 記事一覧
SELECT * FROM articles ORDER BY created_at DESC LIMIT 100;
-- 2回目: 著者をまとめて取得
SELECT * FROM users WHERE id IN (1, 5, 3, 7, ...);
-- 合計2回のクエリで完了
IN の要素数は無制限ではない。プレースホルダの個数に上限を持つ DB もあり、要素が増えると実行計画が悪化することもある。件数が読めない場面では数百件単位のチャンクに分割して発行する。
DataLoader パターン
GraphQL で広く使われるパターン。リクエスト内で発生する個別の取得を自動的にバッチ化する。
// バッチ関数は keys と同じ長さ・同じ順序の配列を返す契約になっている
const userLoader = new DataLoader(async (ids: readonly string[]) => {
const users = await db.query('SELECT * FROM users WHERE id IN (?)', [ids]);
return ids.map(id => users.find(u => u.id === id) ?? null);
});
// 同一ティック内に発生した load だけが 1 クエリにまとまる
const [author1, author2] = await Promise.all([
userLoader.load('1'),
userLoader.load('5'),
]);
落とし穴が 2 つある。1 つはバッチ化の単位で、まとまるのは同一ティック (イベントループの 1 フレーム) 内に発生した load だけである。await userLoader.load(...) を 1 件ずつ順に待つと 2 件目の load は次のティックに落ちるため、クエリは 1 回にまとまらない。GraphQL の resolver が並行に走る前提だからバッチが成立するのであって、逐次に書き換えた時点で N+1 へ戻る。もう 1 つはキャッシュの寿命で、DataLoader はインスタンス内に load 結果を保持する。インスタンスを使い回すとリクエストをまたいで古い値が返るため、公式にもリクエストごとに新しいインスタンスを作ることが推奨されている。
ORM ごとの対策
ORM の遅延読み込み (Lazy Loading) は N+1 の温床だ。各 ORM で明示的な一括読み込みを指定する必要がある。
| ORM | N+1 が起きるコード | 解決策 |
|---|---|---|
| Prisma | findMany() 後に article.author にアクセス | include: { author: true } |
| TypeORM | find() 後にリレーションにアクセス | relations: ['author'] または QueryBuilder で JOIN |
| Sequelize | findAll() 後にアソシエーションにアクセス | include: [User] |
| Django ORM | Article.objects.all() 後に article.author | select_related('author') |
DynamoDB での N+1
RDB だけの問題ではない。DynamoDB でも同様のパターンが発生する。
// ❌ N+1: 注文一覧を取得後、各注文のユーザーを個別取得
const orders = await docClient.query({ TableName: 'Orders', ... });
for (const order of orders.Items) {
const user = await docClient.get({
TableName: 'Users', Key: { userId: order.userId }
});
}
// ✅ BatchGetItem で最大 100 項目をまとめて取得
const userIds = [...new Set(orders.Items.map(o => o.userId))];
const users = await docClient.batchGet({
RequestItems: {
Users: { Keys: userIds.map(id => ({ userId: id })) }
}
});
BatchGetItem には 1 回の呼び出しで 100 項目・16 MB という上限がある。上限に触れたりテーブルのスループットが不足したりすると応答は部分結果になり、読めなかったキーが UnprocessedKeys として返る。ここを見ていないと「たまに一部のユーザー名が空欄になる」形の欠落として現れる。指数バックオフを挟み、UnprocessedKeys が空になるまで再試行するのが正しい使い方だ。存在しないキーは結果に含まれず、返る順序も保証されないため、受け取った側はキーで引き当てる形に組む。
DynamoDB の根本的な解決策はシングルテーブル設計だ。注文とユーザーを同じテーブルに格納し、1 回の Query で関連データを含めて取得する。ただしシングルテーブル設計はアクセスパターンの事前設計が必要で、後からの変更が難しいトレードオフがある。
検出方法
N+1 問題は発生してから気づくのでは遅い。以下の方法で早期に検出する。
- クエリログの監視: 同一リクエスト内で同じテーブルへの SELECT が大量に発行されていないか確認する
- APM ツール: X-Ray や Datadog で、1 リクエストあたりのクエリ数をメトリクスとして監視する
- ORM のクエリログ: 開発環境で ORM が発行する SQL をログ出力し、N+1 パターンを目視確認する
- テストでの検証: テスト内でクエリ数をカウントし、閾値を超えたら失敗させる
閾値に絶対的な正解はない。実用的なのは、画面ごとに必要なクエリ数を先に数えて「この一覧は 3 回で足りるはず」と決め、そこから逸脱したら気づける形にすることだ。件数そのものより、機能追加のたびに静かに増えていないかを見る。
実践的な知識は関連書籍でも得られる。
この記事は役に立ちましたか?
関連用語
データベースインデックス
検索クエリの高速化のためにデータベースが維持する補助的なデータ構造
キャッシュ
頻繁にアクセスされるデータを高速なストレージに保存し、読み取り性能を向上させる手法
シャーディング
データを複数のデータベースインスタンスに水平分割し、書き込みスループットとストレージ容量をスケールさせる手法
シングルテーブル設計
DynamoDB で複数のエンティティを 1 つのテーブルに格納し、キー設計で関連データを 1 回の Query にまとめる設計手法
DynamoDB
AWS のフルマネージド NoSQL データベースで、ミリ秒単位のレイテンシとシームレスなスケーリングを提供する
GraphQL
クライアントが必要なデータだけを指定して取得できる API クエリ言語