灾难性回溯:一条正则如何耗尽 CPU
有些正则在匹配失败时会卡死——不是写错了,而是回溯引擎要尝试指数级的组合。
典型成因是嵌套量词((a+)+、(a|a)*):同一段文本能被拆成无数种方式,
一旦末尾对不上,引擎就把每一种拆法都试一遍。下面实测它每加一个字符、步数翻一倍——
这就是 ReDoS(Regular-expression Denial of Service)的根。
(a+)+b 去匹配 "aaaa…a"(没有结尾的 b)。
外层 + 要把这串 a 切成若干段、每段交给内层 a+——n 个 a 有
2n-1 种切法。因为整串注定匹配不上(缺 b),引擎被迫把所有切法都尝试一遍,
于是步数 ≈ 2n。
24
输入 =
"a"×n(故意不放结尾字符 → 匹配失败 → 触发全回溯)
回溯步数随 n 增长(纵轴对数刻度;红 = 超出预算上限)
同一条 regex,换成线性引擎的表现
上面那条爆炸曲线是回溯引擎的病。基于 NFA/DFA 的线性引擎(本仓库的 Pike VM、Google RE2)
同时跟踪「所有可能状态」,从不回溯,所以同一条 (a+)+b 是 O(n)——根本不存在指数爆炸。
点下面用本仓库 @vega/parsing 的两条真引擎实测耗时对比(回溯故意强制开启)。
回溯一栏到 n≈22 就要数百 ms、再大直接卡死——这正是为什么默认引擎
auto 会自动躲开它选 Pike。点上面按钮开跑。
其一,精确化、消歧义:
(.*?,){11} → ([^,\r\n]*,){11},去掉嵌套量词的重叠区。其二,原子组
(?>…):整组匹配完就「提交」,绝不回溯进去。其三,占有量词
a*+ / a++:吃掉就不吐。根本解决:换线性引擎(RE2 / Pike VM)。Cloudflare 2019 年那次全球宕机,正是一条
.*.*=.* 类的正则在 WAF 里灾难性回溯占满 CPU——后来迁到了 RE2。
{ engine: 'pike' };在浏览器 / Node 的原生
RegExp 上,V8 也内置了一个非回溯的线性引擎,用实验性的 l flag 开启
(/(a+)+$/l,l = linear)。但它至今默认关闭、需启动时带
--enable-experimental-regexp-engine,且和 RE2 一样不支持 lookaround / 反向引用
——思路与权衡都跟本仓库 engine:'pike' 一模一样。Safari 的 JSC 则是另一路:回溯超过约一百万次就直接返回
false(快,但结果可能是错的)。
🔗 相关链接
- Runaway Regular Expressions: Catastrophic Backtracking · regular-expressions.info 把灾难性回溯的成因(嵌套量词)与三种修法(精确化 / 原子组 / 占有量词)讲得最透的一篇。
- Regular Expression Matching Can Be Simple And Fast · Russ Cox RE2 作者的经典长文:为什么 Thompson NFA 是线性的、Perl/PCRE 的回溯为何指数——本页 Pike VM 那条线的理论根。
- Cloudflare 2019-07-02 全球宕机复盘 · blog.cloudflare.com 一条带嵌套量词的 WAF 规则灾难性回溯,把全球边缘节点 CPU 打满 —— ReDoS 最著名的真实事故。