更多請移步我的博客
背景
項目中存在以下場景需要延遲觸發一些事件:
- 訂單在未支付狀態下30分鐘後自動關閉;
- 訂單超過15天未主動確認收貨需要自動確認收貨;
- 商品價格需要在不同的時間段生效不同的價格方案等。
以上場景下需要有一個相對平臺化的服務來滿足,而不必每個項目自己做定時任務去進行輪詢。
解刨延遲/定時任務
構成一個任務有兩個要素:執行時間;執行邏輯。對任務規劃者而言,並不關心任務執行邏輯,規劃者只要在既定的時間觸發該任務,但既然作爲一個規劃者,就必須具備任務的基本維護能力:新增,刪除/取消,到期,查找。
那麼一個想要實現規劃者就必須考慮兩件事:1.怎樣即時發現時間到期;2.怎樣提高任務的維護效率,即怎麼存儲任務來保證對任務的高效操作。
本文只關注延時隊列中對任務的基本規劃能力的實現方式,不涉及延時系統的設計討論,系統層面的話題太大了。
Rocketmq延遲隊列實現
Rocketmq的定時隊列通過一個叫做“SCHEDULE_TOPIC_XXXX”的Topic來實現,這個Topic用來處理需要被延遲發送的消息。在Rocketmq中延遲消息被分爲幾個延遲級別,每個延遲級別分別對應“SCHEDULE_TOPIC_XXXX”Topic下一個延遲隊列,默認延遲級別爲:“1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h”。在Broker啓動時,會啓動相對應隊列的線程來處理各個延遲隊列的延遲消息。
盜用艾瑞克
一次分享中的圖來直觀感受下延遲隊列的實現。
Rocketmq處理通過消息體的擴展字段DELAY
來區分Producer是否投遞的是延遲消息,如果DELAY
大於0,即確定是延遲消息,Broker會備份源消息的topic和queueId,並將其替換爲對應延遲隊列的信息,然後將修改後的消息落盤到commitLog,DefaultMessageStore#ReputMessageService
Reput線程將消息分發至對應Topic的消息隊列(messageQueue),延遲隊列被ScheduleMessageService
消費,延遲消息到期後會被封裝爲一個新消息(恢復其源Topic及queueId等信息)再次走消息的投遞流程到commitLog,然後被Reput到最初要投遞的隊列,在整個過程中ScheduleMessageService
同時扮演了Consumer和Producer的角色,區分好這兩種角色後再來看ScheduleMessageService
這段代碼會清楚不少。下面列出的代碼有所刪減改,目的是爲了表達核心邏輯。
// ScheduleMessageService
public void start() {
if (started.compareAndSet(false, true)) {
this.timer = new Timer("ScheduleMessageTimerThread", true);
// 給不同級別的隊列啓動對應的任務線程
for (Map.Entry<Integer, Long> entry : this.delayLevelTable.entrySet()) {
Integer level = entry.getKey();
Long timeDelay = entry.getValue();
Long offset = this.offsetTable.get(level);
if (null == offset) {
offset = 0L;
}
if (timeDelay != null) {
this.timer.schedule(new DeliverDelayedMessageTimerTask(level, offset), FIRST_DELAY_TIME);
}
}
this.timer.scheduleAtFixedRate(new TimerTask() {
@Override
public void run() {
try {
// 定時持久化消費進度
if (started.get()) ScheduleMessageService.this.persist();
} catch (Throwable e) {
log.error("scheduleAtFixedRate flush exception", e);
}
}
}, 10000, this.defaultMessageStore.getMessageStoreConfig().getFlushDelayOffsetInterval());
}
}
/**
* ScheduleMessageService的內部類
*/
class DeliverDelayedMessageTimerTask extends TimerTask {
public void executeOnTimeup() {
// 找到對應的隊列
ConsumeQueue cq =
ScheduleMessageService.this.defaultMessageStore.findConsumeQueue(SCHEDULE_TOPIC,
delayLevel2QueueId(delayLevel));
long failScheduleOffset = offset;
// 如果隊列不存在,DELAY_FOR_A_WHILE後重新嘗試。todo: 什麼情況下會出現隊列爲null呢???
if (cq == null) {
ScheduleMessageService.this.timer.schedule(new DeliverDelayedMessageTimerTask(this.delayLevel,
failScheduleOffset), DELAY_FOR_A_WHILE);
return;
}
// 從指定位置拉取隊列中的可用消息
SelectMappedBufferResult bufferCQ = cq.getIndexBuffer(this.offset);
if (bufferCQ != null) {
try {
long nextOffset = offset;
int i = 0;
ConsumeQueueExt.CqExtUnit cqExtUnit = new ConsumeQueueExt.CqExtUnit();
// 隊列中的消息體大小均爲CQ_STORE_UNIT_SIZE,依次進行遍歷
for (; i < bufferCQ.getSize(); i += ConsumeQueue.CQ_STORE_UNIT_SIZE) {
long offsetPy = bufferCQ.getByteBuffer().getLong();
int sizePy = bufferCQ.getByteBuffer().getInt();
// 延遲消息的tagCode保存的是延時時間
long tagsCode = bufferCQ.getByteBuffer().getLong();
// omit tagsCode 校正
long now = System.currentTimeMillis();
// 交付時間計算
long deliverTimestamp = this.correctDeliverTimestamp(now, tagsCode);
// 下次開始拉取消息的位置
nextOffset = offset + (i / ConsumeQueue.CQ_STORE_UNIT_SIZE);
long countdown = deliverTimestamp - now;
if (countdown <= 0) {
// 如果到了交付時間,則從commitLog中拉取出消息
MessageExt msgExt =
ScheduleMessageService.this.defaultMessageStore.lookMessageByOffset(
offsetPy, sizePy);
if (msgExt != null) {
try {
// 封裝稱爲一條新的消息,主要是將在topic、queueId、tagCode等替換爲源消息的值
MessageExtBrokerInner msgInner = this.messageTimeup(msgExt);
PutMessageResult putMessageResult =
ScheduleMessageService.this.writeMessageStore
.putMessage(msgInner);
if (putMessageResult != null
&& putMessageResult.getPutMessageStatus() == PutMessageStatus.PUT_OK) {
continue;
} else {
// reput失敗的處理,主要是重設定時任務及持久化消費進度
return;
}
} catch (Exception e) {
//omit
}
}
} else {
// 重設定時任務及持久化消費進度
return;
}
} // end of for
nextOffset = offset + (i / ConsumeQueue.CQ_STORE_UNIT_SIZE);
// 重設定時任務及持久化消費進度
ScheduleMessageService.this.timer.schedule(new DeliverDelayedMessageTimerTask(
this.delayLevel, nextOffset), DELAY_FOR_A_WHILE);
ScheduleMessageService.this.updateOffset(this.delayLevel, nextOffset);
return;
} finally {
bufferCQ.release();
}
} // end of if (bufferCQ != null)
else {
// 校正消費偏移量
long cqMinOffset = cq.getMinOffsetInQueue();
if (offset < cqMinOffset) {
failScheduleOffset = cqMinOffset;
// omit error log
}
}
}
}
此種方式數據結構類似鏈表,但鏈表中的數據天然有序,爲什麼呢?因爲消息的延遲時間是Broker落盤時間加上延遲級別對應的時間,落盤後的消息纔會被ReputMessageService
Reput線程進行分發到指定的MessageQueue
,而Reput線程是個單線程,整個過程FIFO,所以延遲隊列天然有序。
我們再從任務的行爲:新增,刪除/取消,到期,查找,來看下Rocketmq延遲隊列作爲任務的優缺點。
- 因爲Rocketmq的設計方式,註定其延遲隊列只能支持特定延遲時間的特點;
- 因爲Rocketmq並不是爲延遲任務而生,所以它無法刪除/取消一個定時任務;
- 生產一個延遲消息和生產一個普通消息的過程是一致的,因此新增一個延遲消息無非就是像broker進行消息投遞,如果網絡穩定,其時間消耗穩定;
MessageQueue
的天然有序,保證隊列頭的消息一定是最先到期的,所以到期任務的檢索時間穩定;- 消息的查找只可根據msgId查找或者消息key查找,msgId中包含消息的物理偏移量(類似MySql的主鍵)可直接定位消息,而key的查找是根據key的hashCode查找索引文件獲得,可能或出現查出多條的情況,需要客戶端自己根據key再次過濾,除了在控制檯很少在Consumer端使用key發起查詢。
個人認爲雖然Rocketmq具備延遲的功能,但其主要目的是爲了實現“至少投遞一次”語義,因爲一個延遲消息處理完成會被Rocketmq落盤兩次,就只是因爲topic和queueId不同,如果Rocketmq只用來處理延遲消息,那麼其存儲的數據量延遲消息的兩倍。所以如果延遲消息量不是很大並且不需要靈活的延遲時間的話,Rocketmq不失爲一種好的選擇。
DelayQueue
在JDK中帶有定時/延遲特性的兩個類:DelayQueue
和ScheduledThreadPoolExecutor
。這兩個類名起的很好,見名知意。我們從這兩個類的數據結構入手看下JDK這兩個自帶類如何實現任務的規劃。
DelayQueue
內部使用PriorityQueue
來存儲定時/延時任務,相當於是PriorityQueue
的一種使用場景,主要特性是當隊列爲空或任務時間未到期時可讓拉取線程進行等待。
PriorityQueue
使用二叉堆
來存儲數據,這裏不去深究二叉堆
的定義,其特性是根節點一定是整個堆中最大/最小的點,這點是比較符合優先隊列的特性,PriorityQueue
的根節點queue[0]
是最小的節點,稱爲最小堆
。PS:二叉堆
是完全二叉樹
或者是近似完全二叉樹。
完全二叉樹 | 滿二叉樹 | |
---|---|---|
總節點k | ||
樹高h |
PriorityQueue
用數組來實現二叉堆
,queue[n]
節點的兩個子節點存儲在queue[2*n+1]
和queue[2*(n+1)]
,使用指定的Comparator
或節點本身實現比較接口Comparable
來排序。站在DelayQueue
的角色來看,PriorityQueue
的排序關鍵字是到期時間,比較器Comparator/Comparable
比較的就是延遲時間的大小。
PriorityQueue
關鍵操作的平均時間複雜度:增加O(log(n))
,查找O(n)
,刪除=查找+移除O(n) + O(log(n))
,到期任務檢索O(1)
。PS:就二叉堆
本身而言還有堆合併等操作,而且不同構造方式的時間複雜度也不相同,但不是這裏的關注重點。
// PriorityQueue
/**
* 隊列
*/
transient Object[] queue;
/**
* 隊列中元素的數量。同時用來記錄下一個元素的index。
*/
private int size = 0;
/**
* 排序使用的比較器,可以不設置,默認使用元素的自然排序
*/
private final Comparator<? super E> comparator;
public boolean offer(E e) {
// omit check
// 元素在數組中的index
int i = size;
// omit grow Capacity
size = i + 1;
if (i == 0)
queue[0] = e;
else
// 構造堆結構
siftUp(i, e);
return true;
}
private void siftUp(int k, E x) {
// 是自定義比較器
if (comparator != null)
siftUpUsingComparator(k, x);
else
siftUpComparable(k, x);
}
private void siftUpUsingComparator(int k, E x) {
while (k > 0) {
// 按照存儲規則找到自己的父節點
int parent = (k - 1) >>> 1;
Object e = queue[parent];
// 如果待插入節點不小於父節點,說明位置正確,不再繼續向上比較
// 最差循環次數爲堆的高度
if (comparator.compare(x, (E) e) >= 0)
break;
// 否則將父節點放在當前位置,繼續向上比較
queue[k] = e;
k = parent;
}
// 進入隊列
queue[k] = x;
}
/**
* 查找指定的節點
*/
private int indexOf(Object o) {
if (o != null) {
// 因爲實際存儲結構爲數組,所以此時需要進行遍歷
// 因此時間複雜度爲O(n)
for (int i = 0; i < size; i++)
if (o.equals(queue[i]))
return i;
}
return -1;
}
在JDK中使用ScheduledThreadPoolExecutor
同樣使用了最小堆
來規劃任務,但與PriorityQueue
不同,ScheduledThreadPoolExecutor
對任務元素進行了優化,讓任務本身持有自己在數組中的index,這樣將取消操作時間複雜度降到了O(1),但如果removeOnCancel
參數配置爲true時,取消操作會引起刪除操作,此時就增加了堆的維護。該配置默認爲false,這延遲了垃圾回收,會導致無謂的內存佔用,前提是任務存在極大可能在開始前被取消。
// ScheduledThreadPoolExecutor內部類
private class ScheduledFutureTask<V>
extends FutureTask<V> implements RunnableScheduledFuture<V> {
/** 延遲時間,單位:納秒 */
private long time;
/**
* 任務的重複時間,單位:納秒。
* 正數表明固定速率執行。
* 負數說明固定延時時間執行。
* 0說明該任務不需重複。
*/
private final long period;
/** 任務本身,可通過reExecutePeriodic方法重新入隊 */
RunnableScheduledFuture<V> outerTask = this;
/**
* 任務在延遲隊列中的index,用來支持快速取消任務。
*/
int heapIndex;
}
static class DelayedWorkQueue extends AbstractQueue<Runnable>
implements BlockingQueue<Runnable> {
// 數組初識長度
private static final int INITIAL_CAPACITY = 16;
// 任務數組
private RunnableScheduledFuture<?>[] queue =
new RunnableScheduledFuture<?>[INITIAL_CAPACITY];
private final ReentrantLock lock = new ReentrantLock();
// 隊列中元素的數量。同時用來記錄下一個元素的index。
private int size = 0;
/**
* leader是指等待在queue上的第一個線程,該線程等待時間爲第一個任務到期的時間,
* 其餘的等待線程無限期等待,直到leader把其喚醒。此處爲Leader-Follower模式,目的是減少不必要的等待時間。
* 更多釋義參見遠嗎註釋。
*/
private Thread leader = null;
/**
* 當隊列頭部任務變得可用或者一個新線程稱爲leader是會在該條件上發出信號。
*/
private final Condition available = lock.newCondition();
// 對數組的增加刪除操作同PriorityQueue相似
}
TimeWheel
以上兩種實現方式有一個共同點,他們並沒有按照預想將任務和延遲時間分離,接下來我們要討論的時間輪就是按照這個思路來實現任務的規劃,看一下時間輪如何來存儲和存儲/規劃任務。
網上盜圖來直觀感受下時間輪。
上圖展示的一個多層時間輪,紅線部分表示任務隨時間流逝遷移的過程,直到最後被執行。
時間輪是對時鐘的一個模擬,在時間輪中有以下幾點需要注意:
- Tick,一次時間推進,每次推進會檢查/執行超時任務。
- TickDuration,時間輪推進的最小單元,每隔TickDuration會有一次Tick,它決定了時間輪的精確程度。
- Bucket(TicksPerWheel),上圖中的每一隔就是一個Bucket,表示一個時間輪可以有多少個Tick,它是存儲任務的最小單元。
- 上層時間輪的TickDuration是下層時間輪的表示時間的最大範圍,即:父TickDuration = 子TickDuration * 子Bucket梳理
Netty和Kafka使用時間輪來管理超時任務,因爲具體場景不同實現也不同。
Netty
Netty的時間輪用來管理網絡連接的超時,實際應用過程中網絡超時時間一般不會很長,Netty採用單時間輪來規劃超時任務。
public class HashedWheelTimer implements Timer {
// 默認 100ms
private final long tickDuration;
// Bucket,使用數組實現,默認 512
private final HashedWheelBucket[] wheel;
// 待加入時間輪的任務
private final Queue<HashedWheelTimeout> timeouts = PlatformDependent.newMpscQueue();
// 被取消的任務
private final Queue<HashedWheelTimeout> cancelledTimeouts = PlatformDependent.newMpscQueue();
// 新增一個任務
public Timeout newTimeout(TimerTask task, long delay, TimeUnit unit) {
// 計算超時時間
long deadline = System.nanoTime() + unit.toNanos(delay) - startTime;
// 構建任務
HashedWheelTimeout timeout = new HashedWheelTimeout(this, task, deadline);
// 添加到 timeouts 鏈表
timeouts.add(timeout);
return timeout;
}
// Bucket用雙向鏈表組織數據
private static final class HashedWheelBucket {
// Used for the linked-list datastructure
private HashedWheelTimeout head;
private HashedWheelTimeout tail;
}
// 超時任務結構
private static final class HashedWheelTimeout implements Timeout {
// 任務本身
private final TimerTask task;
// 超時時間
private final long deadline;
// 剩餘輪詢次數
long remainingRounds;
// 被添加到的Bucket
HashedWheelBucket bucket;
}
}
上面是Netty時間輪主要的數據結構,從源碼中我們可以看出Netty爲提升效率廢了不少心思,比如:PlatformDependent.newMpscQueue()
來特定解決多生產單消費的場景,有興趣的可以看下,這不是本文重點。
前面我們說了,Netty是單時間輪,當規劃的任務時間超過一圈怎麼辦呢?我們看到超時任務HashedWheelTimeout
中有一個remainingRounds
字段,這個字段記錄了該任務在被遍歷多少次時可以被過期,任務每被遍歷一次該字段值減1,當其值不大於0時,表示可以被過期。
Netty使用數組 + 雙向鏈表
的方式來組織時間輪,對於添加/取消操作僅做了記錄,真正的操作實際發生在下一個Tick。我們來看下Netty版的時間輪對任務規劃的時間複雜度。
-
添加任務
通過
newTimeout
方法新增任務,Netty僅僅把任務放到timeouts
鏈表的隊尾,時間相對穩定,可看作O(1)。 -
取消任務
取消是
HashedWheelTimeout
任務本身提供的,在調用HashedWheelTimeout#cancel()
方法後,Netty僅僅修改了任務狀態並把任務放到了cancelledTimeouts
鏈表的隊尾,時間相對穩定,可看作O(1)。 -
刪除任務
void remove() { // 當前任務被規劃到的Bucket,如果不存在說明尚未被規劃,直接忽略 HashedWheelBucket bucket = this.bucket; if (bucket != null) { // 從bucket的鏈表中移除 bucket.remove(this); } }
外部不暴露該操作,發生在過期任務時,由
HashedWheelTimeout
任務本身提供,因爲任務本身有記錄當前的Bucket,所以刪除操作等於是鏈表的一個刪除操作,時間相對穩定,可看作O(1)。 -
查找任務
不存在該場景。
-
過期任務
// 時間推進線程 private final class Worker implements Runnable { private long tick; public void run() { do { // 獲取本次過期時間 final long deadline = waitForNextTick(); if (deadline > 0) { // 本次Bucket位置 int idx = (int) (tick & mask); // 將 cancelledTimeouts 中任務從Bucket中刪除 processCancelledTasks(); HashedWheelBucket bucket = wheel[idx]; // 將 timeouts 中的任務添加到對應的Bucket中 transferTimeoutsToBuckets(); // 遍歷當前Bucket,執行當前Bucket中的過期任務 bucket.expireTimeouts(deadline); // 記錄嘀嗒次數 tick++; } } while (WORKER_STATE_UPDATER.get(HashedWheelTimer.this) == WORKER_STATE_STARTED); } }
時間的推進是獨立的一個線程在做,該線程同時也負責過期任務的執行等操作,可簡單認爲此步驟操作爲O(n),因爲推進線程需要完全遍歷
timeouts
、cancelledTimeouts
與bucket
鏈表,在遍歷timeouts
時,Netty爲了避免任務過多,所以限制每次最多遍歷10萬個,也就是說,一個tick只能規劃10萬個任務,當任務量過大時,會存在超時任務執行時間延遲的現象。
我們上面有說到取消任務時,只是將任務放在了超時鏈表中,在下次發生時間推進時纔會真正將被取消的任務從隊列移除,這就導致空間的浪費,GC回收會至少延遲一個推進間隔(TickDuration)。
如今Netty在網絡通信中的霸主地位不必多言,雖然Netty中時間輪的實現不是最好的但一定是滿足了Netty這個特定場景的性能需要,即:沒有最好的技術,只有適合的技術。這一點對平時開發設計也有借鑑意義。
Kafka
Kafka也使用數組 + 雙向鏈表
的方式來組織時間輪(Timer.scala
),只是有多個數組來表示多層時間輪而已,所以其對任務的增加/刪除/取消操作實際也就是對雙向鏈表的增刪操作,時間複雜度與其一致,其到期任務的過程下面我們與Netty做比較來看。
上面關於Netty實現的一些比較蛇皮的地方,在Kafka中均得到了解決,並且Kafka中使用JDK中的DelayQueue
來幫助做時間推進,使得一個線程可以輕鬆管理錯層時間輪的時間推進。
我們先來看他規避了Netty中哪些不太合適的地方:
-
Netty單層時間輪規劃超過一輪的任務時使用
remainingRounds
來做控制,這就導致在一次推進中,當前Bucket下的任務並不是全部都是過期的,而Kafka使用多層時間輪,一個Bucket被某次推進選中,它下掛的任務行爲完全一致,處理起來相對簡單,Kafka目前也是由推進線程來遍歷到期任務,但它也可以方便的使用fork/join方式來進一步提高處理效率。 -
Kafka雖然也由推進線程遍歷到期Bucket下的任務,但任務的執行卻交給專門的線程池來執行,自己僅將該Bucket下的所有任務重新走一遍添加邏輯,以便決定任務是交給線程池執行還是下降到下層時間輪。
Kafka既然是多層時間輪,那他是如何來推進每個時間輪的時間呢?Kafka在時間推進層面跳過了時間輪這一層,直接規劃到時間輪下的Bucket,Kafka將所有Bucket放在DelayQueue
中來簡化時間推進的操作,這樣多個時間輪的推進任務只需要一個線程便可完成!
我們之前講DelayQueue
不太適合任務規劃,但Kafka時間輪中的Bucket數量並不會很多,在數量不多的情況下DelayQueue
性能還是不錯的(應該是滿足了Kafka的性能需求),所以此處選擇了DelayQueue
來實現時間的推進。還是應了這句話:沒有最好的技術,只有適合的技術。
Kafka的時間輪實現更契合我對超時中心的認知,超時中心只關心時間是否到期並進行回調通知,並不關心和執行任務的情況,Kafka明確了角色分工,所以在海量數據下會更高效。
擴展
文章拖的時間太久了,還有兩個與定時任務相關的開源項目沒來得及看,大家自行看下里面怎麼實現定時功能的吧。
總結
這篇文章前後花了一個月的休息時間來做調研和整理,雖然文中有貼出部分源代碼,但代碼作者的巧妙構思完全不能被表達,還是建議去看源碼。
正所謂:紙上得來終覺淺,絕知此事要躬行。
下面給出代碼版本:
-
Rocketmq release-4.5.0
-
Netty 4.1
-
Kafka 2.2
-
JDK 1.8.0_74
小生不才,以上如有描述有誤的地方還望各位不吝賜教 !_!
參考
如何讓快遞更"快"?菜鳥自研定時任務調度引擎首次公開
TimingWheel本質與DelayedOperationPurgatory核心結構
Kafka技術內幕樣章 層級時間輪
Kafka解惑之時間輪(TimingWheel)
你真的瞭解延遲隊列嗎
定時器(Timer)的實現
二叉堆動畫展示
二叉樹