億級日誌佇列回放效能測試初探

佇列通常是軟體設計模式中的基本元件。但是如果每秒接收到數百萬條訊息,改如何處理?如果多個消費者都需要能夠讀取所有訊息,又改如何處理?難道需要把所有訊息的資料都放在記憶體中嗎?這樣 JVM GC 又表現如何?

之前我寫過幾個流量回放模型:

基於時間戳的日誌回放引擎

2022-08-22

千萬級日誌回放引擎設計稿

2021-12-30

雖然方案 2 已經被更優秀的方案替代,但是思路相同,均是把日誌進行格式轉換之後存放(這一點跟 goreplay 略有相似),在千萬日誌級別,我是直接放在記憶體中。大約 1 千萬日誌的大小約為 1G,這樣來說對 JVM 記憶體壓力並不高,對於 GC 的影響也可以接受,目前的測試結果是 YoungGC 1次/3s,全程無 FullGC。

但是如果想要更近一步,實現更大規模的日誌回放,就不能採取這種方式,需要把日誌存在磁碟中,用的時候順序讀取,這個速度大概 80 萬/s。也算是滿足需求了。但是其中需要使用

java。lang。String#split(java。lang。String, int)

,又比較消耗效能。

這個時候接觸了Chronicle Queue,看了簡介,簡直爆炸,而且 API 簡單好用,效能又高。特別是支援 TB 級別檔案高效能、低延遲的讀寫。太符合我的需求了。後續我再根據實際情況進行實踐、測試、分享。

本文介紹如何使用 Chronicle Queue 建立巨大的持久佇列,同時保持可預測和一致的低延遲。

演示

在本文中,我維護一個保留日誌回放的日誌佇列,首先是一個日誌類,對原來的文章進行了一些Chronicle Queue化改造,保留了日誌時間戳、host等資訊。

private static class FunLog extends SelfDescribingMarshallable { String url String host int time FunLog() { } FunLog(String url, String host, int time) { this。url = url this。host = host this。time = time } }

官方提醒:欄位值為浮點型別時,切記注意有效位數長度問題。有興趣的可以看一看Java 序列化10倍效能最佳化對比測試關於Chronicle Queue序列化相關方案。

最初的方案

首先想到了探索使用 ConcurrentLinkedQueue 的方法:

public static void main(String[] args) { final Queue queue = new ConcurrentLinkedQueue<>(); for (long i = 0; i < 1e9; i++) { queue。add(new FunLog(Time。getDate(), index。getAndIncrement() + EMPTY, getMark())); } }

但是最終將會崩潰,有幾個原因:

ConcurrentLinkedQueue 將為新增到佇列中的每個元素建立一個包裝節點。這將使建立的物件數量增加一倍。

物件放置在 Java 堆上,導致堆記憶體壓力和垃圾收集問題,很可能導致卡死,只能強制結束程序。

無法從其他程序(即其他 JVM)讀取佇列。

一旦 JVM 終止,佇列的內容就會丟失,佇列不是持久化的。

其他各種標準 Java 類,均是不支援大型持久佇列。

Chronicle Queue

Chronicle Queue 是一個開源庫,旨在滿足上述要求。這是設定和使用它的一種方法:

static void main(String[] args) { String basePath = getLongFile(“chronicle”) ChronicleQueue queue = ChronicleQueue。singleBuilder(basePath)。build() def appender = queue。acquireAppender() int total = 1_0000_0000 def start = Time。getTimeStamp() total。times { def log = new FunLog(Time。getDate(), index。getAndIncrement() + EMPTY, getMark()) appender。writeDocument(log) } def end = Time。getTimeStamp() output(total / (end - start) * 1000) output(queue。lastIndex() - queue。firstIndex()) }

由於不可描述的原因,我本機的 IO 效能被降低了很多,但是在使用以上用例建立一個長度 1 億的佇列時,Chronicle Queue還是表現了非常好的效能,平均的 QPS 為 170 萬,佔用磁碟空間 4。5G,而且讀取速度也保持在 160 萬 QPS 量級。

讀取用例如下:

static void main(String[] args) { String basePath = getLongFile(“chronicle”) ChronicleQueue queue = ChronicleQueue。singleBuilder(basePath)。build() def tailer = queue。createTailer() def log = new FunLog() int total = 1_0000_0000 def start = Time。getTimeStamp() total。times { tailer。readDocument(log) } def end = Time。getTimeStamp() output(total / (end - start) * 1000) output(queue。lastIndex() - queue。firstIndex()) }

可以看出,我只用了一個

com。funtest。queue。Qt。FunLog

物件,這樣就進一步降低了 JVM 記憶體和 GC 的壓力。當然我們寫入佇列時,也可以使用這樣的方式,不過在我的設計中,直接讀取日誌檔案進行格式轉換,可以直接使用通用池化框架GenericObjectPool效能測試、通用池化框架GenericKeyedObjectPool效能測試,後面有時間再來分享。

下面是我兩次測試的 JVM 監控截圖,可見Chronicle Queue的強大:

億級日誌佇列回放效能測試初探

億級日誌佇列回放效能測試初探