服务明明很慢,CPU 火焰图为什么找不到热点?
一个接口的 P99 延迟从 80 ms 升到 2 s,进程 CPU 却只有 35%。工程师抓了一张 CPU 火焰图,没看到明显的业务计算热点,于是得出结论:“代码没问题,应该是网络抖动。”但继续查看线程后才发现,大量请求都阻塞在连接池和互斥锁上——这些时间根本不属于 CPU 火焰图的主要采样对象。
这正是火焰图最常见的误用:把一种采样事件画出的宽度,误当成程序所有时间的分布。CPU 火焰图回答“线程在 CPU 上运行时做了什么”,Off-CPU 火焰图回答“线程离开 CPU 后在哪里等待”,分配图和锁图又分别观察不同事件。选错事件,图画得再漂亮也不会出现真正的瓶颈。
火焰图不是一个实时监控指标,而是一种 采样 Profiling 结果的可视化方式。它适合回答的问题是:
CPU 时间、阻塞时间、内存分配或锁等待,最终都消耗在哪些调用链上?
系统监控通常先告诉我们“哪里异常”,火焰图进一步告诉我们“代码为什么异常”。它应该放在 top、vmstat、pidstat、iostat、业务指标之后使用,而不是一上来就对所有进程采样。
本文的 perf、BCC/eBPF 和 /proc 命令面向 Linux。当前整理环境是 macOS,因此只对命令含义和文档进行核对,没有在本机伪造采样结果;生产环境还要结合内核、perf、BCC 版本与权限实际验证。
1. 第一件事不是找最宽的框,而是先读对图
一张典型火焰图中:
| 位置 | 含义 |
|---|---|
| 横向宽度 | 采样命中的次数占比,越宽表示越热 |
| 纵向高度 | 调用栈深度,不代表耗时长短 |
| 底部 | 调用链入口或线程根部 |
| 顶部 | 当前正在执行或等待的位置 |
| 颜色 | 通常只是为了区分块,不一定有语义 |
需要注意:
- 横轴不是时间线,左右顺序没有先后含义。
- 宽度表示“采样占比”,不是单次函数耗时。
- 一个很宽的函数不一定是问题,它可能只是所有请求都会经过的公共入口。
- 真正有价值的是找到从业务入口一路向上的热点调用链。
火焰图常见类型:
| 类型 | 看什么问题 | 常见工具 |
|---|---|---|
| CPU 火焰图 | CPU 时间消耗在哪些函数 | perf、async-profiler、py-spy |
| Off-CPU 火焰图 | 线程阻塞、等待、睡眠在哪里 | bcc offcputime、eBPF 工具 |
| 内存火焰图 | 内存分配来自哪些调用链 | heaptrack、jemalloc prof、async-profiler |
| 锁火焰图 | 锁竞争和等待在哪里 | eBPF、语言运行时 profiler |
2. 使用前先确认问题类型
不要只因为“服务慢”就直接抓火焰图。建议先用监控指标判断方向:
1 | 业务延迟升高 / 吞吐下降 |
简单判断:
| 现象 | 优先方向 |
|---|---|
| 单核或多核 CPU 长时间打满 | CPU 火焰图 |
| Load 很高但 CPU 使用率不高 | Off-CPU、I/O、锁等待 |
%iowait 高 |
磁盘、网络存储、同步写 |
| 线程数很多,CPU 不高,延迟高 | 锁等待、连接池、下游阻塞 |
| 内存持续增长或频繁 OOM | 内存分配和泄漏分析 |
火焰图要和业务时间窗口对应起来。例如报警发生在 14:03 到 14:08,采样也应尽量覆盖这个窗口,而不是事后在正常流量下采样。
3. C++ 程序采样前的准备
C++ 程序能不能得到清晰调用栈,主要取决于符号和栈回溯能力。
3.1 编译参数
建议保留调试符号,并避免省略 frame pointer:
1 | -g -fno-omit-frame-pointer |
常见组合:
1 | CXXFLAGS="-O2 -g -fno-omit-frame-pointer" |
说明:
- 不需要为了 profiling 关闭优化,生产性能分析通常仍使用
-O2或项目默认优化级别。 -g用于保留符号信息,方便把地址解析成函数名和源码位置。-fno-omit-frame-pointer能让perf -g更稳定地回溯调用栈。- 如果线上二进制不能带完整 debug 信息,可以使用单独的 debug symbol 包。
3.2 最小可运行目标:同一个程序里同时制造计算和等待
先用一个可控程序验证采样链路,比直接在复杂服务上猜 [unknown] 更容易。下面的程序会运行约 30 秒:burn_cpu() 持续进行计算,wait_for_work() 模拟等待下游或队列。
1 |
|
在 Linux 上使用 GCC 或 Clang 编译:
1 | clang++ -std=c++20 -O2 -g -fno-omit-frame-pointer \ |
程序会打印 PID。在另一个终端用该 PID 采样:CPU 图中应能看到 burn_cpu(),Off-CPU 图中则应看到包含 wait_for_work()/睡眠路径的调用栈。__attribute__((noinline)) 是 GCC/Clang 扩展,只用于让演示函数在优化构建中保留清晰栈帧;MSVC 需要使用对应属性。
这个例子也说明:同一个进程既可能在计算,也可能在等待。选择哪张图取决于你要解释的是哪部分时间,而不是程序使用了哪种语言。
3.3 系统权限
Linux 上 perf 可能受内核参数限制:
1 | cat /proc/sys/kernel/perf_event_paranoid |
如果权限不足,可能出现无法采样内核栈、无法 attach 目标进程或符号不完整。生产环境不要随意全局放开权限,应按公司规范申请临时权限或在专用诊断机器上复现。
4. CPU 火焰图:perf + FlameGraph
这是 Linux C/C++ 服务最常见的方式。
4.1 找到目标进程
1 | ps -ef | grep <process_name> |
如果只是某个线程 CPU 高,先记下线程 ID:
1 | top -Hp <PID> |
top 里的线程 ID 是十进制 TID,后续可以用 perf 或 /proc/<PID>/task/<TID> 继续看。
4.2 采样目标进程
1 | perf record -F 99 -g -p <PID> -- sleep 30 |
参数含义:
| 参数 | 含义 |
|---|---|
-F 99 |
每秒采样 99 次,避免和固定周期产生明显共振 |
-g |
记录调用栈 |
-p <PID> |
只采样目标进程 |
sleep 30 |
采样 30 秒后自动停止 |
生产环境建议:
- 先采样 15 到 60 秒,不要长时间抓取。
- 优先指定进程,不要直接全机采样。
- 高峰期采样要控制频率和时长。
- 采样期间记录业务 QPS、延迟、错误率,方便对照。
4.3 先用 perf report 快速查看
生成火焰图前,可以先看文本报告:
1 | perf report |
或输出为文本:
1 | perf report --stdio |
如果这里已经能看到明显热点,就不一定必须生成火焰图。
4.4 生成火焰图
需要 Brendan Gregg 的 FlameGraph 脚本:
1 | git clone https://github.com/brendangregg/FlameGraph.git |
生成流程:
1 | perf script > out.perf |
然后用浏览器打开:
1 | xdg-open cpu-flame.svg # Linux 桌面环境 |
在 Linux 服务器上可以把 cpu-flame.svg 下载到本地查看。SVG 支持搜索函数名,适合从业务函数、库函数或系统调用一路展开。
5. 常用 perf 采样模式
5.1 采样整个系统
适合不知道哪个进程消耗 CPU 的场景:
1 | perf record -F 99 -a -g -- sleep 30 |
-a 表示 all CPUs。这个模式开销和输出量更大,生产环境要谨慎。
5.2 采样指定线程
1 | perf record -F 99 -g -t <TID> -- sleep 30 |
适合一个进程中只有某个工作线程异常的场景。
5.3 使用 DWARF 回溯
如果程序没有 frame pointer,可以尝试:
1 | perf record -F 99 --call-graph dwarf -p <PID> -- sleep 30 |
DWARF 回溯通常开销更大,也更依赖 debug 信息。长期来看,更推荐在线上构建中保留 frame pointer。
5.4 观察热点事件
默认采样通常是 CPU cycles。也可以指定事件:
1 | perf record -e cycles -F 99 -g -p <PID> -- sleep 30 |
大多数业务排查先从默认 CPU 采样开始即可,不要一开始就采各种硬件事件。
6. Off-CPU 火焰图:看线程在哪里等待
CPU 火焰图只能告诉我们“线程在 CPU 上运行时做了什么”。如果服务延迟很高,但 CPU 不高,线程可能在:
- 等锁
- 等磁盘 I/O
- 等网络返回
- 等条件变量
- 等连接池或队列
- 被调度器挂起
这种情况需要 Off-CPU 火焰图。
常见方式是使用 bcc/eBPF 工具:
1 | offcputime -f -p <PID> 30 > offcpu.folded |
含义:
| 参数 | 含义 |
|---|---|
-f |
输出 folded stack,便于直接生成火焰图 |
-p <PID> |
只观察目标进程 |
30 |
采样 30 秒 |
BCC offcputime 的 folded 输出会把相同阻塞调用栈聚合,并以累计 Off-CPU 微秒数作为计数,因此图中的宽度表示该采样窗口内聚合的阻塞时间占比。-d 在当前 BCC 脚本中表示在用户栈和内核栈之间插入分隔符,并不表示 duration;不同发行版打包的脚本参数可能不同,执行前应先运行 offcputime --help。
Off-CPU 图适合定位“慢在等待哪里”,但它仍需结合等待原因、业务请求和唤醒者分析。例如同一条 futex 栈既可能是正常条件变量等待,也可能是严重锁竞争;仅看函数名还不足以下结论。
7. 容器环境中的注意点
容器内分析火焰图经常遇到三个问题:
- 容器没有
perf、bcc 或对应权限。 - PID namespace 导致宿主机 PID 和容器内 PID 不一致。
- 二进制和 debug symbol 在容器内,采样工具在宿主机上,符号解析不完整。
常见做法:
1 | # 在宿主机找容器主进程 |
如果在宿主机上对容器进程采样,要使用宿主机视角下的 PID。符号解析不完整时,需要把容器内二进制、动态库和 debug symbol 路径对应好。
Kubernetes 环境中通常通过临时调试容器、节点侧诊断工具或平台提供的 profiling 能力完成,不建议直接改业务容器镜像来塞大量诊断工具。
8. 语言运行时的火焰图工具
不同语言有更适合自己的 profiler。混合栈项目应优先使用能理解语言运行时的工具。
| 语言 / 场景 | 推荐工具 | 说明 |
|---|---|---|
| C / C++ | perf、eBPF、heaptrack |
最通用,适合 native 服务 |
| Java | async-profiler |
支持 CPU、wall、lock、alloc 等事件 |
| Python | py-spy、scalene |
可以无侵入 attach Python 进程 |
| Go | pprof |
运行时内置支持较好 |
| Node.js | 0x、clinic flame |
更适合 V8 栈 |
例如 Go 服务通常不需要先上 perf,可以直接用:
1 | go tool pprof -http=:8080 http://127.0.0.1:6060/debug/pprof/profile?seconds=30 |
Java 服务可以用 async-profiler 生成 HTML:
1 | ./profiler.sh -d 30 -e cpu -f cpu.html <PID> |
9. 常见结论怎么解释
9.1 热点集中在业务函数
说明 CPU 主要花在业务逻辑上。继续看:
- 是否有重复计算
- 是否有低效循环或算法复杂度问题
- 是否可以减少序列化、拷贝、格式转换
- 是否存在不必要的日志拼接或字符串处理
9.2 热点集中在 malloc / free
可能是频繁小对象分配:
- 检查请求路径中是否大量创建临时对象。
- 复用 buffer,减少短生命周期分配。
- 检查容器扩容和字符串拼接。
- 必要时使用内存 profiler 看分配来源。
9.3 热点集中在锁相关函数
例如 pthread_mutex_lock、futex:
- 可能存在锁竞争。
- 检查锁粒度是否过大。
- 检查临界区里是否做了 I/O、日志或复杂计算。
- CPU 图看到
futex不一定足够,需要结合 Off-CPU 图确认等待时间。
9.4 热点集中在系统调用
例如 read、write、send、recv、epoll_wait:
- 阻塞中的
epoll_wait不会持续消耗 CPU 采样;如果它在 CPU 火焰图中仍然很宽,应检查高频唤醒、空轮询或系统调用开销,并用 Off-CPU 数据确认真实等待时间。 read/write/send/recv宽时,要结合吞吐、包量、系统调用次数看。- 同步日志、频繁小包、频繁文件 I/O 都可能放大系统调用成本。
9.5 栈大量显示 [unknown]
通常是符号或栈回溯问题:
- 二进制缺少符号。
- 没有 frame pointer。
- 动态库符号不可见。
- JIT 语言没有开启对应符号映射。
- 容器和宿主机路径不一致。
10. 生产环境使用规范
火焰图采样有开销,线上使用要遵守最小化原则:
- 明确目标:先知道要采哪个进程、哪个时间窗口。
- 控制范围:优先
-p <PID>,谨慎使用-a。 - 控制时间:通常 15 到 60 秒足够。
- 控制频率:CPU 图常用
-F 49、-F 99、-F 199。 - 留存上下文:记录采样时间、机器、进程、版本、流量和告警。
- 不在未知影响下长时间运行 profiler。
- 不在生产容器内临时安装大量工具,优先使用平台诊断能力。
一次采样记录建议包含:
1 | 服务名: |
11. 推荐排查流程
1 | 1. 业务指标确认问题 |
优化前后要比较同一口径:
- 相同机器规格
- 相同版本和配置
- 相同流量模型
- 相同采样时间
- 相同业务指标
火焰图只能证明“采样期间热点在哪里”,不能单独证明“优化一定有效”。最终仍要回到延迟、吞吐、错误率和资源使用率。
12. 快速命令备忘
CPU 火焰图:
1 | perf record -F 99 -g -p <PID> -- sleep 30 |
全机 CPU 火焰图:
1 | perf record -F 99 -a -g -- sleep 30 |
指定线程:
1 | perf record -F 99 -g -t <TID> -- sleep 30 |
DWARF 回溯:
1 | perf record -F 99 --call-graph dwarf -p <PID> -- sleep 30 |
Off-CPU 火焰图:
1 | offcputime -f -p <PID> 30 > offcpu.folded |
快速查看 perf 报告:
1 | perf report --stdio |
查看线程:
1 | top -Hp <PID> |
13. 最终检查清单
抓火焰图前:
- 是否确认是 CPU 问题,还是等待问题?
- 是否知道目标 PID / TID?
- 是否覆盖了异常时间窗口?
- 是否控制了采样频率和时长?
- 是否保留了二进制符号和 frame pointer?
分析火焰图时:
- 最宽的栈是否和业务路径有关?
- 热点是计算、分配、锁、系统调用,还是等待?
- 是否存在大量
[unknown]? - 结论是否能被业务指标或系统指标印证?
优化后:
- 是否用相同方式重新采样?
- P95 / P99 延迟是否下降?
- CPU、Load、I/O 或内存是否符合预期?
- 是否引入新的瓶颈?
14. 回到开头:没有 CPU 热点,能说明代码没有问题吗?
不能。CPU 火焰图只描述线程实际占用 CPU 时的调用栈。开头那个 P99 达到 2 秒、CPU 只有 35% 的服务,更可能把时间花在锁、连接池、磁盘或下游响应上;此时应把业务时间窗口与线程状态对齐,再选择 Off-CPU、锁、I/O 或分配事件。
可以带走四条结论:
- 先用业务和系统指标确定问题类型,再选择采样事件。
- 火焰图宽度表示输入数据中的聚合计数,不天然等于所有墙钟时间。
[unknown]、容器 PID 和符号路径属于采样质量问题,应在解释热点前解决。- 火焰图只能提出和验证热点假设,优化是否成功仍由相同负载下的延迟、吞吐和错误率证明。
最实用的做法是:每次采样都保存命令、二进制版本、时间窗口、QPS、P95/P99 和原始 profile。没有这些上下文,一张 SVG 很快就会变成无法复现的截图。
参考资料
- Brendan Gregg FlameGraph:https://github.com/brendangregg/FlameGraph
- BCC 工具集:https://github.com/iovisor/bcc
- BCC
offcputime.py:https://github.com/iovisor/bcc/blob/master/tools/offcputime.py