一条让 CPU 打满的正则
// 校验一个『域名』,看似无害:
/^(\w+\.)+\w+$/.test('a'.repeat(30) + '!')
第三个参数是个不匹配的坏串:30 个 a 再加一个 !。结果不是「返回 false」这么简单——现代引擎需要 指数级 时间挣扎后才会放弃,长度涨到 60、100,机器直接卡死。这段再见多怪的正则,就是「灾难性回溯」的标准案例。
NFA 引擎是怎么工作的
大多数语言(PCRE、Java、Python、JS 的部分语法)默认用的是 NFA(非确定有限自动机) 引擎,它的匹配方式是「回溯」:
- 拿着子表达式从当前位置去试;
- 量词能多吞就多吞(贪婪);
- 一旦最终失败,逐格退回一步,换一种吞法再试。
这种设计非常灵活、好写,代价是同一个位置可能被反复试探无数次。
为什么会指数爆炸
(a+)+ 这类嵌套量词的可怕之处在于:外层 + 决定『分成几组』,内层 + 决定『每组几个』。对长度 n 的坏串,分组方案数本身就是指数级的(本质上是 n 的组合划分数)。引擎会忠实地把每种分法都试一遍:
(a+b)+ 对 'aaaab'(不匹配):
尝试 (a)(a)(a)(b) ✗ → (aa)(a)(b) ✗ → (a)(aa)(b) ✗ → (aaa)(b) ✗ ...
只差最后一个字符匹配不上,前面的所有分法却要先全部试完——复杂度从「匹配成功时的 O(n)」暴涨到「不匹配时的 O(2ⁿ)」。
三个经典高危模式
(a+)+ 嵌套量词:分成几组 × 每组几个 = 组合爆炸
(.*)* 用 . 吞一切再重复:无数种吞法相互叠加
(.|a)* 分支与量词嵌套:每一个字符都可选择走哪条分支
真实的危险写法还常藏在这些「看起来正常」的模式里:
^(\w+\.)+\w+$ 域名/子域名校验
^(([a-z])+.)+[A-Z]([a-z])+$ 多段强密码式输入
^(\d+);(\d+;)*\d*$ 分号分隔数字串
它们共同点:可重复的子表达式相邻且重叠。对满足格式的好输入它们秒过;对恰好不匹配、前缀又很长的坏输入,就进入指数地狱。
三种治本手段
1. 原子组 / 占有量词 —— 吞了不还
(?>a+)b 原子组:一旦吃掉 a+ 就不再退让
a++b 占有量词:等价写法的量词版
它直接禁止引擎对这部分回退,从根上消除该子表达式的回溯,是最干净的修法。JS 的正则引擎不支持原子组/占有量词?——那就换方案二或三。
2. 前置整体前瞻 —— 先判形,再匹配
(?=[a-z0-9.]+$)^(\w+\.)+\w+$ 先整串过一遍格式前瞻
把「格式是否合法」这个最耗回溯的判定,用一次前瞻在进入量词匹配之前完成;坏输入在第一步就被否掉,不再触发后面的回溯爆炸。
3. 超时 + 限长兜底 —— 无论对错绝不挂死
// Node 场景:包一层超时,坏输入也别拖死进程
const r = new RegExp(pattern, 'd'); // 带 indices
await Promise.race([doMatch(r, s), sleep(50)]);
还有通用纪律:对不可信输入先限长(s.slice(0, 1000)),再匹配;公开校验接口必须限制可传文本大小。即便正则写得再对,也不能把进程的生死押在用户输入上。
用调试工具高亮回溯
光修炼理论没用,要看见回溯才记得住。把你的正则和失败串丢进支持「回溯高亮」的工具(很多 正则测试 站点能一步步展示匹配在哪些位置反复退让)——尤其注意:是否在同一个位置来回走了很多次?是否红色高亮集中在尾端的一小段?这两点正是回溯炸弹的直观写照。看到它,比记十条口诀都深刻。
自查
给下面这条长坏串配一个安全版本(不用原子组/占有量词的 JS 也行):
输入:'a'.repeat(40) + '!'
模式:^(\w+\.)+\w+$
试着用「先加长度守卫再做整体前瞻」把它改写,到 正则测试 工具里对比改前改后的耗时。能交出差了几个数量级的对比,规避回溯的本事就算进门了。