【Netty】【XXL-JOB】時間輪的原理以及應用分析

酷酷-發表於2024-05-03

1 前言

今天晚上看了一本 70 多頁的講解時間輪的 PDF,從是什麼為什麼以及原理到原始碼中的應用分析,講的真好。這節我就按我理解的思路捋一下,記錄一下哈。

2 時間輪概述

2.1 時間輪是什麼

時間輪是一種高效利用執行緒資源進行批次化排程的一種排程模型。把大批次的排程任務全部繫結到同一個排程器上,使用這一個排程器來進行所有任務的管理、觸發、以及執行。時間輪其實就是一種環形的資料結構,其設計參考了時鐘轉動的思維,可以想象成時鐘,分成很多格子,一個格子代表一段時間。我們這裡的時間輪就是由多個時間格組成,比如下圖中有8個時間格,每個時間格代表當前時間輪的基本時間跨度 (tickDuration),其中時間輪的時間格的個數是固定的。

圖中,有8個時間格(槽),假設每個時間格的單位為100ms,那麼整個時間輪走完一圈需要800ms。每100ms指標會沿著順時針方向移動一個時間單位,這個單位可以代表時間精度,這個單位可以設定,比如以秒為單位,也可以以一小時為單位。

而對於每個時間格里存放的是什麼呢?放的就是當前時間格要觸發的任務列表,透過指標移動,來獲得每個時間格中的任務列表,然後遍歷任務列表來執行每個任務,以此迴圈。

那我們大概能看到時間輪中涉及的幾個變數:

(1)格子數,也就是一圈有多少個時間格

(2)格子的耗時,也就是每個時間格代表多少時長,比如1小時1分鐘1秒等

(3)輪數,也就是某個任務是第幾輪才觸發的,比如一輪有60個格子,每個格子表示1分鐘,那麼1輪就是1小時,放置一個1小時10分鐘後觸發的任務,那麼它的輪數就是1

對於輪數,不一定要有哈,比如一些任務可能很久才要執行,那麼輪數會變的非常大的一個數字,也會在任務列表中插入很多當前不需要執行的任務,如果每次都執行上面的邏輯,顯然會浪費大量的資源,可以利用時間輪的多層來化解。

涉及到的資料結構:比如一輪中的每個時間格用什麼來存放,每個時間格中的任務用什麼資料結構來存放呢,我們後續會在原始碼分析中提到哈。

2.2 時間輪的特點

時間輪是一個高效能,低消耗的資料結構,它適合用非準實時,延遲的短平快任務,例如心跳檢測。

比如Netty動輒管理100w+的連線,每一個連線都會有很多超時任務。 比如傳送超時、心跳檢測間隔等,如果每一個定時任務都啟動一個Timer,不僅低效,而且會消耗大量 的資源。

在Netty中的一個典型應用場景是判斷某個連線是否idle,如果idle(如客戶端由於網路原因導致到服 務器的心跳無法送達),則伺服器會主動斷開連線,釋放資源。 得益於Netty NIO的優異效能,基於Netty開發的伺服器可以維持大量的長連線,單臺8核16G的雲主機 可以同時維持幾十萬長連線,及時掐掉不活躍的連線就顯得尤其重要。

2.3 時間輪的場景

然後我們再看下,為什麼要有時間輪或者它的場景是什麼呢?

時間輪的模型能夠高效管理各種任務: 延時任務、 週期任務、 通知任務。

比如一個大型內容稽核平時,在運營設定稽核了內容的透過的時間,到了這個時間之後,相關內容自動稽核 透過。本是個小的需求,但是考慮到如果需要定時稽核的東西很多,這樣大量的定時任務帶來的一系列問題,海量定時任務管理的場景非常多,在實際專案中,存在大量需要定時或是延時觸發的任務,比如電商中,延時需要檢查訂單是否支付成功,是否配送成功,定時給使用者推送提醒等等。

(1) 單定時器方案

描述: 把所有需要定時稽核的資源放到redis中,例如sorted set中,需要稽核透過的時間作為score值。 後臺啟動一個定時器,定時輪詢sortedSet,當score值小於當前時間,則執行任務稽核透過。

問題 這個方案在小批次資料的情況下沒有問題, 但是在大批次任務的情況下就會出現問題了,因為每次都要輪詢全量的資料,逐個判斷是否需要執行, 一旦輪詢任務執行比較長,就會出現任務無法按照定時的時間執行的問題。

(2) 多定時器方案

描述:每個需要定時完成的任務都啟動一個定時任務,然後等待完成之後銷燬

問題:這個方案帶來的問題很明顯,定時任務比較多的情況下,會啟動很多的執行緒,這樣伺服器會承受不了之 後崩潰。 基本上不會採取這個方案。

(3)redis的過期通知功能

描述:和方案一類似,針對每一個需要定時稽核的任務,設定過期時間,過期時間也就是稽核透過的時間,訂閱redis的過期事件,當這個事件發生時,執行相應的稽核透過任務。

問題:這個方案來說是借用了redis這種中介軟體來實現我們的功能,這中實際上屬於redis的釋出訂閱功能中的 一部分,針對redis釋出訂閱功能是不推薦我們在生產環境中做業務操作的,通常redis內部(例如redis叢集節點上下線,選舉等等來使用),我們業務系統使用它的這個事件會產 生如下兩個問題一個是redis釋出訂閱的不穩定問題,另一個是redid釋出訂閱的可靠性問題,具體可以參考redis的釋出訂閱缺陷

(4)Hash分層記時輪(分層時間輪)演算法

這個東西就是專為大批次定時任務管理而生。比如要支援觸發時間是一年的精度為秒級別的時間輪,如果單純的用一個秒級的時間輪:365*24*60*60 這都三千多萬個時間格了,造成大量資源開銷。而分層的話,那麼可分為四個層次:天級別的時間輪,小時級時間輪,分鐘級時間輪,秒級時間輪,他們的時間格數分別為:365,24,60,60;總時間格數只有365+24+60+60 = 509個!

(5)MQ的延時訊息

當然 MQ的延時訊息也可以實現,但是你要知道比如你傳送一個延時訊息到MQ,但是當你想取消的時候,就沒辦法刪除佇列裡的訊息了,只能透過增加某個取消標誌,當延時訊息執行的時候,判斷一下取消標誌,再決定是否進行後續的操作。

時間輪的本質是一種類似延遲任務佇列的實現, 那麼它的特點如上所述,適用於對時效性不高的,可快速執行的,大量這樣的“小”任務,能夠做到高性 能,低消耗。

應用場景大致有:心跳檢測(客戶端探活)、會話或者請求是否超時、訊息延遲推送、業務場景超時取消(訂單、退款單等)

時間輪的思想應用範圍非常廣泛,各種作業系統的定時任務排程,Crontab,還有基於java的通訊框架 Netty中也有時間輪的實現, 幾乎所有的時間任務排程系統採用的都是時間輪的思想。 至於採用round型的基礎時間輪還是採用分層時間輪,看實際需要吧,時間複雜度和實現複雜度的取捨。

3 原始碼應用

接下來我們就從原始碼的角度看看如何使用。

3.1 Netty 中的時間輪

Netty 的時間輪主要是在類 HashedWheelTimer 中,我們這裡就從它的屬性和幾個關鍵方法看起。

3.1.1 HashedWheelTimer 屬性

// 真正執行工作的執行緒
private final Worker worker = new Worker();
private final Thread workerThread;
// 工作執行緒的狀態
public static final int WORKER_STATE_INIT = 0;
public static final int WORKER_STATE_STARTED = 1;
public static final int WORKER_STATE_SHUTDOWN = 2;
// 每個時間格表示的時長
private final long tickDuration;
// 有多少個格子
private final HashedWheelBucket[] wheel;
// 與運算用於計算某個任務應該存放在哪個格子
private final int mask;
// 最多允許多少個等待任務
private final long maxPendingTimeouts;
// 時間輪的啟動時間單位是納秒
private volatile long startTime;
// 啟動控制 防止多次啟動
private final CountDownLatch startTimeInitialized = new CountDownLatch(1);
// 存放提交的任務比如往時間輪中提交一個任務會先放置在該佇列中
private final Queue<HashedWheelTimeout> timeouts = PlatformDependent.newMpscQueue();
// 已經取消的任務
private final Queue<HashedWheelTimeout> cancelledTimeouts = PlatformDependent.newMpscQueue();
// 等待執行的任務數的計數器
private final AtomicLong pendingTimeouts = new AtomicLong(0);

我們從一個圖大概先了解一下執行過程,先有個全域性的認識,然後我們再細看每個方法:

(1)我們例項化好時間輪後,會透過 newTimeout 方法,新增任務到時間輪,這個時候他還不會進入到時間輪,會先進入到 timeouts佇列中

(2)當工作執行緒執行的時候,會先從 timeouts 佇列中撈任務,然後計算應該存放在哪個時間槽中

(3)根據計算的槽位,然後將任務放進該槽的連結串列中

(4)然後取出當前時刻的時間槽中的任務,依次執行。

3.1.2 HashedWheelTimer 例項化

它的例項化方法有多個:

// 空參的例項化
public HashedWheelTimer() {
    this(Executors.defaultThreadFactory());
}
// 帶執行緒工廠的 預設每個時間槽是100毫秒
public HashedWheelTimer(ThreadFactory threadFactory) {
    this(threadFactory, 100, TimeUnit.MILLISECONDS);
}
// 預設一輪有512個時間槽
public HashedWheelTimer(
        ThreadFactory threadFactory, long tickDuration, TimeUnit unit) {
    this(threadFactory, tickDuration, unit, 512);
}
// 預設開啟記憶體洩漏檢查
public HashedWheelTimer(
        ThreadFactory threadFactory,
        long tickDuration, TimeUnit unit, int ticksPerWheel) {
    this(threadFactory, tickDuration, unit, ticksPerWheel, true);
}
// 預設不限制等待任務數
public HashedWheelTimer(
    ThreadFactory threadFactory,
    long tickDuration, TimeUnit unit, int ticksPerWheel, boolean leakDetection) {
    this(threadFactory, tickDuration, unit, ticksPerWheel, leakDetection, -1);
}
// 最後的落點 都會走到這個例項化
public HashedWheelTimer(
        ThreadFactory threadFactory,
        long tickDuration, TimeUnit unit, int ticksPerWheel, boolean leakDetection,
        long maxPendingTimeouts) {
    if (threadFactory == null) {
        throw new NullPointerException("threadFactory");
    }
    if (unit == null) {
        throw new NullPointerException("unit");
    }
    if (tickDuration <= 0) {
        throw new IllegalArgumentException("tickDuration must be greater than 0: " + tickDuration);
    }
    if (ticksPerWheel <= 0) {
        throw new IllegalArgumentException("ticksPerWheel must be greater than 0: " + ticksPerWheel);
    }
    // 先把時間格陣列建立出來,所以你時間格越多資源申請的也越多。Normalize ticksPerWheel to power of two and initialize the wheel.
    wheel = createWheel(ticksPerWheel);
    // 這裡就是 與運算 方便計算任務所在的時間格子
    mask = wheel.length - 1;
    // 時間都轉為納秒 Convert tickDuration to nanos.
    this.tickDuration = unit.toNanos(tickDuration);
    // 檢驗引數的合法性 Prevent overflow.
    if (this.tickDuration >= Long.MAX_VALUE / wheel.length) {
        throw new IllegalArgumentException(String.format(
                "tickDuration: %d (expected: 0 < tickDuration in nanos < %d",
                tickDuration, Long.MAX_VALUE / wheel.length));
    }
    // 初始化工作執行緒
    workerThread = threadFactory.newThread(worker);
    // 記憶體洩漏檢查的執行緒
    leak = leakDetection || !workerThread.isDaemon() ? leakDetector.track(this) : null;
    // 最大等待的任務數 預設-1不限制
    this.maxPendingTimeouts = maxPendingTimeouts;
    // 判斷時間輪的例項化個數 64個 也就是不能建立過多的時間輪 
    if (INSTANCE_COUNTER.incrementAndGet() > INSTANCE_COUNT_LIMIT &&
        WARNED_TOO_MANY_INSTANCES.compareAndSet(false, true)) {
        reportTooManyInstances();
    }
}

3.1.3 HashedWheelTimer 啟動

它的啟動有兩個入口:

(1)直接呼叫 HashedWheelTimer 的 start 方法

(2)newTimeout 也就是新增任務的時候,會呼叫 start 方法啟動時間輪

那我們這裡直接看它的 start 方法:

public void start() {
    // 例項化後的預設的狀態是0 表示初始化
    switch (WORKER_STATE_UPDATER.get(this)) {
        // 如果是初始化,則透過 CAS 啟動工作現場
        case WORKER_STATE_INIT:
            if (WORKER_STATE_UPDATER.compareAndSet(this, WORKER_STATE_INIT, WORKER_STATE_STARTED)) {
                workerThread.start();
            }
            break;
        // 如果已經啟動 直接跳出
        case WORKER_STATE_STARTED:
            break;
        // 如果已經停止了,則拋個異常
        case WORKER_STATE_SHUTDOWN:
            throw new IllegalStateException("cannot be started once stopped");
        default:
            throw new Error("Invalid WorkerState");
    }
    // 當工作現場啟動的時候,會設定 startTime 這裡是保證工作執行緒絕對啟動吧 Wait until the startTime is initialized by the worker.
    while (startTime == 0) {
        try {
            startTimeInitialized.await();
        } catch (InterruptedException ignore) {
            // Ignore - it will be ready very soon.
        }
    }
}

3.1.4 newTimeout 新增任務

public Timeout newTimeout(TimerTask task, long delay, TimeUnit unit) {
    if (task == null) {
        throw new NullPointerException("task");
    }
    if (unit == null) {
        throw new NullPointerException("unit");
    }
    // 統計任務個數
    long pendingTimeoutsCount = pendingTimeouts.incrementAndGet();
    // 判斷最大任務數量是否超過限制
    if (maxPendingTimeouts > 0 && pendingTimeoutsCount > maxPendingTimeouts) {
        pendingTimeouts.decrementAndGet();
        throw new RejectedExecutionException("Number of pending timeouts ("
            + pendingTimeoutsCount + ") is greater than or equal to maximum allowed pending "
            + "timeouts (" + maxPendingTimeouts + ")");
    }
    // 如果時間輪沒有啟動,則透過start方法進行啟動
    start();
    // Add the timeout to the timeout queue which will be processed on the next tick.
    // During processing all the queued HashedWheelTimeouts will be added to the correct HashedWheelBucket.
    // 計算任務的延遲時間,透過當前的時間+當前任務執行的延遲時間-時間輪啟動的時間 也就是在多少納秒值的時候要啟動
    long deadline = System.nanoTime() + unit.toNanos(delay) - startTime;
    // 如果為負數,那麼說明超過了long的最大值 Guard against overflow.
    if (delay > 0 && deadline < 0) {
        deadline = Long.MAX_VALUE;
    }
    // 建立一個Timeout任務,理 論上來說,這個任務應該要加入到時間輪的時間格子中,但是這裡並不是先新增到時間格,而是先   
    // 加入到一個阻塞佇列,然後等到時間輪執行到下一個格子時,再從佇列中取出最多100000個任務新增到指定的 時間格(槽)中。
    HashedWheelTimeout timeout = new HashedWheelTimeout(this, task, deadline);
    // 加到佇列中
    timeouts.add(timeout);
    return timeout;
}

3.1.5 Worker 執行任務

Worker 類是 HashedWheelTimer 的內部類,我們看看它的執行過程:

private final class Worker implements Runnable {
    // 工作執行緒停止了,還沒有執行的任務
    private final Set<Timeout> unprocessedTimeouts = new HashSet<Timeout>();
    // 當前到幾個時間格了
    private long tick;
    @Override
    public void run() {
        // 當前的納秒值 Initialize the startTime.
        startTime = System.nanoTime();
        // 這個還真不知道是幹啥的 什麼時候能等於 0 呢?
        if (startTime == 0) {
            // We use 0 as an indicator for the uninitialized value here, so make sure it's not 0 when initialized.
            startTime = 1;
        }
        // 工作執行緒啟動了,其他執行緒可以不用等著了 喚醒被阻塞的start()方法 Notify the other threads waiting for the initialization at start().
        startTimeInitialized.countDown();
        do {
            // 返回每tick一次的時間間隔 也就是當前要執行的時間格的納秒值 它是一個差值 也就是距離 startTime的差值 而我們新增任務的時候也是計算的每個任務距離 startTime 的差值
            // 那也就是這裡的 deadLine 大於等於任務的 deadLine 的時候,這個任務就應該執行

            final long deadline = waitForNextTick();
            if (deadline > 0) {
                // 計算並獲取時間格
                int idx = (int) (tick & mask);
                processCancelledTasks();
                HashedWheelBucket bucket =
                        wheel[idx];
                // 從等待佇列裡撈任務
                transferTimeoutsToBuckets();
                // 執行任務
                bucket.expireTimeouts(deadline);
                // 下一個時間格++
                tick++;
            }
        } while (WORKER_STATE_UPDATER.get(HashedWheelTimer.this) == WORKER_STATE_STARTED);
        // 清空每個時間格 Fill the unprocessedTimeouts so we can return them from stop() method.
        for (HashedWheelBucket bucket: wheel) {
            bucket.clearTimeouts(unprocessedTimeouts);
        }
        // 取出等待佇列中還沒來得及執行的任務 放到未執行的集合中
        for (;;) {
            HashedWheelTimeout timeout = timeouts.poll();
            if (timeout == null) {
                break;
            }
            if (!timeout.isCancelled()) {
                unprocessedTimeouts.add(timeout);
            }
        }
        // 處理被取消的任務
        processCancelledTasks();
    }
}

3.1.5.1 waitForNextTick 指標跳動

這個方法的主要作用就是返回下一個指標指向的時間間隔,然後進行sleep操作。

大家可以想象一下,一個鐘錶上秒與秒之間是有時間間隔的,那麼waitForNextTick就是根據當前時間 計算出跳動到下個時間的時間間隔,然後進行sleep,然後再返回當前時間距離時間輪啟動時間的時間間隔(時間差)。

private long waitForNextTick() {
    // tick表示到了第幾個時間格 tickDuration表示每個時間格的跨度,所以deadline返回的是下一次時間輪指標跳動的時間
    long deadline = tickDuration * (tick + 1);
    for (;;) {
        // 計算當前時間距離啟動時間的時間間隔
        final long currentTime = System.nanoTime() - startTime;
        // 透過下一次指標跳動的延遲時間距離當前時間的差額,這個作為sleep時間使用 
        long sleepTimeMs = (deadline - currentTime + 999999) / 1000000;
        // sleepTimeMs小於零表示走到了下一個時間槽位置
        if (sleepTimeMs <= 0) {
            if (currentTime == Long.MIN_VALUE) {
                return -Long.MAX_VALUE;
            } else {
                return currentTime;
            }
        }
        // Check if we run on windows, as if thats the case we will need
        // to round the sleepTime as workaround for a bug that only affect
        // the JVM if it runs on windows.
        //
        // See https://github.com/netty/netty/issues/356
        if (PlatformDependent.isWindows()) {
            sleepTimeMs = sleepTimeMs / 10 * 10;
        }
        // 進入到這裡進行sleep,表示當前時間距離下一次tick時間還有一段距離,需要sleep
        try {
            Thread.sleep(sleepTimeMs);
        } catch (InterruptedException ignored) {
            if (WORKER_STATE_UPDATER.get(HashedWheelTimer.this) == WORKER_STATE_SHUTDOWN) {
                return Long.MIN_VALUE;
            }
        }
    }
}

3.1.5.2 transferTimeoutsToBuckets 撈佇列中的任務

轉移任務到時間輪中,前面我們講過,任務新增進來時,是先放入到阻塞佇列。而在現在這個方法中,就是把阻塞佇列中的資料轉移到時間輪的指定位置。

在這個轉移方法中,寫死了一個迴圈,每次都只轉移10萬個任務。然後根據HashedWheelTimeout的deadline延遲時間計算出時間輪需要執行多少次才能執行當前的任 務,如果當前的任務延遲時間大於時間輪跑一圈所需要的時間,那麼就計算需要跑幾圈才能到這個任務執行。最後計算出該任務在時間輪中的槽位,新增到時間輪的連結串列中。

private void transferTimeoutsToBuckets() {
    // transfer only max. 100000 timeouts per tick to prevent a thread to stale the workerThread when it just
    // adds new timeouts in a loop.
    // 迴圈100000次,也就是每次轉移10w個任務
    for (int i = 0; i < 100000; i++) {
        // 從阻塞佇列中獲得具體的任務
        HashedWheelTimeout timeout = timeouts.poll();
        if (timeout == null) {
            // all processed
            break;
        }
        if (timeout.state() == HashedWheelTimeout.ST_CANCELLED) {
            // Was cancelled in the meantime.
            continue;
        }
        // 計算tick次數,deadline表示當前任務的延遲時間, tickDuration表示時間槽的間隔,兩者相除就可以計算當前任務需要tick幾次才能被執行
        long calculated = timeout.deadline / tickDuration;
        // 計算剩餘的輪數, 只有 timer 走夠輪數, 並且到達了 task 所在的 slot, task 才會過期.(被執行)
        timeout.remainingRounds = (calculated - tick) / wheel.length;
        // 如果任務在 timeouts佇列裡面放久了, 以至於已經過了執行時間, 這個時候就使用當前tick, 也就是放到當前 bucket, 此方法呼叫完後就會被執行
        final long ticks = Math.max(calculated, tick); // Ensure we don't schedule for past.
        // 算出任務應該插入的 wheel 的 slot, stopIndex = tick 次數 & mask, mask = wheel.length - 1
        int stopIndex = (int) (ticks & mask);
        // 把timeout任務插入到指定的bucket鏈中。
        HashedWheelBucket bucket = wheel[stopIndex];
        bucket.addTimeout(timeout);
    }
}

我們再小看一下 Bucket 新增任務的方法:

private static final class HashedWheelBucket {
    // Used for the linked-list datastructure
    private HashedWheelTimeout head;
    private HashedWheelTimeout tail;
    /**
     * Add {@link HashedWheelTimeout} to this bucket.
     * 典型的連結串列結構 插入哈
     */
    public void addTimeout(HashedWheelTimeout timeout) {
        assert timeout.bucket == null;
        timeout.bucket = this;
        if (head == null) {
            head = tail = timeout;
        } else {
            tail.next = timeout;
            timeout.prev = tail;
            tail = timeout;
        }
    }
}

3.1.5.3 expireTimeouts 執行時間輪中的任務

當指標跳動到某一個時間槽中時,會就觸發這個槽中的任務的執行。該功能是透過expireTimeouts來實現,這個方法的主要作用是: 過期並執行格子中到期的任務。也就是當tick進入到指定格子時,worker執行緒 會呼叫這個方法。

HashedWheelBucket是一個連結串列,所以我們需要從head節點往下進行遍歷。如果連結串列沒有遍歷到連結串列 尾部那麼就繼續往下遍歷。

獲取的timeout節點節點,如果剩餘輪數remainingRounds大於0,那麼就說明要到下一圈才能執行, 所以將剩餘輪數減一;

如果當前剩餘輪數小於等於零了,那麼就將當前節點從bucket連結串列中移除,並判斷一下當前的時間是否 大於timeout的延遲時間,如果是則呼叫timeout的expire執行任務。

因為要執行某個時間槽的任務,所以這裡呼叫的是 bucket 的方法哈:

public void expireTimeouts(long deadline) {
    HashedWheelTimeout timeout = head;
    // process all timeouts
    // 遍歷當前時間槽中的所有任務
    while (timeout != null) {
        HashedWheelTimeout next = timeout.next;
        // 輪數小於等於0 說明當前輪要執行
        if (timeout.remainingRounds <= 0) {
            // 取出當前的任務
            next = remove(timeout);
            // 小於當前的時間間隔了 執行
            if (timeout.deadline <= deadline) {
                timeout.expire();
            } else {
                // 按理不可能會走到這裡的 The timeout was placed into a wrong slot. This should never happen.
                throw new IllegalStateException(String.format(
                        "timeout.deadline (%d) > deadline (%d)", timeout.deadline, deadline));
            }
        } else if (timeout.isCancelled()) {
            // 如果已經取消了 移除當前返回下一個
            next = remove(timeout);
        } else {
            // 因為當前的槽位已經過了,說明已經走了一圈了,把輪數減一
            timeout.remainingRounds --;
        }
        timeout = next;
    }
}

3.2 XXL-JOB 中的時間輪

3.2.1 XXL-JOB 介紹

XXL JOB 是一個輕量級分散式任務排程平臺,主打特點是平臺化,易部署,開發迅速、學習簡單、輕量 級、易擴充套件,程式碼仍在持續更新中。目前 XXL-JOB 任務執行已經摒棄 Quartz 框架,目前 透過時間輪方式來管理任務觸發任務。

排程中心: 任務排程控制檯,平臺自身並不承擔業務邏輯,只是負責任務的統一管理和排程執行, 並且提供任務管理平臺

執行器: 負責接收“排程中心”的排程並執行,可直接部署執行器,也可以將執行器整合到現有業務 專案中。 透過將任務的排程控制和任務的執行解耦,業務使用只需要關注業務邏輯的開發。

XXL-JOB 主要提供了任務的動態配置管理、任務監控和統計報表以及排程日誌幾大功能模組,支 持多種執行模式和路由策略,可基於對應執行器機器叢集數量進行簡單分片資料處理。

3.2.2 XXL-JOB 特性

(1)、簡單:支援透過 Web頁面對任務進行 CRUD 操作, 操作簡單 ,一分鐘上手;

(2)、動態:支援 動態修改任務狀態、啟動 / 停止任務,以及終止執行中任務,即時生效 ;

(3)、排程中心HA(中心式):排程採用 中心式設計,排程中心自研排程元件並 證排程中心HA; 支援叢集部署,可保

(4)、執行器HA(分散式):任務分散式執行,任務"執行器"支援叢集部署,可保證任務執行HA;

(5)、註冊中心: 執行器會週期性自動註冊任務, 排程中心將會自動發現註冊的任務並觸發執行。也支 持手動錄入執行器地址;

(6)、彈性擴容縮容:一旦有新執行器機器上線或者下線,下次排程時將會重新分配任務;

(7)、路由策略:執行器叢集部署時提供豐富的路由策略,包括: 第一個、最後一個、輪詢、隨機、 一致性 HASH 、最不經常使用、最近最久未使用、故障轉移、忙碌轉移 等;

(8)、故障轉移:任務路由策略選擇 故障轉移 情況下,如果執行器叢集中某一臺機器故障,將會自動 Failover切換到一臺正常的執行器傳送排程請求。

(9)、阻塞處理策略:排程過於密集執行器來不及處理時的處理策略,策略包括: 單機序列(默 認)、丟棄後續排程、覆蓋之前排程 ;

(10)、任務超時控制:支援 自定義任務超時時間 ,任務執行超時將會主動中斷任務;

(11)、任務失敗重試:支援 自定義任務失敗重試次數 ,當任務失敗時將會按照預設的失敗重試次數 主動進行重試;其中分片任務支援分片粒度的失敗重試;

(12)、任務失敗警告:預設提供郵件方式失敗告警,同時預留擴充套件介面,可方便的擴充套件簡訊、釘釘 等告警方式;

(13)、分片廣播任務:執行器叢集部署時,任務路由策略選擇 分片廣播情況下,一次任務排程將會 廣播觸發叢集中所有執行器執行一次任務,可根據分片引數開發分片任務;

(14)、動態分片:分片廣播任務以執行器為維度進行分片,支援動態擴容執行器叢集從而動態增加 分片數量,協同進行業務處理;在進行大資料量業務操作時可顯著提升任務處理能力和速度。

(15)、事件觸發:除了 Cron方式和 任務依賴方式觸發任務執行之外,支援基於事件的觸發任務方 式。排程中心提供觸發任務單次執行的API服務,可根據業務事件靈活觸發

3.2.3 時間輪-任務執行

XXL-JOB 時間輪實現方式比較簡單,就是一個 Map 結構資料,key值0-60,value是任務ID列表 Map<Integer, List> ringData 。

XXL-JOB 任務執行中啟動了兩個執行緒:

執行緒 scheduleThread 執行中不斷的從任務表中查詢 查詢近 5000 毫秒(5秒)中要執行的任務,如 果當前時間大於任務接下來要執行的時間則立即執行,否則將任務執行時間除以 1000 變為秒之後再與 60 求餘新增到時間輪中。

執行緒 ringThread 執行中不斷根據當前時間求餘從 時間輪 ringData 中獲取任務列表,取出任務之 後執行任務。

我們從 JobScheduleHelper 這個類的 start 看起。

public void start (){
 
    // 啟動排程執行緒,這些執行緒是用來取資料的 schedule thread
    scheduleThread = new Thread( new Runnable() {
    @Override
    public void run () {
    try { // 不知道為啥要休眠 4-5 秒 時間,然後再啟動
        TimeUnit. MILLISECONDS .sleep( 5000 - System. currentTimeMillis ()% 1000 ) ;
    } catch (InterruptedException e) {
        if (! scheduleThreadToStop ) {
            logger .error(e.getMessage() , e) ;
        }
    }
    logger .info( ">>>>>>>>> init xxl-job admin scheduler success." ) ;
 
     // 這裡是預讀數量 pre-read count: treadpool-size * trigger-qps (each trigger cost 50ms, qps = 1000/50 = 20)
    int preReadCount = (XxlJobAdminConfig. getAdminConfig ().getTriggerPoolFastMax() + XxlJobAdminConfig. getAdminConfig ().getTriggerPoolSlowMax()) * 20 ;
 
    while (! scheduleThreadToStop ) {
    // 掃描任務 Scan Job
    long start = System. currentTimeMillis () ;
    Connection conn = null;
    Boolean connAutoCommit = null;
    PreparedStatement preparedStatement = null
    boolean preReadSuc = true;
    try {
        conn = XxlJobAdminConfig. getAdminConfig ().getDataSource().getConnection() ;
          connAutoCommit = conn.getAutoCommit() ;
          conn.setAutoCommit( false ) ;
          // 採用 select for update ,是排它鎖。說白了 xxl-job 用一張資料庫表來當分散式鎖了,確保多個 xxl-job admin 節點下,依舊只能同時執行一個排程執行緒任務
        preparedStatement = conn.prepareStatement( "select * from xxl_job_lock where lock_name = 'schedule_lock' for update" ) ;
          preparedStatement.execute() ;
 
          // tx start
 
          // 1 、預讀資料 pre read
          long nowTime = System. currentTimeMillis () ;
          // -- 從資料庫中讀取截止到五秒後未執行的 job ,並且讀取 preReadCount=6000 條
          List<XxlJobInfo> scheduleList = XxlJobAdminConfig. getAdminConfig ().getXxlJobInfoDao().scheduleJobQuery(nowTime + PRE_READ_MS , preReadCount) ;
          if (scheduleList!= null && scheduleList.size()> 0 ) {
              // 2 、 push 壓進 時間輪 push time-ring
              for (XxlJobInfo jobInfo: scheduleList) {
 
                  // time-ring jump
                    if (nowTime > jobInfo.getTriggerNextTime() + PRE_READ_MS ) {
                        // 當前時間 大於 (任務的下一次觸發時間 + PRE_READ_MS ( 5s )) , 可能是查詢太久了,然後下面的程式碼重新整理了任務下次執行時間,導致超過五秒,所以就需要特殊處理
                        // 2.1 、 trigger-expire > 5s : pass && make next-trigger-time
                        logger .warn( ">>>>>>>>>>> xxl-job, schedule misfire, jobId = " + jobInfo.getId()) ;
                        // 1 、匹配過期失效的策略: DO_NOTHING= 過期啥也不幹,廢棄; FIRE_ONCE_NOW= 過期立即觸發一次 misfire match
                        MisfireStrategyEnum misfireStrategyEnum = MisfireStrategyEnum. match (jobInfo.getMisfireStrategy() , MisfireStrategyEnum. DO_NOTHING ) ;
                      if (MisfireStrategyEnum. FIRE_ONCE_NOW == misfireStrategyEnum) {
                            // FIRE_ONCE_NOW 》 trigger
                              JobTriggerPoolHelper. trigger (jobInfo.getId() , TriggerTypeEnum. MISFIRE , - 1 , null, null, null ) ;
                              logger .debug( ">>>>>>>>>>> xxl-job, schedule push trigger : jobId = " + jobInfo.getId() ) ;
                        }
                        // 2 、重新整理上一次觸發 和 下一次待觸發時間 fresh next
                         refreshNextValidTime(jobInfo , new Date()) ;
                    } else if (nowTime > jobInfo.getTriggerNextTime()) {
                        // 當前時間 大於 任務的下一次觸發時間 並且是沒有過期的
                    // 2.2 、 trigger-expire < 5s : direct-trigger && make next-trigger-time
                        // 1 、直接觸發任務執行器 trigger
                        JobTriggerPoolHelper. trigger (jobInfo.getId() , TriggerTypeEnum. CRON , - 1 , null, null, null ) ;
                        logger .debug( ">>>>>>>>>>> xxl-job, schedule push trigger : jobId = " + jobInfo.getId() ) ;
                        // 2 、重新整理上一次觸發 和 下一次待觸發時間 fresh next
                        refreshNextValidTime(jobInfo , new Date()) ;
 
                        // 如果下一次觸發在五秒內,直接放進時間輪裡面待排程 next-trigger-time in 5s, pre-read again
                        if (jobInfo.getTriggerStatus()== 1 && nowTime + PRE_READ_MS > jobInfo.getTriggerNextTime()) {
                              // 1 、求當前任務下一次觸發時間所處一分鐘的第 N 秒 make ring second
                              int ringSecond = ( int )((jobInfo.getTriggerNextTime()/ 1000 )% 60 ) ;
                              // 2 、將當前任務 ID 和 ringSecond 放進時間輪裡面 push time ring
                              pushTimeRing(ringSecond , jobInfo.getId()) ;
                              // 3 、重新整理上一次觸發 和 下一次待觸發時間 fresh next
                              refreshNextValidTime(jobInfo , new Date(jobInfo.getTriggerNextTime())) ;
                        }
 
                    } else {
                      // 當前時間 小於 下一次觸發時間
                        // 2.3 、 trigger-pre-read : time-ring trigger && make next-trigger-time
                        // 1 、求當前任務下一次觸發時間所處一分鐘的第 N 秒 make ring second
                        int ringSecond = ( int )((jobInfo.getTriggerNextTime()/ 1000 )% 60 ) ;
                        // 2 、將當前任務 ID 和 ringSecond 放進時間輪裡面 push time ring
                        pushTimeRing(ringSecond , jobInfo.getId()) ;
                        // 3 、重新整理上一次觸發 和 下一次待觸發時間 fresh next
                        refreshNextValidTime(jobInfo , new Date(jobInfo.getTriggerNextTime())) ;
                    }
              }
 
              // 3 、更新資料庫執行器資訊,如 trigger_last_time 、 trigger_next_time update trigger info
              for (XxlJobInfo jobInfo: scheduleList) {
                    XxlJobAdminConfig. getAdminConfig ().getXxlJobInfoDao().scheduleUpdate(jobInfo) ;
              }
 
          } else {
            preReadSuc = false;
          }
          // tx stop
    } catch (Exception e) {
          if (! scheduleThreadToStop ) {
              logger .error( ">>>>>>>>>>> xxl-job, JobScheduleHelper#scheduleThread error:{}" , e) ;
          }
    } finally {
          // 提交事務,釋放資料庫 select for update 的鎖 commit
        .......................省略.............    
    }
    long cost = System. currentTimeMillis ()-start ;
 
     // 如果執行太快了,就稍微 sleep 等待一下 Wait seconds, align second
    if (cost < 1000 ) { // scan-overtime, not wait
        try {
            // pre-read period: success > scan each second; fail > skip this period;
            TimeUnit. MILLISECONDS .sleep((preReadSuc? 1000 : PRE_READ_MS ) - System. currentTimeMillis ()% 1000 ) ;
        } catch (InterruptedException e) {
            if (! scheduleThreadToStop ) {
                logger .error(e.getMessage() , e) ;
            }
        }
    }) ;
    scheduleThread .setDaemon( true ) ;
    scheduleThread .setName( "xxl-job, admin JobScheduleHelper#scheduleThread" ) ;
    scheduleThread .start() ;
 
 
     // 時間輪執行緒,用於取出每秒的資料,然後處理 ring thread
    ringThread = new Thread( new Runnable() {
        @Override
        public void run () {
            while (! ringThreadToStop ) {
                   // align second
                   try {
                       TimeUnit. MILLISECONDS .sleep( 1000 - System. currentTimeMillis () % 1000 ) ;
                   } catch (InterruptedException e) {
                    if (! ringThreadToStop ) {
                        logger .error(e.getMessage() , e) ;
                    }
                }
                   try {
                       // second data
                       List<Integer> ringItemData = new ArrayList<>() ;
                       // 獲取當前所處的一分鐘第幾秒,然後 for 兩次,第二次是為了重跑前面一個刻度沒有被執行的的 job list ,避免前面的刻度遺漏了
                    int nowSecond = Calendar. getInstance ().get(Calendar. SECOND ) ; // 避免處理耗時太長,跨過刻度,向前校驗一個刻度;
                    for ( int i = 0 ; i < 2 ; i++) {
                        List<Integer> tmpData = ringData .remove( (nowSecond+ 60 -i)% 60 ) ;
                        if (tmpData != null ) {
                            ringItemData.addAll(tmpData) ;
                        }
                       }
 
                       // ring trigger
                       logger .debug( ">>>>>>>>>>> xxl-job, time-ring beat : " + nowSecond + " = " + Arrays. asList (ringItemData) ) ;
                       if (ringItemData.size() > 0 ) {
                           // do trigger
                              for ( int jobId: ringItemData) {
                                  // 執行觸發器 do trigger
                                  JobTriggerPoolHelper. trigger (jobId , TriggerTypeEnum. CRON , - 1 , null, null, null ) ;
                              }
                              // 清除當前刻度列表的資料 clear
                              ringItemData.clear() ;
                       }
                   } catch (Exception e) {
                         if (! ringThreadToStop ) {
                              logger .error( ">>>>>>>>>>> xxl-job, JobScheduleHelper#ringThread error:{}" , e) ;
                       }
                   }
               }
            logger .info( ">>>>>>>>>>> xxl-job, JobScheduleHelper#ringThread stop" ) ;
        }
    }) ;
    ringThread .setDaemon( true ) ;
    ringThread .setName( "xxl-job, admin JobScheduleHelper#ringThread" ) ;
    ringThread .start() ;
}

總結下來就是:

(1)scheduleThread-取待執行任務資料入時間輪
-- 第一步:用select for update 資料庫作為分散式鎖加鎖,避免多個xxl-job admin排程器節點同時執行
-- 第二步:預讀資料,從資料庫中讀取當前截止到五秒後內會執行的job資訊,並且讀取分頁大小為preReadCount=6000條資料
----  preReadCount = (XxlJobAdminConfig.getAdminConfig().getTriggerPoolFastMax() + XxlJobAdminConfig.getAdminConfig().getTriggerPoolSlowMax()) * 20;
-- 第三步:將當前時間與下次排程時間對比,有如下三種情況
****  當前時間 大於 (任務的下一次觸發時間 + PRE_READ_MS(5s)):可能是查詢太久了,然後下面的程式碼重新整理了任務下次執行時間,導致超過五秒,所以就需要特殊處理
--------  1、匹配過期失效的策略:DO_NOTHING=過期啥也不幹,廢棄;FIRE_ONCE_NOW=過期立即觸發一次
--------  2、重新整理上一次觸發 和 下一次待觸發時間
****  當前時間 大於 任務的下一次觸發時間 並且是沒有過期的:
--------  1、直接觸發任務執行器
--------  2、重新整理上一次觸發 和 下一次待觸發時間
--------  3、如果下一次觸發在五秒內,直接放進時間輪裡面待排程
----------------  1、求當前任務下一次觸發時間所處一分鐘的第N秒
----------------  2、將當前任務ID和ringSecond放進時間輪裡面
----------------  3、重新整理上一次觸發 和 下一次待觸發時間
****  當前時間 小於 下一次觸發時間:
--------  1、求當前任務下一次觸發時間所處一分鐘的第N秒
--------  2、將當前任務ID和ringSecond放進時間輪裡面
--------  3、重新整理上一次觸發 和 下一次待觸發時間
-- 第四步:更新資料庫執行器資訊,如trigger_last_time、trigger_next_time
-- 第五步:提交資料庫事務,釋放資料庫select for update排它鎖
 
(2)ringThread-根據時間輪執行job任務
首先時間輪資料格式為:Map<Integer, List<Integer>> ringData = new ConcurrentHashMap<>()
-- 第一步:獲取當前所處的一分鐘第幾秒,然後for兩次,第二次是為了重跑前面一個刻度沒有被執行的的job list,避免前面的刻度遺漏了
-- 第二步:執行觸發器
-- 第三步:清除當前刻度列表的資料
**** 執行的過程中還會選擇對應的策略,如下:
-------- 阻塞策略:序列、廢棄後面、覆蓋前面
-------- 路由策略:取第一個、取最後一個、最小分發、一致性hash、快速失敗、LFU最不常用、LRU最近最少使用、隨機、輪詢

另外有個小細節,執行任務其實就是往執行緒池中,放置任務,如下:

// 執行任務
public static void trigger(int jobId, TriggerTypeEnum triggerType, int failRetryCount, String executorShardingParam, String executorParam, String addressList) {
    helper.addTrigger(jobId, triggerType, failRetryCount, executorShardingParam, executorParam, addressList);
}
// 往執行緒池中放
public void addTrigger(final int jobId,
                       final TriggerTypeEnum triggerType,
                       final int failRetryCount,
                       final String executorShardingParam,
                       final String executorParam,
                       final String addressList) {

    // choose thread pool  看這裡有兩個執行緒池供選擇 一個快的 一個慢的
    ThreadPoolExecutor triggerPool_ = fastTriggerPool;
    AtomicInteger jobTimeoutCount = jobTimeoutCountMap.get(jobId);
    if (jobTimeoutCount!=null && jobTimeoutCount.get() > 10) {      // job-timeout 10 times in 1 min
        triggerPool_ = slowTriggerPool;
    }

    // trigger
    triggerPool_.execute(new Runnable() {
        @Override
        public void run() {
            ...
        }
    });
}

可以看到有兩個執行緒池供選擇,也就是會根據當前任務ID的超時次數,來選擇快慢執行緒池,學到了。

4 小結

好啦,關於時間輪的認識就到這裡了,有理解不對的地方歡迎指正哈。

相關文章