kafka--Kafka 設計解析(六):Kafka 高性能關鍵技術解析

轉載自:https://www.infoq.cn/article/kafka-analysis-part-6

上一篇文章《Kafka 設計解析(五)- Kafka 性能測試方法及 Benchmark 報告》從測試角度說明了 Kafka 的性能。本文從宏觀架構層面和具體實現層面分析了 Kafka 如何實現高性能。

宏觀架構層面

利用 Partition 實現並行處理

Partition 提供並行處理的能力

Kafka 是一個 Pub-Sub 的消息系統,無論是發佈還是訂閱,都須指定 Topic。如《Kafka 設計解析(一)- Kafka 背景及架構介紹》一文所述,Topic 只是一個邏輯的概念。每個 Topic 都包含一個或多個 Partition,不同 Partition 可位於不同節點。同時 Partition 在物理上對應一個本地文件夾,每個 Partition 包含一個或多個 Segment,每個 Segment 包含一個數據文件和一個與之對應的索引文件。在邏輯上,可以把一個 Partition 當作一個非常長的數組,可通過這個“數組”的索引(offset)去訪問其數據。

一方面,由於不同 Partition 可位於不同機器,因此可以充分利用集羣優勢,實現機器間的並行處理。另一方面,由於 Partition 在物理上對應一個文件夾,即使多個 Partition 位於同一個節點,也可通過配置讓同一節點上的不同 Partition 置於不同的 disk drive 上,從而實現磁盤間的並行處理,充分發揮多磁盤的優勢。

利用多磁盤的具體方法是,將不同磁盤 mount 到不同目錄,然後在 server.properties 中,將 log.dirs 設置爲多目錄(用逗號分隔)。Kafka 會自動將所有 Partition 儘可能均勻分配到不同目錄也即不同目錄(也即不同 disk)上。

注:雖然物理上最小單位是 Segment,但 Kafka 並不提供同一 Partition 內不同 Segment 間的並行處理。因爲對於寫而言,每次只會寫 Partition 內的一個 Segment,而對於讀而言,也只會順序讀取同一 Partition 內的不同 Segment。

Partition 是最小併發粒度

如同《Kafka 設計解析(四)- Kafka Consumer 設計解析》一文所述,多 Consumer 消費同一個 Topic 時,同一條消息只會被同一 Consumer Group 內的一個 Consumer 所消費。而數據並非按消息爲單位分配,而是以 Partition 爲單位分配,也即同一個 Partition 的數據只會被一個 Consumer 所消費(在不考慮 Rebalance 的前提下)。

如果 Consumer 的個數多於 Partition 的個數,那麼會有部分 Consumer 無法消費該 Topic 的任何數據,也即當 Consumer 個數超過 Partition 後,增加 Consumer 並不能增加並行度。

簡而言之,Partition 個數決定了可能的最大並行度。如下圖所示,由於 Topic 2 只包含 3 個 Partition,故 group2 中的 Consumer 3、Consumer 4、Consumer 5 可分別消費 1 個 Partition 的數據,而 Consumer 6 消費不到 Topic 2 的任何數據。

(點擊放大圖像)

以 Spark 消費 Kafka 數據爲例,如果所消費的 Topic 的 Partition 數爲 N,則有效的 Spark 最大並行度也爲 N。即使將 Spark 的 Executor 數設置爲 N+M,最多也只有 N 個 Executor 可同時處理該 Topic 的數據。

ISR 實現可用性與數據一致性的動態平衡

CAP 理論

CAP 理論是指,分佈式系統中,一致性、可用性和分區容忍性最多隻能同時滿足兩個。

一致性

- 通過某個節點的寫操作結果對後面通過其它節點的讀操作可見

- 如果更新數據後,併發訪問情況下後續讀操作可立即感知該更新,稱爲強一致性

- 如果允許之後部分或者全部感知不到該更新,稱爲弱一致性

- 若在之後的一段時間(通常該時間不固定)後,一定可以感知到該更新,稱爲最終一致性

可用性

- 任何一個沒有發生故障的節點必須在有限的時間內返回合理的結果

分區容忍性

- 部分節點宕機或者無法與其它節點通信時,各分區間還可保持分佈式系統的功能

一般而言,都要求保證分區容忍性。所以在 CAP 理論下,更多的是需要在可用性和一致性之間做權衡。

常用數據複製及一致性方案

Master-Slave

- RDBMS 的讀寫分離即爲典型的 Master-Slave 方案

- 同步複製可保證強一致性但會影響可用性

- 異步複製可提供高可用性但會降低一致性

WNR

- 主要用於去中心化的分佈式系統中。DynamoDB 與 Cassandra 即採用此方案或其變種

- N 代表總副本數,W 代表每次寫操作要保證的最少寫成功的副本數,R 代表每次讀至少要讀取的副本數

- 當 W+R>N 時,可保證每次讀取的數據至少有一個副本擁有最新的數據

- 多個寫操作的順序難以保證,可能導致多副本間的寫操作順序不一致。Dynamo 通過向量時鐘保證最終一致性

Paxos 及其變種

- Google 的 Chubby,Zookeeper 的原子廣播協議(Zab),RAFT 等

基於 ISR 的數據複製方案

如《 Kafka High Availability(上)》一文所述,Kafka 的數據複製是以 Partition 爲單位的。而多個備份間的數據複製,通過 Follower 向 Leader 拉取數據完成。從一這點來講,Kafka 的數據複製方案接近於上文所講的 Master-Slave 方案。不同的是,Kafka 既不是完全的同步複製,也不是完全的異步複製,而是基於 ISR 的動態複製方案。

ISR,也即 In-sync Replica。每個 Partition 的 Leader 都會維護這樣一個列表,該列表中,包含了所有與之同步的 Replica(包含 Leader 自己)。每次數據寫入時,只有 ISR 中的所有 Replica 都複製完,Leader 纔會將其置爲 Commit,它才能被 Consumer 所消費。

這種方案,與同步複製非常接近。但不同的是,這個 ISR 是由 Leader 動態維護的。如果 Follower 不能緊“跟上”Leader,它將被 Leader 從 ISR 中移除,待它又重新“跟上”Leader 後,會被 Leader 再次加加 ISR 中。每次改變 ISR 後,Leader 都會將最新的 ISR 持久化到 Zookeeper 中。

至於如何判斷某個 Follower 是否“跟上”Leader,不同版本的 Kafka 的策略稍微有些區別。

- 對於 0.8.* 版本,如果 Follower 在 replica.lag.time.max.ms 時間內未向 Leader 發送 Fetch 請求(也即數據複製請求),則 Leader 會將其從 ISR 中移除。如果某 Follower 持續向 Leader 發送 Fetch 請求,但是它與 Leader 的數據差距在 replica.lag.max.messages 以上,也會被 Leader 從 ISR 中移除。

- 從 0.9.0.0 版本開始,replica.lag.max.messages 被移除,故 Leader 不再考慮 Follower 落後的消息條數。另外,Leader 不僅會判斷 Follower 是否在 replica.lag.time.max.ms 時間內向其發送 Fetch 請求,同時還會考慮 Follower 是否在該時間內與之保持同步。

- 0.10.* 版本的策略與 0.9.* 版一致

對於 0.8.* 版本的 replica.lag.max.messages 參數,很多讀者曾留言提問,既然只有 ISR 中的所有 Replica 複製完後的消息才被認爲 Commit,那爲何會出現 Follower 與 Leader 差距過大的情況。原因在於,Leader 並不需要等到前一條消息被 Commit 才接收後一條消息。事實上,Leader 可以按順序接收大量消息,最新的一條消息的 Offset 被記爲 High Wartermark。而只有被 ISR 中所有 Follower 都複製過去的消息纔會被 Commit,Consumer 只能消費被 Commit 的消息。由於 Follower 的複製是嚴格按順序的,所以被 Commit 的消息之前的消息肯定也已經被 Commit 過。換句話說,High Watermark 標記的是 Leader 所保存的最新消息的 offset,而 Commit Offset 標記的是最新的可被消費的(已同步到 ISR 中的 Follower)消息。而 Leader 對數據的接收與 Follower 對數據的複製是異步進行的,因此會出現 Commit Offset 與 High Watermark 存在一定差距的情況。0.8.* 版本中 replica.lag.max.messages 限定了 Leader 允許的該差距的最大值。

Kafka 基於 ISR 的數據複製方案原理如下圖所示。

(點擊放大圖像)

如上圖所示,在第一步中,Leader A 總共收到 3 條消息,故其 high watermark 爲 3,但由於 ISR 中的 Follower 只同步了第 1 條消息(m1),故只有 m1 被 Commit,也即只有 m1 可被 Consumer 消費。此時 Follower B 與 Leader A 的差距是 1,而 Follower C 與 Leader A 的差距是 2,均未超過默認的 replica.lag.max.messages,故得以保留在 ISR 中。在第二步中,由於舊的 Leader A 宕機,新的 Leader B 在 replica.lag.time.max.ms 時間內未收到來自 A 的 Fetch 請求,故將 A 從 ISR 中移除,此時 ISR={B,C}。同時,由於此時新的 Leader B 中只有 2 條消息,並未包含 m3(m3 從未被任何 Leader 所 Commit),所以 m3 無法被 Consumer 消費。第四步中,Follower A 恢復正常,它先將宕機前未 Commit 的所有消息全部刪除,然後從最後 Commit 過的消息的下一條消息開始追趕新的 Leader B,直到它“趕上”新的 Leader,才被重新加入新的 ISR 中。

使用 ISR 方案的原因

  • 由於 Leader 可移除不能及時與之同步的 Follower,故與同步複製相比可避免最慢的 Follower 拖慢整體速度,也即 ISR 提高了系統可用性。
  • ISR 中的所有 Follower 都包含了所有 Commit 過的消息,而只有 Commit 過的消息纔會被 Consumer 消費,故從 Consumer 的角度而言,ISR 中的所有 Replica 都始終處於同步狀態,從而與異步複製方案相比提高了數據一致性。
  • ISR 可動態調整,極限情況下,可以只包含 Leader,極大提高了可容忍的宕機的 Follower 的數量。與 Majority Quorum 方案相比,容忍相同個數的節點失敗,所要求的總節點數少了近一半。

ISR 相關配置說明

  • Broker 的 min.insync.replicas 參數指定了 Broker 所要求的 ISR 最小長度,默認值爲 1。也即極限情況下 ISR 可以只包含 Leader。但此時如果 Leader 宕機,則該 Partition 不可用,可用性得不到保證。
  • 只有被 ISR 中所有 Replica 同步的消息才被 Commit,但 Producer 發佈數據時,Leader 並不需要 ISR 中的所有 Replica 同步該數據才確認收到數據。Producer 可以通過 acks 參數指定最少需要多少個 Replica 確認收到該消息才視爲該消息發送成功。acks 的默認值是 1,即 Leader 收到該消息後立即告訴 Producer 收到該消息,此時如果在 ISR 中的消息複製完該消息前 Leader 宕機,那該條消息會丟失。而如果將該值設置爲 0,則 Producer 發送完數據後,立即認爲該數據發送成功,不作任何等待,而實際上該數據可能發送失敗,並且 Producer 的 Retry 機制將不生效。更推薦的做法是,將 acks 設置爲 all 或者 -1,此時只有 ISR 中的所有 Replica 都收到該數據(也即該消息被 Commit),Leader 纔會告訴 Producer 該消息發送成功,從而保證不會有未知的數據丟失。

具體實現層面

高效使用磁盤

順序寫磁盤

根據《一些場景下順序寫磁盤快於隨機寫內存》所述,將寫磁盤的過程變爲順序寫,可極大提高對磁盤的利用率。

Kafka 的整個設計中,Partition 相當於一個非常長的數組,而 Broker 接收到的所有消息順序寫入這個大數組中。同時 Consumer 通過 Offset 順序消費這些數據,並且不刪除已經消費的數據,從而避免了隨機寫磁盤的過程。

由於磁盤有限,不可能保存所有數據,實際上作爲消息系統 Kafka 也沒必要保存所有數據,需要刪除舊的數據。而這個刪除過程,並非通過使用“讀 - 寫”模式去修改文件,而是將 Partition 分爲多個 Segment,每個 Segment 對應一個物理文件,通過刪除整個文件的方式去刪除 Partition 內的數據。這種方式清除舊數據的方式,也避免了對文件的隨機寫操作。

通過如下代碼可知,Kafka 刪除 Segment 的方式,是直接刪除 Segment 對應的整個 log 文件和整個 index 文件而非刪除文件中的部分內容。

複製代碼


 
   
 

/**

 

* Delete this log segment from the filesystem.

 

*

 

* @throws KafkaStorageException if the delete fails.

 

*/

 

def delete() {

 

val deletedLog = log.delete()

 

val deletedIndex = index.delete()

 

val deletedTimeIndex = timeIndex.delete()

 

if(!deletedLog && log.file.exists)

 

throw new KafkaStorageException("Delete of log " + log.file.getName + " failed.")

 

if(!deletedIndex && index.file.exists)

 

throw new KafkaStorageException("Delete of index " + index.file.getName + " failed.")

 

if(!deletedTimeIndex && timeIndex.file.exists)

 

throw new KafkaStorageException("Delete of time index " + timeIndex.file.getName + " failed.")

 

}

充分利用 Page Cache

使用 Page Cache 的好處如下

- I/O Scheduler 會將連續的小塊寫組裝成大塊的物理寫從而提高性能

- I/O Scheduler 會嘗試將一些寫操作重新按順序排好,從而減少磁盤頭的移動時間

- 充分利用所有空閒內存(非 JVM 內存)。如果使用應用層 Cache(即 JVM 堆內存),會增加 GC 負擔

- 讀操作可直接在 Page Cache 內進行。如果消費和生產速度相當,甚至不需要通過物理磁盤(直接通過 Page Cache)交換數據

- 如果進程重啓,JVM 內的 Cache 會失效,但 Page Cache 仍然可用

Broker 收到數據後,寫磁盤時只是將數據寫入 Page Cache,並不保證數據一定完全寫入磁盤。從這一點看,可能會造成機器宕機時,Page Cache 內的數據未寫入磁盤從而造成數據丟失。但是這種丟失只發生在機器斷電等造成操作系統不工作的場景,而這種場景完全可以由 Kafka 層面的 Replication 機制去解決。如果爲了保證這種情況下數據不丟失而強制將 Page Cache 中的數據 Flush 到磁盤,反而會降低性能。也正因如此,Kafka 雖然提供了 flush.messages 和 flush.ms 兩個參數將 Page Cache 中的數據強制 Flush 到磁盤,但是 Kafka 並不建議使用。

如果數據消費速度與生產速度相當,甚至不需要通過物理磁盤交換數據,而是直接通過 Page Cache 交換數據。同時,Follower 從 Leader Fetch 數據時,也可通過 Page Cache 完成。下圖爲某 Partition 的 Leader 節點的網絡 / 磁盤讀寫信息。

(點擊放大圖像)

從上圖可以看到,該 Broker 每秒通過網絡從 Producer 接收約 35MB 數據,雖然有 Follower 從該 Broker Fetch 數據,但是該 Broker 基本無讀磁盤。這是因爲該 Broker 直接從 Page Cache 中將數據取出返回給了 Follower。

支持多 Disk Drive

Broker 的 log.dirs 配置項,允許配置多個文件夾。如果機器上有多個 Disk Drive,可將不同的 Disk 掛載到不同的目錄,然後將這些目錄都配置到 log.dirs 裏。Kafka 會儘可能將不同的 Partition 分配到不同的目錄,也即不同的 Disk 上,從而充分利用了多 Disk 的優勢。

零拷貝

Kafka 中存在大量的網絡數據持久化到磁盤(Producer 到 Broker)和磁盤文件通過網絡發送(Broker 到 Consumer)的過程。這一過程的性能直接影響 Kafka 的整體吞吐量。

傳統模式下的四次拷貝與四次上下文切換

以將磁盤文件通過網絡發送爲例。傳統模式下,一般使用如下僞代碼所示的方法先將文件數據讀入內存,然後通過 Socket 將內存中的數據發送出去。

複製代碼


 
   
 

buffer = File.read

 

Socket.send(buffer)

這一過程實際上發生了四次數據拷貝。首先通過系統調用將文件數據讀入到內核態 Buffer(DMA 拷貝),然後應用程序將內存態 Buffer 數據讀入到用戶態 Buffer(CPU 拷貝),接着用戶程序通過 Socket 發送數據時將用戶態 Buffer 數據拷貝到內核態 Buffer(CPU 拷貝),最後通過 DMA 拷貝將數據拷貝到 NIC Buffer。同時,還伴隨着四次上下文切換,如下圖所示。

(點擊放大圖像)

sendfile 和 transferTo 實現零拷貝

而 Linux 2.4+ 內核通過 sendfile 系統調用,提供了零拷貝。數據通過 DMA 拷貝到內核態 Buffer 後,直接通過 DMA 拷貝到 NIC Buffer,無需 CPU 拷貝。這也是零拷貝這一說法的來源。除了減少數據拷貝外,因爲整個讀文件 - 網絡發送由一個 sendfile 調用完成,整個過程只有兩次上下文切換,因此大大提高了性能。零拷貝過程如下圖所示。

(點擊放大圖像)

從具體實現來看,Kafka 的數據傳輸通過 TransportLayer 來完成,其子類 PlaintextTransportLayer 通過Java NIO的 FileChannel 的 transferTo 和 transferFrom 方法實現零拷貝,如下所示。

複製代碼


 
   
 

@Override

 

public long transferFrom(FileChannel fileChannel, long position, long count) throws IOException {

 

return fileChannel.transferTo(position, count, socketChannel);

 

}

注: transferTo 和 transferFrom 並不保證一定能使用零拷貝。實際上是否能使用零拷貝與操作系統相關,如果操作系統提供 sendfile 這樣的零拷貝系統調用,則這兩個方法會通過這樣的系統調用充分利用零拷貝的優勢,否則並不能通過這兩個方法本身實現零拷貝。

減少網絡開銷

批處理

批處理是一種常用的用於提高 I/O 性能的方式。對 Kafka 而言,批處理既減少了網絡傳輸的 Overhead,又提高了寫磁盤的效率。

Kafka 0.8.1 及以前的 Producer 區分同步 Producer 和異步 Producer。同步 Producer 的 send 方法主要分兩種形式。一種是接受一個 KeyedMessage 作爲參數,一次發送一條消息。另一種是接受一批 KeyedMessage 作爲參數,一次性發送多條消息。而對於異步發送而言,無論是使用哪個 send 方法,實現上都不會立即將消息發送給 Broker,而是先存到內部的隊列中,直到消息條數達到閾值或者達到指定的 Timeout 才真正的將消息發送出去,從而實現了消息的批量發送。

Kafka 0.8.2 開始支持新的 Producer API,將同步 Producer 和異步 Producer 結合。雖然從 send 接口來看,一次只能發送一個 ProducerRecord,而不能像之前版本的 send 方法一樣接受消息列表,但是 send 方法並非立即將消息發送出去,而是通過 batch.size 和 linger.ms 控制實際發送頻率,從而實現批量發送。

由於每次網絡傳輸,除了傳輸消息本身以外,還要傳輸非常多的網絡協議本身的一些內容(稱爲 Overhead),所以將多條消息合併到一起傳輸,可有效減少網絡傳輸的 Overhead,進而提高了傳輸效率。

零拷貝章節的圖中可以看到,雖然 Broker 持續從網絡接收數據,但是寫磁盤並非每秒都在發生,而是間隔一段時間寫一次磁盤,並且每次寫磁盤的數據量都非常大(最高達到 718MB/S)。

數據壓縮降低網絡負載

Kafka 從 0.7 開始,即支持將數據壓縮後再傳輸給 Broker。除了可以將每條消息單獨壓縮然後傳輸外,Kafka 還支持在批量發送時,將整個 Batch 的消息一起壓縮後傳輸。數據壓縮的一個基本原理是,重複數據越多壓縮效果越好。因此將整個 Batch 的數據一起壓縮能更大幅度減小數據量,從而更大程度提高網絡傳輸效率。

Broker 接收消息後,並不直接解壓縮,而是直接將消息以壓縮後的形式持久化到磁盤。Consumer Fetch 到數據後再解壓縮。因此 Kafka 的壓縮不僅減少了 Producer 到 Broker 的網絡傳輸負載,同時也降低了 Broker 磁盤操作的負載,也降低了 Consumer 與 Broker 間的網絡傳輸量,從而極大得提高了傳輸效率,提高了吞吐量。

高效的序列化方式

Kafka 消息的 Key 和 Payload(或者說 Value)的類型可自定義,只需同時提供相應的序列化器和反序列化器即可。因此用戶可以通過使用快速且緊湊的序列化 - 反序列化方式(如 Avro,Protocal Buffer)來減少實際網絡傳輸和磁盤存儲的數據規模,從而提高吞吐率。這裏要注意,如果使用的序列化方法太慢,即使壓縮比非常高,最終的效率也不一定高。

作者簡介

郭俊(Jason),從事大數據平臺研發工作,精通 Kafka 等分佈式消息系統,Hadoop/Storm/Spark 等大數據系統及數據倉庫建模和性能調優。


感謝杜小芳對本文的審校。

給 InfoQ 中文站投稿或者參與內容翻譯工作,請郵件至[email protected]。也歡迎大家通過新浪微博(@InfoQ@丁曉昀),微信(微信號:InfoQChina)關注我們。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章