Kafka 效能篇:為何 Kafka 這麼快?

碼哥位元組發表於2021-09-26

『碼哥』的 Redis 系列文章有一篇講透了 Redis 的效能優化 ——《Redis 核心篇:唯快不破的祕密》。深入地從 IO、執行緒、資料結構、編碼等方面剖析了 Redis “快”的內部祕密。65 哥深受啟發,在學習 Kafka 的過程中,發現 Kafka 也是一個效能十分優秀的中介軟體,遂要求『碼哥』講一講 Kafka 效能優化方面的知識,所以『碼哥』決定將這篇效能方面的博文作為 Kafka 系列的開篇之作。

先預告一下 Kafka 系列文章,大家敬請期待哦:

以講解效能作為 Kafka 之旅的開篇之作,讓我們一起來深入瞭解 Kafka “快”的內部祕密。你不僅可以學習到 Kafka 效能優化的各種手段,也可以提煉出各種效能優化的方法論,這些方法論也可以應用到我們自己的專案之中,助力我們寫出高效能的專案。

關公戰秦瓊

65: Redis 和 Kafka 完全是不同作用的中介軟體,有比較性嗎?

是的,所以此文講的不是《分散式快取的選型》,也不是《分散式中介軟體對比》。我們聚焦於這兩個不同領域的專案對效能的優化,看一看優秀專案對效能優化的通用手段,以及在針對不同場景下的特色的優化方式。

很多人學習了很多東西,瞭解了很多框架,但在遇到實際問題時,卻常常會感覺到知識不足。這就是沒有將學習到的知識體系化,沒有從具體的實現中抽象出可以行之有效的方法論

學習開源專案很重要的一點就是歸納,將不同專案的優秀實現總結出方法論,然後演繹到自我的實踐中去。

碼哥寄語

理性、客觀、謹慎是程式設計師的特點,也是優點,但是很多時候我們也需要帶一點感性,帶一點衝動,這個時候可以幫助我們更快的做決策。「悲觀者正確、樂觀者成功。」希望大家都是一個樂觀地解決問題的人。

Kafka 效能全景

從高度抽象的角度來看,效能問題逃不出下面三個方面:

  • 網路
  • 磁碟
  • 複雜度

對於 Kafka 這種網路分散式佇列來說,網路和磁碟更是優化的重中之重。針對於上面提出的抽象問題,解決方案高度抽象出來也很簡單:

  • 併發
  • 壓縮
  • 批量
  • 快取
  • 演算法

知道了問題和思路,我們再來看看,在 Kafka 中,有哪些角色,而這些角色就是可以優化的點:

  • Producer
  • Broker
  • Consumer

是的,所有的問題,思路,優化點都已經列出來了,我們可以儘可能的細化,三個方向都可以細化,如此,所有的實現便一目瞭然,即使不看 Kafka 的實現,我們自己也可以想到一二點可以優化的地方。

這就是思考方式。提出問題 > 列出問題點 > 列出優化方法 > 列出具體可切入的點 > tradeoff和細化實現

現在,你也可以嘗試自己想一想優化的點和方法,不用盡善盡美,不用管好不好實現,想一點是一點。

65 哥:不行啊,我很笨,也很懶,你還是直接和我說吧,我白嫖比較行。

順序寫

65 哥:人家 Redis 是基於純記憶體的系統,你 kafka 還要讀寫磁碟,能比?

為什麼說寫磁碟慢?

我們不能只知道結論,而不知其所以然。要回答這個問題,就得回到在校時我們學的作業系統課程了。65 哥還留著課本嗎?來,翻到講磁碟的章節,讓我們回顧一下磁碟的執行原理。

65 哥:鬼還留著哦,課程還沒上到一半書就沒了。要不是考試俺眼神好,估計現在還沒畢業。

看經典大圖:

完成一次磁碟 IO,需要經過尋道旋轉資料傳輸三個步驟。

影響磁碟 IO 效能的因素也就發生在上面三個步驟上,因此主要花費的時間就是:

  1. 尋道時間:Tseek 是指將讀寫磁頭移動至正確的磁軌上所需要的時間。尋道時間越短,I/O 操作越快,目前磁碟的平均尋道時間一般在 3-15ms。
  2. 旋轉延遲:Trotation 是指碟片旋轉將請求資料所在的扇區移動到讀寫磁碟下方所需要的時間。旋轉延遲取決於磁碟轉速,通常用磁碟旋轉一週所需時間的 1/2 表示。比如:7200rpm 的磁碟平均旋轉延遲大約為 60*1000/7200/2 = 4.17ms,而轉速為 15000rpm 的磁碟其平均旋轉延遲為 2ms。
  3. 資料傳輸時間:Ttransfer 是指完成傳輸所請求的資料所需要的時間,它取決於資料傳輸率,其值等於資料大小除以資料傳輸率。目前 IDE/ATA 能達到 133MB/s,SATA II 可達到 300MB/s 的介面資料傳輸率,資料傳輸時間通常遠小於前兩部分消耗時間。簡單計算時可忽略。

因此,如果在寫磁碟的時候省去尋道旋轉可以極大地提高磁碟讀寫的效能。

Kafka 採用順序寫檔案的方式來提高磁碟寫入效能。順序寫檔案,基本減少了磁碟尋道旋轉的次數。磁頭再也不用在磁軌上亂舞了,而是一路向前飛速前行。

Kafka 中每個分割槽是一個有序的,不可變的訊息序列,新的訊息不斷追加到 Partition 的末尾,在 Kafka 中 Partition 只是一個邏輯概念,Kafka 將 Partition 劃分為多個 Segment,每個 Segment 對應一個物理檔案,Kafka 對 segment 檔案追加寫,這就是順序寫檔案。

65 哥:為什麼 Kafka 可以使用追加寫的方式呢?

這和 Kafka 的性質有關,我們來看看 Kafka 和 Redis,說白了,Kafka 就是一個Queue,而 Redis 就是一個HashMapQueueMap的區別是什麼?

Queue 是 FIFO 的,資料是有序的;HashMap資料是無序的,是隨機讀寫的。Kafka 的不可變性,有序性使得 Kafka 可以使用追加寫的方式寫檔案。

其實很多符合以上特性的資料系統,都可以採用追加寫的方式來優化磁碟效能。典型的有Redis的 AOF 檔案,各種資料庫的WAL(Write ahead log)機制等等。

所以清楚明白自身業務的特點,就可以針對性地做出優化。

零拷貝

65 哥:哈哈,這個我面試被問到過。可惜答得一般般,唉。

什麼是零拷貝?

我們從 Kafka 的場景來看,Kafka Consumer 消費儲存在 Broker 磁碟的資料,從讀取 Broker 磁碟到網路傳輸給 Consumer,期間涉及哪些系統互動。Kafka Consumer 從 Broker 消費資料,Broker 讀取 Log,就使用了 sendfile。如果使用傳統的 IO 模型,虛擬碼邏輯就如下所示:

readFile(buffer)
send(buffer)

如圖,如果採用傳統的 IO 流程,先讀取網路 IO,再寫入磁碟 IO,實際需要將資料 Copy 四次。

  1. 第一次:讀取磁碟檔案到作業系統核心緩衝區;
  2. 第二次:將核心緩衝區的資料,copy 到應用程式的 buffer;
  3. 第三步:將應用程式 buffer 中的資料,copy 到 socket 網路傳送緩衝區;
  4. 第四次:將 socket buffer 的資料,copy 到網路卡,由網路卡進行網路傳輸。

65 哥:啊,作業系統這麼傻嗎?copy 來 copy 去的。

並不是作業系統傻,作業系統的設計就是每個應用程式都有自己的使用者記憶體,使用者記憶體和核心記憶體隔離,這是為了程式和系統安全考慮,否則的話每個應用程式記憶體滿天飛,隨意讀寫那還得了。

不過,還有零拷貝技術,英文——Zero-Copy零拷貝就是儘量去減少上面資料的拷貝次數,從而減少拷貝的 CPU 開銷,減少使用者態核心態的上下文切換次數,從而優化資料傳輸的效能。

常見的零拷貝思路主要有三種:

  • 直接 I/O:資料直接跨過核心,在使用者地址空間與 I/O 裝置之間傳遞,核心只是進行必要的虛擬儲存配置等輔助工作;
  • 避免核心和使用者空間之間的資料拷貝:當應用程式不需要對資料進行訪問時,則可以避免將資料從核心空間拷貝到使用者空間;
  • 寫時複製:資料不需要提前拷貝,而是當需要修改的時候再進行部分拷貝。

Kafka 使用到了 mmapsendfile 的方式來實現零拷貝。分別對應 Java 的 MappedByteBufferFileChannel.transferTo

使用 Java NIO 實現零拷貝,如下:

FileChannel.transferTo()

在此模型下,上下文切換的數量減少到一個。具體而言,transferTo()方法指示塊裝置通過 DMA 引擎將資料讀取到讀取緩衝區中。然後,將該緩衝區複製到另一個核心緩衝區以暫存到套接字。最後,套接字緩衝區通過 DMA 複製到 NIC 緩衝區。

我們將副本數從四減少到三,並且這些副本中只有一個涉及 CPU。 我們還將上下文切換的數量從四個減少到了兩個。這是一個很大的改進,但是還沒有查詢零副本。當執行 Linux 核心 2.4 及更高版本以及支援收集操作的網路介面卡時,後者可以作為進一步的優化來實現。如下所示。

根據前面的示例,呼叫transferTo()方法會使裝置通過 DMA 引擎將資料讀取到核心讀取緩衝區中。但是,使用gather操作時,讀取緩衝區和套接字緩衝區之間沒有複製。取而代之的是,給 NIC 一個指向讀取緩衝區的指標以及偏移量和長度,該偏移量和長度由 DMA 清除。CPU 絕對不參與複製緩衝區。

關於零拷貝詳情,可以詳讀這篇文章零拷貝 (Zero-copy) 淺析及其應用

PageCache

producer 生產訊息到 Broker 時,Broker 會使用 pwrite() 系統呼叫【對應到 Java NIO 的 FileChannel.write() API】按偏移量寫入資料,此時資料都會先寫入page cache。consumer 消費訊息時,Broker 使用 sendfile() 系統呼叫【對應 FileChannel.transferTo() API】,零拷貝地將資料從 page cache 傳輸到 broker 的 Socket buffer,再通過網路傳輸。

leader 與 follower 之間的同步,與上面 consumer 消費資料的過程是同理的。

page cache中的資料會隨著核心中 flusher 執行緒的排程以及對 sync()/fsync() 的呼叫寫回到磁碟,就算程式崩潰,也不用擔心資料丟失。另外,如果 consumer 要消費的訊息不在page cache裡,才會去磁碟讀取,並且會順便預讀出一些相鄰的塊放入 page cache,以方便下一次讀取。

因此如果 Kafka producer 的生產速率與 consumer 的消費速率相差不大,那麼就能幾乎只靠對 broker page cache 的讀寫完成整個生產 - 消費過程,磁碟訪問非常少。

網路模型

65 哥:網路嘛,作為 Java 程式設計師,自然是 Netty

是的,Netty 是 JVM 領域一個優秀的網路框架,提供了高效能的網路服務。大多數 Java 程式設計師提到網路框架,首先想到的就是 Netty。Dubbo、Avro-RPC 等等優秀的框架都使用 Netty 作為底層的網路通訊框架。

Kafka 自己實現了網路模型做 RPC。底層基於 Java NIO,採用和 Netty 一樣的 Reactor 執行緒模型。

Reacotr 模型主要分為三個角色

  • Reactor:把 IO 事件分配給對應的 handler 處理
  • Acceptor:處理客戶端連線事件
  • Handler:處理非阻塞的任務

在傳統阻塞 IO 模型中,每個連線都需要獨立執行緒處理,當併發數大時,建立執行緒數多,佔用資源;採用阻塞 IO 模型,連線建立後,若當前執行緒沒有資料可讀,執行緒會阻塞在讀操作上,造成資源浪費

針對傳統阻塞 IO 模型的兩個問題,Reactor 模型基於池化思想,避免為每個連線建立執行緒,連線完成後將業務處理交給執行緒池處理;基於 IO 複用模型,多個連線共用同一個阻塞物件,不用等待所有的連線。遍歷到有新資料可以處理時,作業系統會通知程式,執行緒跳出阻塞狀態,進行業務邏輯處理

Kafka 即基於 Reactor 模型實現了多路複用和處理執行緒池。其設計如下:

其中包含了一個Acceptor執行緒,用於處理新的連線,Acceptor 有 N 個 Processor 執行緒 select 和 read socket 請求,N 個 Handler 執行緒處理請求並相應,即處理業務邏輯。

I/O 多路複用可以通過把多個 I/O 的阻塞複用到同一個 select 的阻塞上,從而使得系統在單執行緒的情況下可以同時處理多個客戶端請求。它的最大優勢是系統開銷小,並且不需要建立新的程式或者執行緒,降低了系統的資源開銷。

總結: Kafka Broker 的 KafkaServer 設計是一個優秀的網路架構,有想了解 Java 網路程式設計,或需要使用到這方面技術的同學不妨去讀一讀原始碼。後續『碼哥』的 Kafka 系列文章也將涉及這塊原始碼的解讀。

批量與壓縮

Kafka Producer 向 Broker 傳送訊息不是一條訊息一條訊息的傳送。使用過 Kafka 的同學應該知道,Producer 有兩個重要的引數:batch.sizelinger.ms。這兩個引數就和 Producer 的批量傳送有關。

Kafka Producer 的執行流程如下圖所示:

傳送訊息依次經過以下處理器:

  • Serialize:鍵和值都根據傳遞的序列化器進行序列化。優秀的序列化方式可以提高網路傳輸的效率。
  • Partition:決定將訊息寫入主題的哪個分割槽,預設情況下遵循 murmur2 演算法。自定義分割槽程式也可以傳遞給生產者,以控制應將訊息寫入哪個分割槽。
  • Compress:預設情況下,在 Kafka 生產者中不啟用壓縮.Compression 不僅可以更快地從生產者傳輸到代理,還可以在複製過程中進行更快的傳輸。壓縮有助於提高吞吐量,降低延遲並提高磁碟利用率。
  • Accumulate:Accumulate顧名思義,就是一個訊息累計器。其內部為每個 Partition 維護一個Deque雙端佇列,佇列儲存將要傳送的批次資料,Accumulate將資料累計到一定數量,或者在一定過期時間內,便將資料以批次的方式傳送出去。記錄被累積在主題每個分割槽的緩衝區中。根據生產者批次大小屬性將記錄分組。主題中的每個分割槽都有一個單獨的累加器 / 緩衝區。
  • Group Send:記錄累積器中分割槽的批次按將它們傳送到的代理分組。 批處理中的記錄基於 batch.size 和 linger.ms 屬性傳送到代理。 記錄由生產者根據兩個條件傳送。 當達到定義的批次大小或達到定義的延遲時間時。

Kafka 支援多種壓縮演算法:lz4、snappy、gzip。Kafka 2.1.0 正式支援 ZStandard —— ZStandard 是 Facebook 開源的壓縮演算法,旨在提供超高的壓縮比 (compression ratio),具體細節參見 zstd

Producer、Broker 和 Consumer 使用相同的壓縮演算法,在 producer 向 Broker 寫入資料,Consumer 向 Broker 讀取資料時甚至可以不用解壓縮,最終在 Consumer Poll 到訊息時才解壓,這樣節省了大量的網路和磁碟開銷。

分割槽併發

Kafka 的 Topic 可以分成多個 Partition,每個 Paritition 類似於一個佇列,保證資料有序。同一個 Group 下的不同 Consumer 併發消費 Paritition,分割槽實際上是調優 Kafka 並行度的最小單元,因此,可以說,每增加一個 Paritition 就增加了一個消費併發。

Kafka 具有優秀的分割槽分配演算法——StickyAssignor,可以保證分割槽的分配儘量地均衡,且每一次重分配的結果儘量與上一次分配結果保持一致。這樣,整個叢集的分割槽儘量地均衡,各個 Broker 和 Consumer 的處理不至於出現太大的傾斜。

65 哥:那是不是分割槽數越多越好呢?

當然不是。

越多的分割槽需要開啟更多的檔案控制程式碼

在 kafka 的 broker 中,每個分割槽都會對照著檔案系統的一個目錄。在 kafka 的資料日誌檔案目錄中,每個日誌資料段都會分配兩個檔案,一個索引檔案和一個資料檔案。因此,隨著 partition 的增多,需要的檔案控制程式碼數急劇增加,必要時需要調整作業系統允許開啟的檔案控制程式碼數。

客戶端 / 伺服器端需要使用的記憶體就越多

客戶端 producer 有個引數 batch.size,預設是 16KB。它會為每個分割槽快取訊息,一旦滿了就打包將訊息批量發出。看上去這是個能夠提升效能的設計。不過很顯然,因為這個引數是分割槽級別的,如果分割槽數越多,這部分快取所需的記憶體佔用也會更多。

降低高可用性

分割槽越多,每個 Broker 上分配的分割槽也就越多,當一個發生 Broker 當機,那麼恢復時間將很長。

檔案結構

Kafka 訊息是以 Topic 為單位進行歸類,各個 Topic 之間是彼此獨立的,互不影響。每個 Topic 又可以分為一個或多個分割槽。每個分割槽各自存在一個記錄訊息資料的日誌檔案。

Kafka 每個分割槽日誌在物理上實際按大小被分成多個 Segment。

  • segment file 組成:由 2 大部分組成,分別為 index file 和 data file,此 2 個檔案一一對應,成對出現,字尾”.index”和“.log”分別表示為 segment 索引檔案、資料檔案。
  • segment 檔案命名規則:partion 全域性的第一個 segment 從 0 開始,後續每個 segment 檔名為上一個 segment 檔案最後一條訊息的 offset 值。數值最大為 64 位 long 大小,19 位數字字元長度,沒有數字用 0 填充。

index 採用稀疏索引,這樣每個 index 檔案大小有限,Kafka 採用mmap的方式,直接將 index 檔案對映到記憶體,這樣對 index 的操作就不需要操作磁碟 IO。mmap的 Java 實現對應 MappedByteBuffer

65 哥筆記:mmap 是一種記憶體對映檔案的方法。即將一個檔案或者其它物件對映到程式的地址空間,實現檔案磁碟地址和程式虛擬地址空間中一段虛擬地址的一一對映關係。實現這樣的對映關係後,程式就可以採用指標的方式讀寫操作這一段記憶體,而系統會自動回寫髒頁面到對應的檔案磁碟上,即完成了對檔案的操作而不必再呼叫 read,write 等系統呼叫函式。相反,核心空間對這段區域的修改也直接反映使用者空間,從而可以實現不同程式間的檔案共享。

Kafka 充分利用二分法來查詢對應 offset 的訊息位置:

  1. 按照二分法找到小於 offset 的 segment 的.log 和.index
  2. 用目標 offset 減去檔名中的 offset 得到訊息在這個 segment 中的偏移量。
  3. 再次用二分法在 index 檔案中找到對應的索引。
  4. 到 log 檔案中,順序查詢,直到找到 offset 對應的訊息。

總結

Kafka 是一個優秀的開源專案。其在效能上面的優化做的淋漓盡致,是很值得我們深入學習的一個專案。無論是思想還是實現,我們都應該認真的去看一看,想一想。

Kafka 效能優化:

  1. 零拷貝網路和磁碟
  2. 優秀的網路模型,基於 Java NIO
  3. 高效的檔案資料結構設計
  4. Parition 並行和可擴充套件
  5. 資料批量傳輸
  6. 資料壓縮
  7. 順序讀寫磁碟
  8. 無鎖輕量級 offset

往期回顧

  1. 圖解 | 搞定分散式,程式設計師進階之路
  2. 從面試角度一文學完 Kafka
  3. 不可不知的軟體架構模式
  4. Redis 日誌篇:無畏當機快速恢復的殺手鐗

文章如有錯誤,感謝指正,關注我,獲取真正的硬核知識點。另外技術讀者群也開通了,後臺回覆「加群」獲取「碼哥位元組」作者微信,一起成長交流。

以上就是 Kafka“快”的祕密,覺得不錯請點贊、分享,「碼哥位元組」感激不盡。

相關文章