大數據技術,Kafka為啥這麼快?

加米穀大數據 發佈 2020-04-13T18:55:31+00:00

如果你知道這個諺語「redpill」,請閱讀「介紹 Kafka 和 Kafdrop 中的事件流 Introduction to Event Streaming with Kafka and Kafdrop[2]」。

在過去的幾年裡,軟體架構領域發生了巨大的變化。人們不再認為所有的系統都應該共享一個資料庫。作者:鍾濤編譯;來源:分布式實驗室


微服務、事件驅動架構和 CQRS(命令查詢的責任分離 Command Query Responsibility Segregation)是構建當代業務應用程式的主要工具。

除此以外,物聯網、移動設備和可穿戴設備的普及,進一步對系統的近實時能力提出了挑戰。

首先讓我們對「快」這個詞達成共識,這個詞是多方面的、複雜的、高度模糊的。一種解釋是把」延遲、吞吐量和抖動「作為對「快」的衡量指標。

還有,比如工業應用領域,行業本身設置了對於「快」的規範和期望。所以,「快」在很大程度上取決於你的參照體系是什麼。

Apache Kafka 以犧牲延遲和抖動為代價優化了吞吐量,但並沒有犧牲,比如持久性、嚴格的記錄有序性和至少一次的分發語義。

當有人說「Kafka 速度很快」,並假設他們至少有一定的能力時,你可以認為他們指的是 Kafka 在短時間內分發大量記錄的能力。

Kafka 誕生於 LinkedIn,當時 LinkedIn 需要高效地傳遞大量信息,相當於每小時傳輸數 TB 的數據量。

在當時,消息傳播的延遲被認為是可以接受的。畢竟,LinkedIn 不是一家從事高頻交易的金融機構,也不是一個在確定期限內運行的工業控制系統。Kafka 可用於近實時系統。

注意:「實時」並不意味著「快」,它的意思是「可預測的」。具體來說,實時意味著完成一個動作具有時間限制,也就是最後期限。

如果一個系統不能滿足這個要求,它就不能被歸類為」實時系統「。能夠容忍一定範圍內延遲的系統被稱為「近實時」系統。從吞吐量的角度來說,實時系統通常比近實時或非實時系統要慢。

Kafka 在速度上有兩個重要的方面,需要單獨討論:

  • 與客戶端與服務端之間的低效率實現有關。
  • 源自於流處理的並行性。

服務端優化

日誌的存儲

Kafka 利用分段、追加日誌的方式,在很大程度上將讀寫限制為順序 I/O(sequential I/O),這在大多數的存儲介質上都很快。人們普遍錯誤地認為硬碟很慢。

然而,存儲介質的性能,很大程度上依賴於數據被訪問的模式。同樣在一塊普通的 7200 RPM SATA 硬碟上,隨機 I/O(random I/O)與順序 I/O 相比,隨機 I/O 的性能要比順序 I/O 慢 3 到 4 個數量級。

此外,現代的作業系統提供了預先讀和延遲寫的技術,這些技術可以以塊為單位,預先讀取大量數據,並將較小的邏輯寫操作合併成較大的物理寫操作。

因此,順序 I/O 和隨機 I/O 之間的性能差異在快閃記憶體和其他固態非易失性介質中仍然很明顯,不過它們在旋轉存儲,比如固態硬碟中的性能差異就沒有那麼明顯。

記錄的批處理

順序 I/O 在大多數存儲介質上都非常快,可以與網絡 I/O 的最高性能相媲美。在實踐中,這意味著一個設計良好的日誌持久化層能跟上網絡的讀寫速度。事實上,Kafka 的性能瓶頸通常並不在硬碟上,而是網絡。

因此,除了作業系統提供的批處理外,Kafka 的客戶端和服務端會在一個批處理中積累多個記錄——包括讀寫記錄,然後在通過網絡發送出去。

記錄的批處理可以緩解網絡往返的開銷,使用更大的數據包,提高帶寬的效率。

批量壓縮

當啟用壓縮時,對批處理的影響特別明顯,因為隨著數據大小的增加,壓縮通常會變得更有效。

特別是在使用基於文本的格式時,比如 JSON,壓縮的效果會非常明顯,壓縮比通常在 5x 到 7x 之間。

此外,記錄的批處理主要作為一個客戶端操作,負載在傳遞的過程中,不僅對網絡帶寬有積極影響,而且對服務端的磁碟 I/O 利用率也有積極影響。

便宜的消費者

不同於傳統的消息隊列模型,當消息被消費時會刪除消息(會導致隨機 I/O),Kafka 不會在消息被消費後刪除它們——相反,它會獨立地跟蹤每個消費者組的偏移量。

可以參考 Kafka 的內部主題 __consumer_offsets 了解更多。同樣,由於只是追加操作,所以速度很快。消息的大小在後台被進一步減少(使用 Kafka 的壓縮特性),只保留任何給定消費者組的最後已知偏移量。

將此模型與傳統的消息模型進行對比,後者通常提供幾種不同的消息分發拓撲。

一種是消息隊列——用於點對點消息傳遞的持久化傳輸,沒有點對多點功能。

另一種是發布訂閱主題允許點對多點消息通信,但這樣做的代價是持久性。在傳統消息隊列模型中實現持久化的點對多點消息通信模型需要為每個有狀態的使用者維護專用消息隊列。

這將放大讀寫的消耗。消息生產者被迫將消息寫入多個消息隊列中。另外一種選擇是使用扇出中繼,扇出中繼可以消費來自一個隊列中的記錄,並將記錄寫入其他多個隊列中,但這只會將延遲放大點。

並且,一些消費者正在服務端上生成負載——讀和寫 I/O 的混合,既有順序的,也有隨機的。

Kafka 中的消費者是「便宜的」,只要他們不改變日誌文件(只有生產者或 Kafka 的內部進程被允許這樣做)。

這意味著大量消費者可以並發地從同一主題讀取數據,而不會使集群崩潰。添加一個消費者仍然有一些成本,但主要是順序讀取夾雜很少的順序寫入。

因此,在一個多樣化的消費者系統中,看到一個主題被共享是相當正常的。

未刷新的緩衝寫操作

Kafka 性能的另一個基本原因是,一個值得進一步研究的原因:Kafka 在確認寫操作之前並沒有調用 fsync。ACK 的唯一要求是記錄已經寫入 I/O 緩衝區。

這是一個鮮為人知的事實,但卻是一個至關重要的事實。實際上,這就是 Kafka 的執行方式,就好像它是一個內存隊列一樣——Kafka 實際上是一個由磁碟支持的內存隊列(受緩衝區/頁面緩存大小的限制)。

但是,這種形式的寫入是不安全的,因為副本的出錯可能導致數據丟失,即使記錄似乎已經被 ACK。

換句話說,與關係型資料庫不同,僅寫入緩衝區並不意味著持久性。保證 Kafka 持久性的是運行幾個同步的副本。

即使其中一個出錯了,其他的(假設不止一個)將繼續運行——假設出錯的原因不會導致其他的副本也出錯。

因此,無 fsync 的非阻塞 I/O 方法和冗餘的同步副本組合為 Kafka 提供了高吞吐、持久性和可用性。

客戶端優化

大多數資料庫、隊列和其他形式的持久性中間件都是圍繞全能伺服器(或伺服器集群)和瘦客戶端的概念設計的。

客戶端的實現通常被認為比伺服器端簡單得多。伺服器會處理大部分的負載,而客戶端僅充當服務端的門面。

Kafka 採用了不同的客戶端設計方法。在記錄到達伺服器之前,會在客戶端上執行大量的工作。

這包括對累加器中的記錄進行分段、對記錄鍵進行散列以得到正確的分區索引、對記錄進行校驗以及對記錄批處理進行壓縮。

客戶端知道集群元數據,並定期刷新元數據以跟上服務端拓撲的更改。這讓客戶端更準確的做出轉發決策。

不同於盲目地將記錄發送到集群並依靠後者將其轉發到適當的節點,生產者客戶端可以直接將寫請求轉發到分區主機。

類似地,消費者客戶端能夠在獲取記錄時做出更明智的決定,比如在發出讀查詢時,可以使用在地理上更接近消費者客戶端的副本。(該特性是從 Kafka 的 2.4.0 版本開始提供。)

零拷貝

一種典型的低效方式是在緩衝之間複製字節數據。Kafka 使用由生產者、消費者、服務端三方共享的二進位消息格式,這樣即使數據塊被壓縮了,也可以不加修改地傳遞數據。

雖然消除通信方之間的數據結構差異是重要的一步,但它本身並不能避免數據的複製。

Kafka 使用 Java 的 NIO 框架,特別是 java.nio.channels.FileChannel 的 transferTo() 方法,在 Linux 和 UNIX 系統上解決了這個問題。

此方法允許字節從源通道傳輸到接收通道,而不需要將應用程式作為傳輸中介。

了解 NIO 的不同之處,請思考傳統的方法會怎麼做,將源通道讀入字節緩衝區,然後作為兩個獨立的操作寫入接收器通道:

File.read(fileDesc, buf, len); Socket.send(socket, buf, len); 

可以用下圖來表示:

雖然這副圖看起來很簡單,但是在內部,複製操作需要在用戶態和內核態之間進行四次上下文切換,並且在操作完成之前要複製四次數據。

下圖概述了每次步驟的上下文切換:

詳細說明:

  • 初始的 read() 方法導致上下文從用戶態切換到內核態。文件被讀取,它的內容被 DMA(Direct Memory Access 直接存儲器訪問)引擎複製到內核地址空間中的緩衝區。這與代碼段中使用的緩衝區是不同的。
  • 在 read() 方法返回之前,將數據從內核緩衝區複製到用戶空間緩衝區。此時,我們的應用程式可以讀取文件的內容了。
  • 隨後的 send() 方法將切回到內核態,將數據從用戶空間緩衝區複製到內核地址空間——這一次是將數據複製到與目標套接字相關聯的另一個緩衝區中。在後台,由 DMA 引擎接管,異步地將數據從內核緩衝區複製到協議棧。send() 方法在返回之前不會等待這個操作完成。
  • send() 方法調用返回,切回用戶態。

儘管用戶態與內核態之間的上下文切換效率很低,而且還需要進行額外的複製,但在許多情況下,它可以提高性能。

它可以充當預讀緩存,異步預讀取,從而提前運行來自應用程式的請求。但是,當請求的數據量遠遠大於內核緩衝區的大小時,內核緩衝區就成為了性能瓶頸。

不同於直接複製數據,而是迫使系統在用戶態和內核態之間頻繁切換,直到所有數據都被傳輸。

相比之下,零拷貝方法是在單個操作中處理的。前面例子中的代碼可以改寫為一行代碼:

fileDesc.transferTo(offset, len, socket); 

下面詳細解釋說明是零拷貝:

在這個模型中,上下文切換的數量減少到一個。具體來說,transferTo() 方法指示塊設備通過 DMA 引擎將數據讀入讀緩衝區。

然後,將數據從讀緩衝區複製到套接字緩衝區。最後,通過 DMA 將數據從套接字緩衝區複製到 NIC 緩衝區。


因此,我們將複製的數量從 4 個減少到 3 個,並且其中只有一個複製操作涉及到 CPU。我們還將上下文切換的數量從 4 個減少到 2 個。

這是一個巨大的改進,但還不是查詢零拷貝。在運行 Linux 內核 2.4 或更高版本時,以及在支持 gather 操作的網卡上,可以進一步優化。

如下圖所示:

按照前面的示例,調用 transferTo() 方法會導致設備通過 DMA 引擎將數據讀入內核緩衝區。

但是,對於 gather 操作,讀緩衝區和套接字緩衝區之間不存在複製。相反,NIC被賦予一個指向讀緩衝區的指針,連同偏移量和長度。在任何情況下,CPU 都不涉及複製緩衝區。

文件大小從幾 MB 到 1GB 的範圍內,傳統拷貝和零拷貝相比,結果顯示零拷貝的性能提高了兩到三倍。

但更令人印象深刻的是,Kafka 使用純 JVM 實現了這一點,沒有本地庫或 JNI 代碼。

避免垃圾回收

大量使用通道、緩衝區和頁面緩存還有一個額外的好處——減少垃圾收集器的工作負載。

例如,在 32 GB RAM 的機器上運行 Kafka 將產生 28-30 GB 的頁面緩存可用空間,完全超出了垃圾收集器的範圍。

吞吐量的差異非常小(大約幾個百分點),但是經過正確調優的垃圾收集器的吞吐量可能非常高,特別是在處理短生存期對象時。真正的收益在於減少抖動。

通過避免垃圾回收,服務端不太可能遇到因垃圾回收引起的程序暫停,從而影響客戶端,加大記錄的通信延遲。

與初期的 Kafka 相比,現在避免垃圾回收已經不是什麼問題了。像 Shenandoah 和 ZGC 這樣的現代垃圾收集器可以擴展到巨大的、多 TB 級的堆,在最壞的情況下,並且可以自動調整垃圾收集的暫停時間,降到幾毫秒。

現在,可以看見大量的基於 Java 虛擬機的應用程式使用堆緩存,而不是堆外緩存。

流處理的並行性

日誌的 I/O 效率是性能的一個重要方面,主要的性能影響在於寫。Kafka 對主題結構和消費生態系統中的並行性處理是其讀性能的基礎。

這種組合產生了整體非常高的端到端消息吞吐量。將並發性深入到分區方案和使用者組的操作中,這實際上是 Kafka 中的一種負載均衡機制——將分區平均地分配到各個消費者中。

將此與傳統的消息隊列進行比較:在 RabbitMQ 的設置中,多個並發的消費者可以以輪詢的方式從隊列中讀取數據,但這樣做會喪失消息的有序性。

分區機制有利於 Kafka 服務端的水平擴展。每個分區都有一個專門的領導者。因此,任何重要的多分區的主題都可以利用整個服務端集群進行寫操作。

這是 Kafka 和傳統消息隊列的另一個區別。當後者利用集群來提高可用性時,Kafka 通過負載均衡來提高可用性、持久性和吞吐量。

發布具有多個分區的主題時,生產者指定發布記錄時的分區。(可能有一個單分區主題,那就不是問題了)

可以通過指定分區索引直接完成,或通過記錄鍵間接完成,記錄鍵通過計算散列值確定分區索引。具有相同散列值的記錄共享相同的分區。

假設一個主題有多個分區,那麼具有不同鍵的記錄可能會出現在不同的分區中。

然而,由於散列衝突,具有不同散列值的記錄也可能最終出現在同一個分區中。這就是散列的本質。如果你理解了散列表的工作方式,一切都很自然了。

記錄的實際處理由消費者完成,在一個可選的消費者組中完成。Kafka 保證一個分區最多只能分配給消費者組中的一個消費者。(為什麼用」最多「,當所有消費者都離線時,那就是 0 個消費者了)

當組中的第一個消費者訂閱主題時,它將接收該主題上的所有分區。當第二個消費者訂閱主題時,它將接收到大約一半的分區,從而減輕第一個消費者的負載。

根據需要添加消費者(理想情況下,使用自動伸縮機制),這使你能夠並行地處理事件流,前提是你已經對事件流進行了分區。

以兩種方式控制記錄的吞吐量:

①主題分區方案。應該對主題進行分區,最大化事件流的數量。換句話說,只有在絕對需要時才提供記錄的順序。

如果任何兩個記錄不存在關聯,它們就不應該被綁定到同一個分區。這意味著要使用不同的鍵,因為 Kafka 使用記錄鍵的散列值作為分區映射的根據。

②組中消費者的數量。你可以增加消費者的數量來均衡入站記錄的負載,消費者的數量最多可以增加到和分區數量一樣多。(你可以增加更多的消費者,但每個分區最多只能有一個的活動消費者,剩下的消費者將處於閒置狀態)

請注意,你可以提供一個線程池,根據消費者執行工作負載的不同,消費者可以是一個進程或一個線程。

如果你想知道 Kafka 為什麼這麼快,它是如何做到的,以及它是否適合你,我想你現在已經有了答案了。

為了更清楚地說明問題,Kafka 不是最快的消息中間件,吞吐量也不是最大的。有其他平台能夠提供更高的吞吐量——有些是基於軟體的,有些是基於硬體的。

很難同時做到吞吐量大且延遲低,Apache Pulsar[1] 是一個有前途的技術,可擴展,更好的吞吐量-延遲配置文件,同時提供順序性和持久性。

採用 Kafka 的理由是,作為一個完整的生態系統,它在整體上仍然是無與倫比的。

它展示了出色的性能,同時提供了一個豐富和成熟的環境,Kafka 仍在以令人羨慕的速度增長。

Kafka 的設計者和維護者在設計一個以性能為核心的解決方案時做了大量的工作。它的設計元素中很少有讓人覺得是事後才想到的,或者是補全的。

從將工作負載轉移到客戶端,到服務端日誌的持久性、批處理、壓縮、零拷貝 I/O 和並行流處理——Kafka 向任何其他消息中間件廠商發起挑戰,無論是商業的還是開源的。

最令人印象深刻的是,它做到了這一點,卻沒有犧牲持久性、記錄有序性和至少一次分發的語義。

Kafka 不是最簡單的消息中間件平台,還有許多需要改進的地方。在設計和構建高性能事件驅動系統之前,必須掌握總體和部分的順序、主題、分區、消費者和消費者組的概念。

雖然知識曲線很陡峭,但值得你花時間去學習。如果你知道這個諺語「red pill」(red pill,指為了達到對某種事物的深度探索或追求,選擇去思考,不放棄,繼續走下去,哪怕這條路多難走),請閱讀「介紹 Kafka 和 Kafdrop 中的事件流 Introduction to Event Streaming with Kafka and Kafdrop[2]」。

相關連結:

  • https://pulsar.apache.org/
  • https://medium.com/swlh/introduction-to-event-streaming-with-kafka-and-kafdrop-22afdb4b380a
關鍵字: