mcpField
I
investigating-performance

investigating-performance

github.com/osmontero开发16114 次安装今天更新
代码仓库

关于 investigating-performance

面向需要诊断软件性能问题的工程师的技能,强调先测量再优化:复现问题、按症状选择剖析方式、一次更改一项并验证效果。

主要功能

  1. 01定义指标、统计口径、负载与目标
  2. 02稳定复现缓慢行为
  3. 03根据症状选择性能剖析方式
  4. 04识别数据库、应用和网络瓶颈
  5. 05每次更改一个变量并重新测量
  6. 06考虑尾延迟与负载测试偏差

适用场景

  1. 01定位请求延迟过高的原因
  2. 02调查仅在生产环境出现的缓慢问题
  3. 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 分析,只会看到进程处于空闲状态,毫无收获。

维度适用情况可显示的内容
CPUCPU 满负荷、计算密集型哪些函数消耗了 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 恶化,通常就是回归,因为用户报告的往往是尾延迟。

没有事后测量。 没有事后测量,你交付的只是一个差异,而不是修复——你也不知道它究竟属于哪一种。

评论

支持 Markdown