开场:一条 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 曲线就永远抬不下来了。
写正则的时候多一点敬畏。这不是性能优化,这是安全边界。