如何實現百萬TPS?詳解JMQ4的儲存設計

京東發表於2019-01-19

JMQ是京東中介軟體團隊自研的訊息中介軟體,誕生於2014年,服務於京東近萬個應用。2018年11.11大促期間的峰值流量超過5000億條訊息。

2018年,JMQ完成了第四次大版本的迭代,在效能上有了極大提升,單個Broker節點的寫入效能超過100萬TPS。

效能

在相同的硬體環境下,我們選取了2個典型的場景,分別對JMQ4、JMQ2和Kafka的進行了訊息生產對比效能測試,測試結果如下圖。

如何實現百萬TPS?詳解JMQ4的儲存設計

單條同步刷盤場景

單條同步刷盤是業務最長使用的場景:一個微服務由多個節點提供相同的服務組成微服務叢集,每個微服務節點接收一個請求後進行業務處理後傳送一條訊息,確認訊息成功傳送後,返回響應。

設定如下:

  • Broker設定為資料寫入磁碟後返回傳送成功確認;

  • Producer每次傳送一條訊息,收到傳送成功確認後再傳送下一條訊息;

在這種場景下,JMQ4的寫入速度約為每秒23萬條,相比上一代JMQ效能提升了約2倍;Kafka在相同場景下測得的寫入速度大約為每秒9.2萬條,JMQ4的效能更好。

批次非同步刷盤場景

批次非同步刷盤場景主要測試訊息中介軟體的極限寫入效能。

設定如下:

  • Broker設定為訊息批次非同步寫入磁碟,無需返回傳送成功確認;

  • Producer設定為批次非同步傳送;

批次非同步場景下,JMQ的寫入速度達到了每秒103.7萬條,效能是上一代JMQ的10倍;Kafka在相同場景下測得的寫入速度大約為每秒114萬條,效能最好。


儲存設計

JMQ4在儲存結構設計繼承自上一代JMQ,參考了Kafka,並做了一些改進。

JMQ2

我們先來看一看JMQ2的儲存結構:

如何實現百萬TPS?詳解JMQ4的儲存設計

JMQ2的儲存包括一組訊息檔案(Journal Files)用於存放訊息,每個Topic包含多個佇列檔案(Queue Files),存放訊息的索引。

訊息寫入時,所有Topic的訊息按照收到訊息的自然順序依次追加寫入訊息檔案中,然後非同步建立索引並寫入對應的佇列檔案中。

這種所有Topic共享一個訊息檔案的設計,最大限度的利用了"磁碟在批次順序寫入時具有最佳效能"的特性。並且單個Broker上可以支援大量的Topic和Parition/Queue,隨著Topic增多沒有明顯的效能下降。在京東,JMQ2的單個節點支撐了超過1000個Topic。

侷限性是靈活性欠佳,很難做到以Topic維度進行資料的複製、遷移和刪除。

Kafka

下圖是Kafka的儲存設計:

如何實現百萬TPS?詳解JMQ4的儲存設計

Kafka的儲存以Partition為單位,每個Partition包含一組訊息檔案(Log Files)和一組索引檔案(Index Files),並且訊息檔案和索引檔案一一對應。

這種設計的優勢是在批次寫入時具備較好的效能,預設配置下,Kafka收到訊息並不立即寫入磁碟而是滿足一定條件後再批次刷盤。以分割槽為儲存單元,在資料複製、遷移上更加靈活。

這種設計的問題在於,在大規模微服務叢集和IOT場景下,單個Topic需要支援海量的Producer和Consumer併發讀寫,勢必要有和Consumer數量相當的Parition。隨著Partition的數量增多,寫入時需要頻繁的在多個訊息檔案之間切換,效能會顯著下降。

JMQ4

JMQ4採用了相對摺中的儲存設計,兼顧了效能和靈活性。

如何實現百萬TPS?詳解JMQ4的儲存設計

JMQ4儲存的基本單元是Topic。在同一個Broker上,每個Topic對應一組訊息檔案(Log Files),順序存放這個Topic的訊息。與Kafka類似,每個Topic包含若干Partition,每個Partition對應一組索引檔案(Index Files),索引中存放訊息在訊息檔案中的位置和訊息長度。

訊息寫入時,收到的訊息按照對應的Topic寫入依次追加寫入訊息檔案中,然後非同步建立索引並寫入對應Partition的索引檔案中。

以Topic為基本儲存單元的設計,在兼顧靈活性的同時,具有較好的效能,並且單個Topic可以支援更多的併發。

索引設計

在索引的設計上,Kafka採用稀疏索引的。查詢訊息時,首先根據檔名找到所在的索引檔案,然後二分法遍歷索引檔案裡找到離目標訊息最近的索引,再順序遍歷訊息檔案找到目標訊息。一次定址的時間複雜度為O(log2n)+O(m),其中n為索引檔案中的索引個數,m為索引的稀疏程度。可以看到,定址過程還是需要一定時間。一旦找到訊息後位置後,就可以批次順序讀取,不必每條訊息都要進行一次定址。

JMQ採用定長稠密索引設計,每個索引固定長度。定長設計的好處是,直接根據索引序號就可以計算出索引在檔案中的位置:

索引位置 = 索引序號 * 索引長度

這樣,訊息的查詢過程就比較簡單了,首先計算出索引所在的位置,直接讀取索引,然後根據索引中記錄的訊息位置讀取訊息。

這兩種設計各自擅長的場景不同,無所謂優劣。Kafka更加適合批次消費,JMQ更適合單條資料的消費。


高效能IO

JMQ使用Java作為開發語言。Java提供了非常豐富的IO API和資料讀寫方法,不同的API在不同的場景的效能差異非常大,選擇適合JMQ資料讀寫方法就顯得非常重要。通常來說,使用記憶體對映檔案(MappedByteBuffer/ Memory Mapped File)是讀寫大檔案效能最佳的方案。上一代JMQ使用的就是這種方法。

JMQ4的儲存寫入資料採用了一種更直接的方法:使用DirectBuffer作為快取,資料先寫入DirectBuffer,再非同步透過FileChannel寫入到檔案中。這種方式對於大檔案的追加寫入的效能要明顯優於記憶體對映檔案。Stack Overflow上的一個帖子:Performance of MappedByteBuffer vs ByteBuffer給出的效能對比效能測試如下圖:

如何實現百萬TPS?詳解JMQ4的儲存設計

可以看出DirectBuffer的效能優勢非常明顯,我們實測的結果也驗證了這個結論。

為什麼使用DirectBuffer的效能更快?我們分析了這兩種方法的寫入過程的底層實現:

如何實現百萬TPS?詳解JMQ4的儲存設計

MappedByteBuffer方式寫入過程是,首先將資料複製到OS的PageCache中,然後OS再將資料寫入檔案中。除非使用者呼叫MappedByteBuffer.force()方法強制刷盤,否則OS自己決定什麼時候將PageCache中的資料Write back回磁碟檔案。寫入過程包含一次記憶體資料複製和一次磁碟寫入。

DirectBuffer方式寫入的過程是,首先將資料複製到堆外的DirectBuffer中,然後再將資料批次寫入檔案中,但是OS處理寫入檔案的過程是先將資料複製到PageCache中,然後再Write back到檔案中。寫入過程包含二次記憶體資料複製和一次磁碟寫入。

可以看到,實際上DirectBuffer方式相比MappedByteBuffer方式多了一次記憶體複製,為什麼反而效能更好呢?

我們分析幾點可能的原因:

併發寫入緩解了DirectBuffer記憶體複製的效能損耗

首先需要注意到,寫入的過程並不是序列執行的。MappedByteBuffer方式中,寫入PageCache過程在JVM的執行緒中執行,PageCache寫入檔案的過程在OS的pdflush執行緒中執行。

類似的,DirectBuffer方式中,三次複製分別在JVM的write執行緒、flush執行緒和OS的pdflush執行緒中執行。

總體的寫入效能取決於速度最慢的那個執行緒,考慮到磁碟與記憶體的讀寫效能的巨大差距,OS Write Back刷盤的過程是整個流程的效能瓶頸。因此,多一次併發的記憶體複製對總體效能不一定有影響。

MappedByteBuffer的記憶體對映開銷

在寫入每個檔案的開始階段,MappedByteBuffer多出一個無法並行的記憶體對映過程:在呼叫FileChannel.map()方法建立MappedByteBuffer時,實際上是呼叫了OS核心的mmap()系統呼叫,OS會在PageCache的頁表中查詢對應的Page,如果不存在則建立Page並加入到頁表中。每個Page的大小是4K,對映一段較大的記憶體時,需要進行多個頁的查詢或建立過程,這一過程需要消耗一定的時間。

而DirectBuffer方式中,對應的過程就是簡單的在記憶體中申請一塊DirectBuffer,並且在連續寫入多個檔案時,這個DirectBuffer是可以反覆重用的,同樣的過程幾乎沒有耗時。

MappedByteBuffer的Page Fault開銷

MappedByteBuffer在建立時,只是做了檔案內塊的地址和記憶體地址的對映,並沒有真正將檔案的資料複製到記憶體中。當程式第一次訪問(注意:讀和寫都是“訪問”)記憶體中的Page時,會產生產生Page Fault中斷,OS在中斷中將該頁對應磁碟中的資料複製到記憶體中。在對檔案進行追加寫入的情況下,這一無法避免的過程是完全沒有必要,反而增加了寫入的耗時。

批次大小

另外一個影響寫入效能的因素是每批寫入資料的大小。DirectBuffer方式由於多了一層可以自行控制的快取層,應用程式可以自行控制選擇合適的批次大小,以達到最佳的效能。相比之下,使用MappedByteBuffer方式並不太容易控制進行批次控制,實測下來OS的批次控制策略並不能達到相對滿意的批次效能。一個可能的方式是調整OS相關的核心引數以達到滿意效能,但面對大規模叢集和容器化的趨勢,顯然這種方式並不可取。


快取

JMQ4快取的設計思路是儘可能的充分利用作業系統記憶體,減少磁碟的IO,以提升總體讀寫效能。

JMQ4的快取頁以檔案為單位對映,每個訊息檔案對應記憶體中的一個快取頁。

如何實現百萬TPS?詳解JMQ4的儲存設計

考慮到訊息的檔案讀寫的一些特性:

  1. 追加寫入和不可變性:訊息只在尾部追加寫入,已寫入的訊息具有不可變性;

  2. 順序讀取:絕大部分對訊息檔案的讀訪問都是順序讀取;

  3. 熱尾效應:大部分的訊息生產後立即就會被消費,因此絕大部分的讀訪問都發生在儲存的尾部。

JMQ4在快取設計上針對這些特性的做了一些最佳化。

頁的讀寫轉換

上一章提到過,在寫入訊息的時候,會先將訊息寫入用於資料緩衝的DirectBuffer中。這個DirectBuffer不僅被用於寫入的資料緩衝,本身也是作為快取頁加入到了快取列表中,用於訊息讀取。這種設計方式,不僅減少了一次檔案從磁碟到到快取的資料複製,並且減少了整個生產-消費流程的時延:訊息不必等到寫入磁碟才能被消費。

快取清理策略

快取清理策略決定當快取即將溢位時,哪些頁將被優先從快取中移出。JMQ4採用冷熱分割槽和尾部距離二個維度綜合決策被移出快取的頁。

以當前時間為截止時間,將之前的時間劃分為冷熱二個區間,距離當前時間教近的為熱區,較遠的為冷區。例如,將熱區的時間範圍設為10秒,那麼最後一次訪問時間距離當前時間小於10秒的頁屬於熱區,其它頁屬於冷區。

快取清理的策略如下:

  1. 優先移出冷區快取頁,如果冷區為空,再清理熱區中快取頁;

  2. 區內按照快取頁所在位置與尾部的距離選擇被移出的頁:優先移出距離最遠的快取頁。

上述快取清理策略不僅對頻繁需要訪問的熱資料保持較高的命中率,而且有效的解決了偶發批次訪問導致的快取汙染問題。

例如,正常情況下,對快取的請求集中在訊息檔案的尾部,快取內的大部分快取頁的位置也都靠近訊息檔案的尾部。當某個使用者從訊息檔案的中間某個位置開始向後連續訪問訊息資料時:如果使用LRU等快取策略,隨著使用者訪問,大量中間位置的快取頁會把大量尾部的快取頁置換出快取,導致其他使用者正常訪問尾部訊息快取命中率下降。

使用JMQ4的快取清理策略,由於中間位置的快取頁相對尾部的快取頁距離更遠,剛剛被訪問過的中間位置快取頁將被優先清理出快取,有效的避免了快取汙染問題。

快取預載入

大多數情況下,訊息資料具有連續的讀寫的特性,即從某個位置開始向後連續進行讀寫。基於這一特性,可以預測即將被訪問的位置,提前非同步載入快取頁,進一步提升快取的命中率。

當請求快取時判斷是否滿足如全部條件,如果滿足則進行非同步載入快取頁Pn+1:

  1. 命中快取,將命中的快取頁記為Pn;

  2. Pn 位於熱區;

  3. 請求的訊息位置位於Pn的尾部。

相關文章