一个被忽略的排查工具
服务器卡、页面慢,大家第一反应是查 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.svgflame.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 掉了符号。
解决办法有三种,按成本从低到高:
- 装 debug 符号包:Debian/Ubuntu 上执行
apt-get install nginx-dbgsym php8.2-fpm-dbgsym(需要启用 ddebs 源)。这是最推荐的方式。 - 自己编译时保留符号:
./configure --enable-debug编译出来的二进制带符号。 - 只采内核栈和用户栈的边界:如果只是想确认「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 每周跑一次,成本几乎为零,但它会在你真正需要的时候省下几个小时。这套「先有基线、再谈优化」的思路,比任何单点的调优技巧都更值钱。