Kafka 延時佇列&重試佇列

下半夜的風發表於2022-07-06

一、延時佇列

1. 簡介

TimingWheel是kafka時間輪的實現,內部包含了⼀個TimerTaskList陣列,每個陣列包含了⼀些連結串列組成的TimerTaskEntry事件,每個TimerTaskList表示時間輪的某⼀格,這⼀格的時間跨度為tickMs,同⼀個TimerTaskList中的事件都是相差在⼀個tickMs跨度內的,整個時間輪的時間跨度為interval = tickMs * wheelSize,該時間輪能處理的時間範圍在cuurentTime到currentTime + interval之間的事件。

當新增⼀個時間他的超時時間⼤於整個時間輪的跨度時, expiration >= currentTime + interval,則會將該事件向上級傳遞,上級的tickMs是下級的interval,傳遞直到某⼀個時間輪滿⾜expiration < currentTime + interval,

然後計算對應位於哪⼀格,然後將事件放進去,重新設定超時時間,然後放進jdk延遲佇列

else if (expiration < currentTime + interval) {
    // Put in its own bucket
    val virtualId = expiration / tickMs 
    val bucket = buckets((virtualId % wheelSize.toLong).toInt) 
    bucket.add(timerTaskEntry)
    // Set the bucket expiration time
    if (bucket.setExpiration(virtualId * tickMs)) {
        // The bucket needs to be enqueued because it was an expired bucket
        // We only need to enqueue the bucket when its expiration time has changed, i.e. the wheel has advanced
        // and the previous buckets gets reused; further calls to set the expiration within the same wheel cycle
        // will pass in the same value and hence return false, thus the bucket with the same expiration will not
        // be enqueued multiple times.
        queue.offer(bucket)
    }

SystemTimer會取出queue中的TimerTaskList,根據expiration將currentTime往前推進,然後把⾥⾯所有的事件重新放進時間輪中,因為ct推進了,所以有些事件會在第0格,表示到期了,直接返回。

else if (expiration < currentTime + tickMs) {

然後將任務提交到java執行緒池中處理。

服務端在處理客戶端的請求,針對不同的請求,可能不會⽴即返回響應結果給客戶端。在處理這類請求時,服務端會為這類請求建立延遲操作物件放⼊延遲快取佇列中。延遲快取的資料結構類似MAP,延遲操作物件從延遲快取佇列中完成並移除有兩種⽅式:

  1. 延遲操作對應的外部事件發⽣時,外部事件會嘗試完成延遲快取中的延遲操作 。
  2. 如果外部事件仍然沒有完成延遲操作,超時時間達到後,會強制完成延遲的操作 。

2. 延時操作介面

DelayedOperation接⼝表示延遲的操作物件。此接⼝的實現類包括延遲加⼊,延遲⼼跳,延遲⽣產,延遲拉取。延遲接⼝相關的⽅法:

  • tryComplete:嘗試完成,外部事件發⽣時會嘗試完成延遲的操作。該⽅法返回值為true,表示可以完成延遲操作,會調⽤強制完成的⽅法(forceComplete)。返回值為false,表示不可以完成延遲操作。
  • forceComplete:強制完成,兩個地⽅調⽤,嘗試完成⽅法(tryComplete)返回true時;延遲操作超時時。
  • run:執行緒運⾏,延遲操作超時後,會調⽤執行緒的運⾏⽅法,只會調⽤⼀次,因為超時就會發⽣⼀次。超時後會調⽤強制完成⽅法(forceComplete),如果返回true,會調⽤超時的回撥⽅法。
  • onComplete:完成的回撥⽅法。
  • onExpiration:超時的回撥⽅法。

外部事件觸發完成和超時完成都會調⽤forceComplete(),並調⽤onComplete()forceCompleteonComplete只會調⽤⼀次。多執行緒下⽤原⼦變數來控制只有⼀個執行緒會調⽤onCompleteforceComplete

延遲⽣產和延遲拉取完成時的回撥⽅法,嘗試完成的延遲操作

副本管理器在建立延遲操作時,會把回撥⽅法傳給延遲操作物件。當延遲操作完成時,在onComplete⽅法中會調⽤回撥⽅法,返回響應結果給客戶端。

建立延遲操作物件需要提供請求對應的後設資料。延遲⽣產後設資料是分割槽的⽣產結果;延遲拉取後設資料是分割槽的拉取資訊

建立延遲的⽣產物件之前,將訊息集寫⼊分割槽的主副本中,每個分割槽的⽣產結果會作為延遲⽣產的後設資料。建立延遲的拉取物件之前,從分割槽的主副本中讀取訊息集,但並不會使⽤分割槽的拉取結果作為延遲拉取的後設資料,因為延遲⽣產返回給客戶端的響應結果可以直接從分割槽的⽣產結果中獲取,⽽延遲的拉取返回給客戶端的響應結果不能直接從分割槽的拉取結果中獲取。

後設資料包含返回結果的條件是:從建立延遲操作物件到完成延遲操作物件,後設資料的含義不變。對於延遲的⽣產,服務端寫⼊訊息集到主副本返回的結果是確定的。是因為ISR中的備份副本還沒有全部傳送應答給主副本,才會需要建立延遲的⽣產。服務端在處理備份副本的拉取請求時,不會改變分割槽的⽣產結果。最後在完成延遲⽣產的操作物件時,服務端就可以把 “建立延遲操作物件” 時傳遞給它的分割槽⽣產結果直接返回給⽣產者 。對應延遲的拉取,讀取了主副本的本地⽇志,但是因為訊息數量不夠,才會需要建立延遲的拉取,⽽不⽤分割槽的拉取結果⽽是⽤分割槽的拉取資訊作為延遲拉取的後設資料,是因為在嘗試完成延遲拉取操作物件時,會再次讀取主副本的本地⽇志,這次的讀取有可能會讓訊息數量達到⾜夠或者超時,從⽽完成延遲拉取操作物件。這樣建立前和完成時延遲拉取操作物件的返回結果是不同的。但是拉取資訊不管讀取多少次都是⼀樣的。

延遲的⽣產的外部事件是:ISR的所有備份副本傳送了拉取請求;備份副本的延遲拉取的外部事件是:追加訊息集到主副本;消費者的延遲拉取的外部事件是:增加主副本的最⾼⽔位。

3. 嘗試完成延遲的生產

服務端處理⽣產者客戶端的⽣產請求,將訊息集追加到對應主副本的本地⽇志後,會等待ISR中所有的備份剛本都向主副本傳送應答 。⽣產請求包括多個分割槽的訊息集,每個分割槽都有對應的ISR集合。當所有分割槽的ISR副本都向對應分割槽的主副本傳送了應答,⽣產請求才能算完成。⽣產請求中雖然有多個分割槽,但是延遲的⽣產操作物件只會建立⼀個。

判斷分割槽的ISR副本是否都已經向主副本傳送了應答,需要檢查ISR中所有備份副本的偏移量是否到了延遲⽣產後設資料的指定偏移量(延遲⽣產的後設資料是分割槽的⽣產結果中包含有追加訊息集到本地⽇志返回下⼀個偏移量)。所以ISR所有副本的偏移量只要等於後設資料的偏移量,就表示備份副本向主副本傳送了應答。由於當備份副本向主副本傳送拉取請求,服務端讀取⽇志後,會更新對應備份副本的偏移量資料。所以在具體的實現上,備份副本並不需要真正傳送應答給主副本,因為主副本所在訊息代理節點的分割槽物件已經記錄了所有副本的資訊,所以嘗試完成延遲的⽣產時,根據副本的偏移量就可以判斷備份副本是否傳送了應答。進⽽檢查分割槽是否有⾜夠的副本趕上指定偏移量,只需要判斷主副本的最⾼⽔位是否等於指定偏移量(最⾼⽔位的值會選擇ISR中所有備份副本中最⼩的偏移量來設定,最⼩的值都等於了指定偏移量,那麼就代表所有的ISR都傳送了應答)。

總結

總結:服務端建立的延遲⽣產操作物件,在嘗試完成時根據主副本的最⾼⽔位是否等於延遲⽣產操作物件中後設資料的指定偏移量來判斷。具體步驟:

  1. 服務端處理⽣產者的⽣產請求,寫⼊訊息集到Leader副本的本地⽇志。
  2. 服務端返回追加訊息集的下⼀個偏移量,並且建立⼀個延遲⽣產操作物件。後設資料為分割槽的⽣產結果(其中就包含下⼀個偏移量的值)
  3. 服務端處理備份副本的拉取請求,⾸先讀取主副本的本地⽇志。
  4. 服務端返回給備份副本讀取訊息集,並更新備份副本的偏移量。
  5. 選擇ISR備份副本中最⼩的偏移量更新主副本的最⾼⽔位。
  6. 如果主副本的最⾼⽔位等於指定的下⼀個偏移量的值,就完成延遲的⽣產。

4. 嘗試完成延遲的拉取

服務端處理消費者或備份副本的拉取請求,如果建立了延遲的拉取操作物件,⼀般都是客戶端的消費進度能夠⼀直趕上主副本。⽐如備份副本同步主副本的資料,備份副本如果⼀直能趕上主副本,那麼主副本有新訊息寫⼊,備份副本就會⻢上同步。但是針對備份副本已經消費到主副本的最新位置,⽽主副本並沒有新訊息寫⼊時:服務端沒有⽴即返回空的拉取結果給備份副本,這時會建立⼀個延遲的拉取操作物件,如果有新的訊息寫⼊,服務端會等到收集⾜夠的訊息集後,才返回拉取結果給備份副本,有新的訊息寫⼊,但是還沒有收集到⾜夠的訊息集,等到延遲操作物件超時後,服務端會讀取新寫⼊主副本的訊息後,返回拉取結果給備份副本(完成延遲的拉取時,服務端還會再讀取⼀次主副本的本地⽇志,返回新讀取出來的訊息集)。

客戶端的拉取請求包含多個分割槽,服務端判斷拉取的訊息⼤⼩時,會收集拉取請求涉及的所有分割槽。只要訊息的總⼤⼩超過拉取請求設定的最少位元組數,就會調⽤forceComplete()⽅法完成延遲的拉取

外部事件嘗試完成延遲的⽣產和拉取操作時的判斷條件:

拉取偏移量是指拉取到訊息⼤⼩。對於備份副本的延遲拉取,主副本的結束偏移量是它的最新偏移量(LEO)。對於消費者的拉取延遲,主副本的結束偏移量是它的最⾼⽔位(HW)。備份副本要時刻與主副本同步,消費者只能消費到主副本的最⾼⽔位。

5. ⽣產請求和拉取請求的延遲快取

客戶端的⼀個請求包括多個分割槽,服務端為每個請求都會建立⼀個延遲操作物件。⽽不是為每個分割槽建立⼀個延遲操作物件。服務端的“延遲操作快取”管理了所有的“延遲操作物件”,快取的鍵是每⼀個分割槽,快取的值是分割槽對應的延遲操作列表。

⼀個客戶端請求對應⼀個延遲操作,⼀個延遲操作對應多個分割槽。在延遲快取中,⼀個分割槽對應多個延遲操作。延遲快取中儲存了分割槽到延遲操作的對映關係。

根據分割槽嘗試完成延遲的操作,因為⽣產者和消費者是以分割槽為最⼩單位來追加訊息和消費訊息。雖然延遲操作的建立是針對⼀個請求,但是⼀個請求中會有多個分割槽,在⽣產者追加訊息時,⼀個⽣產請求總的不同分割槽包含的訊息是不⼀樣的。這樣追加到分割槽對應的主副本的本地⽇志中,有的分割槽就可以去完成延遲的拉取,但是有的分割槽有可能還達不到完成延遲拉取操作的條件。同樣完成延遲的⽣產也⼀樣。所以在延遲快取中要以分割槽為鍵來儲存各個延遲操作。

由於⼀個請求建立⼀個延遲操作,⼀個請求⼜會包含多個分割槽,所以不同的延遲操作可能會有相同的分割槽。在加⼊到延遲快取時,每個分割槽都對應相同的延遲操作。外部事件發⽣時,服務端會以分割槽為粒度,嘗試完成這個分割槽中的所有延遲操作 。 如果指定分割槽對應的某個延遲操作可以被完成,那麼延遲操作會從這個分割槽的延遲操作列表中移除。但這個延遲操作還有其他分割槽,其他分割槽中已經被完成的延遲操作也需要從延遲快取中刪除。但是不會⽴即被刪除,因為分割槽作為延遲快取的鍵,在服務端的數量會很多。只要分割槽對應的延遲操作完成了⼀個,就要⽴即檢查所有分割槽,對服務端的效能影響⽐較⼤。所以採⽤⼀個清理器,會負責定時地清理所有分割槽中已經完成的延遲操作。

副本管理器針對⽣產請求和拉取請求都分別有⼀個全域性的延遲快取。⽣產請求對應延遲快取中儲存了延遲的⽣產。拉取請求對應延遲快取中儲存了延遲的拉取。

延遲快取提供了兩個⽅法:

  • tryCompleteElseWatch():嘗試完成延遲的操作,如果不能完成,將延遲操作加⼊延遲快取中。⼀旦將延遲操作加⼊延遲快取的監控,延遲操作的每個分割槽都會監視該延遲操作。換句話說就是每個分割槽發⽣了外部事件後,都會去嘗試完成延遲操作。
  • checkAndComplete():引數是延遲快取的鍵,外部事件調⽤該⽅法,根據指定的鍵嘗試完成延遲快取中的延遲操作。

延遲快取在調⽤tryCompleteElseWatch⽅法將延遲操作加⼊延遲快取之前,會先嚐試⼀次完成延遲的操作,如果不能完成,會調⽤⽅法將延遲操作加⼊到分割槽對應的監視器,之後還會嘗試完成⼀次延遲操作,如果還不能完成,會將延遲操作加⼊定時器。如果前⾯的加⼊過程中,可以完成延遲操作後,那麼就可以不⽤加⼊到其他分割槽的延遲快取了。

延遲操作不僅存在於延遲快取中,還會被定時器監控。定時器的⽬的是在延遲操作超時後,服務端可以強制完成延遲操作返回結果給客戶端。延遲快取的⽬的是讓外部事件去嘗試完成延遲操作。

6. 監視器

延遲快取的每個鍵都有⼀個監視器(類似每個分割槽有⼀個監視器),以連結串列結構來管理延遲操作。當外部事件發⽣時,會根據給定的鍵,調⽤這個鍵的對應監視器的tryCompleteWatch()⽅法,嘗試完成監視器中所有的延遲操作。監視器嘗試完成所有延遲操作的過程中,會調⽤每個延遲操作的tryComplete()⽅法,判斷能否完成延遲的操作。如果能夠完成,就從連結串列中刪除對應的延遲操作。

7. 清理執行緒

清理執行緒的作⽤是清理所有監視器中已經完成的延遲操作。

8. 定時器

服務端建立的延遲操作會作為⼀個定時任務,加⼊定時器的延遲佇列中。當延遲操作超時後,定時器會將延遲操作從延遲佇列中彈出,並調⽤延遲操作的運⾏⽅法,強制完成延遲的操作。

定時器使⽤延遲佇列管理服務端建立的所有延遲操作,延遲佇列的每個元素是定時任務列表,⼀個定時任務列表可以存放多個定時任務條⽬。服務端建立的延遲操作物件,會先包裝成定時任務條⽬,然後加⼊延遲佇列指定的⼀個定時任務列表。延遲佇列是定時器中儲存定時任務列表的全域性資料結構,服務端建立的延遲操作不是直接加⼊定時任務列表,⽽是加⼊時間輪。

時間輪和延遲佇列的關係:

  1. 定時器擁有⼀個全域性的延遲佇列和時間輪,所有時間輪公⽤⼀個計數器。
  2. 時間輪持有延遲佇列的引⽤。
  3. 定時任務條⽬新增到時間輪對應的時間格(槽)(槽中是定時任務列表)中,並且把該槽表也會加⼊到延遲佇列中。
  4. ⼀個執行緒會將超時的定時任務列表會從延遲佇列的poll⽅法彈出。定時任務列表超時並不⼀定代表定時任務超時,將定時任務重新加⼊時間輪,如果加⼊失敗,說明定時任務確實超時,提交給執行緒池執⾏。
  5. 延遲佇列的poll⽅法只會彈出超時的定時任務列表,佇列中的每個元素(定時任務列表)按照超時時間排序,如果第⼀個定時任務列表都沒有過期,那麼其他定時任務列表也⼀定不會超時。

延遲操作本身的失效時間是客戶端請求設定的,延遲佇列的元素(每個定時任務列表)也有失效時間,當定時任務列表中的getDelay()⽅法返回值⼩於等於0,就表示定時任務列表已經過期,需要⽴即執⾏。

如果當前的時間輪放不下加⼊的時間時,就會建立⼀個更⾼層的時間輪。定時器只持有第⼀層的時間輪的引⽤,並不會持有更⾼層的時間輪。因為第⼀層的時間輪會持有第⼆層的時間輪的引⽤,第⼆層會持有第三層的時間輪的引⽤。定時器將定時任務加⼊到當前時間輪,要判斷定時任務的失效時間⾸是否在當前時間輪的範圍內,如果不在當前時間輪的範圍內,則要將定時任務上升到更⾼⼀層的時間輪中。時間輪包含了定時器全域性的延遲佇列。

時間輪中的變數:tickMs=1:表示⼀格的⻓度是1毫秒;wheelSize=20表示⼀共20格,時間輪的範圍就是20毫秒,定時任務的失效時間⼩於等於20毫秒的都會加⼊到這⼀層的時間輪中;interval=tickMs*wheelSize=20,如果需要建立更⾼⼀層的時間輪,那麼低⼀層的時間輪的interval的值作為⾼⼀層資料輪的tickMs值;currentTime當前時間輪的當前時間,往前移動時間輪,主要就是更新當前時間輪的當前時間,更新後重新加⼊定時任務條⽬。

9. 一道面試題

⾯試題⼤致上是這樣的:消費者去Kafka⾥拉去訊息,但是⽬前Kafka中⼜沒有新的訊息可以提供,那麼Kafka會如何處理?

如下圖所示,兩個follower副本都已經拉取到了leader副本的最新位置,此時⼜向leader副本傳送拉取請求,⽽leader副本並沒有新的訊息寫⼊,那麼此時leader副本該如何處理呢?可以直接返回空的拉取結果給follower副本,不過在leader副本⼀直沒有新訊息寫⼊的情況下,follower副本會⼀直髮送拉取請求,並且總收到空的拉取結果,這樣徒耗資源,顯然不太合理。

這⾥就涉及到了Kafka延遲操作的概念。Kafka在處理拉取請求時,會先讀取⼀次⽇志⽂件,如果收集不到⾜夠多(fetchMinBytes,由引數fetch.min.bytes配置,預設值為1)的訊息,那麼就會建立⼀個延時拉取操作(DelayedFetch)以等待拉取到⾜夠數量的訊息。當延時拉取操作執⾏時,會再讀取⼀次⽇志⽂件,然後將拉取結果返回給follower副本。

延遲操作不只是拉取訊息時的特有操作,在Kafka中有多種延時操作,⽐如延時資料刪除、延時⽣產等。

對於延時⽣產(訊息)⽽⾔,如果在使⽤⽣產者客戶端傳送訊息的時候將acks引數設定為-1,那麼就意味著需要等待ISR集合中的所有副本都確認收到訊息之後才能正確地收到響應的結果,或者捕獲超時異常。

假設某個分割槽有3個副本:leader、follower1和follower2,它們都在分割槽的ISR集合中。為了簡化說明,這⾥我們不考慮ISR集合伸縮的情況。Kafka在收到客戶端的⽣產請求後,將訊息3和訊息4寫⼊leader副本的本地⽇志⽂件,如上圖所示。

由於客戶端設定了acks為-1,那麼需要等到follower1和follower2兩個副本都收到訊息3和訊息4後才能告知客戶端正確地接收了所傳送的訊息。如果在⼀定的時間內,follower1副本或follower2副本沒能夠完全拉取到訊息3和訊息4,那麼就需要返回超時異常給客戶端。⽣產請求的超時時間由引數request.timeout.ms配置,預設值為30000,即30s。

那麼這⾥等待訊息3和訊息4寫⼊follower1副本和follower2副本,並返回相應的響應結果給客戶端的動作是由誰來執⾏的呢?在將訊息寫⼊leader副本的本地⽇志⽂件之後,Kafka會建立⼀個延時的⽣產操作(DelayedProduce),⽤來處理訊息正常寫⼊所有副本或超時的情況,以返回相應的響應結果給客戶端。

延時操作需要延時返回響應的結果,⾸先它必須有⼀個超時時間(delayMs),如果在這個超時時間內沒有完成既定的任務,那麼就需要強制完成以返回響應結果給客戶端。其次,延時操作不同於定時操作,定時操作是指在特定時間之後執⾏的操作,⽽延時操作可以在所設定的超時時間之前完成,所以延時操作能夠⽀持外部事件的觸發。

就延時⽣產操作⽽⾔,它的外部事件是所要寫⼊訊息的某個分割槽的HW(⾼⽔位)發⽣增⻓。也就是說,隨著follower副本不斷地與leader副本進⾏訊息同步,進⽽促使HW進⼀步增⻓,HW每增⻓⼀次都會檢測是否能夠完成此次延時⽣產操作,如果可以就執⾏以此返回響應結果給客戶端;如果在超時時間內始終⽆法完成,則強制執⾏。

回顧⼀下⽂中開頭的延時拉取操作,它也同樣如此,也是由超時觸發或外部事件觸發⽽被執⾏的。超時觸發很好理解,就是等到超時時間之後觸發第⼆次讀取⽇志⽂件的操作。外部事件觸發就稍複雜了⼀些,因為拉取請求不單單由follower副本發起,也可以由消費者客戶端發起,兩種情況所對應的外部事件也是不同的。如果是follower副本的延時拉取,它的外部事件就是訊息追加到了leader副本的本地⽇志⽂件中;如果是消費者客戶端的延時拉取,它的外部事件可以簡單地理解為HW的增⻓。

二、重試佇列

kafka沒有重試機制不⽀持訊息重試,也沒有死信佇列,因此使⽤kafka做訊息佇列時,需要⾃⼰實現訊息重試的功能。

自己實現(建立新的kafka主題作為重試佇列):

  1. 建立⼀個topic作為重試topic,⽤於接收等待重試的訊息。
  2. 普通topic消費者設定待重試訊息的下⼀個重試topic。
  3. 從重試topic獲取待重試訊息儲存到redis的zset中,並以下⼀次消費時間排序
  4. 定時任務從redis獲取到達消費事件的訊息,並把訊息傳送到對應的topic
  5. 同⼀個訊息重試次數過多則不再重試

相關文章