灾难性回溯

76 阅读4分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第15天,点击查看活动详情

有些正则表达式看起来很简单,但执行起来耗时却非常长,甚至会导致 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 种组合)。

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