思考延時隊列

更多請移步我的博客

背景

項目中存在以下場景需要延遲觸發一些事件:

  1. 訂單在未支付狀態下30分鐘後自動關閉;
  2. 訂單超過15天未主動確認收貨需要自動確認收貨;
  3. 商品價格需要在不同的時間段生效不同的價格方案等。

以上場景下需要有一個相對平臺化的服務來滿足,而不必每個項目自己做定時任務去進行輪詢。

解刨延遲/定時任務

構成一個任務有兩個要素:執行時間;執行邏輯。對任務規劃者而言,並不關心任務執行邏輯,規劃者只要在既定的時間觸發該任務,但既然作爲一個規劃者,就必須具備任務的基本維護能力:新增,刪除/取消,到期,查找。

那麼一個想要實現規劃者就必須考慮兩件事: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啓動時,會啓動相對應隊列的線程來處理各個延遲隊列的延遲消息。

盜用艾瑞克一次分享中的圖來直觀感受下延遲隊列的實現。
rmqDelayQueue.png

Rocketmq處理通過消息體的擴展字段DELAY來區分Producer是否投遞的是延遲消息,如果DELAY大於0,即確定是延遲消息,Broker會備份源消息的topic和queueId,並將其替換爲對應延遲隊列的信息,然後將修改後的消息落盤到commitLog,DefaultMessageStore#ReputMessageServiceReput線程將消息分發至對應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落盤時間加上延遲級別對應的時間,落盤後的消息纔會被ReputMessageServiceReput線程進行分發到指定的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中帶有定時/延遲特性的兩個類:DelayQueueScheduledThreadPoolExecutor。這兩個類名起的很好,見名知意。我們從這兩個類的數據結構入手看下JDK這兩個自帶類如何實現任務的規劃。

DelayQueue內部使用PriorityQueue來存儲定時/延時任務,相當於是PriorityQueue的一種使用場景,主要特性是當隊列爲空或任務時間未到期時可讓拉取線程進行等待。

PriorityQueue使用二叉堆來存儲數據,這裏不去深究二叉堆的定義,其特性是根節點一定是整個堆中最大/最小的點,這點是比較符合優先隊列的特性,PriorityQueue的根節點queue[0]是最小的節點,稱爲最小堆。PS:二叉堆完全二叉樹或者是近似完全二叉樹。

完全二叉樹 滿二叉樹
總節點k 2h1&lt;=k&lt;=2h12^{h-1}&lt;= k &lt;= 2^{h}-1 2h12^{h}-1
樹高h h=log2k+1h=log_{2}k+1 h=log2(k+1)h=log_{2}(k+1)

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

以上兩種實現方式有一個共同點,他們並沒有按照預想將任務和延遲時間分離,接下來我們要討論的時間輪就是按照這個思路來實現任務的規劃,看一下時間輪如何來存儲和存儲/規劃任務。

網上盜圖來直觀感受下時間輪。
timerWheel.png

上圖展示的一個多層時間輪,紅線部分表示任務隨時間流逝遷移的過程,直到最後被執行。

時間輪是對時鐘的一個模擬,在時間輪中有以下幾點需要注意:

  1. Tick,一次時間推進,每次推進會檢查/執行超時任務。
  2. TickDuration,時間輪推進的最小單元,每隔TickDuration會有一次Tick,它決定了時間輪的精確程度。
  3. Bucket(TicksPerWheel),上圖中的每一隔就是一個Bucket,表示一個時間輪可以有多少個Tick,它是存儲任務的最小單元。
  4. 上層時間輪的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),因爲推進線程需要完全遍歷timeoutscancelledTimeoutsbucket鏈表,在遍歷timeouts時,Netty爲了避免任務過多,所以限制每次最多遍歷10萬個,也就是說,一個tick只能規劃10萬個任務,當任務量過大時,會存在超時任務執行時間延遲的現象。

我們上面有說到取消任務時,只是將任務放在了超時鏈表中,在下次發生時間推進時纔會真正將被取消的任務從隊列移除,這就導致空間的浪費,GC回收會至少延遲一個推進間隔(TickDuration)。

如今Netty在網絡通信中的霸主地位不必多言,雖然Netty中時間輪的實現不是最好的但一定是滿足了Netty這個特定場景的性能需要,即:沒有最好的技術,只有適合的技術。這一點對平時開發設計也有借鑑意義。

Kafka

Kafka也使用數組 + 雙向鏈表的方式來組織時間輪(Timer.scala),只是有多個數組來表示多層時間輪而已,所以其對任務的增加/刪除/取消操作實際也就是對雙向鏈表的增刪操作,時間複雜度與其一致,其到期任務的過程下面我們與Netty做比較來看。

上面關於Netty實現的一些比較蛇皮的地方,在Kafka中均得到了解決,並且Kafka中使用JDK中的DelayQueue來幫助做時間推進,使得一個線程可以輕鬆管理錯層時間輪的時間推進。

我們先來看他規避了Netty中哪些不太合適的地方:

  1. Netty單層時間輪規劃超過一輪的任務時使用remainingRounds來做控制,這就導致在一次推進中,當前Bucket下的任務並不是全部都是過期的,而Kafka使用多層時間輪,一個Bucket被某次推進選中,它下掛的任務行爲完全一致,處理起來相對簡單,Kafka目前也是由推進線程來遍歷到期任務,但它也可以方便的使用fork/join方式來進一步提高處理效率。

  2. 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)的實現
二叉堆動畫展示
二叉樹

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章