上周处理了一个线上问题:Nginx 反向代理的 PHP 服务 CPU 持续 90%+,top 一看是 php-fpm 在吃 CPU,但具体是哪个函数、哪段代码在吃——完全不知道。重启只能管 10 分钟。
这种时候,strace 太慢、gdb 太重,真正好用的工具是 perf——Linux 内核自带的性能分析器,零依赖、非侵入式,能直接告诉你 CPU 到底在忙什么。
这篇文章就是我在那之后总结的一站式 perf 实战指南。从安装到生成火焰图,每一步都有命令和解释。

📎 推荐阅读:Python性能剖析三件套:py-spy、Scalene、memray实战对比 —— 三个工具的横向对比,从CPU热点到内存分配全覆盖。
📖 扩展阅读:eBPF + bpftrace 生产环境调试实战 — 不用改代码不用重启,一行命令定位线上问题。
🔗 延伸阅读:perf 能告诉你 CPU 热点在哪里,但有些问题 perf 看不到——比如进程卡在 futex 等锁、DNS 解析超时。这种场景用 strace 生产环境调试完全指南 更直接。
perf 是什么?为什么不用 strace?
perf 是 Linux 内核的性能分析子系统(perf_events),它通过硬件性能计数器(PMU)和软件事件来采样 CPU 正在执行什么。本质上它是采样不是追踪——每隔一定时间(比如 1ms)看一眼 CPU 在跑什么,然后统计。
strace 可以追踪系统调用,但它有两个致命伤:一是慢——每个 syscall 都要上下文切换,生产环境跑 strace 等于给服务踩刹车;二是 strace 看不到用户态 CPU 热点——如果你的代码在 while 循环里做 json_decode,strace 啥也看不出来。
perf 的采样开销通常在 1-3%,生产环境直接跑没问题。
第一步:安装 perf
不同发行版的包名不一样,这里踩过的坑直接告诉你:
# Ubuntu/Debian
sudo apt install linux-tools-common linux-tools-$(uname -r) linux-cloud-tools-$(uname -r)
# CentOS/RHEL
sudo yum install perf
# Alpine(容器里常见)
apk add perf
# 验证安装
perf --version
如果 Ubuntu 报 Unable to locate package linux-tools-xxx,多半是内核版本太新包还没跟上。装个通用版本就行:sudo apt install linux-tools-generic。
第二步:perf top — 实时看 CPU 热点
最快的上手方式是 perf top,它像 top 一样实时刷新,但显示的不是进程而是函数:
sudo perf top
输出大概长这样:
Samples: 12K of event 'cpu-clock', 4000 Hz, Event count: 2876543210
Overhead Shared Object Symbol
28.32% php8.3 [.] zend_hash_find
18.71% php8.3 [.] php_json_decode_ex
14.25% libc.so.6 [.] __strstr_sse2
10.48% php8.3 [.] _zend_array_merge
8.11% [kernel] [k] copy_user_enhanced_fast_string
一眼就能看到:zend_hash_find 占 28%,json_decode 占 18%。你的直觉可能会说”json_decode 慢”,但 perf 告诉你——哈希查找才是真凶。
实用技巧: 按 e 键展开调用链,能看到是谁调用了这个热点函数。按 d 可以过滤只看某个 DSO(动态共享对象),比如 php8.3。
但 perf top 只能看当前,不能回溯。排查间歇性 CPU 飙高的时候,你需要采样数据保存下来——这就是 perf record 的主场。
第三步:perf record — 采样保存 + 离线分析
采样 30 秒,保存到 perf.data:
# 全系统采样(推荐,能看到所有进程)
sudo perf record -a -g -F 99 -- sleep 30
# 只采样 php-fpm 进程
sudo perf record -p $(pgrep -d',' php-fpm) -g -F 99 -- sleep 30
# 参数说明:
# -a: 全系统
# -g: 记录调用链(call graph),火焰图必需
# -F 99: 采样频率 99Hz(推荐值,避免与系统时钟同频导致偏差)
# sleep 30: 采样 30 秒
采样完后看报告:
sudo perf report --stdio
输出是文本版的调用树,能看到每个函数的 CPU 占比和父子调用关系。但说实话,文本版的可读性很差——当调用链深到 10 层以上时,你很难直观看出瓶颈在哪。
这时候就该上火焰图了。
第四步:生成火焰图(FlameGraph)
火焰图是 Brendan Gregg 发明的可视化方法,把 perf 的调用栈数据变成一张 SVG 图——宽度 = CPU 时间,颜色随机(或按函数类型着色),从下到上是调用栈。
生成火焰图需要 Brendan 的脚本:
# 克隆 FlameGraph 仓库(你需要 git)
git clone https://github.com/brendangregg/FlameGraph.git
cd FlameGraph
# 1. perf script 把采样数据转成可读文本
sudo perf script -i /path/to/perf.data > out.perf
# 2. 折叠调用栈
./stackcollapse-perf.pl out.perf > out.folded
# 3. 生成 SVG 火焰图
./flamegraph.pl out.folded > flamegraph.svg
三条命令出图。打开 flamegraph.svg,你会看到一张像山峰一样层叠的图——最高的”山峰”就是 CPU 消耗最大的调用路径。点击任意函数块可以放大,鼠标悬停看百分比。
这里有个容易踩的坑:如果你的 perf.data 是在 Docker 容器里采样的,宿主机的符号表可能不匹配。解决方案是用 perf archive 打包符号文件:
perf archive /path/to/perf.data
# 生成 perf.data.tar.bz2,带到开发机解压后再 perf script
第五步:读懂火焰图 — 一个真实案例
回到开头那个 CPU 90% 的问题。火焰图出来后,我一眼看到两个大平顶:
- json_decode — 占了 28% 的 CPU 时间,看调用链是一个 API 接口在循环里反复 decode 同一个 JSON 字符串
- zend_hash_find — 18%,追踪下去发现是每次请求都去读一个 200KB 的配置文件做 Hash 查找
两个问题都是典型的重复计算。修复非常简单:
// 修复 1:缓存 json_decode 结果
// Before: 每个请求 decode 3 次同一个 JSON
$data = json_decode($raw_json, true); // 第1次
// ... 中间代码 ...
$data2 = json_decode($raw_json, true); // 第2次(重复!)
// ...
$data3 = json_decode($raw_json, true); // 第3次(重复!)
// After: 一次 decode,到处使用
$data = json_decode($raw_json, true);
// 后续全部引用 $data
修复 2 是把配置文件用 APCu 缓存起来,避免每次请求做 Hash 查找。
改完重启 php-fpm,CPU 从 90% 掉到 12%。perf 再采样一次生成火焰图,原来的”高原”变成了”丘陵”。
进阶:perf 采样事件不止 CPU
perf record 默认采样 cpu-clock 事件,但它能采样的远不止这个:
# 查看可用事件
perf list
# 采样缓存未命中(找内存瓶颈)
sudo perf record -e cache-misses -a -g -- sleep 10
# 采样上下文切换(找调度问题)
sudo perf record -e context-switches -a -g -- sleep 10
# 采样缺页错误(找内存分配热点)
sudo perf record -e page-faults -a -g -- sleep 10
# 采样分支预测失败(找 CPU 流水线停顿)
sudo perf record -e branch-misses -a -g -- sleep 10
记住:CPU 高不一定全是计算密集。如果你采样 cache-misses 发现火焰图最高的函数是 memcpy——那是内存带宽瓶颈,不是代码算法问题。
perf stat — 一行命令看整体
如果你只是想快速了解”这个程序到底哪里有问题”,perf stat 比 perf record 更快:
sudo perf stat -d php your_script.php
输出:
Performance counter stats for 'php your_script.php':
1234.56 msec task-clock
4567 context-switches # 3.699 K/sec
123 cpu-migrations # 99.630 /sec
98765 page-faults # 80.012 K/sec
3456789012 cycles # 2.799 GHz
1234567890 instructions # 0.36 insn per cycle
234567890 branches # 190.012 M/sec
12345678 branch-misses # 5.26% of all branches
345678901 L1-dcache-loads # 280.012 M/sec
12345678 L1-dcache-load-misses # 3.57% of all L1-dcache accesses
关键指标解读:
- instructions per cycle (IPC) < 1.0:CPU 在等内存/等分支预测,代码有优化空间
- branch-misses > 5%:分支预测失败率高,检查是否存在大量不可预测的条件分支
- L1-dcache-load-misses > 5%:数据局部性差,考虑调整数据结构(AoS → SoA)
FAQ
Q: perf 采样需要 root 权限吗?
看内核参数 /proc/sys/kernel/perf_event_paranoid。值 ≤ 0 时普通用户也能采样(Docker 容器通常设为 -1),值 ≥ 2 时只有 root 能用。生产环境 Docker 里一般直接 sudo 就行。
Q: perf top 看不到函数名,只显示地址怎么办?
通常是缺少调试符号。安装对应的 dbgsym 包:sudo apt install php8.3-dbgsym。如果找不到 dbgsym 包,检查 /etc/apt/sources.list.d/ddebs.list 是否配置了 debug 源。
Q: perf record 生成的 perf.data 文件很大怎么办?
降低采样频率:-F 49 或更低。减少采样时间:sleep 10 而不是 60。或者在 Docker 容器里采样(容器内进程少,数据量小很多)。采样 30 秒 99Hz 的 perf.data 通常在 5-20MB,不算大。
Q: 火焰图中看到了 __memcpy_avx_unaligned 占大量 CPU,怎么优化?
这通常说明你的代码在做大量内存拷贝。排查方向:是否在函数间传递了大对象而不是引用?是否在循环中拼接大字符串?PHP 里 $a = $b 是写时复制但 $a .= $b 会触发拷贝。如果是字符串操作热路径,考虑用引用传递或分块处理。
Q: perf 和 eBPF(bpftrace)有什么区别?什么时候用哪个?
perf 是”看”——采样统计 CPU 热点、缓存未命中、分支预测等。bpftrace 是”插”——在函数入口/出口插桩,记录参数、返回值、延迟。简单说:先用 perf 找到热点函数,再用 bpftrace 深入分析那个函数的输入输出。两者配合使用效果最好。
总结
perf 是我用过性价比最高的性能分析工具——内核对它的支持是原生的,零依赖,采样开销低到可以忽略。配合火焰图,原本”CPU 高”这种模糊的问题能在几分钟内精确到函数级别。
记住这个排查流程:
- perf top — 30 秒看全局,找到可疑的 DSO/函数
- perf record -g -F 99 — 采样 30-60 秒,保存现场
- 生成火焰图 — 一眼看到最大的”平顶”
- perf stat — 补充 IPC、cache-miss 等宏观指标
- bpftrace(可选)— 在热点函数插桩看参数
别再用 echo 大法和 var_dump 排查性能问题了。CPU 从来不骗人,你只需要学会听懂它说的话。
💡 相关文章: 如果你还没看过,推荐搭配阅读本站的 Linux生产环境CPU 100%问题排查实战 和 MySQL慢查询优化实战,这两篇和本文组成了完整的性能排查三部曲。