你可見過如此細緻的延時任務詳解

騎牛上青山發表於2022-11-23

概述

延時任務相信大家都不陌生,在現實的業務中應用場景可以說是比比皆是。例如訂單下單15分鐘未支付直接取消,外賣超時自動賠付等等。這些情況下,我們該怎麼設計我們的服務的實現呢?

笨一點的方法自然是定時任務去資料庫進行輪詢。但是當業務量較大,事件處理比較費時的時候,我們的系統和資料庫往往會面臨巨大的壓力,如果採用這種方式或許會導致資料庫和系統的崩潰。那麼有什麼好辦法嗎?今天我來為大家介紹幾種實現延時任務的辦法。

JAVA DelayQueue

你沒看錯,java內部有內建延時佇列,位於java concurrent包內。

DelayQueue是一個jdk中自帶的延時佇列實現,他的實現依賴於可重入鎖ReentrantLock以及條件鎖Condition和優先佇列PriorityQueue。而且本質上他也是一個阻塞佇列。那麼他是如何實現延時效果的呢。

DelayQueue的實現原理

首先DelayQueue佇列中的元素必須繼承一個介面叫做Delayed,我們找到這個類

    public interface Delayed extends Comparable<Delayed> {
        long getDelay(TimeUnit unit);
    }

發現這個類內部定義了一個返回值為long的方法getDelay,這個方法用來定義佇列中的元素的過期時間,所有需要放在佇列中的元素,必須實現這個方法。

然後我們來看看延遲佇列的佇列是如何操作的,我們就拿最典型的offertake來看:

    public boolean offer(E e) {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            q.offer(e);
            if (q.peek() == e) {
                leader = null;
                available.signal();
            }
            return true;
        } finally {
            lock.unlock();
        }
    }

offer操作平平無奇,甚至直接呼叫到了優先佇列的offer來將佇列根據延時進行排序,只不過加了個鎖,做了些資料的調整,沒有什麼深入的地方,但是take的實現看上去就很複雜了。(注意,Dalayed繼承了Comparable方法,所以是可以直接用優先佇列來排序的,只要你自己實現了compareTo方法)我嘗試加了些註釋讓各位看得更明白些:

    public E take() throws InterruptedException {
        final ReentrantLock lock = this.lock;
        lock.lockInterruptibly();
        try {
            // 自選操作
            for (;;) {
                // 獲取佇列第一個元素,如果佇列為空
                // 阻塞住直到有新元素加入佇列,offer等方法呼叫signal喚醒執行緒
                E first = q.peek();
                if (first == null)
                    available.await();
                else {
                    // 如果佇列中有元素
                    long delay = first.getDelay(NANOSECONDS);
                    // 判斷延時時間,如果到時間了,直接取出資料並return
                    if (delay <= 0)
                        return q.poll();
                    first = null;
                    // 如果leader為空則阻塞
                    if (leader != null)
                        available.await();
                    else {
                        // 獲取當前執行緒
                        Thread thisThread = Thread.currentThread();
                        // 設定leader為當前執行緒
                        leader = thisThread;
                        try {
                            // 阻塞延時時間
                            available.awaitNanos(delay);
                        } finally {
                            if (leader == thisThread)
                                leader = null;
                        }
                    }
                }
            }
        } finally {
            if (leader == null && q.peek() != null)
                available.signal();
            lock.unlock();
        }
    }

我們可以看到take的實現依靠了無限自旋,直到第一個佇列元素過了超時時間後才會返回,否則等待他的只有被阻塞。

DelayQueue實現延時佇列的優缺點

看了原始碼後,我們應該對DelayQueue的實現有了一個大致的瞭解,也對他的優缺點有了一定的理解。他的優點很明顯:

  1. java原生支援,不需要引入第三方工具
  2. 執行緒安全,即插即用使用方便

但是他的缺點也是很明顯的:

  1. 不支援分散式,並且資料放在記憶體中,沒有持久化的支援,服務當機會丟失資料
  2. 插入時使用的是優先佇列的排序,時間複雜度較高,並且對於佇列中的任務不能很好的管理

所以有沒有更好的延時佇列的實現呢,我們繼續看下去~

時間輪演算法

時間輪演算法是一個被設計出來處理延時任務的演算法,現實中的應用可以在kafka以及netty等專案中找到類似的實現。

時間輪的具體實現

所謂時間輪,顧名思義,他是一個類似於時鐘的結構,即他的主結構是一個環形陣列,如圖:

環形陣列中存放的是一個一個的連結串列,連結串列中存放著需要執行的任務,我們設定好陣列中執行的間隔,假設我們的環形陣列的長度是60,每個陣列的執行間隔為1s,那麼我們會在每過1s就會執行陣列下一個元素中的連結串列中的元素。如果只是這樣,那麼我們將無法處理60秒之外的延時任務,這顯然不合適,所以我們會在每個任務中加上一個引數圈數,來表明任務會在幾圈後執行。假如我們有一個任務是在150s後執行,那麼他應該在30s的位置,同時圈數應該為2。我們每次執行一個連結串列中的任務的時候會把當圈需要執行的任務取出執行,然後把他從連結串列中刪除,如果任務不是當圈執行,則修改他的圈數,將圈數減1,於是一個簡單的時間輪出爐了。

那麼這樣的時間輪有什麼優缺點呢?

先來說優點吧:

  1. 相比DelayQueue來說,時間輪的插入更加的高效,時間複雜度為O(1)
  2. 實現簡單清晰,任務排程更加方便合理

當然他的缺點也不少:

  1. 他和DelayQueue一樣不支援分散式,並且資料放在記憶體中,沒有持久化的支援,服務當機會丟失資料
  2. 陣列間的間隔設定會影響任務的精度
  3. 由於不同圈數的任務會在同一個連結串列中,執行到每個陣列元素時需要遍歷所有的連結串列資料,效率會很低

進階最佳化版時間輪演算法

剛才提到了一些時間輪演算法的缺點,那麼是不是有一些方法來進行下最佳化?這裡我來介紹一下時間輪的最佳化版本。

之前我們提到不同圈數的任務會在同一個連結串列中被重複遍歷影響效率,這種情況下我們可以進行如下最佳化:將時間輪進行分層

我們可以看到圖中,我們採用了多層級的設計,上圖中分了三層,每層都是60格,第一個輪盤中的間隔為1小時,我們的資料每一次都是插入到這個輪盤中,每當這個輪盤經過一個小時後來到下一個刻度,就會取出其中的所有元素,按照延遲時間放入到第二個象徵著分鐘的輪盤中,以此類推。

這樣的實現好處可以說是顯而易見的:

  1. 首先避免了當時間跨度較大時空間的浪費
  2. 每一次到達刻度的時候我們不用再像以前那樣遍歷連結串列取出需要的資料,而是可以一次性全部拿出來,大大節約了操作的時間

時間輪演算法的應用

時間輪演算法可能在之前大家沒有聽說過,但是他在各個地方都有著不小的作用。linux的定時器的實現中就有時間輪的身影,同樣如果你是一個喜好看原始碼的讀者,你也可能會在kafka以及netty中找到他的實現。

kafka

kafka中應用了時間輪演算法,他的實現和之前提到的進階版時間輪沒有太大的區別,只有在一點上:kafka內部實現的時間輪應用到了DelayQueue

    @nonthreadsafe
    private[timer] class TimingWheel(tickMs: Long, wheelSize: Int, startMs: Long, taskCounter: AtomicInteger, queue: DelayQueue[TimerTaskList]) {

    private[this] val interval = tickMs * wheelSize
    private[this] val buckets = Array.tabulate[TimerTaskList](wheelSize) { _ => new TimerTaskList(taskCounter) }

    private[this] var currentTime = startMs - (startMs % tickMs)
    
    @volatile private[this] var overflowWheel: TimingWheel = null

    private[this] def addOverflowWheel(): Unit = {
        synchronized {
        if (overflowWheel == null) {
            overflowWheel = new TimingWheel(
            tickMs = interval,
            wheelSize = wheelSize,
            startMs = currentTime,
            taskCounter = taskCounter,
            queue
            )
        }
        }
    }

    def add(timerTaskEntry: TimerTaskEntry): Boolean = {
        val expiration = timerTaskEntry.expirationMs

        if (timerTaskEntry.cancelled) {
        false
        } else if (expiration < currentTime + tickMs) {
        false
        } else if (expiration < currentTime + interval) {
        val virtualId = expiration / tickMs
        val bucket = buckets((virtualId % wheelSize.toLong).toInt)
        bucket.add(timerTaskEntry)

        if (bucket.setExpiration(virtualId * tickMs)) {
            queue.offer(bucket)
        }
        true
        } else {
        if (overflowWheel == null) addOverflowWheel()
        overflowWheel.add(timerTaskEntry)
        }
    }

    def advanceClock(timeMs: Long): Unit = {
        if (timeMs >= currentTime + tickMs) {
        currentTime = timeMs - (timeMs % tickMs)

        if (overflowWheel != null) overflowWheel.advanceClock(currentTime)
        }
    }
    }

上面是kafka內部的實現(使用的語言是scala),我們可以看到實現非常的簡潔,並且使用到了DelayQueue。我們剛才已經討論過了DelayQueue的優缺點,檢視原始碼後我們已經可以有一個大致的結論了:DelayQueue在kafka的時間輪中的作用是負責推進任務的,為的就是防止在時間輪中由於任務比較稀疏而造成的"空推進"。DelayQueue的觸發機制可以很好的避免這一點,同時由於DelayQueue的插入效率較低,所以僅用於底層的推進,任務的插入由時間輪來操作,兩者配置,可以實現效率和資源的平衡。

netty

netty的內部也有時間輪的實現HashedWheelTimer

HashedWheelTimer的實現要比kafka內部的實現複雜許多,和kafka不同的是,它的內部推進不是依靠的DelayQueue而是自己實現了一套,原始碼太長,有興趣的讀者可以自己去看一下。

小結

時間輪說了這麼多,我們可以看到他的效率是很出眾的,但是還是有這麼一個問題:他不支援分散式。當我們的業務很複雜,需要分散式的時候,時間輪顯得力不從心,那麼這個時候有什麼好一點的延時佇列的選擇呢?我們或許可以嘗試使用第三方的工具

redis延時佇列

其實啊說起延時,我們如果常用redis的話,就會想起redis是存在過期機制的,那麼我們是否可以利用這個機制來實現一個延時佇列呢?

redis自帶key的過期機制,而且可以設定過期後的回撥方法。基於此特性,我們可以非常容易就完成一個延時佇列,任務進來時,設定定時時間,並且配置好過期回撥方法即可。

除了使用redis的過期機制之外,我們也可以利用它自帶的zset來實現延時佇列。zset支援高效能的排序,因此我們任務進來時可以將時間戳作為排序的依據,以此將任務的執行先後進行有序的排列,這樣也能實現延時佇列。

zset實現延時佇列的好處:

  1. 支援高效能排序
  2. redis本身的高可用和高效能以及永續性

mq延時佇列

rocketmq延時訊息

rocketmq天然支援延時訊息,他的延時訊息分為18個等級,每個等級對應不同的延時時間。

那麼他的原理是怎樣的呢?

rocketmqbroker收到訊息後會將訊息寫入commitlog,並且判斷這個訊息是否是延時訊息(即delay屬性是否大於0),之後如果判斷確實是延時訊息,那麼他不會馬上寫入,而是透過轉發的方式將訊息放入對應的延時topic(18個延時級別對應18個topic

rocketmq會有一個定時任務進行輪詢,如果任務的延遲時間已經到了就發往指定的topic

這個設計比較的簡單粗暴,但是缺點也十分明顯:

  1. 延時是固定的,如果想要的延遲超出18個級別就沒辦法實現
  2. 無法實現精準延時,佇列的堆積等等情況也會導致執行產生誤差

rocketmq的精準延時訊息

rocketmq本身是不支援的精確延遲的,他的商業版本ons倒是支援。不過rocketmq的社群中有相應的解決方案。方案是藉助於時間輪演算法來實現的,感興趣的朋友可以自行去社群檢視。(社群中的一些未被合併的pr是不錯的實現參考)

總結

延時佇列的實現千千萬,但是如果要在生產中大規模使用,那麼大部分情況下其實都避不開時間輪演算法。改進過的時間輪演算法可以做到精準延時,持久化,高效能,高可用性,可謂是完美。但是話又說回來,其他的延時方式就無用了嗎?其實不是的,所有的方式都是需要匹配自己的使用場景。如果你是極少量資料的輪詢,那麼定時輪詢資料庫或許才是最佳的解決方案,而不是無腦的引入複雜的延時佇列。如果是單機的任務,那麼jdk的延時佇列也是不錯的選擇。

本文介紹的這些延時佇列只是為了向大家展示他們的原理和優缺點,具體的使用還需要結合自己業務的場景。

相關文章