正则表达式中隐藏的陷阱

几天前,一个在线项目的监控系统忽然报告了一个例外。在检查相关资源的使用状况后,咱们发现CPU利用率接近100%。而后咱们使用Java附带的thread dump工具导出问题的堆栈信息。java

regex-trap-01.png
咱们能够看到全部堆栈都指向一个被调用的方法validateUrl,它在堆栈上得到了100多条错误消息。经过对代码进行故障排除,咱们知道该方法的主要功能是验证URL是否合法。web

那么正则表达式如何致使高CPU利用率。为了重现问题,咱们提取关键代码并进行简单的单元测试。正则表达式

public static void main(String[] args) {
    String badRegex = "^([hH][tT]{2}[pP]://|[hH][tT]{2}[pP][sS]://)(([A-Za-z0-9-~]+).)+([A-Za-z0-9-~\\\\/])+$";
    String bugUrl = "http://www.fapiao.com/dddp-web/pdf/download?request=6e7JGxxxxx4ILd-kExxxxxxxqJ4-CHLmqVnenXC692m74H38sdfdsazxcUmfcOH2fAfY1Vw__%5EDadIfJgiEf";
    if (bugUrl.matches(badRegex)) {
        System.out.println("match!!");
    } else {
        System.out.println("no match!!");
    }
}

当运行上面的示例时,经过资源监视器,咱们能够看到一个被调用的进程java的CPU利用率飙升至91.4%。算法

regex-trap-02.png
如今几乎能够判断正则表达式就是致使CPU利用率高的缘由!api

因此,让咱们关注正则表达式:并发

^([hH][tT]{2}[pP]://|[hH][tT]{2}[pP][sS]://)(([A-Za-z0-9-~]+).)+([A-Za-z0-9-~\\/])+$

分解一下上面的正则表达式:工具

它匹配第一部分中的httphttps协议,匹配www.第二部分中的字符,并匹配第三部分中的其余字符。我盯着正则表达很长一段时间并无发现任何大问题。性能

实际上,这里CPU使用率高的关键缘由是Java正则表达式使用的引擎实现是NFA,在执行字符匹配时执行回溯。一旦发生回溯,所需的时间将变得很是长。多是几分钟甚至几个小时。时间量取决于回溯的数量和复杂性。单元测试

顺便说一下,也许有些人仍然不清楚回溯是什么。不要紧,让咱们从正则表达式的原则入手。测试

正则表达式引擎

正则表达式是一组方便的匹配符号。要实现这种复杂而强大的匹配语法,咱们必须拥有一组算法,而且算法的实现称为正则表达式引擎。简而言之,有两种方法能够实现正则表达式引擎:(DFA肯定性最终自动机)和NFA(非肯定性有限自动机)。

这两个自动机是不一样的,咱们不会深刻研究它们的原理。简单地说,时间复杂度DFA是线性的,更稳定但功能有限。时间复杂度NFA相对不稳定,因此有时它很是好,有时它不是,取决于你写的正则表达式。但其优势NFA是其功能更强大,所以Java,.NET,Perl,Python,Ruby和PHP等语言使用NFA来实现其正则表达式。

如何在NFA 比赛?咱们使用如下字符和表达式做为示例。

text="Today is a nice day."
regex="day"

请注意,NFA匹配是基于正则表达式。也就是说,NFA将读取正则表达式的一个字符并将其与目标字符串匹配。若是匹配成功,它将转到正则表达式的下一个字符,不然它将继续与目标字符串的下一个字符进行比较。

让咱们一步一步地看看上面的例子。

  • 首先,取正则表达式的第一个匹配字符:  d。而后将它与字符串的第一个字符进行比较,即T. 它不匹配,因此转到下一个字符  。第二个字符是  o,它也不匹配。因此继续下一个,  d 如今。它匹配。而后阅读常规的第二个字符:  a
  • 正则表达式的第二个匹配字符:  a。它将与字符串的第四个字符进行比较  a. 。它再次匹配。而后继续阅读正则表达式的第三个字符  y
  • 正则表达式的第三个匹配字符是  y。让咱们继续将它与字符串的第五个字符匹配,而后匹配。而后尝试读取正则表达式的下一个字符,发现没有,因此匹配结束。

以上是匹配过程,NFA实际匹配过程要复杂得多。可是,匹配原则是同样的。

Backtracking(回溯)NFA

既然您已经学会了如何NFA执行字符串匹配,那么让咱们来谈谈文章的重点:回溯。为了更好地解释回溯,咱们将使用如下示例。

text="abbc"
regex="ab{1,3}c"

这是一个相对简单的例子。正则表达式  a 以及以它结尾  c,而且在它们之间有一个1-3个b 字符的字符串  。匹配过程NFA是这样的:

  • 首先,取正则表达式的第一个匹配字符,即将  a, 其与字符串的第一个字符进行比较  a。它匹配,因此移动到正则表达式的第二个字符。
  • 取正则表达式的第二个匹配字符,即将 b{1,3}, 其与字符串的第二个字符进行比较  b. 再次匹配。可是因为  b{1,3} 表示1-3个  b 字符串和贪婪的性质NFA(即尽量匹配),它此时不会读取正则表达式的下一个字符,但仍然b{1,3} 与字符串的第三个字符进行比较  ,这  b 也是。它也匹配。而后它将继续使用  b{1,3} 与字符串的第四个字符进行比较  c,并发现它不匹配。 此时发生回溯 
  • 回溯如何运做?在回溯以后,c已经读取的字符串的第四个字符(即  )将被吐出,指针将返回到字符串的第三个字符。以后,它将读取c 正则表达式的下一个字符  ,并将其与c 当前指针的下一个字符进行比较  ,并匹配。而后阅读下一篇,但结束了。

让咱们回过头来看一下用于验证URL的正则表达式:

^([hH][tT]{2}[pP]://|[hH][tT]{2}[pP][sS]://)(([A-Za-z0-9-~]+).)+([A-Za-z0-9-~\\/])+$

发生问题的URL是:

http://www.fapiao.com/dzfp-web/pdf/download?request=6e7JGm38jfjghVrv4ILd-kEn64HcUX4qL4a4qJ4-CHLmqVnenXC692m74H5oxkjgdsYazxcUmfcOH2fAfY1Vw__%5EDadIfJgiEf

咱们将正则表达式分为三个部分:

  • 第1部分:验证协议。^([hH][tT]{2}[pP]://|[hH][tT]{2}[pP][sS]://)
  • 第2部分:验证域。 (([A-Za-z0-9-~]+).)+
  • 第3部分:验证参数。 ([A-Za-z0-9-~\\/])+$

能够发现正则表达式验证协议的部分没有问题  http://,但在验证时www.fapiao.com,它使用  xxxx. 验证方式。因此匹配过程是这样的:

  • 匹配到www
  • 匹配到fapiao
  • 匹配  com/dzfp-web/pdf/download?request=6e7JGm38jf.....,您将看到因为贪婪的性质,程序将始终尝试读取后续字符串以匹配,最后它发现没有点,所以它开始逐个字符回溯。

这是正则表达式中的第一个问题。

另外一个问题是正则表达式的第三部分。能够发现有问题的URL有下划线(_)和百分号(%),但对应于第三部分的正则表达不具有。所以,只有在匹配一长串字符后,才会发现它不匹配,而后再回溯。

这是这个正则表达式中的第二个问题。

解释

已经了解到回溯是致使问题的缘由。所以问题的解决方案是减小回溯。实际上,您会发现若是将下划线和百分号添加到第三部分,程序将变为正常。

public static void main(String[] args) {
    String badRegex = "^([hH][tT]{2}[pP]://|[hH][tT]{2}[pP][sS]://)(([A-Za-z0-9-~]+).)+([A-Za-z0-9-~_%\\\\/])+$";
    String bugUrl = "http://www.fapiao.com/dddp-web/pdf/download?request=6e7JGxxxxx4ILd-kExxxxxxxqJ4-CHLmqVnenXC692m74H38sdfdsazxcUmfcOH2fAfY1Vw__%5EDadIfJgiEf";
    if (bugUrl.matches(badRegex)) {
        System.out.println("match!!");
    } else {
        System.out.println("no match!!");
    }
}

运行上面的程序,它将打印出来  match!!

若是未来还有其余包含杂乱字符的URL怎么办?再改一次?固然这不现实!

事实上,正则表达式有三种模式:贪婪模式不情愿模式占有模式

若是? 在正则表达式中添加一个符号  ,则Greedy模式将变为Reluctant模式,也就是说,它将尽量少地匹配。可是,在Reluctant模式下仍会发生回溯。例如:

text="abbc"
regex="ab{1,3}?c"

正则表达式a的第一个字符:匹配字符串的第一个字符a。正则表达式的第二个运算符  b{1,3}?匹配b 字符串的第二个字符  。因为最小匹配原则,c 正则表达式的第三个运算符  b 与字符串的第三个字符不匹配  。因此它回溯并将正则表达式的第二个运算符  b{1,3}? 与b 字符串的第三个字符  进行比较,如今匹配成功。而后正则表达式的第三个字符  c 匹配c 字符串的第四个字符  。结束。

若是你添加一个符号+ ,原来的贪婪模式将变为独占模式,也就是说,它将尽量匹配,但不会回溯。

所以,若是您想彻底解决问题,必须保证功能,同时确保不回溯。我在正则表达式的第二部分添加一个加号来验证上面的URL:

^([hH][tT]{2}[pP]:\/\/|[hH][tT]{2}[pP][sS]:\/\/)
(([A-Za-z0-9-~]+).)++    --->>> (added + here)
([A-Za-z0-9-~_%\\\/])+$

如今运行该程序没有问题。

最后,我推荐一个网站,能够检查你写的正则表达式是否有问题以及相应的字符串匹配。

Online regex tester and debugger: PHP, PCRE, Python, Golang and JavaScript

例如,使用站点检查后将提示本文中存在问题的URL:灾难性的回溯。

regex-trap-03.png
单击左下角的“正则表达式调试器”时,它将告诉您已检查了多少步骤,并将列出全部步骤并指出回溯发生的位置。

本文中的正则表达式在110,000步尝试后自动中止。它代表正则表达式确实存在问题,须要改进。

可是当我用修改后的正则表达式测试它时以下:

^([hH][tT]{2}[pP]:\/\/|[hH][tT]{2}[pP][sS]:\/\/)(([A-Za-z0-9-~]+).)++([A-Za-z0-9-~\\\/])+$

提示完成检查只须要58步。

regex-trap-05.png
一个字符的差别会致使巨大的性能差距。

最后

使人惊讶的是,一个小的正则表达式可让CPU死掉。当遇到正则表达式时它也给咱们敲响了警钟,使用时应该注意贪婪模式和回溯问题。

相关文章
相关标签/搜索