你写的正则正在用 2^n 的时间等你:回溯引擎的灾难性回溯,一次线上事故复盘

2026-11-14 15 0

开场:一条 12 个字符的正则,把 16 核 CPU 吃满了

事情是这样的:某个表单校验接口的 P99 突然从 8ms 涨到 12s。扩容无效,重启无效,流量其实一点没涨。火焰图一拉,满屏全是同一个函数——regexp/exec,只不过不是在 Go 里,而是 PHP 7.3 里那个基于 PCRE 的 pcre_exec。

罪魁祸首是这么一行:

^(?:[a-zA-Z0-9!#$%&'*+\-/=?^_`{|}~]+\.)+[a-zA-Z]{2,}$

看起来人畜无害,对吧?邮箱格式校验,写了十年没人出事。但当有人在某个字段里塞进 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!" 这种合法前缀 + 一个不匹配的尾巴时,回溯引擎会在指数级的路径空间里疯狂搜索。

灾难性回溯(Catastrophic Backtracking)不是 bug,是正则回溯模型的必然产物。你写下的正则语法是有限的,但匹配它的路径可以是无限的。

为什么"贪婪匹配"会变成"指数爆炸"

回溯引擎(NFA 的反实现,靠栈回溯)的核心逻辑是:按顺序尝试每一条可能的路径,失败了就退回上一个选择点,尝试下一条。贪婪量词 + 会先吃掉尽可能多的字符,失败后再逐个回退字符重试。

关键在于,当两个相邻的量词匹配同一段文本时,分歧点数量会相乘。看这个经典例子:

^(a+)+b$

对输入 aaaaaaaaaaaaaaaaaaaaaaaaaaaaa(25 个 a),引擎的执行路径数量是 2^24 = 1677 万。而 30 个 a 就是 10 亿级别——机器表面上"没在干活",其实在疯狂枚举组合。这不是"慢了一点",这是复杂度爆炸。

import re
import time

pat = re.compile(r'^(a+)+b$')

for n in [20, 24, 28]:
    s = 'a' * n
    t0 = time.time()
    pat.match(s)
    print(n, round(time.time() - t0, 4))

你会看到 n 每加 1,耗时几乎翻倍。教科书式的指数曲线。真实场景里这意味着:一个 30 字符的输入,能让单核跑几十秒;如果是 Node.js 的正则或者某个同步阻塞的 PHP worker,那整个进程就是挂着等,连接池被一个个占满,最后表现为"服务不可用"。

真正阴险的三种伪装

第一类:嵌套量词。 (a+)+、(\w*\s*)*、(.*)+,凡是"分组里含量词,分组本身还带量词",都要拉响警报。

第二类:相邻可重叠的量词。 不一定非要有嵌套。^(\s*\w+)*$、^(.*\.)+$ 这种"两个量词能匹配同一段文本"的组合,同样会指数爆炸。检测方法很土但有效:把任意量词换成"只匹配一个字符",如果表达式仍能匹配同样长度的串,那它多半有歧义。

第三类:不在正则里的回溯。 很多语言的正则引擎把"最后一条匹配成功即可"和"整体匹配"混为一谈,于是你写 pattern.find(s) 而不是 fullmatch,引擎仍要为"最左最长/最左优先"做完整的回溯搜索。贪婪与懒惰的选择,有时只是把爆炸推迟了几步。

各语言引擎的"性格"差异

这不是玄学,是引擎实现的区别,值得单独记一笔:

  • Go (RE2):用 DFA/NFA 混合模拟,保证线性时间,没有回溯。你在 Go 里写灾难性正则,它不会爆炸——但代价是它不支持反向引用和环视,想写就只能换方案。Go 的选择是"我给你安全,你少点功能"。
  • Java:java.util.regex 是回溯引擎,但 HotSpot 从很早就开始对嵌套量词做"循环检查"优化(所谓 group-node expansion),遇到已知的危险模式会主动放弃部分搜索。这让 Java 相对安全,但不能依赖它——优化是启发式的,不是形式化保证。
  • Python:re 是纯回溯,没做嵌套量词优化,re.match 一样能把你拖垮。Python 3.11 引入的 re 超时机制(signal 层的 timeout 参数)算是"承认了这个问题存在"。
  • JavaScript:V8 的 Irregexp 做了很多优化,对简单模式很快,但回溯模型仍在,嵌套量词同样中招。而且 Node 是单线程——一条慢正则能直接卡住整个事件循环。
  • PCRE / PCRE2 (PHP, Ruby, Perl):经典回溯,且有 pcre.backtrack_limit、pcre.jit 这类旋钮可调,属于"承认自己会爆炸,但给你一个刹车"。
  • RE2 (C++):与 Go 同源,线性时间,牺牲反向引用。

顺带一提:.NET 的正则引擎在匹配超长字符串时会做"自动超时"(matchTimeout),抛 RegexMatchTimeoutException——这是把"爆炸"转化成"可捕获异常"的工程化处理,比闷头烧 CPU 强得多。

线上到底该怎么防

我现在的处理顺序是这样的,从上到下依次考虑:

# 1. 能不用正则就不用
#    邮箱、URL、手机号这类"结构化输入",
#    优先用专门的解析库或简单的前后缀判断

# 2. 必须用正则时,砍掉歧义
#    把 (\w+\s*)+ 改成 \w+(\s+\w+)*
#    把 (.+)+ 改成 .+

# 3. 给输入设长度上限(在进正则之前)
if len(value) > 200:
    return ErrTooLong

# 4. 用支持线性时间的引擎/语言
#    Go 优先;Java 慎用嵌套量词;Node 放 worker_threads

第 3 条被严重低估了。长度上限是最便宜的防线——指数爆炸的规模由输入长度决定,把输入截断到合理长度,指数再大也炸不了。绝大多数"用户输入"业务里,字段本来就该有长度限制,这一条往往能顺手解决。

另外几条实战经验:

  • 永远用完整匹配语义。需要"整串匹配"时用 fullmatch(Python)、^...$ 加 \A...\z(注意 JS 的 $ 会匹配换行前的位置,是个坑)。
  • 别用 .* 开头。它几乎总是配合后面的模糊匹配引发灾难。
  • 监控要看 CPU profile 里的 regexp 栈帧。如果火焰图上出现 re.compile/regexp.(*backtrack) 且持续增长,不用怀疑,直接查。
  • 正则要缓存。Go 里 regexp.MustCompile 在包级变量里编译一次;Python 里注意 re 模块自带缓存(512 条),但 re.compile 传 re.IGNORECASE 之类的 flag 时会走另一套键,仍然有上限。

一点更深的想法

这次排查让我意识到一个更大的问题:正则表达式是一种"没有类型系统保护的 DSL"。你写 SELECT * FROM t WHERE id = ?,数据库会告诉你有没有索引;你写 SQL,ORM 至少有基本的校验能力。但正则是一段字符串,它可以被字符串拼接污染,可以被嵌套到无法静态分析,而且——最要命的是——它的最坏情况复杂度不写在文档里,也不写在类型里。

大部分团队把正则当成"字符串查找"来用,忘了它其实是一个可以在用户输入上触发任意计算的图灵完备玩具(配合嵌套量词)。所以真正的防线不该是"每次上线都记得检查正则",而是架构层面把不可信输入挡在正则之外:长度上限、结构化校验、引擎选择、乃至把用户提供的模式当作代码执行时直接拒绝(很多 CMS 的自定义正则字段就是这么被打穿的)。

说句得罪人的话:那些把"正则表达式性能优化"写进面试八股文的,大概率没在生产环境被 ReDoS 咬过。这个 bug 的可怕之处在于,它平时完全无害——代码审查看不出、单元测试全过、代码简洁漂亮。然后某个倒霉用户在表单里打了一串 a,生产环境的 CPU 曲线就永远抬不下来了。

写正则的时候多一点敬畏。这不是性能优化,这是安全边界。

相关文章

你的数据库连接池正在悄悄排队:21 个线程抢 10 个连接,然后集体石化
缓存和数据库打架的时候,到底谁先动手?
半夜三点,你的 Kafka 消费者在互相踢来踢去:一次 rebalance 风暴的现场重建
分布式系统里没有「现在」:时钟、超时与租约,一次讲透
我们给支付接口加了幂等键,结果它照样扣了三次钱
Prometheus 悄悄吃掉了你 40G 内存:一次指标基数爆炸的现场拆解

发布评论