正則表達式引發的慘痛代價

關注Java後端技術棧

回覆“面試”獲取最新資料

案例

在一次小型項目開發中,我遇到過這樣一個問題。爲了宣傳新品,我們開發了一個小程序,按照之前評估的訪問量,這次活動預計參與用戶量 30W+,TPS(每秒事務處理量)最高 3000 左右。

這個結果來自我對接口做的微基準性能測試。我習慣使用 ab 工具(通過 yum -y install httpd-tools 可以快速安裝)在另一臺機器上對 http 請求接口進行測試。

我可以通過設置 -n 請求數 /-c 併發用戶數來模擬線上的峯值請求,再通過 TPS、RT(每秒響應時間)以及每秒請求時間分佈情況這三個指標來衡量接口的性能,如下圖所示(圖中隱藏部分爲我的服務器地址):

就在做性能測試的時候,我發現有一個提交接口的 TPS 一直上不去,按理說這個業務非常簡單,存在性能瓶頸的可能性並不大。

我迅速使用了排除法查找問題。首先將方法裏面的業務代碼全部註釋,留一個空方法在這裏,再看性能如何。這種方式能夠很好地區分是框架性能問題,還是業務代碼性能問題。

我快速定位到了是業務代碼問題,就馬上逐一查看代碼查找原因。我將插入數據庫操作代碼加上之後,TPS 稍微下降了,但還是沒有找到原因。最後,就只剩下 Split() 方法操作了,果然,我將 Split() 方法加入之後,TPS 明顯下降了。

可是一個 Split() 方法爲什麼會影響到 TPS 呢?下面我們就來了解下正則表達式的相關內容,學完了答案也就出來了。以下是java.lang.String的源碼:

什麼是正則表達式?

很基礎,這裏帶你簡單回顧一下。

正則表達式是計算機科學的一個概念,很多語言都實現了它。正則表達式使用一些特定的元字符來檢索、匹配以及替換符合規則的字符串。

正則表達式語法

構造正則表達式語法的元字符:

  • 普通字符

  • 標準字符、

  • 限定字符(量詞)、

  • 定位字符(邊界字符)

詳情可見下圖:

正則表達式引擎

正則表達式是一個用正則符號寫出的公式,程序對這個公式進行語法分析,建立一個語法分析樹,再根據這個分析樹結合正則表達式的引擎生成執行程序(這個執行程序我們把它稱作狀態機,也叫狀態自動機),用於字符匹配。

而這裏的正則表達式引擎就是一套核心算法,用於建立狀態機。

目前實現正則表達式引擎的方式有兩種:DFA 自動機(Deterministic Final Automata 確定有限狀態自動機)和 NFA 自動機(Non deterministic Finite Automaton 非確定有限狀態自動機)。

對比來看,構造 DFA 自動機的代價遠大於 NFA 自動機,但 DFA 自動機的執行效率高於 NFA 自動機。

假設一個字符串的長度是 n,如果用 DFA 自動機作爲正則表達式引擎,則匹配的時間複雜度爲 O(n);如果用 NFA 自動機作爲正則表達式引擎,由於 NFA 自動機在匹配過程中存在大量的分支和回溯,假設 NFA 的狀態數爲 s,則該匹配算法的時間複雜度爲 O(ns)。

NFA 自動機的優勢是支持更多功能。例如,捕獲 group、環視、佔有優先量詞等高級功能。這些功能都是基於子表達式獨立進行匹配,因此在編程語言裏,使用的正則表達式庫都是基於 NFA 實現的。

那麼 NFA 自動機到底是怎麼進行匹配的呢?我以下面的字符和表達式來舉例說明。

text = "aabcab"
regex = "bc"

NFA 自動機會讀取正則表達式的每一個字符,拿去和目標字符串匹配,匹配成功就換正則表達式的下一個字符,反之就繼續和目標字符串的下一個字符進行匹配。分解一下過程。

首先,讀取正則表達式的第一個匹配符和字符串的第一個字符進行比較,b 對 a,不匹配;繼續換字符串的下一個字符,也是 a,不匹配;繼續換下一個,是 b,匹配。

然後,同理,讀取正則表達式的第二個匹配符和字符串的第四個字符進行比較,c 對 c,匹配;繼續讀取正則表達式的下一個字符,然而後面已經沒有可匹配的字符了,結束。

這就是 NFA 自動機的匹配過程,雖然在實際應用中,碰到的正則表達式都要比這複雜,但匹配方法是一樣的。

NFA 自動機的回溯

用 NFA 自動機實現的比較複雜的正則表達式,在匹配過程中經常會引起回溯問題。大量的回溯會長時間地佔用 CPU,從而帶來系統性能開銷。我來舉例說明。

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

這個例子,匹配目的比較簡單。匹配以 a 開頭,以 c 結尾,中間有 1-3 個 b 字符的字符串。NFA 自動機對其解析的過程是這樣的:

首先,讀取正則表達式第一個匹配符 a 和字符串第一個字符 a 進行比較,a 對 a,匹配。

然後,讀取正則表達式第二個匹配符 b{1,3} 和字符串的第二個字符 b 進行比較,匹配。但因爲 b{1,3} 表示 1-3 個 b 字符串,NFA 自動機又具有貪婪特性,所以此時不會繼續讀取正則表達式的下一個匹配符,而是依舊使用 b{1,3} 和字符串的第三個字符 b 進行比較,結果還是匹配。

接着繼續使用 b{1,3} 和字符串的第四個字符 c 進行比較,發現不匹配了,此時就會發生回溯,已經讀取的字符串第四個字符 c 將被吐出去,指針回到第三個字符 b 的位置。

那麼發生回溯以後,匹配過程怎麼繼續呢?程序會讀取正則表達式的下一個匹配符 c,和字符串中的第四個字符 c 進行比較,結果匹配,結束。

如何避免回溯問題?

既然回溯會給系統帶來性能開銷,那我們如何應對呢?如果你有仔細看上面那個案例的話,你會發現 NFA 自動機的貪婪特性就是導火索,這和正則表達式的匹配模式息息相關,一起來了解一下。

1. 貪婪模式(Greedy)

顧名思義,就是在數量匹配中,如果單獨使用 +、 ? 、* 或{min,max} 等量詞,正則表達式會匹配儘可能多的內容。

例如,上邊那個例子:

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

就是在貪婪模式下,NFA 自動機讀取了最大的匹配範圍,即匹配 3 個 b 字符。匹配發生了一次失敗,就引起了一次回溯。如果匹配結果是“abbbc”,就會匹配成功。

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

2. 懶惰模式(Reluctant)

在該模式下,正則表達式會儘可能少地重複匹配字符。如果匹配成功,它會繼續匹配剩餘的字符串。

例如,在上面例子的字符後面加一個“?”,就可以開啓懶惰模式。

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

匹配結果是“abc”,該模式下 NFA 自動機首先選擇最小的匹配範圍,即匹配 1 個 b 字符,因此就避免了回溯問題。

3. 獨佔模式(Possessive)

同貪婪模式一樣,獨佔模式一樣會最大限度地匹配更多內容;不同的是,在獨佔模式下,匹配失敗就會結束匹配,不會發生回溯問題。

還是上邊的例子,在字符後面加一個“+”,就可以開啓獨佔模式。

text = "abbc"
regex = "ab{1,3}+bc"

結果是不匹配,結束匹配,不會發生回溯問題。講到這裏,你應該非常清楚了,避免回溯的方法就是:使用懶惰模式和獨佔模式。

還有開頭那道“一個 split() 方法爲什麼會影響到 TPS”的存疑,你應該也清楚了吧?

我使用了 split() 方法提取域名,並檢查請求參數是否符合規定。split() 在匹配分組時遇到特殊字符產生了大量回溯,我當時是在正則表達式後加了一個需要匹配的字符和“+”,解決了這個問題。

\\?(([A-Za-z0-9-~_=%]++\\&{0,1})+)

正則表達式的優化

正則表達式帶來的性能問題,給我敲了個警鐘,在這裏我也希望分享給你一些心得。任何一個細節問題,都有可能導致性能問題,而這背後折射出來的是我們對這項技術的瞭解不夠透徹。所以我鼓勵你學習性能調優,要掌握方法論,學會透過現象看本質。下面我就總結幾種正則表達式的優化方法給你。

1. 少用貪婪模式,多用獨佔模式

貪婪模式會引起回溯問題,我們可以使用獨佔模式來避免回溯。前面詳解過了,這裏我就不再解釋了。

2. 減少分支選擇

分支選擇類型“(X|Y|Z)”的正則表達式會降低性能,我們在開發的時候要儘量減少使用。如果一定要用,我們可以通過以下幾種方式來優化:

首先,我們需要考慮選擇的順序,將比較常用的選擇項放在前面,使它們可以較快地被匹配;

其次,我們可以嘗試提取共用模式,例如,將“(abcd|abef)”替換爲“ab(cd|ef)”,後者匹配速度較快,因爲 NFA 自動機會嘗試匹配 ab,如果沒有找到,就不會再嘗試任何選項;

最後,如果是簡單的分支選擇類型,我們可以用三次 index 代替“(X|Y|Z)”,如果測試的話,你就會發現三次 index 的效率要比“(X|Y|Z)”高出一些。

3. 減少捕獲嵌套

在講這個方法之前,我先簡單介紹下什麼是捕獲組和非捕獲組。

捕獲組是指把正則表達式中,子表達式匹配的內容保存到以數字編號或顯式命名的數組中,方便後面引用。一般一個 () 就是一個捕獲組,捕獲組可以進行嵌套。

非捕獲組則是指參與匹配卻不進行分組編號的捕獲組,其表達式一般由(?:exp)組成。

在正則表達式中,每個捕獲組都有一個編號,編號 0 代表整個匹配到的內容。我們可以看下面的例子:

運行結果:

如果你並不需要獲取某一個分組內的文本,那麼就使用非捕獲分組。例如,使用“(?:X)”代替“(X)”,我們再看下面的例子:

運行結果:

綜上可知:減少不需要獲取的分組,可以提高正則表達式的性能。

總結

正則表達式雖然小,卻有着強大的匹配功能。我們經常用到它,比如,註冊頁面手機號或郵箱的校驗。

但很多時候,我們又會因爲它小而忽略它的使用規則,測試用例中又沒有覆蓋到一些特殊用例,不乏上線就中招的情況發生。

綜合我以往的經驗來看,如果使用正則表達式能使你的代碼簡潔方便,那麼在做好性能排查的前提下,可以去使用;如果不能,那麼正則表達式能不用就不用,以此避免造成更多的性能問題。
推薦閱讀往期好文,篇篇都有精彩~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章