2022年8月9日

灾难性回溯

有些正则表达式看起来很简单,但执行起来耗时却非常长,甚至会导致 JavaScript 引擎“挂起”。

大多数开发者迟早会遇到这样的情况。典型的症状就是 —— 正则表达式有时可以正常工作,但对于某些字符串,它会消耗 100% 的 CPU 算力,出现“挂起”的现象。

在这种情况下,Web 浏览器会建议终止脚本并重新加载页面。这显然不是我们愿意看到的。

对于服务器端 JavaScript,这样的正则表达式可能会挂起服务器进程,这甚至更糟。所以我们绝对应该研究研究它。

举例

假设,我们现在有一个字符串,我们想检查其中是否包含一些后面跟着可选空格 \s? 的单词 \w+

构造此正则表达式最显而易见的方式是一个单词后跟着一个可选空格 \w+\s?,然后用 * 重复它。

写成正则表达式即 ^(\w+\s?)*$,它指定 0 个及以上这样的词,从开头 ^ 开始,并在行的结尾 $ 结束。

运行一下:

let regexp = /^(\w+\s?)*$/;

alert( regexp.test("A good string") ); // true
alert( regexp.test("Bad characters: $@#") ); // false

这似乎能正常工作。结果是正确的。但在特定的字符串上,它会消耗很多时间。它耗时太久以至于让 CPU 会跑满 100% 负载,导致 JavaScript 引擎“挂起”。

如果你运行下面这个例子,由于 JavaScript 会进导致“挂起”,所以你可能什么结果都看不到。此时浏览器会停止对事件的响应,UI 也会停止工作。一段时间之后,浏览器会建议重新加载页面。所以请谨慎对待:

let regexp = /^(\w+\s?)*$/;
let str = "An input string that takes a long time or even makes this regexp hang!";

// 会耗费很长时间
alert( regexp.test(str) );

有一些正则表达式引擎可以很好地处理这样的搜索,例如从 8.8 版本开始的 V8 引擎(因此 88 及以上版本的 Google Chrome 不会在这里挂起),而火狐(Firefox)浏览器确实会挂起。

简化的例子

问题出在哪?为什么正则表达式会导致“挂起”?

为了理解它,我们来简化一下例子:移除空格符 \s?,使其简化为 ^(\w+)*$

同时为了让问题更明显,再用 \d 替换掉 \w。生成的新正则表达式执行时仍会导致挂起,例如:

let regexp = /^(\d+)*$/;

let str = "012345678901234567890123456789z";

// 会消耗很长时间(请小心!)
alert( regexp.test(str) );

所以正则表达式哪里出了问题?

首先,有人可能会注意到这个正则表达式的 (\d+)* 部分有点奇怪。量词 * 看起来没什么必要。如果我们要匹配一个数字,那可以使用 \d+

实际上,正则表达式很死板。我们通过简化前面的例子得到了一个简化版的正则表达式。但慢的原因是一样的。所以让我们来理解一下它的执行过程,然后问题的原因就会显而易见了。

123456789z 这行(清楚起见,这里缩短了字符串,请注意末尾的非数字字符 z,这很重要)中搜索 ^(\d+)*$ 时到底发生了什么,为什么耗时这么久?

下面是正则表达式引擎的执行过程:

  1. 首先,正则表达式引擎尝试查找括号中的内容:数字 \d+。加号 + 默认为贪婪模式,所以它消耗了所有数字:

    \d+.......
    (123456789)z

    消耗完所有数字后,认为找到了 \d+(如 123456789)。

    然后它尝试应用星号量词,但此时已经没有更多数字了,所以星号没有给出任何信息。

    模式中接下来的 $ 匹配字符串的结束,但是我们例子的文字中有 z,所以匹配失败:

               X
    \d+........$
    (123456789)z
  2. 由于没有匹配结果,贪婪量词 + 的重复匹配次数会减一,并回溯一个字符。

    现在 \d+ 会匹配除了最后一个数字之外的所有数字(12345678):

    \d+.......
    (12345678)9z
  3. 然后引擎尝试从新位置 (9) 继续搜索。

    星号 (\d+)* 可以成功应用 —— 它匹配到了数字 9

    \d+.......\d+
    (12345678)(9)z

    引擎再次去尝试匹配 $,但又失败了,因为它遇到了 z

                 X
    \d+.......\d+
    (12345678)(9)z
  4. 没有匹配结果,所以引擎继续回溯,减少重复匹配次数。回溯通常是这样工作的:最后一个贪婪量词逐渐减少重复次数,直到达到最小值。然后前一个贪婪量词再减少重复次数,以此类推。

    它会尝试所有可能的排列组合,这里是它们的例子。

    第一个数字 \d+ 有 7 位数,后面跟着一个 2 位数的数字:

                 X
    \d+......\d+
    (1234567)(89)z

    第一个数字有 7 位数,后面跟着两个 1 位数:

                   X
    \d+......\d+\d+
    (1234567)(8)(9)z

    第一个数字有 6 位数,后面跟着一个 3 位数:

                 X
    \d+.......\d+
    (123456)(789)z

    第一个数字有 6 位数,后面跟着两个数字:

                   X
    \d+.....\d+ \d+
    (123456)(78)(9)z

    ……以此类推。

有很多种方式可以将数字序列 123456789 拆分为多个数字。准确地说,有 2n-1 种,其中 n 是序列的长度。

  • 对于 123456789n=9,也就是说有 511 种组合。
  • 对于更长一点的 n=20 的字符串,差不多有 100 万种组合。
  • 对于 n=30 —— 又增加了 1000 倍以上(1073741823 种组合)。

搜索需要这么长时间正是因为在一个一个地尝试这么多种组合。

回到单词和字符串

在我们第一个例子中,当我们用 ^(\w+\s?)*$ 这种模式在字符串 An input that hangs! 中查找单词时,就会发生类似的问题。

就是因为一个单词 \w+ 可以被表示成很多种:

(input)
(inpu)(t)
(inp)(u)(t)
(in)(p)(ut)
...

以我们人的角度来看,很显然它无法匹配成功,因为示例中的字符串以叹号 ! 结尾,然而正则表达式期望在的是一个单词 \w 末尾有或没有空格 \s。但引擎理解不了这种状况。

它尝试了 (\w+\s?)* 的所有排列组合试图去囊括整个字符串,包括带空格 (\w+\s)* 的情形和不带空格 (\w+)* 的情形(因为空格 \s? 是可选的)。由于各种排列组合的数量(我们已经通过计算直观感受过了)太多了,所以耗费了大量时间去查询。

那怎么办?

我们应该改用懒惰模式吗?

不幸的是,这没用:如果我们用 \w+? 去替代 \w+,还是会挂起。排列组合的顺序会变化,但是总数不变。

有些正则表达式引擎具有对棘手内容的测试和自动化有限处理,可以避免遍历所有排列组合来优化速度,但大多数引擎没有,而且也不是在所有情况下都有效果。

如何解决?

主要有 2 种解决方式。

第一种是减少可能的组合数量。

让我们把正则表达式重写为 ^(\w+\s)*\w*$ 以使空格变为非可选的 —— 我们将查找任意数量的单词后跟空格 (\w+\s)*,然后跟着最后一个单词 \w*(可选)。

这个正则表达式等同于之前那个(匹配内容相同),并且运行起来也没问题:

let regexp = /^(\w+\s)*\w*$/;
let str = "An input string that takes a long time or even makes this regex hang!";

alert( regexp.test(str) ); // false

为什么问题消失了?

因为现在空格是强制性的。

前面的正则表达式,如果我们省略空格,就会变成 (\w+)*,导致单个单词中有很多 \w+ 组合 。

所以 input 可以匹配为 \w+ 的两次重复,如下所示:

\w+  \w+
(inp)(ut)

新模式有所不同:(\w+\s)* 指定单词的重复后面跟着一个空格!input 字符串不能匹配为 \w+\s 的两次重复,因为空格是强制性的。

现在节省了尝试大量(实际上是大多数)组合所需的时间。

防止回溯

有时候重写正则表达式会比较麻烦。在上面的示例中,这很容易,但如何做到这一点并不总是很明显。

此外,重写的正则表达式通常更复杂,这并不好。在不做其他更改的情况下,正则表达式已经够复杂了。

幸运的是,还有另一种方式。我们可以禁止量词的回溯。

问题的根源在于正则表达式引擎尝试了许多对人类看来显然是错误的组合。

例如,正则表达式 (\d+)*$+ 对于我们人类来说很明显不应去回溯。就算我们用两个单独的 \d+\d+ 去替换一个 \d+,也根本没变化:

\d+........
(123456789)!

\d+...\d+....
(1234)(56789)!

在原先的那个例子 ^(\w+\s?)*$ 中,我们可能希望在 \w+ 中禁止回溯。即:\w+ 应该匹配一个完整的单词,并且具有最大可能的长度。无需降低 \w+ 的重复次数或将其拆分为两个单词 \w+\w+ 等等。

为此,现代正则表达式引擎支持占有型量词(Possessive Quantifiers)。如果我们在常规量词之后添加 +,则常规量词就变成了占有型量词。也就是说,我们可以使用 \d++ 替代 \d+ 来阻止 + 回溯。

占有型量词实际上比“常规”量词更简单。它们只是尽可能多地匹配,没有任何回溯。没有回溯的搜索过程更简单。

还有所谓的“原子捕获组” —— 一种禁用括号内回溯的方法。

……但坏消息是,JavaScript 并不支持它。

我们可以通过使用“前瞻变换(lookahead transform)”来模拟它们。

用前瞻视角解决问题

所以,我们来到了真正的高阶主题。我们希望量词,例如 + 不要回溯,因为有时回溯没有意义。

在不回溯的情况下尽可能多地重复 \w 的模式可以写为:(?=(\w+))\1。当然,我们可以采用另一种模式来代替 \w

这可能看起来很奇怪,但它实际上是一个非常简单的转换。

让我们解读一下:

  • 前瞻断言 ?= 从当前位置开始,向前查找最长的单词 \w+
  • 引擎不会去记住带有 ?=... 的括号中的内容。所以将 \w+ 放入括号中,这样引擎就会记住这些内容了。
  • ……然后用 \1 来引用括号中的内容。

也就是说:我们先进行前瞻查找 —— 如果有符合 \w+ 的单词,我们就可将其匹配为 \1

为什么?因为前瞻断言查找到一个单词 \w+,将其作为一个整体,然后将其捕获为 \1。所以我们最终实现了一种占有型加号 + 量词。它只捕获整个单词 \w+,而不会只捕获一部分。

例如,在单词 JavaScript 中不仅可以匹配 Java,而且可以忽略 Script,以匹配模式的其余部分。

下面是 2 个模式的对比:

alert( "JavaScript".match(/\w+Script/)); // JavaScript
alert( "JavaScript".match(/(?=(\w+))\1Script/)); // null
  1. 第一个变体 \w+ 首先捕获整个 JavaScript 单词,然而接下来 + 会一个字符一个字符地进行回溯,试图匹配整个模式的其余部分,直到 \w+ 匹配到了 Java 时,它最终才匹配成功。
  2. 第二个变体 (?=(\w+)) 前瞻查找并匹配整个单词 JavaScript,然后把整个单词作为一个整体包含进 \1 中,所以在它后面就无法查找到 Script 了。

当我们需要禁止 + 进行回溯,只需要把 (?=(\w+))\1 中的 \w 替换成更复杂的正则表达式就能实现了。

请注意:

这些文章中有更多关于占有型量词和前瞻断言之间关系的内容:正则表达式:使用前瞻断言模拟原子分组(和占有型量词)模拟原子组

现在让我们用前瞻断言重写第一个例子中的正则表达式来防止回溯吧:

let regexp = /^((?=(\w+))\2\s?)*$/;

alert( regexp.test("A good string") ); // true

let str = "An input string that takes a long time or even makes this regex hang!";

alert( regexp.test(str) ); // false,有效且执行的很快!

这里我们用 \2 而不是 \1,因为这里有额外的外部括号。为了防止数字弄混了,我们可以给括号命名,例如 (?<word>\w+)

// 括号被命名为 ?<word>,使用 \k<word> 进行引用
let regexp = /^((?=(?<word>\w+))\k<word>\s?)*$/;

let str = "An input string that takes a long time or even makes this regex hang!";

alert( regexp.test(str) ); // false

alert( regexp.test("A correct string") ); // true

本文所描述的问题称作“灾难性回溯(Catastrophic Backtracking)”,又译作“回溯陷阱”。

我们介绍了两种解决方式:

  • 重写正则表达式,以尽可能降低可能的组合数量。
  • 防止回溯。
教程路线图

评论

在评论之前先阅读本内容…
  • 如果你发现教程有错误,或者有其他需要修改和提升的地方 — 请 提交一个 GitHub issue 或 pull request,而不是在这评论。
  • 如果你对教程的内容有不理解的地方 — 请详细说明。
  • 使用 <code> 标签插入只有几个词的代码,插入多行代码可以使用 <pre> 标签,对于超过 10 行的代码,建议你使用沙箱(plnkrJSBincodepen…)