一行正则把 CPU 打到 100%:我把回溯的账算了一遍

上周给一个表单加邮箱校验,我写了这么一行:

const RE = /^([a-zA-Z0-9_\-\.]+)+@([a-zA-Z0-9_\-\.]+)+\.[a-zA-Z]{2,}$/

看着人畜无害。我随手拿 a@b.com 试了,通过;!!@x.com,拒绝。挺好,提交。

然后我把 32 个 a 加一个感叹号丢进去:

n=32 → 2650.85 ms

两秒六。而且这两秒六里,进程是完全卡死的——健康检查不响应、心跳超时、其他请求全在排队。一行正则把整个服务拖下水。

一团发光的正则代码在黑暗机房里缠成巨大的死结,蓝色监视器上 CPU 曲线顶在 100%

下面所有数字都是我在本机 Linux x86_64、Node v24.16.0 上跑出来的,脚本不依赖网络,你可以照着复现。

# 一、30 个字符,2.9 秒

先拿教科书级的 ^(a+)+$ 开刀。输入是 n 个 a 后面接一个 X(保证匹配失败):

n 耗时 n 耗时
21 7.2 ms 26 180.4 ms
22 14.4 ms 27 367.8 ms
23 28.5 ms 28 721.0 ms
24 48.6 ms 29 1453.0 ms
25 92.2 ms 30 2894.0 ms

每多一个字符,耗时翻一倍。 按这个倍率外推,n=50 大约是 35 天。

这不是理论值。这是我在自己机器上掐表掐出来的真实曲线。

# 二、为什么会指数:引擎在试每一种切法

a+ 是贪婪的,先一口气吃掉全部 n 个 a;外层 + 想再来一轮,没字符了;于是外层结束,引擎去看 $——后面还站着个 X,失败。

灾难从失败这一刻才开始。引擎回退,让内层的 a+ 吐出一个 a,外层再试;再吐一个,再试……"把 n 个 a 切成若干段"的每一种切法都要走一遍,切法一共 2^(n-1) 种。

所以有一条最容易记的规律:慢的不是"匹配",是"匹配不上"。 同一个正则,把结尾那个 X 去掉让它能匹配上:

n=30   能匹配: 0.041 ms      匹配不上: 2904.343 ms

差 7 万倍。这也解释了为什么这类 Bug 常年潜伏——你的测试用例里全是"应该通过"的输入,它们跑得飞快。

# 为什么 JS 不干脆换个不会回溯的引擎

因为换不了。Thompson 构造那套 NFA/DFA 模拟(RE2、Rust 的 regex crate)能保证 O(n·m),代价是不支持反向引用,也对 lookaround 支持得很有限。

而 ECMAScript 的正则规范里,反向引用 (a)\1、前瞻 (?=...)、以及捕获组"谁先匹配到算谁"的语义,全都按回溯式引擎的行为写死了。规范不改,引擎就换不掉。这不是 V8 偷懒,是 /(a)\1/ 这种写法本身就不是一个正则语言能表达的东西( pumping lemma 直接否决)。

# 三、不是所有"慢"都是指数:三种曲线

三条增长曲线画在同一张坐标纸上,蓝色平缓的抛物线、琥珀色更陡的三次曲线、以及几乎垂直的红色指数线

把 ^(a+)+$ 当成唯一的敌人会漏掉很多东西。我测了另外两类。

斐波那契型:^(a|aa)+$

n 耗时
28 5.88 ms
32 29.18 ms
36 164.10 ms
40 1082.51 ms
44 7412.76 ms

每 4 个字符涨 6.85 倍,折算下来每字符 ×1.618——正好是黄金比例。因为"把 n 个 a 拆成若干个 1 和 2 之和"的方法数就是 Fibonacci(n)。它也是指数,只是底数小,测试时更容易蒙混过关(40 个字符才 1 秒)。

多项式:相邻的 a*

正则 实测
^a*a*$ 1 万字符 25.1 ms、10 万字符 3328.9 ms(输入 ×10 → 耗时 ×100,约 O(n²))
^a*a*a*$ 1000 字符 134.5 ms、2000 字符 1026.6 ms、4000 字符 7842.7 ms(翻倍 ×7.6,约 O(n³))
^a*a*a*a*$ 200 字符 48.6 ms、400 字符 903.6 ms、800 字符 13698.2 ms(翻倍 ×15,约 O(n⁴))

规律很干净:k 个相邻、且能匹配同一段文本的无界量词,大约是 O(n^k)。

这一类才是最阴险的。指数型你随手丢个 40 字符的输入就炸了,当场发现;多项式型在你所有测试数据(10 个字符)上永远都是 0.01 ms,只有线上来了个 10 KB 的字段才现形——而那时候它已经被当成"偶发抖动"忽略三个月了。

顺便说一句量级:^a*a*$ 10 万字符 3.3 秒,^a*a*a*$ 4000 字符 7.8 秒,^a*a*a*a*$ 800 字符 13.7 秒。这三个都还在"人类勉强能忍"的区间里。

# 四、ReDoS 的三个必要条件

我把它记成三条,缺一条都只是"慢",三条齐了才是漏洞:

  1. 量词嵌套或分支歧义:(x+)+、(x*)*、(a|aa)+、(a|a?)+,或者 \s*\w+\s* 这种两段能抢同一批字符的写法。
  2. 整体必须匹配失败:引擎只有在走投无路时才会穷举。
  3. 输入长度由外部控制:有人能往里塞很长的字符串。

攻击串的构造方法就是「前缀 + 泵 + 破坏字符」:让泵的那一段重复很多次,末尾再放一个必定让匹配失败的字符。我那行邮箱正则的攻击串就是 'a'.repeat(32) + '!'。

反过来,如果输入长度是你自己控制的(比如枚举值、配置文件的固定字段),前两条成立也不算漏洞。

# 五、V8 自己的两级执行:第一次跑慢 7.7 倍

测到一半我发现一个对不上的数字:同样是 ^(a+)+$ 配 n=28,有时 721 ms,有时 5584 ms。原因是 V8 的正则有两级——先解释执行,跑够次数再 tier-up 编译成本机码(默认 --regexp-tier-up-ticks=1,跑一次就升)。

同一个进程里,第一个正则对象:

第 1 次: 5702 ms   第 2 次: 721 ms   第 3 次: 720 ms   第 4 次: 715 ms

之后 new 一个同 pattern 的新对象,第一次就是 715 ms——编译产物按 pattern 复用,不是按对象。

用 --regexp-interpret-all 强制留在解释器里,和编译态对比:

n 解释器 编译后
24 355.2 ms 48.6 ms
26 1429.9 ms 180.4 ms
28 5592.1 ms 721.0 ms

约 7.7 倍。

实践含义有两条:一是冷启动的第一次最慢,压测里"第一发特别慢"往往就是这个(我第一版数据被它污染过);二是"我本机跑挺快啊"有可能是因为你已经跑热了,而线上那个低频接口每次都是冷的。

陡峭的指数曲线由发光的红点组成,每个点是前一个的两倍大,深色工程网格背景

# 六、卡住的不是那一行,是整个进程

正则在 JS 里是同步执行的,没有任何可中断点。我起了一个 50 ms 的 setInterval,然后跑一次 n=28(717 ms):

正则跑了 717 ms,50 ms 定时器本应触发 14 次,实际触发 0 次
一次 717 ms 的正则阻塞,让一个 10 ms 的 setTimeout 实际 717 ms 后才跑

不是延迟,是零次。这 717 ms 里你的 HTTP 服务不响应任何请求,健康检查失败,K8s 可能已经把 Pod 摘了。

对比一下正常正则有多快(100 万次取平均):

操作 单次耗时
/\d+/.test 0.0107 µs
/\d+/.exec 0.0232 µs
str.match(/\d+/) 0.0225 µs
str.replace(/\d+/, '#') 0.0267 µs
str.split(/\s+/) 0.0737 µs

那 717 ms 差不多等于 6700 万次普通 test。一次卡死,把你几天的正则预算全花光了。

一块巨大的红色石块卡死工厂传送带,墙上的一排时钟全部停摆,戏剧性工业光照

# 七、怎么修

# 1. 拆掉嵌套量词

我那行邮箱正则,问题就出在两个 + 上:([\w.]+)+ 是量词套量词。把外层的 + 去掉:

// 坏
/^([a-zA-Z0-9_\-\.]+)+@([a-zA-Z0-9_\-\.]+)+\.[a-zA-Z]{2,}$/
// 好
/^[a-zA-Z0-9_\-\.]+@[a-zA-Z0-9_\-\.]+\.[a-zA-Z]{2,}$/
n 坏版本 好版本
24 49.5 ms —
28 167.0 ms —
30 669.4 ms —
32 2650.9 ms —
100000 别想了 0.10 ms

差别只是删了两个字符。

# 2. 先限长度

if (s.length > 200) return false

粗暴,但有效——指数型的杀伤力全在长输入上,长度闸门直接把最坏情况钉死。作为纵深防御的一层,性价比最高。

# 3. 用户输入进正则前必须转义

RegExp.escape('a+b(c)[d]') // '\\x61\\+b\\(c\\)\\[d\\]'

注意连普通字母 a 都被转成了 \x61——这是规范为了避开未来语法扩展做的保守处理,看起来怪但没问题。

不转义的后果不只是行为错乱,还有 DoS:用户提交一个 (a+)+ 当搜索词,服务端 new RegExp(q) 一下就成了攻击面。

# 4. JS 里没有原子组,别去找了

别的语言里 (?>...) 或者 a++ 这种占有优先量词是标准解法,JS 没有。我试了 V8 的 --regexp-possessive-quantifier:

new RegExp('a++')  →  SyntaxError: Invalid regular expression: /a++/: Nothing to repeat
new RegExp('a*+')  →  SyntaxError: Invalid regular expression: /a*+/: Nothing to repeat

语法还没接上。现阶段只能靠改写正则,或者把一步匹配拆成几步简单匹配。

# 八、兜底:把正则丢进 Worker

如果那个正则你改不动(第三方库、历史包袱),Worker 是唯一能真正打断它的办法:

输入 n=24(正常量级) → 356 ms 后返回结果
输入 n=40(主线程上外推约 35 天) → 1000 ms 时被 terminate() 掐断,主线程毫发无伤

两个细节:

  • 356 ms 里只有约 16 ms 是 worker 启动开销(空任务往返实测 14.7~18.9 ms),剩下 340 ms 是正则本身——因为 worker 里第一次跑同样是解释态(就是第五节那 355.2 ms)。热起来的 worker 会快得多。
  • worker.terminate() 是真的能把进程杀掉的,主线程上的正则没有这种开关。

代价是要维护超时逻辑和一份 worker 代码。只对你确实改不动、又确实吃外部输入的那几条正则做这件事。

# 九、V8 其实已经写好了一个线性引擎

这是我最意外的一节。

# l 标志

node --enable-experimental-regexp-engine

/^(a+)+$/l     n=40: 0.00 ms      n=1000: 0.04 ms      n=100000: 3.61 ms

10 万字符,3.61 毫秒,线性时间。不加那个 flag 的话,new RegExp(p, 'l') 直接报 Invalid flags supplied to RegExp constructor 'l'。

代价也实测了,同一个 /\d+/ 跑 200 万次:

引擎 单次
默认 0.0138 µs
l 0.8773 µs

慢 64 倍——这就是它没法默认开启的原因。而且它不支持反向引用和前瞻:

new RegExp('(a)\\1', 'l')   →  Invalid regular expression: /(a)\1/l: Cannot be executed in linear time
new RegExp('(?=a)a', 'l')   →  同上
new RegExp('(?<=a)a', 'l')  →  能创建

# 还有一个"回溯过量就自动降级"的开关

--enable-experimental-regexp-engine-on-excessive-backtracks
--regexp-backtracks-before-fallback=50000   ← 默认阈值

开启后,回溯次数超过阈值就自动切到广度优先引擎:

n 默认 开启自动降级
28 721 ms 0.79 ms
32 约 11.6 秒(外推) 0.13 ms
1000 宇宙寿命级别 0.13 ms

把阈值调成 100(--regexp-backtracks-before-fallback=100),n=22 到 32 全部 ≤ 0.04 ms。

两个开关默认都是关的。 能力已经写在 V8 里了,只是没给你。

蓝图上并排的两台机器:左边是缠成一团、布满死胡同的分叉管道,标注 backtracking;右边是一条笔直的公路,绿色箭头标注 linear time

# 十、顺手:/g 和 /y 的 lastIndex

同源的另一个坑,顺手记一下:

同一个 /a/g 连续 test('a') 5 次:   true, false, true, false, true
同一个 /a/y 连续 test('aa') 4 次:  true, true, false, true

带 g / y 的正则对象是有状态的:每次匹配成功会把 lastIndex 停在上次结束的位置,下一次从那儿开始;匹配失败时重置为 0(所以 /a/y 那个 false 之后又变回了 true)。

模块顶层 const RE = /x/g 然后到处复用,是最经典的偶发 Bug 来源——奇数次调用通过、偶数次失败,而且你本地复现不了,因为别人的调用次数和你不一样。要么每次新建,要么用完 RE.lastIndex = 0。

# 最后

把上面这些收成几条能直接用的规则:

  1. 看到 (x+)+、(x*)* 这种量词套量词,直接改。 没有例外。
  2. 分支里有重叠也要警惕:(a|aa)、(a|a?),增长是 1.618^n,比 2^n 更容易蒙混过关。
  3. 相邻多个 a* 是同一个坑,k 个就是 O(n^k),而且在短输入上完全测不出来。
  4. 用户给的正则片段,RegExp.escape。 用户给的字符串,先用 indexOf / startsWith 之类处理。
  5. 跑在请求路径上的正则,先限长度再匹配。
  6. 改不动又吃外部输入的正则,丢 Worker + 超时 terminate()。
  7. 别指望语言层面救你:V8 有线性引擎,默认关着。
  8. 测试用例里一定要有"长的不匹配输入"。 慢的永远是匹配不上的那一次。

那行邮箱正则我最后改成了先 indexOf('@') 切成两段、再各做一次字符集检查。写完发现可读性还更好了。

正则很好用,但它是一把没有护手的刀——锋利的那面朝着输入,握着的那面朝着你的主线程。