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 端再做解壓縮操作。
3. 寫在最後
以上就是 Kafka 12 條高效能設計手段的詳解,這兩篇文章 先從 IO 和計算 兩個維度進行巨集觀上的切入,然後 順著 MQ 一發一存一消費的脈絡,從微觀上解構了 Kafka 高效能的全景圖。
可以說 Kafka 在高效能設計方面是教科書般的存在 ,它從 Prodcuer 、到 Broker、再到 Consumer,在掏空心思地優化每一個細節,最終才做到了單機每秒幾十萬 TPS 的極致效能。
最後,希望本文的分析技巧 可以幫助你吃透其他高效能的中介軟體。我是武哥,我們下期見!
- 面試官:cglib為什麼不能代理private方法?
- 想看Dubbo原始碼?一定要先看一看這一篇!
- 死磕synchronized二:系統剖析延遲偏向篇一
- 架構與思維:高併發下解決主從延時的一些思路
- 道與術
- OopMap看不懂,怎麼調優哇
- Kafka 精妙的高效能設計(下篇)
- 一次tcp視窗被填滿問題的排查實踐
- 搶了個票,還以為發現了12306的系統BUG
- 微服務5:服務註冊與發現(實踐篇)
- 看一遍就理解:零拷貝詳解
- 分散式:分散式系統下的唯一序列
- 面試官:為什麼jdk動態代理只能代理介面實現類?
- 揭開記憶體屏障的神祕面紗
- 微服務4:服務註冊與發現
- 我就奇了怪了,STW到底是怎麼做到的
- 這樣使用 IDEA ,效率提升10倍!| IDEA 高效使用指南
- 從hotspot原始碼層面剖析Java的多型實現原理
- 垃圾回收全集之十二:GC 調優的實戰篇—Weak, Soft 及 Phantom 引用
- JVM的多型是如何實現的