灾难性回溯:正则为什么会让 CPU 打满
嵌套量词让匹配尝试数指数增长,(a+)+b 在一条长的不匹配输入上就能拖死进程。修法是去掉嵌套量词或给正则加超时。
一条正则让整个服务卡死,通常是灾难性回溯:匹配引擎尝试的组合数随输入长度指数增长。
出问题的形状
/^(a+)+b$/.test('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')
没有 b,所以永远匹配失败。但引擎在放弃前会尝试 a 的所有切分方式:外层 + 和内层 + 都能任意分配这些 a,组合数是 2 的 n 次方。30 个 a 就是十亿次尝试。
危险信号是量词套量词:(x+)*、(x*)*、(x|y)+ 里两支能匹配同一字符。
修法一:去掉嵌套
上面那条正则想表达的其实是「全是 a,最后一个 b」:
/^a+b$/
一条无嵌套的量词是线性的。大部分灾难性正则手工改写成非嵌套形式就够了。
修法二:用固化的组
确实需要分组捕获时,把内层组变成原子组或占有量词,让引擎不回退:
/^(?:a++)+b$/ // 占有量词
JS 从 ES2018 起支持后行断言,但原子组要 ES2025;在此之前可以靠重写规避。
修法三:限制输入长度
如果业务上输入不可能超过 200 字符,先 if (s.length > 200) return false。指数增长只要底数被限制在小区间就不可怕。
修法四:加超时
最稳的兜底是不信任任何正则:
function safeTest(re, s, tag) {
const start = Date.now();
const worker = new Worker(/* 在独立线程里跑 re.test(s) */);
// 超时则 terminate,兜底返回 false
}
Node 没有内置正则超时,只能靠独立线程或进程隔离。RE2 这类不做回溯的引擎则从根上免疫,代价是不支持反向引用和后行断言。
检查清单
| 看到什么 | 风险 |
|---|---|
(a+)* (a*)* |
高,指数 |
| `(a | a)*` 两支重叠 |
| 用户可控的正则 | 高,等同于远程代码 |
| 固定字面量正则 | 低,无回溯 |
最后一条最容易被忽略:绝不要让用户提交正则表达式。那不是配置项,是可在你服务器上运行的图灵完备程序。
量词套量词就是警铃。改不成非嵌套形式,就必须加超时。

评论
…