指南内含 1 场可玩狩猎

灾难性回溯:正则表达式为什么会卡死

一个 7 个字符的正则,在 30 个字符的输入上可能要跑好几秒,在 40 个字符的输入上可能要跑几个小时。本文讲解回溯的原理、(a+)+ 这类嵌套量词为什么会爆炸、它在 Stack Overflow 和 Cloudflare 引发的故障,以及让正则变安全的改写方法。

Regex Hunter 标志

· 阅读约 4 分钟

/^key=[^=]*$/

抓住

  • key=value
  • key=
  • key=42
  • key=on

放过

  • key=a=b
  • key
  • value
  • k=v

大多数正则表达式几微秒就能跑完。但有少数正则遇到不对的输入,会跑上几秒、几分钟甚至更久,全程占满一个 CPU 核心。这叫做灾难性回溯(catastrophic backtracking),它曾让真实的网站宕机。

好消息是,危险的正则有一眼就能认出的形状,修复通常也很小。本文会展示这种形状、具体数字和改写方法。

01回溯的原理

JavaScript、Python、Java、PHP 和大多数其他语言用的都是回溯引擎。当正则里有选择(一个 + 吃掉几个字符、| 先试哪一边)时,引擎先选一个继续往下走。如果后面匹配失败,它就退回来,换下一个选择再试。

拿 ^a+$ 去匹配 aaaa!,可试的不多:a+ 吃掉全部四个 a,$ 在 ! 处失败,引擎每次吐回一个 a,每次尝试都很快失败。几步之后,结果是“不匹配”。

02会爆炸的形状

现在把一个量词放进另一个量词里:

匹配2/2 符合预期
/^(a+)+$/

应当匹配1/1

  • aaaa

不应匹配1/1

  • aaaa!
在沙盒里试试
它匹配的字符串和 ^a+$ 完全一样。区别在于匹配失败时要做多少工作。

(a+)+ 的意思是“一个或多个分组,每组是一个或多个 a”。四个 a 有八种分组方式:aaaa、aaa|a、a|aaa、aa|aa、aa|a|a,等等。当 ! 让匹配失败时,引擎会把每一种分法都试一遍才放弃。每多一个 a,分法就翻一倍。

下面是在一台普通笔记本上用 Node 23 测的结果,输入是一串 a 后面跟一个 !:

长度 ^(a+)+$ ^a+$
24 个 a 0.12 秒 不到 0.01 毫秒
26 个 a 0.5 秒 不到 0.01 毫秒
28 个 a 2 秒 不到 0.01 毫秒
40 个 a 超过两小时(估算) 不到 0.01 毫秒

匹配的字符串相同,代价却天差地别。危险总是出在失败的匹配上:输入能匹配时,第一条路径通常就走通了。

要警惕的一般形状是:一个被重复的分组,其内部能用不止一种方式匹配同一段文本:

  • 嵌套量词:(a+)+、(\w+)*、(\d*)+
  • 相互重叠的分支,再加重复:(a|a)+、(\w|\d)+、(.*,)*
  • 同一段文本上连续好几个 .*:.*=.*=.*

03生产环境里真的发生过

Stack Overflow,2016 年 7 月。一篇帖子的某一行中间有大约 20,000 个连续的空白字符。一个用来去掉行尾空白的正则,大致就是 \s+$,从这些空格中的每一个开始尝试匹配。每次尝试都一路跑到空白的尽头,发现后面还有文本而不是行尾,于是失败。这是平方级的工作量,大约 2 亿步,让网站宕机了 34 分钟。

Cloudflare,2019 年 7 月。一条新的防火墙规则里有一个包含 .*(?:.*=.*) 的正则。所有运行这条规则的服务器 CPU 都飙到 100%,Cloudflare 的网络在全球范围内丢弃流量约 27 分钟。

这两个正则乍一看都不危险,在正常输入上也都没问题。

04修复 1:让选择互斥

最干净的修复是写出每个字符只能用一种方式匹配的正则。去掉嵌套:

匹配2/2 符合预期
/^a+$/

应当匹配1/1

  • aaaa

不应匹配1/1

  • aaaa!
在沙盒里试试
一个量词,一种匹配方式。会失败的输入几步就失败了。

一个真实的例子:逗号分隔的数字。有风险的写法在另一个量词外面套了一个重复分组,还让逗号可有可无,于是一串数字可以有很多种拆法:

正则10 字符
/^(\d+,?)+$/
在沙盒里试试
有风险:“123”可以读作 1|2|3、12|3、1|23 或 123。一长串数字后面跟一个字母,就会慢得像爬。

安全的写法要求元素之间必须有逗号,这样每个数字只属于唯一的位置:

匹配5/5 符合预期
/^\d+(,\d+)*$/

应当匹配2/2

  • 1
  • 10,200,3000

不应匹配3/3

  • 1,
  • 1,,2
  • 1, 2
在沙盒里试试
安全:一个数字,后面跟零个或多个“逗号、数字”对。任何输入都只有一种读法。

05修复 2:写明不能出现什么

.* 常常是罪魁祸首,因为它什么都能匹配,包括下一部分需要的文本。否定字符类天生就是互斥的:

匹配3/3 符合预期
/^key=[^=]*$/

应当匹配2/2

  • key=value
  • key=

不应匹配1/1

  • key=a=b
在沙盒里试试
[^=]* 吞不下 =,所以这一行只有一种拆法。

06修复 3:原子组和占有量词

原子组 (?>…) 只匹配一次,绝不吐回任何字符。占有量词(a++、\d*+)对单个量词做同样的事。两者都会去掉引擎原本要重试的那些备选路径。

Python 从 3.11 起两者都支持。Java、PCRE 和 PHP 也都有。JavaScript 两个都没有,但先行断言加反向引用的效果相当于原子组:(?=(a+))\1 一次性匹配这些 a,无法再拆分。

07修复 4:限制输入,或者换引擎

  • 匹配前限制输入长度。很多会爆炸的正则在 200 个字符上是无害的。
  • 对不可信的输入使用线性时间引擎。Google 的 RE2(有 Node、Python、Go 等语言的绑定)和 Rust 的 regex crate 从不回溯,所以不会爆炸。代价是它们不支持反向引用和环视。
  • 平台支持时设置超时。.NET 的 Regex 可以传入 matchTimeout。

08试一试

写出逗号分隔数字列表的安全版本:

可玩狩猎标准杆 12

抓住逗号分隔的数字,比如 1,22,333(没有空格,没有空项)。

目标 · 抓住 0/5

  • 1目标
  • 42目标
  • 1,2目标
  • 10,200,3000目标
  • 7,7,7目标

无辜词 · 误抓 0

  • ,1无辜
  • 1,无辜
  • 1,,2无辜
  • a,b无辜
  • 1, 2无辜
  • ,无辜

输入正则——棋盘会实时更新。

在沙盒里试试
喜欢吗?每天都有新的狩猎。玩今天的狩猎

09检查清单

  • 危险信号:一个带量词的分组,其内部能用几种方式匹配同一段文本:(a+)+、(\w|\d)+、(.*,)*。
  • 代价出现在失败的匹配上,而且每多一个字符就可能翻一倍。
  • 让每个字符只有一种匹配方式:分隔符必须出现,不要嵌套。
  • 当下一部分以 x 开头时,优先用 [^x]* 而不是 .*。
  • 能用原子组或占有量词时就用(Python 3.11+、Java、PCRE)。
  • 对不可信的输入,限制长度或使用 RE2。

10继续练习