ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

一条正则为什么能把接口拖死:回溯从哪来、怎么查

一条正则为什么能把接口拖死:回溯从哪来、怎么查

授权与合规声明
本文全部操作对象均为自建隔离靶场(本机容器或隔离虚拟机),涉及安全测试的环节必须以取得合法授权为前提。未经授权的渗透测试违反《中华人民共和国网络安全法》与《刑法》相关条款,须承担相应法律责任。本文只讲环境配置、版本对照与靶场隔离,不含任何攻击步骤、利用载荷与绕过手法,请勿将文中环境指向任何非自有系统。

一、先看现象:同一条正则,短输入秒回、长输入不动

1.1 转圈的那几十秒,日志里什么都没留下

一个再普通不过的字段校验。用户提交一段「备注」,后端先过一条正则,确认文本的构成符合约定。这条正则写在validate()里,一行,注释只有四个字:只允许字母。上线前你自己试过:空串、一个字母、带数字的、带中文的,全部一次通过,耗时看不出差别。

然后某个工作日的下午,接口开始转圈。

不是所有请求都慢,是偶发的一两个。用户并没有做什么特别的操作,他只是把一份文档里的一大段说明文字粘进了备注框——不是为了攻击你,是真的想提交这段备注。请求发出去,前端一直等;网关在预定的时间点把连接掐断;应用日志里有「请求开始」,没有「请求结束」;机器上那条 CPU 曲线,在那几十秒里有一个很尖的峰,随后落回去。

重启之后,一切恢复正常,你拿同样的账号、同样的浏览器再提交一次,怎么都复现不出来。

1.2 把「感觉慢」换成一张表

复现不出来的原因,是触发条件不在「请求」这一层,而在「输入」这一层:它要同时满足两个条件——足够长,并且以一个让匹配失败的方式结束。你手工重试时粘的是能通过的那段,走的全是快路径,自然看不出问题。

要判断这类问题,不用读引擎源码,只要把长度当成唯一变量。固定模式、固定机器、固定输入的字符构成,只改长度,每一档跑多轮,取中位数。下面这张表是 2026-10-25 在本机跑出来的结果,模式是一条用于校验「整段只由同一种字符组成」的表达式,输入是本地合成的字符串,末尾多加一个不属于该类字符的符号,使它必然匹配失败。

连续字符个数 n平均耗时(毫秒)相对上一档
140.38—
161.49×3.9
185.83×3.9
2021.80×3.7
2282.94×3.8

上表为本机实测(CPython 3.13 + macOS),输入全部是本地合成的字符串,不指向任何在线服务;数值随机器变化,倍数关系不变。

读这张表,别读毫秒,读最后一列。四档的比值都落在 3.7 到 3.9 之间,非常稳定:每加 2 个字符,耗时乘 4。也就是说,每加 1 个字符,耗时大约乘 2。

下面这段是把测法固化成脚本的骨架,模式和输入都从脚本外部给。表里那五个数值就是照这套逻辑跑出来的;脚本本身没有再逐字复跑,所以按规矩标了待验证。

⚠️代码待验证

importos,re,time,statistics PATTERN=re.compile(os.environ["RE_PATTERN"])# 待测的那一条,从本地配置读INPUTS=[l.rstrip("\n")forlinopen(os.environ["RE_INPUTS"])]defmeasure(text,rounds=9):samples=[]for_inrange(rounds):t0=time.perf_counter()PATTERN.search(text)samples.append((time.perf_counter()-t0)*1000)returnstatistics.median(samples)fortextinINPUTS:print(f"len={len(text):>3}median={measure(text):9.3f}ms")

1.3 该记住的不是毫秒,是倍数

毫秒数最不可迁移。同一段代码在同事的笔记本上可能快五倍,在容器里可能慢三倍——CPU 型号、主频、是否有别的进程在抢,全都在动。拿本文的 82.94 毫秒去和别人对账,没有意义。

有意义的是那个比值。它描述的是「耗时会怎么随输入长度变化」,这是一个形态,不是一次测量。形态由写法决定,不由机器决定。所以你在自己机器上测一遍,只要看到「每加两个字符乘 4」这个节奏,就已经能判定这条写法有问题了,不需要任何人的数值来背书。

也正因为它是形态,它才值得警惕:从 n=14 到 n=22,只多了 8 个字符,耗时从 0.38 毫秒变成 82.94 毫秒,差了约 218 倍。这不是「慢了一点」,是两种不同的东西。

本章可以带走的一句:一条正则慢不慢,不看它在短输入上多快,看它的耗时怎么随长度变。

二、回溯是什么:匹配失败之后,引擎还得回头

2.1 匹配不是看一眼,是一路做选择

正则引擎读你的模式,从左到右一个字符一个字符地对。凡是遇到量词,它就要做一个决定:这一个「重复」,我到底重复几次?

把它想成走迷宫。你站在入口,前面每个岔口都要选一条路。选对了,继续往前;选错了,走到底发现是死胡同,就得退回到最近的那个岔口,换另一条路再走一次。这个「退回去、换条路」的动作,就是回溯。

关键在于:引擎在走的时候,并不知道哪条路是对的,它只能靠试。所以「重复几次」这个决定不是一次定下来的,而是边走边改。

⚠️代码待验证

模式允许:把一段字符切成若干段,每段至少一个 切法一 → 走到底 → 不通 → 退回 切法二 → 走到底 → 不通 → 退回 切法三 → …… 一次「退回」= 一次回溯 走法有多少种,退回就可能有多少次

2.2 失败的时候,引擎要把做过的选择全部重走一遍

这就解释了一件很多人没想通的事:为什么最慢的情况,恰恰是「这条输入不该被通过」的时候。

如果输入能匹配,引擎走到某个位置发现「成了」,就可以收工。但如果输入注定不匹配,引擎必须把所有可能的路都走完,才敢下结论说「不匹配」。回溯型引擎在这里的代价,是它要把已经做过的选择重新走一遍;而这些失败路径的数量,可以随输入长度指数增长。

Python 的re、Perl、PCRE、Java 的java.util.regex都属于这一类。这句话的分量在于:这是回溯的本质代价,不是 bug,升级依赖、换小版本、加编译缓存都不会改变它。

2.3 指数是从「有多少种走法」里长出来的

指数不是玄学,它可以数出来。假设输入是 n 个一模一样的字符,而你的模式允许「把这一段切成若干段、每段至少一个」,那么切法有多少种?

连续字符个数 n所有可能的分段方式数量相对上一档
48—
1051264
14819216
1813107216
22209715216

每一档加 4 个字符,数量乘 16——也就是每加 1 个字符乘 2,每加 2 个字符乘 4。这和第一章那张计时表最后一列的 3.7 到 3.9,是同一件事的两种说法:一边是「有多少种走法」,一边是「要花多少时间」。

引擎不会把每一种走法都完整走完,它剪掉一部分明显重复的分支,所以真实耗时不会精确等于上表的行数;但量级关系对得上。

本章可以带走的一句:慢的不是匹配,是匹配失败之后那趟回头路。

三、三种放大器:什么样的写法会把回头路变多

3.1 嵌套量词:同一段字符被反复重新切分

第一种形态,是量词套着量词。

具体说:一个分组本身用「一个或多个」修饰,这个分组整体外面,又套了一层「一个或多个」。写成抽象的形态就是这样:

⚠️代码待验证

( X+ )+

这里必须说清楚:上面这行只是形态示意,不作为可直接使用的模式。它想表达的只有一件事——外层每选一种分法,内层就得把同一段字符重新切一遍,同一批字符被数了很多次。你自己代码里真正长什么样,要看你的分组和字符集,本文不给任何可以直接拿去用的完整写法。

识别方法很简单:在正则里找「右括号后紧跟量词、而括号内部本身也以量词结尾」的位置。看到这种结构,先假设它有问题,再去测。

3.2 相邻的重叠分支:两条路通向同一个地方

第二种形态,是分支之间能匹配同一段字符。

(A|B)这样的选择结构里,如果 A 和 B 能吃掉同一批字符——比如一个是另一个的前缀,或者两者的字符范围有交集——那么对同一段输入,引擎就有两条路可走。匹配失败的时候,两条路都得试;如果这个分支后面还跟着量词,那么每一条路的每种长度组合,都要各试一遍。

这种形态更隐蔽,因为它看起来「很正常」:谁写校验规则没写过几个备选分支呢?问题不在有分支,而在分支之间有重叠,且分支后面还有量词在放大它。

判断方法:把每个分支能接受的字符集合写出来,看有没有交集。有交集,且分支之后还跟着量词,就归到这一类。

3.3 无锚点全扫:每个起点都当一次开头

第三种形态,跟模式本身无关,跟你怎么调用有关。

如果一条用于「整段校验」的正则,没有写起点锚定,而调用时用的又是「在字符串里找一处匹配」的接口,那么引擎会把每一个字符位置都当成一次新的开头,各试一遍。输入长 n,就等于把前面的代价乘上 n。

这一条的放大倍数最温和——只是线性的一倍——但它最容易和前面两种叠在一起。一个没有锚定的、带嵌套量词的校验,代价是「每个起点 × 每条回头路」,两者相乘。

形态在代码里怎么认(描述,不给可用写法)放大的是什么一句话识别
嵌套量词分组内部以量词结尾,右括号外又跟一个量词同一段字符的切分次数重复的重复
重叠分支两个分支的字符集有交集,分支后还跟着量词每个位置的可选路径数两条路走同一段
无锚点全扫做整段校验,却没有起点锚定,且用「找一处」的接口起点数量每个位置都当开头

本章可以带走的一句:三种形态单独看都只是「倍数」,叠在一起才是指数。

四、为什么换个语言就没事:两条不同的技术路线

4.1 走回头路,和不走回头路

正则的实现有两条路线。

一条是回溯型:引擎维护一个「选择栈」,走不通就弹栈重试。Perl、PCRE、Python 的re、Java 的java.util.regex都是这一路。它的优点是表达能力强、贴着人的直觉走;代价就是上一章讲的回头路。

另一条是自动机型:把模式编译成一个状态机,一次扫过去,不保留「回头重选」的机制。RE2 就属于这一路。RE2 官方对自己的定位,是把自己放在「backtracking regular expression engines like those used in PCRE, Perl, and Python」的对立面——它明确承认,自己说的是另一条路线,不是同一件事的更快版本。

4.2 Go 那句线性承诺,值得逐字读

Go 的regexp包文档里有一句很硬的话:

The regexp implementation provided by this package is guaranteed to run in time linear in the size of the input.

逐字抠一下:guaranteed——是保证,不是「通常」;linear——线性;in the size of the input——相对于输入规模。合起来的意思是:输入翻倍,耗时最多翻个常数倍,不会出现第一章那种「每加两个字符乘 4」。

注意它没承诺什么。它没承诺更快,也没承诺所有模式都能写。它承诺的是可预测。这个区别很重要:如果你正在排查的是一个偶发的、无法复现的超时,你要的恰恰是可预测,而不是平均快 30%。

⚠️代码待验证

python3-c"import re,sys; print(sys.version); print(re.__file__)"# 看 Python 这一侧go version# 看 Go 这一侧go doc regexp|head-n20# 线性承诺那句就在这份文档里
实现路线复杂度形态本文依据
Pythonre回溯型失败路径可随长度指数增长RE-03
Perl / PCRE回溯型同上RE-03、RE-05
Javajava.util.regex回溯型同上RE-03
Goregexp自动机型(RE2 系)线性承诺RE-04、RE-05
JavaScript 引擎本文不展开各引擎实现与版本差异需自行核对RE-11(待验证)

4.3 换语言看起来解决了,其实只是换了个位置

有人会想:那我用 Go 写这个服务不就行了。可以,但你换的不是一行代码,是整条技术栈。更常见的情况是:同一个校验规则,Python 的老服务里有一份,Go 的新网关里有一份,两边行为不一样;出问题的时候,你在 Go 那边怎么都复现不出来,因为问题在 Python 那边。所以「换引擎」是架构决策,不是写法技巧。

本章可以带走的一句:线性引擎买到的是可预测,不是更快;换引擎是改架构,不是改写法。

五、怎么在自己机器上判定:一张长度与耗时的表

5.1 测法:只改长度,别的都不动

这套测法只有四条纪律。

固定模式,一次只测一条,别把好几条拼在一个脚本里跑。固定输入的字符构成——第三章讲过,形态是由「字符怎么重复」决定的,所以除长度外每档输入应当完全同构。每档跑多轮取中位数,时钟精度、垃圾回收、调度抖动都会给你离群值。最后,不要在共享机器上测,更不要在线上服务上测:你要的是这条正则的形态,不是这台机器此刻的负载。

把第一章那段脚本的输入长度依次取 10、20、40、80,别的都不动,你会拿到四行数字。接下来是判读。

5.2 判读:看第二次翻倍的倍数有没有继续变大

长度翻倍后的耗时倍数大致形态要不要管
约 ×2线性通常不用管
约 ×4,且再翻一倍仍是 ×4平方要管,长输入会明显拖慢
倍数本身还在随长度变大指数形态必须先限长,再改写
忽高忽低、没有规律测法有问题,或被别的负载干扰先修测法,别下结论

平方和指数的区别,就在第二列和第三列之间:平方的倍数是个常数(翻一倍总是 ×4),指数的倍数是「越翻越大」。区分它们的办法很简单——多做一次翻倍。只翻一次,两者看起来一模一样;翻两次,答案就出来了。

还有一个容易误判的点:如果四行数字都很快,也别急着下结论。可能你选的输入没有让匹配失败,全程走的是快路径。校验类正则的问题,几乎都藏在「不通过」的那一侧。

5.3 三个会让你白忙一场的坑

一是输入没构造对。同一段文本,结尾多一个字符和少一个字符,走的可能是完全不同的路径。二是只测一次。单次测量里混着进程启动、模块导入、缓存预热,你测到的是环境,不是正则。三是用了带结果缓存的框架去测,第二次调用直接命中缓存,你会得到一条漂亮但虚假的直线。这三条避开,你得到的形态结论才有意义。

本章可以带走的一句:你不需要读引擎源码,只需要一张长度与耗时的表。

完整版对照资料:本文把「长度与耗时」的测法、判读表、三种形态的识别要点整理成了一页速查,和本章这套判定流程是同一份东西的浓缩版。放在资料包里,扫码即可获取:

六、改法:每一条都有代价,没有免费的

6.1 最便宜的两步:限长,然后加锚点

第一步是限长。这件事必须在正则之前做:先判断长度,超了直接拒,根本不让它进正则。如果顺序反了,你连「这段输入有多长」这件事都要花在匹配里,限长就白限了。

第二步是加锚点。做整段校验的正则,就该用「整段匹配」的语义,而不是「在字符串里找一处」。锚点让起点唯一,第三章里那个「每个位置都当一次开头」的放大器就直接没了。

两件事都有代价。限长要你定一个业务上限——备注到底是 500 字还是 5000 字,这是产品决策,技术侧替不了你。加锚点会改变语义:原来「文本里包含一段符合规则的内容」就通过,现在变成「整段都符合才通过」,上线前必须把正例、反例、边界值全部重跑一遍。

下面是一个把顺序摆对的写法,长度检查在前,正则在后:

⚠️代码待验证

MAX_LEN=500defvalidate_note(text:str)->bool:iftextisNone:returnFalseiflen(text)>MAX_LEN:# 先卡长度,最便宜的一道returnFalseifnottext.isascii():# 再做字符集层面的粗筛returnFalsereturnPATTERN.fullmatch(text)isnotNone# 最后才是结构性校验

6.2 拆平嵌套,改掉重叠分支

嵌套量词的改法,是把「重复的重复」拍平成「一次重复 + 明确的字符集」。原来靠分组表达的结构,改成用字符类和定长结构表达,让同一段字符只有一种切分方式。

重叠分支的改法有两种:一是把分支改成互斥的(调整字符范围,让两者不可能同时匹配同一段),二是调整分支顺序并配合锚定,让引擎尽早排除掉不可能的路。前者更彻底,后者改动更小。

代价是等价性。任何改写都会引入「原来能过、现在过不了」或者反过来的风险。所以改写之后必须做一件事:把这条规则曾经拒绝过的真实样本(脱敏后的)、曾经通过的真实样本,各挑一批重跑。没有这批样本,你不知道自己改的是性能还是语义。

6.3 换引擎、上原子组、加超时,各自代价不同

占有量词和原子组,是 PCRE 系提供的能力:它告诉引擎「这一段一旦匹配上,就别再回头改主意了」。这直接把回头路砍掉,但语义也变了——原本靠回溯才能匹配到的输入,改写后可能匹配不上;而且不是每种语言都有这个语法,跨语言迁移时要注意。

换线性引擎是最彻底的一条,代价也最大:模式语法是另一个集合,要逐条核对;而且第四章说过,你换的是技术栈,不是一行代码。

加超时最容易做,也最容易骗自己。它只是止损:超时之后请求会失败,业务侧必须能处理这种失败,否则你只是把「一直转圈」换成了「转一会儿就失败」,它不是修复。

另外一件事必须标清楚:各语言引擎回溯上限的默认值,属于本文的待验证项(RE-11),本文不引用任何具体数值。要在自己的环境里确认,只能按你所用的那个实现的文档核对,别照抄别人的数字。

手段改什么代价什么时候不该用
限长在正则之前卡长度要定业务上限输入本身必须完整保留时
加锚点把「找一处」改成「整段」语义变化,要重跑用例业务确实是「包含即通过」
拆平嵌套改变分组与量词结构等价性风险缺真实样本可回归时
原子组 / 占有量词禁止回溯语义变化,且非所有语言支持需要跨语言复用同一条模式
换线性引擎换实现语法子集不同,迁移成本高只是给老服务打补丁时
加超时加一层时限只止损,不修复把它当成最终方案时

本章可以带走的一句:所有改法都是拿语义或架构上的让步,换一份可预测的耗时。

七、把它放进校验流程的哪一步

7.1 四道闸的顺序不能乱

一条输入从进来到被使用,按理要过四道闸,顺序是有讲究的:

  1. 长度与体积上限——用最便宜的方式挡掉大部分异常输入;
  2. 编码与字符集粗筛——只允许该字段该有的字符类别;
  3. 结构与格式校验——也就是正则该待的地方;
  4. 业务规则——唯一性、频率、状态合法性。

第一章那个场景的问题,本质上是把第 3 道闸放在了第 1 道前面:正则成了第一个接触原始输入的东西,于是一段很长的、注定不通过的文本,把全部算力都花在了「证明它不通过」上。

还有一个常被忽略的点:入口不止表单。JSON 请求体里的字段、上传文件的文件名、请求头里的自定义字段、日志解析、批量导入的每一行、消息队列里的消息体——每一个都是原始输入的入口。你只在表单那一处加了限长,别的入口照样能把长输入送进来。

7.2 给每条正则留一份可审的记录

正则写在一行里,看不出成本,所以它需要一份额外记录,至少六栏:模式、用途、允许的输入上限、有没有锚定、有没有嵌套量词或重叠分支、最近一次复测的结论与日期。这份记录的价值不在当下,而在半年后有人要改这条规则的时候——他能看见「这条测过,当时是平方形态,靠限长兜住的」,而不是只看到一行注释,然后照着感觉改。

7.3 边界:这套方法管什么、不管什么

它管的是写法层面的耗时形态:怎么测、怎么读表、怎么改、改在哪一步,全部在你自己的机器和你自己的代码上完成。它不管「怎么去验证某个具体服务扛不扛得住」这类问题:本文所有计时都在本机用合成字符串完成,不给针对具体服务的验证方法,也不给可以直接拿去用的完整模式与配套输入。这类验证只要换一个目标,性质就完全不同了。

本章可以带走的一句:正则不该是第一个接触原始输入的东西。

完整版对照资料:本文第七章这套「四道闸 + 一条正则一份记录」的落地清单,已经整理成可直接照着填的表格,和本章的顺序设计一一对应。放在资料包里,扫码即可获取:

附表 A:本文引用事实与官方出处对照表

序号事实出处核验日期本文位置
1计时环境为 CPython 3.13 + macOS,本机实测RE-012026-10-25第一章 1.2
2n=14/16/18/20/22 对应 0.38 / 1.49 / 5.83 / 21.80 / 82.94 毫秒RE-012026-10-25第一章 1.2
3相邻两档的相对倍数落在 3.7 至 3.9 之间RE-012026-10-25第一章 1.2
4每加 2 个字符约乘 4,即每加 1 个字符约乘 2RE-012026-10-25第一章 1.2、1.3
5计时输入为本地合成字符串,不指向任何在线服务RE-012026-10-25第一章 1.2
6计时反映复杂度形态而非绝对速度,换机器数值会变、倍数关系不变RE-022026-10-25第一章 1.3
7回溯型引擎包括 Perl、PCRE、Pythonre、Javajava.util.regexRE-032026-10-25第二章 2.2、第四章 4.1
8匹配失败时引擎需把已做过的选择重新走一遍RE-032026-10-25第二章 2.2
9失败路径数量可随输入长度指数增长RE-032026-10-25第二章 2.2
10上述代价是回溯的本质,不是 bugRE-032026-10-25第二章 2.2
11放大器之一:量词套量词,内层重复被外层再重复一遍RE-062026-10-25第三章 3.1
12放大器之二:两个分支能匹配同一段字符,失败时任一条路都要试RE-062026-10-25第三章 3.2
13放大器之三:没有起点锚定,每个位置都要试一遍RE-062026-10-25第三章 3.3
14Goregexp文档承诺运行时间相对输入规模线性RE-042026-10-25第四章 4.2
15该线性来自自动机路线,不做回溯RE-042026-10-25第四章 4.2
16RE2 把自己定位为与 PCRE、Perl、Python 那类回溯引擎相对的另一条路线RE-052026-10-25第四章 4.1
17判定阈值:输入长度翻倍时耗时是否接近翻 4 倍或更多RE-072026-10-25第五章 5.2
18缓解手段:加锚点、拆平嵌套、限长、把校验挪到长度检查之后RE-082026-10-25第六章 6.1、6.2
19缓解手段:占有量词 / 原子组(PCRE 系)RE-082026-10-25第六章 6.3
20缓解手段:换线性引擎(RE2 系实现)RE-082026-10-25第六章 6.3
21各语言引擎回溯上限的默认值:待验证,本文不引用具体数值RE-112026-10-25第六章 6.3

附表 B:术语速查表

术语一句话解释
回溯引擎走不通时退回上一个选择点、换一条路重试的动作
回溯型引擎靠维护选择栈来匹配的实现,失败时要走完所有失败路径
自动机引擎把模式编译成状态机、一次扫过、不回头重选的实现
嵌套量词分组内部以量词结尾、分组外又套一个量词的结构
重叠分支两个分支能匹配同一段字符,因而对同一输入存在多条路径
锚点声明匹配只能从字符串开头或结尾开始的符号
原子组 / 占有量词告诉引擎这一段匹配后不再回头改主意的语法,PCRE 系提供
线性时间耗时与输入规模成正比,翻倍只带来常数倍增长
中位数多轮测量里排在中间的那个值,比平均值更抗离群点
待验证项本批未取得官方原文、因而在正文中只标注不引用具体数值的条目

写在最后:这篇用到的资料

写这篇文章时,我把同一段计时反复跑了几遍,确认真正稳定的不是毫秒数,而是最后一列的倍数;顺手把测法和判读表做成了一张能直接填的纸,也整理了几份配套的东西:

  • 靶场环境对照表:DVWA、upload-labs 在 Windows / macOS / Linux 三平台的可行性与推荐路径
  • Web 安全学习路线图:从基础打牢到安全管理,四个阶段各学什么
  • 常用靶场清单:每个靶场练什么、适合哪个阶段

资料是我自己整理的,放在下面这个码上,扫码即可获取:

添加时备注「靶场」,优先通过。

拿到之后建议先看环境对照表那一份,先把「自己的正则跑在哪条引擎路线上」这件事确认下来,再回头改写法。

返回列表