api-performance-api-performance について
APIやデータベース連携サービスを開発する人向けのバックエンド性能最適化スキルです。計測を先に行い、クエリ、キャッシュ、接続管理、非同期処理、ページネーションを調整します。
主な機能
- 01データベースのインデックスとクエリ調整
- 02接続プールと確実な解放
- 03先読み読み込みによるN+1防止
- 04TTL付きキャッシュパターン
- 05イベントループとワーカースレッドの指針
- 06キーセットページネーション
活用シーン
- 01遅いデータベースクエリの調査
- 02データの繰り返し取得負荷の軽減
- 03CPU負荷の高い処理によるリクエスト停止の防止
- 04大規模データセットのページ分割
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ agents-inc/ skills --skill api-performance-api-performanceインストール後、Claude Code で/api-performance-api-performanceを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
metadata.yamlreference.mdexamples/async.mdcaching.mdcore.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つの柱:
- データベースの最適化 - インデックス、クエリプランニング、N+1 の防止、ページネーション
- キャッシュ - TTL を設けたキャッシュで、コストの高い処理の繰り返しを減らす
- 非同期処理の効率化 - イベントループを決してブロックしない
最適化する場面:
- 応答時間が 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 つの解決策:
- Eager loading(ORM の
.with())-- 関連がわかっている場合、JOIN を使って 1 回のクエリで取得します - 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/GiST | LIKE、tsvector によるテキスト検索 |
| JSON フィールドへのアクセス | GIN | JSONB 列へのクエリ |
複合インデックスの列順: 等価条件を先に、範囲条件を最後にし、選択性の高い列を先にします。
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-500ms | setImmediate による分割 | チャンク間でイベントループに処理を譲ります |
| > 500ms | Worker 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>