n-plus-one-prevention について
ループ内で繰り返しクエリを発行せず、関連データをまとめて取得して N+1 問題を防ぐためのスキルです。イーガーローディング、クエリ数の監視、典型的な問題パターンを説明します。
主な機能
- 01ループ内クエリの検出
- 02関連データのイーガーローディング
- 03サブクエリや GROUP BY による集計取得
- 04リクエストごとのクエリ数監視
- 05よくある N+1 の兆候の特定
活用シーン
- 01一覧エンドポイントでの関連データ読み込み
- 02API レスポンス内のクエリ削減
- 03ループ内でクエリする ORM コードの確認
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ yanko-belov/ code-craft --skill n-plus-one-preventionインストール後、Claude Code で/n-plus-one-preventionを入力して読み込まれたか確認します。
詳細
N+1 クエリの防止
概要
ループ内でクエリを実行してはいけません。関連データは1回のクエリで取得します。
N+1 とは、N 件の項目を取得した後、関連データを取得するためにさらに N 回クエリを実行することです。データベースのパフォーマンスを損なう、最もよくある原因です。
使用する場面
- 関連データを含む一覧を取得する場合
- データベースクエリを含むループ
- レスポンスの遅い一覧/インデックスエンドポイント
- 1つの API レスポンスに複数のクエリを実行する場合
絶対ルール
NEVER put a database query inside a loop.
例外はありません:
- 「項目はほんの少しだけだから」
- 「クエリは速いから」
- 「キャッシュするから」
- 「こちらのほうが簡単だから」
検出:N+1 パターン
ループ内でクエリを実行している場合は、中止してください:
// ❌ VIOLATION: N+1 queries
const orders = await Order.findAll(); // 1 query
const ordersWithCustomers = await Promise.all(
orders.map(async (order) => {
// N queries (one per order)
const customer = await Customer.findByPk(order.customerId);
return { ...order, customerName: customer.name };
})
);
// Total: 1 + N queries
注文が100件なら、データベースクエリは101回になります!
正しいパターン:Eager Loading
// ✅ CORRECT: Single query with JOIN
// Sequelize
const orders = await Order.findAll({
include: [{ model: Customer, attributes: ['name'] }]
});
// 1 query with JOIN
// Prisma
const orders = await prisma.order.findMany({
include: { customer: { select: { name: true } } }
});
// TypeORM
const orders = await orderRepository.find({
relations: ['customer']
});
// Raw SQL
const orders = await db.query(`
SELECT o.*, c.name as customer_name
FROM orders o
JOIN customers c ON o.customer_id = c.id
`);
よくある N+1 のケース
1. 関連エンティティ
// ❌ N+1
posts.map(post => await User.findById(post.authorId));
// ✅ Eager load
Post.findAll({ include: [User] });
2. 集計
// ❌ N+1
users.map(user => await Order.count({ where: { userId: user.id } }));
// ✅ Subquery or GROUP BY
User.findAll({
attributes: {
include: [[sequelize.fn('COUNT', sequelize.col('orders.id')), 'orderCount']]
},
include: [{ model: Order, attributes: [] }],
group: ['User.id']
});
3. 複数のリレーション
// ❌ N+1 (multiple)
orders.map(order => {
await Customer.findByPk(order.customerId);
await Product.findAll({ where: { orderId: order.id } });
});
// ✅ Eager load all
Order.findAll({
include: [Customer, Product]
});
検出ツール
// Log query count per request
let queryCount = 0;
db.on('query', () => queryCount++);
app.use((req, res, next) => {
queryCount = 0;
res.on('finish', () => {
if (queryCount > 10) {
console.warn(`N+1 alert: ${req.path} made ${queryCount} queries`);
}
});
next();
});
プレッシャーへの対処手順
1. 「項目はほんの少しだけです」
プレッシャー:「注文は10件しかありません」
**回答:**10件は100件になり、やがて10,000件になります。今すぐ修正しましょう。
**対応:**現在のデータ量にかかわらず、必ず Eager Loading を使いましょう。
2. 「クエリは速いです」
プレッシャー:「各クエリにかかる時間は1msです」
**回答:**1ms × 1000 = 1秒です。ネットワークのオーバーヘッドも加わります。
**対応:**1回の5msのクエリは、1000回の1msのクエリより優れています。
3. 「キャッシュします」
プレッシャー:「Redis で結果をキャッシュします」
**回答:**キャッシュミスが発生すれば、データベースへのアクセスは避けられません。最初のリクエストは遅くなり、キャッシュによって複雑さも増します。
**対応:**まずクエリを修正しましょう。それでも必要ならキャッシュを使います。
4. 「こちらのほうが簡単です」
プレッシャー:「ループのほうが理解しやすいです」
**回答:**100倍遅いシンプルなコードは、シンプルとは言えません。
**対応:**ORM の Eager Loading の構文を覚えましょう。
警告サイン — 中止して再検討してください
.map()または.forEach()の中にあるawait- 結果の件数に応じてクエリ数が増える
- 一覧エンドポイントが詳細エンドポイントより遅い
- 一覧の「読み込み中…」がいつまでも終わらない
- ORM がデフォルトで遅延読み込みを行う
これらはすべて、Eager Loading にリファクタリングすべきサインです。
クイックリファレンス
| N+1(悪い例) | Eager Loading(良い例) |
|---|---|
| ループ+クエリ | JOIN / include |
| 1 + N 回のクエリ | 1回のクエリ |
| O(N) 回の往復 | O(1) 回の往復 |
| データが増えるほど遅くなる | クエリ数は一定 |
よくある言い訳(どれも不適切です)
| 言い訳 | 実情 |
|---|---|
| 「項目が少ない」 | データは増えます。今すぐ修正しましょう。 |
| 「クエリが速い」 | 遅いクエリが N 回より、中程度のクエリが1回のほうが優れています。 |
| 「キャッシュする」 | キャッシュでは不適切なクエリは解決しません。 |
| 「こちらのほうが簡単」 | 遅いものはシンプルではありません。 |
| 「ORM が処理してくれる」 | ORM はデフォルトで遅延読み込みを行います。 |
結論
一覧には1回のクエリ。関連データには1回のクエリ。ループ内でクエリを実行してはいけません。
Eager Loading(include/join)を使って関連データを取得しましょう。クエリ数を確認してください。ループ内のクエリは、データが増えれば問題になるバグです。