一、前言
本文介紹一下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元件,我們實現起來的話,流程可能如下:
假定4個執行緒在時間視窗內都到達了,但是到達的前後順序不一致。執行緒2已到達,執行緒1、執行緒3、執行緒4都還在路上,因此執行緒2一直處於掛起狀態,即便是有新的任務到達,執行緒2也無法進行響應處理,且對應執行緒2的佔用一直要等到ack response後,才能釋放出來,效率低下
而圖中“Wait All Threads Ready”元件如何實現呢?其實可以使用JDK的CyclicBarrier或Semaphore或CountDownLatch均可實現,但不是本文要討論的重點,不再展開
Purgatory如何實現呢?它在整個流程中扮演了一個什麼角色呢
首先流程上還是4個執行緒先後過來呼叫介面,但區別在於,已經到達的執行緒只需要將自己已經receive的訊息(包含回撥、超時等基礎資料)給到Purgatory即可,而後這個執行緒將會被釋放,它可以去處理其他任務。而後續的操作則會由Purgatory來接管,包括判斷條件是否滿足、視窗是否超時,一旦其中一個條件滿足,Purgatory將執行回撥,挨個對這些請求進行ack response
知道了Purgatory在整體流程中扮演的角色,接下來我們就要對這個元件內部實現的細節進行展開了
四、Purgatory組成
我們還是先提供一張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、資料結構
時間輪的資料結構也相對簡單,由一個輪子+雙向連結串列組成
輪子:
雙向列表:
輪子+雙向列表的結構便為:
之所以設計雙向連結串列的方式,主要是Task的新增跟刪除是一件非常頻繁的事兒,我們的資料結構要能確保高效地處理這些請求,而雙向迴圈連結串列則能夠保證任意一個Task節點的新增與刪除都能維持O(1)的時間複雜度,因此可謂是不二之選
5.2、Task新增與移除
具體的Task新增與刪除的過程是什麼樣呢?我們舉個例子來說明:
假定我現在時間輪的粒度為10秒鐘,即每10秒一個格子
現在來了一個任務,這個任務Task1要在第35秒被觸發,此時我們找到第4個時間格,將這個任務放在這裡
接著又來了2個任務,分別在36、38秒被觸發,那麼同樣它們將會被放在第4個時間格中
同理,不難想象,如果又來了幾個任務,它們的觸發時間分別為12、18、69、62、65、53、54,那麼時間輪將會變為如下
如果來個延遲時間是100秒的任務呢?其實這塊就涉及到多級時間輪了
至於任務的移除,參照雙向連結串列刪除節點即可
5.3、多級時間輪
5.3.1、基礎定義
多級時間輪,顧名思義,即有很多個層級的時間輪,越往上,粒度越粗,理論上只要記憶體夠大,時間輪可以儲存無限大小的延遲任務。下圖展示了一個2級時間輪:
- 內層時間輪:時間粒度是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秒後執行的延遲任務
一級時間輪,也就是最細粒度的時間輪,範圍是0-80秒,無法存放,那麼向上拋送;
二級時間輪收到這個任務後,發現超時時間是700秒,而自己的範圍則是0-640,依舊無法存放,繼續向上拋送
三級時間輪收到任務後依然檢查自己能接收的時間範圍,發現是0-5120秒,700秒在自己的範圍內,繼而計算700秒任務應該落在哪個格子,最終其被存放在641-1280這個格子中
總結:任務的新增總是先交給最細粒度的時間輪,而後層層上報,直到找到能承接這個Task的輪子後便將其存放在對應的格子中
5.3.2、Task移除
常規情況下,一個處於高Level的Task,在還沒有真正過期時,它的移除動作就是將其放入更細粒度的時間輪中,還是以上圖中的例子來說明
- 現在700秒的Task被放在三級時間輪的"641-1280"這個格子(TaskList)中,這個格子將在640秒過期
- 現在時鐘剛過640秒,"641-1280"這個格子被推出,發現其中有1個700秒超時的任務,但是其還沒有真正超時,因為當前的時間是640秒
- 而後這個任務將會被重新加入時間輪,因為時鐘已經過了640秒,因此此時的一級、二級時間輪均發生了變化,二級時間輪被替換為如下,因此當前任務會被放入641-720格子中
- 641-720
- 721-800
- 801-880
- 881-960
- 961-1040
- 1041-1120
- 1121-1200
- 1201-1280
- 而641-720格子對應的一級時間輪如下,700秒任務對應的格子為691-700,因此在後續的時鐘模擬中,真正要等到691-700這個格子被喚醒才能呼叫
- 641-650
- 651-660
- 661-670
- 671-680
- 681-690
- 691-700
- 701-710
- 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的第一版就是這樣設計的),其實這裡延遲佇列的元素是時間輪中的雙向迴圈列表,如下圖
這裡關於任務的新增與刪除,站在延遲佇列的角度再討論一下
- 當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個延遲請求統一回撥,同時將其從時間輪中刪除
六、原始碼分析
我們將上圖關鍵部分標記出相關類
- 首先整個時間輪對應的類為
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均處理的非常漂亮,本文不能列舉,讀者有興趣可以參照文章過一遍原始碼,相信大有裨益