天天看點

聊聊 Kafka: Kafka 為啥這麼快?

作者:老周聊架構

一、前言

我們都知道 Kafka 是基于磁盤進行存儲的,但 Kafka 官方又稱其具有高性能、高吞吐、低延時的特點,其吞吐量動辄幾十上百萬。小夥伴們是不是有點困惑了,一般認為在磁盤上讀寫資料是會降低性能的,因為尋址會比較消耗時間。那 Kafka 又是怎麼做到其吞吐量動辄幾十上百萬的呢?

Kafka 高性能,是多方面協同的結果,包括宏觀架構、分布式 partition 存儲、ISR 資料同步、以及“無所不用其極”的高效利用磁盤、作業系統特性。

别急,下面老周從資料的寫入與讀取兩個次元來帶大家一探究竟。

二、順序寫入

磁盤讀寫有兩種方式:順序讀寫或者随機讀寫。在順序讀寫的情況下,磁盤的順序讀寫速度和記憶體持平。

因為磁盤是機械結構,每次讀寫都會尋址->寫入,其中尋址是一個“機械動作”。為了提高讀寫磁盤的速度,Kafka 就是使用順序 I/O。

聊聊 Kafka: Kafka 為啥這麼快?

Kafka 利用了一種分段式的、隻追加 (Append-Only) 的日志,基本上把自身的讀寫操作限制為順序 I/O,也就使得它在各種存儲媒體上能有很快的速度。一直以來,有一種廣泛的誤解認為磁盤很慢。實際上,存儲媒體 (特别是旋轉式的機械硬碟) 的性能很大程度依賴于通路模式。在一個 7200 轉/分鐘的 SATA 機械硬碟上,随機 I/O 的性能比順序 I/O 低了大概 3 到 4 個數量級。此外,一般來說現代的作業系統都會提供預讀和延遲寫技術:以大資料塊的倍數預先載入資料,以及合并多個小的邏輯寫操作成一個大的實體寫操作。正因為如此,順序 I/O 和随機 I/O 之間的性能差距在 flash 和其他固态非易失性存儲媒體中仍然很明顯,盡管它遠沒有旋轉式的存儲媒體那麼明顯。

這裡給出著名學術期刊 ACM Queue 上的性能對比圖: https://queue.acm.org/detail.cf

聊聊 Kafka: Kafka 為啥這麼快?

下圖就展示了 Kafka 是如何寫入資料的, 每一個 Partition 其實都是一個檔案 ,收到消息後 Kafka 會把資料插入到檔案末尾(虛框部分):

聊聊 Kafka: Kafka 為啥這麼快?

這種方法采用了隻讀設計 ,是以 Kafka 是不會修改、删除資料的,它會把所有的資料都保留下來,每個消費者(Consumer)對每個 Topic 都有一個 offset 用來表示讀取到了第幾條資料 。

聊聊 Kafka: Kafka 為啥這麼快?

磁盤的順序讀寫是磁盤使用模式中最有規律的,并且作業系統也對這種模式做了大量優化,Kafka 就是使用了磁盤順序讀寫來提升的性能。Kafka 的 message 是不斷追加到本地磁盤檔案末尾的,而不是随機的寫入,這使得 Kafka 寫入吞吐量得到了顯著提升。

三、頁緩存

即便是順序寫入硬碟,硬碟的通路速度還是不可能追上記憶體。是以 Kafka 的資料并不是實時的寫入硬碟 ,它充分利用了現代作業系統分頁存儲來利用記憶體提高 I/O 效率。具體來說,就是把磁盤中的資料緩存到記憶體中,把對磁盤的通路變為對記憶體的通路。

Kafka 接收來自 socket buffer 的網絡資料,應用程序不需要中間處理、直接進行持久化時。可以使用mmap 記憶體檔案映射。

3.1 Memory Mapped Files

簡稱 mmap,簡單描述其作用就是:将磁盤檔案映射到記憶體,使用者通過修改記憶體就能修改磁盤檔案。

它的工作原理是直接利用作業系統的 Page 來實作磁盤檔案到實體記憶體的直接映射。完成映射之後你對實體記憶體的操作會被同步到硬碟上(作業系統在适當的時候)。

聊聊 Kafka: Kafka 為啥這麼快?

通過 mmap,程序像讀寫硬碟一樣讀寫記憶體(當然是虛拟機記憶體)。使用這種方式可以擷取很大的 I/O 提升,省去了使用者空間到核心空間複制的開銷。

mmap 也有一個很明顯的缺陷:不可靠,寫到 mmap 中的資料并沒有被真正的寫到硬碟,作業系統會在程式主動調用 flush 的時候才把資料真正的寫到硬碟。

Kafka 提供了一個參數 producer.type 來控制是不是主動 flush:

  • 如果 Kafka 寫入到 mmap 之後就立即 flush,然後再傳回 Producer 叫同步(sync);
  • 寫入 mmap 之後立即傳回 Producer 不調用 flush 叫異步(async)。

3.2 Java NIO 對檔案映射的支援

Java NIO,提供了一個 MappedByteBuffer 類可以用來實作記憶體映射。

MappedByteBuffer 隻能通過調用 FileChannel 的 map() 取得,再沒有其他方式。

FileChannel.map() 是抽象方法,具體實作是在 FileChannelImpl.map() 可自行檢視 JDK 源碼,其 map0() 方法就是調用了 Linux 核心的 mmap 的 API。

聊聊 Kafka: Kafka 為啥這麼快?
聊聊 Kafka: Kafka 為啥這麼快?
聊聊 Kafka: Kafka 為啥這麼快?

3.3 使用 MappedByteBuffer 類注意事項

mmap 的檔案映射,在 full gc 時才會進行釋放。當 close 時,需要手動清除記憶體映射檔案,可以反射調用 sun.misc.Cleaner 方法。

當一個程序準備讀取磁盤上的檔案内容時:

  • 作業系統會先檢視待讀取的資料所在的頁(page)是否在頁緩存(pagecache)中,如果存在(命中) 則直接傳回資料,進而避免了對實體磁盤的 I/O 操作;
  • 如果沒有命中,則作業系統會向磁盤發起讀取請求并将讀取的資料頁存入頁緩存,之後再将資料傳回給程序。

如果一個程序需要将資料寫入磁盤:

  • 作業系統也會檢測資料對應的頁是否在頁緩存中,如果不存在,則會先在頁緩存中添加相應的頁,最後将資料寫入對應的頁。
  • 被修改過後的頁也就變成了髒頁,作業系統會在合适的時間把髒頁中的資料寫入磁盤,以保持資料的一緻性。

對一個程序而言,它會在程序内部緩存處理所需的資料,然而這些資料有可能還緩存在作業系統的頁緩存中,是以同一份資料有可能被緩存了兩次。并且,除非使用 Direct I/O 的方式, 否則頁緩存很難被禁止。

當使用頁緩存的時候,即使 Kafka 服務重新開機, 頁緩存還是會保持有效,然而程序内的緩存卻需要重建。這樣也極大地簡化了代碼邏輯,因為維護頁緩存和檔案之間的一緻性交由作業系統來負責,這樣會比程序内維護更加安全有效。

Kafka 中大量使用了頁緩存,這是 Kafka 實作高吞吐的重要因素之一。

消息先被寫入頁緩存,由作業系統負責刷盤任務。

四、零拷貝

導緻應用程式效率低下的一個典型根源是緩沖區之間的位元組資料拷貝。Kafka 使用由 Producer、Broker 和 Consumer 多方共享的二進制消息格式,是以資料塊即便是處于壓縮狀态也可以在不被修改的情況下在端到端之間流動。雖然消除通信各方之間的結構化差異是非常重要的一步,但它本身并不能避免資料的拷貝。

Kafka 通過利用 Java 的 NIO 架構,尤其是 java.nio.channels.FileChannel 裡的 transferTo 這個方法,解決了前面提到的在 Linux 等類 UNIX 系統上的資料拷貝問題。此方法能夠在不借助作為傳輸中介的應用程式的情況下,将位元組資料從源通道直接傳輸到接收通道。要了解 NIO 的帶來的改進,請考慮傳統方式下作為兩個單獨的操作:源通道中的資料被讀入位元組緩沖區,接着寫入接收通道:

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

通過圖表來說明,這個過程可以被描述如下:

盡管上面的過程看起來已經足夠簡單,但是在内部仍需要 4 次使用者态和核心态的上下文切換來完成拷貝操作,而且需要拷貝 4 次資料才能完成這個操作。下面的示意圖概述了每一個步驟中的上下文切換。

聊聊 Kafka: Kafka 為啥這麼快?

讓我們來更詳細地看一下細節:

  • 初始的 read() 調用導緻了一次使用者态到核心态的上下文切換。DMA (Direct Memory Access 直接記憶體通路) 引擎讀取檔案,并将其内容複制到核心位址空間中的緩沖區中。這個緩沖區和上面的代碼片段裡使用的并非同一個。
  • 在從 read() 傳回之前,核心緩沖區的資料會被拷貝到使用者态的緩沖區。此時,我們的程式可以讀取檔案的内容。
  • 接下來的 send() 方法會切換回核心态,拷貝使用者态的緩沖區資料到核心位址空間 —— 這一次是拷貝到一個關聯着目标套接字的不同緩沖區。在背景,DMA 引擎會接手這一操作,異步地把資料從核心緩沖區拷貝到協定堆棧,由網卡進行網絡傳輸。 send() 方法在傳回之前不等待此操作。
  • send() 調用傳回,切換回使用者态。

盡管模式切換的效率很低,而且需要進行額外的拷貝,但在許多情況下,中間核心緩沖區的性能實際上可以進一步提高。比如它可以作為一個預讀緩存,異步預載入資料塊,進而可以在應用程式前端運作請求。但是,當請求的資料量極大地超過核心緩沖區大小時,核心緩沖區就會成為性能瓶頸。它不會直接拷貝資料,而是迫使系統在使用者态和核心态之間搖擺,直到所有資料都被傳輸完成。

相比之下,零拷貝方式能在單個操作中處理完成。前面示例中的代碼片段現在能重寫為一行程式:

fileDesc.transferTo(offset, len, socket);
           

零拷貝方式可以用下圖來說明:

聊聊 Kafka: Kafka 為啥這麼快?

在這種模式下,上下文的切換次數被縮減至一次。具體來說, transferTo() 方法訓示資料塊裝置通過 DMA 引擎将資料讀入讀緩沖區,然後這個緩沖區的資料拷貝到另一個核心緩沖區中,分階段寫入套接字。最後,DMA 将套接字緩沖區的資料拷貝到 NIC 緩沖區中。

聊聊 Kafka: Kafka 為啥這麼快?

在這裡插入圖檔描述

最終結果,我們已經把拷貝的次數從 4 降到了 3,而且其中隻有一次拷貝占用了 CPU 資源。我們也已經把上下文切換的次數從 4 降到了 2。

把磁盤檔案讀取 OS 核心緩沖區後的 fileChannel,直接轉給 socketChannel 發送;底層就是 sendfile。消費者從 broker 讀取資料,就是由此實作。

具體來看,Kafka 的資料傳輸通過 TransportLayer 來完成,其子類 PlaintextTransportLayer 通過 Java NIO 的 FileChannel 的 transferTo 和 transferFrom 方法實作零拷貝。

聊聊 Kafka: Kafka 為啥這麼快?

注:transferTo 和 transferFrom 并不保證一定能使用零拷貝,需要作業系統支援。

這是一個巨大的提升,不過還沒有實作完全 "零拷貝"。我們可以通過利用 Linux 核心 2.4 或更高版本以及支援 gather 操作的網卡來做進一步的優化進而實作真正的 "零拷貝"。下面的示意圖可以說明:

聊聊 Kafka: Kafka 為啥這麼快?

調用 transferTo() 方法會緻使裝置通過 DMA 引擎将資料讀入核心讀緩沖區,就像前面的例子那樣。然而,通過 gather 操作,讀緩沖區和套接字緩沖區之間的資料拷貝将不複存在。相反地,NIC 被賦予一個指向讀緩沖區的指針,連同偏移量和長度,所有資料都将通過 DMA 抽取幹淨并拷貝到 NIC 緩沖區。在這個過程中,在緩沖區間拷貝資料将無需占用任何 CPU 資源。

傳統的方式和零拷貝方式在 MB 位元組到 GB 位元組的檔案大小範圍内的性能對比顯示,零拷貝方式相較于傳統方式的性能提升幅度在 2 到 3 倍。但更令人驚歎的是,Kafka 僅僅是在一個純 JVM 虛拟機下、沒有使用本地庫或 JNI 代碼,就實作了這一點。

五、Broker 性能

5.1 日志記錄批處理

順序 I/O 在大多數的存儲媒體上都非常快,幾乎可以和網絡 I/O 的峰值性能相媲美。在實踐中,這意味着一個設計良好的日志結構的持久層将可以緊随網絡流量的速度。事實上,Kafka 的瓶頸通常是網絡而非磁盤。是以,除了由作業系統提供的底層批處理能力之外,Kafka 的 Clients 和 Brokers 會把多條讀寫的日志記錄合并成一個批次,然後才通過網絡發送出去。日志記錄的批處理通過使用更大的包以及提高帶寬效率來攤薄網絡往返的開銷。

5.2 批量壓縮

當啟用壓縮功能時,批處理的影響尤為明顯,因為壓縮效率通常會随着資料量大小的增加而變得更高。特别是當使用 JSON 等基于文本的資料格式時,壓縮效果會非常顯著,壓縮比通常能達到 5 到 7 倍。此外,日志記錄批處理在很大程度上是作為 Client 側的操作完成的,此舉把負載轉移到 Client 上,不僅對網絡帶寬效率、而且對 Brokers 的磁盤 I/O 使用率也有很大的提升。

5.3 非強制重新整理緩沖寫操作

另一個助力 Kafka 高性能、同時也是一個值得更進一步去探究的底層原因:Kafka 在确認寫成功 ACK 之前的磁盤寫操作不會真正調用 fsync 指令;通常隻需要確定日志記錄被寫入到 I/O Buffer 裡就可以給 Client 回複 ACK 信号。這是一個鮮為人知卻至關重要的事實:事實上,這正是讓 Kafka 能表現得如同一個記憶體型消息隊列的原因 —— 因為 Kafka 是一個基于磁盤的記憶體型消息隊列 (受緩沖區/頁面緩存大小的限制)。

另一方面,這種形式的寫入是不安全的,因為副本的寫失敗可能會導緻資料丢失,即使日志記錄似乎已經被确認成功。換句話說,與關系型資料庫不同,确認一個寫操作成功并不等同于持久化成功。真正使得 Kafka 具備持久化能力的是運作多個同步的副本的設計;即便有一個副本寫失敗了,其他的副本(假設有多個)仍然可以保持可用狀态,前提是寫失敗是不相關的(例如,多個副本由于一個共同的上遊故障而同時寫失敗)。是以,不使用 fsync 的 I/O 非阻塞方法和備援同步副本的結合,使得 Kafka 同時具備了高吞吐量、持久性和可用性。

六、流資料并行

日志結構 I/O 的效率是影響性能的一個關鍵因素,主要影響寫操作;Kafka 在對 Topic 結構和 Consumer 群組的并行處理是其讀性能的基礎。這種組合産生了非常高的端到端消息傳遞總體吞吐量。并發性根深蒂固地存在于 Kafka 的分區方案和 Consumer Groups 的操作中,這是 Kafka 中一種有效的負載均衡機制 —— 把資料分區 (Partition) 近似均勻地配置設定給組内的各個 Consumer 執行個體。将此與更傳統的 MQ 進行比較:在 RabbitMQ 的等效設定中,多個并發的 Consumers 可能以輪詢的方式從隊列讀取資料,然而這樣做,就會失去消息消費的順序性。

分區機制也使得 Kafka Brokers 可以水準擴充。每個分區都有一個專門的 Leader;是以,任何重要的主題 Topic (具有多個分區) 都可以利用整個 Broker 叢集進行寫操作,這是 Kafka 和消息隊列之間的另一個差別;後者利用叢集來獲得可用性,而 Kafka 将真正地在 Brokers 之間負載均衡,以獲得可用性、持久性和吞吐量。

生産者在釋出日志記錄之時指定分區,假設你正在釋出消息到一個有多個分區的 Topic 上。(也可能有單一分區的 Topic, 這種情況下将不成問題。) 這可以通過直接指定分區索引來完成,或者間接通過日志記錄的鍵值來完成,該鍵值能被确定性地哈希到一個一緻的 (即每次都相同) 分區索引。擁有相同哈希值的日志記錄将會被存儲到同一個分區中。假設一個 Topic 有多個分區,那些不同哈希值的日志記錄将很可能最後被存儲到不同的分區裡。但是,由于哈希碰撞的緣故,不同哈希值的日志記錄也可能最後被存儲到相同的分區裡。這是哈希的本質,如果你了解哈希表的原理,那應該是顯而易見的。

日志記錄的實際處理是由一個在 (可選的) Consumer Group 中的 Consumer 操作完成。Kafka 確定一個分區最多隻能配置設定給它的 Consumer Group 中的一個 Consumer 。(我們說 "最多" 是因為考慮到一種全部 Consumer 都離線的情況。) 當第一個 Consumer Group 裡的 Consumer 訂閱了 Topic,它将消費這個 Topic 下的所有分區的資料。當第二個 Consumer 緊随其後加入訂閱時,它将大緻獲得這個 Topic 的一半分區,減輕第一個 Consumer 先前負荷的一半。這使得你能夠并行處理事件流,并根據需要增加 Consumer (理想情況下,使用自動伸縮機制),前提是你已經對事件流進行了合理的分區。

日志記錄吞吐量的控制一般通過以下兩種方式來達成:

  • Topic 的分區方案。應該對 Topics 進行分區,以最大限度地增加獨立子事件流的數量。換句話說,日志記錄的順序應該隻保留在絕對必要的地方。如果任意兩個日志記錄在某種意義上沒有合理的關聯,那它們就不應該被綁定到同一個分區。這暗示你要使用不同的鍵值,因為 Kafka 将使用日志記錄的鍵值作為一個散列源來派生其一緻的分區映射。
  • 一個組裡的 Consumers 數量。你可以增加 Consumer Group 裡的 Consumer 數量來均衡入站的日志記錄的負載,這個數量的上限是 Topic 的分區數量。(如果你願意的話,你當然可以增加更多的 Consumers ,不過分區計數将會設定一個上限來確定每一個活躍的 Consumer 至少被指派到一個分區,多出來的 Consumers 将會一直保持在一個空閑的狀态。) 請注意, Consumer 可以是程序或線程。依據 Consumer 執行的工作負載類型,你可以線上程池中使用多個獨立的 Consumer 線程或程序記錄。

如果你之前一直想知道 Kafka 是否很快、它是如何擁有其現如今公認的高性能标簽,或者它是否可以滿足你的使用場景,那麼相信你現在應該有了所需的答案。

為了讓事情足夠清楚,必須說明 Kafka 并不是最快的 (也就是說,具有最大吞吐量能力的) 消息傳遞中間件,還有其他具有更大吞吐量的平台 —— 有些是基于軟體的 —— 有些是在硬體中實作的。Apache Pulsar 是一項極具前景的技術,它具備可擴充性,在提供相同的消息順序性和持久性保證的同時,還能實作更好的吞吐量-延遲效果。使用 Kafka 的根本原因是,它作為一個完整的生态系統仍然是無與倫比的。它展示了卓越的性能,同時提供了一個豐富和成熟而且還在不斷進化的環境,盡管 Kafka 的規模已經相當龐大了,但仍以一種令人羨慕的速度在成長。

Kafka 的設計者和維護者們在創造一個以性能導向為核心的解決方案這方面做得非常出色。它的大多數設計/理念元素都是早期就構思完成、幾乎沒有什麼是事後才想到的,也沒有什麼是附加的。從把工作負載分攤到 Client 到 Broker 上的日志結構持久性,批處理、壓縮、零拷貝 I/O 和流資料級并行 —— Kafka 向幾乎所有其他面向消息的中間件 (商業的或開源的) 發起了挑戰。而且最令人歎為觀止的是,它做到這些事情的同時竟然沒有犧牲掉持久性、日志記錄順序性和至少傳遞一次的語義等特性。

七、總結

7.1 mmap 和 sendfile

  • Linux 核心提供、實作零拷貝的 API。
  • mmap 将磁盤檔案映射到記憶體,支援讀和寫,對記憶體的操作會反映在磁盤檔案上。
  • sendfile 是将讀到核心空間的資料,轉到 socket buffer,進行網絡發送。
  • RocketMQ 在消費消息時,使用了 mmap;Kafka 使用了 sendfile。

7.2 Kafka 為啥這麼快?

  • Partition 順序讀寫,充分利用磁盤特性,這是基礎。
  • Producer 生産的資料持久化到 Broker,采用 mmap 檔案映射,實作順序的快速寫入。
  • Customer 從 Broker 讀取資料,采用 sendfile,将磁盤檔案讀到 OS 核心緩沖區後,直接轉到 socket buffer 進行網絡發送。
  • Broker 性能優化:日志記錄批處理、批量壓縮、非強制重新整理緩沖寫操作等。
  • 流資料并行