一口氣說出Kafka為啥這么快?
在過去的幾年里,軟件架構(gòu)領(lǐng)域發(fā)生了巨大的變化。人們不再認(rèn)為所有的系統(tǒng)都應(yīng)該共享一個數(shù)據(jù)庫。
圖片來自 Pexels
微服務(wù)、事件驅(qū)動架構(gòu)和 CQRS(命令查詢的責(zé)任分離 Command Query Responsibility Segregation)是構(gòu)建當(dāng)代業(yè)務(wù)應(yīng)用程序的主要工具。
除此以外,物聯(lián)網(wǎng)、移動設(shè)備和可穿戴設(shè)備的普及,進(jìn)一步對系統(tǒng)的近實(shí)時能力提出了挑戰(zhàn)。
首先讓我們對“快”這個詞達(dá)成共識,這個詞是多方面的、復(fù)雜的、高度模糊的。一種解釋是把”延遲、吞吐量和抖動“作為對“快”的衡量指標(biāo)。
還有,比如工業(yè)應(yīng)用領(lǐng)域,行業(yè)本身設(shè)置了對于“快”的規(guī)范和期望。所以,“快”在很大程度上取決于你的參照體系是什么。
Apache Kafka 以犧牲延遲和抖動為代價(jià)優(yōu)化了吞吐量,但并沒有犧牲,比如持久性、嚴(yán)格的記錄有序性和至少一次的分發(fā)語義。
當(dāng)有人說“Kafka 速度很快”,并假設(shè)他們至少有一定的能力時,你可以認(rèn)為他們指的是 Kafka 在短時間內(nèi)分發(fā)大量記錄的能力。
Kafka 誕生于 LinkedIn,當(dāng)時 LinkedIn 需要高效地傳遞大量信息,相當(dāng)于每小時傳輸數(shù) TB 的數(shù)據(jù)量。
在當(dāng)時,消息傳播的延遲被認(rèn)為是可以接受的。畢竟,LinkedIn 不是一家從事高頻交易的金融機(jī)構(gòu),也不是一個在確定期限內(nèi)運(yùn)行的工業(yè)控制系統(tǒng)。Kafka 可用于近實(shí)時系統(tǒng)。
注意:“實(shí)時”并不意味著“快”,它的意思是“可預(yù)測的”。具體來說,實(shí)時意味著完成一個動作具有時間限制,也就是最后期限。
如果一個系統(tǒng)不能滿足這個要求,它就不能被歸類為”實(shí)時系統(tǒng)“。能夠容忍一定范圍內(nèi)延遲的系統(tǒng)被稱為“近實(shí)時”系統(tǒng)。從吞吐量的角度來說,實(shí)時系統(tǒng)通常比近實(shí)時或非實(shí)時系統(tǒng)要慢。
Kafka 在速度上有兩個重要的方面,需要單獨(dú)討論:
- 與客戶端與服務(wù)端之間的低效率實(shí)現(xiàn)有關(guān)。
- 源自于流處理的并行性。
服務(wù)端優(yōu)化
日志的存儲
Kafka 利用分段、追加日志的方式,在很大程度上將讀寫限制為順序 I/O(sequential I/O),這在大多數(shù)的存儲介質(zhì)上都很快。人們普遍錯誤地認(rèn)為硬盤很慢。
然而,存儲介質(zhì)的性能,很大程度上依賴于數(shù)據(jù)被訪問的模式。同樣在一塊普通的 7200 RPM SATA 硬盤上,隨機(jī) I/O(random I/O)與順序 I/O 相比,隨機(jī) I/O 的性能要比順序 I/O 慢 3 到 4 個數(shù)量級。
此外,現(xiàn)代的操作系統(tǒng)提供了預(yù)先讀和延遲寫的技術(shù),這些技術(shù)可以以塊為單位,預(yù)先讀取大量數(shù)據(jù),并將較小的邏輯寫操作合并成較大的物理寫操作。
因此,順序 I/O 和隨機(jī) I/O 之間的性能差異在閃存和其他固態(tài)非易失性介質(zhì)中仍然很明顯,不過它們在旋轉(zhuǎn)存儲,比如固態(tài)硬盤中的性能差異就沒有那么明顯。
記錄的批處理
順序 I/O 在大多數(shù)存儲介質(zhì)上都非??欤梢耘c網(wǎng)絡(luò) I/O 的最高性能相媲美。在實(shí)踐中,這意味著一個設(shè)計(jì)良好的日志持久化層能跟上網(wǎng)絡(luò)的讀寫速度。事實(shí)上,Kafka 的性能瓶頸通常并不在硬盤上,而是網(wǎng)絡(luò)。
因此,除了操作系統(tǒng)提供的批處理外,Kafka 的客戶端和服務(wù)端會在一個批處理中積累多個記錄——包括讀寫記錄,然后在通過網(wǎng)絡(luò)發(fā)送出去。
記錄的批處理可以緩解網(wǎng)絡(luò)往返的開銷,使用更大的數(shù)據(jù)包,提高帶寬的效率。
批量壓縮
當(dāng)啟用壓縮時,對批處理的影響特別明顯,因?yàn)殡S著數(shù)據(jù)大小的增加,壓縮通常會變得更有效。
特別是在使用基于文本的格式時,比如 JSON,壓縮的效果會非常明顯,壓縮比通常在 5x 到 7x 之間。
此外,記錄的批處理主要作為一個客戶端操作,負(fù)載在傳遞的過程中,不僅對網(wǎng)絡(luò)帶寬有積極影響,而且對服務(wù)端的磁盤 I/O 利用率也有積極影響。
便宜的消費(fèi)者
不同于傳統(tǒng)的消息隊(duì)列模型,當(dāng)消息被消費(fèi)時會刪除消息(會導(dǎo)致隨機(jī) I/O),Kafka 不會在消息被消費(fèi)后刪除它們——相反,它會獨(dú)立地跟蹤每個消費(fèi)者組的偏移量。
可以參考 Kafka 的內(nèi)部主題 __consumer_offsets 了解更多。同樣,由于只是追加操作,所以速度很快。消息的大小在后臺被進(jìn)一步減少(使用 Kafka 的壓縮特性),只保留任何給定消費(fèi)者組的最后已知偏移量。
將此模型與傳統(tǒng)的消息模型進(jìn)行對比,后者通常提供幾種不同的消息分發(fā)拓?fù)洹?/p>
一種是消息隊(duì)列——用于點(diǎn)對點(diǎn)消息傳遞的持久化傳輸,沒有點(diǎn)對多點(diǎn)功能。
另一種是發(fā)布訂閱主題允許點(diǎn)對多點(diǎn)消息通信,但這樣做的代價(jià)是持久性。在傳統(tǒng)消息隊(duì)列模型中實(shí)現(xiàn)持久化的點(diǎn)對多點(diǎn)消息通信模型需要為每個有狀態(tài)的使用者維護(hù)專用消息隊(duì)列。
這將放大讀寫的消耗。消息生產(chǎn)者被迫將消息寫入多個消息隊(duì)列中。另外一種選擇是使用扇出中繼,扇出中繼可以消費(fèi)來自一個隊(duì)列中的記錄,并將記錄寫入其他多個隊(duì)列中,但這只會將延遲放大點(diǎn)。
并且,一些消費(fèi)者正在服務(wù)端上生成負(fù)載——讀和寫 I/O 的混合,既有順序的,也有隨機(jī)的。
Kafka 中的消費(fèi)者是“便宜的”,只要他們不改變?nèi)罩疚募?只有生產(chǎn)者或 Kafka 的內(nèi)部進(jìn)程被允許這樣做)。
這意味著大量消費(fèi)者可以并發(fā)地從同一主題讀取數(shù)據(jù),而不會使集群崩潰。添加一個消費(fèi)者仍然有一些成本,但主要是順序讀取夾雜很少的順序?qū)懭搿?/p>
因此,在一個多樣化的消費(fèi)者系統(tǒng)中,看到一個主題被共享是相當(dāng)正常的。
未刷新的緩沖寫操作
Kafka 性能的另一個基本原因是,一個值得進(jìn)一步研究的原因:Kafka 在確認(rèn)寫操作之前并沒有調(diào)用 fsync。ACK 的唯一要求是記錄已經(jīng)寫入 I/O 緩沖區(qū)。
這是一個鮮為人知的事實(shí),但卻是一個至關(guān)重要的事實(shí)。實(shí)際上,這就是 Kafka 的執(zhí)行方式,就好像它是一個內(nèi)存隊(duì)列一樣——Kafka 實(shí)際上是一個由磁盤支持的內(nèi)存隊(duì)列(受緩沖區(qū)/頁面緩存大小的限制)。
但是,這種形式的寫入是不安全的,因?yàn)楦北镜某鲥e可能導(dǎo)致數(shù)據(jù)丟失,即使記錄似乎已經(jīng)被 ACK。
換句話說,與關(guān)系型數(shù)據(jù)庫不同,僅寫入緩沖區(qū)并不意味著持久性。保證 Kafka 持久性的是運(yùn)行幾個同步的副本。
即使其中一個出錯了,其他的(假設(shè)不止一個)將繼續(xù)運(yùn)行——假設(shè)出錯的原因不會導(dǎo)致其他的副本也出錯。
因此,無 fsync 的非阻塞 I/O 方法和冗余的同步副本組合為 Kafka 提供了高吞吐、持久性和可用性。
客戶端優(yōu)化
大多數(shù)數(shù)據(jù)庫、隊(duì)列和其他形式的持久性中間件都是圍繞全能服務(wù)器(或服務(wù)器集群)和瘦客戶端的概念設(shè)計(jì)的。
客戶端的實(shí)現(xiàn)通常被認(rèn)為比服務(wù)器端簡單得多。服務(wù)器會處理大部分的負(fù)載,而客戶端僅充當(dāng)服務(wù)端的門面。
Kafka 采用了不同的客戶端設(shè)計(jì)方法。在記錄到達(dá)服務(wù)器之前,會在客戶端上執(zhí)行大量的工作。
這包括對累加器中的記錄進(jìn)行分段、對記錄鍵進(jìn)行散列以得到正確的分區(qū)索引、對記錄進(jìn)行校驗(yàn)以及對記錄批處理進(jìn)行壓縮。
客戶端知道集群元數(shù)據(jù),并定期刷新元數(shù)據(jù)以跟上服務(wù)端拓?fù)涞母摹_@讓客戶端更準(zhǔn)確的做出轉(zhuǎn)發(fā)決策。
不同于盲目地將記錄發(fā)送到集群并依靠后者將其轉(zhuǎn)發(fā)到適當(dāng)?shù)墓?jié)點(diǎn),生產(chǎn)者客戶端可以直接將寫請求轉(zhuǎn)發(fā)到分區(qū)主機(jī)。
類似地,消費(fèi)者客戶端能夠在獲取記錄時做出更明智的決定,比如在發(fā)出讀查詢時,可以使用在地理上更接近消費(fèi)者客戶端的副本。(該特性是從 Kafka 的 2.4.0 版本開始提供。)
零拷貝
一種典型的低效方式是在緩沖之間復(fù)制字節(jié)數(shù)據(jù)。Kafka 使用由生產(chǎn)者、消費(fèi)者、服務(wù)端三方共享的二進(jìn)制消息格式,這樣即使數(shù)據(jù)塊被壓縮了,也可以不加修改地傳遞數(shù)據(jù)。
雖然消除通信方之間的數(shù)據(jù)結(jié)構(gòu)差異是重要的一步,但它本身并不能避免數(shù)據(jù)的復(fù)制。
Kafka 使用 Java 的 NIO 框架,特別是 java.nio.channels.FileChannel 的 transferTo() 方法,在 Linux 和 UNIX 系統(tǒng)上解決了這個問題。
此方法允許字節(jié)從源通道傳輸?shù)浇邮胀ǖ溃恍枰獙?yīng)用程序作為傳輸中介。
了解 NIO 的不同之處,請思考傳統(tǒng)的方法會怎么做,將源通道讀入字節(jié)緩沖區(qū),然后作為兩個獨(dú)立的操作寫入接收器通道:
- File.read(fileDesc, buf, len);
- Socket.send(socket, buf, len);
可以用下圖來表示:
雖然這副圖看起來很簡單,但是在內(nèi)部,復(fù)制操作需要在用戶態(tài)和內(nèi)核態(tài)之間進(jìn)行四次上下文切換,并且在操作完成之前要復(fù)制四次數(shù)據(jù)。
下圖概述了每次步驟的上下文切換:
詳細(xì)說明:
- 初始的 read() 方法導(dǎo)致上下文從用戶態(tài)切換到內(nèi)核態(tài)。文件被讀取,它的內(nèi)容被 DMA(Direct Memory Access 直接存儲器訪問)引擎復(fù)制到內(nèi)核地址空間中的緩沖區(qū)。這與代碼段中使用的緩沖區(qū)是不同的。
- 在 read() 方法返回之前,將數(shù)據(jù)從內(nèi)核緩沖區(qū)復(fù)制到用戶空間緩沖區(qū)。此時,我們的應(yīng)用程序可以讀取文件的內(nèi)容了。
- 隨后的 send() 方法將切回到內(nèi)核態(tài),將數(shù)據(jù)從用戶空間緩沖區(qū)復(fù)制到內(nèi)核地址空間——這一次是將數(shù)據(jù)復(fù)制到與目標(biāo)套接字相關(guān)聯(lián)的另一個緩沖區(qū)中。在后臺,由 DMA 引擎接管,異步地將數(shù)據(jù)從內(nèi)核緩沖區(qū)復(fù)制到協(xié)議棧。send() 方法在返回之前不會等待這個操作完成。
- send() 方法調(diào)用返回,切回用戶態(tài)。
盡管用戶態(tài)與內(nèi)核態(tài)之間的上下文切換效率很低,而且還需要進(jìn)行額外的復(fù)制,但在許多情況下,它可以提高性能。
它可以充當(dāng)預(yù)讀緩存,異步預(yù)讀取,從而提前運(yùn)行來自應(yīng)用程序的請求。但是,當(dāng)請求的數(shù)據(jù)量遠(yuǎn)遠(yuǎn)大于內(nèi)核緩沖區(qū)的大小時,內(nèi)核緩沖區(qū)就成為了性能瓶頸。
不同于直接復(fù)制數(shù)據(jù),而是迫使系統(tǒng)在用戶態(tài)和內(nèi)核態(tài)之間頻繁切換,直到所有數(shù)據(jù)都被傳輸。
相比之下,零拷貝方法是在單個操作中處理的。前面例子中的代碼可以改寫為一行代碼:
- fileDesc.transferTo(offset, len, socket);
下面詳細(xì)解釋說明是零拷貝:
在這個模型中,上下文切換的數(shù)量減少到一個。具體來說,transferTo() 方法指示塊設(shè)備通過 DMA 引擎將數(shù)據(jù)讀入讀緩沖區(qū)。
然后,將數(shù)據(jù)從讀緩沖區(qū)復(fù)制到套接字緩沖區(qū)。最后,通過 DMA 將數(shù)據(jù)從套接字緩沖區(qū)復(fù)制到 NIC 緩沖區(qū)。
因此,我們將復(fù)制的數(shù)量從 4 個減少到 3 個,并且其中只有一個復(fù)制操作涉及到 CPU。我們還將上下文切換的數(shù)量從 4 個減少到 2 個。
這是一個巨大的改進(jìn),但還不是查詢零拷貝。在運(yùn)行 Linux 內(nèi)核 2.4 或更高版本時,以及在支持 gather 操作的網(wǎng)卡上,可以進(jìn)一步優(yōu)化。
如下圖所示:
按照前面的示例,調(diào)用 transferTo() 方法會導(dǎo)致設(shè)備通過 DMA 引擎將數(shù)據(jù)讀入內(nèi)核緩沖區(qū)。
但是,對于 gather 操作,讀緩沖區(qū)和套接字緩沖區(qū)之間不存在復(fù)制。相反,NIC被賦予一個指向讀緩沖區(qū)的指針,連同偏移量和長度。在任何情況下,CPU 都不涉及復(fù)制緩沖區(qū)。
文件大小從幾 MB 到 1GB 的范圍內(nèi),傳統(tǒng)拷貝和零拷貝相比,結(jié)果顯示零拷貝的性能提高了兩到三倍。
但更令人印象深刻的是,Kafka 使用純 JVM 實(shí)現(xiàn)了這一點(diǎn),沒有本地庫或 JNI 代碼。
避免垃圾回收
大量使用通道、緩沖區(qū)和頁面緩存還有一個額外的好處——減少垃圾收集器的工作負(fù)載。
例如,在 32 GB RAM 的機(jī)器上運(yùn)行 Kafka 將產(chǎn)生 28-30 GB 的頁面緩存可用空間,完全超出了垃圾收集器的范圍。
吞吐量的差異非常小(大約幾個百分點(diǎn)),但是經(jīng)過正確調(diào)優(yōu)的垃圾收集器的吞吐量可能非常高,特別是在處理短生存期對象時。真正的收益在于減少抖動。
通過避免垃圾回收,服務(wù)端不太可能遇到因垃圾回收引起的程序暫停,從而影響客戶端,加大記錄的通信延遲。
與初期的 Kafka 相比,現(xiàn)在避免垃圾回收已經(jīng)不是什么問題了。像 Shenandoah 和 ZGC 這樣的現(xiàn)代垃圾收集器可以擴(kuò)展到巨大的、多 TB 級的堆,在最壞的情況下,并且可以自動調(diào)整垃圾收集的暫停時間,降到幾毫秒。
現(xiàn)在,可以看見大量的基于 Java 虛擬機(jī)的應(yīng)用程序使用堆緩存,而不是堆外緩存。
流處理的并行性
日志的 I/O 效率是性能的一個重要方面,主要的性能影響在于寫。Kafka 對主題結(jié)構(gòu)和消費(fèi)生態(tài)系統(tǒng)中的并行性處理是其讀性能的基礎(chǔ)。
這種組合產(chǎn)生了整體非常高的端到端消息吞吐量。將并發(fā)性深入到分區(qū)方案和使用者組的操作中,這實(shí)際上是 Kafka 中的一種負(fù)載均衡機(jī)制——將分區(qū)平均地分配到各個消費(fèi)者中。
將此與傳統(tǒng)的消息隊(duì)列進(jìn)行比較:在 RabbitMQ 的設(shè)置中,多個并發(fā)的消費(fèi)者可以以輪詢的方式從隊(duì)列中讀取數(shù)據(jù),但這樣做會喪失消息的有序性。
分區(qū)機(jī)制有利于 Kafka 服務(wù)端的水平擴(kuò)展。每個分區(qū)都有一個專門的領(lǐng)導(dǎo)者。因此,任何重要的多分區(qū)的主題都可以利用整個服務(wù)端集群進(jìn)行寫操作。
這是 Kafka 和傳統(tǒng)消息隊(duì)列的另一個區(qū)別。當(dāng)后者利用集群來提高可用性時,Kafka 通過負(fù)載均衡來提高可用性、持久性和吞吐量。
發(fā)布具有多個分區(qū)的主題時,生產(chǎn)者指定發(fā)布記錄時的分區(qū)。(可能有一個單分區(qū)主題,那就不是問題了)
可以通過指定分區(qū)索引直接完成,或通過記錄鍵間接完成,記錄鍵通過計(jì)算散列值確定分區(qū)索引。具有相同散列值的記錄共享相同的分區(qū)。
假設(shè)一個主題有多個分區(qū),那么具有不同鍵的記錄可能會出現(xiàn)在不同的分區(qū)中。
然而,由于散列沖突,具有不同散列值的記錄也可能最終出現(xiàn)在同一個分區(qū)中。這就是散列的本質(zhì)。如果你理解了散列表的工作方式,一切都很自然了。
記錄的實(shí)際處理由消費(fèi)者完成,在一個可選的消費(fèi)者組中完成。Kafka 保證一個分區(qū)最多只能分配給消費(fèi)者組中的一個消費(fèi)者。(為什么用”最多“,當(dāng)所有消費(fèi)者都離線時,那就是 0 個消費(fèi)者了)
當(dāng)組中的第一個消費(fèi)者訂閱主題時,它將接收該主題上的所有分區(qū)。當(dāng)?shù)诙€消費(fèi)者訂閱主題時,它將接收到大約一半的分區(qū),從而減輕第一個消費(fèi)者的負(fù)載。
根據(jù)需要添加消費(fèi)者(理想情況下,使用自動伸縮機(jī)制),這使你能夠并行地處理事件流,前提是你已經(jīng)對事件流進(jìn)行了分區(qū)。
以兩種方式控制記錄的吞吐量:
①主題分區(qū)方案。應(yīng)該對主題進(jìn)行分區(qū),最大化事件流的數(shù)量。換句話說,只有在絕對需要時才提供記錄的順序。
如果任何兩個記錄不存在關(guān)聯(lián),它們就不應(yīng)該被綁定到同一個分區(qū)。這意味著要使用不同的鍵,因?yàn)?Kafka 使用記錄鍵的散列值作為分區(qū)映射的根據(jù)。
②組中消費(fèi)者的數(shù)量。你可以增加消費(fèi)者的數(shù)量來均衡入站記錄的負(fù)載,消費(fèi)者的數(shù)量最多可以增加到和分區(qū)數(shù)量一樣多。(你可以增加更多的消費(fèi)者,但每個分區(qū)最多只能有一個的活動消費(fèi)者,剩下的消費(fèi)者將處于閑置狀態(tài))
請注意,你可以提供一個線程池,根據(jù)消費(fèi)者執(zhí)行工作負(fù)載的不同,消費(fèi)者可以是一個進(jìn)程或一個線程。
如果你想知道 Kafka 為什么這么快,它是如何做到的,以及它是否適合你,我想你現(xiàn)在已經(jīng)有了答案了。
為了更清楚地說明問題,Kafka 不是最快的消息中間件,吞吐量也不是最大的。有其他平臺能夠提供更高的吞吐量——有些是基于軟件的,有些是基于硬件的。
很難同時做到吞吐量大且延遲低,Apache Pulsar[1] 是一個有前途的技術(shù),可擴(kuò)展,更好的吞吐量-延遲配置文件,同時提供順序性和持久性。
采用 Kafka 的理由是,作為一個完整的生態(tài)系統(tǒng),它在整體上仍然是無與倫比的。
它展示了出色的性能,同時提供了一個豐富和成熟的環(huán)境,Kafka 仍在以令人羨慕的速度增長。
Kafka 的設(shè)計(jì)者和維護(hù)者在設(shè)計(jì)一個以性能為核心的解決方案時做了大量的工作。它的設(shè)計(jì)元素中很少有讓人覺得是事后才想到的,或者是補(bǔ)全的。
從將工作負(fù)載轉(zhuǎn)移到客戶端,到服務(wù)端日志的持久性、批處理、壓縮、零拷貝 I/O 和并行流處理——Kafka 向任何其他消息中間件廠商發(fā)起挑戰(zhàn),無論是商業(yè)的還是開源的。
最令人印象深刻的是,它做到了這一點(diǎn),卻沒有犧牲持久性、記錄有序性和至少一次分發(fā)的語義。
Kafka 不是最簡單的消息中間件平臺,還有許多需要改進(jìn)的地方。在設(shè)計(jì)和構(gòu)建高性能事件驅(qū)動系統(tǒng)之前,必須掌握總體和部分的順序、主題、分區(qū)、消費(fèi)者和消費(fèi)者組的概念。
雖然知識曲線很陡峭,但值得你花時間去學(xué)習(xí)。如果你知道這個諺語“red pill”(red pill,指為了達(dá)到對某種事物的深度探索或追求,選擇去思考,不放棄,繼續(xù)走下去,哪怕這條路多難走),請閱讀“介紹 Kafka 和 Kafdrop 中的事件流 Introduction to Event Streaming with Kafka and Kafdrop[2]”。
相關(guān)鏈接:
- https://pulsar.apache.org/
- https://medium.com/swlh/introduction-to-event-streaming-with-kafka-and-kafdrop-22afdb4b380a