正则表达式的回溯爆炸:一段正则为何能拖垮服务器

正则表达式的回溯爆炸:一段正则为何能拖垮服务器
模式(a+)+遇上30个a再加一个不匹配的尾缀,引擎要试五亿多条路径,一秒的活跑成几小时。

模式 (a+)+ 只有 6 个字符,看起来人畜无害,却能被一段 40 字符的输入挂死一个线程几十分钟。这不是演示用的玩具例子,而是 JavaScript、Python、Java、Perl 全部真实会发生的执行行为。

引擎分两派:会回头的与不回头的

正则引擎有两条完全不同的实现路线。DFA 流派(Google 的 RE2、Go 标准库 regexp、Rust 的 regex crate)把模式编译成状态机,扫描文本时同时维持所有可能状态,每个字符只读一遍,耗时与文本长度成线性关系——代价是功能残缺,不支持反向引用和环视。NFA 回溯流派(PCRE、Python 的 re、Java、JavaScript)顺着一条路径试探,撞墙就退回上一个岔路口换个选择继续走,功能强大到能写反向引用,代价是最坏情况下要走的路径数随输入长度指数增长。回溯爆炸只发生在第二派身上。

(a+)+ 为什么能吃掉一台服务器

用 (a+)+ 匹配「20 个 a 结尾挂一个对不上的 X」。内层加号要求每段至少一个 a,外层加号要求至少一段,于是把 20 个 a 切成若干非空段的所有切法都必须试完才能宣告整体失败——方案数是 2 的 19 次方,524288 条路径。输入加长到 30 个 a,路径暴涨到 2 的 29 次方,约 5.4 亿条;40 个 a 是 5.5 万亿条,按引擎每秒一亿步的速度要跑一个半小时以上。2015 年 Stack Overflow 那次持续多日的线上故障,复盘元凶正是问题编辑器里一个带嵌套量词和可选分支的模式,被 34 个字符的普通标题触发,把多台 Web 服务器的 CPU 拉满。

引爆需要三个条件凑齐

灾难性回溯讲究三要素同时在场。其一,歧义结构:量词套量词,或可选分支能拼出同一段文本,(a+)+、(a|aa)+、(a*)* 都是标准雷区;其二,匹配最终失败:多数引擎命中即短路,成功的字符串常常跑得飞快,宣告失败才需要穷举,因此最毒的输入是一串合法前缀加一个错误尾缀;其三,失败点之前有足够长的可歧义文本。三样凑齐,攻击者在注册表单里粘一行几十字符,就能让单次请求占用几十分钟 CPU——这就是正则版拒绝服务攻击 ReDoS 的全部原理。

拆引信的四层办法

第一层是改写:把 (a+)+ 直接写成 a+,从源头消除歧义切分。第二层是锁回溯:占有量词 a++ 或原子组 (?>a+) 吃进去的字符就不再吐出来,PCRE 与 Java 支持,JavaScript 长期没有原生占有量词,只能靠环视模拟。第三层是换引擎:服务端不受信任的输入一律交给 RE2 家族,线性复杂度是可证明的,任何输入都炸不出指数。第四层是兜底:限制输入长度、给匹配设超时、把正则校验挪出请求线程。拿不准自己写的模式有多危险,把它和一段 30 个 a 加 X 的测试串放进 正则测试工具,转几秒不出结果就是危险信号。

两个常见的错觉

一是「我的输入很短,炸不了」:指数增长不跟你商量,20 字符几十万步,30 字符就上亿,长度从来不是护身符。二是「我加了全局匹配标志才慢」:真正的耗时来自单个匹配位置内部的路径穷举,标志位只是让每个起点再各炸一遍。反过来也有好消息:回溯并非原罪,日常模式里量词不嵌套、分支前缀互斥,引擎的试探通常在几毫秒内收敛。爆炸是结构与输入共振的产物——要排查,只需盯住嵌套量词、失败尾缀这两样。

← 时区是怎么回事:为什么「北京时间」不是北京的时间 PDF 转图片时分辨率该选多少:72、150、300 DPI 的取舍 →