关于 investigating-performance
面向需要诊断软件性能问题的工程师的技能,强调先测量再优化:复现问题、按症状选择剖析方式、一次更改一项并验证效果。
主要功能
- 01定义指标、统计口径、负载与目标
- 02稳定复现缓慢行为
- 03根据症状选择性能剖析方式
- 04识别数据库、应用和网络瓶颈
- 05每次更改一个变量并重新测量
- 06考虑尾延迟与负载测试偏差
适用场景
- 01定位请求延迟过高的原因
- 02调查仅在生产环境出现的缓慢问题
- 03验证优化是否改善目标指标
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ osmontero/ opencode-skills --skill investigating-performance安装后在 Claude Code 中输入/investigating-performance确认已加载。
附带文件references/profiling-tools.md
详细介绍
调查性能问题
概述
性能优化常以一种特定方式失败:工程师阅读代码,凭直觉判断哪些地方一定很慢,优化了那些地方,最后发布的改动经测量发现毫无效果。瓶颈在别处,因为瓶颈几乎总是在别处。
核心原则: 测量、定位、改一处、再次测量。直觉可以决定测量什么,但绝不能决定修复什么。
配套技能: systematic-debugging——将同样的严谨方法用于排查错误,而不是缓慢。两者都不会在定位原因前采取行动。
铁律
NO OPTIMIZATION WITHOUT A MEASUREMENT THAT NAMES THE BOTTLENECK
在更改 X 之前,你必须能够说出“X 占用 N% 的时间”。不能说“X 看起来开销很大”,也不能说“X 的复杂度是 O(n²)”。必须有带具体数字的测量结果。
没有例外:
- 不能因为“这显然是最慢的部分”就跳过测量——越是显然,越值得测量
- 不能因为“修复成本很低”就跳过测量——对非瓶颈的低成本修复,只有风险,没有收益
- 不能用于无论性能如何,你都会出于代码风格原因进行的更改。尽管去改;但不要称之为性能优化
推论: 没有改后测量的更改,不是优化,而是已发布的猜测。
第 0 步:确定目标
“很慢”不是一条错误报告。在动手之前,先写下以下四项:
| 示例 | |
|---|---|
| 指标 | POST /documents 的端到端延迟 |
| 统计量 | p99,而非平均值——平均值会掩盖用户抱怨的问题 |
| 负载 | 200 个并发客户端,接近生产环境的数据量 |
| 目标 | 低于 300ms,目前为 2.1s |
没有目标,你就无法判断何时完成,而性能优化没有天然的停止点。没有统计量,你就会优化平均值,却始终不处理引发投诉的长尾延迟。
第 1 步:复现问题
你需要一个能够按需、可重复地产生缓慢行为的测试工具。
- 真实的数据形态。 一千条开发环境数据无法复现缺少索引的问题;一千万条数据才可以。
- 先预热,再测量。JIT 编译、连接池和页缓存都会让首次运行表现得像另一个程序。
- 重复测试并观察分布。 单次计时只是噪声。报告最小值/p50/p99,而不是单个数字。
- 固定环境。 使用相同的机器和构建标志,且不承受其他负载。尤其要测量生产构建——框架的开发模式构建可能慢 10 倍,而且它不是你要发布的版本。
如果无法在本地复现,就在问题出现的环境中测量。以较低采样率进行生产环境性能分析是安全的,也胜过无法做到的忠实复现。
第 2 步:先分析特征,再进行性能分析
先花两分钟分析特征,可以省下一小时在错误维度上做性能分析。
| 现象 | 可能原因 |
|---|---|
| 首次调用很慢,之后很快 | 冷缓存、延迟初始化、连接建立、JIT |
| 始终都慢,且速度稳定 | 算法问题或每次调用的固定开销 |
| 低负载时快,高负载时骤然变慢 | 争用、连接池耗尽或队列问题——而非代码路径问题 |
| 耗时随输入大小呈超线性增长 | 复杂度问题——先找嵌套循环,再做性能分析 |
| p50 正常,p99 糟糕 | GC 暂停、锁争用、重试或罕见的慢路径 |
| CPU 使用率达到 100% | 计算受限 → CPU 性能分析 |
| CPU 使用率低,延迟却很高 | 等待——I/O、网络、锁或 sleep |
| 内存持续增长且不回落 | 对象保留问题,而非分配速率问题 → 获取两份堆快照进行堆分析 |
| 只在生产环境中慢 | 数据量、并发量、网络拓扑或配置 |
快速合理性检查: 并发数 ≈ 吞吐量 × 延迟(利特尔法则)。如果每秒处理 100 个请求,每个请求耗时 2s,那么大约有 200 个请求正在处理中。如果连接池只有 20 个连接,瓶颈就在连接池,怎么调优查询都无济于事。
第 3 步:分析正确的维度
分析特征定位所指向的维度。对 I/O 密集型程序进行 CPU 分析,只会看到进程处于空闲状态,毫无收获。
| 维度 | 适用情况 | 可显示的内容 |
|---|---|---|
| CPU | CPU 满负荷、计算密集型 | 哪些函数消耗了 CPU 周期 |
| 分配 | GC 时间长、内存频繁变动 | 垃圾从何而来 |
| 堆(正在使用) | 内存无限增长 | 保留了什么,以及由谁保留 |
| 阻塞 / 等待 | CPU 使用率低、延迟高 | 程序在等待什么 |
| 互斥锁 / 锁 | 并发增加时性能下降 | 发生争用的锁 |
| 执行跟踪 | 延迟突增,但原因不明显 | GC 暂停、调度器停滞、序列化 |
| 查询计划 | 任何涉及数据库的情况 | 扫描、糟糕的连接顺序、缺失的索引 |
各技术栈的具体命令——Go、Node、Python、浏览器、SQL、系统级:references/profiling-tools.md。
按累计耗时从上到下阅读分析结果,并警惕平坦的分析结果。没有明显峰值,通常意味着成本分散在某个调用上,而该调用被执行了过多次——统计调用次数,而不只是耗时。
第 4 步:常见嫌疑项
定位后,原因通常在以下列表中。
| 层 | 原因 | 特征 |
|---|---|---|
| 数据库 | N+1 查询 | 查询次数随结果数量增长 |
| 数据库 | 缺少索引 | 计划中对大型表执行 Seq Scan |
| 数据库 | 索引存在但未使用 | 类型不匹配、对列使用函数,或带前置通配符的 LIKE |
| 数据库 | 对宽行使用 SELECT * | 传输的字节数远超所需行数 |
| 应用程序 | 在循环中执行本可提到循环外的工作 | 相同调用、相同参数,重复 n 次 |
| 应用程序 | 序列化 / 反序列化 | JSON 编码 / 解码在 CPU 分析中占主导 |
| 应用程序 | 并发无上限 | Goroutine / promise 数量随负载增长;内存也随之增长 |
| 应用程序 | 锁跨 I/O 持有 | 互斥锁分析显示持有时间长,而不是获取次数多 |
| 应用程序 | 热路径中发生分配 | 分配分析结果主要集中在一个调用位置 |
| 网络 | 繁琐的协议交互 | 多次小型往返,而一次批处理即可完成 |
| 网络 | 未复用连接 | 跟踪中出现 TLS 握手 |
| 前端 | 阻塞渲染的请求瀑布流 | 网络面板中出现顺序执行的依赖请求 |
| 前端 | 在主线程上执行工作 | 性能跟踪中出现长任务 |
| 前端 | 发送整个打包文件 | 初始 JS 负载很大,其中大部分未使用 |
优化前,先问能否完全避免这项工作。 缓存、批处理、分页、异步执行,或者不执行,带来的收益都比单纯加快执行更大,而且通常更简单。
第 5 步:一次只改一项,然后再次测量
one change → re-measure → keep it or revert it → repeat
- 一次只改一个变量。 同时改两项,就无法判断结果归因于哪一项——即使其中一项带来收益,另一项造成了同等程度的损失。
- 撤销没有带来可测量收益的改动。 每项优化都会牺牲可读性;没有回报的复杂性会成为永久负担。
- 确认改进确实存在,而不是噪声。比较多次运行的分布(
benchstat及同类工具能正确完成此事),不要只比较两个单次测量值。 - 将基准测试与修复一并提交。 只有它能在回归再次出现时发现问题。
- 每次取得成效后都重新分析。 瓶颈会转移。第二大的成本如今成了最大的成本,而且往往出现在全新的地方。
达到第 0 步设定的目标后就停止。继续优化只会牺牲真正的可维护性,去追逐没人要求的数字。
尾延迟
如果问题出在 p99,平均值就会产生严重误导——改善平均值的修复往往会让尾延迟更糟。
常见原因,按大致出现频率排序:
- 排队。 利用率超过 ~70% 时,等待时间会急剧上升。解决方法是增加容量或施加背压,而不是加快代码。
- GC 暂停。 查看暂停时间分布,而不是 GC 总耗时。降低分配速率或缩小保留集。
- 锁队列。 一个持锁者变慢,就会拖慢所有等待者;延迟峰值与争用相关,而不是与负载相关。
- 重试。 一次超时加一次重试,会把一个慢请求变成三个请求,而此时系统恰恰最无力处理它们。
- 冷路径。 缓存未命中、空闲后收到的第一个请求、重新建立连接。
- 队头阻塞。 一个慢项目会阻塞同一连接、批次或分区中排在它后面的所有项目。
注意负载生成器中的协调遗漏。 如果测试工具每次都等到收到响应后才发送下一个请求,那么系统最慢的时候,它恰好会停止发送负载——这会抹掉你试图测量的尾延迟。使用按固定速率发送请求的生成器,或能明确进行校正的生成器。
危险信号
- “显然这部分最慢”——却没有性能分析结果
- 优化性能分析结果中仅占运行时间 0.4% 的函数
- 使用开发构建版本进行基准测试,或使用空数据库进行测试
- 用单次计时作为证据
- 一次提交中包含两项更改,且都标为性能优化
- 微基准测试有所改善,但端到端指标没有变化
- 尚未测量未缓存路径的成本,就添加缓存
- “现在应该更快了”
出现以上任何一种情况,都意味着:回去测量。
常见错误
优化自己熟悉的部分。 熟悉不等于证据。瓶颈往往更可能出现在你没有编写的代码中。
无法反映真实使用情况的微基准测试。 阿姆达尔定律毫不留情:让占运行时间 5% 的部分快一倍,整体也只快 2.5%。
混淆分配与保留。 高分配速率是 GC 压力问题;在用堆不断增长则是内存泄漏。它们需要不同的性能分析结果和不同的修复方法。
用缓存掩盖缺陷。 在意外呈二次复杂度的函数前加缓存,会让问题暂时消失,直到缓存恰好在你最需要它时未命中。
忽略方差。 如果一项更改改善了 p50,却使 p99 恶化,通常就是回归,因为用户报告的往往是尾延迟。
没有事后测量。 没有事后测量,你交付的只是一个差异,而不是修复——你也不知道它究竟属于哪一种。