
文章目录
-
- 一、为什么匹配失败反而更慢?
- 二、哪些模式有这种风险?
-
- [2.1 量词套量词](#2.1 量词套量词)
- [2.2 分支之间有重叠](#2.2 分支之间有重叠)
- [2.3 末尾有锚定,且会失败](#2.3 末尾有锚定,且会失败)
- 三、怎么快速自查?
- 四、三种修法,我实测了
-
- [4.1 三种写法](#4.1 三种写法)
- [4.2 实测结果](#4.2 实测结果)
- 五、原子组不是免费的
- [六、3.11 之前没有原子组,怎么给正则加超时?](#六、3.11 之前没有原子组,怎么给正则加超时?)
-
- [6.1 用 signal 打断](#6.1 用 signal 打断)
- [6.2 丢进子进程](#6.2 丢进子进程)
- [6.3 换引擎](#6.3 换引擎)
- 七、怎么落到日常
- 结论
正则表达式灾难性回溯实测:Python re 的 ReDoS 成因、atomic group 与 possessive quantifier 修复
今天有条新闻,中科大给学生每天一亿 token 的额度。一亿 token 这个量级,放到文本处理里,很多平时看不出问题的代码会突然暴露出来。
这篇文章讲的就是其中一种:一条正则,在短文本上快得察觉不到,在长一点点的文本上直接卡死。而且它不报错、不抛异常、不占 CPU 告警阈值,只是那个函数再也不返回了。
我先拿一段真实的文本做实验。这段文本是香港统计处一张公开数据表的英文注脚,814 个字符,来自官方接口,任何人可以复现。
text
Hong Kong's external merchandise trade statistics are compiled based on
information contained in import/export...
我用的模式是 ([A-Za-z]+[ ,]?)+$,本意是校验「由字母和空格、逗号组成的结尾」。这是很常见的一类写法。
然后我截取它的前 N 个字符去匹配:
text
前 96 个字符 → 0.04 毫秒,匹配成功
前 100 个字符 → 0.04 毫秒,匹配成功
前 103 个字符 → 0.04 毫秒,匹配成功
前 104 个字符 → 超过 12 秒,没有结束
第 104 个字符是斜杠,来自原文里的 import/export。
前 103 个字符以 import 结尾,能匹配。加上斜杠之后变成 import/,匹配失败。
耗时从 0.04 毫秒跳到 12 秒以上。这不是十倍百倍,是三十万倍,而且 12 秒只是我设的超时上限,实际多久我没等。

一、为什么匹配失败反而更慢?
这是整个问题的关键,也是最反直觉的地方。
正则引擎匹配时不是一次扫过去。它每次遇到可以「多匹配一点」或「少匹配一点」的地方,都会先做一个选择,然后继续往下试。如果后面的部分失败了,它要退回来,换一个选择再试。
这个退回重试的过程叫回溯。
([A-Za-z]+[ ,]?)+$ 这个模式里,+ 套在 (...)+ 里面,就是量词套量词。对于一串 n 个字母,引擎可以把它们切成 1 组、2 组、n 组,每一种切法都是一种组合。末尾的 $ 要求必须在这里结束,一旦结尾有个斜杠让 $ 失败,引擎就要把所有切法都试一遍才能确认「确实不匹配」。
组合数是 2 的 n 次方量级。n 是字母个数。
匹配成功时不用穷举,撞对了就返回。匹配失败时没有捷径,必须试完。所以失败比成功慢得多。

我用受控输入量了一遍。模式 (a+)+$,输入是 n 个 a 加一个 !:
| a 的个数 | 耗时 |
|---|---|
| 10 | 0.15 毫秒 |
| 14 | 2.5 毫秒 |
| 18 | 40 毫秒 |
| 22 | 650 毫秒 |
每多 2 个字符,耗时涨到 4 倍左右。6 组实测倍率是 3.96 到 4.12,理论上就是 4。
换成每加 1 个字符,就是翻一倍。22 个字符 650 毫秒,26 个字符大约 10 秒,30 个字符接近 3 分钟。
同样输入换成 a+$,200 个字符也只要 0.25 毫秒。两个模式的意图几乎一样,代价差了六个数量级。
二、哪些模式有这种风险?
不是所有正则都有这个问题。触发它需要同时具备几个条件。
2.1 量词套量词
(\w+)+、(a*)*、(\d+[,.]?)+ 这类,括号里面有量词,括号外面又套量词。这是最典型的形态。
我实测了几个:
text
(\w+\s?)+$ 19 字符输入 → 71 毫秒
^(\d+)*$ 19 字符输入 → 23 毫秒
(\d+[,.]?)+$ 19 字符输入 → 69 毫秒
\d+$ 19 字符输入 → 0.005 毫秒
前三个都有嵌套,最后一个只有单层量词,永远快。
2.2 分支之间有重叠
(a|ab) 这种,两个分支能匹配同一段字符的不同长度。引擎试完 a 发现后面不对,还要退回来试 ab。
2.3 末尾有锚定,且会失败
$、\Z、(?!...) 这类要求。它们制造了「必须失败」的场景,而失败才触发穷举。
反过来,如果模式没锚定、或者在长文本里前面就已经匹配上了,同样的写法可能一直不出事。这就是为什么这类问题常常上线几个月才爆。
三、怎么快速自查?
拿你手上最长的真实样本,把长度依次加 5 个字符跑一遍,看耗时。
如果耗时随长度明显抬头(不是线性上升,是每加一点就翻倍),就有问题。
如果耗时不随长度变化,基本安全。
四、三种修法,我实测了
Python 3.11 起,re 模块支持两个新语法:原子组 (?>...) 和占有量词 *+、++、?+、{m,n}+。它们的作用都是「一旦匹配上就不许再退回来」。
4.1 三种写法
原来的写法:
python
import re
BAD = r"(\w+\s?)+$"
原子组版本,把内层包起来:
python
ATOMIC = r"(?>\w+\s?)+$"
占有量词版本,把 + 改成 ++:
python
POSSESSIVE = r"(\w+\s?)++$"
不改语义、只改结构的重写版本:
python
REWRITE = r"\w+(?:\s+\w+)*$"
4.2 实测结果
同样的 19 字符输入,四种写法:
| 写法 | 耗时 | 是否匹配 |
|---|---|---|
(\w+\s?)+$ |
71.5 毫秒 | 否 |
\w+(?:\s+\w+)*$ |
0.013 毫秒 | 否 |
(?>\w+\s?)+$ |
0.005 毫秒 | 否 |
(\w+\s?)++$ |
0.005 毫秒 | 否 |
最慢比最快差约 1.5 万倍,结论也都没变(都不匹配)。
表里的耗时都是同一台机器上重复 5 次取最小值。换台机器绝对值会变,但量级关系和倍数关系不变。

我又拿 hello world foo 测了正例,四种写法都能匹配。也就是说这三种改法没有把该匹配的弄坏。
五、原子组不是免费的
到这儿很容易得出一个结论:以后统统加原子组就行。
不行。原子组和占有量词会改变语义,因为它们禁止了回溯。
python
re.search(r"(a|ab)c", "abc") # 匹配
re.search(r"(?>a|ab)c", "abc") # 不匹配
abc 这个串,非原子版本先用 a 匹配,发现后面要 c 但剩的是 bc,退回来改用 ab,成功。原子组版本不允许退,a 匹配完就锁死,于是失败。
占有量词同理:
python
re.search(r"a*a", "aaaa") # 匹配
re.search(r"a*+a", "aaaa") # 不匹配
a*+ 把 4 个 a 全吃掉了,不吐回来,后面的 a 没得匹配。
所以这两把刀只能用在「本来就该确定匹配」的位置。判断标准是:这个子模式匹配的内容,是否唯一由它自己决定,不依赖后面。
(\w+\s?)++ 里,每个词和它后面的空格是唯一的,不会因为后面要什么而改变吞多少,所以安全。
(a|ab) 里,a 和 ab 有重叠,吞多少取决于后面,就不能用原子组。
拿不准的时候,用重写的方式。把嵌套量词拆平,是最保险的:
text
(\w+\s?)+ → \w+(?:\s+\w+)*
^(\d+)*$ → ^\d+$
(\d+[,.]?)+ → \d+(?:[,.]\d+)*
三条都测了,输入都是 19 个字符:
| 原始写法 | 耗时 | 改写后 | 耗时 |
|---|---|---|---|
(\w+\s?)+$ |
69.8 毫秒 | \w+(?:\s+\w+)*$ |
0.013 毫秒 |
^(\d+)*$ |
22.6 毫秒 | ^\d+$ |
0.001 毫秒 |
(\d+[,.]?)+$ |
68.1 毫秒 | \d+(?:[,.]\d+)*$ |
0.012 毫秒 |
三条都从几十毫秒降到 0.02 毫秒以下,12,345.67 这种带分隔符的正例两边都能匹配。
六、3.11 之前没有原子组,怎么给正则加超时?
如果你的运行环境低于 3.11,上面两把刀都用不了。
而 re 模块本身没有任何超时机制:
python
import inspect
inspect.signature(re.compile)
# (pattern, flags=0)
没有 timeout 参数。一旦进去就出不来。
三个可用的替代办法。
6.1 用 signal 打断
主线程可以设闹钟,到点抛异常:
python
import re
import signal
class RegexTimeout(Exception):
pass
def _alarm(signum, frame):
raise RegexTimeout()
def safe_search(pattern, text, seconds=1.0):
signal.signal(signal.SIGALRM, _alarm)
signal.setitimer(signal.ITIMER_REAL, seconds)
try:
return re.search(pattern, text)
except RegexTimeout:
return None
finally:
signal.setitimer(signal.ITIMER_REAL, 0)
这篇文章里所有的超时测量都是这么做的。限制是只能在主线程用,子线程里 signal 不生效。
6.2 丢进子进程
concurrent.futures.ProcessPoolExecutor 配合 future.result(timeout=...)。超时之后 cancel() 未必能杀掉已经跑起来的进程,更稳的是用 multiprocessing.Process 加 terminate()。
代价是每次调用都要付进程启动开销,只适合低频但风险高的校验。
6.3 换引擎
regex 模块是 re 的兼容替代,支持 timeout 参数,也支持占有量词和原子组(比 Python 3.11 早很多年)。装它就能直接用:
python
import regex
try:
regex.search(r"(\w+\s?)+$", text, timeout=1.0)
except TimeoutError:
pass
Google 的 RE2 用另一套算法,从原理上就不会回溯爆炸,代价是不支持反向引用和部分环视。
七、怎么落到日常
几条我自己会守着的做法。
新写或改动的正则,先拿最长的真实样本跑一遍,而不是拿示例数据跑。示例数据通常很短,短到根本触发不了。
看到量词套量词,先想能不能拆平。(\w+)+ 这种写法多数时候是没必要的,\w+ 就够了。
上线前把正则集中在配置文件或常量里,别散落在各处。这样以后要统一加原子组或者统一换成 regex 模块,只改一处。
如果这个正则处理的是外部输入(用户提交的、合作方推过来的、爬来的),按不可信数据处理。长度设上限,超过上限直接拒,不要交给正则去试。
最后一条,超时要有兜底。哪怕只是记一条日志说「这里超时了」,也比整个进程卡死强。
结论
数据层四条:
-
真实样本取自香港统计处表 410-50001 的英文注脚,814 字符。模式
([A-Za-z]+[ ,]?)+$在前 103 个字符上 0.04 毫秒匹配成功,加上第 104 个字符(斜杠,来自import/export)后匹配失败,超过 12 秒未返回。 -
受控实验
(a+)+$对 n 个 a 加一个!:n=10 耗时 0.15 毫秒,n=22 耗时 650 毫秒,每加 2 个字符涨到约 4 倍(6 组实测倍率 3.96 至 4.12),12 个字符差 4000 倍以上。 -
同样输入换成
a+$,200 个字符仅 0.25 毫秒,与指数模式在 22 字符上的 650 毫秒形成六个数量级差距。 -
re版本 2.2.1,re.compile签名只有(pattern, flags=0),没有任何超时参数。
工程层四条:
-
19 字符输入上四种写法对比:
(\w+\s?)+$71.5 毫秒,重写\w+(?:\s+\w+)*$0.013 毫秒,原子组(?>\w+\s?)+$0.005 毫秒,占有量词(\w+\s?)++$0.005 毫秒,最慢比最快差约 1.5 万倍。 -
原子组与占有量词会改变语义:
(a|ab)c匹配abc而(?>a|ab)c不匹配;a*a匹配aaaa而a*+a不匹配。只在子模式匹配内容唯一时可用。 -
三种风险特征:量词套量词、分支间有重叠、末尾有会失败的锚定。三者同时出现且输入长度可控于外部时最危险。
-
修复优先级:拆平嵌套量词优先,其次原子组或占有量词(需评估语义),最后才是加超时兜底。Python 3.11 起原生支持,3.11 之前可用
signal或子进程超时,或换regex、RE2 引擎。
