Kafka 性能篇:爲何 Kafka 這麼 "快"?

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

關公戰秦瓊

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

是的,所以此文講的不是《分佈式緩存的選型》,也不是《分佈式中間件對比》。我們聚焦於這兩個不同領域的項目對性能的優化,看一看優秀項目對性能優化的通用手段,以及在針對不同場景下的特色的優化方式。

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

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

開篇寄語

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

Kafka 性能全景

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

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

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

是的,所有的問題,思路,優化點都已經列出來了,我們可以儘可能的細化,三個方向都可以細化,如此,所有的實現便一目瞭然,即使不看 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 模型,僞代碼邏輯就如下所示:

1readFile(buffer)
2send(buffer)
3
4

如圖,如果採用傳統的 IO 流程,先讀取網絡 IO,再寫入磁盤 IO,實際需要將數據 Copy 四次。

  1. 第一次:讀取磁盤文件到操作系統內核緩衝區;

  2. 第二次:將內核緩衝區的數據,copy 到應用程序的 buffer;

  3. 第三步:將應用程序 buffer 中的數據,copy 到 socket 網絡發送緩衝區;

  4. 第四次:將 socket buffer 的數據,copy 到網卡,由網卡進行網絡傳輸。

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

並不是操作系統傻,操作系統的設計就是每個應用程序都有自己的用戶內存,用戶內存和內核內存隔離,這是爲了程序和系統安全考慮,否則的話每個應用程序內存滿天飛,隨意讀寫那還得了。

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

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

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

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

1FileChannel.transferTo()
2
3

在此模型下,上下文切換的數量減少到一個。具體而言,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 模型主要分爲三個角色

在傳統阻塞 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 的執行流程如下圖所示:

發送消息依次經過以下處理器:

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。

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

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