爲什麼 Kafka 如此之快?

作者 | Emil Koutanov

譯者 | 彎月

最近幾年,軟件體系結構領域發生了巨大的變化。單體應用,乃至共享一個通用數據存儲的多個服務的概念已從軟件從業者的世界消失了。微服務、事件驅動的體系結構以及 CQRS 成了構建以業務爲中心的現代化應用程序的主要工具。而在這之上,則是物聯網、移動設備、可穿戴設備等設備連接的激增,系統必須實時處理的事件數量的壓力也增大了。

首先,我們必須承認 “快速” 這個詞涉及的方面很廣泛,很複雜,而且比較模糊。延遲、吞吐量和抖動等都是人們衡量 “快速” 的決定性指標。快速本身也要考慮上下文關係,各個行業和應用程序領域都有特定的規範和期望值。快慢的判斷很大程度上取決於相應的參照系。

Apache Kafka 針對吞吐量進行了優化,但犧牲了延遲和抖動,同時保留了其他所需的品質,比如持久性、嚴格的記錄順序以及 “至少一次” 的交付語義。當有人說“Kafka 很快”,並假設它們具備一定的能力時,你可以認爲他們指的是 Kafka 在短時間內安全地積累和分發大量記錄的能力。

究其歷史,Kafka 的誕生是因爲 LinkedIn 的需求,因爲他們需要有效地移動大量的消息,每小時的數據總量達數 TB。一條消息的傳播延遲倒是次要的。畢竟,LinkedIn 不是從事高頻交易的金融機構,也不是有明確截止期限的工業控制系統。我們可以利用 Kafka 實現近乎實時(又名軟實時)的系統。

注意:有些人可能對實時這個術語不太熟悉,“實時” 並不意味着 “快速”,它的意思是 “可預測”。具體而言,實時意味着完成動作所需花費的時間有硬性上線,即截止期限。如果系統整體每次都無法滿足截止期限,則不能稱其爲實時系統。能夠在一定概率公差範圍內完成操作的系統稱爲 “近乎實時”。單從吞吐量看來,一般實時系統都比近乎實時或非實時系統慢。

爲了提高速度,Kafka 做了兩方面的努力,下面我們來分別討論。第一個關係到客戶端與代理之間的低效;第二個源於流處理的機會並行性。

代理的性能

日誌結構的持久性

Kafka 利用分段式追加日誌,將大部分讀寫都限制爲順序 I / O,這種方式在各種存儲介質上的讀寫速度都非常快。人們普遍認爲磁盤的讀寫速度很慢,但實際上存儲介質(尤其是旋轉介質)的性能很大程度上取決於訪問模式。常見的 7,200 RPM SATA 磁盤上的隨機 I / O 的性能要比順序 I / O 慢 3~4 個數量級。此外,現代操作系統提供了預讀和延遲寫入技術,可以預先取出大塊的數據,並將較小的邏輯寫入組合成較大的物理寫入。因此,即使在閃存和其他形式的固態非易失性介質中,隨機 I/O 和順序 I/O 的差異仍然很明顯,儘管與旋轉介質相比,這種差異性已經很小了。

記錄批處理

在大多數媒體類型上,順序 I / O 的速度非常快,可與網絡 I / O 的峯值性能相媲美。在實踐中,這意味着精心設計的日誌結構持久層能夠跟上網絡流量的速度。實際上,Kafka 的瓶頸通常不在於磁盤,而是網絡。因此,除了操作系統提供的低級批處理外,Kafka 客戶和代理還會將讀寫的多個記錄打包成批次,然後再通過網絡發送。記錄的批處理通過使用更大的數據包,以及提高帶寬效率來分攤網絡往返的開銷。

批量壓縮

啓用壓縮後,批處理的影響將更爲明顯,因爲隨着數據量的增加,壓縮會更加有效。尤其是當使用基於文本的格式(如 JSON)時,壓縮的效果會非常明顯,壓縮率通常會到 5~7 倍之間。此外,記錄的批處理大部分是在客戶端完成的,它將負載轉移到客戶端上,不僅可以減輕網絡帶寬的壓力,而且對代理的磁盤 I / O 利用率也有積極的影響。

廉價的消費者

傳統的 MQ 風格的代理程序會在消費消息的時候刪除消息(會導致隨機 I / O 的性能下降),Kafka 與之不同,它不會在使用過後刪除消息,它會按照每個消費者組單獨跟蹤偏移量。偏移量的進度本身發佈在 Kafka 的內部主題__consumer_offsets 上。同樣,由於這是一個僅追加的操作,所以速度非常快。在後臺,這個主題的內容將進一步減少(使用 Kafka 的壓縮功能),僅保留消費者組的最新已知偏移量。

我們來比較一下該模型與更傳統的消息代理(這些代理通常都會提供多種不同的消息分發拓撲)。一方面是消息隊列(一種持久的傳輸,用於點對點消息傳遞,沒有點對多點的功能。)另一方面,pub-sub 主題允許點對多點消息傳遞,但犧牲了持久性。在傳統的 MQ 中實現持久的點對多點消息傳遞模型需要爲每個有狀態的消費者維護一個專用的消息隊列。這會放大讀寫量。一方面,發佈者不得不寫入多個隊列。或者,扇出中繼可能會從一個隊列中消費記錄,並寫入幾個其他隊列,但這只是把讀寫放大的點推遲了而已。另一方面,多個消費者會在代理上產生負載,這些負載既包含順序 I / O 的讀寫,也包含隨機 I / O 的讀寫。

只要 Kafka 中的消費者不更改日誌文件(僅允許生產者或內部 Kafka 進程更改日誌文件),它們就很 “廉價”。這意味着大量的消費者可以同時讀取同一主題,而不會佔用過多的集羣。雖然添加消費者還是需要付出一些代價,但是大多都是順序讀取,加上極少量的順序寫入。因此,多個消費者生態系統共享一個主題是很正常的。

未刷新的緩衝寫入

Kafka 高性能的另一個根本原因(也是值得進一步探討的原因)在於,在確認寫入之前,Kafka 在寫入磁盤時實際上並不會調用 fsync。ACK 唯一的要求就是記錄已被寫入 I / O 緩衝區。這一點鮮爲人知,但至關重要,正是因爲這一點,Kafka 的操作就像是一個內存隊列一樣,因爲 Kafka 的目標就是由磁盤支持的內存隊列(規模由緩衝區 / 頁面緩存的大小決定)。

另一方面,這種寫入的形式是不安全的,因爲即使看似記錄已被確認,副本出問題也可能導致數據丟失。換句話說,與關係數據庫不同,僅承認寫入並不意味着持久性。保證 Kafka 持久的原因在於它運行了多個同步副本。即使其中一個出現問題,其他副本也將繼續運行,當然前提是其他副本沒有受影響(有時,某個常見的上游故障可能會導致多個副本同時出問題)。因此,無 fsync 的 I / O 非阻塞方法與冗餘的同步副本的結合保證了 Kafka 的高吞吐量、持久性和可用性。

客戶端優化

大多數數據庫、隊列以及其他形式的持久性中間件的設計理念中,都有一個全能的服務器(或服務器集羣),加上多個瘦客戶端,兩者之間通過常見的通信協議通信。通常我們認爲,客戶端的實現難度遠低於服務器端。因此,服務器承擔了大部分負載,而客戶端僅充當應用程序代碼和服務器之間的接口。

Kafka 的客戶端設計採用了不同的方法。在記錄到達服務器之前,客戶端需要執行大量操作,包括將記錄暫存到收集器中,對記錄的鍵進行哈希處理以獲得正確的分區索引,對記錄進行校驗以及對批次進行壓縮。客戶端掌握了集羣的元數據,並會定期刷新這些元數據,以瞭解代理拓撲的變化。因此,客戶端可以決定底層的轉發,生產者客戶端不會將記錄盲目地發給集羣,並依賴集羣將記錄轉發到合適的代理節點,而是直接將寫入轉發給分區的主服務器。同樣,消費者客戶在選擇記錄源時也可以做一些智能處理,例如在發送讀取查詢時,選擇地理位置更接近的副本。(此功能是 Kafka 的新功能,自 2.4.0 版開始提供。)

零複製

最常見的低效處理來自緩衝區之間的字節數據複製。Kafka 的生產者、代理和消費者之間共享了同樣的二進制消息格式,因此數據塊即使經過壓縮,在端與端之間流動時也無需進行任何修改。儘管消除通信雙方的結構差異是很重要的一步,但它本身並不能避免數據複製。

爲了在 Linux 和 UNIX 系統上解決了此問題,Kafka 使用了 Java 的 NIO 框架,特別是 java.nio.channels.FileChannel 的方法 transferTo()。我們可以通過這種方法,將字節從源通道傳輸到接收器通道,而無需將應用程序作爲傳輸中介。爲了說明 NIO 的不同之處,我們可以考慮一下傳統的方法:將源通道讀取到字節緩衝區中,然後作爲兩個單獨的操作寫入到接收器通道中:

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

流程圖大致如下:

儘管看起來很簡單,但是在內部,複製操作需要在用戶模式和內核模式之間進行 4 次上下文切換,而在操作完成之前數據將被複制 4 次。下圖概述了每個步驟的上下文切換。

詳細來說:

儘管這種模式存在切換效率低下和額外複製的問題,但在許多情況下,中間內核緩衝區實際上可以提高性能。它可以充當預讀緩存和異步預取塊,因此可以預先運行來自應用程序的請求。但是,當請求的數據量明顯大於內核緩衝區的大小時,內核緩衝區就會成爲性能瓶頸。它不是直接複製數據,而是迫使系統在用戶和內核模式之間來回切換,直到所有數據傳輸完爲止。

相比之下,單個操作可以採用零複製的方法。上述示例中的代碼可以改寫成一行:

1fileDesc.transferTo(offset, len, socket);
2

如下是零複製的方法:

在這個模型中,上下文切換的次數減少到了一次。具體來說,就是 transferTo() 方法指示塊設備通過 DMA 引擎將數據讀取到緩衝區中。然後,將該緩衝區複製到另一個內核緩衝區,供套接字使用。最後,套接字緩衝區通過 DMA 複製到 NIC 緩衝區。

最終結果,我們的副本數目從 4 個減少到了 3 個,而其中只有一個副本需要 CPU。我們還將上下文切換的次數從 4 個減少到了 2 個。

這是一個巨大的提升,但還不是真正的查詢零複製。在運行 Linux 內核 2.4 及更高版本,並且網卡支持 gather 操作的情況下,就可以實現查詢零複製,作爲進一步的優化來實現。如下所示。

根據前面的示例,調用 transferTo() 時,設備會通過 DMA 引擎將數據讀入內核讀取緩衝區。但是,使用 gather 操作時,讀取緩衝區和套接字緩衝區之間不需要複製數據。NIC 會得到一個指向讀取緩衝區的指針以及偏移量和長度,然後可以通過 DMA 直接讀取。在任何時候,CPU 都不需要複製緩衝區。

傳統方式與零複製方式(文件大小範圍從幾兆字節到千兆字節)的比較結果表明。零複製的性能提高了 2~3 倍。但更令人驚訝的是,Kafka 僅使用了普通的 JVM 就實現了該功能,沒有用到任何原生庫或 JNI 代碼。

避免垃圾收集

大量使用通道、原生緩衝區和頁面緩存還有另一個好處:減少了垃圾收集器(garbage collector,GC)的負載。例如,在擁有 32GB 內存的計算機上運行 Kafka,就有 28~30GB 的頁面緩存可用,這完全超出了 GC 的範圍。吞吐量的差異很小(在幾個百分點左右),因爲經過正確地微調後,GC 的吞吐量可以達到很高,尤其是在處理短期對象時。真正的收益在於抖動的減少:避免使用 GC,代理可以減少可能會影響到客戶端的暫停,延長記錄端到端傳播的延遲。

平心而論,對於 Kafka 來說,與最初的設想時相比,如今避免使用 GC 已不再是一個問題。Shenandoah 和 ZGC 等現代 GC 可以擴展到數 TB,並且其最壞情況下的暫停時間是可調節的,最低可以調節到幾毫秒。如今,對於基於 JVM 的應用程序來說,使用大型基於堆的緩存的效果遠勝於不採用堆的設計。

流並行

==========

日誌結構 I / O 的效率是影響性能的關鍵方面,主要影響在於寫入。Kafka 對主題結構以及消費者生態系統中並行性的處理是其讀取性能的基礎。這種組合產生了很高的端到端消息傳遞吞吐量。併發根植於它的分區方案與用戶組的操作中,它實際上是 Kafka 的負載平衡機制:在組內的各個用戶實例之間均勻地分配分區配額。比較一下傳統的 MQ:在同等的 RabbitMQ 設置中,多個併發消費者以循環方式從隊列中讀取,但這種做法丟掉了消息排序的概念。

分區機制還爲 Kafka 代理帶來了水平可伸縮性。每個分區都有專門的主節點,因此,任何重大主題(具有多個分區)都可以利用代理的整個集羣執行寫操作。這是 Kafka 和消息隊列之間的又一個區別,後者利用集羣來提高可用性,而 Kafka 可以平衡代理之間的負載,並提高可用性、持久性和吞吐量。

如果你打算髮布擁有多個分區的主題,那麼生產者需要在發佈記錄時指定分區。(只有一個分區的主題沒有這種問題。)實現方法有兩種:直接的方式(指定分區索引)和間接的方式(通過記錄鍵的方式,該鍵可以通過哈希生成唯一的分區索引)。擁有同樣哈希的記錄會佔據同一個分區。假設一個主題具有多個分區,則具有不同鍵的記錄可能也會位於不同的分區中。但是,由於哈希衝突,具有不同哈希值的記錄也可能會在同一個分區中。這就是哈希的本質。如果你瞭解哈希表的運作方式,就會發現這正是哈希表的原理。

實際的記錄處理由消費者負責,在一個消費者組(可選)內進行操作。Kafka 可以保證分區最多隻能指定給一個消費者組內的一個消費者。(我們說 “最多” 是考慮到可能所有消費者都離線的情況。)當組中的第一個消費者訂閱該主題時,它會收到該主題上的所有分區。當第二個消費者加入時,它會收到大約一半的分區,從而降低了第一個消費者的大約一半的負擔。這樣,只要你的事件流中有足夠多的分區,就能並行處理事件流,根據需要添加消費者(最好使用自動伸縮機制)。

控制記錄的吞吐量可以通過兩個途徑完成:

  1. 主題分區架構。主題應該按照獨立事件子流的最大數量分區。換句話說,記錄的順序只有在絕對必要的時候才需要保證。如果任何兩個記錄都不相關,那麼不應該被綁定到同一個分區。這就需要使用不同的鍵,因爲 Kafka 使用記錄的鍵作爲哈希的來源,以保證分區映射的一致性。

  2. 組內的消費者數量。你可以增加消費者數量來匹配輸入記錄的負載,最大等於主題中的分區數量。(你甚至可以擁有多個消費者,但能夠獲得至少一個分區的活躍消費者數量的上限就是分區數量,其餘的消費者只能處於閒置狀態。)注意消費者可以是進程或線程。根據消費者的負載類型,你可以採用多個獨立的消費者線程,或者在線程池中處理記錄。

如果你想知道爲什麼 Kafka 這麼快,它的性能特性是怎樣實現的,或者怎樣才能伸縮你的集羣,那麼看完這篇文章,你就應該得到答案了。

更明確地說,Kafka 並不是最快的(即並不是吞吐量最大的)消息中間件,有些平臺的吞吐量更大,其中有軟件實現的也有硬件實現的。Kafka 對於吞吐量與延遲之間的平衡處理也算不上最好的,Apache Pulsar 的吞吐量平衡性更好,還能提供順序一致性和可靠性保證。選擇 Kafka 的原因作爲整個生態系統的原因是,從整體上來說它還沒有對手。它展示了優異的性能,同時還提供了龐大、成熟且一直在進步的社區。

Kafka 的設計者和維護者設計了一個非常優秀的、以性能爲主的方案。其設計幾乎沒有任何返工或補丁的跡象。不論是將工作量交個客戶端,還是代理的日誌式架構,甚至是批處理、壓縮、零複製 I/O 和流式並行,Kafka 幾乎打敗了所有面向消息的中間件,不論是商業的還是開源的。更精彩的是,這些實現沒有在持久性、記錄順序、“至少一次” 傳輸語義等質量方面做出任何妥協。

作爲消息平臺,Kafka 並不簡單,需要學習許多知識才能掌握。你必須理解全序和偏序、主題、分區、消費者、消費者組等概念,才能毫無障礙地設計並構建一個高性能的事件驅動系統。儘管學習曲線陡峭,但結果非常值得。

原文鏈接:https://medium.com/swlh/why-kafka-is-so-fast-bde0d987cd03

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