mcpField
N
n-plus-one-prevention

n-plus-one-prevention

github.com/yanko-belov開発1340 回インストール40 週間前に更新
リポジトリ

n-plus-one-prevention について

ループ内で繰り返しクエリを発行せず、関連データをまとめて取得して N+1 問題を防ぐためのスキルです。イーガーローディング、クエリ数の監視、典型的な問題パターンを説明します。

主な機能

  1. 01ループ内クエリの検出
  2. 02関連データのイーガーローディング
  3. 03サブクエリや GROUP BY による集計取得
  4. 04リクエストごとのクエリ数監視
  5. 05よくある N+1 の兆候の特定

活用シーン

  1. 01一覧エンドポイントでの関連データ読み込み
  2. 02API レスポンス内のクエリ削減
  3. 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)を使って関連データを取得しましょう。クエリ数を確認してください。ループ内のクエリは、データが増えれば問題になるバグです。

コメント