关于 api-performance-api-performance
面向 API 和数据库服务开发者的后端性能优化技能,强调先测量再调优,涵盖查询、缓存、连接管理、异步处理与分页。
主要功能
- 01数据库索引与查询调优
- 02连接池及安全释放
- 03通过预加载避免 N+1
- 04基于 TTL 的缓存模式
- 05事件循环与工作线程指南
- 06键集分页
适用场景
- 01排查缓慢的数据库查询
- 02减少重复数据读取负载
- 03避免 CPU 密集任务阻塞请求
- 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)、采用缓存策略(cache-aside、write-through)、使用连接池和非阻塞异步模式来优化后端性能。优化前务必先进行度量——运行 EXPLAIN ANALYZE、检查事件循环延迟,并跟踪缓存命中率,再考虑增加复杂性。
<critical_requirements>
重要:使用此技能前须知
(必须始终使用 finally 代码块将数据库连接释放回连接池)
(必须使用预加载或批处理(DataLoader)来防止 N+1 查询——绝不能在循环中延迟加载)
(必须为所有缓存数据设置 TTL,以防止数据过时和内存耗尽)
(必须将 CPU 密集型工作交由 Worker Threads 处理——阻塞事件循环会降低所有请求的性能)
</critical_requirements>
详细资源:
- examples/core.md - 数据库模式:连接池、防止 N+1、索引、预处理语句、分页
- examples/caching.md - Cache-aside、write-through、失效处理、键策略、TTL 指南
- examples/async.md - 事件循环优化、工作线程、分块处理、并发控制
- reference.md - 决策框架、性能监控
自动检测: 连接池、查询优化、数据库索引、N+1、缓存、缓存失效、预处理语句、工作线程、事件循环、CPU 密集型、延迟、吞吐量、性能调优、EXPLAIN ANALYZE、键集分页、cache-aside、write-through
适用场景:
- 数据库查询耗时超过 100ms
- 高流量端点重复获取数据
- API 响应包含多个相关实体(存在 N+1 风险)
- CPU 密集型操作阻塞请求处理
- 需要通过缓存降低数据库负载
不适用场景:
- 尚未进行度量就过早优化
- 低流量的简单 CRUD(增加复杂性却没有收益)
- 频繁变化且必须始终保持最新的数据(缓存会导致数据过时)
- 开发/调试(缓存会掩盖问题)
涵盖的关键模式:
- 数据库索引策略(复合、部分、覆盖)
- 确保连接释放的连接池
- 防止 N+1 查询(预加载、DataLoader)
- 缓存策略(cache-aside、write-through、失效处理)
- 事件循环优化(异步 I/O、setImmediate 分块处理)
- 使用工作线程处理 CPU 密集型操作
- 大型数据集的键集分页
理念
后端性能优化遵循一个核心原则:先度量,再优化。过早优化会浪费开发时间,并在缺乏收益证据的情况下增加复杂性。
后端性能的三大支柱:
- 数据库优化 - 索引、查询规划、防止 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 次数据库往返。
两种解决方案:
- 预加载(ORM
.with())——通过一次带 JOIN 的查询加载已知关联 - DataLoader——将
.load()调用批处理为每个事件循环周期一次查询,非常适合 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),
});
}
为什么不好: 获取 jobs 需要 1 次查询,获取 companies 需要 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 子集条件 | 部分索引 | 大多数查询按 active/non-deleted 筛选 |
| 全文搜索 | 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:使用 Worker Threads 处理 CPU 密集型操作
Node.js 使用单个线程执行 JavaScript。CPU 密集型工作会阻塞所有并发请求。
经验法则:
| CPU 耗时 | 解决方案 | 原因 |
|---|---|---|
| < 50ms | 保持在主线程执行 | Worker 开销不值得 |
| 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;
}
Worker 池实现、使用 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 行)、需要总数、需要随机访问页面。
适用键集分页的情况: 大型数据集、无限滚动、实时数据(插入数据不应导致重复)。
参见 examples/core.md,了解这两种模式的完整 TypeScript 实现。
<red_flags>
危险信号
高优先级问题:
- 未释放连接——连接永远不会返回连接池,导致连接池耗尽和应用程序挂起
- 循环中的 N+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 线程的启动开销约为 30ms——不要用于快速操作
- 如果将连接池的
idleTimeoutMillis设置得过短,可能会出现 “connection terminated unexpectedly” SELECT *会获取不必要的数据,并阻止覆盖索引优化——请指定具体列- 使用 EX 选项执行缓存 SET 会替换现有 TTL——再次调用 SET 会重置过期计时器
</red_flags>
<critical_reminders>
重要提醒
(必须始终使用 finally 块将数据库连接释放回连接池)
(必须使用预先加载或批处理(DataLoader)来防止 N+1 查询——切勿在循环中延迟加载)
(必须为所有缓存数据设置 TTL,以防止陈旧数据和内存耗尽)
(必须将 CPU 密集型工作转移到 Worker 线程——阻塞事件循环会降低所有请求的性能)
不遵循这些规则会导致连接池耗尽、N+1 查询造成性能下降、无界缓存导致内存泄漏,以及事件循环阻塞并影响所有并发请求。
</critical_reminders>