mcpField
A
api-performance-api-performance

api-performance-api-performance

github.com/agents-inc開発2680 回インストール2 日前に更新
リポジトリ

api-performance-api-performance について

APIやデータベース連携サービスを開発する人向けのバックエンド性能最適化スキルです。計測を先に行い、クエリ、キャッシュ、接続管理、非同期処理、ページネーションを調整します。

主な機能

  1. 01データベースのインデックスとクエリ調整
  2. 02接続プールと確実な解放
  3. 03先読み読み込みによるN+1防止
  4. 04TTL付きキャッシュパターン
  5. 05イベントループとワーカースレッドの指針
  6. 06キーセットページネーション

活用シーン

  1. 01遅いデータベースクエリの調査
  2. 02データの繰り返し取得負荷の軽減
  3. 03CPU負荷の高い処理によるリクエスト停止の防止
  4. 04大規模データセットのページ分割

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/agents-inc/skills --skill api-performance-api-performance

インストール後、Claude Code で/api-performance-api-performanceを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

metadata.yaml
reference.md
examples/
async.md
caching.md
core.md

詳細

バックエンドのパフォーマンス最適化

クイックガイド: データベースクエリの最適化(インデックス、プリペアドステートメント、N+1 の回避)、キャッシュ戦略(キャッシュ・アサイド、ライトスルー)、コネクションプーリング、ノンブロッキングの非同期パターンを通じて、バックエンドのパフォーマンスを最適化します。最適化の前に必ず計測してください。複雑さを加える前に、EXPLAIN ANALYZE を実行し、イベントループの遅延を確認し、キャッシュヒット率を追跡します。


<critical_requirements>

重要:このスキルを使用する前に

(finally ブロックを使用して、必ずデータベース接続をプールに返却してください)

(N+1 クエリを防ぐため、必ず先行読み込みまたはバッチ処理(DataLoader)を使用してください。ループ内で遅延読み込みをしてはいけません)

(古いデータやメモリ枯渇を防ぐため、すべてのキャッシュデータに必ず TTL を設定してください)

(CPU 負荷の高い処理は必ず Worker Threads にオフロードしてください。イベントループをブロックすると、すべてのリクエストが遅くなります)

</critical_requirements>


詳細なリソース:

  • examples/core.md - データベースのパターン:コネクションプーリング、N+1 の防止、インデックス作成、プリペアドステートメント、ページネーション
  • examples/caching.md - キャッシュ・アサイド、ライトスルー、無効化、キー戦略、TTL の指針
  • examples/async.md - イベントループの最適化、ワーカースレッド、チャンク処理、並行処理の制御
  • reference.md - 意思決定のフレームワーク、パフォーマンスの監視

自動検出: コネクションプール、クエリ最適化、データベースインデックス、N+1、キャッシュ、キャッシュ無効化、プリペアドステートメント、ワーカースレッド、イベントループ、CPU バウンド、レイテンシ、スループット、パフォーマンスチューニング、EXPLAIN ANALYZE、キーセットページネーション、キャッシュ・アサイド、ライトスルー

使用する場面:

  • データベースクエリに 100ms を超える時間がかかる場合
  • データを繰り返し取得する、高トラフィックのエンドポイント
  • 関連エンティティが複数ある API レスポンス(N+1 のリスク)
  • CPU 負荷の高い処理がリクエスト処理をブロックしている場合
  • キャッシュを使ってデータベースの負荷を軽減する必要がある場合

使用しない場面:

  • 事前に計測せず、早まった最適化を行う場合
  • トラフィックの少ないシンプルな CRUD(メリットがなく、複雑さだけが増すため)
  • 頻繁に変更され、常に最新でなければならないデータ(キャッシュによって古くなるため)
  • 開発・デバッグ時(キャッシュが問題を見えにくくするため)

対象となる主なパターン:

  • データベースのインデックス戦略(複合、部分、カバリング)
  • 確実に接続を返却するコネクションプーリング
  • N+1 クエリの防止(先行読み込み、DataLoader)
  • キャッシュ戦略(キャッシュ・アサイド、ライトスルー、無効化)
  • イベントループの最適化(非同期 I/O、setImmediate によるチャンク処理)
  • CPU バウンド処理向けのワーカースレッド
  • 大規模なデータセット向けのキーセットページネーション

基本方針

バックエンドのパフォーマンス最適化には、「まず計測し、その後に最適化する」という中核原則があります。早まった最適化は、効果を示す根拠がないまま開発時間を浪費し、複雑さを増やします。

バックエンドのパフォーマンスを支える3つの柱:

  1. データベースの最適化 - インデックス、クエリプランニング、N+1 の防止、ページネーション
  2. キャッシュ - TTL を設けたキャッシュで、コストの高い処理の繰り返しを減らす
  3. 非同期処理の効率化 - イベントループを決してブロックしない

最適化する場面:

  • 応答時間が SLA のしきい値を超えている
  • データベースの CPU/メモリ使用量が上限に近づいている
  • メトリクスで具体的なボトルネックが確認できる(EXPLAIN ANALYZE、イベントループの遅延)
  • 負荷テストでスケーリング上の問題が明らかになっている

最適化しない場面:

  • 「いつか遅くなるかもしれない」という理由(早まった最適化)
  • コールドパスの最適化(実行頻度の低いコード)
  • プロファイリングで実際のボトルネックを特定する前

基本パターン

パターン 1:接続を確実に解放するコネクションプーリング

コネクションプーリングでは、リクエストごとに新しいデータベース接続を作成する代わりに、接続を再利用します。PostgreSQL のハンドシェイクには 20~30ms かかりますが、プーリングによりこのオーバーヘッドをなくせます。

主なルール:

  • 単純なクエリには pool.query() を使用します(接続のライフサイクルを自動で管理します)
  • トランザクションでは、pool.connect() で手動で接続を取得し、finally で必ず解放します
  • プールのエラーを監視します(アイドル状態のクライアントでもエラーが発生することがあります)
// Transaction with guaranteed connection release
async function createUserWithProfile(
  userData: UserData,
  profileData: ProfileData,
) {
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    const userResult = await client.query(
      "INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id",
      [userData.name, userData.email],
    );
    await client.query("INSERT INTO profiles (user_id, bio) VALUES ($1, $2)", [
      userResult.rows[0].id,
      profileData.bio,
    ]);
    await client.query("COMMIT");
    return userResult.rows[0];
  } catch (error) {
    await client.query("ROLLBACK");
    throw error;
  } finally {
    client.release(); // CRITICAL: Always release back to pool
  }
}

適切な理由: エラーが発生した場合でも finally によって接続が確実に解放され、プールの枯渇を防げます。

プールの設定全般、サイズの計算式、外部プーラーのガイダンスについては、examples/core.md を参照してください。


パターン 2:N+1 クエリの防止

N+1 問題は、N 件のレコードを取得するときに、関連データの取得のために追加で N 個のクエリが発生する問題です。100 件のレコードでは、データベースとの往復が 101 回になります。

2 つの解決策:

  1. Eager loading(ORM の .with())-- 関連がわかっている場合、JOIN を使って 1 回のクエリで取得します
  2. DataLoader -- .load() の呼び出しをまとめ、ティックごとに 1 回のクエリにします。GraphQL に最適です
// Eager loading: single query fetches jobs + companies + skills
const jobs = await db.query.jobs.findMany({
  where: and(eq(jobs.isActive, true), isNull(jobs.deletedAt)),
  with: {
    company: { with: { locations: true } },
    jobSkills: { with: { skill: true } },
  },
});
// BAD: N+1 anti-pattern -- one query per job
for (const job of jobs) {
  job.company = await db.query.companies.findFirst({
    where: eq(companies.id, job.companyId),
  });
}

不適切な理由: 求人情報の取得に 1 回のクエリ、企業情報の取得に N 回のクエリが必要となり、データ量に比例してレイテンシーが増加します。

DataLoader のバッチ処理パターンについては、examples/core.md を参照してください。


パターン 3:データベースのインデックス作成

インデックスは、テーブル全体のスキャンを避けてクエリを高速化します。WHERE、JOIN、ORDER BY 句で使用する列にインデックスを作成してください。

// Strategic indexes on a table definition
export const jobs = pgTable(
  "jobs",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    companyId: uuid("company_id").notNull(),
    country: varchar("country", { length: 100 }),
    employmentType: varchar("employment_type", { length: 50 }),
    isActive: boolean("is_active").default(true),
    createdAt: timestamp("created_at").defaultNow(),
    deletedAt: timestamp("deleted_at"),
  },
  (table) => [
    // Composite index for common filter combination
    index("jobs_country_employment_idx").on(
      table.country,
      table.employmentType,
    ),
    // Partial index -- only indexes active non-deleted jobs
    index("jobs_active_idx")
      .on(table.isActive, table.createdAt)
      .where(sql`${table.deletedAt} IS NULL`),
    // Foreign key index for JOIN performance
    index("jobs_company_id_idx").on(table.companyId),
  ],
);

インデックス選択の指針:

列の使用方法インデックスの種類使用する場面
WHERE の等価条件B-tree(デフォルト)選択性の高い列
WHERE の範囲条件(>, <, BETWEEN)B-tree日付範囲、数値範囲
WHERE の複数列条件複合インデックス常に同じ列の組み合わせで絞り込むクエリ
WHERE の一部の条件部分インデックス多くのクエリが有効なデータ/削除されていないデータに絞り込む場合
全文検索GIN/GiSTLIKE、tsvector によるテキスト検索
JSON フィールドへのアクセスGINJSONB 列へのクエリ

複合インデックスの列順: 等価条件を先に、範囲条件を最後にし、選択性の高い列を先にします。

EXPLAIN ANALYZE の例、インデックス監視用クエリ、未使用インデックスの検出については、examples/core.md を参照してください。


パターン 4:TTL を使用したキャッシュ・アサイド

最も一般的なキャッシュパターンです。まずキャッシュを確認し、キャッシュミス時にデータベースから取得して、TTL を設定して保存します。

const CACHE_TTL_SECONDS = 300;
const CACHE_PREFIX = "app:user";

async function getUserById(userId: string): Promise<User | null> {
  const cacheKey = `${CACHE_PREFIX}:${userId}`;
  const cached = await cacheClient.get(cacheKey);
  if (cached) return JSON.parse(cached) as User;

  const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
  if (!user) return null;

  await cacheClient.set(cacheKey, JSON.stringify(user), {
    EX: CACHE_TTL_SECONDS,
  });
  return user;
}

適切な理由: TTL によって古いデータの蓄積を防ぎ、名前空間付きのキーによって衝突を防ぎ、キャッシュヒット時には早期リターンできます。

ライトスルー、タグベースの無効化、キー戦略、TTL の指針については、examples/caching.md を参照してください。


パターン 5:CPU 負荷の高い処理に Worker Threads を使用

Node.js は JavaScript の実行に単一のスレッドを使用します。CPU 負荷の高い処理は、並行して処理されるすべてのリクエストをブロックします。

経験則:

CPU 処理時間解決策理由
< 50msメインスレッドで実行ワーカーのオーバーヘッドに見合いません
50-500mssetImmediate による分割チャンク間でイベントループに処理を譲ります
> 500msWorker Threads別のスレッドに完全にオフロードします
// Chunked processing with setImmediate -- yields to event loop between batches
const CHUNK_SIZE = 100;

async function processLargeArray(items: Item[]): Promise<ProcessedItem[]> {
  const results: ProcessedItem[] = [];
  for (let i = 0; i < items.length; i += CHUNK_SIZE) {
    const chunk = items.slice(i, i + CHUNK_SIZE);
    for (const item of chunk) {
      results.push(expensiveTransform(item));
    }
    if (i + CHUNK_SIZE < items.length) {
      await new Promise((resolve) => setImmediate(resolve));
    }
  }
  return results;
}

ワーカープールの実装、p-limit による並行処理の制御、イベントループの遅延監視については、examples/async.md を参照してください。


パターン 6:大規模データセット向けのキーセットページネーション

OFFSET によるページネーションでは、前の行をすべてスキャンします。OFFSET が 100,000 の場合、データベースは 100,000 行を読み取って破棄します。キーセットページネーションではカーソルを使用するため、一定時間で処理できます。

-- BAD: OFFSET scans all previous rows
SELECT * FROM products ORDER BY id LIMIT 20 OFFSET 100000;

-- GOOD: Keyset pagination -- constant time regardless of position
SELECT * FROM products
WHERE id > :last_seen_id
ORDER BY id LIMIT 20;

OFFSET を使用する場合: 小規模なデータセット(< 100k 行)、合計件数が必要な場合、任意のページへのアクセスが必要な場合。

キーセットを使用する場合: 大規模なデータセット、無限スクロール、挿入による重複を防ぐ必要があるリアルタイムデータ。

両方のパターンの TypeScript 実装については、examples/core.md を参照してください。


<red_flags>

要注意事項

優先度の高い問題:

  • 接続の解放漏れ — 接続がプールに返却されないと、プールが枯渇してアプリケーションが停止します
  • ループ内の N+1 クエリ — 関連データを一括読み込みせず、1 件ずつ取得するとパフォーマンスが大きく低下します
  • イベントループのブロック — 同期 I/O や CPU 負荷の高い処理は、すべての並行リクエストをブロックします
  • TTL のないキャッシュ — キャッシュが無制限に増加してメモリを使い果たすか、古いデータを無期限に返します
  • 大規模なテーブルの全件スキャン — WHERE/JOIN 列にインデックスがありません

優先度が中程度の問題:

  • 外部キーにインデックスがない — JOIN や ON DELETE CASCADE が遅くなります
  • インデックスの過剰な作成 — インデックスはすべて書き込みを遅くします。pg_stat_user_indexes を使って未使用のインデックスを削除してください
  • キャッシュキーの衝突 — 汎用的なキーを使うと、誤ったデータが別のユーザーに返されます
  • 大規模なテーブルでの OFFSET ページネーション — OFFSET は前の行をすべてスキャンします。キーセットページネーションを使用してください
  • 無制限の並列処理 — 10,000 件の項目に対して Promise.all を実行すると、下流サービスに過剰な負荷がかかります

注意点とエッジケース:

  • EXPLAIN は推定値を表示し、EXPLAIN ANALYZE は実測値を表示します。実際のパフォーマンスデータには必ず ANALYZE を使用してください
  • 複合インデックス(a, b)は、b のみで絞り込むクエリには役立ちません。列の順序が重要です
  • インデックス付き列に関数を適用すると(例:YEAR(created_at))、インデックスが使われなくなります。範囲条件に書き換えてください
  • DataLoader のキャッシュはリクエスト内で有効です。リクエスト間で再利用すると、古いデータが返されます
  • Worker Threads の起動には約 30ms かかります。高速に処理できる操作には使用しないでください
  • Connection pool の idleTimeoutMillis を短く設定しすぎると、「connection terminated unexpectedly」が発生する場合があります
  • SELECT * は不要なデータを取得し、カバリングインデックスの最適化を妨げます。必要な列を指定してください
  • EX オプション付きのキャッシュ SET は既存の TTL を置き換えます。SET を再度呼び出すと、有効期限のタイマーがリセットされます

</red_flags>


<critical_reminders>

重要な注意事項

(データベース接続は必ず finally ブロックを使用してプールに返却してください)

(N+1 クエリを防ぐため、必ず一括読み込みまたはバッチ処理(DataLoader)を使用してください。ループ内で遅延読み込みを行わないでください)

(古いデータやメモリの枯渇を防ぐため、キャッシュデータには必ず TTL を設定してください)

(CPU 負荷の高い処理は必ず Worker Threads に移してください。イベントループがブロックされると、すべてのリクエストの処理が低下します)

これらのルールに従わないと、接続プールの枯渇、N+1 クエリによるパフォーマンス低下、無制限のキャッシュによるメモリリーク、そしてすべての並行リクエストに影響するイベントループのブロックが発生します。

</critical_reminders>

コメント