淺析JAVA日誌中的幾則性能實踐與原理解釋

前言

程序記錄日誌的過程,就是將需要記錄的內容寫入到磁盤文件中的過程。與生活的物流場景類似,好比是一車貨物通過一套運輸體系運送至目的地的過程,然而在這套物流體系中,我們往往不需要自己完成整套打包、上車、運輸、卸貨等全套流程,只需要將包打好之後交由專業的物流公司即可。對於我們今天所要描述的日誌場景而言,日誌內容是需要運送的貨物,日誌框架就是物流公司,而目的地就是磁盤上的文件(或其他日誌收集服務器)。在 Java 的語言體系中,針對日誌處理很早有了很好的日誌框架 log4j、 logback以及 jul(Java Util Logging) 等,這些框架替我們隱藏了日誌記錄的技術細節,程序員只需要使用 Logger這一個工具類,即可高效的完成業務日誌的記錄,如下面代碼所示:

Logger logger = LogFactory.getLogger("PoweredByEDAS");

String product = "EDAS";
logger.info("This is powered by product: " + product);

這一篇文章是想通過幾個技術點來說明日誌記錄過程中的性能實踐,計算機領域的性能往往都遵循着冰山法則,即你能看得見的、程序員能感知的只是其中的一小部分,還有大量的細節隱藏在冰山之下,如下圖所示:

簡單針對上圖做一個說明:當程序員在業務代碼中通過 http://logger.info的方式對日誌內容進行輸出後,日誌的目的地是磁盤,而在最終將日誌內容刷入磁盤之前,它需要經過日誌框架、JVM、Linux 文件系統的層層處理。這就好比在物流運輸過程中,期間有多個經停站點,在某些站點可能還需要進行換乘。運輸中用到的整個交通體系(車、司機、道路等)就是我們圖中的所畫的“日誌通道”。根據這個圖,也給出了我們進行系統性優化的思路,即:避免通道擁塞、減少看得見的業務開銷、躲開看不見的系統開銷。

避免通道擁塞

交通體系中,避免通道擁塞的思路主要是兩個:1) 儘量控制運輸流量 ,2) 優化整個交通運輸體系(修更多的道路,增加更多的信息化技術等等)。在日誌輸出場景中,程序員能控制的主要是業務日誌的內容和日誌策略的配置,還有相當一部分能力依賴底層基礎設施的性能。針對程序員能控制的,我們儘量優化;而對於我們無法控制的,我們儘量解耦。這是我們這一章節闡述的主要思路。

減少業務輸出內容

直觀來說,日誌內容越大,對整個系統會造成一些更大的壓力。爲了量化差別,我們進行了下面的測試對比:第一組,我們僅僅將不同日誌大小寫入內存。第二組中,我們將不同的日誌大小寫入磁盤文件。寫入內存我們使用了 Log4j 中的 CountingNoOp Appender ,他的作用是在進行日誌的正式輸出時,僅僅對輸出的日誌做計數統計,這樣的一種測試方式,從某種程度上能衡量出來單純日誌框架的處理效率。

<Appenders>
  <CountingNoOp name="NoOp">
  </CountingNoOp>
</Appenders>

在下圖所呈現的測試結果中,我們可以看到,即使不進行刷盤的動作,寫入的吞吐量隨着內容的大小而明顯下降

在另外一組的測試中,我們再將不同日誌大小的內容寫入文件,再做類似的對比,從實驗結果來看我們能得出兩個簡單的結論:

  1. 與只寫入內存的吞吐量相比,二者的吞吐量隨着日誌內容的變大差距越來越大。
  2. 同時隨着輸出內容的數量變多,在磁盤場景下呈現明顯的下滑趨勢,隨着內容的增多,呈現逐漸趨平的趨勢。

具體結果如下圖:

上圖中的測試數據是我們從一個 IO 設備提供了 400MB/s 左右的速度中獲得;在 IO 沒有被用滿的情況下,增加寫入內容尚能提升整體的寫入量,但是一旦達到設備的瓶頸。繼續寫入將造成寫入的堆積。不過兩組數據均能得出相同的結論,即:更大的日誌文本內容,只會導致更差的處理時間。類比到生活中運輸的場景,如果我們要運輸的貨物非常大的時候,那麼就需要我們的貨車具備更大空間的、更強的動力,而且運輸速度也會更慢。同時過重的貨物會有動力失調,輪胎爆胎等風險。爲了提高運輸效率和健康度,就應該儘量避免運超大型的貨物。從我們的日誌場景出發,過大的日誌會同樣會在在 CPU、內存、IO 等資源上均會對系統產生不同程度的衝擊。

減少系統輸出內容

壓縮Logger輸出:

在獲取一個 Logger 進行日誌輸出時,大多數程序員的編程習慣是直接使用 Class 對象進行獲取,參見如下的代碼片段:

package com.alibabacloud.edas.demo;

public class PoweredByEdas {
  private static Logger logger = LogFactory.getLogger(
        ProweredByEdas.class);

    public void execute() {
        String product = "EDAS"; 
        logger.info("Prowered by " + product);
    }
}

而在進行日誌輸出時,如果 logger 是 Class 將默認輸出對應它所對應的 FQCN,即:com.alibabacloud.edas.demo.PoweredByEdas

其實我們可以使用 logger 的 re-format 方式,將其進行壓縮,比如,在 logback 中使用 %logger{5} 或 %c{5} 精簡後,logger 在輸出時將壓縮成爲c.a.e.d.PoweredByEdas,平均每條日誌將減少 19 字符。

// 使用默認 [%logger] 進行輸出
2023-11-11 16:14:36.790 INFO [com.alibabacloud.edas.demo.PoweredByEdas] Prowered by EDAS 

// 使用默認 [%logger{5}] 進行輸出
2023-11-11 16:24:44.879 INFO [c.a.e.d.PoweredByEdas] Prowered by EDAS
不過這種日誌處理由於做字符串的拆分和截取,會額外耗費一定的 CPU,如果是計算密集型的業務(CPU 佔用本來就很高的情況下)則不建議生產使用。

壓縮異常輸出

異常信息的記錄,是我們的系統在線上出現問題或者故障時的一個重要的排查依據,他的全面與否很多時候直接影響了問題解決的效率,然而過多的異常信息記錄,往往容易把真正有用的信息進行覆蓋。而當我們將系統中拋出的異常拆開來看的時候,不難看出通篇的堆棧信息中,能對自己排查問題產生幫助的信息,往往只有幾行,如下圖所示:

根據筆者自己的經驗,在將異常直接進行打印輸出之前,我們可以嘗試將重新遍歷異常堆棧,將信息重新整理之後再輸出,具體實踐可參考以下幾點:

  1. 保留棧頂的幾幀:棧頂往往包含的是最爲關鍵的信息,是案發的第一現場,他的信息完整性顯得尤爲重要。
  2. 保留業務棧幀:在 Java 語言中,大家會遵循給業務代碼一個單獨包名的實踐,此時我們可以利用包名進行棧幀的過濾和保留操作。
  3. 抽樣打印全棧信息:這裏可以根據具體的業務情況而定,需要將全棧信息進行隨機輸出的原因是有的時候可能會追蹤到一些系統級別的 BUG 或想了解他的一些機制。全棧信息的輸出有助於問題的追根溯源。

壓縮異常不僅能帶來性能上的提升,而且還能節省大量的存儲空間,這裏感興趣的同學可以進一步查閱之前的一篇文章:《十行代碼讓日誌存儲降低80%

解耦通道依賴

如果說上面提到的減少內容是把承載的貨物減輕的話,那麼針對通道的優化思路就是優化交通運輸的整體效率;站在應用的角度上思考,通道的優化,和系統運行時的狀態、以及所使用框架的實現方式有着莫大的關係,言下之意就是有着很大一部分的內容不受編程人員的控制。對於不受控制的部分,我們的思路是最大限度解耦底層的實現,具體思路是兩個:

  1. 在進行日誌內容寫入時,通過異步緩衝區解耦業務代碼到通道(從日誌框架 到 JVM 到 操作系統 FileSystem)的瓶頸。
  2. 在進行文件內容落盤時,通過大文件切分成小文件的方式,儘量解耦硬件級別的瓶頸。

如下圖所示:

使用異步日誌

由於異步的方式是業務代碼先把日誌內容放入一個緩衝區,再由專門的線程異步刷入到文件系統中,這樣可以最大限度確保業務的吞吐不受底層框架的影響。但是是否所有日誌都適合異步的策略這個需要根據業務場景進行區分:常規業務日誌如遇到日誌丟棄的場景可能對於業務影響不會太大,但是有的場景是必須做到嚴格數據一致,比如 RocketMQ 的 Commit Log,因爲一條日誌代表着一條完整的業務消息的投遞情況,他必須和業務狀態的返回做到嚴格一致,這種情況異步方式就不是一個好的選擇;在 Log4j 中,他也提供了兩種方式,一種是細粒度的 Appender 級別的配置,一種是全局的配置;下圖展示的是三種策略對於性能吞吐的影響:

簡單解讀上圖:首先,同步寫入的性能在所有場景中都是最低的,這個和我們常規的認知是一致的;而AsyncLogger (藍色柱狀圖) 的 TPS 卻能隨着 Worker 的增加而增加,但 AsyncAppender只能持平 。這一點和我們常規的認知有些出入,帶着這個疑慮,我們下面稍微深入的探究一下。

1.Log4j2 AsyncAppender

下面是 AsyncAppender 的配置方式,框架提供了更多的參數來做更多精細化的控制,核心參數解讀如下:

    • shutdownTimeout:等待worker線程處理日誌的時間,默認爲0,表示無限等待;
    • bufferSize:緩衝隊列的大小,默認爲1024;
    • blocking:是否採用阻塞式,默認爲true。當隊列滿時,會同步等待。
<Async name="Async">
  <AppenderRef ref="RollingRandomAccessFile"/>
  <shutdownTimeout>500</shutdownTimeout>
  <bufferSize>1024</bufferSize>
  <blocking>true</blocking>
</Async>

簡單解讀其設計意圖:框架會先提供一個系統緩衝區來緩存即將寫入的內容,但是當緩衝區滿時,框架還提供了兩種策略進行選擇,第一種是直接丟棄,第二種是進行等待,但是具體等待多長時間也依然可以配置。

2.Log4j2 AsyncLogger

與 AsyncAppender 相比,其使用上也更爲簡單,只需要通過設置啓動參數-Dlog4j2.contextSelector=org.apache.logging.log4j.core.async.AsyncLoggerContextSelector便可全局啓用異步日誌。同樣,AsyncLogger 也會有緩衝區的大小的設置,默認是 256K 。當超出緩衝區大小時,可以使用丟棄策略。可以通過配置參數-Dlog4j2.asyncQueueFullPolicy=discard和-Dlog4j2.discardThreshold=INFO 來明確指定丟棄哪一級別的日誌。值得一提的是,AsyncLogger 使用了 LMAX Disruptor的高性能隊列,這是爲什麼 AsyncLogger 相比於 AsyncAppender 在單線程吞吐和多線程併發方面具有更好的性能的關鍵。LMAX Disruptor爲什麼相比阻塞隊列性能能隨線程數擴展,主要有三點:首先,解決了僞共享問題;其次,無鎖的隊列設計,只需CAS的開銷;最後,需要明確的,對比的是該日誌場景下的隊列性能。

<dependency>
   <groupId>com.lmax</groupId>
   <artifactId>disruptor</artifactId>
</dependency>

與 Log4j 一樣,Logback 也有着類似的策略,這裏我們就不再贅述它的具體使用方式,下面的表格中,我們總結了在各種策略下的優缺點,希望在大家進行選型時能有所幫助:

  log4j2 AsyncLogger log4j2 Async Appender logback AsyncAppender 同步日誌
性能 最優 較好 較好
易用 易用,只需jvm參數-Dlog4j2.contextSelector=org.apache.logging.log4j.core.async.AsyncLoggerContextSelector即可啓用 配置較爲複雜,需要配置多個AsyncAppender 配置較爲複雜,需要配置多個AsyncAppender 易用
內存壓力 較大 較大 較大 較小
是否會丟失日誌 進程退出時 或 IO 跟不上 進程退出時 或 IO 跟不上 進程退出時 或 IO跟不上
兼容性 Appender中ThreadLocal相關的pattern、filter異常 Appender中ThreadLocal相關的pattern、filter異常 Appender中ThreadLocal相關的pattern、filter異常
是否受磁盤滿或IO受限影響 丟棄時,不受影響 丟棄時,不受影響 丟棄時,不受影響 影響
配置注意點1 XxFileAppender指定immediateFlush爲false XxFileAppender指定immediateFlush爲false XxFileAppender指定immediateFlush爲false XxFileAppender指定immediateFlush爲true
配置注意點2 設置丟棄策略-Dlog4j2.asyncQueueFullPolicy=discard和-Dlog4j2.discardThreshold=INFO 設置丟棄策略-Dlog4j2.asyncQueueFullPolicy=discard和-Dlog4j2.discardThreshold=INFO 設置丟棄策略:neverBlock爲true

使用滾動日誌

對於操作系統而言,小文件的處理相比於大文件,從系統資源角度,大文件往往意味着更多的內存佔用,更多的 I/O 操作、更苛刻的磁盤空間、更多的總線帶寬等等,當任意方資源出現瓶頸時,還會帶來更多的 CPU 使用進而造成系統更高的 Load。而小文件除了在上述資源角度帶來更好的優化空間之外,還在運維管理上提供了更多便利,如:使用更小的磁盤、儘早歸檔、儘早清理磁盤空間等等。在生產實踐中,適當使用滾動日誌,是一項極爲可觀的實踐,下面的例子是在 log4j 中的的配置片段,配置內容爲在時間上以天滾動,大小上按 100MB 滾動,最多保留 5 個文件的策略來對日誌文件進行滾動:

<RollingRandomAccessFile name="RollingRandomAccessFile" fileName="logs/app.log"
                                 filePattern="powered_by/edas-%d{MM-dd-yyyy}-%i.log">
    <PatternLayout>
        <Pattern>${commonPattern}</Pattern>
    </PatternLayout>
    <Policies>
        <TimeBasedTriggeringPolicy />
        <SizeBasedTriggeringPolicy size="100 MB"/>
    </Policies>
    <DefaultRolloverStrategy>
        <max>5</max>
    </DefaultRolloverStrategy>
</RollingRandomAccessFile>

減少看得見的業務開銷

看得見的業務開銷,就好比是在運輸途中的貨物,我們期望打包裝車的貨物是最終都會使用到的;即確定能上車才進行打包,確定最終要運輸才裝車。在日誌輸出的場景中,我們也分爲兩部分來闡述:

確定輸出才執行(避免構建複雜日誌參數)

在一些需要記錄詳細日誌內容的場景中,往往需要根據上下文中的某些參數再進行全量信息的獲取(如:查詢數據庫),這樣的動作暗含這一次開銷很大的調用開銷,這個時候我們推薦使用"logger.isXXEnabled()"來進行控制。既確定對應的日誌 Level 滿足所需纔開啓對應的查詢,參考下面的代碼所呈現的方式:

// 不推薦
log.debug("Powered by {}", getProductInfoByCode("EDAS"));

// 推薦
if (log.isDebugEnabled()) {
    log.debug("Powered by {}", getProductInfoByCode("EDAS"));
}

上面的邏輯雖然簡單,原理也簡單易懂,但是我們很多的客戶因爲這樣的代碼太多而帶來的性能退化案例不在少數,一個很典型的例子就是JSON序列化大的對象,究其原因代碼往往是在日常迭代中對於工程實施沒有規範,Code Review 流程的缺失而導致惡化。

確定輸出才拼接(使用參數佔位符)

與上面的 Case 類似,這個實踐也簡單有效。使用參數佔位符方式,有兩個好處。首先,它更容易編寫,對於記錄內容的句子完整性和可讀性上相比直接拼接字符串會友好很多;其次,由於它生成內容延遲的特性,可以保證在需要真實輸出時,纔對內容進行填充,這樣無形之中就節省了很多的開銷。代碼樣例如下:

String product = "EDAS";
//推薦
log.debug("Powered by {}", product);

//不推薦
log.debug("Powered by " + product);

不過可能有的同學心中會有一個疑慮:如果日誌級別爲 “DEBUG”,他帶來的性能開銷難道不是一樣(或更差)的嗎?帶着這個疑慮,我們使用 log4j 這個框架針對性的做了一個測試,測試效果如下圖所示:

上圖的測試結果,能得出以下兩個結論:1)在輸出字符較短時,字符串拼接比佔位符快,因佔位符方式需要執行佔位符掃描替換過程。2)但是隨着輸出字符越來越大,佔位符反過來比字符串拼接更快,而且越長的字符串快的越多。原因是針對長字符的輸出,日誌框架會有針對性的優化。在 log4j2 中,它使用 ThreadLocal 緩存並複用了StringBuilder 對象,無需每次都爲大的 StringBuilder 構建一個大對象。而字符串拼接則每次都創建新的StringBuilder 對象。

躲開看不見的系統開銷

繼續類比到貨物運輸的場景,看不見的系統開銷,就好比是整車中的資產折舊,道路狀況與司機駕駛習慣造成的綜合油耗。在計算機軟件中,我們常說的系統開銷爲主要資源的開銷(計算、內存、磁盤、網絡等),在這篇文章中,我們主要從內存與計算兩個角度闡述:

避免多餘的內存資源(Garbage Free)

"Garbage Free" 也叫做 "No GC",即不產生 GC;這是 log4j2 中新引入的一項內存優化技術,設計目標是減少對垃圾回收(GC)的壓力,他的實現原理比較簡單:通過重複利用對象來避免不必要的對象創建。實現方式包括將需要重複利用的對象放置於線程的 ThreadLocal 中,或者重複利用 ByteBuffer 來避免創建不必要的字符串對象。通過這個兩個技術手段避免 GC 的開銷後,它能夠顯著降低延遲。官方提供的性能測試結果對比如下:

需要注意的是,傳統的 J2EE Web 應用程序的場景中,會有熱加載的訴求,由於 Garbage Free 會緩存很多大的 StringBuilder 在 ThreadLocal 中,這在程序熱加載時可能會造成潛在的內存泄漏。因此當檢測到是 J2EE Web 應用程序時,log4j2 會默認禁用這項技術。如需強制開啓,可在啓動參數中加入 -Dlog4j2.enable.threadlocals=true -Dlog4j2.enable.direct.encoders=true。

避免多餘的計算資源(避免元信息打印)

日誌輸出時的元數據信息打印是指在進行內容輸出時,將程序運行時的與相關代碼信息進行輸出,這些內容包括:類名稱、文件名、方法名、行號等。以獲取行號爲例,下圖展示了在不同的日誌框架下使用行號輸出與不使用的性能差異。圖中很清晰的展示了幾乎所有的框架在進行行號輸出時性能的急劇下降:

我們的疑問是:Why?以 Log4j2 爲例,在進行 Location 計算時,是通過構建一個 Throwable 的方式拿到堆棧之後,然後再反向尋找與 Logger 同名的類所在的棧幀,再進行 Location 的獲取。這個過程光聽聽是不是就好感人?感興趣的同學可以查閱對應的代碼,如下:

   public StackTraceElement calcLocation(final String fqcnOfLogger) {
        if (fqcnOfLogger == null) {
            return null;
        }
        // LOG4J2-1029 new Throwable().getStackTrace is faster than Thread.currentThread().getStackTrace().
        final StackTraceElement[] stackTrace = new Throwable().getStackTrace();
        boolean found = false;
        for (int i = 0; i < stackTrace.length; i++) {
            final String className = stackTrace[i].getClassName();
            if (fqcnOfLogger.equals(className)) {

                found = true;
                continue;
            }
            if (found && !fqcnOfLogger.equals(className)) {
                return stackTrace[i];
            }
        }
        return null;
    }

總結

本文是從 EDAS 團隊在客戶服務的過程中將日誌配置相關的工單答疑整理輸出,嘗試給出幾條 JAVA 日誌的經驗實踐。受限於筆者自身的知識面,可能無法一一枚舉出所有的有性能影響的因素,如果您有其他額外的補充,歡迎留言與我們交流。大家也可以加入釘羣 “雲上微服務應用管理最佳實踐 - EDAS(一):21958624” 與我們溝通。另外EDAS也推出了運行時調整日誌配置的能力,歡迎使用。

參考鏈接

Garbage Free: https://logging.apache.org/log4j/2.x/manual/garbagefree.html

Log4j 配置:https://logging.apache.org/log4j/2.x/manual/configuration.html

Logback 配置:https://logback.qos.ch/manual/configuration.html

EDAS 運行時日誌動態調整:https://help.aliyun.com/zh/edas/user-guide/dynamic-log-configuration-2261535

十行代碼讓日誌存儲降低80%:https://mp.weixin.qq.com/s/MIBHh5NO0GvWBOVJ_Jzn2w

作者:驕龍、孤弋

原文鏈接

本文爲阿里雲原創內容,未經允許不得轉載。

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