一个斜杠,把毫秒变成十几秒

文章目录

正则表达式灾难性回溯实测: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 模块,只改一处。

如果这个正则处理的是外部输入(用户提交的、合作方推过来的、爬来的),按不可信数据处理。长度设上限,超过上限直接拒,不要交给正则去试。

最后一条,超时要有兜底。哪怕只是记一条日志说「这里超时了」,也比整个进程卡死强。

结论

数据层四条:

  1. 真实样本取自香港统计处表 410-50001 的英文注脚,814 字符。模式 ([A-Za-z]+[ ,]?)+$ 在前 103 个字符上 0.04 毫秒匹配成功,加上第 104 个字符(斜杠,来自 import/export)后匹配失败,超过 12 秒未返回。

  2. 受控实验 (a+)+$ 对 n 个 a 加一个 !:n=10 耗时 0.15 毫秒,n=22 耗时 650 毫秒,每加 2 个字符涨到约 4 倍(6 组实测倍率 3.96 至 4.12),12 个字符差 4000 倍以上。

  3. 同样输入换成 a+$,200 个字符仅 0.25 毫秒,与指数模式在 22 字符上的 650 毫秒形成六个数量级差距。

  4. re 版本 2.2.1,re.compile 签名只有 (pattern, flags=0),没有任何超时参数。

工程层四条:

  1. 19 字符输入上四种写法对比:(\w+\s?)+$ 71.5 毫秒,重写 \w+(?:\s+\w+)*$ 0.013 毫秒,原子组 (?>\w+\s?)+$ 0.005 毫秒,占有量词 (\w+\s?)++$ 0.005 毫秒,最慢比最快差约 1.5 万倍。

  2. 原子组与占有量词会改变语义:(a|ab)c 匹配 abc 而 (?>a|ab)c 不匹配;a*a 匹配 aaaa 而 a*+a 不匹配。只在子模式匹配内容唯一时可用。

  3. 三种风险特征:量词套量词、分支间有重叠、末尾有会失败的锚定。三者同时出现且输入长度可控于外部时最危险。

  4. 修复优先级:拆平嵌套量词优先,其次原子组或占有量词(需评估语义),最后才是加超时兜底。Python 3.11 起原生支持,3.11 之前可用 signal 或子进程超时,或换 regex、RE2 引擎。

相关推荐
悟道书生1 小时前
(一)Navicate为什么转存结构和数据只转存表而不包含建库?
数据库·oracle
2601_967980111 小时前
Python 初学者必备的常用内置函数
python·编程语言·内置函数·初学者·常用函数
OnlineProxy1 小时前
移动应用数据爬取:通过模拟器与流量嗅探对 API 进行逆向工程
爬虫·python·httpx
染指11101 小时前
162.Agent-LangGraph-检查点的作用
人工智能·python·langchain·agents
赤龙ERP2 小时前
ERP周报:连接器开闸、国产GPU进场与智能工厂253家
数据库·人工智能·大模型·rpa·erp·企业数字化·builderpattern
lusklusklusk2 小时前
SQL Server补丁版本号为什么“日期新反而版本低”?——GDR、CU、CU+GDR 版本号规则与升级路径详解
数据库
我命由我123452 小时前
访客系统形式(有用户概念、无用户概念)
java·开发语言·前端·javascript·python·产品运营·产品经理
key_3_feng2 小时前
评估与对齐:Agent 自我进化的护栏
前端·数据库·agent
xixingzhe22 小时前
图表中同比与环比的区别:看趋势还是看变化?
开发语言·python