一、前言
在如今的分布式環境時代,任何一款中間件產品,大多都有一套機制去保證一致性的,Kafka 作為一個商業級消息中間件,消息一致性的重要性可想而知,那 Kafka 如何保證一致性的呢?本文從高水位更新機制、副本同步機制以及 Leader Epoch 幾個方面去介紹 Kafka 是如何保證一致性的。
二、HW 和 LEO
要想 Kafka 保證一致性,我們必須先了解 HW(High Watermark)高水位和 LEO(Log End Offset)日志末端位移,看下面這張圖你就清晰了:
高水位的作用:
- 定義消息可見性,即用來標識分區下的哪些消息是可以被消費者消費的。
- 幫助 Kafka 完成副本同步
這里我們不討論 Kafka 事務,因為事務機制會影響消費者所能看到的消息的范圍,它不只是簡單依賴高水位來判斷。它依靠一個名為 LSO(Log Stable Offset)的位移值來判斷事務型消費者的可見性。
日志末端位移的作用:
- 副本寫入下一條消息的位移值
- 數字 15 所在的方框是虛線,這就說明,這個副本當前只有 15 條消息,位移值是從 0 到 14,下一條新消息的位移是 15。
- 介于高水位和 LEO 之間的消息就屬于未提交消息。這也反應出一個事實,那就是:同一個副本對象,其高水位值不會大于 LEO 值。
高水位和 LEO 是副本對象的兩個重要屬性。Kafka 所有副本都有對應的高水位和 LEO 值,而不僅僅是 Leader 副本。只不過 Leader 副本比較特殊,Kafka 使用 Leader 副本的高水位來定義所在分區的高水位。換句話說,分區的高水位就是其 Leader 副本的高水位。
三、HW 和 LEO 的更新機制
現在,我們知道了每個副本對象都保存了一組高水位值和 LEO 值,但實際上,在 Leader 副本所在的 Broker 上,還保存了其他 Follower 副本的 LEO 值,請看下圖:
從圖中可以看出,Broker 0 上保存了某分區的 Leader 副本和所有 Follower 副本的 LEO 值,而 Broker 1 上僅僅保存了該分區的某個 Follower 副本。Kafka 把 Broker 0 上保存的這些 Follower 副本又稱為遠程副本(Remote Replica)。Kafka 副本機制在運行過程中,會更新 Broker 1 上 Follower 副本的高水位和 LEO 值,同時也會更新 Broker 0 上 Leader 副本的高水位和 LEO 以及所有遠程副本的 LEO,但它不會更新遠程副本的高水位值,也就是我在圖中標記為灰色的部分。
這里你可能就困惑了?
- 為啥 Leader 副本所在的 Broker 上,還保存了其他 Follower 副本的 LEO 值?
- 為啥 Leader 副本所在的 Broker 上不會更新 Follower 副本 HW?
別著急,老周帶你看下源碼:
在 kafka.cluster.Partition#makeLeader
中:
Leader 副本所在的 Broker 上只有重置更新遠程副本的 LEO,并沒有遠程副本的 HW。
這里你又可能會問了?
- 為什么要在 Broker 0 上保存這些遠程副本呢?
- Broker 0 不會更新遠程副本 HW,那遠程副本的 HW 的更新機制又是怎樣的呢?
Broker 0 上保存這些遠程副本的主要作用是,幫助 Leader 副本確定其高水位,也就是分區高水位。
第二個問題我們直接來看下 HW 和 LEO 被更新的時機:
3.1 Leader 副本
處理生產者請求的邏輯如下:
- 寫入消息到本地磁盤
- 更新分區高水位值
- 獲取 Leader 副本所在 Broker 端保存的所有遠程副本 LEO 值(LEO-1,LEO-2,……,LEO-n)
- 獲取 Leader 副本高水位值:currentHW
- 更新 currentHW = max{currentHW, min(LEO-1, LEO-2, ……,LEO-n)}
處理 Follower 副本拉取消息的邏輯如下:
- 讀取磁盤(或頁緩存)中的消息數據
- 使用 Follower 副本發送請求中的位移值更新遠程副本 LEO 值
- 更新分區高水位值(具體步驟與處理生產者請求的步驟相同)
3.2 Follower 副本
從 Leader 拉取消息的處理邏輯如下:
- 寫入消息到本地磁盤
- 更新 LEO 值
- 更新高水位值
- 獲取 Leader 發送的高水位值:currentHW
- 獲取步驟 2 中更新過的 LEO 值:currentLEO
- 更新高水位為 min(currentHW, currentLEO)
四、副本同步機制
搞清楚了上面 HW 和 LEO 的更新機制后,我們舉一個單分區且有兩個副本的主題來演示下 Kafka 副本同步的全流程。
當生產者發送一條消息時,Leader 和 Follower 副本對應的 HW 和 LEO 是怎么被更新的呢?
首先是初始狀態。下面這張圖中的 remote LEO 就是剛才的遠程副本的 LEO 值。在初始狀態時,所有值都是 0。
當生產者給主題分區發送一條消息后,狀態變更為:
此時,Leader 副本成功將消息寫入了本地磁盤,故 LEO 值被更新為 1。
Follower 再次嘗試從 Leader 拉取消息。和之前不同的是,這次有消息可以拉取了,因此狀態進一步變更為:
這時,Follower 副本也成功地更新 LEO 為 1。此時,Leader 和 Follower 副本的 LEO 都是 1,但各自的高水位依然是 0,還沒有被更新。它們需要在下一輪的拉取中被更新,如下圖所示:
在新一輪的拉取請求中,由于位移值是 0 的消息已經拉取成功,因此 Follower 副本這次請求拉取的是位移值 =1 的消息。Leader 副本接收到此請求后,更新遠程副本 LEO 為 1,然后更新 Leader 高水位為 1。做完這些之后,它會將當前已更新過的高水位值 1 發送給 Follower 副本。Follower 副本接收到以后,也將自己的高水位值更新成 1。至此,一次完整的消息同步周期就結束了。事實上,Kafka 就是利用這樣的機制,實現了 Leader 和 Follower 副本之間的同步。
五、Leader Epoch 機制
上面的副本同步機制似乎很完美,我們不妨來思考下這種場景:
從剛才的分析中,我們知道,Follower 副本的高水位更新需要一輪額外的拉取請求才能實現。如果把上面那個例子擴展到多個 Follower 副本,情況可能更糟,也許需要多輪拉取請求。也就是說,Leader 副本高水位更新和 Follower 副本高水位更新在時間上是存在錯配的。這種錯配是很多“數據丟失”或“數據不一致”問題的根源。基于此,社區在 0.11 版本正式引入了 Leader Epoch 概念,來規避因高水位更新錯配導致的各種不一致問題。
所謂 Leader Epoch,我們大致可以認為是 Leader 版本。它由兩部分數據組成。
-
Epoch
。一個單調增加的版本號。每當副本領導權發生變更時,都會增加該版本號。小版本號的 Leader 被認為是過期 Leader,不能再行使 Leader 權力。 -
起始位移(Start Offset)
。Leader 副本在該 Epoch 值上寫入的首條消息的位移。
我舉個例子來說明一下 Leader Epoch。假設現在有兩個 Leader Epoch<0, 0> 和 <1, 120>,那么,第一個 Leader Epoch 表示版本號是 0,這個版本的 Leader 從位移 0 開始保存消息,一共保存了 120 條消息。之后,Leader 發生了變更,版本號增加到 1,新版本的起始位移是 120。
Kafka Broker 會在內存中為每個分區都緩存 Leader Epoch 數據,同時它還會定期地將這些信息持久化到一個 checkpoint 文件中。當 Leader 副本寫入消息到磁盤時,Broker 會嘗試更新這部分緩存。如果該 Leader 是首次寫入消息,那么 Broker 會向緩存中增加一個 Leader Epoch 條目,否則就不做更新。這樣,每次有 Leader 變更時,新的 Leader 副本會查詢這部分緩存,取出對應的 Leader Epoch 的起始位移,以避免數據丟失和不一致的情況。
源碼在 org.apache.kafka.raft.LeaderState
中:
Kafka Broker 會在內存中為每個分區都緩存 Leader Epoch 數據:
同時它還會定期地將這些信息持久化到一個 checkpoint 文件中:
org.apache.kafka.common.message.LeaderAndIsrRequestData.LeaderAndIsrPartitionState#write
接下來,我們來看一個實際的例子,它展示的是 Leader Epoch 是如何防止數據丟失的。請先看下圖:
開始時,副本 A 和副本 B 都處于正常狀態,A 是 Leader 副本。某個使用了默認 acks 設置的生產者程序向 A 發送了兩條消息,A 全部寫入成功,此時 Kafka 會通知生產者說兩條消息全部發送成功。
現在我們假設 Leader 和 Follower 都寫入了這兩條消息,而且 Leader 副本的高水位也已經更新了,但 Follower 副本高水位還未更新——這是可能出現的。還記得吧,Follower 端高水位的更新與 Leader 端有時間錯配。倘若此時副本 B 所在的 Broker 宕機,當它重啟回來后,副本 B 會執行日志截斷操作,將 LEO 值調整為之前的高水位值,也就是 1。這就是說,位移值為 1 的那條消息被副本 B 從磁盤中刪除,此時副本 B 的底層磁盤文件中只保存有 1 條消息,即位移值為 0 的那條消息。
當執行完截斷操作后,副本 B 開始從 A 拉取消息,執行正常的消息同步。如果就在這個節骨眼上,副本 A 所在的 Broker 宕機了,那么 Kafka 就別無選擇,只能讓副本 B 成為新的 Leader,此時,當 A 回來后,需要執行相同的日志截斷操作,即將高水位調整為與 B 相同的值,也就是 1。這樣操作之后,位移值為 1 的那條消息就從這兩個副本中被永遠地抹掉了。這就是這張圖要展示的數據丟失場景。
嚴格來說,這個場景發生的前提是 Broker 端參數 min.insync.replicas
設置為 1
。此時一旦消息被寫入到 Leader 副本的磁盤,就會被認為是“已提交狀態”,但現有的時間錯配問題導致 Follower 端的高水位更新是有滯后的。如果在這個短暫的滯后時間窗口內,接連發生 Broker 宕機,那么這類數據的丟失就是不可避免的。
現在,我們來看下如何利用 Leader Epoch 機制來規避這種數據丟失。請看下圖:
場景和之前大致是類似的,只不過引用 Leader Epoch 機制后,Follower 副本 B 重啟回來后,需要向 A 發送一個特殊的請求去獲取 Leader 的 LEO 值。在這個例子中,該值為 2。當獲知到 Leader LEO=2 后,B 發現該 LEO 值不比它自己的 LEO 值小,而且緩存中也沒有保存任何起始位移值 > 2 的 Epoch 條目,因此 B 無需執行任何日志截斷操作。這是對高水位機制的一個明顯改進,即副本是否執行日志截斷不再依賴于高水位進行判斷。
現在,副本 A 宕機了,B 成為 Leader。同樣地,當 A 重啟回來后,執行與 B 相同的邏輯判斷,發現也不用執行日志截斷,至此位移值為 1 的那條消息在兩個副本中均得到保留。后面當生產者程序向 B 寫入新消息時,副本 B 所在的 Broker 緩存中,會生成新的 Leader Epoch 條目:[Epoch=1, Offset=2]。之后,副本 B 會使用這個條目幫助判斷后續是否執行日志截斷操作。這樣,通過 Leader Epoch 機制,Kafka 完美地規避了這種數據丟失場景。