本文來說下Kafka的高性能設計
文章目錄
- 什么是高性能設計
- Kafka高性能設計的全景圖
- 生產訊息的性能優化手段
- 批量發送訊息
- 訊息壓縮
- 高效序列化
- 記憶體池復用
- 存盤訊息的性能優化手段
- IO 多路復用.
- 磁盤順序寫
- Page Cache
- 磁區分段結構
- 消費訊息的性能優化手段
- 稀疏索引.
- mmap
- 零拷貝
- 批量拉取
- 本文小結
什么是高性能設計
我們暫且把 Kafka 拋在一邊,先嘗試理解下高性能設計的本質,
有過高并發開發經驗的同學,對于執行緒池、多級快取、IO 多路復用、零拷貝等技術概念早就了然于胸,但是返璞歸真,這些技術手段的本質到底是什么?
這其實是一個系統性的問題,至少需要深入到作業系統層面,從 CPU 和存盤入手,去了解底層的實作機制,然后再自底往上,一層一層去解密和貫穿起來,
但是站在更高的視角來看,我認為:高性能設計其實萬變不離其宗,一定是從「計算和 IO」這兩個維度出發,去考慮可能的優化點,
那「計算」維度的性能優化手段有哪些呢?無外乎這兩種方式:
- 讓更多的核來參與計算:比如用多執行緒代替單執行緒、用集群代替單機等,
- 減少計算量:比如用索引來取代全域掃描、用同步代替異步、通過限流來減少請求處理量、采用更高效的資料結構和演算法等,
再看下「IO」維度的性能優化手段又有哪些? 可以通過 Linux 系統的 IO 堆疊圖來輔助思考,

可以看到,整個 IO 體系結構是分層的,我們能夠從應用程式、作業系統、磁盤等各個層次來考慮性能優化,而所有這些手段又幾乎圍繞以下兩個方面展開:
- 加快 IO 速度:比如用磁盤順序寫代替隨機寫、用 NIO 代替 BIO、用性能更好的 SSD 代替機械硬碟等,
- 減少 IO 次數或者 IO 資料量:比如借助系統快取或者外部快取、通過零拷貝技術減少 IO 復制次數、批量讀寫、資料壓縮等,
上面這些內容可以理解成高性能設計的「道」,當然絕不是幾百字就可以說清楚的,我更多的是拋磚引玉,用另外一個視角來看高并發,給大家一個方向上的指引,
當大家抓住了「計算和 IO」這兩個最本質的東西,然后以這兩點作為根,再去探究這兩個維度分別有哪些性能優化手段?它們的原理又是什么樣的?便能一層一層剝開高性能設計的神秘面紗,形成可靠的知識體系,
這種分析方法可用來研究 Kafka,同樣可以用來研究我們熟知的 Redis、ES 以及其他高性能的應用系統,
Kafka高性能設計的全景圖
有了高性能設計的思維模式后,我們再回到 Kafka 本身進行分析,
前文提到過 Kafka 的性能優化手段非常豐富,至少有 10 條以上的精妙設計,雖然我們可以從計算和 IO 兩個維度去聯想這些手段,但是要完整地記住它們,似乎也不是件容易的事,
這樣就引出了另外一個話題:我們應該選用一條什么樣的脈絡,去串聯這些優化手段呢?
之前的文章做過分析:不管 Kafka 、RocketMQ 還是其他訊息佇列,其本質都是「一發一存一消費」,
我們完全可以順著這條主線去做結構化梳理,基于這個思路,便形成了下面這張 Kafka 高性能設計的全景圖,我按照生產訊息、存盤訊息、消費訊息 3 個模塊,將 Kafka 最具代表性的 12 條性能優化手段做了歸類,

有了這張全景圖,下面我再挨個分析下每個手段背后的大致原理,并嘗試解讀下 Kafka 的設計哲學,
生產訊息的性能優化手段
我們先從生產訊息開始看,下面是 Producer 端所采用的 4 條優化手段,

批量發送訊息
Kafka 作為一個訊息佇列,很顯然是一個 IO 密集型應用,它所面臨的挑戰除了磁盤 IO(Broker 端需要對訊息持久化),還有網路 IO(Producer 到 Broker,Broker 到 Consumer,都需要通過網路進行訊息傳輸),
在上一篇文章已經指出過:磁盤順序 IO 的速度其實非常快,不亞于記憶體隨機讀寫,這樣網路 IO 便成為了 Kafka 的性能瓶頸所在,
基于這個背景, Kafka 采用了批量發送訊息的方式,通過將多條訊息按照磁區進行分組,然后每次發送一個訊息集合,從而大大減少了網路傳輸的 overhead,
看似很平常的一個手段,其實它大大提升了 Kafka 的吞吐量,而且它的精妙之處遠非如此,下面幾條優化手段都和它息息相關,
訊息壓縮
訊息壓縮的目的是為了進一步減少網路傳輸帶寬,而對于壓縮演算法來說,通常是:資料量越大,壓縮效果才會越好,
因為有了批量發送這個前期,從而使得 Kafka 的訊息壓碩訓制能真正發揮出它的威力(壓縮的本質取決于多訊息的重復性),對比壓縮單條訊息,同時對多條訊息進行壓縮,能大幅減少資料量,從而更大程度提高網路傳輸率,
有文章對 Kafka 支持的三種壓縮演算法:gzip、snappy、lz4 進行了性能對比,測驗 2 萬條訊息,效果如下:

整體來看,gzip 壓縮效果最好,但是生成耗時更長,綜合對比 lz4 性能最佳,
其實壓縮訊息不僅僅減少了網路 IO,它還大大降低了磁盤 IO,因為批量訊息在持久化到 Broker 中的磁盤時,仍然保持的是壓縮狀態,最終是在 Consumer 端做了解壓縮操作,
這種端到端的壓縮設計,其實非常巧妙,它又大大提高了寫磁盤的效率,
高效序列化
Kafka 訊息中的 Key 和 Value,都支持自定義型別,只需要提供相應的序列化和反序列化器即可,因此,用戶可以根據實際情況選用快速且緊湊的序列化方式(比如 ProtoBuf、Avro)來減少實際的網路傳輸量以及磁盤存盤量,進一步提高吞吐量,
記憶體池復用
前面說過 Producer 發送訊息是批量的,因此訊息都會先寫入 Producer 的記憶體中進行緩沖,直到多條訊息組成了一個 Batch,才會通過網路把 Batch 發給 Broker,
當這個 Batch 發送完畢后,顯然這部分資料還會在 Producer 端的 JVM 記憶體中,由于不存在參考了,它是可以被 JVM 回收掉的,
但是大家都知道,JVM GC 時一定會存在 Stop The World 的程序,即使采用最先進的垃圾回收器,也勢必會導致作業執行緒的短暫停頓,這對于 Kafka 這種高并發場景肯定會帶來性能上的影響,
有了這個背景,便引出了 Kafka 非常優秀的記憶體池機制,它和連接池、執行緒池的本質一樣,都是為了提高復用,減少頻繁的創建和釋放,
具體是如何實作的呢?其實很簡單:Producer 一上來就會占用一個固定大小的記憶體塊,比如 64MB,然后將 64 MB 劃分成 M 個小記憶體塊(比如一個小記憶體塊大小是 16KB),
當需要創建一個新的 Batch 時,直接從記憶體池中取出一個 16 KB 的記憶體塊即可,然后往里面不斷寫入訊息,但最大寫入量就是 16 KB,接著將 Batch 發送給 Broker ,此時該記憶體塊就可以還回到緩沖池中繼續復用了,根本不涉及垃圾回收,最終整個流程如下圖所示:

了解了 Producer 端上面 4 條高性能設計后,大家一定會有一個疑問:傳統的資料庫或者訊息中間件都是想辦法讓 Client 端更輕量,將 Server 設計成重量級,僅讓 Client 充當應用程式和 Server 之間的介面,
但是 Kafka 卻反其道而行之,采取了獨具一格的設計思路,在將訊息發送給 Broker 之前,需要先在 Client 端完成大量的作業,例如:訊息的磁區路由、校驗和的計算、壓縮訊息等,這樣便很好地分攤 Broker 的計算壓力,
可見,沒有最好的設計,只有最合適的設計,這就是架構的本源,
存盤訊息的性能優化手段
存盤訊息屬于 Broker 端的核心功能,下面是它所采用的 4 條優化手段,

IO 多路復用.
對于 Kafka Broker 來說,要做到高性能,首先要考慮的是:設計出一個高效的網路通信模型,用來處理它和 Producer 以及 Consumer 之間的訊息傳遞問題,
先參考 Kafka 2.8.0 原始碼里 SocketServer 類中一段很關鍵的注釋:

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

通俗點記憶就是 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 網路通信模型,
磁盤順序寫
通過 IO 多路復用搞定網路通信后,Broker 下一步要考慮的是:如何將訊息快速地存盤起來?
在 Kafka 存盤選型的奧秘 一文中提到了:Kafka 選用的是「日志檔案」來存盤訊息,那這種寫磁盤檔案的方式,又究竟是如何做到高性能的呢?
這一切得益于磁盤順序寫,怎么理解呢?
Kafka 作為訊息佇列,本質上就是一個佇列,是先進先出的,而且訊息一旦生產了就不可變,這種有序性和不可變性使得 Kafka 完全可以「順序寫」日志檔案,也就是說,僅僅將訊息追加到檔案末尾即可,
有了順序寫的前提,我們再來看一個對比實驗,從下圖中可以看到:磁盤順序寫的性能遠遠高于磁盤隨機寫,甚至高于記憶體隨機寫,

原因很簡單:對于普通的機械磁盤,如果是隨機寫入,性能確實極差,也就是隨便找到檔案的某個位置來寫資料,但如果是順序寫入,因為可大大節省磁盤尋道和盤片旋轉的時間,因此性能提升了 3 個數量級,
Page Cache
磁盤順序寫已經很快了,但是對比記憶體順序寫仍然慢了幾個數量級,那有沒有可能繼續優化呢?答案是肯定的,
這里 Kafka 用到了 Page Cache 技術,簡單理解就是:利用了作業系統本身的快取技術,在讀寫磁盤日志檔案時,其實操作的都是記憶體,然后由作業系統決定什么時候將 Page Cache 里的資料真正刷入磁盤,
通過下面這個示例圖便一目了然,

那 Page Cache 究竟什么時候會發揮最大的威力呢?這又不得不提 Page Cache 所用到的兩個經典原理,
Page Cache 快取的是最近會被使用的磁盤資料,利用的是「時間區域性」原理,依據是:最近訪問的資料很可能接下來再訪問到,而預讀到 Page Cache 中的磁盤資料,又利用了「空間區域性」原理,依據是:資料往往是連續訪問的,
而 Kafka 作為訊息隊列,訊息先是順序寫入,而且立馬又會被消費者讀取到,無疑非常契合上述兩條區域性原理,因此,頁快取可以說是 Kafka 做到高吞吐的重要因素之一,
除此之外,頁快取還有一個巨大的優勢,用過 Java 的人都知道:如果不用頁快取,而是用 JVM 行程中的快取,物件的記憶體開銷非常大(通常是真實資料大小的幾倍甚至更多),此外還需要進行垃圾回收,GC 所帶來的 Stop The World 問題也會帶來性能問題,可見,頁快取確實優勢明顯,而且極大地簡化了 Kafka 的代碼實作,
磁區分段結構
磁盤順序寫加上頁快取很好地解決了日志檔案的高性能讀寫問題,但是如果一個 Topic 只對應一個日志檔案,顯然只能存放在一臺 Broker 機器上,
當面對海量訊息時,單機的存盤容量和讀寫性能肯定有限,這樣又引出了又一個精妙的存盤設計:對資料進行磁區存盤,
我在 Kafka 架構設計的任督二脈 一文中詳細解釋了磁區(Partition)的概念和作用,它是 Kafka 并發處理的最小粒度,很好地解決了存盤的擴展性問題,隨著磁區數的增加,Kafka 的吞吐量得以進一步提升,
其實在 Kafka 的存盤底層,在磁區之下還有一層:那便是「分段」,簡單理解:磁區對應的其實是檔案夾,分段對應的才是真正的日志檔案,

每個 Partition 又被分成了多個 Segment,那為什么有了 Partition 之后,還需要 Segment 呢?
如果不引入 Segment,一個 Partition 只對應一個檔案,那這個檔案會一直增大,勢必造成單個 Partition 檔案過大,查找和維護不方便,
此外,在做歷史訊息洗掉時,必然需要將檔案前面的內容洗掉,只有一個檔案顯然不符合 Kafka 順序寫的思路,而在引入 Segment 后,則只需將舊的 Segment 檔案洗掉即可,保證了每個 Segment 的順序寫,
消費訊息的性能優化手段
Kafka 除了要做到百萬 TPS 的寫入性能,還要解決高性能的訊息讀取問題,否則稱不上高吞吐,下面再來看看 Kafka 消費訊息時所采用的 4 條優化手段,

稀疏索引.
如何提高讀性能,大家很容易想到的是:索引,Kafka 所面臨的查詢場景其實很簡單:能按照 offset 或者 timestamp 查到訊息即可,
如果采用 B Tree 類的索引結構來實作,每次資料寫入時都需要維護索引(屬于隨機 IO 操作),而且還會引來「頁分裂」這種比較耗時的操作,而這些代價對于僅需要實作簡單查詢要求的 Kafka 來說,顯得非常重,所以,B Tree 類的索引并不適用于 Kafka,
相反,哈希索引看起來卻非常合適,為了加快讀操作,如果只需要在記憶體中維護一個「從 offset 到日志檔案偏移量」的映射關系即可,每次根據 offset 查找訊息時,從哈希表中得到偏移量,再去讀檔案即可,(根據 timestamp 查訊息也可以采用同樣的思路)
但是哈希索引常駐記憶體,顯然沒法處理資料量很大的情況,Kafka 每秒可能會有高達幾百萬的訊息寫入,一定會將記憶體撐爆,
可我們發現訊息的 offset 完全可以設計成有序的(實際上是一個單調遞增 long 型別的欄位),這樣訊息在日志檔案中本身就是有序存放的了,我們便沒必要為每個訊息建 hash 索引了,完全可以將訊息劃分成若干個 block,只索引每個 block 第一條訊息的 offset 即可,先根據大小關系找到 block,然后在 block 中順序搜索,這便是 Kafka “稀疏索引” 的設計思想,

采用 “稀疏索引”,可以認為是在磁盤空間、記憶體空間、查找性能等多方面的一個折中,有了稀疏索引,當給定一個 offset 時,Kafka 采用的是二分查找來高效定位不大于 offset 的物理位移,然后找到目標訊息,
mmap
利用稀疏索引,已經基本解決了高效查詢的問題,但是這個程序中仍然有進一步的優化空間,那便是通過 mmap(memory mapped files) 讀寫上面提到的稀疏索引檔案,進一步提高查詢訊息的速度,
注意:mmap 和 page cache 是兩個概念,網上很多資料把它們混淆在一起,此外,還有資料談到 Kafka 在讀 log 檔案時也用到了 mmap,通過對 2.8.0 版本的原始碼分析,這個資訊也是錯誤的,其實只有索引檔案的讀寫才用到了 mmap.
究竟如何理解 mmap?前面提到,常規的檔案操作為了提高讀寫性能,使用了 Page Cache 機制,但是由于頁快取處在內核空間中,不能被用戶行程直接尋址,所以讀檔案時還需要通過系統呼叫,將頁快取中的資料再次拷貝到用戶空間中,
而采用 mmap 后,它將磁盤檔案與行程虛擬地址做了映射,并不會招致系統呼叫,以及額外的記憶體 copy 開銷,從而提高了檔案讀取效率,

具體到 Kafka 的原始碼層面,就是基于 JDK nio 包下的 MappedByteBuffer 的 map 函式,將磁盤檔案映射到記憶體中,
至于為什么 log 檔案不采用 mmap?其實是一個特別好的問題,這個問題社區并沒有給出官方答案,網上的答案只能揣測作者的意圖,個人比較認同 stackoverflow 上的這個答案:
mmap 有多少位元組可以映射到記憶體中與地址空間有關,32 位的體系結構只能處理 4GB 甚至更小的檔案,Kafka 日志通常足夠大,可能一次只能映射部分,因此讀取它們將變得非常復雜,然而,索引檔案是稀疏的,它們相對較小,將它們映射到記憶體中可以加快查找程序,這是記憶體映射檔案提供的主要好處,
零拷貝
訊息借助稀疏索引被查詢到后,下一步便是:將訊息從磁盤檔案中讀出來,然后通過網卡發給消費者,那這一步又可以怎么優化呢?
Kafka 用到了零拷貝(Zero-Copy)技術來提升性能,所謂的零拷貝是指資料直接從磁盤檔案復制到網卡設備,而無需經過應用程式,減少了內核和用戶模式之間的背景關系切換,
下面這個程序是不采用零拷貝技術時,從磁盤中讀取檔案然后通過網卡發送出去的流程,可以看到:經歷了 4 次拷貝,4 次背景關系切換,

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

批量拉取
和生產者批量發送訊息類似,訊息者也是批量拉取訊息的,每次拉取一個訊息集合,從而大大減少了網路傳輸的 overhead,
另外,在前面介紹過,生產者其實在 Client 端對批量訊息進行了壓縮,這批訊息持久化到 Broker 時,仍然保持的是壓縮狀態,最終在 Consumer 端再做解壓縮操作,
本文小結
本文從生產者,kafka服務器和消費者三個層面介紹了kafka這個MQ高性能的原因,對kafka有了進一步的理解與掌握,
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/342051.html
標籤:其他
上一篇:Elasticsearch筆記
下一篇:HDFS常用的shell命令
