有一类性能问题格外阴险:一条平时跑得飞快的正则,在某个特定输入下突然让 CPU 飙满、请求卡死,甚至拖垮整个服务。这就是"灾难性回溯",它还能被攻击者刻意利用,构成一种拒绝服务攻击。可怕之处在于,这样的正则往往看起来人畜无害,问题只在遇到精心构造的输入时才暴露。理解回溯的机制,是避开这类陷阱的唯一可靠办法。

回溯是怎么发生的

很多正则引擎采用回溯式匹配:当某条匹配路径走不通时,引擎会退回去,尝试另一种可能的组合,直到找到匹配或穷尽所有路径。对大多数模式,这没什么问题。但在某些结构下,可尝试的组合数会随输入长度爆炸式增长。

当组合数呈指数级膨胀时,引擎可能要尝试天文数字般的路径才能得出"不匹配"的结论。于是一条对短输入瞬间完成的正则,对稍长的特定输入却要跑上几秒、几分钟,甚至看似永远跑不完。

危险的嵌套量词

引发灾难性回溯的典型结构,是嵌套的量词——一个可重复的组里又包含可重复的部分,比如对一个本身就能匹配多种长度的子模式再套一个量词。这种结构让引擎在划分输入时有了海量种方式,组合数随之指数增长。

另一个常见诱因,是相邻的量词能匹配重叠的内容。当引擎可以用多种方式在它们之间分配字符时,回溯的分支就成倍增加。看到模式里有量词套量词或重叠匹配,就该提高警惕。

为什么"不匹配"最致命

一个反直觉的事实是:灾难性回溯往往在输入"几乎匹配但最终不匹配"时最严重。只要还存在匹配的可能,引擎就会一条路一条路地试;只有把所有路径都试完,它才能确定失败。正是这个"穷尽所有可能"的过程吃掉了全部时间。

这也是攻击者利用它的方式:构造一段恰好能让引擎反复回溯、却始终匹配不上的输入,迫使它陷入漫长的徒劳计算。理解这一点,你才会明白为什么测试时要特别关注那些"差一点就匹配"的输入。

写出结构上更安全的模式

预防灾难性回溯,首先从模式结构入手。尽量避免嵌套量词和相邻的重叠匹配。如果一段子模式能匹配多种长度,再在外面套量词时就要格外小心。把含糊的、可多种方式划分的结构,改写成更确定、更不易产生歧义的形式。

很多时候,一条容易回溯的正则都能改写成一条等价但更安全的版本——让引擎在每个位置上的选择更少、更明确,回溯的空间自然就被压缩了。

善用引擎提供的防护手段

有些正则引擎提供了能抑制回溯的特性,比如占有型量词或原子分组:它们一旦匹配了某段内容就不再回退,从而切断了灾难性回溯的路径。在支持的引擎里,合理使用这些特性能从根上消除问题。

此外,部分系统允许给正则匹配设置超时或步数上限。当某次匹配耗时异常时直接中止,虽然不能根治,却能防止单条请求拖垮整个服务,是一道有用的兜底防线。

把不可信输入当成攻击面

当正则被用来处理来自用户的不可信输入时,它就成了一个潜在的攻击面。攻击者可能专门构造输入来触发灾难性回溯,让你的服务因一个请求而瘫痪。任何对外暴露、又用正则校验输入的地方,都值得用恶意输入做压力测试。

对这类场景,要么使用结构上不会灾难性回溯的模式,要么改用不基于回溯的引擎,要么加上严格的超时和长度限制。把正则当成可能被攻击的代码来对待,而不是无害的小工具。

用测试守住这条底线

避免灾难性回溯,最终要落实到测试上。除了功能用例,还应专门加入针对性能的测试:用较长的、特别是"接近匹配但不匹配"的输入去跑你的正则,观察耗时是否随长度异常增长。

把这类测试纳入日常,你就能在问题进入生产之前发现它。一条正则跑得对固然重要,但在面对恶意输入时仍然跑得快、跑得稳,同样是它必须经受的考验。