Kafka原理剖析之「Purgatory(煉獄 | 時間輪)」

昔久發表於2024-10-15

一、前言

本文介紹一下Kafka赫赫有名的元件Purgatory,相信做Kafka的朋友或多或少都對其有一定的瞭解,至少是聽過它的名字。那它的作用是什麼呢,用來解決什麼問題呢?官網confluent早就有文章對其做了闡述

https://cwiki.apache.org/confluence/pages/viewpage.action?pageId=34839465

這裡簡單總結一下:Purgatory是用來儲存那些處於臨時或等待狀態的請求,這些請求可能某些條件未被滿足,而被臨時管理了起來。在這些條件滿足後,或者請求超時後,這些請求會被Purgatory高效回撥,繼而繼續執行後續邏輯

這裡聊個題外話,為什麼Kafka要給其取名“煉獄”呢?以下可以看一下百科對其的釋義

在教會的傳統中,煉獄是指人死後精煉的過程,是將人身上的罪汙加以淨化,是一種人經過死亡而達到圓滿的境界(天堂)過程中被淨煉的體驗

相信Purgatory在這裡更強調的是臨時,另外還有諸如Reaper(死神)等的命名,可見Kafka原作者們還是很有文藝範的 :)

二、演化

關於Purgatory元件的形成,並不是一蹴而就的,它至少經歷了2個大版本的迭代。

  • 版本一:在Kafka 0.8版本及以前,使用的是第一版,這個版本的核心是嚴重依賴了JUC的延遲佇列(java.util.concurrent.DelayQueue)。然而放入Purgatory中的這些延遲任務,大多數的時候,並不會真正等到時間超時。例如acks=all的這種case,假設預設的超時時間為1秒,即需要在1秒鐘之內將資料同步給所有的follower,leader將資料放入Purgatory後便開始了回撥等待,但大多數情況,可能幾十ms資料便同步完並執行回撥結束本次非同步操作,然而存在於延遲佇列DelayQueue中的請求並不能真正被刪除,它只能在真正超時的時候(1秒後),才能被發現並刪除。因此白白佔用昂貴的記憶體資源是一個弊端,而且存在一些效能上的問題,對於entry的增加、修改時間複雜度也達到了log(N)
  • 版本二:而在之後的版本中,Kafka對其做了最佳化,引入了優秀的設計 Hierarchical Timing Wheel(多層時間輪)的概念,不僅能夠立即將已完成的任務刪除,而且使其效能飆升,幾乎達到了常量O(1)的程度,關於具體的版本演練及效能測試可以參看官網文章

https://cwiki.apache.org/confluence/display/KAFKA/Purgatory+Redesign+Proposal

https://www.confluent.io/blog/apache-kafka-purgatory-hierarchical-timing-wheels/

不過文章中對很多細節並沒有展開,也沒有原始碼級流程的講解,這也是本文誕生的初衷,接下來我們會對Purgatory有一個全方位的介紹,包括其設計理念及原始碼分析。另:原始碼均來自於當前社群最新的trunk分支,也就是不久後的4.0.0的release分支,考慮到Purgatory已經相當穩定成熟,因此在當前trunk分支至4.0.0並不會有大的變動

三、整體業務流程

為了對Purgatroy扮演的角色有一個全域性的概念,我們以Consumer的Join Group來舉例說明。Join Group要做的事情也非常簡單,它需要協調多個Consumer在某個時間視窗內,儘量快速呼叫Join Group介面,在此,後續的結果分兩種情況考慮

  • 所有的Consumer在時間視窗內均呼叫了Join Group後,Coordinator開始制定分割槽分配策略
  • 在時間視窗結束的時候,只有一部分Consumer呼叫了Join Group,Coordinator便將那些沒有呼叫Join Group剔除,只對這些呼叫了Join Group的Consumer開始制定分配策略

假設現在沒有Purgatory元件,我們實現起來的話,流程可能如下:

Kafka原理剖析之「Purgatory(煉獄 | 時間輪)」

假定4個執行緒在時間視窗內都到達了,但是到達的前後順序不一致。執行緒2已到達,執行緒1、執行緒3、執行緒4都還在路上,因此執行緒2一直處於掛起狀態,即便是有新的任務到達,執行緒2也無法進行響應處理,且對應執行緒2的佔用一直要等到ack response後,才能釋放出來,效率低下

而圖中“Wait All Threads Ready”元件如何實現呢?其實可以使用JDK的CyclicBarrier或Semaphore或CountDownLatch均可實現,但不是本文要討論的重點,不再展開

Purgatory如何實現呢?它在整個流程中扮演了一個什麼角色呢

Kafka原理剖析之「Purgatory(煉獄 | 時間輪)」

首先流程上還是4個執行緒先後過來呼叫介面,但區別在於,已經到達的執行緒只需要將自己已經receive的訊息(包含回撥、超時等基礎資料)給到Purgatory即可,而後這個執行緒將會被釋放,它可以去處理其他任務。而後續的操作則會由Purgatory來接管,包括判斷條件是否滿足、視窗是否超時,一旦其中一個條件滿足,Purgatory將執行回撥,挨個對這些請求進行ack response

知道了Purgatory在整體流程中扮演的角色,接下來我們就要對這個元件內部實現的細節進行展開了

四、Purgatory組成

我們還是先提供一張Purgatory運作的流程圖

Kafka原理剖析之「Purgatory(煉獄 | 時間輪)」

4.1、業務執行緒

業務執行緒,對應上圖左側部分的流程。所謂業務執行緒,即使用Purgatory元件作為暫存請求的執行緒,例如Join Group、Producer ACKS=all等,雖然業務執行緒呼叫Purgatory的程式碼非常簡單,只有一行,拿Join Group舉例:

rebalancePurgatory.tryCompleteElseWatch(delayedRebalance, Seq(groupKey))

但在Purgatory內部卻做了很多事兒:

  • 首先嚐試去完成呼叫,如果所有條件均已滿足,那麼當前任務直接成功,也就不需要與時間輪互動了
  • 如果條件不滿足,則會將任務儲存在時間輪
  • 如果使用者設定了key,同時還會對同key的TimeTask進行監聽
    • 其實就是對同key的任務做批次操作,比如一起取消,後文還會提及

可見業務執行緒只負責向時間輪中寫入資料,那時間輪中的資料什麼時候被清除呢?這就要涉及另外一個核心執行緒ExpiredOperationReaper

4.2、收割執行緒

在Purgatory的內部還存在一個獨立的執行緒ExpiredOperationReaper,我們可以將其翻譯為收割執行緒或清理執行緒,它的作用是實時掃描那些已經過期的任務,並將其從時間輪中移除。它的定義如下

/**
 * A background reaper to expire delayed operations that have timed out
 */
private class ExpiredOperationReaper extends ShutdownableThread(
  "ExpirationReaper-%d-%s".format(brokerId, purgatoryName),
  false) {

  override def doWork(): Unit = {
    advanceClock(200L)
  }
}

這裡需要注意的是,收割執行緒只是一個獨立的單執行緒,它的作用只是去實時找出那些已經過期的任務,並將後續的回撥邏輯扔給執行緒池,繼而繼續掃描,由此可見其任務並不繁重

這裡簡單提一下“回撥執行緒池”,由上文我們知道執行緒將任務交給Purgatory後便結束使命了,後續的觸發均有這個“回撥執行緒池”中的執行緒來執行的,這個執行緒池定義如下

this.taskExecutor = Executors.newFixedThreadPool(1,
            runnable -> KafkaThread.nonDaemon(SYSTEM_TIMER_THREAD_PREFIX + executorName, runnable));

可見它是一個單執行緒的,且固定執行緒數的執行緒池。為什麼要設定為單執行緒呢?如果某個應用的回撥阻塞了,那豈不是所有執行緒池中的回撥均會阻塞嗎?

的確是這樣,不過考慮到這個執行緒池做的工作只是回撥,一般是網路傳送模組,資料其實都是已經準備好的,TPS響應是非常快的,因此通常也不會成為瓶頸

由上文可知,不論是業務執行緒還是收割執行緒,其與時間輪均有密不可分的關係

五、時間輪(Timing Wheel)

不論是延遲任務的管理、儲存、移除等核心操作,均是由時間輪來完成,因此時間輪是整個Purgatory中的最核心元件。此處我們再明確一下Purgatory與時間輪的關係,時間輪只是Purgatory中的一個子概念,是為了讓Purgatory更高效、效能更快而提煉出的一個內部元件

5.1、資料結構

時間輪的資料結構也相對簡單,由一個輪子+雙向連結串列組成

輪子:

Kafka原理剖析之「Purgatory(煉獄 | 時間輪)」

雙向列表:

Kafka原理剖析之「Purgatory(煉獄 | 時間輪)」

輪子+雙向列表的結構便為:

Kafka原理剖析之「Purgatory(煉獄 | 時間輪)」

之所以設計雙向連結串列的方式,主要是Task的新增跟刪除是一件非常頻繁的事兒,我們的資料結構要能確保高效地處理這些請求,而雙向迴圈連結串列則能夠保證任意一個Task節點的新增與刪除都能維持O(1)的時間複雜度,因此可謂是不二之選

5.2、Task新增與移除

具體的Task新增與刪除的過程是什麼樣呢?我們舉個例子來說明:

假定我現在時間輪的粒度為10秒鐘,即每10秒一個格子

Kafka原理剖析之「Purgatory(煉獄 | 時間輪)」

現在來了一個任務,這個任務Task1要在第35秒被觸發,此時我們找到第4個時間格,將這個任務放在這裡

Kafka原理剖析之「Purgatory(煉獄 | 時間輪)」

接著又來了2個任務,分別在36、38秒被觸發,那麼同樣它們將會被放在第4個時間格中

Kafka原理剖析之「Purgatory(煉獄 | 時間輪)」

同理,不難想象,如果又來了幾個任務,它們的觸發時間分別為12、18、69、62、65、53、54,那麼時間輪將會變為如下

Kafka原理剖析之「Purgatory(煉獄 | 時間輪)」

如果來個延遲時間是100秒的任務呢?其實這塊就涉及到多級時間輪了

至於任務的移除,參照雙向連結串列刪除節點即可

5.3、多級時間輪

5.3.1、基礎定義

多級時間輪,顧名思義,即有很多個層級的時間輪,越往上,粒度越粗,理論上只要記憶體夠大,時間輪可以儲存無限大小的延遲任務。下圖展示了一個2級時間輪:

Kafka原理剖析之「Purgatory(煉獄 | 時間輪)」

  • 內層時間輪:時間粒度是10秒鐘,每個時間輪有8個格子,因此內層的時間輪可以儲存0-80秒的任務
  • 外層時間輪:時間粒度是80秒鐘,同樣也是有8個格子,外層的時間輪則可以儲存0-640秒的任務

其實每個外層時間輪的一個格子,均對應一個內層時間輪,只不過上圖沒有呈現出這一點,因此,接上文,如果我們要儲存一個100秒的任務時,當前時間輪發現越界了,它會無腦向上拋,直到找到能接住這個超時時間的時間輪,上層時間輪的跨度更大,因此100秒的任務會落在“81-160”這個格子上。雖然更上層的時間輪承接了這個任務,但其實處理這個任務的最終還將會是最細粒度的時間輪,也就是將來在“81-160”這個格子對應的內層時間輪會最終接受這個任務並觸發回撥,這點我們在“時鐘模擬”章節再展開

因此其實我們不用在意到底Purgatory會有多少個層級的時間輪,理論上它可能是無限大的,我們只需要知道最細粒度的時間輪的步長+個數,後面的輪子構成都可以推匯出來。那Kafka設定的最細粒度的輪子步長跟個數分別是多少呢?這個答案藏在org.apache.kafka.server.util.timer.SystemTimer類的構造方法中

public SystemTimer(String executorName) {
    this(executorName, 1, 20, Time.SYSTEM.hiResClockMs());
}

可見,最細粒度的時間步長為1ms,個數為20,由此可推匯出一個表格

層級

步長

個數

最長時間

1

1ms

20

20ms

2

20ms

20

400ms

3

400ms

20

8s

4

8s

20

160s (2分40秒)

5

160s

20

3200s (53分20秒)

6

3200s

20

64000s (17時46分40秒)

可見到了第6層,延時時長已經到達了17個小時,而Kafka一般的case,可能到第4層就足矣;而從整體看,時間輪最細粒度精確到了1ms,且可以接收理論上無限長的定時任務,真可謂是神器了。不過這裡也存有一點疑問,那就是粒度做的這麼細,效能方面是不是存在問題?這點我們在後文也會涉及

5.3.2、Task新增

這節梳理一下在多級時間輪下,Task的新增與移除操作,關於Task的新增,一言以蔽之就是如果目標Task超過當前時間輪的最大時間範圍,那麼直接拋給上級時間輪;還是那上文舉個例子,假如時間輪收到一個700秒後執行的延遲任務

Kafka原理剖析之「Purgatory(煉獄 | 時間輪)」

一級時間輪,也就是最細粒度的時間輪,範圍是0-80秒,無法存放,那麼向上拋送;

二級時間輪收到這個任務後,發現超時時間是700秒,而自己的範圍則是0-640,依舊無法存放,繼續向上拋送

三級時間輪收到任務後依然檢查自己能接收的時間範圍,發現是0-5120秒,700秒在自己的範圍內,繼而計算700秒任務應該落在哪個格子,最終其被存放在641-1280這個格子中

總結:任務的新增總是先交給最細粒度的時間輪,而後層層上報,直到找到能承接這個Task的輪子後便將其存放在對應的格子中

5.3.2、Task移除

常規情況下,一個處於高Level的Task,在還沒有真正過期時,它的移除動作就是將其放入更細粒度的時間輪中,還是以上圖中的例子來說明

  1. 現在700秒的Task被放在三級時間輪的"641-1280"這個格子(TaskList)中,這個格子將在640秒過期
  2. 現在時鐘剛過640秒,"641-1280"這個格子被推出,發現其中有1個700秒超時的任務,但是其還沒有真正超時,因為當前的時間是640秒
  3. 而後這個任務將會被重新加入時間輪,因為時鐘已經過了640秒,因此此時的一級、二級時間輪均發生了變化,二級時間輪被替換為如下,因此當前任務會被放入641-720格子中
    1. 641-720
    2. 721-800
    3. 801-880
    4. 881-960
    5. 961-1040
    6. 1041-1120
    7. 1121-1200
    8. 1201-1280
  1. 而641-720格子對應的一級時間輪如下,700秒任務對應的格子為691-700,因此在後續的時鐘模擬中,真正要等到691-700這個格子被喚醒才能呼叫
    1. 641-650
    2. 651-660
    3. 661-670
    4. 671-680
    5. 681-690
    6. 691-700
    7. 701-710
    8. 711-720

5.4、時鐘模擬

接下來就是非常重要的一步,Purgatory要模擬時鐘往前推進時間,從而觸發相關任務被喚醒

5.4.1、java.util.concurrent.DelayQueue

在真正開始介紹時鐘模擬前,我們需要先鋪墊一個關鍵的JUC包下的類java.util.concurrent.DelayQueue,整個時鐘模擬在很大程度上依賴這個延遲佇列的能力。DelayQueue有如下幾個核心方法:

  • put (java.util.concurrent.Delayed delayed) 將一個延遲物件放入延遲佇列中
  • offer (java.util.concurrent.Delayed delayed) 同 put
  • poll() 將會一直阻塞, 直到返回一個已經過期的延遲物件,不過如果當前的延遲佇列中沒有資料,將會直接返回null
  • poll(long timeout, TimeUnit unit) 功能與poll() 相似,只不過當前方法加入了超時限定,且如果延遲佇列為空的話,也不會立即返回null,而是等待超時

因為DelayQueue只接受java.util.concurrent.Delayed物件,此物件的定義如下

/**
 * A mix-in style interface for marking objects that should be
 * acted upon after a given delay.
 *
 * <p>An implementation of this interface must define a
 * {@code compareTo} method that provides an ordering consistent with
 * its {@code getDelay} method.
 *
 * @since 1.5
 * @author Doug Lea
 */
public interface Delayed extends Comparable<Delayed> {

    /**
     * Returns the remaining delay associated with this object, in the
     * given time unit.
     *
     * @param unit the time unit
     * @return the remaining delay; zero or negative values indicate
     * that the delay has already elapsed
     */
    long getDelay(TimeUnit unit);
}

可見是一個介面,如果使用的話,我們需要定義一個延遲類,並實現這個介面。我們可以寫一個延遲佇列的小例子來個直觀感受

public class DelayedQueueExample {
    public static void main(String[] args) throws InterruptedException {
        DelayQueue<DelayedItem> delayQueue = new DelayQueue<>();

        delayQueue.put(new DelayedItem(2000));
        delayQueue.put(new DelayedItem(5000));
        delayQueue.offer(new DelayedItem(6000));

        while (!delayQueue.isEmpty()) {
            DelayedItem delayedItem = delayQueue.poll(200, TimeUnit.MILLISECONDS);
            if (delayedItem != null) {
                System.out.println("delayedItem content : " + delayedItem);
            } else {
                System.out.println("DelayedItem is null");
            }
        }
    }

    private static class DelayedItem implements Delayed {
        private final long expirationTime;

        public DelayedItem(long delayTime) {
            this.expirationTime = System.currentTimeMillis() + delayTime;
        }

        @Override
        public long getDelay(TimeUnit unit) {
            long diff = expirationTime - System.currentTimeMillis();
            return unit.convert(diff, TimeUnit.MILLISECONDS);
        }

        @Override
        public int compareTo(Delayed other) {
            if (this.getDelay(TimeUnit.MILLISECONDS) < other.getDelay(TimeUnit.MILLISECONDS)) {
                return -1;
            }
            if (this.getDelay(TimeUnit.MILLISECONDS) > other.getDelay(TimeUnit.MILLISECONDS)) {
                return 1;
            }
            return 0;
        }
    }
}

上述例子中,我們往延遲佇列中放入了3條資料,它們需要處理延遲請求的時間分別是2秒、5秒、6秒,當呼叫poll()方法時,可以精確在對應的時間收到該請求的回撥,當然這塊的高效得益於Doug Lea大神的JUC包

有同學可能會說,既然JUC的延遲佇列都能把這些事兒做了,還要時間輪做什麼用呢?自然延遲佇列是有它自己問題的,參看“演化”模組

5.4.2、延遲物件

那放入延遲佇列java.util.concurrent.DelayQueue的元素是這些延遲Task嗎?答案是否定的,因為這些任務一旦放入延遲佇列,那它的刪除就會成為負擔,而且帶來大量記憶體的佔用(其實Purgatory的第一版就是這樣設計的),其實這裡延遲佇列的元素是時間輪中的雙向迴圈列表,如下圖

Kafka原理剖析之「Purgatory(煉獄 | 時間輪)」

這裡關於任務的新增與刪除,站在延遲佇列的角度再討論一下

  • 當Task加入到時間輪中的一個空格子中時,此時會建立一個TaskList物件,當然這個TaskList的雙向連結串列中只有一個元素,而後這個TaskList被加入延遲佇列
  • 當Task加入一個有資料的格子中時,直接將這個Task加入TaskList的連結串列中,因為這個TaskList已經託付給延遲佇列管理,因此此時不涉及延遲佇列操作
  • 當某個Task需要刪除時,直接找到對應的TaskList,將其從連結串列中移除
  • 當TaskList超時,被延遲佇列喚起,此時這些Task將會被依次處理,而如果TaskList中的連結串列為空,則直接跳過

這樣不僅完美避開了對延遲佇列中元素刪除的操作,而且完美解決了OOM的問題,且元素的新增、刪除時間複雜度均為O(1)

5.4.3、Tick

模擬時鐘推進使用的執行緒即為上文提到的收割執行緒,方法的入口為kafka.server.DelayedOperationPurgatory#advanceClock,當然這裡處理的均是已經超時的請求,因此如果所有的操作均沒有超時,那收割執行緒實際沒有需要處理的業務

其實關於Tick操作的核心就是呼叫延遲佇列的poll操作,用來獲取那些已經超時的TimerTaskList

TimerTaskList bucket = delayQueue.poll(timeoutMs, TimeUnit.MILLISECONDS);

不過這個Tick的粒度是時間輪的每一個格子,因此它與Task的頻率是不一致的,通常一個格子中可能包含了多個Task,這些Task如果在時間上確實超時了,那麼會真正業務回撥,如果沒有超時,將重新加入時間輪

5.5、Watch Key

所謂Watch Key,通常是將一組生命週期相關的資料設定同一個key,這樣在條件達成後,可將這組任務統一回撥,不論是成功還是取消

例如當執行Group的Join操作時,預期會有10個consumer呼叫Join介面,然後每個consumer呼叫介面時,均帶上watch key引數(這裡的watch key可以設定為group name),只要發現呼叫這個key的數量滿10個後,便可以將這10個延遲請求統一回撥,同時將其從時間輪中刪除

六、原始碼分析

我們將上圖關鍵部分標記出相關類

Kafka原理剖析之「Purgatory(煉獄 | 時間輪)」

  • 首先整個時間輪對應的類為org.apache.kafka.server.util.timer.TimingWheel
  • 時間輪上每個格子對應的類為org.apache.kafka.server.util.timer.TimerTaskList
  • 每個格子中連結串列中的元素對應的類為org.apache.kafka.server.util.timer.TimerTaskEntry
  • 每個元素中需要儲存TimerTask,這個類為抽象類,也是需要使用者自己去實現的 org.apache.kafka.server.util.timer.TimerTask

其實透過這張圖,我們對Purgatory涉及的類便有了全貌的瞭解,這裡主要了解的是TimerTask,因為其他類均被包裝在Purgatory元件內部,不需要繼承,也不涉及改動。TimerTask的定義如下

public abstract class TimerTask implements Runnable {
    private volatile TimerTaskEntry timerTaskEntry;
    public final long delayMs;
}

這兩個屬性也較好理解

  • timerTaskEntry:它與TimerTask其實就是1:1的關係,同樣在TimerTask中也有TimerTaskEntry的引用
  • delayMs:延遲時間,也就該任務將來被觸發呼叫的時間

然而僅僅有這個類還是不夠的,還需要在一些關鍵操作時,對相關介面進行回撥,例如onComplete、onExpiration等。因此Kafka涉及了TimerTask的子類DelayedOperation

abstract class DelayedOperation(delayMs: Long,
                                lockOpt: Option[Lock] = None)
  extends TimerTask(delayMs) with Logging {

  private val completed = new AtomicBoolean(false)
  // Visible for testing
  private[server] val lock: Lock = lockOpt.getOrElse(new ReentrantLock)

  /*
   * Force completing the delayed operation, if not already completed.
   * This function can be triggered when
   *
   * 1. The operation has been verified to be completable inside tryComplete()
   * 2. The operation has expired and hence needs to be completed right now
   *
   * Return true iff the operation is completed by the caller: note that
   * concurrent threads can try to complete the same operation, but only
   * the first thread will succeed in completing the operation and return
   * true, others will still return false
   */
  def forceComplete(): Boolean = {
    if (completed.compareAndSet(false, true)) {
      // cancel the timeout timer
      cancel()
      onComplete()
      true
    } else {
      false
    }
  }

  /**
   * Check if the delayed operation is already completed
   */
  def isCompleted: Boolean = completed.get()

  /**
   * Call-back to execute when a delayed operation gets expired and hence forced to complete.
   */
  def onExpiration(): Unit

  /**
   * Process for completing an operation; This function needs to be defined
   * in subclasses and will be called exactly once in forceComplete()
   */
  def onComplete(): Unit

  /**
   * Try to complete the delayed operation by first checking if the operation
   * can be completed by now. If yes execute the completion logic by calling
   * forceComplete() and return true iff forceComplete returns true; otherwise return false
   *
   * This function needs to be defined in subclasses
   */
  def tryComplete(): Boolean

  /**
   * Thread-safe variant of tryComplete() and call extra function if first tryComplete returns false
   * @param f else function to be executed after first tryComplete returns false
   * @return result of tryComplete
   */
  private[server] def safeTryCompleteOrElse(f: => Unit): Boolean = inLock(lock) {
    if (tryComplete()) true
    else {
      f
      // last completion check
      tryComplete()
    }
  }

  /**
   * Thread-safe variant of tryComplete()
   */
  private[server] def safeTryComplete(): Boolean = inLock(lock)(tryComplete())

  /*
   * run() method defines a task that is executed on timeout
   */
  override def run(): Unit = {
    if (forceComplete())
      onExpiration()
  }
}

所有的業務類均需要繼承DelayedOperation並重寫相關方法,相關邏輯不再贅述

總結:以上只是分析了Purgatory的設計思路及大致流程,還有很多多執行緒併發相關的效能操作,Kafka均處理的非常漂亮,本文不能列舉,讀者有興趣可以參照文章過一遍原始碼,相信大有裨益

相關文章