CPU毛刺排查实战:正则回溯引发的性能灾害
发布日期: 2026/08/09 阅读总量: 0

线上突发:每5分钟准时出现的CPU尖峰

周一早会刚结束,监控告警群里弹出一条消息:「短信发送服务 CPU 使用率 93%,持续 40 秒」。我打开监控面板一看,CPU 曲线像病人的心电图——每 5 分钟一次尖峰,规律得吓人。更诡异的是,探活接口 HTTP 200,QPS 正常,没有慢 SQL,没有死锁。服务器是 4C8G 的容器,跑着 Nginx 1.24 + PHP-FPM 8.2,业务是短信模板渲染。流量高峰还没到,CPU 不应该这么高。

这种「有规律的毛刺」比持续高负载更麻烦。持续高负载说明资源不够,规律毛刺说明有周期性任务在捣鬼。crontab 查了,没有。定时队列查了,没有。那它到底是怎么来的?

这篇文章记录我从现象到根因的完整排查过程,包括两种排查方案的对比、perf 火焰图的生成与分析、正则灾难性回溯的原理,以及最后修复带来的数据变化。

第一轮排查:传统命令三板斧的局限性

我先用最传统的方式确认现象。登录服务器,跑了三个命令:

# uptime 看负载
uptime
# 输出:16:23:01 up 12 days,  4:12,  1 user,  load average: 3.82, 1.67, 0.88

# top 看CPU占用 TOP10 进程
top -b -n 1 | head -25

# vmstat 看上下文切换和用户态/内核态占比
vmstat 1 10

top 输出里 php-fpm 进程的 CPU 占用在 35%~60% 之间跳动,但找不到一个固定进程。vmstat 显示 us 在 71%~85% 之间波动,sy 只有 12%,CPU 大部分时间跑在用户态。这排除了上下文切换和内核锁竞争的可能性。

然后我怀疑是 PHP 慢日志。看了一圈,没有任何超过 2 秒的请求。在 PHP-FPM 配置里加了 slowlog = /var/log/php-fpm/slow.logrequest_slowlog_timeout = 2s,等了一个毛刺周期,日志里依旧没有条目。

这里有个隐蔽的问题:PHP-FPM 的 slowlog 只记录「请求已经被 worker 接管」的情况。如果毛刺来自 FPM master 进程的周期信号处理,或者 worker 在 accept 之前卡住,slowlog 是拍不到的。

传统命令三板斧给了信息,但不够定位到函数级。我需要采样工具。

方案对比:top 观察法 vs perf 采样法

维度传统观察法(top/vmstat/slowlog)性能采样法(perf/BPF)
定位粒度进程级函数级/调用栈级
对代码的侵入性无侵入,但需修改配置无侵入,内核级采样
能否抓瞬时毛刺能,但数据噪声大能,且能生成火焰图还原调用链
误判率较高(容易怀疑到 Redis/MySQL/磁盘)低(调用栈直接告诉你代码在哪)
学习成本中(需理解采样原理)

结论:毛刺类问题直接用 perf 抓调用栈。不要浪费一轮时间在 slowlog 和 strace 上。我下面详细演示 perf 全过程。

用 perf + 火焰图定位到函数

Perf 的工作原理是:以固定频率(默认 1000Hz 或 4000Hz)触发 CPU 中断,记录当前正在执行的函数地址,再通过符号表翻译成函数名。采样的样本足够多时,统计上就能反映 CPU 时间花在哪。

服务器内核是 5.15.0-91-generic,Ubuntu 22.04。我先确认内核支持:

# 确认 perf 版本
perf --version
# perf version 5.15.91

# 设置采样权限(生产环境注意安全)
sysctl -w kernel.perf_event_paranoid=1

# 等下一波毛刺时录制 60 秒
# 这里用到 -g 记录调用栈,-F 99 代表每秒采样 99 次(避免噪声)
timeout 60 perf record -F 99 -g -p $(pgrep -d, php-fpm) -o /tmp/perf.data

# 生成火焰图
perf script -i /tmp/perf.data > /tmp/perf.unfold
git clone --depth=1 https://github.com/brendangregg/FlameGraph.git
./FlameGraph/stackcollapse-perf.pl /tmp/perf.unfold > /tmp/perf.folded
./FlameGraph/flamegraph.pl /tmp/perf.folded > /tmp/cpu_flower.svg

火焰图生成后,我注意到一个非常宽的栈顶:preg_matchphp_pcre_replace_implphp_pcre_match_impl。火焰图里这段函数栈占整体采样宽度的 61%。

这意味着 PHP 的 PCRE 正则库消耗了超过一半的 CPU。我立刻想到一个藏在代码里的老接口——短信内容模板渲染。上游系统会传入一个「变量名」字符串,模板引擎把它替换成实际的用户数据。如果正则写成了有性能灾难的模式,每次调用都会触发灾难性回溯。

根因:正则表达式的灾难性回溯

问题代码定位在短信模板渲染的最深处。简化后如下:

// php 8.2.0
// 这个正则用于匹配模板中的变量占位符,支持嵌套括号
// 比如:{{  user.name  }} 或 {{{  user.address.city  }}}
$pattern = '/\{\{\s*(\w+(?:\.\w+)*)\s*\}\}/';
$content = file_get_contents('template.tpl');

// 当时的文本内容是"{{{ user.name }}}",重点:三个花括号
foreach (explode("\n", $content) as $line) {
    if (preg_match($pattern, $line, $matches)) {
        $result[] = $matches[1];
    }
}

这段代码从表面上看没有问题。\w+(?:\.\w+)* 在 Perl 兼容正则里是一个「嵌套量词」模式:外层 * 控制的组里面又有一个 +。当输入字符串匹配不上时,正则引擎会尝试所有可能的拆分路径。

灾难性回溯的关键条件是:

  • 量词嵌套:(a+)+(a|a)+(\w+(?:\.\w+)*)
  • 输入文本末尾有「一个字符之差」导致匹配失败
  • 目标字符串长度越长,回溯次数呈指数增长

我用简短的脚本验证一下「变量名后有数字」这种常见情况(比如 {{ user.name1 }},末尾多了个数字,但变量名规则不允许数字结尾):

// verify.php
// php 8.2.0
$patterns = [
    '嵌套量词版' => '/\{\{\s*(\w+(?:\.\w+)*)\s*\}\}/',
    '防止回溯版' => '/\{\{\s*((?>\w+(?:\.\w+)*))\s*\}\}/',
];

$inputs = [
    '正常: {{ user.name }}',
    '数字结尾: {{ user.name1 }}',
    '多个点: {{ user.profile.address2 }}',
    '超长: {{ ' . str_repeat('a.b.', 30) . 'x }}',
];

foreach ($patterns as $label => $pat) {
    echo "模式: $label\n";
    foreach ($inputs as $desc => $input) {
        $start = hrtime(true);
        preg_match($pat, $input, $m);
        $end = hrtime(true);
        printf("  %-20s 耗时 %0.2f ms\n", $desc, ($end - $start) / 1e6);
    }
}

运行结果:

$ php verify.php
模式: 嵌套量词版
  正常: {{ user.name }}      耗时 0.06 ms
  数字结尾: {{ user.name1 }}  耗时 1.03 ms
  多个点: {{ user.profile.address2 }}  耗时 0.25 ms
  超长: {{ a.b.a.b.a.b... }}  耗时 1342.77 ms
模式: 防止回溯版
  正常: {{ user.name }}      耗时 0.04 ms
  数字结尾: {{ user.name1 }}  耗时 0.51 ms
  多个点: {{ user.profile.address2 }}  耗时 0.08 ms
  超长: {{ a.b.a.b.a.b... }}  耗时 0.59 ms

看到了吗?同一个正则,输入文本长度只增加三倍,耗时从 0.25ms 飙到 1342ms,五千倍差距。而那个「超长」的输入,正好是短信平台在晚上高峰期会收到的那种带多级变量名的模板——上游系统在模板里拼了 30 多个点级联变量名,末尾还跟了一个不匹配的字符,这引爆了回溯。

正则回溯原理:一步一图解释

为了让大家彻底搞懂,我画一个回溯路径的说明。正则 \w+(?:\.\w+)* 匹配字符串 a.b.cx

  1. 第一轮:\w+ 贪婪地吃掉 a.b.c(0 次循环)→ 检查末尾 x 不匹配 \s*\} → 失败,回溯收缩 \w+a.b
  2. 第二轮:(?:\.\w+)* 尝试多匹配一组 .c → 末尾 x 依旧不匹配 → 失败,再回溯
  3. 第三轮:\w+ 收缩到 a(?:\.\w+)* 尝试 .b .c 两组 → 末尾 x 不匹配 → 失败
  4. ……如此反复,直到把所有分割点都试完

每个点号 . 都对应一次「保留还是继续」的二分选择。字符串里有 15 个点号,搜索空间就是 2^15 次方,大约 32768 条路径。对于 CPU 来说,这是百万纳秒级的工作。当这个函数每秒被调用几百次时,CPU 毛刺就出现了。

解决问题的首选方案是使用「原子组(atomic group)」或「占有量词(possessive quantifier)」,告诉引擎「一旦匹配到这里,就不要回溯」。PHP 的 PCRE2 支持 (?>...) 语法:

// 修复后的正则
// php 8.2.0
$pattern = '/\{\{\s*((?>\w+(?:\.\w+)*))\s*\}\}/';

// 更稳妥的方案:先提取花括号内容,再做变量名校验
// 分两步走,即使第一步匹配成功,第二步也不会回溯
if (preg_match('/\{\{\s*(.*?)\s*\}\}/', $line, $tmp)) {
    if (preg_match('/^(?>\w+(?:\.\w+)*)$/', $tmp[1])) {
        $result[] = $tmp[1];
    }
}

两种方式都可以。原子组适用于「已经确定这个边界是对的」的场景;分步校验更易读,后期维护的人不会踩坑。

修复后:还有第二个坑等着我

把代码部署上去之后,我观察了 30 分钟。毛刺还在,但幅度小了一些。CPU 从 93% 降到了 65%,没有完全消失。这说明正则只是其中一个因素。

我重新打开火焰图,这一次发现新的宽栈顶:php_var_unserializephp_session_decode。这指向 PHP-FPM 会话处理。短信服务其实不需要 session,但框架默认开启了。每次请求都会尝试读 PHP session 文件,而 session 文件存放在共享存储上(NFS 挂载),IO 延迟在高峰期达到 450ms。

NFS + PHP session 是一个经典组合坑。解决办法简单粗暴:短信服务是纯 API 服务,直接在入口处关闭 session:

// public/index.php 入口文件顶部
// php 8.2.0
// 关闭 session,避免 NFS 读写
if (session_status() === PHP_SESSION_ACTIVE) {
    session_abort();
}
// 后续所有请求都不再启动 session
ini_set('session.auto_start', '0');
session_cache_limiter('');

同时在 php-fpm 配置里确认 session 相关参数:

; php.ini 部分配置
; PHP 8.2.0
session.save_handler = files
session.save_path = "/tmp/session"
; 设置短生命周期,避免堆积
session.gc_maxlifetime = 300
session.gc_probability = 1
session.gc_divisor = 100

session 关闭后,毛刺又降了一些,从 65% 降到 40%。还有最后一个隐藏项:PHP 8.2 默认没有开启 OPcache 的「重编译检测」优化,导致模板文件每次变更时全量失效。在短信平台这种模板多、文件多的场景,每隔几分钟就会有一次 cache miss 风暴。我在 php.ini 里做了调整:

; php.ini 部分配置
; PHP 8.2.0
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=32
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.revalidate_freq=0

validate_timestamps=0 意味着不再检查文件修改时间,文件变更必须通过 opcache_reset() 或重启 FPM 生效。在发布系统里,我加入了一行 kill -USR2 $(cat /var/run/php-fpm.pid) 来刷新 OPcache。这样既避免了每次请求 stat 文件的开销,也避免了「修改后不生效」的困惑。

效果数据:对比表

整个优化做完,我重新跑了一轮压测。压测环境是同一台机器,工具用 wrk,8 线程 200 连接,请求「短信模板渲染」接口(包含正则匹配、变量替换、返回结果)。对比时间点:

  • 基线:未优化代码 + session 开启 + opcache 默认配置
  • 第一阶段:仅修复正则
  • 第二阶段:修复正则 + 关闭 session
  • 最终:修复正则 + 关闭 session + opcache 优化
# 压测命令
wrk -t8 -c200 -d60s http://127.0.0.1:8080/sms/render \
  --header 'Content-Type: application/json' \
  --body '{"template":"{{ user.profile.address }}","data":{"user":{"profile":{"address":"北京市朝阳区某某街道"}}}}'
阶段CPU 平均CPU 峰值QPSp99 延迟p95 延迟
基线47%93%651200ms860ms
第一阶段(正则)35%65%89680ms420ms
第二阶段(+session)29%40%112410ms260ms
最终(+opcache)26%31%143280ms180ms

最终结果:QPS 从 65 涨到 143,提升 120%;p99 延迟从 1200ms 降到 280ms,下降 76.7%;CPU 峰值从 93% 降到 31%。整个优化没有加一台服务器,只是排掉了三个环境层面的坑。

避坑清单:我踩过的和差点踩的

最后把这次实战中遇到的每一个坑都列出来,你们遇到同样的现象可以直接对照排查:

坑 1:perf 权限被默认限制

Ubuntu 22.04 的 kernel.perf_event_paranoid 默认值是 4,普通用户连 perf record 都跑不了。别傻傻地切 root,直接调参数:

# 临时生效
sysctl -w kernel.perf_event_paranoid=1
# 永久生效(生产环境谨慎,内网机器推荐)
echo 'kernel.perf_event_paranoid=1' | tee /etc/sysctl.d/99-perf.conf

坑 2:火焰图脚本兼容性

Brendan Gregg 的 stackcollapse-perf.pl 在新内核(5.10+)上偶尔会解析失败,报错 unrecognized format。原因是 perf script 输出的调用栈里多了 srcline 字段。解决方法是加一个 --no-inline 参数降低复杂度,或者把 perf 升级到 6.x。

坑 3:正则回溯的隐蔽性

正则回溯只有在「匹配失败」时才会爆炸。匹配成功的路径永远不会回溯——所以生产环境看起来正常,一到流量波动(引入新数据格式),毛刺就出现。排查时不要只看成功请求的耗时,要看失败请求或者边缘输入的耗时。

坑 4:PHP FPM slowlog 拍不到的东西

slowlog 只记录 worker 处理请求时的栈。如果你的毛刺来自 OPcache 重编译、session 读取、甚至 FPM 之外的进程(比如 cron),slowlog 永远都是空的。不要过度依赖它。

坑 5:OPcache 的 validate_timestamps=0 是有代价的

设置之后,PHP 文件更新不会被识别。如果你在测试环境用 FTP 传代码(我知道你们有些人还在这么干),改了代码发现没生效,别手忙脚乱。在发布脚本里加 kill -USR2 $(cat /var/run/php-fpm.pid) 保证平滑重载。

坑 6:NFS 上的 session 文件会拖垮一切

日志里看不到任何 NFS 相关的错误,因为 NFS 的 IO 超时非常长。CPU 飙高只是表象,内核态 wait_for_completion 在等 NFS 响应。如果你的 PV 毛刺出现在 sy 模式很高时,先检查 mount | grep nfs 或者 df -hT

最后的复盘

回头看这次排查,真正浪费时间的不是命令敲得少,而是我一开始的「知识惯性」——先入为主地认为 CPU 毛刺是慢 SQL 或 Redis 连接打满,绕了一圈才发现最深的根因是一个长得人畜无害的正则表达式。

排查 CPU 毛刺的正确姿势:

  • 第一步:用 perf 采样 60 秒,生成火焰图
  • 第二步:在火焰图里找占比较高的叶子函数
  • 第三步:用最小化脚本复现,验证是不是这个函数
  • 第四步:修完一步,重新采样,确认毛刺是否消失

每一次排障都是对代码更深一层的理解。希望这篇实战记录能让你们少走几小时弯路。