跳到主内容

正则回溯灾难避坑实录:一次 CPU 100% 事故的定位与修复 ​

给内容审核服务值守过的同学都知道,「CPU 平稳运行很久,某个下午突然打满」这种事故最让人心慌:没有发版、没有流量洪峰、没有 GC 风暴,监控曲线却像被人一把拉直了。我们团队去年就经历过这样一次事故,罪魁祸首最终被定位在一行看起来人畜无害的正则上。这篇实录把这个过程完整还原,并给出四种修复方案的实测对比。

问题背景:异常特征与第一轮排除 ​

事故发生在周三下午 14:23。监控系统先报了接口 P99 超时,紧接着服务实例 CPU 一路冲到 100%。汇总当时观察到的现象,有几个反直觉的特征值得记录:

观察项实际表现据此排除的方向
流量曲线与前一日基本持平流量突增、爬虫攻击
GC 指标频率与耗时均正常内存泄漏、Full GC
依赖服务下游响应时间正常中间件拖慢
发布记录最近五天无任何变更代码变更引入
慢查询数量无增长数据库瓶颈

初步结论是:CPU 被某个「内生的计算热点」吃掉了,而不是外部负载放大。更麻烦的是故障具备粘性——重启后二十分钟内必然复发,说明触发条件与持续到达的某类请求强相关。翻日志发现,超时请求的请求体里都包含一个超长的设备名片段,长度在六十到一百二十字符之间,由大量字母和空格组成。这个共性成了破案的第一条线索。

定位过程:从线程栈到那行正则 ​

CPU 型问题最快的定位组合拳是「线程栈粗看方向 + 火焰图精确定位」。先连续抓几次线程栈看业务线程卡在哪,再采样火焰图确认热点占比:

bash
# 第一步:连抓两次线程栈,看业务线程卡在哪个调用栈上
jstack <pid> > stack-1.txt
sleep 3
jstack <pid> > stack-2.txt

# 第二步:async-profiler 采样 60 秒 CPU 火焰图,产出可在浏览器打开的 HTML
./profiler.sh -d 60 -f flame.html <pid>

线程栈里十几根业务线程整齐地停在 java.util.regex.Pattern$Curly.match0,火焰图显示约八成的 CPU 样本落在正则匹配链路上。顺着调用栈往上翻,最终定位到一段设备名白名单校验:

java
// 出事的正则:把「若干个 token(token 之间可有空格)」写成了外层再包一层量词
private static final Pattern DEVICE_NAME =
    Pattern.compile("^(\\w+\\s?)+$");

一行正则吃掉八成 CPU,这就是教科书级的回溯灾难现场。

原理推导:回溯为什么能指数爆炸 ​

要理解这条正则为什么威力这么大,得先看 NFA 引擎的匹配方式:它像一个走迷宫的人,在每个岔路口先选择一条路往前冲,撞墙了再退回上一个岔路口换条路重走——这个「退回重走」就是回溯。正常情况下回溯次数与输入长度成正比;但当正则存在歧义切分时,回溯路径数会随输入长度指数增长。

(\w+\s?)+ 的歧义在于:一段连续字母既可以被当作「一个较长的 token」,也可以被切成「多个较短的 token」。对输入 aaaa aaaa aaaa aX 这样末尾无法匹配的字符串,引擎必须穷举每一种切分组合才能宣告失败——n 段文本存在 2 的 n 次方种切法。实测三十个字符的失败输入就要尝试上亿条路径,五十个字符足以让一次匹配跑满一分钟。我们用 JavaScript 做了个对照实验(同样的正则、同为回溯型引擎):

javascript
// 仅用于观察回溯规模的最小复现,请勿对更长输入直接执行
const re = /^(\w+\s?)+$/;
for (let n = 12; n <= 18; n += 2) {
  const evil = "a".repeat(n) + "a ".repeat(n) + "X";
  const t0 = performance.now();
  re.test(evil); // 失败匹配触发最坏路径
  console.log(`len=${n}: ${(performance.now() - t0).toFixed(1)}ms`);
}
// 输入每加长约 2 个字符,耗时接近翻倍:典型的指数增长曲线

关键结论可以压缩成一句话:回溯灾难几乎总是同时满足三个条件——量词嵌套(外层量词套内层量词)、歧义切分(同一段文本有多种匹配方式)、匹配最终失败(引擎必须穷尽所有路径)。破坏三者中任何一个,灾难即被解除。这也是后文所有修复方案的共同原理。

修复方案对比:四种改法实测 ​

针对这条正则,我们评估了四种修复方案,实测基于相同输入集:三十条恶意样本加一万条正常样本。

方案核心思路恶意输入耗时改动成本适用边界
改写消除歧义每个位置只留一种切法微秒级低首选,绝大多数场景
占有量词或原子组吃进去就不吐出来微秒级低Java、PCRE、Python 3.11+
超时护栏匹配超时主动中断毫秒级中断中改不动正则时的兜底
输入前置防御长度与字符白名单请求直接拒绝低纵深防御的常规项

首选方案是改写。把「token 的拼接方式」唯一化,歧义即消失:

java
// 修复版:外层只负责「空格 + token」的重复,每个字符只有一种归属方式
private static final Pattern DEVICE_NAME =
    Pattern.compile("^\\w+(\\s?\\w+)*$");

第二种思路是占有量词:让内层匹配「吃进去就不再吐回」,直接掐断回溯路径。Java 支持 ++ 写法与原子组 (?>...):

java
// 占有量词版:\w++ 与 \s?+ 失配后不回退,切分数被固定为一种
Pattern.compile("^(\\w++\\s?+)+$");

必须提醒方言差异:截至 2025 年,JavaScript 引擎仍不支持占有量词与原子组,前端遇到同样的问题只能靠改写或输入限长防御;Python 自 3.11 起、Java 与 PCRE 则早已支持。跨端复用正则前务必确认目标引擎的能力矩阵,这一条也是方言踩坑的高发区。

实操:给存量正则加一道超时护栏 ​

对于历史代码里改不动、又不敢贸然下线的正则,超时护栏是最后的兜底。Java 的 Matcher 没有原生超时参数,标准做法是包一层 CharSequence,在读字符时检查耗时:

java
// 可复用的超时护栏:读字符时检查截止时间,超时直接抛异常终止匹配
public class TimeoutCharSequence implements CharSequence {
    private final CharSequence inner;
    private final long deadlineNanos;

    public TimeoutCharSequence(CharSequence inner, long timeoutMillis) {
        this.inner = inner;
        this.deadlineNanos = System.nanoTime() + timeoutMillis * 1_000_000L;
    }

    @Override
    public char charAt(int index) {
        if (System.nanoTime() > deadlineNanos) {
            throw new IllegalStateException("正则匹配超时,疑似回溯灾难");
        }
        return inner.charAt(index);
    }

    @Override
    public int length() { return inner.length(); }

    @Override
    public CharSequence subSequence(int start, int end) {
        long remain = Math.max(0, (deadlineNanos - System.nanoTime()) / 1_000_000L);
        return new TimeoutCharSequence(inner.subSequence(start, end), remain);
    }
}
// 使用方式:DEVICE_NAME.matcher(new TimeoutCharSequence(input, 100)).matches();

搭配两条工程纪律效果最好。其一,Pattern 必须预编译成静态常量(事故代码里它已经是,否则还要叠加反复编译的开销),这也是所有正则使用规范的第一课;其二,护栏抛出的超时异常要单独打点告警,它是发现新回溯风险最直接的哨兵——比事后从火焰图里找线索便宜得多。

避坑总结 ​

  1. 量词嵌套优先排查。见到「量词包量词」结构(如 (A+)+、(A*B*)*)先问一句:同一段文本是否存在多种切分方式?有歧义就必须改写。
  2. 失败匹配才是最坏路径。回溯灾难通常发生在「匹配不上」的输入上,性能测试不能只跑正例,要专门构造失败样本。
  3. 修复首选改写而非超时。改写消除歧义是根治,超时护栏只是止血;两者可以叠加,但不能只用后者。
  4. 方言差异要前置确认。占有量词在 JavaScript 不可用,跨端复用正则前先核对目标引擎支持矩阵。
  5. 防御要分层。输入限长与字符白名单把恶意样本挡在正则之前,超时护栏守住最后底线,监控告警负责快速发现,三层缺一不可。
  6. 把嵌套量词写进评审清单。新提交的正则一旦出现嵌套量词,评审时必须要求作者说明歧义切分是否存在,并附失败样本的性能证据。

写在最后:正则回溯灾难的本质,是「穷举所有可能性」这一设计在歧义输入上的必然代价。理解了这一点,无论是改写、占有量词还是超时护栏,都只是同一原理在不同位置的落地形式。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。