CPU 性能分析笔记:如何用 perf 定位一次延迟抖动

沿着一次服务 p99 抖动的排查过程,串起 CPU 执行与等待分析、perf、火焰图、假设验证和基准测试。

Engineering notesOpen source engineeringPerformance

CPU 性能分析最怕变成看图猜谜。

看到火焰图上哪个块最宽,就去改哪个函数;改完跑一次本地基准测试,快了一点,就说优化完成。这种做法很容易把时间花在不主导线上延迟的问题上。

我更愿意把性能分析放进一次完整排查里:

text
问题现象
  -> 系统指标
  -> 判断是执行时间还是等待时间
  -> 在相同负载下采样
  -> 提出假设
  -> 只改一个主要变量
  -> 用相同负载验证

这篇不写 perf 命令大全,只写一次延迟问题里我会怎样把证据串起来。

事故入口:p99 抖,但 CPU 看起来不一定满

假设服务出现这样的现象:

text
avg latency: 4 ms
p99 latency: 180 ms
QPS: stable
error rate: low
CPU usage: 45% - 65%

第一反应不应该是打开火焰图。

CPU 使用率不满,不代表没有 CPU 问题;但也可能根本不是执行阶段的问题。线程可能在等锁、等 I/O、等网络、等运行时停顿或等调度。

我会先看系统层:

bash
top
pidstat -p <pid> 1
pidstat -w -p <pid> 1
vmstat 1
iostat -xz 1
sar -n DEV 1

这里先回答几个问题:

  • user/sys CPU 是否接近瓶颈。
  • 主动和被动上下文切换是否异常。
  • 运行队列是否堆积。
  • 磁盘等待时间和利用率是否升高。
  • 网络是否丢包或打满。
  • p99 抖动是否和某个资源曲线对齐。

如果 CPU 不高,但上下文切换、I/O 等待或锁等待很高,普通 CPU 火焰图可能不是首选工具。

flow

性能分析不应该跳过资源判定

先判断线程是在 CPU 上花时间,还是没在 CPU 上等东西。

  1. 1

    问题现象

    p99、超时或吞吐变化

    延迟QPS错误率
  2. 2

    系统指标

    先看系统资源和调度状态

    CPU运行队列I/O 等待上下文切换
  3. 3

    采样分析

    按执行或等待状态选择工具

    perf互斥锁分析等待分析
  4. 4

    验证

    用同一负载验证改动

    基线单一改动对比

先分清 CPU 执行时间和等待时间

CPU 执行分析关注线程正在 CPU 上运行时,时间花在哪里。

等待分析关注线程不在 CPU 上运行时,卡在哪里。

两者回答的问题不同:

text
CPU 执行:
  CPU 周期花在哪些调用栈上?

等待:
  墙上时钟时间消耗在等待什么?

如果 CPU 已经打满,CPU 执行火焰图通常很有用,可能看到序列化、压缩、哈希、内存分配、日志格式化、拷贝、正则、JSON 等热点。

如果 CPU 不满但延迟很高,等待分析可能更有价值。原因可能是互斥锁、条件变量、磁盘 I/O、套接字读写、epoll 等待、运行时安全点或调度等待。

只看 CPU 执行时间,容易优化一个“线程真正在跑时最忙”的函数,却错过“线程大部分时间根本没在跑”的事实。

perf 采样要贴近真实负载

Linux 上常见入口:

bash
perf record -F 99 -p <pid> -g -- sleep 30
perf report

或者生成火焰图:

bash
perf script > out.perf
stackcollapse-perf.pl out.perf > out.folded
flamegraph.pl out.folded > cpu.svg

这里有几个容易犯错的点。

第一,采样窗口要覆盖问题发生时段。没有流量时采样,看到的只是 idle、background thread 或 GC。

第二,负载要和线上症状接近。请求类型、请求体大小、并发和数据规模都要对齐。

第三,采样频率不要盲目拉高。频率太高会影响被测进程,太低又看不清。99Hz 常作为起点,是为了避开某些周期性对齐。

第四,符号要可读。C/C++/Rust 服务最好保留帧指针或提供可用的调试符号,否则火焰图上全是地址和 [unknown],解释力会大幅下降。

火焰图不是时间线

火焰图横向宽度表示采样占比,不表示时间顺序。

text
宽框:
  很多采样都经过这个函数

高栈:
  调用路径很深

顶部宽框:
  CPU 周期实际花费的叶子函数

底部框架宽,不一定说明框架慢。它可能只是很多请求都经过那里。真正要看的是顶部宽块,以及它是否符合预期。

比如:

  • 压缩函数宽:可能是必要计算,不一定是浪费。
  • JSON 解析很宽:如果高频路径反复解析同一字段,可能有优化空间。
  • malloc/free 很宽:可能是对象生命周期或缓冲区复用问题。
  • 日志处理很宽:高频格式化日志可能很不值。
  • memcpy 宽:要看是不是跨层重复复制。

火焰图给的是证据,不是自动结论。

我会先区分必要工作和额外开销

看到热点后,我会先问它是不是系统应该做的工作。

必要工作:

  • 加密。
  • 压缩。
  • 模型计算。
  • 正常序列化。
  • 必要校验。

额外开销:

  • 同一请求重复解析。
  • 锁内做重活。
  • 每层都复制缓冲区。
  • 临时对象大量分配。
  • 调试日志在热路径格式化。
  • 低效的映射或列表查找。
  • 明明可以批量处理却逐条调用。

优化主要工作通常要更换算法或实现,并接受相应取舍;优化浪费则更像把不该出现的成本拿掉。

一个假设只能配一个主要改动

性能优化很容易越改越多。最后数字变了,但不知道是哪一处生效。

我会把假设写具体:

text
假设:
  请求接入阶段反复解析 JSON,抬高了 p99。

证据:
  perf 显示 parse_request_fields 占 on-CPU 样本的 18%
  p99 较高的负载中也出现了相同调用栈

改动:
  在入口处只解析一次,并向下游传递类型化字段

然后只做这一类改动。不要顺手改批次大小、日志级别、锁粒度和缓冲池,否则验证阶段无法解释结果。

基准测试要和性能分析场景对得上

性能数据来自线上负载,验证却只用本地单线程模拟,这样的证据很弱。

我会尽量对齐:

  • 请求类型。
  • 请求体大小。
  • 并发度。
  • 数据规模。
  • 编译参数。
  • CPU 调频策略。
  • 容器 CPU 限额。
  • 日志级别。
  • 下游模拟行为。

如果线上 p99 来自高并发下的锁竞争,本地单线程基准测试只会证明函数本身快,不会证明尾延迟已经改善。

结果记录要能被复盘

一条性能优化记录至少应该留下:

text
现象:
  负载 X 下的 p99 为 180 ms

基线:
  QPS、平均延迟、p95、p99、CPU、内存和上下文切换

采样:
  命令、时间窗口、负载和主要调用栈

假设:
  为什么这段调用栈属于额外开销或瓶颈

改动:
  只改变一个主要变量

结果:
  使用同一负载进行对比

风险:
  语义变化、内存取舍和复杂度

没有这些,优化就会变成“我记得当时快了”。

性能分析的最终目标不是画出更好看的火焰图

CPU 性能分析的目标不是把某个宽块变窄,而是解释一次性能症状。

如果症状是 p99 抖动,最后就要回到 p99;如果症状是 CPU 打满,最后就要回到 CPU 利用率和吞吐;如果症状是请求卡住,最后可能要证明等待时间已经下降。

火焰图只是中间证据。真正的闭环是:同一个负载下,问题指标改善,而且你知道为什么。