深入剖析Kafka

OPPO網際網路技術發表於2020-01-17

本文來自OPPO網際網路技術團隊,轉載請註名作者。同時歡迎關注我們的公眾號:OPPO_tech,與你分享OPPO前沿網際網路技術及活動。

Kafka是一個分散式的基於釋出、訂閱的訊息系統,具有著高吞吐、高容錯、高可靠以及高效能等特性,主要用於應用解耦、流量削峰、非同步訊息等場景。

為了讓大家更加深入的瞭解Kafka內部實現原理,文中將會從主題與日誌開始介紹訊息的儲存、刪除以及檢索,然後介紹其副本機制的實現原理,最後介紹生產與消費的實現原理以便更合理的應用於實際業務。

另外,本文較長,建議點贊後慢慢看 : )

1. 引言

Kafka是一個分散式的基於釋出、訂閱的訊息系統,有著強大的訊息處理能力,相比與其他訊息系統,具有以下特性:

  • 快速資料持久化,實現了O(1)時間複雜度的資料持久化能力。
  • 高吞吐,能在普通的伺服器上達到10W每秒的吞吐速率。
  • 高可靠,訊息持久化以及副本系統的機制保證了訊息的可靠性,訊息可以多次消費。
  • 高擴充套件,與其他分散式系統一樣,所有元件均支援分散式、自動實現負載均衡,可以快速便捷的擴容系統。
  • 離線與實時處理能力並存,提供了線上與離線的訊息處理能力。

正是因其具有這些的優秀特性而廣泛用於應用解耦、流量削峰、非同步訊息等場景,比如訊息中介軟體、日誌聚合、流處理等等。

本文將從以下幾個方面去介紹kafka:

  1. 第一章簡單介紹下kafka作為分散式的訊息釋出與訂閱系統所具備的特徵與優勢

  2. 第二章節介紹kafka系統的主題與日誌,瞭解訊息如何存放、如何檢索以及如何刪除

  3. 第三章節介紹kafka副本機制以瞭解kafka內部如何實現訊息的高可靠

  4. 第四章節將會從訊息的生產端去介紹訊息的分割槽演算法以及冪等特性的具體實現

  5. 第五章節將從訊息的消費端去了解消費組、消費位移以及重平衡機制具體實現

  6. 最後章節簡單總結下本文

2. 主題與日誌

2.1 主題

主題是儲存訊息的一個邏輯概念,可以簡單理解為一類訊息的集合,由使用方去建立。Kafka中的主題一般會有多個訂閱者去消費對應主題的訊息,也可以存在多個生產者往主題中寫入訊息。

深入剖析Kafka

每個主題又可以劃分成多個分割槽,每個分割槽儲存不同的訊息。當訊息新增至分割槽時,會為其分配一個位移offset(從0開始遞增),並保證分割槽上唯一,訊息在分割槽上的順序由offset保證,即同一個分割槽內的訊息是有序的,如下圖所示

深入剖析Kafka

同一個主題的不同分割槽會分配在不同的節點上(broker),分割槽時保證Kafka叢集具有水平擴充套件的基礎。

深入剖析Kafka

以主題nginx_access_log為例,分割槽數為3,如上圖所示。分割槽在邏輯上對應一個日誌(Log),物理上對應的是一個資料夾。

drwxr-xr-x  2 root root 4096 10月 11 20:07 nginx_access_log-0/
drwxr-xr-x  2 root root 4096 10月 11 20:07 nginx_access_log-1/
drwxr-xr-x  2 root root 4096 10月 11 20:07 nginx_access_log-2/
複製程式碼

訊息寫入分割槽時,實際上是將訊息寫入分割槽所在的資料夾中。日誌又分成多個分片(Segment),每個分片由日誌檔案與索引檔案組成,每個分片大小是有限的(在kafka叢集的配置檔案log.segment.bytes配置,預設為1073741824byte,即1GB),當分片大小超過限制則會重新建立一個新的分片,外界訊息的寫入只會寫入最新的一個分片(順序IO)。

-rw-r--r--  1 root root    1835920 10月 11 19:18 00000000000000000000.index
-rw-r--r--  1 root root 1073741684 10月 11 19:18 00000000000000000000.log
-rw-r--r--  1 root root    2737884 10月 11 19:18 00000000000000000000.timeindex
-rw-r--r--  1 root root    1828296 10月 11 19:30 00000000000003257573.index
-rw-r--r--  1 root root 1073741513 10月 11 19:30 00000000000003257573.log
-rw-r--r--  1 root root    2725512 10月 11 19:30 00000000000003257573.timeindex
-rw-r--r--  1 root root    1834744 10月 11 19:42 00000000000006506251.index
-rw-r--r--  1 root root 1073741771 10月 11 19:42 00000000000006506251.log
-rw-r--r--  1 root root    2736072 10月 11 19:42 00000000000006506251.timeindex
-rw-r--r--  1 root root    1832152 10月 11 19:54 00000000000009751854.index
-rw-r--r--  1 root root 1073740984 10月 11 19:54 00000000000009751854.log
-rw-r--r--  1 root root    2731572 10月 11 19:54 00000000000009751854.timeindex
-rw-r--r--  1 root root    1808792 10月 11 20:06 00000000000012999310.index
-rw-r--r--  1 root root 1073741584 10月 11 20:06 00000000000012999310.log
-rw-r--r--  1 root root         10 10月 11 19:54 00000000000012999310.snapshot
-rw-r--r--  1 root root    2694564 10月 11 20:06 00000000000012999310.timeindex
-rw-r--r--  1 root root   10485760 10月 11 20:09 00000000000016260431.index
-rw-r--r--  1 root root  278255892 10月 11 20:09 00000000000016260431.log
-rw-r--r--  1 root root         10 10月 11 20:06 00000000000016260431.snapshot
-rw-r--r--  1 root root   10485756 10月 11 20:09 00000000000016260431.timeindex
-rw-r--r--  1 root root          8 10月 11 19:03 leader-epoch-checkpoint
複製程式碼

一個分片包含多個不同字尾的日誌檔案,分片中的第一個訊息的offset將作為該分片的基準偏移量,偏移量固定長度為20,不夠前面補齊0,然後將其作為索引檔案以及日誌檔案的檔名,如00000000000003257573.index00000000000003257573.log00000000000003257573.timeindex、相同檔名的檔案組成一個分片(忽略字尾名),除了.index.timeindex.log字尾的日誌檔案外其他日誌檔案,對應含義如下:

檔案型別 作用
.index 偏移量索引檔案,記錄<相對位移,起始地址>對映關係,其中相對位移表示該分片的第一個訊息,從1開始計算,起始地址表示對應相對位移訊息在分片.log檔案的起始地址
.timeindex 時間戳索引檔案,記錄<時間戳,相對位移>對映關係
.log 日誌檔案,儲存訊息的詳細資訊
.snaphot 快照檔案
.deleted 分片檔案刪除時會先將該分片的所有檔案加上.delete字尾,然後有delete-file任務延遲刪除這些檔案(file.delete.delay.ms可以設定延時刪除的的時間)
.cleaned 日誌清理時臨時檔案
.swap Log Compaction 之後的臨時檔案
.leader-epoch-checkpoint

2.2 日誌索引

深入剖析Kafka

首先介紹下.index檔案,這裡以檔案00000000000003257573.index為例,首先我們可以通過以下命令檢視該索引檔案的內容,我們可以看到輸出結構為<offset,position>,實際上索引檔案中儲存的並不是offset而是相對位移,比如第一條訊息的相對位移則為0,格式化輸出時加上了基準偏移量,如上圖所示,<114,17413>表示該分片相對位移為114的訊息,其位移為3257573+114,即3257687,position表示對應offset在.log檔案的實體地址,通過.index索引檔案則可以獲取對應offset所在的實體地址。索引採用稀疏索引的方式構建,並不保證分片中的每個訊息都在索引檔案有對映關係(.timeindex索引也是類似),主要是為了節省磁碟空間、記憶體空間,因為索引檔案最終會對映到記憶體中。

# 檢視該分片索引檔案的前10條記錄
bin/kafka-dump-log.sh --files /tmp/kafka-logs/nginx_access_log-1/00000000000003257573.index |head -n 10
Dumping /tmp/kafka-logs/nginx_access_log-1/00000000000003257573.index
offset: 3257687 position: 17413
offset: 3257743 position: 33770
offset: 3257799 position: 50127
offset: 3257818 position: 66484
offset: 3257819 position: 72074
offset: 3257871 position: 87281
offset: 3257884 position: 91444
offset: 3257896 position: 95884
offset: 3257917 position: 100845
# 檢視該分片索引檔案的後10條記錄
$ bin/kafka-dump-log.sh --files /tmp/kafka-logs/nginx_access_log-1/00000000000003257573.index |tail -n 10
offset: 6506124 position: 1073698512
offset: 6506137 position: 1073702918
offset: 6506150 position: 1073707263
offset: 6506162 position: 1073711499
offset: 6506176 position: 1073716197
offset: 6506188 position: 1073720433
offset: 6506205 position: 1073725654
offset: 6506217 position: 1073730060
offset: 6506229 position: 1073734174
offset: 6506243 position: 1073738288
複製程式碼

比如檢視offset為6506155的訊息:首先根據offset找到對應的分片,65061所對應的分片為00000000000003257573,然後通過二分法在00000000000003257573.index檔案中找到不大於6506155的最大索引值,得到<offset: 6506150, position: 1073707263>,然後從00000000000003257573.log的1073707263位置開始順序掃描找到offset為650155的訊息

Kafka從0.10.0.0版本起,為分片日誌檔案中新增了一個.timeindex的索引檔案,可以根據時間戳定位訊息。同樣我們可以通過指令碼kafka-dump-log.sh檢視時間索引的檔案內容。

# 檢視該分片時間索引檔案的前10條記錄
bin/kafka-dump-log.sh --files /tmp/kafka-logs/nginx_access_log-1/00000000000003257573.timeindex |head -n 10
Dumping /tmp/kafka-logs/nginx_access_log-1/00000000000003257573.timeindex
timestamp: 1570792689308 offset: 3257685
timestamp: 1570792689324 offset: 3257742
timestamp: 1570792689345 offset: 3257795
timestamp: 1570792689348 offset: 3257813
timestamp: 1570792689357 offset: 3257867
timestamp: 1570792689361 offset: 3257881
timestamp: 1570792689364 offset: 3257896
timestamp: 1570792689368 offset: 3257915
timestamp: 1570792689369 offset: 3257927

# 檢視該分片時間索引檔案的前10條記錄
bin/kafka-dump-log.sh --files /tmp/kafka-logs/nginx_access_log-1/00000000000003257573.timeindex |tail -n 10
Dumping /tmp/kafka-logs/nginx_access_log-1/00000000000003257573.timeindex
timestamp: 1570793423474 offset: 6506136
timestamp: 1570793423477 offset: 6506150
timestamp: 1570793423481 offset: 6506159
timestamp: 1570793423485 offset: 6506176
timestamp: 1570793423489 offset: 6506188
timestamp: 1570793423493 offset: 6506204
timestamp: 1570793423496 offset: 6506214
timestamp: 1570793423500 offset: 6506228
timestamp: 1570793423503 offset: 6506240
timestamp: 1570793423505 offset: 6506248
複製程式碼

比如我想檢視時間戳1570793423501開始的訊息:1.首先定位分片,將1570793423501與每個分片的最大時間戳進行對比(最大時間戳取時間索引檔案的最後一條記錄時間,如果時間為0則取該日誌分段的最近修改時間),直到找到大於或等於1570793423501的日誌分段,因此會定位到時間索引檔案00000000000003257573.timeindex,其最大時間戳為1570793423505;2.通過二分法找到大於或等於1570793423501的最大索引項,即<timestamp: 1570793423503 offset: 6506240>(6506240為offset,相對位移為3247667);3.根據相對位移3247667去索引檔案中找到不大於該相對位移的最大索引值<3248656,1073734174>;4.從日誌檔案00000000000003257573.log的1073734174位置處開始掃描,查詢不小於1570793423501的資料。

2.3 日誌刪除

與其他訊息中介軟體不同的是,Kafka叢集中的訊息不會因為消費與否而刪除,跟日誌一樣訊息最終會落盤,並提供對應的策略週期性(通過引數log.retention.check.interval.ms來設定,預設為5分鐘)執行刪除或者壓縮操作(broker配置檔案log.cleanup.policy引數如果為“delete”則執行刪除操作,如果為“compact”則執行壓縮操作,預設為“delete”)。

2.3.1 基於時間的日誌刪除

引數 預設值 說明
log.retention.hours 168 日誌保留時間(小時)
log.retention.minutes 日誌保留時間(分鐘),優先順序大於小時
log.retention.ms 日誌保留時間(毫秒),優先順序大於分鐘

當訊息在叢集保留時間超過設定閾值(log.retention.hours,預設為168小時,即七天),則需要進行刪除。這裡會根據分片日誌的最大時間戳來判斷該分片的時間是否滿足刪除條件,最大時間戳首先會選取時間戳索引檔案中的最後一條索引記錄,如果對應的時間戳值大於0則取該值,否則為最近一次修改時間。

深入剖析Kafka

這裡不直接選取最後修改時間的原因是避免分片日誌的檔案被無意篡改而導致其時間不準。

如果恰好該分割槽下的所有日誌分片均已過期,那麼會先生成一個新的日誌分片作為新訊息的寫入檔案,然後再執行刪除引數。

2.3.2 基於空間的日誌刪除

引數 預設值 說明
log.retention.bytes 1073741824(即1G),預設未開啟,即無窮大 日誌檔案總大小,並不是指單個分片的大小
log.segment.bytes 1073741824(即1G) 單個日誌分片大小

首先會計算待刪除的日誌大小diff(totalSize-log.rentention.bytes),然後從最舊的一個分片開始檢視可以執行刪除操作的檔案集合(如果diff-segment.size>=0,則滿足刪除條件),最後執行刪除操作。

深入剖析Kafka

2.3.3 基於日誌起始偏移量的日誌刪除

深入剖析Kafka

一般情況下,日誌檔案的起始偏移量(logStartOffset)會等於第一個日誌分段的baseOffset,但是其值會因為刪除訊息請求而增長,logStartOffset的值實際上是日誌集合中的最小訊息,而小於這個值的訊息都會被清理掉。如上圖所示,我們假設logStartOffset=7421048,日誌刪除流程如下:

  • 從最舊的日誌分片開始遍歷,判斷其下一個分片的baseOffset是否小於或等於logStartOffset值,如果滿足,則需要刪除,因此第一個分片會被刪除。
  • 分片二的下一個分片baseOffset=6506251<7421048,所以分片二也需要刪除。
  • 分片三的下一個分片baseOffset=9751854>7421048,所以分片三不會被刪除。

2.4 日誌壓縮

前面提到當broker配置檔案log.cleanup.policy引數值設定為“compact”時,則會執行壓縮操作,這裡的壓縮跟普通意義的壓縮不一樣,這裡的壓縮是指將相同key的訊息只保留最後一個版本的value值,如下圖所示,壓縮之前offset是連續遞增,壓縮之後offset遞增可能不連續,只保留5條訊息記錄。

深入剖析Kafka

Kafka日誌目錄下cleaner-offset-checkpoint檔案,用來記錄每個主題的每個分割槽中已經清理的偏移量,通過這個偏移量可以將分割槽中的日誌檔案分成兩個部分:clean表示已經壓縮過;dirty表示還未進行壓縮,如下圖所示(active segment不會參與日誌的壓縮操作,因為會有新的資料寫入該檔案)。

深入剖析Kafka

-rw-r--r--  1 root root    4 10月 11 19:02 cleaner-offset-checkpoint
drwxr-xr-x  2 root root 4096 10月 11 20:07 nginx_access_log-0/
drwxr-xr-x  2 root root 4096 10月 11 20:07 nginx_access_log-1/
drwxr-xr-x  2 root root 4096 10月 11 20:07 nginx_access_log-2/
-rw-r--r--  1 root root    0 9月  18 09:50 .lock
-rw-r--r--  1 root root    4 10月 16 11:19 log-start-offset-checkpoint
-rw-r--r--  1 root root   54 9月  18 09:50 meta.properties
-rw-r--r--  1 root root 1518 10月 16 11:19 recovery-point-offset-checkpoint
-rw-r--r--  1 root root 1518 10月 16 11:19 replication-offset-checkpoint

#cat cleaner-offset-checkpoint
nginx_access_log 0 5033168
nginx_access_log 1 5033166
nginx_access_log 2 5033168
複製程式碼

日誌壓縮時會根據dirty部分資料佔日誌檔案的比例(cleanableRatio)來判斷優先壓縮的日誌,然後為dirty部分的資料建立key與offset對映關係(儲存對應key的最大offset)存入SkimpyoffsetMap中,然後複製segment分段中的資料,只保留SkimpyoffsetMap中記錄的訊息,壓縮之後的相關日誌檔案大小會減少,為了避免出現過小的日誌檔案與索引檔案,壓縮時會對所有的segment進行分組(一個組的分片大小不會超過設定的log.segment.bytes值大小),同一個分組的多個分片日誌壓縮之後變成一個分片。

深入剖析Kafka

如上圖所示,所有訊息都還沒壓縮前clean checkpoint值為0,表示該分割槽的資料還沒進行壓縮,第一次壓縮後,之前每個分片的日誌檔案大小都有所減少,同時會移動clean checkpoint的位置到這一次壓縮結束的offset值。第二次壓縮時,會將前兩個分片{0.5GB,0.4GB}組成一個分組,{0.7GB,0.2GB}組成一個分組進行壓縮,以此類推。

深入剖析Kafka

如上圖所示,日誌壓縮的主要流程如下:

  1. 計算deleteHorizonMs值:當某個訊息的value值為空時,該訊息會被保留一段時間,超時之後會在下一次的得日誌壓縮中被刪除,所以這裡會計算deleteHorizonMs,根據該值確定可以刪除value值為空的日誌分片。(deleteHorizonMs = clean部分的最後一個分片的lastModifiedTime - deleteRetionMs,deleteRetionMs通過配置檔案log.cleaner.delete.retention.ms配置,預設為24小時)。
  2. 確定壓縮dirty部分的offset範圍[firstDirtyOffset,endOffset):其中firstDirtyOffset表示dirty的起始位移,一般會等於clear checkpoint值,firstUncleanableOffset表示不能清理的最小位移,一般會等於活躍分片的baseOffset,然後從firstDirtyOffset位置開始遍歷日誌分片,並填充key與offset的對映關係至SkimpyoffsetMap中,當該map被填充滿或到達上限firstUncleanableOffset時,就可以確定日誌壓縮上限endOffset
  3. 將[logStartOffset,endOffset)中的日誌分片進行分組,然後按照分組的方式進行壓縮。

3. 副本

kafka支援訊息的冗餘備份,可以設定對應主題的副本數(--replication-factor引數設定主題的副本數可在建立主題的時候指定,offsets.topic.replication.factor設定消費主題_consumer_offsets副本數,預設為3),每個副本包含的訊息一樣(但不是完全一致,可能從副本的資料較主副本稍微有些落後)。每個分割槽的副本集合中會有一個副本被選舉為主副本(leader),其他為從副本,所有的讀寫請求由主副本對外提供,從副本負責將主副本的資料同步到自己所屬分割槽,如果主副本所在分割槽當機,則會重新選舉出新的主副本對外提供服務。

3.1 ISR集合

ISR(In-Sync Replica)集合,表示目前可以用的副本集合,每個分割槽中的leader副本會維護此分割槽的ISR集合。這裡的可用是指從副本的訊息量與主副本的訊息量相差不大,加入至ISR集合中的副本必須滿足以下幾個條件:

  1. 副本所在節點需要與ZooKeeper維持心跳。
  2. 從副本的最後一條訊息的offset需要與主副本的最後一條訊息offset差值不超過設定閾值(replica.lag.max.messages)或者副本的LEO落後於主副本的LEO時長不大於設定閾值(replica.lag.time.max.ms),官方推薦使用後者判斷,並在新版本kafka0.10.0移除了replica.lag.max.messages引數。

如果從副本不滿足以上的任意條件,則會將其提出ISR集合,當其再次滿足以上條件之後又會被重新加入集合中。ISR的引入主要是解決同步副本與非同步複製兩種方案各自的缺陷(同步副本中如果有個副本當機或者超時就會拖慢該副本組的整體效能;如果僅僅使用非同步副本,當所有的副本訊息均遠落後於主副本時,一旦主副本當機重新選舉,那麼就會存在訊息丟失情況)

3.2 HW&LEO

HW(High Watermark)是一個比較特殊的offset標記,消費端消費時只能拉取到小於HW的訊息而HW及之後的訊息對於消費者來說是不可見的,該值由主副本管理,當ISR集合中的全部從副本都拉取到HW指定訊息之後,主副本會將HW值+1,即指向下一個offset位移,這樣可以保證HW之前訊息的可靠性。

深入剖析Kafka

LEO(Log End Offset)表示當前副本最新訊息的下一個offset,所有副本都存在這樣一個標記,如果是主副本,當生產端往其追加訊息時,會將其值+1。當從副本從主副本成功拉取到訊息時,其值也會增加。

深入剖析Kafka

3.2.1 從副本更新LEO與HW

深入剖析Kafka

從副本的資料是來自主副本,通過向主副本傳送fetch請求獲取資料,從副本的LEO值會儲存在兩個地方,一個是自身所在的節點),一個是主副本所在節點,自身節點儲存LEO主要是為了更新自身的HW值,主副本儲存從副本的LEO也是為了更新其HW。當從副本每寫入一條新訊息就會增加其自身的LEO,主副本收到從副本的fetch請求,會先從自身的日誌中讀取對應資料,在資料返回給從副本之前會先去更新其儲存的從副本LEO值。一旦從副本資料寫入完成,就會嘗試更新自己的HW值,比較LEO與fetch響應中主副本的返回HW,取最小值作為新的HW值。

3.2.2 主副本更新LEO與HW

主副本有日誌寫入時就會更新其自身的LEO值,與從副本類似。而主副本的HW值是分割槽的HW值,決定分割槽資料對應消費端的可見性,以下四種情況,主副本會嘗試更新其HW值:

  • 副本成為主副本:當某個副本成為主副本時,kafka會嘗試更新分割槽的HW值。
  • broker出現奔潰導致副本被踢出ISR集合:如果有broker節點奔潰則會看是否影響對應分割槽,然後會去檢查分割槽的HW值是否需要更新。
  • 生成端往主副本寫入訊息時:訊息寫入會增加其LEO值,此時會檢視是否需要修改HW值。
  • 主副本接受到從副本的fetch請求時:主副本在處理從副本的fetch請求時會嘗試更新分割槽HW值。

前面是去嘗試更新HW,但是不一定會更新,主副本上儲存著從副本的LEO值與自身的LEO值,這裡會比較所有滿足條件的副本LEO值,並選擇最小的LEO值最為分割槽的HW值,其中滿足條件的副本是指滿足以下兩個條件之一:

  • 副本在ISR集合中
  • 副本的LEO落後於主副本的LEO時長不大於設定閾值(replica.lag.time.max.ms,預設為10s)

3.3 資料丟失場景

深入剖析Kafka

前面提到如果僅僅依賴HW來進行日誌截斷以及水位的判斷會存在問題,如上圖所示,假定存在兩個副本A、副本B,最開始A為主副本,B為從副本,且引數min.insync.replicas=1,即ISR只有一個副本時也會返回成功:

  • 初始情況為主副本A已經寫入了兩條訊息,對應HW=1,LEO=2,LEOB=1,從副本B寫入了一條訊息,對應HW=1,LEO=1。
  • 此時從副本B向主副本A發起fetchOffset=1請求,主副本收到請求之後更新LEOB=1,表示副本B已經收到了訊息0,然後嘗試更新HW值,min(LEO,LEOB)=1,即不需要更新,然後將訊息1以及當前分割槽HW=1返回給從副本B,從副本B收到響應之後寫入日誌並更新LEO=2,然後更新其HW=1,雖然已經寫入了兩條訊息,但是HW值需要在下一輪的請求才會更新為2。
  • 此時從副本B重啟,重啟之後會根據HW值進行日誌截斷,即訊息1會被刪除。
  • 從副本B向主副本A傳送fetchOffset=1請求,如果此時主副本A沒有什麼異常,則跟第二步驟一樣沒有什麼問題,假設此時主副本也當機了,那麼從副本B會變成主副本。
  • 當副本A恢復之後會變成從副本並根據HW值進行日誌截斷,即把訊息1丟失,此時訊息1就永久丟失了。

3.4 資料不一致場景

深入剖析Kafka

如圖所示,假定存在兩個副本A、副本B,最開始A為主副本,B為從副本,且引數min.insync.replicas=1,即ISR只有一個副本時也會返回成功:

  • 初始狀態為主副本A已經寫入了兩條訊息對應HW=1,LEO=2,LEOB=1,從副本B也同步了兩條訊息,對應HW=1,LEO=2。
  • 此時從副本B向主副本傳送fetchOffset=2請求,主副本A在收到請求後更新分割槽HW=2並將該值返回給從副本B,如果此時從副本B當機則會導致HW值寫入失敗。
  • 我們假設此時主副本A也當機了,從副本B先恢復併成為主副本,此時會發生日誌截斷,只保留訊息0,然後對外提供服務,假設外部寫入了一個訊息1(這個訊息與之前的訊息1不一樣,用不同的顏色標識不同訊息)。
  • 等副本A起來之後會變成從副本,不會發生日誌截斷,因為HW=2,但是對應位移1的訊息其實是不一致的

3.5 leader epoch機制

HW值被用於衡量副本備份成功與否以及出現失敗情況時候的日誌截斷依據可能會導致資料丟失與資料不一致情況,因此在新版的Kafka(0.11.0.0)引入了leader epoch概念,leader epoch表示一個鍵值對<epoch, offset>,其中epoch表示leader主副本的版本號,從0開始編碼,當leader每變更一次就會+1,offset表示該epoch版本的主副本寫入第一條訊息的位置,比如<0,0>表示第一個主副本從位移0開始寫入訊息,<1,100>表示第二個主副本版本號為1並從位移100開始寫入訊息,主副本會將該資訊儲存在快取中並定期寫入到checkpoint檔案中,每次發生主副本切換都會去從快取中查詢該資訊,下面簡單介紹下leader epoch的工作原理:

  • 每條訊息會都包含一個4位元組的leader epoch number值
  • 每個log目錄都會建立一個leader epoch sequence檔案用來存放主副本版本號以及開始位移。
  • 當一個副本成為主副本之後,會在leader epoch sequence檔案末尾新增一條新的記錄,然後每條新的訊息就會變成新的leader epoch值。
  • 當某個副本當機重啟之後,會進行以下操作:
    • 從leader epoch sequence檔案中恢復所有的leader epoch。
    • 向分割槽主副本傳送LeaderEpoch請求,請求包含了從副本的leader epoch sequence檔案中的最新leader epoch值。
    • 主副本返回從副本對應LeaderEpoch的lastOffset,返回的lastOffset分為兩種情況,一種是返回比從副本請求中leader epoch版本大1的開始位移,另外一種是與請求中的leader epoch相等則直接返回當前主副本的LEO值。
    • 如果從副本的leader epoch開始位移大於從leader中返回的lastOffset,那麼會將從副本的leader epoch sequence值保持跟主副本一致。
    • 從副本截斷本地訊息到主副本返回的LastOffset所在位移處。
    • 從副本開始從主副本開始拉取資料。
    • 在獲取資料時,如果從副本發現訊息中的leader epoch值比自身的最新leader epoch值大,則會將該leader epoch 值寫到leader epoch sequence檔案,然後繼續同步檔案。

下面看下leader epoch機制如何避免前面提到的兩種異常場景

3.5.1 資料丟失場景解決

深入剖析Kafka

  • 如圖所示,當從副本B重啟之後向主副本A傳送offsetsForLeaderEpochRequest,epoch主從副本相等,則A返回當前的LEO=2,從副本B中沒有任何大於2的位移,因此不需要截斷。
  • 當從副本B向主副本A傳送fetchoffset=2請求時,A當機,所以從副本B成為主副本,並更新epoch值為<epoch=1, offset=2>,HW值更新為2。
  • 當A恢復之後成為從副本,並向B傳送fetcheOffset=2請求,B返回HW=2,則從副本A更新HW=2。
  • 主副本B接受外界的寫請求,從副本A向主副本A不斷髮起資料同步請求。

從上可以看出引入leader epoch值之後避免了前面提到的資料丟失情況,但是這裡需要注意的是如果在上面的第一步,從副本B起來之後向主副本A傳送offsetsForLeaderEpochRequest請求失敗,即主副本A同時也當機了,那麼訊息1就會丟失,具體可見下面資料不一致場景中有提到。

3.5.2 資料不一致場景解決

深入剖析Kafka

  • 從副本B恢復之後向主副本A傳送offsetsForLeaderEpochRequest請求,由於主副本也當機了,因此副本B將變成主副本並將訊息1截斷,此時接受到新訊息1的寫入。
  • 副本A恢復之後變成從副本並向主副本A傳送offsetsForLeaderEpochRequest請求,請求的epoch值小於主副本B,因此主副本B會返回epoch=1時的開始位移,即lastoffset=1,因此從副本A會截斷訊息1。
  • 從副本A從主副本B拉取訊息,並更新epoch值<epoch=1, offset=1>。

可以看出epoch的引入避免的資料不一致,但是兩個副本均當機,則還是存在資料丟失的場景,前面的所有討論都是建立在min.insync.replicas=1的前提下,因此需要在資料的可靠性與速度方面做權衡。

4. 生產者

4.1 訊息分割槽選擇

生產者的作用主要是生產訊息,將訊息存入到Kafka對應主題的分割槽中,具體某個訊息應該存入哪個分割槽,有以下三個策略決定(優先順序由上到下,依次遞減):

  • 如果訊息傳送時指定了訊息所屬分割槽,則會直接發往指定分割槽。
  • 如果沒有指定訊息分割槽,但是設定了訊息的key,則會根據key的雜湊值選擇分割槽。
  • 如果前兩者均不滿足,則會採用輪詢的方式選擇分割槽。

4.2 ack引數設定及意義

生產端往kafka叢集傳送訊息時,可以通過request.required.acks引數來設定資料的可靠性級別

  • 1:預設為1,表示在ISR中的leader副本成功接收到資料並確認後再傳送下一條訊息,如果主節點當機則可能出現資料丟失場景,詳細分析可參考前面提到的副本章節。
  • 0:表示生產端不需要等待節點的確認就可以繼續傳送下一批資料,這種情況下資料傳輸效率最高,但是資料的可靠性最低。
  • -1:表示生產端需要等待ISR中的所有副本節點都收到資料之後才算訊息寫入成功,可靠性最高,但是效能最低,如果服務端的min.insync.replicas值設定為1,那麼在這種情況下允許ISR集合只有一個副本,因此也會存在資料丟失的情況。

4.3 冪等特性

所謂的冪等性,是指一次或者多次請求某一個資源對於資源本身應該具有同樣的結果(網路超時等問題除外),通俗一點的理解就是同一個操作任意執行多次產生的影響或效果與一次執行影響相同,冪等的關鍵在於服務端能否識別出請求是否重複,然後過濾掉這些重複請求,通常情況下需要以下資訊來實現冪等特性:

  • 唯一標識:判斷某個請求是否重複,需要有一個唯一性標識,然後服務端就能根據這個唯一標識來判斷是否為重複請求。
  • 記錄已經處理過的請求:服務端需要記錄已經處理過的請求,然後根據唯一標識來判斷是否是重複請求,如果已經處理過,則直接拒絕或者不做任何操作返回成功。

kafka中Producer端的冪等性是指當傳送同一條訊息時,訊息在叢集中只會被持久化一次,其冪等是在以下條件中才成立:

  • 只能保證生產端在單個會話內的冪等,如果生產端因為某些原因意外掛掉然後重啟,此時是沒辦法保證冪等的,因為這時沒辦法獲取到之前的狀態資訊,即無法做到垮會話級別的冪等。
  • 冪等性不能垮多個主題分割槽,只能保證單個分割槽內的冪等,涉及到多個訊息分割槽時,中間的狀態並沒有同步。

如果要支援垮會話或者垮多個訊息分割槽的情況,則需要使用kafka的事務性來實現。

為了實現生成端的冪等語義,引入了Producer ID(PID)與Sequence Number的概念:

  • Producer ID(PID):每個生產者在初始化時都會分配一個唯一的PID,PID的分配對於使用者來說是透明的。
  • Sequence Number(序列號):對於給定的PID而言,序列號從0開始單調遞增,每個主題分割槽均會產生一個獨立序列號,生產者在傳送訊息時會給每條訊息新增一個序列號。broker端快取了已經提交訊息的序列號,只有比快取分割槽中最後提交訊息的序列號大1的訊息才會被接受,其他會被拒絕。

4.3.1 生產端訊息傳送流程

下面簡單介紹下支援冪等的訊息傳送端工作流程

  1. 生產端通過Kafkaproducer會將資料新增到RecordAccumulator中,資料新增時會判斷是否需要新建一個ProducerBatch。
  2. 生產端後臺啟動傳送執行緒,會判斷當前的PID是否需要重置,重置的原因是因為某些訊息分割槽的batch重試多次仍然失敗最後因為超時而被移除,這個時候序列號無法連續,導致後續訊息無法傳送,因此會重置PID,並將相關快取資訊清空,這個時候訊息會丟失。
  3. 傳送執行緒判斷是否需要新申請PID,如果需要則會阻塞直到獲取到PID資訊。
  4. 傳送執行緒在呼叫sendProducerData()方法傳送資料時,會進行以下判斷:
    • 判斷主題分割槽是否可以繼續傳送、PID是否有效、如果是重試batch需要判斷之前的batch是否傳送完成,如果沒有傳送完成則會跳過當前主題分割槽的訊息傳送,直到前面的batch傳送完成。
    • 如果對應ProducerBatch沒有分配對應的PID與序列號資訊,則會在這裡進行設定。

4.3.2 服務端訊息接受流程

服務端(broker)在收到生產端傳送的資料寫請求之後,會進行一些判斷來決定是否可以寫入資料,這裡也主要介紹關於冪等相關的操作流程。

  1. 如果請求設定了冪等特性,則會檢查是否對ClusterResource有IdempotentWrite許可權,如果沒有,則會返回錯誤CLUSTER_AUTHORIZATION_FAILED
  2. 檢查是否有PID資訊。
  3. 根據batch的序列號檢查該batch是否重複,服務端會快取每個PID對應主題分割槽的最近5個batch資訊,如果有重複,則直接返回寫入成功,但是不會執行真正的資料寫入操作。
  4. 如果有PID且非重複batch,則進行以下操作:
    • 判斷該PID是否已經存在快取中。
    • 如果不存在則判斷序列號是否是從0開始,如果是則表示為新的PID,在快取中記錄PID的資訊(包括PID、epoch以及序列號資訊),然後執行資料寫入操作;如果不存在但是序列號不是從0開始,則直接返回錯誤,表示PID在服務端以及過期或者PID寫的資料已經過期。
    • 如果PID存在,則會檢查PID的epoch版本是否與服務端一致,如果不一致且序列號不是從0開始,則返回錯誤。如果epoch不一致但是序列號是從0開始,則可以正常寫入。
    • 如果epoch版本一致,則會查詢快取中最近一次序列號是否連續,不連續則會返回錯誤,否則正常寫入。

5. 消費者

消費者主要是從Kafka叢集拉取訊息,然後進行相關的消費邏輯,消費者的消費進度由其自身控制,增加消費的靈活性,比如消費端可以控制重複消費某些訊息或者跳過某些訊息進行消費。

5.1 消費組

多個消費者可以組成一個消費組,每個消費者只屬於一個消費組。消費組訂閱主題的每個分割槽只會分配給該消費組中的某個消費者處理,不同的消費組之間彼此隔離無依賴。同一個訊息只會被消費組中的一個消費者消費,如果想要讓同一個訊息被多個消費者消費,那麼每個消費者需要屬於不同的消費組,且對應消費組中只有該一個消費者,消費組的引入可以實現消費的“獨佔”或“廣播”效果。

  • 消費組下可以有多個消費者,個數支援動態變化。
  • 消費組訂閱主題下的每個分割槽只會分配給消費組中的一個消費者。
  • group.id標識消費組,相同則屬於同一消費組。
  • 不同消費組之間相互隔離互不影響。

深入剖析Kafka

如圖所示,消費組1中包含兩個消費者,其中消費者1分配消費分割槽0,消費者2分配消費分割槽1與分割槽2。此外消費組的引入還支援消費者的水平擴充套件及故障轉移,比如從上圖我們可以看出消費者2的消費能力不足,相對消費者1來說消費進度比較落後,我們可以往消費組裡面增加一個消費者以提高其整體的消費能力,如下圖所示。

深入剖析Kafka

假設消費者1所在機器出現當機,消費組會傳送重平衡,假設將分割槽0分配給消費者2進行消費,如下圖所示。同個消費組中消費者的個數不是越多越好,最大不能超過主題對應的分割槽數,如果超過則會出現超過的消費者分配不到分割槽的情況,因為分割槽一旦分配給消費者就不會再變動,除非組內消費者個數出現變動而發生重平衡。

深入剖析Kafka

5.2 消費位移

5.2.1 消費位移主題

Kafka 0.9開始將消費端的位移資訊儲存在叢集的內部主題(__consumer_offsets)中,該主題預設為50個分割槽,每條日誌項的格式都是:<TopicPartition, OffsetAndMetadata>,其key為主題分割槽主要存放主題、分割槽以及消費組資訊,value為OffsetAndMetadata物件主要包括位移、位移提交時間、自定義後設資料等資訊。只有消費組往kafka中提交位移才會往這個主題中寫入資料,如果消費端將消費位移資訊儲存在外部儲存,則不會有消費位移資訊,下面可以通過kafka-console-consumer.sh指令碼檢視主題消費位移資訊。

# bin/kafka-console-consumer.sh --topic __consumer_offsets --bootstrap-server localhost:9092 --formatter "kafka.coordinator.group.GroupMetadataManager\$OffsetsMessageFormatter" --consumer.config config/consumer.properties --from-beginning

[consumer-group01,nginx_access_log,2]::OffsetAndMetadata(offset=17104625, leaderEpoch=Optional.[0], metadata=, commitTimestamp=1573475863555, expireTimestamp=None)
[consumer-group01,nginx_access_log,1]::OffsetAndMetadata(offset=17103024, leaderEpoch=Optional.[0], metadata=, commitTimestamp=1573475863555, expireTimestamp=None)
[consumer-group01,nginx_access_log,0]::OffsetAndMetadata(offset=17107771, leaderEpoch=Optional.[0], metadata=, commitTimestamp=1573475863555, expireTimestamp=None)
複製程式碼

5.2.2 消費位移自動提交

消費端可以通過設定引數enable.auto.commit來控制是自動提交還是手動,如果值為true則表示自動提交,在消費端的後臺會定時的提交消費位移資訊,時間間隔由auto.commit.interval.ms(預設為5秒)。

但是如果設定為自動提交會存在以下幾個問題:

  1. 可能存在重複的位移資料提交到消費位移主題中,因為每隔5秒會往主題中寫入一條訊息,不管是否有新的消費記錄,這樣就會產生大量的同key訊息,其實只需要一條,因此需要依賴前面提到日誌壓縮策略來清理資料。
  2. 重複消費,假設位移提交的時間間隔為5秒,那麼在5秒內如果發生了rebalance,則所有的消費者會從上一次提交的位移處開始消費,那麼期間消費的資料則會再次被消費。

5.2.3 消費位移手動提交

手動提交需要將enable.auto.commit值設定為false,然後由業務消費端來控制消費進度,手動提交又分為以下三種型別:

  • 同步手動提交位移:如果呼叫的是同步提交方法commitSync(),則會將poll拉取的最新位移提交到kafka叢集,提交成功前會一直等待提交成功。
  • 非同步手動提交位移:呼叫非同步提交方法commitAsync(),在呼叫該方法之後會立刻返回,不會阻塞,然後可以通過回撥函式執行相關的異常處理邏輯。
  • 指定提交位移:指定位移提交也分為非同步跟同步,傳參為Map<TopicPartition, OffsetAndMetadata>,其中key為訊息分割槽,value為位移物件。

5.3 分組協調者

分組協調者(Group Coordinator)是一個服務,kafka叢集中的每個節點在啟動時都會啟動這樣一個服務,該服務主要是用來儲存消費分組相關的後設資料資訊,每個消費組均會選擇一個協調者來負責組內各個分割槽的消費位移資訊儲存,選擇的主要步驟如下:

  • 首選確定消費組的位移資訊存入哪個分割槽:前面提到預設的__consumer_offsets主題分割槽數為50,通過以下演算法可以計算出對應消費組的位移資訊應該存入哪個分割槽 partition = Math.abs(groupId.hashCode() % groupMetadataTopicPartitionCount) 其中groupId為消費組的id,這個由消費端指定,groupMetadataTopicPartitionCount為主題分割槽數。
  • 根據partition尋找該分割槽的leader所對應的節點broker,該broker的Coordinator即為該消費組的Coordinator。

5.4 重平衡機制

5.4.1 重平衡發生場景

以下幾種場景均會觸發重平衡操作:

  1. 新的消費者加入到消費組中。
  2. 消費者被動下線。比如消費者長時間的GC、網路延遲導致消費者長時間未向Group Coordinator傳送心跳請求,均會認為該消費者已經下線並踢出。
  3. 消費者主動退出消費組。
  4. 消費組訂閱的任意一個主題分割槽數出現變化。
  5. 消費者取消某個主題的訂閱。

5.4.2 重平衡操作流程

重平衡的實現可以分為以下幾個階段:

  1. 查詢Group Coordinator:消費者會從kafka叢集中選擇一個負載最小的節點傳送GroupCoorinatorRequest請求,並處理返回響應GroupCoordinatorResponse。其中請求引數中包含消費組的id,響應中包含Coordinator所在節點id、host以及埠號資訊。
  2. Join group:當消費者拿到協調者的資訊之後會往協調者傳送加入消費組的請求JoinGroupRequest,當所有的消費者都傳送該請求之後,協調者會從中選擇一個消費者作為leader角色,然後將組內成員資訊、訂閱等資訊發給消費者(響應格式JoinGroupResponse見下表),leader負責消費方案的分配。

JoinGroupRequest請求資料格式

名稱 型別 說明
group_id String 消費者id
seesion_timeout int 協調者超過session_timeout指定的時間沒有收到心跳訊息,則認為該消費者下線
member_id String 協調者分配給消費者的id
protocol_type String 消費組實現的協議,預設為sonsumer
group_protocols List 包含此消費者支援的全部PartitionAssignor型別
protocol_name String PartitionAssignor型別
protocol_metadata byte[] 針對不同PartitionAssignor型別序列化後的消費者訂閱資訊,包含使用者自定義資料userData

JoinGroupResponse響應資料格式

名稱 型別 說明
error_code short 錯誤碼
generation_id int 協調者分配的年代資訊
group_protocol String 協調者選擇的PartitionAssignor型別
leader_id String Leader的member_id
member_id String 協調者分配給消費者的id
members Map集合 消費組中全部的消費者訂閱資訊
member_metadata byte[] 對應消費者的訂閱資訊
  1. Synchronizing Group State階段:當leader消費者完成消費方案的分配後會傳送SyncGroupRequest請求給協調者,其他非leader節點也會傳送該請求,只是請求引數為空,然後協調者將分配結果作為響應SyncGroupResponse發給各個消費者,請求及相應的資料格式如下表所示:

SyncGroupRequest請求資料格式

名稱 型別 說明
group_id String 消費組的id
generation_id int 消費組儲存的年代資訊
member_id String 協調者分配的消費者id
member_assignment byte[] 分割槽分配結果

SyncGroupResponse響應資料格式

名稱 型別 說明
error_code short 錯誤碼
member_assignment byte[] 分配給當前消費者的分割槽

5.4.3 分割槽分配策略

Kafka提供了三個分割槽分配策略:RangeAssignor、RoundRobinAssignor以及StickyAssignor,下面簡單介紹下各個演算法的實現。

  1. RangeAssignor: kafka預設會採用此策略進行分割槽分配,主要流程如下

    • 將所有訂閱主題下的分割槽進行排序得到集合TP={TP0,Tp1,...,TPN+1}
    • 對消費組中的所有消費者根據名字進行字典排序得到集合CG={C0,C1,...,CM+1}
    • 計算D=N/MR=N%M
    • 消費者Ci獲取消費分割槽起始位置=D*i+min(i,R),Ci獲取的分割槽總數=D+(if (i+1>R)0 else 1)。

    假設一個消費組中存在兩個消費者{C0,C1},該消費組訂閱了三個主題{T1,T2,T3},每個主題分別存在三個分割槽,一共就有9個分割槽{TP1,TP2,...,TP9}。通過以上演算法我們可以得到D=4,R=1,那麼消費組C0將消費的分割槽為{TP1,TP2,TP3,TP4,TP5},C1將消費分割槽{TP6,TP7,TP8,TP9}。這裡存在一個問題,如果不能均分,那麼前面的幾個消費者將會多消費一個分割槽。

  2. RoundRobinAssignor: 使用該策略需要滿足以下兩個條件:1) 消費組中的所有消費者應該訂閱主題相同;2) 同一個消費組的所有消費者在例項化時給每個主題指定相同的流數。

    • 對所有主題的所有分割槽根據主題+分割槽得到的雜湊值進行排序。
    • 對所有消費者按字典排序。
    • 通過輪詢的方式將分割槽分配給消費者。
  3. StickyAssignor:該分配方式在0.11版本開始引入,主要是保證以下特性:1) 儘可能的保證分配均衡;2) 當重新分配時,保留儘可能多的現有分配。其中第一條的優先順序要大於第二條。

6. 總結

在本文中,我們圍繞Kafka的特性,詳細介紹了其原理實現,通過主題與日誌的深入剖析,瞭解了Kafka內部訊息的存放、檢索以及刪除機制。副本系統中的ISR概念的引入解決同步副本與非同步複製兩種方案各自的缺陷,lead epoch機制的出現解決了資料丟失以及資料不一致問題。生產端的分割槽選擇演算法實現了資料均衡,冪等特性的支援則解決了之前存在的重複訊息問題。

最後介紹了消費端的相關原理,消費組機制實現了消費端的訊息隔離,既有廣播也有獨佔的場景支援,而重平衡機制則保證的消費端的健壯性與擴充套件性。

參考文獻

[1] 徐郡明.Apach Kafka 原始碼剖析[M].北京.電子工業出版社,2017.

[2] Kafka深度解析.

[3] 深入淺出理解基於 Kafka 和 ZooKeeper 的分散式訊息佇列.

[4] Kafka 事務性之冪等性實現.

[5] Kafka水位(high watermark)與leader epoch的討論.

[6] kafka消費者如何分配分割槽.

深入剖析Kafka

相關文章