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,000 件なら 2 秒、10,000 件なら 20 秒だ。
解決策
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回のクエリで完了
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回のクエリで完了
DataLoader パターン
GraphQL で広く使われるパターン。リクエスト内で発生する個別の取得を自動的にバッチ化する。
// DataLoader が同一ティック内のリクエストをバッチ化
const userLoader = new DataLoader(async (ids: string[]) => {
const users = await db.query('SELECT * FROM users WHERE id IN (?)', [ids]);
return ids.map(id => users.find(u => u.id === id));
});
// 個別に呼んでも内部でバッチ化される
const author1 = await userLoader.load('1');
const author2 = await userLoader.load('5');
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 })) }
}
});
DynamoDB の根本的な解決策はシングルテーブル設計だ。注文とユーザーを同じテーブルに格納し、1 回の Query で関連データを含めて取得する。ただしシングルテーブル設計はアクセスパターンの事前設計が必要で、後からの変更が難しいトレードオフがある。
検出方法
N+1 問題は発生してから気づくのでは遅い。以下の方法で早期に検出する。
- クエリログの監視: 同一リクエスト内で同じテーブルへの SELECT が大量に発行されていないか確認する
- APM ツール: X-Ray や Datadog で、1 リクエストあたりのクエリ数をメトリクスとして監視する
- ORM のクエリログ: 開発環境で ORM が発行する SQL をログ出力し、N+1 パターンを目視確認する
- テストでの検証: テスト内でクエリ数をカウントし、閾値を超えたら失敗させる
実務では「1 リクエストあたりのクエリ数が 20 を超えたらアラート」のような閾値を設けるチームが多い。
実践的な知識は関連書籍でも得られる。