正則表達式中隱藏的陷阱

幾天前,一個在線項目的監控系統忽然報告了一個例外。在檢查相關資源的使用狀況後,咱們發現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死掉。當遇到正則表達式時它也給咱們敲響了警鐘,使用時應該注意貪婪模式和回溯問題。

相關文章
相關標籤/搜索