Kafka 精妙的高性能設計(下篇)

大家好,我是武哥。

這是《喫透 MQ 系列》的連載:Kafka 高性能設計的下篇。

上一篇文章 中,指出了高性能設計的兩個關鍵維度:計算和 IO,可以將它們理解成「道」。同時給出了 Kafka 高性能設計的全景圖,可以理解成「術」。

圖 1:Kafka 高性能設計的全景圖

這篇文章將繼續對存儲消息和消費消息的 8 條高性能設計手段,逐個展開分析,廢話不多說,開始發車。

 1. 存儲消息的性能優化手段  

存儲消息屬於 Broker 端的核心功能,下面是它所採用的 4 條優化手段。

1、IO 多路複用

對於 Kafka Broker 來說,要做到高性能,首先要考慮的是:設計出一個高效的網絡通信模型,用來處理它和 Producer 以及 Consumer 之間的消息傳遞問題。

先引用 Kafka 2.8.0 源碼裏 SocketServer 類中一段很關鍵的註釋:

通過這段註釋,其實可以瞭解到 Kafka 採用的是:很典型的 Reactor 網絡通信模型,完整的網絡通信層框架圖如下所示:

圖 2:Kafka 網絡通信層的框架圖

通俗點記憶就是 1 + N + M:

1:表示 1 個 Acceptor 線程,負責監聽新的連接,然後將新連接交給 Processor 線程處理。

N:表示 N 個 Processor 線程,每個 Processor 都有自己的 selector,負責從 socket 中讀寫數據。

M:表示 M 個 KafkaRequestHandler 業務處理線程,它通過調用 KafkaApis 進行業務處理,然後生成 response,再交由給 Processor 線程。

對於 IO 有所研究的同學,應該清楚:Reactor 模式正是採用了很經典的 IO 多路複用技術,它可以複用一個線程去處理大量的 Socket 連接,從而保證高性能。Netty 和 Redis 爲什麼能做到十萬甚至百萬併發?它們其實都採用了 Reactor 網絡通信模型。

2、磁盤順序寫

通過 IO 多路複用搞定網絡通信後,Broker 下一步要考慮的是:如何將消息快速地存儲起來?

在  Kafka 存儲選型的**奧祕 ** 一文中提到了:Kafka 選用的是「日誌文件」來存儲消息,那這種寫磁盤文件的方式,又究竟是如何做到高性能的呢?

這一切得益於磁盤順序寫,怎麼理解呢?

Kafka 作爲消息隊列,本質上就是一個隊列,是先進先出的,而且消息一旦生產了就不可變。這種有序性和不可變性使得 Kafka 完全可以「順序寫」日誌文件,也就是說,僅僅將消息追加到文件末尾即可。

有了順序寫的前提,我們再來看一個對比實驗,從下圖中可以看到:磁盤順序寫的性能遠遠高於磁盤隨機寫,甚至高於內存隨機寫。

圖 3:磁盤和內存的 IO 速度對比

原因很簡單:對於普通的機械磁盤,如果是隨機寫入,性能確實極差,也就是隨便找到文件的某個位置來寫數據。但如果是順序寫入,因爲可大大節省磁盤尋道和盤片旋轉的時間,因此性能提升了 3 個數量級。

3、Page Cache

磁盤順序寫已經很快了,但是對比內存順序寫仍然慢了幾個數量級,那有沒有可能繼續優化呢?答案是肯定的。

這裏 Kafka 用到了 Page Cache 技術,簡單理解就是:利用了操作系統本身的緩存技術,在讀寫磁盤日誌文件時,其實操作的都是內存,然後由操作系統決定什麼時候將 Page Cache 裏的數據真正刷入磁盤。

通過下面這個示例圖便一目瞭然。

圖 4:Kafka 的 Page Cache 原理

那 Page Cache 究竟什麼時候會發揮最大的威力呢?這又不得不提 Page Cache 所用到的兩個經典原理。

Page Cache 緩存的是最近會被使用的磁盤數據,利用的是「時間局部性」原理,依據是:最近訪問的數據很可能接下來再訪問到。而預讀到 Page Cache 中的磁盤數據,又利用了「空間局部性」原理,依據是:數據往往是連續訪問的。

而 Kafka 作爲消息隊列,消息先是順序寫入,而且立馬又會被消費者讀取到,無疑非常契合上述兩條局部性原理。因此,頁緩存可以說是 Kafka 做到高吞吐的重要因素之一。

除此之外,頁緩存還有一個巨大的優勢。用過 Java 的人都知道:如果不用頁緩存,而是用 JVM 進程中的緩存,對象的內存開銷非常大(通常是真實數據大小的幾倍甚至更多),此外還需要進行垃圾回收,GC 所帶來的 Stop The World 問題也會帶來性能問題。可見,頁緩存確實優勢明顯,而且極大地簡化了 Kafka 的代碼實現。 

4、分區分段結構

磁盤順序寫加上頁緩存很好地解決了日誌文件的高性能讀寫問題。但是如果一個 Topic 只對應一個日誌文件,顯然只能存放在一臺 Broker 機器上。

當面對海量消息時,單機的存儲容量和讀寫性能肯定有限,這樣又引出了又一個精妙的存儲設計:對數據進行分區存儲**。**

我在Kafka 架構設計的任督二脈 一文中詳細解釋了分區(Partition)的概念和作用,它是 Kafka 併發處理的最小粒度,很好地解決了存儲的擴展性問題。隨着分區數的增加,Kafka 的吞吐量得以進一步提升。

其實在 Kafka 的存儲底層,在分區之下還有一層:那便是「分段」。簡單理解:分區對應的其實是文件夾,分段對應的纔是真正的日誌文件。

圖 5:Kafka 的 分區分段存儲

每個 Partition 又被分成了多個 Segment,那爲什麼有了 Partition 之後,還需要 Segment 呢?

如果不引入 Segment,一個 Partition 只對應一個文件,那這個文件會一直增大,勢必造成單個 Partition 文件過大,查找和維護不方便。

此外,在做歷史消息刪除時,必然需要將文件前面的內容刪除,只有一個文件顯然不符合 Kafka 順序寫的思路。而在引入 Segment 後,則只需將舊的 Segment 文件刪除即可,保證了每個 Segment 的順序寫。

 2. 消費消息的性能優化手段  

Kafka 除了要做到百萬 TPS 的寫入性能,還要解決高性能的消息讀取問題,否則稱不上高吞吐。下面再來看看 Kafka 消費消息時所採用的 4 條優化手段。

1、稀疏索引

如何提高讀性能,大家很容易想到的是:索引。Kafka 所面臨的查詢場景其實很簡單:能按照 offset 或者 timestamp 查到消息即可。

如果採用 B Tree 類的索引結構來實現,每次數據寫入時都需要維護索引(屬於隨機 IO 操作),而且還會引來「頁分裂」這種比較耗時的操作。而這些代價對於僅需要實現簡單查詢要求的 Kafka 來說,顯得非常重。所以,B Tree 類的索引並不適用於 Kafka。

相反,哈希索引看起來卻非常合適。爲了加快讀操作,如果只需要在內存中維護一個「從 offset 到日誌文件偏移量」的映射關係即可,每次根據 offset 查找消息時,從哈希表中得到偏移量,再去讀文件即可。(根據 timestamp 查消息也可以採用同樣的思路)

但是哈希索引常駐內存,顯然沒法處理數據量很大的情況,Kafka 每秒可能會有高達幾百萬的消息寫入,一定會將內存撐爆。

可我們發現消息的 offset 完全可以設計成有序的(實際上是一個單調遞增 long 類型的字段),這樣消息在日誌文件中本身就是有序存放的了,我們便沒必要爲每個消息建 hash 索引了,完全可以將消息劃分成若干個 block,只索引每個 block 第一條消息的 offset 即可,先根據大小關係找到 block,然後在 block 中順序搜索,這便是 Kafka “稀疏索引” 的設計思想。

圖 6:Kafka 的稀疏索引設計

採用 “稀疏索引”,可以認爲是在磁盤空間、內存空間、查找性能等多方面的一個折中。有了稀疏索引,當給定一個 offset 時,Kafka 採用的是二分查找來高效定位不大於 offset 的物理位移,然後找到目標消息。

2、mmap

利用稀疏索引,已經基本解決了高效查詢的問題,但是這個過程中仍然有進一步的優化空間,那便是通過 mmap(memory mapped files) 讀寫上面提到的稀疏索引文件,進一步提高查詢消息的速度。

注意:mmap 和 page cache 是兩個概念,網上很多資料把它們混淆在一起。此外,還有資料談到 Kafka 在讀 log 文件時也用到了 mmap,通過對 2.8.0 版本的源碼分析,這個信息也是錯誤的,其實只有索引文件的讀寫纔用到了 mmap.

究竟如何理解 mmap?前面提到,常規的文件操作爲了提高讀寫性能,使用了 Page Cache 機制,但是由於頁緩存處在內核空間中,不能被用戶進程直接尋址,所以讀文件時還需要通過系統調用,將頁緩存中的數據再次拷貝到用戶空間中。

而採用 mmap 後,它將磁盤文件與進程虛擬地址做了映射,並不會招致系統調用,以及額外的內存 copy 開銷,從而提高了文件讀取效率。

圖 7:mmap 示意圖,引自《碼農的荒島求生》

關於 mmap,好友小風哥寫過一篇很通俗的文章:mmap 可以讓程序員解鎖哪些騷操作?大家可以參考。

具體到 Kafka 的源碼層面,就是基於 JDK nio 包下的 MappedByteBuffer 的 map 函數,將磁盤文件映射到內存中。

至於爲什麼 log 文件不採用 mmap?其實是一個特別好的問題,這個問題社區並沒有給出官方答案,網上的答案只能揣測作者的意圖。個人比較認同 stackoverflow 上的這個答案:

mmap 有多少字節可以映射到內存中與地址空間有關,32 位的體系結構只能處理 4GB 甚至更小的文件。Kafka 日誌通常足夠大,可能一次只能映射部分,因此讀取它們將變得非常複雜。然而,索引文件是稀疏的,它們相對較小。將它們映射到內存中可以加快查找過程,這是內存映射文件提供的主要好處。

3、零拷貝

消息藉助稀疏索引被查詢到後,下一步便是:將消息從磁盤文件中讀出來,然後通過網卡發給消費者,那這一步又可以怎麼優化呢?

Kafka 用到了零拷貝(Zero-Copy)技術來提升性能。所謂的零拷貝是指數據直接從磁盤文件複製到網卡設備,而無需經過應用程序,減少了內核和用戶模式之間的上下文切換。

下面這個過程是不採用零拷貝技術時,從磁盤中讀取文件然後通過網卡發送出去的流程,可以看到:經歷了 4 次拷貝,4 次上下文切換。

圖 8:非零拷貝技術的流程圖,引自《艾小仙》

如果採用零拷貝技術(底層通過 sendfile 方法實現),流程將變成下面這樣。可以看到:只需 3 次拷貝以及 2 次上下文切換,顯然性能更高。

圖 9:零拷貝技術的流程圖,引自《艾小仙》

4、批量拉取

和生產者批量發送消息類似,消息者也是批量拉取消息的,每次拉取一個消息集合,從而大大減少了網絡傳輸的 overhead。

另外,在 Kafka 精妙的高性能設計(上篇) 中介紹過,生產者其實在 Client 端對批量消息進行了壓縮,這批消息持久化到 Broker 時,仍然保持的是壓縮狀態,最終在 Consumer 端再做解壓縮操作。

  1. 寫在最後  

以上就是 Kafka 12 條高性能設計手段的詳解,這兩篇文章先從 IO 和計算兩個維度進行宏觀上的切入,然後順着 MQ 一發一存一消費的脈絡,從微觀上解構了 Kafka 高性能的全景圖。

可以說 Kafka 在高性能設計方面是教科書般的存在,它從 Prodcuer 、到 Broker、再到 Consumer,在掏空心思地優化每一個細節,最終才做到了單機每秒幾十萬 TPS 的極致性能。

最後,希望本文的分析技巧可以幫助你喫透其他高性能的中間件。我是武哥,我們下期見!

大家好,我是武哥,前亞馬遜工程師,現大廠技術管理者,持續分享個人的成長收穫,關注我一定能提升你的視野,讓我們一起進階吧!

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/knn6gPUkG41ByJAxilDyoQ