記一次通過優化日誌解決高併發服務性能瓶頸問題

事故發現

服務在生產環境中,由於同一時間段請求量過大,導致服務響應速度急劇下降。甚至會出現拒絕服務的問題,第一時間想到是機器性能問題,無法滿足併發如此大的場景,需要進行擴容或者服務限流。經過擴容之後平穩了一個多月之後,又一次大量請求打進來的時候出現了此問題。這時才意識到開始從各個角度去排查問題。

事故排查過程

一個系統的吞吐量(承壓能力)與request對CPU的消耗、外部接口、IO等等緊密關聯。單個reqeust 對CPU消耗越高,外部系統接口、IO影響速度越慢,系統吞吐能力越低,反之越高。所以對於性能方面影響,需要排查的原因比較廣。

首先通過top排查機器情況,並未發現太多問題。然後又結合業務代碼、第三方接口請求和數據庫請求進行排查,依舊沒能找到突破點。

此時通過觀察日誌發現,日誌的打印間隔竟然有高達2秒的,而且比例還不少,這明顯是不正常的。

log4j採用buffer進行輸出,懷疑是否log4j中buffer容量配置問題。於是增加buffer大小,進行壓力測試,buffer增加到了8M,發現並沒有什麼實際的效果,於是開始懷疑同步性能低。

爲了驗證是否同步性能問題導致的,故將該服務的日誌改爲異步日誌,發現qps得到了數十倍的增長,採用異步之後,問題已經覺得基本解決了。但是回過頭來想,同步日誌到底是什麼問題導致性能會如此低下呢?還有就是其他的使用同步日誌的服務,爲什麼沒有出現這個問題?

同步日誌性能探究

對於服務線程響應變慢,我想到了用jstack直接去觀察服務的線程堆棧信息,發現大量線程的堆棧類似如下:

"pool-1-thread-190" prio=10 tid=0x00002b853809d800 nid=0xc01 waiting for monitor entry [0x00002b84b85d5000]
   java.lang.Thread.State: BLOCKED (on object monitor)
    at org.apache.log4j.Category.callAppenders(Category.java:204)
    - waiting to lock <0x00000007880579e8> (a org.apache.log4j.spi.RootLogger)
    at org.apache.log4j.Category.forcedLog(Category.java:391)
    at org.apache.log4j.Category.info(Category.java:666)
    at com.jlpay.commons.rpc.thrift.server.Dispatcher.invoke(Dispatcher.java:38)
    at com.jlpay.commons.rpc.thrift.server.RpcAdapterImpl.Invoke(RpcAdapterImpl.java:32)
    at com.jlpay.commons.rpc.thrift.server.RpcAdapter$Processor$Invoke.getResult(RpcAdapter.java:175)
    at com.jlpay.commons.rpc.thrift.server.RpcAdapter$Processor$Invoke.getResult(RpcAdapter.java:160)
    at org.apache.thrift.ProcessFunction.process(ProcessFunction.java:39)
    at org.apache.thrift.TBaseProcessor.process(TBaseProcessor.java:39)
    at org.apache.thrift.server.AbstractNonblockingServer$FrameBuffer.invoke(AbstractNonblockingServer.java:518)
    at org.apache.thrift.server.Invocation.run(Invocation.java:18)
    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1145)
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:615)
    at java.lang.Thread.run(Thread.java:724)

大量線程阻塞,等待某個鎖,但是這個鎖被以下線程持有:

"pool-1-thread-102" prio=10 tid=0x00002b8538053800 nid=0xba9 runnable [0x00002b84b2f71000]
   java.lang.Thread.State: RUNNABLE
    at java.text.DateFormat.format(DateFormat.java:336)
    at org.apache.log4j.helpers.PatternParser$DatePatternConverter.convert(PatternParser.java:443)
    at org.apache.log4j.helpers.PatternConverter.format(PatternConverter.java:65)
    at org.apache.log4j.PatternLayout.format(PatternLayout.java:506)
    at org.apache.log4j.WriterAppender.subAppend(WriterAppender.java:310)
    at org.apache.log4j.WriterAppender.append(WriterAppender.java:162)
    at org.apache.log4j.AppenderSkeleton.doAppend(AppenderSkeleton.java:251)
    - locked <0x0000000788057650> (a org.apache.log4j.ConsoleAppender)
    at org.apache.log4j.helpers.AppenderAttachableImpl.appendLoopOnAppenders(AppenderAttachableImpl.java:66)
    at org.apache.log4j.Category.callAppenders(Category.java:206)
    - locked <0x00000007880579e8> (a org.apache.log4j.spi.RootLogger)

恍然大悟,同步日誌的速度慢是因爲大量線程阻塞等待獲取鎖,再寫日誌。

簡單看了下logger輸出日誌的過程源碼,發現有很多邏輯都在加鎖的場景下處理的。高併發下,大量寫日誌請求,這個問題就會暴露出來。

而且寫日誌操作相對來說還是比較慢的,高併發下會導致請求的平均處理時間高於正常情況下的處理時間;但是處理時間也不會大幅度增加到引起客戶端請求超時的地步。因爲相比與純粹的cpu處理操作,寫日誌是一個慢操作,但是也不是肉眼能見的慢,寫完日誌的線程最終會釋放鎖,其他線程獲得鎖。

思考

還存在一個問題,爲什麼其他的同步日誌服務,在業務量和請求量相似的情況下,沒有暴露出該問題呢?對比了一下對應的日誌配置,發現主要在打印格式上的區別:


//出問題服務:
%d{yyyy/MM/dd HH:mm:ss.SSS}[traceId: %X{trace_id}] %t [%p] %C{1} (%F:%M:%L) %msg%n

//未出問題的服務
[%d{yy-MM-dd.HH:mm:ss.SSS}] [%thread]  [%-5p %-22c{0} -] %m%n

專門看了下log4j官網的信息(http://logging.apache.org/log4j/2.x/performance.html#asyncLoggingWithLocation

在這裏插入圖片描述
大概也就是說日誌格式使用這些參數的使用會導致性能的急劇下降
C or $class, %F or %file, %l or %location, %L or %line, %M or %method
大概下降30-100倍左右,這才找到真正的原因,原來同步和異步的性能影響是一部分,本身日誌格式的配置也是存在問題的。

較爲影響性能的日誌輸出參數

%C     - 調用者的類名(速度慢,不推薦使用);
%F     - 調用者的文件名(速度極慢,不推薦使用)
%l     - 調用者的函數名、文件名、行號(速度極其極其慢,不推薦使用)
%L     - 調用者的行號(速度極慢,不推薦使用)
%M     - 調用者的函數名(速度極慢,不推薦使用)

發現這幾個性能比較低下的參數,做的都是定位類的工作,都是LocationInfo類下的成員。
LocationInfo.java

public class LocationInfo implements java.io.Serializable {

  /**
     Caller's line number.
  */
  transient String lineNumber;
  /**
     Caller's file name.
  */
  transient String fileName;
  /**
     Caller's fully qualified class name.
  */
  transient String className;
  /**
     Caller's method name.
  */
  transient String methodName;
  /**
     All available caller information, in the format
     <code>fully.qualified.classname.of.caller.methodName(Filename.java:line)</code>
    */
  public String fullInfo;


//...
}

再通過LogEvent.getLocationInformation()的源碼發現,log4j爲了拿到函數名稱和行號信息,利用了異常機制,首先拋出一個異常,之後捕獲異常並打印出異常信息的堆棧內容,再從堆棧內容中解析出行號。( jvm對異常的處理損耗是不可忽略的,這是性能慢的原因之一

public class LogEvent implements java.io.Serializable {
    
    public LocationInfo getLocationInformation() {
    if(locationInfo == null) {
      locationInfo = new LocationInfo(new Throwable(), fqnOfCategoryClass);
    }
    return locationInfo;
  }

}

再看看LocationInfo對應的構造方法部分,需要通過同步鎖去獲取異常棧內的內容,可見這是操作是有多消耗性能:


 public LocationInfo(Throwable t, String fqnOfCallingClass) {
      
      //...

      synchronized(sw) {
        t.printStackTrace(pw);
        s = sw.toString();
        sw.getBuffer().setLength(0);
     }
      //System.out.println("s is ["+s+"].");
      int ibegin, iend;

      
      ibegin = s.lastIndexOf(fqnOfCallingClass);

      //...

問題基本明瞭了。

再次壓測

將服務中日誌格式中的(%F:%M:%L)剔除,再進行壓測。QPS上升至2000+,果然不踩坑就很難注意到這中小配置,有這麼大的性能影響,算是給自己上了一課。

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