正則表達式和 CPU 100%有什麼故事?

2021-01-11 純潔的微笑

前幾天線上一個項目監控信息突然報告異常,上到機器上後查看相關資源的使用情況,發現 CPU 利用率將近 100%。通過 Java 自帶的線程 Dump 工具,我們導出了出問題的堆棧信息。

我們可以看到所有的堆棧都指向了一個名為 validateUrl 的方法,這樣的報錯信息在堆棧中一共超過 100 處。通過排查代碼,我們知道這個方法的主要功能是校驗 URL 是否合法。

很奇怪,一個正則表達式怎麼會導致 CPU 利用率居高不下。為了弄清楚復現問題,我們將其中的關鍵代碼摘抄出來,做了個簡單的單元測試。

publicstaticvoidmain(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% 。

看到這裡,我們基本可以推斷,這個正則表達式就是導致 CPU 利用率居高不下的兇手!

於是,我們將排錯的重點放在了那個正則表達式上:

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

這個正則表達式看起來沒什麼問題,可以分為三個部分:

第一部分匹配 http 和 https 協議,第二部分匹配 www. 字符,第三部分匹配許多字符。我看著這個表達式發呆了許久,也沒發現沒有什麼大的問題。

其實這裡導致 CPU 使用率高的關鍵原因就是:Java 正則表達式使用的引擎實現是 NFA 自動機,這種正則表達式引擎在進行字符匹配時會發生回溯(backtracking)。而一旦發生回溯,那其消耗的時間就會變得很長,有可能是幾分鐘,也有可能是幾個小時,時間長短取決於回溯的次數和複雜度。

看到這裡,可能大家還不是很清楚什麼是回溯,還有點懵。沒關係,我們一點點從正則表達式的原理開始講起。

正則表達式引擎

正則表達式是一個很方便的匹配符號,但要實現這麼複雜,功能如此強大的匹配語法,就必須要有一套算法來實現,而實現這套算法的東西就叫做正則表達式引擎。簡單地說,實現正則表達式引擎的有兩種方式:DFA 自動機(Deterministic Final Automata 確定型有窮自動機)和 NFA 自動機(Non deterministic Finite Automaton 不確定型有窮自動機)。

對於這兩種自動機,他們有各自的區別,這裡並不打算深入將它們的原理。簡單地說,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。那著繼續和字符串的第五個字符 y 比較,又匹配了。嘗試讀取正則表達式的下一個字符,發現沒有了,那麼匹配結束。上面這個匹配過程就是 NFA 自動機的匹配過程,但實際上的匹配過程會比這個複雜非常多,但其原理是不變的。

文章首發於【博客園-陳樹義】,點擊跳轉到原文《藏在正則表達式裡的陷阱》

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

我們把這個正則表達式分為三個部分:

第一部分:校驗協議。^([hH][tT]{2}[pP]://|[hH][tT]{2}[pP][sS]://)。第二部分:校驗域名。(([A-Za-z0-9-~]+).)+。第三部分:校驗參數。([A-Za-z0-9-~\\/])+$。我們可以發現正則表達式校驗協議 http:// 這部分是沒有問題的,但是在校驗 www.fapiao.com 的時候,其使用了 xxxx. 這種方式去校驗。那麼其實匹配過程是這樣的:

匹配到 www.匹配到 fapiao.匹配到 com/dzfp-web/pdf/download?request=6e7JGm38jf.....,你會發現因為貪婪匹配的原因,所以程序會一直讀後面的字符串進行匹配,最後發現沒有點號,於是就一個個字符回溯回去了。這是這個正則表達式存在的第一個問題。

另外一個問題是在正則表達式的第三部分,我們發現出現問題的 URL 是有下劃線(_)和百分號(%)的,但是對應第三部分的正則表達式裡面卻沒有。這樣就會導致前面匹配了一長串的字符之後,發現不匹配,最後回溯回去。

這是這個正則表達式存在的第二個問題。

解決方案

明白了回溯是導致問題的原因之後,其實就是減少這種回溯,你會發現如果我在第三部分加上下劃線和百分號之後,程序就正常了。

publicstaticvoidmain(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 包含了亂七八糟的字符呢,我們難不成還再修改一遍。肯定不現實嘛!

其實在正則表達式中有這麼三種模式:貪婪模式、懶惰模式、獨佔模式。

在關於數量的匹配中,有 + ? * {min,max} 四種兩次,如果只是單獨使用,那麼它們就是貪婪模式。

如果在他們之後加多一個 ? 符號,那麼原先的貪婪模式就會變成懶惰模式,即儘可能少地匹配。但是懶惰模式還是會發生回溯現象的。TODO例如下面這個例子:

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-~]+).)++ --->>> (這裡加了個+號)([A-Za-z0-9-~\\/])+$

這樣之後,運行原有的程序就沒有問題了。

最後推薦一個網站,這個網站可以檢查你寫的正則表達式和對應的字符串匹配時會不會有問題。

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

例如我本文中存在問題的那個 URL 使用該網站檢查後會提示:catastrophic backgracking(災難性回溯)。

通過查閱網上資料,我發現深圳阿里中心 LAZADA 的同學也在 17 年遇到了這個問題。他們同樣也是在測試環境沒有發現問題,但是一到線上的時候就發生了 CPU 100% 的問題,他們遇到的問題幾乎跟我們的一模一樣。

雖然把這篇文章寫完了,但是關於 NFA 自動機的原理方面,特別是關於懶惰模式、獨佔模式的解釋方面還是沒有解釋得足夠深入。因為 NFA 自動機確實不是那麼容易理解,所以在這方面還需要不斷學習加強。歡迎有懂行的朋友來學習交流,互相促進。

原文出處:https://mp.weixin.qq.com/s/qVBzB8U_v-XmHAQUquFafw

END

相關焦點

  • 正則表達式在VBA中間是如何應用?正則表達式的實現方式?
    Hi,大家好,本章節開始將會從零開始和大家用圖文的方式,讓你從零基礎學會正則表達式!有興趣的小夥伴可以持續關注我,或者在專欄中進行查看自我學習,願與君攜手前行!在上一個章節說到正則表達式的入門級知識點,本節將會與大家分享一下正則表達式的是具體實現方式是怎麼樣的?
  • 學習Python正則表達式
    如何不需要一個接一個地複製和粘貼,就可以輕鬆地找到這些電話號碼?Python中的正則表達式(re)就可以解決這個問題!正則表達式正則表達式是一個具有特殊字符的序列。它有助於檢查字符串中的每個字符,看它是否與某個模式匹配:哪些字符在什麼位置出現了多少次。
  • Python「正則表達式」詳解(上)
    看看畫紅線的部分,為什麼你輸入什麼文字,網址裡面(也就是藍框所圈住部分)就會出現什麼文字。我現在告訴你,你之所以能在搜索框裡輸入幾個文字,按下回車,就出來大量結果,是因為瀏覽器已經幫你生成了正則表達式,也就是藍框所圈住的那一部分內容。當然搜尋引擎絕不止正則表達式這麼簡單,但正則表達式無疑佔據了最核心的部位。說了這麼多,那什麼是正則表達式呢?
  • 使用JavaScript對正則表達式進行解析
    我第一次遇到正則表達式是在很多年前,但是我仍然記得我對它的最初想法:這串東西是什麼?我不想不碰它,看起來很嚇人。我不太記得那個正則表達式在做什麼了,或者看起來到底是什麼樣子,但是它嚇到了我。回顧過去,我意識到這可能一點都不可怕,實際上,這是解決當前問題的簡便方法。但是為什麼會有這種感覺?
  • Python正則表達式:特殊符號和字符
    正表達式為高級的文本模式匹配,抽取,與/或文本形式的搜索和替換功能提供了基礎。簡而言之,正則表達式(簡稱regex)是由一些字符和特殊符號組成的字符串,它描述了模式的重複或者表達多個字符。python通過標準庫中的re模塊來支持正則表達式。
  • 精通正則表達式的 12 個有用資源
    而正則表達式用來處理這樣的任務可以說是輕而易舉,而且代碼量很少。另外一方面,正則表達式被認為是非常難學的(@紅薯 深以為然),但其實不盡然。這裡有 12 個很棒的資源可以讓你學習並精通正則表達式。RegexPlanet 可以讓你測試不同程式語言的正則表達式匹配效果。你可以存儲正則表達式,同時該工具也提供一些常用的表達式。
  • Python:正則表達式基本符號總結
    字符串是我們在編程的時候很常用的一種數據類型,檢查會在字符串裡面查找一些內容,對於比較簡單的查找,字符串裡面就有一些內置的方法可以處理,對於比較複雜的字符串查找,或者是有一些內容經常變化的字符串裡面查找,那麼字符串內置的查找方法已經不好使了,滿足不了我們的要求,這個時候就得用正則表達式了,正則表達式就是用來匹配一些比較複雜的字符串。
  • Python 正則表達式-函數用法分析
    Python正則表達式的主要作用是檢索、替換符合匹配規則的文本,什麼時候檢索,什麼時候替換,我們根據需求,選擇最合適的函數。【函數一】compile(pattern, flags=0)我們編寫的正則表達式 pattern,指定使用的模式 flags 默認為0 即不使用任何模式【函數二】 purge()這個函數的作用是清除緩存中的正則表達式【函數三】escape(pattern)如果需要操作的文本中含有正則的元字符時,需要將元字符加上反斜扛
  • 正則表達式:如何匹配一個或多個字符?
    我們來看一個例子:這裡使用的正則表達式是純文本,它將匹配原始文本裡的Ben。我們再來看一個例子,它使用了與剛才相同的原始文本和另外一個正則表達式:my也是靜態文本,它在原始文本裡找到了兩個匹配結果。有多個匹配結果絕大多數正則表達式引擎的默認行為是只返回第1個匹配結果。如上例,原始文本裡的第1個my通常是一個, 但第2個往往不是。
  • 給JAVA程式設計師的正則表達式一課
    正則基礎正則表達式(Regex,簡稱RE)是一種根據字符串集中的每個字符串的共同特徵來描述字符串集的方法。可用於搜索,編輯或處理文本和數據。簡單來說,正則表達式是幫助我們根據特定格式驗證或匹配字符串的方式。可以類比資料庫的SQL語言,sql是搜索數據,RE是搜索字符串。正則表達式和SQL語言是開發界的兩個偉大發明。
  • Python程式語言:如何運用正則表達式
    這篇文章,小編要和大家分享的知識是Python語言的正則表達式,以及自己學到的使用方法!學會正則表達式可以幫助我們抓取網絡信息,正則表達式又叫Re庫!這裡我們要了解什麼是正則表達式,正則表達式是用來簡潔表達一組字符串的表達式!
  • 新手上路:圖文解讀助你理解和使用正則表達式
    所以,就讓我們揭開正則表達式的神秘面紗!如果你理解正則表達式,它會突然變成一個超快速和強大的工具……但你首先需要理解它,老實說,我覺得新手可能會對它望而生畏!讓我們從基礎開始。正則表達式(regex)是什麼?它們的用途是什麼?
  • 正則表達式A - 方法及特殊字符用法
    .正則表達式的用法 正則表達式概念: 正則表達式是由普通字符及特殊字符組成的對字符串進行過濾的邏輯公式 正則表達式的創建方式: 1.字面量方式創建 (隱式創建): var reg = /正則表達式/gi;
  • 正則表達式的基礎知識和Python中的基本應用
    第八十一節:正則表達式正則表達式又叫「規則表達式」(Regular Expression),簡稱RE,是對字符串操作的一種邏輯公式,就是用事先定義好的一些特定字符、及這些特定字符的組合,組成一個「規則字符串」,這個「規則字符串」用來表達對字符串的一種過濾邏輯。
  • PHP正則表達式的快速學習方法
    此外,象JavaScript這種客戶端的腳本語言也提供了對正則表達式的支持。由此可見,正則表達式已經超出了某種語言或某個系統的局限,成為人們廣為接受的概念和功能。正則表達式可以讓用戶通過使用一系列的特殊字符構建匹配模式,然後把匹配模式與數據文件、程序輸入以及WEB頁面的表單輸入等目標對象進行比較,根據比較對象中是否包含匹配模式,執行相應的程序。
  • python正則表達式使用方法說明
    曾光紅/文 (同步發布豆瓜網)一、導入re庫python使用正則表達式要導入re庫。import re在re庫中。正則表達式通常被用來檢索查找、替換那些符合某個模式(規則)的文本。二、使用正則表達式步驟1、尋找規律;2、使用正則符號表示規律;3、提取信息,如果每一個字符都能匹配,則匹配成功;一旦有匹配不成功的字符則匹配失敗。
  • Python正則表達式由淺入深(二)
    在前兩篇連載文章中,我們學習了re模塊的match()、search()、findall()方法,以及學習了使用正則表達式中常用的元字符、限定符、選擇字符、中括號來搭配這些方法來靈活處理常見的數據匹配問題。這本篇文章分鐘,我們將會進一步學習正則表達式中其他符合,包括令初學者非常頭疼的分組問題。
  • 帶您一小時玩轉正則表達式
    在日常開發中我們經常會對用戶輸入的數據進行校驗、對字符串進行提取或者替換,這時候往往會使用正則來實現,那麼今天我給大家分享下正則表達式的一些知識。一、什麼是正則表達式正則表達式是一種描述字符串結果的語法規則,是一個特定的格式化模式,可以匹配、替換、截取匹配的字符串。
  • 正則表達式與神經網絡的深度融合
    與之對應的,基於符號主義的規則系統,如正則表達式(regular expression, RE),通常由人類專家基於領域知識構建,具備著良好的可解釋性,可用於沒有任何數據的冷啟動場景,並且可以通過規則的增刪和修改來快速應對目標任務的變化。因此,儘管神經網絡和深度學習如火中天,在工業界實際應用場景中,基於規則的方法仍然有著穩固的地位。
  • Python(2):正則表達式的常見符號與作用,每個都有示例
    承接上篇文章,本文將羅列出Python中正則表達式常用的符號,也叫做元字符,正是憑藉元字符正則表達式方才展現出強大的檢索功能和受人青睞的靈活性。常見的正則表達式特殊符號為了便於讀者保存,這裡把符號以表格圖片的形式總結給大家,並就常見的正則表達式給出簡單的示例供大家參考、學習。點號(.)-匹配除換行符\n外的任意單個字符示例:a.o—匹配字母a和o且二者中間為任意單字符的字符串,如axo,a!