正则通常是纳秒级操作,但某些模式遇到特定输入会突然卡死几分钟——这就是”灾难性回溯”(catastrophic backtracking)。理解它能避免被 ReDoS 攻击,也能写出更快的正则。
一个经典案例
/^(a+)+$/.test('aaaaaaaaaaaaaaaaaaaaaaaaaaaaX')
看起来人畜无害的正则,在 29 个 a 加一个 X 上能跑 30 秒甚至更久。多加一个 a 耗时翻倍——典型指数爆炸。
Cloudflare 2019 年一次全网宕机 27 分钟,直接原因就是一条含 .*(?:.*=.*) 的 WAF 规则,碰到构造的输入触发了 PCRE 的这种回溯。
为什么会回溯
引擎遇到量词(*、+、?)时先贪婪吃,吃到不匹配再往回退一位继续试。嵌套量词 (a+)+ 让每个内层的 a+ 有多种”吃法”:
输入 aaaa,(a+)+ 可以拆成:
(aaaa)
(aaa)(a)
(aa)(aa)
(aa)(a)(a)
(a)(aaa)
(a)(aa)(a)
(a)(a)(aa)
(a)(a)(a)(a)
n 个 a 大约有 2ⁿ⁻¹ 种拆法。只要末尾 $ 不匹配(比如多了个 X),引擎会把所有拆法都穷举一遍才放弃。
三个危险模式
1. 嵌套量词
(a+)+ 危险
(a*)* 危险
(.*)+ 危险
([a-z]+)* 危险
修复:把内层量词拿掉或改写成不等价但等效的单层形式。
a+ OK
[a-z]+ OK
2. 交替分支有公共前缀
(a|a)+ 危险(两个分支都能吃 a)
(a|ab)+ 危险(第二分支吃的 a 也能分给第一分支)
修复:去重,或把公共前缀提出来。
a+b? 替代上面第二条
3. 贪婪 .* + 末尾锚点失配
^.*.*=.*$ 两个 .* 同级叠加
^(\w+\s?)+$ 空格可选让分支重叠
修复:用具体字符集缩窄,消除歧义。
^[^=]*=.*$ OK
^\w+(\s\w+)*$ OK(空格必选)
线性引擎 vs 回溯引擎
| 语言 / 库 | 引擎类型 | 反向引用 | 环视 |
|---|---|---|---|
Rust regex | Thompson NFA(线性) | ✗ | 有限 |
Go regexp | RE2(线性) | ✗ | ✗ |
Python re2(第三方) | RE2(线性) | ✗ | ✗ |
| JavaScript | 回溯 | ✓ | ✓ |
Python re | 回溯 | ✓ | ✓ |
Java Pattern | 回溯 | ✓ | ✓ |
反向引用 (\d)\1 和多数环视是回溯引擎的核心能力,也是线性引擎不支持的原因——这些特性让 DFA 构造无法完成。
独占量词 / 原子组
Java、PCRE、Python 3.11+ 支持独占量词 a++、a*+——吃了就不回溯。能从根源上消灭回溯:
(a+)+ 回溯指数爆炸
(a++)+ 独占,线性时间
JavaScript ES2024 纳入了原子组 (?>...),Node 22+ 支持:
^(?>a+)+$ 原子组内不回溯
ES2024 之前 JS 没有等价语法,只能靠改写表达式或换引擎。
超时兜底
改不动正则时,用线性引擎:
// Node.js
import RE2 from 're2';
const re = new RE2('^(a+)+$');
re.test(userInput); // 线性时间,永远不会挂起
或者丢到 worker 线程跑正则,主线程 setTimeout 100ms 超时就 terminate worker。
手写测试用例
拿到可疑正则,构造”长前缀 + 不匹配后缀”做压测:
const s = 'a'.repeat(30) + 'X';
console.time('r');
re.test(s);
console.timeEnd('r');
30 字符跑超 100ms,基本有回溯问题;跑超 1 秒,立刻整改。
排雷清单
- 嵌套量词
(a+)+/(a*)*→ 改单层 - 交替分支能匹配同一段 → 去重或重构
.*与$同行 → 用非点字符集缩窄- 用户输入正则或拼
new RegExp(userInput)→ 永远不允许,等于把 DoS 开关交给攻击者 - 复杂正则上线前跑”长前缀 + 失配后缀”计时
- 敏感场景换 RE2 / 独占量词
正则表达力很强,但回溯引擎的复杂度曲线是隐藏悬崖。写完复杂正则记得拿压测样例跑一次——没卡就安全,卡了马上改。