perf火焰图定位线上CPU热点:从采到读的完整流程

一个被忽略的排查工具

服务器卡、页面慢,大家第一反应是查 Nginx 日志、查 MySQL 慢查询、查内存占用。这些都对,但有一类问题它们全都看不出来:程序本身没报错、SQL 也不慢、内存也不高,可 CPU 就是被吃到 100%,或者某个请求莫名其妙要三秒才返回。这种时候需要的不是「看指标」,而是「看程序在干什么」——也就是性能剖析(profiling)。

很多站长觉得火焰图是大型互联网公司才用的东西,要装一堆 agent、上分布式追踪。其实在单台 VPS 上,用 Linux 自带的 perf 加一个开源脚本,十分钟就能出一张火焰图,定位到具体函数。这篇文章讲完整流程,包括我自己踩过的三个坑。

先安装 perf,注意内核版本匹配

Debian/Ubuntu 上装的是 linux-perf(旧版本叫 linux-tools)。这里第一个坑:perf 的版本必须和当前内核版本匹配,否则会报 perf not found for kernel X.Y 或者采集时直接崩。

uname -r
apt-get update
apt-get install -y linux-perf

# 检查版本是否匹配
perf --version
# 如果报 "perf not found for kernel",装对应版本的包:
apt-get install -y linux-perf-$(uname -r | cut -d- -f1-2)

如果源里没有匹配版本(比如自编译内核、或者云厂商魔改内核),最省事的办法是换用 bpftrace 或者直接用 /proc 采样,但一般云主机的官方内核都有对应包。

装完之后先跑一次 perf stat 确认能工作:

perf stat -a sleep 3

如果输出里 insn per cycle 之类的计数全是 <not supported>,说明这台机器是虚拟机且没开 PMU 透传(KVM 下需要宿主设置 -cpu host),此时只能采样不能计数。采样(perf record)通常仍然可用,火焰图照做。

perf record 的几个关键参数

最常用的采集命令长这样:

perf record -F 99 -g -p $(pgrep -d, -f 'php-fpm|nginx') -- sleep 30

逐个解释:

  • -F 99:采样频率 99 Hz。为什么是 99 而不是 100?这是刻意避开整秒的整除关系,减少和定时任务、时钟中断的共振,让采样更均匀。这个数字是 Brendan Gregg 定的行业惯例。
  • -g:采集调用栈(call graph)。这是火焰图的基础,没有它只能看到最外层的函数。
  • -p:指定进程。注意 pgrep -d, 会生成逗号分隔的 PID 列表,perf 支持多进程同时采集。
  • -- sleep 30:采集 30 秒后自动停止。不加这个会一直采到手动 Ctrl-C。

采完之后当前目录会生成 perf.data。第二步是展开成可读的文本栈:

perf script > out.perf

然后生成火焰图。需要两个脚本:stackcollapse-perf.pl 和 flamegraph.pl,从 Brendan Gregg 的 FlameGraph 仓库拿:

git clone --depth 1 https://github.com/brendangregg/FlameGraph.git /opt/FlameGraph
/opt/FlameGraph/stackcollapse-perf.pl out.perf > out.folded
/opt/FlameGraph/flamegraph.pl out.folded > flame.svg

flame.svg 是单文件 SVG,直接用浏览器打开,或者传到自己的图床上在手机上也能缩放查看。

怎么读火焰图:三个原则

第一次看火焰图容易懵,其实就是一棵倒置的调用树:

  • X 轴是采样次数(占比),不是时间顺序。宽度越宽表示该函数占用的 CPU 时间越多。左右位置没有时间含义。
  • Y 轴是调用深度。最底下是入口,往上是被调用的函数。看的时候从下往上找「宽度突然变宽的层」——那就是耗时集中的地方。
  • 颜色无含义(默认配色只是区分层级),这点和热力图不同,别去猜红色代表什么。

实际操作时,我一般先看顶部最宽的几个块(叶子函数),它们是真正的 CPU 消耗点;再看中间有没有很宽的「墙块」——一大块宽度只对应一个函数、下面没有子调用,通常意味着那里在自旋、在做字符串处理、或者在等锁。

实战:PHP-FPM 占满 CPU 的三次定位

案例一:正则回溯导致的 CPU 尖刺

现象是某个文章页偶尔要 3~5 秒,其他页秒开。perf 采到的火焰图里,pcre_exec 的宽度占了对应用例的 70% 以上,上层是 preg_match。顺着调用链往回看,是主题里的一段摘要处理代码把一个长字符串喂给了一条「贪婪匹配 + 多个可选分组」的正则。

这类问题的特征是:火焰图上 pcre_exec 或 pcre2_match_8 异常宽,而且只在特定页面出现(因为只有那些页面有超长输入)。修法是把正则改写成非贪婪、或者加长度上限、或者干脆用字符串函数替代。PCRE 有回溯限制 pcre.backtrack_limit,默认 100 万,超出会返回 false 而不是报错,这种「静默失败」在代码层很难发现,但在火焰图上一目了然。

案例二:数据库连接没复用

现象是首页 TTFB 从 80ms 涨到 400ms,QPS 不高。火焰图显示大量时间花在 connect 系统调用和 mysql_real_connect 上。原因是一个新加的小工具页每次都新建数据库连接、用完不关,连接耗尽之后开始排队。

从火焰图上看,__libc_connect 或 poll 的宽块出现在业务代码之下、SQL 执行之上,就是典型的「连接建立成本」。修法是使用持久连接或者连接池,并把页面里的临时连接改成复用。

案例三:日志写入的同步阻塞

现象是高峰期整体变慢,但 MySQL 和网络都正常。火焰图里 write 和 fsync 很宽,调用链指向写日志的函数。原因是应用每处理一个请求都同步写一条日志,而日志文件所在分区正在被 rsync 备份扫描,磁盘 IO 排队。

这类问题的关键不是「日志不该写」,而是「写的地方不对」。改成异步写(写到内存缓冲、由单独的进程刷盘),或者把日志放到独立分区、备份时排除掉活跃日志目录,就能解决。

看不到函数名怎么办:符号表问题

这是 perf 用起来最容易卡住的地方。采出来的火焰图里全是 [unknown] 或者一串十六进制地址,完全看不懂。原因是 perf 需要程序的符号表(symbol table)才能把地址翻译成函数名,而发行版安装的 nginx、php-fpm 通常被 strip 掉了符号。

解决办法有三种,按成本从低到高:

  1. 装 debug 符号包:Debian/Ubuntu 上执行 apt-get install nginx-dbgsym php8.2-fpm-dbgsym(需要启用 ddebs 源)。这是最推荐的方式。
  2. 自己编译时保留符号:./configure --enable-debug 编译出来的二进制带符号。
  3. 只采内核栈和用户栈的边界:如果只是想确认「CPU 花在内核态还是用户态」,用 perf report --sort comm,dso,symbol 按模块聚合就够了,不需要精确到函数。

另外 PHP 脚本本身的函数栈默认采不到,需要开 php-fpm 的 --enable-fpm 之外还要在 perf 里能看到 Zend 引擎的符号;脚本层面的性能问题用 Xdebug 或 XHProf 更合适,perf 负责看「整个进程在 CPU 上干了什么」。

on-CPU 还是 off-CPU:两种完全不同的图

这里有个概念必须分清,否则会得出错误结论:

  • on-CPU 火焰图(perf record 默认):只包含「正在占用 CPU」的栈。适合回答「CPU 被谁吃了」。
  • off-CPU 火焰图:包含「在等待」的栈(等 IO、等锁、等网络)。适合回答「请求为什么慢但 CPU 不高」。

如果你的现象是「CPU 不高但页面很慢」,用 on-CPU 图会看到一片空白,得出「程序没耗 CPU 所以没问题」的错误结论。这时候需要 off-CPU 分析,常用工具是 bpftrace:

bpftrace -e 'kprobe:finish_task_switch { @[kstack, ustack, comm] = count(); }'

或者用 bcc 工具集里的 offcputime(安装 bpfcc-tools):

offcputime -p $(pgrep -f php-fpm | head -1) 30

输出按等待时长排序的栈,能直接看出「卡在哪个函数等」。我在一次「MySQL 连接池被占满」的排查里就是靠这个定位的:on-CPU 图里什么异常都没有,off-CPU 图里 poll 占据了 90% 的时间,指向等待连接。

采集时对线上服务的影响

perf record 的频率 99 Hz 意味着每秒钟中断 99 次去读一次栈,开销通常在 1%~3%,对个人站来说完全可以接受,但要避免两个错误操作:

  • 不要用 -F 1000 以上的频率做长时间采集。高频率在高 QPS 场景下能把 CPU 打满,反而制造事故。
  • 不要在没有限制的情况下采集系统全局(-a 不加 -p)。全系统采集会包含所有进程,数据量巨大,而且更容易受其他进程干扰。优先按 PID 采。

生产环境采集前,先确认磁盘有几百 MB 空间(perf.data 可能几十到几百 MB),采集时长控制在 30~60 秒——火焰图只需要足够多的样本,不需要长时间。

# 采集前检查空间
df -h /tmp
# 把输出直接放到 /tmp,避免污染工作目录
perf record -F 99 -g -p $(pgrep -d, -f php-fpm) -o /tmp/perf.data -- sleep 30
ls -lh /tmp/perf.data

没有 perf 权限时的替代方案

部分云主机或容器环境里,perf 会因为 perf_event_paranoid 限制而无法采集。检查并临时放宽:

cat /proc/sys/kernel/perf_event_paranoid
# 值为 2 时只能采自己的进程,-1 表示不限制
sysctl -w kernel.perf_event_paranoid=-1

注意这个值放宽后会让同机器上的其他用户也能采你的进程,共享主机上别长期保持。如果完全没权限,退而求其次的办法:

  • strace -c -p PID 统计系统调用耗时,能看出是不是卡在 IO 或网络调用上;
  • pidstat -p PID 1 看 CPU 与上下文切换,cs 数值异常高说明有锁竞争或自旋;
  • PHP 侧用 XHProf 或者 xdebug 的 profiler 输出,虽然是函数级而不是指令级,但对定位业务代码足够。

把它变成例行检查

火焰图最大的价值不是救火,而是「平时就有一份基线」。我的做法是每周挑一个低峰时段采 30 秒,把 SVG 存到备份目录里,命名带日期。当某天突然变慢时,拿当天的图和一周前的图并排对比,哪个函数的宽度变了,答案立刻就出来了——比从零开始猜快得多。

mkdir -p /root/perf-history
D=$(date +%Y%m%d)
perf record -F 99 -g -p $(pgrep -d, -f php-fpm) -o /tmp/p.data -- sleep 30
perf script -i /tmp/p.data > /root/perf-history/$D.perf
/opt/FlameGraph/stackcollapse-perf.pl /root/perf-history/$D.perf | \
  /opt/FlameGraph/flamegraph.pl > /root/perf-history/$D.svg
# 只保留最近 8 份,避免占满磁盘
ls -1t /root/perf-history/*.svg | tail -n +9 | xargs -r rm -f

这条命令放进 crontab 每周跑一次,成本几乎为零,但它会在你真正需要的时候省下几个小时。这套「先有基线、再谈优化」的思路,比任何单点的调优技巧都更值钱。

Last modification:October 21st, 2026 at 12:30 pm

Leave a Comment