[從原始碼學設計]螞蟻金服SOFARegistry之時間輪的使用
0x00 摘要
在我們的業務系統和日常開發之中,定期任務是一個常見的需求。即也有普通需求,也有特殊業務需求。本文和下文就以 SOFARegistry 為例,看看阿里是如何實現定期任務的。這裡會結合業務來進行講解。
在學習過程中,要隨時考慮:設想如果你是設計者,你應該如何設計,採用什麼樣的演算法和資料結構;如果你想擴充到分散式領域,你需要做哪些額外的考慮,如何修改;
本文是系列第八篇,借鑑了網上眾多文章,請參見0xFF 參考。也分析了Netty同Kakfa之中的時間輪特點。
0x01 業務領域
我們將業務系統中需要使用定時任務排程總結成三種場景:
- 時間驅動處理場景:如整點傳送優惠券,每天定時更新收益;
- 批量處理資料:如按月批量統計報表資料,批量更新某些資料狀態,實時性要求不高;
- 非同步執行解耦:如先反饋使用者操作狀態,後臺非同步執行較耗時的資料操作,以實現非同步邏輯解耦;
1.1 應用場景
在網路中,會有大量使用定時任務的需求,比如Netty:
由於Netty動輒管理100w+的連線,每一個連線都會有很多超時任務。比如傳送超時、心跳檢測間隔等,如果每一個定時任務都啟動一個Timer,不僅低效,而且會消耗大量的資源。
在Netty中的一個典型應用場景是判斷某個連線是否idle,如果idle(如客戶端由於網路原因導致到伺服器的心跳無法送達),則伺服器會主動斷開連線,釋放資源。得益於Netty NIO的優異效能,基於Netty開發的伺服器可以維持大量的長連線,單臺8核16G的雲主機可以同時維持幾十萬長連線,及時掐掉不活躍的連線就顯得尤其重要。
同理,在 SOFARegistry 之中,也有類似需求。
0x02 定時任務
2.1 什麼是定時任務
定時器是什麼?可以理解為這樣一個資料結構:
儲存一系列的任務集合,Deadline 越接近的任務,擁有越高的執行優先順序。
在使用者視角支援以下幾種操作:
- NewTask:將新任務加入任務集合
- Cancel:取消某個任務
在任務排程的視角還要支援:
- Run:執行一個到底的定時任務
判斷一個任務是否到期,基本採用輪詢的方式,每隔一個時間片 去檢查 最近的任務是否到期。說到底,定時器還是靠執行緒輪詢實現的。
2.2 Java定時任務框架
本文集中在單機領域。
- timer:一個定時器類,通過該類可以為指定的定時任務進行配置。TimerTask類是一個定時任務類,該類實現了Runnable介面,缺點是異常未檢查會中止執行緒;
- ScheduledExecutorService:相對延遲或者週期作為定時任務排程,缺點沒有絕對的日期或者時間;
- DelayQueue :JDK中提供的一組實現延遲佇列的API,位於Java.util.concurrent包下;DelayQueue是一個BlockingQueue(無界阻塞)佇列;
- spring定時框架:配置簡單功能較多,如果系統使用單機的話可以優先考慮spring定時器;
這些定時任務框架實現思想基本類似,都圍繞三個要素:任務、任務的組織者(佇列),執行者排程執行者。
Timer、ScheduledThreadPoolExecutor 完整的實現了這三個要素,DelayQueue只實現了任務組織者這個要素,需要與執行緒配合使用。其中任務組織者這個要素,它們都是通過優先佇列來實現,因此插入和刪除任務的時間複雜度都為O(logn),並且 Timer 、ScheduledThreadPool 的週期性任務是通過重置任務的下一次執行時間來完成的。
0x03 時間輪
3.1 緣由
大量的排程任務如果每一個都使用自己的排程器來管理任務的生命週期的話,浪費cpu的資源並且很低效。
我們可以將這些場景抽象用時間輪管理,時間輪就是和鐘錶很相似的存在!時間輪的實現類似鐘錶的運作方式,是一種高效來利用執行緒資源來進行批量化排程的一種排程模型,把大批量的排程任務全部都繫結到同一個的排程器上面,使用這一個排程器來進行所有任務的管理(manager),觸發(trigger)以及執行(runnable)。能夠高效的管理各種延時任務,週期任務,通知任務等等。
時間輪的任務插入和刪除時間複雜度都為O(1),相對而言時間輪更適合任務數很大的延時場景。
3.2 定義
George Varghese 和 Tony Lauck 1996 年的論文:Hashed and Hierarchical Timing Wheels: data structures to efficiently implement a timer facility。提出了一種定時輪的方式來管理和維護大量的Timer
排程。
時間輪其實就是一種環形的資料結構,可以想象成時鐘,分成很多bucket ,一個bucket 代表一段時間(這個時間越短,Timer
的精度越高)。每一個 bucket 上可以存放多個任務,使用一個 List 儲存該時刻到期的所有任務,同時一個指標隨著時間流逝一格一格轉動,並執行對應 bucket 上所有到期的任務。任務通過 取模
決定應該放入哪個 bucket 。和 HashMap 的原理類似,newTask 對應 put,使用 List 來解決 Hash 衝突。
傳統定時器是面向任務的,時間輪定時器是面向 bucket 的。
用到延遲任務時,比較直接的想法是DelayQueue、ScheduledThreadPoolExecutor 這些,而時間輪相比之下,最大的優勢是在時間複雜度上,當任務較多時,TimingWheel的時間效能優勢會更明顯。
HashedWheelTimer本質是一種類似延遲任務佇列的實現,那麼它的特點就是上述所說的,適用於對時效性不高的,可快速執行的,大量這樣的“小”任務,能夠做到高效能,低消耗。例如:
- 心跳檢測;
- 請求/事務/鎖的超時處理;
缺點則是:
- 時間輪排程器的時間精度可能不是很高,對於精度要求特別高的排程任務可能不太適合。因為時間輪演算法的精度取決於,時間段“指標”單元的最小粒度大小,比如時間輪的格子是一秒跳一次,那麼排程精度小於一秒的任務就無法被時間輪所排程;
- 而且時間輪演算法沒有做當機備份,因此無法再當機之後恢復任務重新排程;
3.3 Netty時間輪HashedWheelTimer
我們進行netty.HashedWheelTimer 的解讀。
HashedWheelTimer最初版本wheel是set陣列+ConcurrentHashMap,然後逐步演變。
Netty構建延時佇列主要用HashedWheelTimer,HashedWheelTimer底層資料結構依然是使用DelayedQueue,只是採用時間輪的演算法來實現。
HashedWheelTimer提供的是一個定時任務的一個優化實現方案,在netty中主要用於非同步IO的定時規劃觸發(A timer optimized for approximated I/O timeout scheduling)。
3.3.1 實現
通過程式碼查閱,發現時間輪整體邏輯簡單清晰:等待時間 ---> 處理取消的任務 ---> 佇列中的任務入槽 ---> 處理執行的任務。
我們主要看下這三個問題:
- 等待時間是如何計算的,這個跟時間精度相關
- 佇列中的任務如何入槽的(對應上面的疑問)
- 任務如何執行的
等待時間是如何計算的?worker.waitForNextTick 就是通過tickDuration和此時已經移動的tick算出下一次需要檢查的時間,如果時間未到就sleep。
**佇列中的任務如何入槽的?worker.transferTimeoutsToBuckets ** 就是設定了一次性處理10w個任務入槽,從佇列中拿出任務,計算輪數,如果時間已經過了,放到當前即將執行的槽位中。
任務如何執行的?hashedWheelBucket.expireTimeouts 就是通過輪數和時間雙重判斷,執行任務。
小結:
Netty中時間輪演算法是基於輪次的時間輪演算法實現,通過啟動一個工作執行緒,根據時間精度TickDuration,移動指標找到槽位,根據輪次+時間來判斷是否是需要處理的任務。
不足之處:
- 時間輪的推進是根據時間精度TickDuration來固定推進的,如果槽位中無任務,也需要移動指標,會造成無效的時間輪推進,比如TickDuration為1秒,此時就一個延遲500秒的任務,那就是有499次無用的推進。
- 任務的執行都是同一個工作執行緒處理的,並且工作執行緒的除了處理執行到時的任務還做了其他操作,因此任務不一定會被精準的執行,而且任務的執行如果不是新起一個執行緒執行,那麼耗時的任務會阻塞下個任務的執行。
優勢:
- 時間精度可控;
- 並且增刪任務的時間複雜度都是O(1);
下面是幾個需要注意的點。
3.3.2 單執行緒與業務執行緒池
我們需要注意 HashedWheelTimer
使用的是單執行緒排程任務,如果任務比較耗時,應當設定一個業務執行緒池,將 HashedWheelTimer
當做一個定時觸發器,任務的實際執行,交給業務執行緒池。
3.3.3 全域性定時器
實際使用 HashedWheelTimer
時,應當將其當做一個全域性的任務排程器,例如設計成 static 。時刻謹記一點: HashedWheelTimer
對應一個執行緒,如果每次例項化 HashedWheelTimer
,首先是執行緒會很多,其次是時間輪演算法將會完全失去意義。
3.3.4 佇列
netty的HashedWheelTimer實現還有兩個東西值得關注,分別是pending-timeouts佇列和cancelled-timeouts佇列。
這兩個佇列分別記錄新新增的定時任務和要取消的定時任務,當workerThread每次迴圈執行時,它會先將pending-timeouts佇列中一定數量的任務移動到它們對應的bucket,並取消掉cancelled-timeouts中所有的任務。由於新增和取消任務可以由任意執行緒發起,而相應的處理只會在workerThread裡,所以為了進一步提高效能,這兩個佇列都是用了JCTools裡面的MPSC(multiple-producer-single-consumer)佇列。
3.4 Kafka和多層時間輪
kafka中存在著大量的延時操作,比如延遲生產,延遲拉取,延遲刪除等,這些延時操作是基於時間輪的概念自己實現了一個延時定時器,JDK中Timer和DelayQueue的插入和刪除操作的平均時間複雜度為O(nlogn)並不能滿足Kafka的高效能要求,而基於時間輪可以將插入和刪除操作的時間複雜度都降為 O(1)。
Kafka中時間輪演算法是基於多層次的時間輪演算法實現,並且是按需建立時間輪,採用任務的絕對時間來判斷延期,空間換時間的思想,用DelayQueue存放每個槽,並以每個槽的過期時間排序,通過delayQueue.poll阻塞式進行時間輪的推進,杜絕了空推進的問題。
3.4.1 實現
kafka中的時間輪是一個儲存定時任務的環形佇列,底層採用陣列實現,陣列中的每個元素可以存放一個定時任務列表(TimerTaskList
),TimerTaskList
是一個環形的雙向連結串列,連結串列中的每個元素TimerTaskEntry
封裝了一個真正的定時任務TimerTask
。
時間輪由固定格數(wheelSize
)的時間格組成,每一格都代表當前時間輪的基本時間跨度(tickMs
),整個時間輪的總體時間跨度(interval
)就是 wheelSize*tickMs
。
時間輪還有一個錶盤指標(currentTime
),其值是tickMs
的整數倍,用來表示時間輪當前所處的時間,表示當前需要處理的時間格對應的TimeTaskList
中的所有任務。
總之,整個時間輪的跨度是不會變的,隨著currentTime
的不斷推進,當前時間輪所能處理的時間段也在不斷後移,總體時間範圍就是currentTime
和currentTime + interval
之間。
3.4.2 問題
時間輪環形陣列的每個元素可以稱為槽,槽的內部用雙向連結串列存著待執行的任務,新增和刪除的連結串列操作時間複雜度都是 O(1),槽位本身也指代時間精度,比如一秒掃一個槽,那麼這個時間輪的最高精度就是 1 秒。也就是說延遲 1.2 秒的任務和 1.5 秒的任務會被加入到同一個槽中,然後在 1 秒的時候遍歷這個槽中的連結串列執行任務。
那麼,問題來了,如果一個新的定時任務遠遠超過了當前的總體時間範圍,比如350ms,那怎麼辦呢?
那假設現在要加入一個50秒後執行的任務怎麼辦?這槽好像不夠啊?難道要加槽嘛?和HashMap一樣擴容?
對於延遲超過時間輪所能表示的範圍有兩種處理方式:
- 一是通過增加一個欄位-輪數(Netty);
- 二是多層次時間輪(Kakfa);
Netty是通過增加輪次的概念:
先計算槽位:上面有八個槽。50 % 8 + 2 = 4,即應該放在槽位是 4,下標是 3 的位置。
然後計算輪次:(50 - 1) / 8 = 6,即輪數記為 6。也就是說當迴圈 6 輪之後掃到下標的 3 的這個槽位會觸發這個任務。
Netty 中的 HashedWheelTimer 使用的就是這種方式。
還有一種是通過多層次的時間輪:
這個和我們的手錶就更像了,像我們秒針走一圈,分針走一格,分針走一圈,時針走一格,多層次時間輪就是這樣實現的。
假設上圖就是第一層,那麼第一層走了一圈,第二層就走一格,可以得知第二層的一格就是8秒,假設第二層也是 8 個槽,那麼第二層走一圈,第三層走一格,可以得知第三層一格就是 64 秒。那麼一個三層,每層8個槽,一共24個槽時間輪就可以處理最多延遲 512 秒的任務。
3.4.3 多層時間輪原理
如果任務的時間跨度很大,數量也多,傳統的 HashedWheelTimer
會造成任務的 round
很大,單個 bucket 的任務 List 很長,並會維持很長一段時間。當時間跨度很大時,提升單層時間輪的 tickDuration 可以減少空轉次數,但會導致時間精度變低。
層級時間輪既可以避免精度降低,又避免了指標空轉的次數。如果有長時間跨度的定時任務,則可以交給層級時間輪去排程。為此,Kafka 針對時間輪演算法進行了優化,實現了層級時間輪 TimingWheel
,當任務到期時間遠遠超過當前時間輪所表示的時間範圍時,就會嘗試新增到上層時間輪中。
生活中我們常見的鐘表就是一種具有三層結構的時間輪:
- 第一層時間輪 tickMs=1ms 、wheelSize=60 、interval=1min,此為秒鐘;
- 第二層 tickMs= 1min、wheelSize=60 、interval= 1hour,此為分鐘;
- 第三層 tickMs=1hour 、 wheelSize= 12 、 interval= 12hours,此為時鐘;
所以,所有位於第二及第二層時間輪以上的任務在執行前都會有一個時間輪降級的過程,會從第n級,降到第n-1級,n-2級……直到降到第一級為止。
我們可以總結出,kafka的定時器只是持有第一層時間輪的引用,並不會直接持有其他高層時間輪的引用,但是每個時間輪都會有一個指向更高一層時間輪的引用,隨著時間的推移,高層時間輪內的定時任務也會重新插入到時間輪內,直到插入到第一層時間輪內等待被最終的執行。
現在,每個任務除了要維護在當前輪盤的 round
,還要計算在所有下級輪盤的 round
。當本層的 round
為0時,任務按下級 round
值被下放到下級輪子,最終在最底層的輪盤得到執行。
相比單層時間輪,層級時間輪在時間跨度較大時存在明顯的優勢。
3.4.4 降級
而多層次時間輪還會有降級的操作,假設一個任務延遲500秒執行,那麼剛開始加進來肯定是放在第三層的,當時間過了 436 秒後,此時還需要 64 秒就會觸發任務的執行,而此時相對而言它就是個延遲64秒後的任務,因此它會被降低放在第二層中,第一層還放不下它。再過個 56 秒,相對而言它就是個延遲8秒後執行的任務,因此它會再被降級放在第一層中,等待執行。
降級是為了保證時間精度一致性。
3.4.5 推進
在kafka時間輪中,最難懂的就是DelayQueue
與 時間輪的關係,文章開頭說了DelayQueue
不能滿足kafka的高效能要求,那麼這裡怎麼還要用到DelayQueue
呢?
3.4.5.1 空推進
首先我們想想在Kafka中到底是怎麼推進時間的呢?類似採用JDK中的scheduleAtFixedRate來每秒推進時間輪?顯然這樣並不合理,TimingWheel也失去了大部分意義。
一種直觀的想法是,像現實中的鐘表一樣,“一格一格”地走,這樣就需要有一個執行緒一直不停的執行,而大多數情況下,時間輪中的bucket大部分是空的,指標的“推進”就沒有實質作用。
Netty中時間輪的推進主要就是通過固定的時間間隔掃描槽位,有可能槽位上沒有任務,所以會有空推進的情況。
3.4.5.2 DelayQueue
相比Netty的實現會有空推進的問題,為了減少這種“空推進”,kafka的設計者就使用了DelayQueue
+時間輪的方式,來保證kafka的高效能定時任務的執行,Delayqueue
負責時間輪的推進工作,時間輪則負責將每個定時任務TimerTaskEntry
按照時間順序插入以及刪除,然後又使用專門的一個執行緒來從DelayQueue
中獲取到期的任務列表,然後執行對應的操作,這樣就利用空間換時間的思想解決了空推進的問題,保證了kafka的高效能執行。
即,Kafka用DelayQueue儲存每個bucket,以bucket為單位入隊,通過每個bucket的過期時間排序,這樣擁有最早需要執行任務的槽會被優先獲取。如果時候未到,那麼delayQueue.poll就會阻塞著。
每當有bucket到期,即queue.poll能拿到結果時,才進行時間的“推進”,減少了 ExpiredOperationReaper 執行緒空轉的開銷。這樣就不會有空推進的情況發生,同時呢,任務組織結構仍由時間輪組織,也兼顧了任務插入、刪除操作的高效能。
Kafka中的定時器真可謂是“知人善用”,用TimingWheel做最擅長的任務新增和刪除操作,而用DelayQueue做最擅長的時間推進工作,相輔相成。
3.4.5.3 SystemTimer--核心的排程邏輯
時間輪推進方法主要由工作執行緒SystemTimer呼叫。
kafka會啟動一個執行緒去推動時間輪的轉動,實現原理就是通過queue.poll()取出放在最前面的槽的TimerTaskList。
在時間輪新增任務時,所有有任務元素的TimerTaskList都會被新增到queue中。由於queue是一個延遲佇列,如果佇列中的expireTime沒有到達,該操作會阻塞,等待expireTime時間到達。如果poll取到了TimerTaskList,說明該槽裡面的任務時間到達,會先推進時間輪的轉動變更為當前時間,同時將到期的槽的所有任務都取出來,並通過TimingWheel重新add一遍,此時因為任務到期,並不會真正add進去,而是呼叫執行緒池執行任務,具體可以看SystemTimer.reinsert和TimingWheel.add方法。
3.4.6 總述
kafka對於時間輪最核心的實現包含時間輪的資料結構、新增任務、時間溢位(新增上一級時間輪)、時間輪推進四個核心部分。大的邏輯是
新增任務 ---> 是否時間溢位? ---> 溢位時新增上一級時間輪,並呼叫上一級時間輪的新增任務方法 ---> 未溢位,直接新增到槽位 ---> 遞迴處理。
所以時間輪的資料結構、時間溢位都通過新增任務的邏輯串聯了起來。
總結一下Kafka時間輪效能高的幾個主要原因:
-
時間輪的結構+雙向列表bucket,使得插入操作可以達到O(1)的時間複雜度
-
Bucket的設計讓多個任務“合併”,使得同一個bucket的多次插入只需要在delayQueue中入隊一次,同時減少了delayQueue中元素數量,堆的深度也減小,delayqueue的插入和彈出操作開銷也更小
0x04 SOFARegistry普通定時任務
SOFARegistry 也有幾種不同的定時任務需求,在這裡既實現了普通定時任務,也實現了特殊定時任務,HashedWheelTimer 就是用在了特殊定時任務上。
首先我們要介紹SOFARegistry之中普通定時任務的使用。普通定時任務的使用基本就是ScheduledExecutorService類似,現在以tasks bean為例。
4.1 ScheduledExecutorService
相比 Timer
, ScheduledExecutorService
解決了同一個定時器排程多個任務的阻塞問題,並且並且Java執行緒池的底層runworker實現了異常的捕獲,所以任務的異常不會中斷 ScheduledExecutorService
。
ScheduledExecutorService
提供了兩種常用的週期排程方法 ScheduleAtFixedRate 和 ScheduleWithFixedDelay。ScheduleAtFixedRate 是基於固定時間間隔進行任務排程,ScheduleWithFixedDelay 取決於每次任務執行的時間長短,是基於不固定時間間隔的任務排程。
ScheduledExecutorService
底層使用的資料結構為 PriorityQueue
。
在 Java 中, PriorityQueue
是一個天然的堆,可以利用傳入的 Comparator
來決定其中元素的優先順序。
NewTask:O(logN)
Cancel:O(logN)
Run:O(1)
N:任務數
堆與雙向有序連結串列相比,NewTask 和 Cancel 形成了 trade off,但考慮到現實中,定時任務取消的場景並不是很多,所以堆實現的定時器要比雙向有序連結串列優秀。
4.2 ThreadPoolExecutor
在StartTaskEventHandler之中,其針對 tasks Bean 裡面宣告的task,進行啟動。
public class StartTaskEventHandler extends AbstractEventHandler<StartTaskEvent> {
@Resource(name = "tasks")
private List<AbstractTask> tasks;
private ScheduledExecutorService executor = null;
@Override
public List<Class<? extends StartTaskEvent>> interest() {
return Lists.newArrayList(StartTaskEvent.class);
}
@Override
public void doHandle(StartTaskEvent event) {
if (executor == null || executor.isShutdown()) {
getExecutor();
}
for (AbstractTask task : tasks) {
if (event.getSuitableTypes().contains(task.getStartTaskTypeEnum())) {
executor.scheduleWithFixedDelay(task, task.getInitialDelay(), task.getDelay(),task.getTimeUnit());
}
}
}
private void getExecutor() {
executor = ExecutorFactory.newScheduledThreadPool(tasks.size(), this.getClass()
.getSimpleName());
}
}
可以看出來,都是使用 ExecutorFactory.newScheduledThreadPool 來啟動執行緒進行處理。
對應的Bean如下:
@Bean
public ConnectionRefreshTask connectionRefreshTask() {
return new ConnectionRefreshTask();
}
@Bean
public ConnectionRefreshMetaTask connectionRefreshMetaTask() {
return new ConnectionRefreshMetaTask();
}
@Bean
public RenewNodeTask renewNodeTask() {
return new RenewNodeTask();
}
@Bean(name = "tasks")
public List<AbstractTask> tasks() {
List<AbstractTask> list = new ArrayList<>();
list.add(connectionRefreshTask());
list.add(connectionRefreshMetaTask());
list.add(renewNodeTask());
return list;
}
ConnectionRefreshTask,ConnectionRefreshMetaTask,RenewNodeTask 這三個Task的迴圈間隔分別是 30 秒,10 秒,3 秒。
4.3 Scheduled
在Session Server中有使用spring @Scheduled註解執行定時任務。
SpringBoot自帶的Scheduled,可以將它看成一個輕量級的Quartz,而且使用起來比Quartz簡單許多。
public class SyncClientsHeartbeatTask {
@Scheduled(initialDelayString = "${session.server.syncHeartbeat.fixedDelay}", fixedDelayString = "${session.server.syncHeartbeat.fixedDelay}")
public void syncCounte() {
long countSub = sessionInterests.count();
long countPub = sessionDataStore.count();
long countSubW = sessionWatchers.count();
int channelCount = 0;
Server sessionServer = boltExchange.getServer(sessionServerConfig.getServerPort());
if (sessionServer != null) {
channelCount = sessionServer.getChannelCount();
}
}
}
具體引數是在配置檔案中。
session.server.syncHeartbeat.fixedDelay=30000
session.server.syncExceptionData.fixedDelay=30000
session.server.printTask.fixedDelay=30000
0x05 SOFT bolt的使用
5.1 SOFABolt網路模式
SOFABolt有四種網路模式:它們實現了多種通訊介面 oneway,sync,future,callback。
- oneway 不關心響應,請求執行緒不會被阻塞,但使用時需要注意控制呼叫節奏,防止壓垮接收方;
- sync 呼叫會阻塞請求執行緒,待響應返回後才能進行下一個請求。這是最常用的一種通訊模型;
- future 呼叫,在呼叫過程不會阻塞執行緒,但獲取結果的過程會阻塞執行緒;
- callback 是真正的非同步呼叫,永遠不會阻塞執行緒,結果處理是在非同步執行緒裡執行。
除了 oneway模式,其他三種通訊模型都需要進行超時控制,SOFABolt 同樣採用 Netty 裡針對超時機制,所設計的高效方案 HashedWheelTimer。
其原理是首先在發起呼叫前,SOFABolt 會新增一個超時任務 timeoutTask到 MpscQueue(Netty 實現的一種高效的無鎖佇列)裡,然後在迴圈裡,會不斷的遍歷 Queue 裡的這些超時任務(每次最多10萬),針對每個任務,會根據其設定的超時時間,來計算該任務所屬於的 bucket位置與剩餘輪數 remainingRounds,然後加入到對應 bucket的連結串列結構裡。隨著 tick++的進行,時間在不斷的增長,每 tick8 次,就是 1 個時間輪 round。當對應超時任務的remainingRounds減到 0時,就是觸發這個超時任務的時候,此時再執行其 run()方法,做超時邏輯處理。
- 最佳實踐:通常一個程式使用一個 HashedWheelTimer 例項,採用單例模型即可。
0x06 特殊定時任務
這裡的特殊定時任務 ,指的是使用了 AsyncHashedWheelTimer 的定時任務。
AsyncHashedWheelTimer 繼承了 HashedWheelTimer,加入了非同步執行。
6.1 AsyncHashedWheelTimer
Netty HashedWheelTimer
內部也同樣是使用了單個執行緒來進行任務排程。他跟 JDK 的 Timer
一樣,存在”前一個任務執行時間過長,影響後續定時任務執行的問題“。
以下是 Netty HashedWheelTimer
示意圖:
+--------+
| |
| 8-4 |
+------+ +--------+
| | | |
| 0-3 | | 8-3 |
+------+ +-------+ +--------+
| | | | | |
| 0-2 | | 3-2 | | 8-2 |
+------+ +-------+ +--------+
| | | | | |
| 0-1 | | 3-1 | | 8-1 |
+---+--+ +---+---+ +--------+
^ ^ ^
TimeWheel | | |
+-+-+---+----+-+-+---+---+---+---+-+-+---+
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
++--+---+----+---+---+---+---+---+---+---+
^
|
|
+
指 針
AsyncHashedWheelTimer
就是為了克服 Netty HashedWheelTimer
的問題。針對每個任務,加入了非同步執行的執行緒,這就避免了時間過長任務帶來的影響。以下是 AsyncHashedWheelTimer
示意圖:
+--------+ +----+
| +---->+Task|
| 8-4 | +----+
+----+ +------+ +--------+ +----+
|Task| <---+ | | +---->+Task|
+----+ | 0-3 | | 8-3 | +----+
+----+ +------+ +-------+ +----+ +--------+ +----+
|Task| <---+ | | +-->+Task| | +---->+Task|
+----+ | 0-2 | | 3-2 | +----+ | 8-2 | +----+
+----+ +------+ +-------+ +----+ +--------+ +----+
|Task| <---+ | | +-->+Task| | +---->+Task|
+----+ | 0-1 | | 3-1 | +----+ | 8-1 | +----+
+---+--+ +---+---+ +--------+
^ ^ ^
TimeWheel | | |
+-+-+---+----+-+-+---+---+---+---+-+-+---+
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
++--+---+----+---+---+---+---+---+---+---+
^
|
|
+
指 針
具體程式碼如下:
public class AsyncHashedWheelTimer extends HashedWheelTimer {
protected final Executor executor;
protected final TaskFailedCallback taskFailedCallback;
public AsyncHashedWheelTimer(ThreadFactory threadFactory, long tickDuration, TimeUnit unit,
int ticksPerWheel, int threadSize, int queueSize,
ThreadFactory asyncThreadFactory,
TaskFailedCallback taskFailedCallback) {
super(threadFactory, tickDuration, unit, ticksPerWheel);
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(threadSize, threadSize,300L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(queueSize), asyncThreadFactory);
threadPoolExecutor.allowCoreThreadTimeOut(true);
this.executor = threadPoolExecutor;
this.taskFailedCallback = taskFailedCallback;
}
public AsyncHashedWheelTimer(ThreadFactory threadFactory, long tickDuration, TimeUnit unit,
int ticksPerWheel, Executor asyncExecutor,
TaskFailedCallback taskFailedCallback) {
super(threadFactory, tickDuration, unit, ticksPerWheel);
this.executor = asyncExecutor;
this.taskFailedCallback = taskFailedCallback;
}
@Override
public Timeout newTimeout(TimerTask task, long delay, TimeUnit unit) {
return super.newTimeout(new AsyncTimerTask(task), delay, unit);
}
class AsyncTimerTask implements TimerTask, Runnable {
TimerTask timerTask;
Timeout timeout;
public AsyncTimerTask(TimerTask timerTask) {
super();
this.timerTask = timerTask;
}
@Override
public void run(Timeout timeout) {
this.timeout = timeout;
try {
AsyncHashedWheelTimer.this.executor.execute(this);
} catch (RejectedExecutionException e) {
taskFailedCallback.executionRejected(e);
} catch (Throwable e) {
taskFailedCallback.executionFailed(e);
}
}
@Override
public void run() {
try {
this.timerTask.run(this.timeout);
} catch (Throwable e) {
taskFailedCallback.executionFailed(e);
}
}
}
public interface TaskFailedCallback {
void executionRejected(Throwable e);
void executionFailed(Throwable e);
}
}
6.2 DatumLeaseManager
這裡業務實現比較複雜。會結合下文 AsyncHashedWheelTimer 一起論述。
afterWorkingProcess比較簡單,也是提交一個在若干時間之後執行的執行緒。
public class DatumLeaseManager implements AfterWorkingProcess {
private ScheduledThreadPoolExecutor executorForHeartbeatLess;
private AsyncHashedWheelTimer datumAsyncHashedWheelTimer;
@Override
public void afterWorkingProcess() {
executorForHeartbeatLess.schedule(() -> {
serverWorking = true;
}, dataServerConfig.getRenewEnableDelaySec(), TimeUnit.SECONDS);
}
}
6.2.1 時間輪
具體時間輪建立如下,這裡是建立了一個100毫秒的時間輪。
datumAsyncHashedWheelTimer = new AsyncHashedWheelTimer(threadFactoryBuilder.setNameFormat(
"Registry-DatumLeaseManager-WheelTimer").build(), 100, TimeUnit.MILLISECONDS, 1024,
dataServerConfig.getDatumLeaseManagerExecutorThreadSize(),
dataServerConfig.getDatumLeaseManagerExecutorQueueSize(), threadFactoryBuilder
.setNameFormat("Registry-DatumLeaseManager-WheelExecutor-%d").build(),
new TaskFailedCallback() {
@Override
public void executionRejected(Throwable e) {
}
@Override
public void executionFailed(Throwable e) {
}
});
這裡主要是用來續約renew
6.2.2 續約操作
續約是對外提供了renew的API,在 PublishDataHandler之中呼叫。
如果可以續約,則呼叫datumLeaseManager.renew(connectId)。
@Override
public Object doHandle(Channel channel, PublishDataRequest request) {
Publisher publisher = Publisher.internPublisher(request.getPublisher());
if (forwardService.needForward()) {
CommonResponse response = new CommonResponse();
response.setSuccess(false);
response.setMessage("Request refused, Server status is not working");
return response;
}
dataChangeEventCenter.onChange(publisher, dataServerConfig.getLocalDataCenter());
if (publisher.getPublishType() != PublishType.TEMPORARY) {
String connectId = WordCache.getInstance().getWordCache(
publisher.getSourceAddress().getAddressString());
sessionServerConnectionFactory.registerConnectId(request.getSessionServerProcessId(),
connectId);
// record the renew timestamp
datumLeaseManager.renew(connectId);
}
return CommonResponse.buildSuccessResponse();
}
記錄最新的時間戳,然後啟動scheduleEvictTask。
public void renew(String connectId) {
// record the renew timestamp
connectIdRenewTimestampMap.put(connectId, System.currentTimeMillis());
// try to trigger evict task
scheduleEvictTask(connectId, 0);
}
6.2.3 續約實現
續約的內部實現是基於AsyncHashedWheelTimer。
- 如果當前ConnectionId已經被鎖定,說明有執行緒在做同樣操作,則返回;
- 否則啟動時間輪,加入一個定時操作,如果時間到,則:
- 釋放當前ConnectionId對應的lock;
- 獲取當前ConnectionId對應的上次續約時間,如果不存在,說明當前ConnectionId已經被移除,則返回;
- 如果當前狀態是不可續約狀態,則設定下次定時操作時間,因為If in a non-working state, cannot clean up because the renew request cannot be received at this time;
- 如果上次續約時間已經到期,則使用evict進行驅逐
- 如果沒到期,則會呼叫 scheduleEvictTask(connectId, nextDelaySec); 設定下次操作
具體程式碼如下:
/**
* trigger evict task: if connectId expired, create ClientDisconnectEvent to cleanup datums bind to the connectId
* PS: every connectId allows only one task to be created
*/
private void scheduleEvictTask(String connectId, long delaySec) {
delaySec = (delaySec <= 0) ? dataServerConfig.getDatumTimeToLiveSec() : delaySec;
// lock for connectId: every connectId allows only one task to be created
Boolean ifAbsent = locksForConnectId.putIfAbsent(connectId, true);
if (ifAbsent != null) {
return;
}
datumAsyncHashedWheelTimer.newTimeout(_timeout -> {
boolean continued = true;
long nextDelaySec = 0;
try {
// release lock
locksForConnectId.remove(connectId);
// get lastRenewTime of this connectId
Long lastRenewTime = connectIdRenewTimestampMap.get(connectId);
if (lastRenewTime == null) {
// connectId is already clientOff
return;
}
/*
* 1. lastRenewTime expires, then:
* - build ClientOffEvent and hand it to DataChangeEventCenter.
* - It will not be scheduled next time, so terminated.
* 2. lastRenewTime not expires, then:
* - trigger the next schedule
*/
boolean isExpired =
System.currentTimeMillis() - lastRenewTime > dataServerConfig.getDatumTimeToLiveSec() * 1000L;
if (!isRenewEnable()) {
nextDelaySec = dataServerConfig.getDatumTimeToLiveSec();
} else if (isExpired) {
int ownPubSize = getOwnPubSize(connectId);
if (ownPubSize > 0) {
evict(connectId);
}
connectIdRenewTimestampMap.remove(connectId, lastRenewTime);
continued = false;
} else {
nextDelaySec = dataServerConfig.getDatumTimeToLiveSec()
- (System.currentTimeMillis() - lastRenewTime) / 1000L;
nextDelaySec = nextDelaySec <= 0 ? 1 : nextDelaySec;
}
}
if (continued) {
scheduleEvictTask(connectId, nextDelaySec);
}
}, delaySec, TimeUnit.SECONDS);
}
具體如下圖所示
+------------------+ +-------------------------------------------+
|PublishDataHandler| | DatumLeaseManager |
+--------+---------+ | |
| | newTimeout |
| | +----------------------> |
doHandle | ^ + |
| | | | |
| renew | +-----------+--------------+ | |
| +--------------> | | AsyncHashedWheelTimer | | |
| | +-----+-----+--------------+ | |
| | | ^ | |
| | | | scheduleEvictTask | |
| | evict | + v |
| | | <----------------------+ |
| +-------------------------------------------+
| |
| |
| |
| |
v v
或者如下圖所示:
+------------------+ +-------------------+ +------------------------+
|PublishDataHandler| | DatumLeaseManager | | AsyncHashedWheelTimer |
+--------+---------+ +--------+----------+ +-----------+------------+
| | new |
doHandle +------------------------> |
| renew | |
+-------------------> | |
| | |
| | |
| scheduleEvictTask |
| | |
| | newTimeout |
| +----------> +------------------------> |
| | | |
| | | |
| | | |
| | | No +
| | | <---------------+ if (ownPubSize > 0)
| | | +
| | v |
| +--+ scheduleEvictTask | Yes
| + v
| | evict
| | |
v v v
6.3 SessionServerNotifier
當有資料釋出者 publisher 上下線時,會分別觸發 publishDataProcessor 或 unPublishDataHandler ,Handler 會往 dataChangeEventCenter 中新增一個資料變更事件,用於非同步地通知事件變更中心資料的變更。事件變更中心收到該事件之後,會往佇列中加入事件。此時 dataChangeEventCenter 會根據不同的事件型別非同步地對上下線資料進行相應的處理。
與此同時,DataChangeHandler 會把這個事件變更資訊通過 ChangeNotifier 對外發布,通知其他節點進行資料同步。
notify函式會遍歷dataChangeNotifiers,找出可以支援本Datum對應SourceType的Notifier來執行。
具體如何支援哪些函式,是由getSuitableSource設定的。
private void notify(Datum datum, DataSourceTypeEnum sourceType, Long lastVersion) {
for (IDataChangeNotifier notifier : dataChangeNotifiers) {
if (notifier.getSuitableSource().contains(sourceType)) {
notifier.notify(datum, lastVersion);
}
}
}
SessionServerNotifier定義如下。
public class SessionServerNotifier implements IDataChangeNotifier {
private AsyncHashedWheelTimer asyncHashedWheelTimer;
@Autowired
private DataServerConfig dataServerConfig;
@Autowired
private Exchange boltExchange;
@Autowired
private SessionServerConnectionFactory sessionServerConnectionFactory;
@Autowired
private DatumCache datumCache;
}
6.3.1 時間輪
建立了一個500毫秒的時間輪。
@PostConstruct
public void init() {
ThreadFactoryBuilder threadFactoryBuilder = new ThreadFactoryBuilder();
threadFactoryBuilder.setDaemon(true);
asyncHashedWheelTimer = new AsyncHashedWheelTimer(threadFactoryBuilder.setNameFormat(
"Registry-SessionServerNotifier-WheelTimer").build(), 500, TimeUnit.MILLISECONDS, 1024,
dataServerConfig.getSessionServerNotifierRetryExecutorThreadSize(),
dataServerConfig.getSessionServerNotifierRetryExecutorQueueSize(), threadFactoryBuilder
.setNameFormat("Registry-SessionServerNotifier-WheelExecutor-%d").build(),
new TaskFailedCallback() {
@Override
public void executionRejected(Throwable e) {
}
@Override
public void executionFailed(Throwable e) {
}
});
}
從業務角度看,當有publisher相關訊息來臨時候,
DataChangeHandler的notify函式會遍歷dataChangeNotifiers,找出可以支援本Datum對應SourceType的Notifier來執行。
private void notify(Datum datum, DataSourceTypeEnum sourceType, Long lastVersion) {
for (IDataChangeNotifier notifier : dataChangeNotifiers) {
if (notifier.getSuitableSource().contains(sourceType)) {
notifier.notify(datum, lastVersion);
}
}
}
到了SessionServerNotifier這裡的notify函式,會遍歷目前快取的所有Connection,逐一通知。
@Override
public void notify(Datum datum, Long lastVersion) {
DataChangeRequest request = new DataChangeRequest(datum.getDataInfoId(),
datum.getDataCenter(), datum.getVersion());
List<Connection> connections = sessionServerConnectionFactory.getSessionConnections();
for (Connection connection : connections) {
doNotify(new NotifyCallback(connection, request));
}
}
具體通知函式:
private void doNotify(NotifyCallback notifyCallback) {
Connection connection = notifyCallback.connection;
DataChangeRequest request = notifyCallback.request;
try {
//check connection active
if (!connection.isFine()) {
return;
}
Server sessionServer = boltExchange.getServer(dataServerConfig.getPort());
sessionServer.sendCallback(sessionServer.getChannel(connection.getRemoteAddress()),
request, notifyCallback, dataServerConfig.getRpcTimeout());
} catch (Exception e) {
onFailed(notifyCallback);
}
}
而時間輪是在呼叫失敗的重試中使用。
就是當沒有達到失敗重試最大次數時,進行定時重試。
private void onFailed(NotifyCallback notifyCallback) {
DataChangeRequest request = notifyCallback.request;
Connection connection = notifyCallback.connection;
notifyCallback.retryTimes++;
//check version, if it's fall behind, stop retry
long _currentVersion = datumCache.get(request.getDataCenter(), request.getDataInfoId()).getVersion();
if (request.getVersion() != _currentVersion) {
return;
}
if (notifyCallback.retryTimes <= dataServerConfig.getNotifySessionRetryTimes()) {
this.asyncHashedWheelTimer.newTimeout(timeout -> {
//check version, if it's fall behind, stop retry
long currentVersion = datumCache.get(request.getDataCenter(), request.getDataInfoId()).getVersion();
if (request.getVersion() == currentVersion) {
doNotify(notifyCallback);
}
}, getDelayTimeForRetry(notifyCallback.retryTimes), TimeUnit.MILLISECONDS);
}
}
如下圖所示:
+-----------------+ +---------------------+ +-------------+ +---------------------+
|DataChangeHandler| |SessionServerNotifier| |sessionServer| |AsyncHashedWheelTimer|
+--+--------------+ +-------+-------------+ +----+--------+ +---------+-----------+
| | | |
| doNotify | | |
| +--------------------> | | |
| | sendCallback | |
| +------------------> | |
| | | |
| | | |
| | | |
| | onFailed | |
| | <----------------+ | |
| | | |
| | | newTimeout |
| +---------------------------------------> |
| | | |
| | | |timeout
| | | doNotify |
| | <-------------------------------------+ |
| | | |
| | | |
| | sendCallback | |
| | +----------------> | |
| | | |
v v ...... v v
0x07 總結
通過本文講解,我們基本理解了時間輪的原理,以及SOFARegistry中的應用,大家在具體工作中,可以參考其實現思路。
Timer、DelayQueue 和 ScheduledThreadPool,它們都是基於優先佇列實現的,O(logn) 的時間複雜度在任務數多的情況下頻繁的插入、刪除操作有效能問題,因此適合於任務數不多的情況。
- Timer是單執行緒的會有任務阻塞的風險,並且對異常沒有做處理,一個任務出錯Timer就掛了。
- ScheduledThreadPool相比於Timer引入了執行緒池,並且執行緒池對異常做了處理,使得任務之間不會有影響。
- Timer和ScheduledThreadPool可以週期性執行任務,DelayQueue就是個具有優先順序的阻塞佇列,需要配合外部的工作執行緒使用。
毋庸置疑,JDK 的 Timer
使用的場景是最窄的,完全可以被後兩者取代。
如何在 ScheduledExecutorService
和 HashedWheelTimer
之間如何做選擇,還是要區分場景來看待。
ScheduledExecutorService
是面向任務的,當任務數非常大時,使用堆(PriorityQueue)維護任務的新增、刪除會造成效能的下降,而HashedWheelTimer
是面向 bucket 的,設定合理的 ticksPerWheel,tickDuration ,可以不受任務量的限制。所以在任務量非常大時,HashedWheelTimer
可以表現出它的優勢。- 相反,如果任務量少,
HashedWheelTimer
內部的 Worker 執行緒依舊會不停的撥動指標,雖然不是特別消耗效能,但至少不能說:HashedWheelTimer
一定比ScheduledExecutorService
優秀。 HashedWheelTimer
由於開闢了一個 bucket 陣列,佔用的記憶體也會稍大。
所以我們得到了一個最佳實踐:在任務量非常大時,使用 HashedWheelTimer
可以獲得效能的提升。例如服務治理框架中的心跳定時任務,當服務例項非常多時,每一個客戶端都需要定時傳送心跳,每一個服務端都需要定時檢測連線狀態,這是一個非常適合使用 HashedWheelTimer
的場景。
0xFF 參考
時間輪演算法解析(Netty HashedWheelTimer原始碼解讀)
netty原始碼解讀之時間輪演算法實現-HashedWheelTimer
Netty工具類HashedWheelTimer原始碼走讀(一)
Netty 工具類 —— HashedWheelTimer 講解