为何回溯式正则表达式模式会悄悄让服务器挂掉
了解贪婪匹配与灾难性回溯如何将看似正确的正则表达式变成耗尽CPU资源并导致生产中断的问题,以及如何识别这类风险。
来源: The Regex That Looked Fine in Testing and Hung the Server in Production
一个用户名验证模式经过了代码审查,也顺利通过了全部测试用例,但在部署数周后,却因单个请求导致某个API接口停止响应长达11秒。并没有程序崩溃,也没有异常抛出——进程只是停止了响应,CPU使用率一直保持在100%,却在处理一个长度仅约40个字符的字符串。
从通常意义上讲,并没有引入任何漏洞。该正则表达式本身是有效的,也能准确匹配预定内容。但它存在一个大多数开发者从未学会注意的隐藏特性,要理解这一点就需要超越符号本身,明白正则表达式在运行时的真实作用,而大多数教程在解释如\d和+这样的标记时都会跳过这部分内容。
正则表达式是微型流程图,而非符号串
假设我们要设计一个用于验证产品代码的模式,该代码由两个大写字母后跟四个数字组成:
const productCode = /^[A-Z]{2}\d{4}$/;
console.log(productCode.test("LM4821")); // true
console.log(productCode.test("LM482")); // false
人们很容易将其视为对字符串结构的简写描述。但更好的思维方式——也是日后会更有效的做法——是将其视为一张微型流程图:从起点开始,要求输入恰好两个大写字母,接着要求输入恰好四个数字,最后要求输入结束。解析引擎会逐个字符检查输入内容,试图沿着该流程图从起点节点走到终点节点。如果成功到达终点,则表示匹配成功;如果遇到障碍且后面还有未尝试的路径,它会退回并尝试另一条路径。
那种回退一步——回到上一步去测试另一个分支的操作——被称为回溯,它可以说是正则表达式中最重要的概念。正是这一机制赋予了正则表达式灵活性,同时也正是导致上述生产事故的根源。
分组将“是否匹配”转化为“其中实际包含什么”
调用.test()只会返回真或假。将模式中的某些部分用括号括起来,就可以分别捕获匹配到的文本片段,这正是正则表达式在解析任务中发挥真正作用、而不仅仅是进行验证的关键所在:
const logPattern = /^\[(\w+)\] (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}) (.+)$/;
const line = "[ERROR] 2026-08-15T10:22:01 Failed to connect to database";
const match = logPattern.exec(line);
console.log(match[1]); // "ERROR"
console.log(match[2]); // "2026-08-15T10:22:01"
console.log(match[3]); // "Failed to connect to database"
模式中每一组括号内容都会成为结果数组中的一个独立条目,其顺序由对应左括号在模式中的出现位置决定。这样一来,原本仅用于判断某行是否符合日志条目格式的简单测试,便转变为更为实用的功能——能够将严重级别、时间戳和消息内容作为三个独立的值直接使用。正因如此,正则表达式在日志处理、配置解析以及任何需要将结构松散的文本转换为程序可直接处理的格式的场景中都被广泛使用。
贪心匹配会导致获取超出预期的内容
下一个错误是几乎每位开发者都至少会犯一次的。假设需要从文本字段中删除轻量级的HTML标签:
const html = "<b>Featured</b> item, <i>on sale</i> now";
console.log(html.replace(/<.*>/g, ""));
// "Featured item, on sale now" — wait, that actually looks right...
const html2 = "<b>Sale</b> ends <i>Friday</i>";
console.log(html2.replace(/<.*>/g, ""));
// "" — everything vanished
第二个字符串暴露了这一缺陷。默认情况下,.*是贪婪匹配的——它会尽可能多地捕获字符,只有当模式的其他部分确实需要时才会返回字符。由于该字符串包含两个独立的标签,模式<.*>不会在第一个闭合标签</b>处停止,而是会继续捕获其后的所有内容——从ends之后开始,一直到最后的一个>,即</i>内的那个>——因为只要能匹配到文本中任意位置的最后一个>,单个贪婪的.*>就已经满足匹配条件了,无论该位置距离多远。
console.log(html2.replace(/<.*?>/g, ""));
// "Sale ends Friday"
在 * 后立即加上 ? 可使其从贪婪模式变为懒惰模式:此时引擎会尽量匹配最少的字符,只有在无法用更少字符满足后续要求时才会进一步扩展匹配范围。由于这一变化,每个 <...> 序列都会在最近的 > 处关闭,而非直接匹配到字符串中的最后一个 >,从而能够按预期逐个移除标签。正是 * 与 *? 这一细微差别,导致了开发者常遇到的“正则表达式删除的文本远超预期”这类错误。
真正导致服务器挂掉的漏洞
考虑一下导致那11秒卡顿的验证模式的简化版本:该验证需要用户名包含一个或多个单词字符片段(这些片段可以分开),其后紧跟一个@符号。
const badPattern = /^(\w+)+@/;
console.log(badPattern.test("valid_user@example.com")); // true, fast
console.log(badPattern.test("this_is_a_fairly_long_username_with_no_at_sign"));
// hangs, sometimes for a very long time, depending on length
问题出在嵌套的重复结构:(\w+)+,即一个由一个或多个单词字符组成的序列,而这个序列本身又处于一个会重复执行一次或多次的组中。当输入字符串确实包含@时,引擎几乎可以立即找到匹配项。但如果输入的字符串根本不含@,引擎就不能立刻放弃,而必须逐一验证所有可能的组合都无法满足条件。需要检查的组合数量多得惊人:内部的+可能选取一个字符,然后让外部组对剩余部分重复处理;或者它可能选取两个字符,再让外部组对剩下的部分重复处理,诸如此类,所有可能的拆分方式都会被尝试。引擎会在宣布失败之前逐一回溯这些组合,而每多一个字符,需要检查的组合数量大约就会翻倍。
const fixedPattern = /^\w+@/;
一旦弄清根本原因,解决方案就显得简单至极:从一开始就不需要那种嵌套重复的结构。对于任何本应正常匹配的输入,单独使用\w+与(\w+)+的效果完全相同,且不存在后者所隐含的指数级回溯风险。这种故障有一个众所周知的名称——灾难性回溯,其典型特征是某个重复操作符被应用于本身已包含重复操作符的组,诸如(x+)+、(x*)+或(x+)*这类模式都属于同一类隐患的不同表现形式。
这实际上教会了你需要注意什么
你无需记住所有存在的危险模式形状。重要的是要牢记一个核心原理:正则表达式引擎并非立即评估模式,而是逐条路径进行匹配搜索,一旦当前路径失败就会回退并尝试其他路径。普通模式的可探索路径极少,因此这种搜索会在你察觉到延迟之前就完成。真正的危险出现在嵌套且重叠的重复结构中,此时需要检查的路径数量不仅会持续增加,还会随着每出现一个异常输入字符而呈指数级上升。因此,在测试正则表达式时,不仅要使用预期能匹配的字符串,还要用专门设计用于失败的长字符串进行测试。那些测试套件从未考虑过的输入,恰恰就是最终会在实际应用中出现的输入。