一行正则把 CPU 打到 100%:我把回溯的账算了一遍
- 作者:Bougie
- 创建于:2026-10-10
上周给一个表单加邮箱校验,我写了这么一行:
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
两秒六。而且这两秒六里,进程是完全卡死的——健康检查不响应、心跳超时、其他请求全在排队。一行正则把整个服务拖下水。

下面所有数字都是我在本机 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 的三个必要条件
我把它记成三条,缺一条都只是"慢",三条齐了才是漏洞:
- 量词嵌套或分支歧义:
(x+)+、(x*)*、(a|aa)+、(a|a?)+,或者\s*\w+\s*这种两段能抢同一批字符的写法。 - 整体必须匹配失败:引擎只有在走投无路时才会穷举。
- 输入长度由外部控制:有人能往里塞很长的字符串。
攻击串的构造方法就是「前缀 + 泵 + 破坏字符」:让泵的那一段重复很多次,末尾再放一个必定让匹配失败的字符。我那行邮箱正则的攻击串就是 '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 里了,只是没给你。

# 十、顺手:/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。
# 最后
把上面这些收成几条能直接用的规则:
- 看到
(x+)+、(x*)*这种量词套量词,直接改。 没有例外。 - 分支里有重叠也要警惕:
(a|aa)、(a|a?),增长是 1.618^n,比 2^n 更容易蒙混过关。 - 相邻多个
a*是同一个坑,k 个就是 O(n^k),而且在短输入上完全测不出来。 - 用户给的正则片段,
RegExp.escape。 用户给的字符串,先用indexOf/startsWith之类处理。 - 跑在请求路径上的正则,先限长度再匹配。
- 改不动又吃外部输入的正则,丢 Worker + 超时
terminate()。 - 别指望语言层面救你:V8 有线性引擎,默认关着。
- 测试用例里一定要有"长的不匹配输入"。 慢的永远是匹配不上的那一次。
那行邮箱正则我最后改成了先 indexOf('@') 切成两段、再各做一次字符集检查。写完发现可读性还更好了。
正则很好用,但它是一把没有护手的刀——锋利的那面朝着输入,握着的那面朝着你的主线程。