一個簡單的基於 Redis 的分散式任務排程器 —— Java 語言實現

碼洞發表於2019-05-29

折騰了一週的 Java Quartz 叢集任務排程,很遺憾沒能搞定,網上的相關文章也少得可憐,在多節點(多程式)環境下 Quartz 似乎無法動態增減任務,惱火。無奈之下自己擼了一個簡單的任務排程器,結果只花了不到 2天時間,而且感覺非常簡單好用,程式碼量也不多,擴充套件性很好。

一個簡單的基於 Redis 的分散式任務排程器 —— Java 語言實現

實現一個分散式的任務排程器有幾個關鍵的考慮點

  1. 單次任務和迴圈任務好做,難的是 cron 表示式的解析和時間計算怎麼做?

  2. 多程式同一時間如何保證一個任務的互斥性?

  3. 如何動態變更增加和減少任務?

程式碼例項

在深入講解實現方法之前,我們先來看看這個排程器是如何使用的

class Demo {
    public static void main(String[] args) {
        var redis = new RedisStore();
        // sample 為任務分組名稱
        var store = new RedisTaskStore(redis, "sample");
        // 5s 為任務鎖壽命
        var scheduler = new DistributedScheduler(store, 5);
        // 註冊一個單次任務
        scheduler.register(Trigger.onceOfDelay(5), Task.of("once1", () -> {
            System.out.println("once1");
        }));
        // 註冊一個迴圈任務
        scheduler.register(Trigger.periodOfDelay(55), Task.of("period2", () -> {
            System.out.println("period2");
        }));
        // 註冊一個 CRON 任務
        scheduler.register(Trigger.cronOfMinutes(1), Task.of("cron3", () -> {
            System.out.println("cron3");
        }));
        // 設定全域性版本號
        scheduler.version(1);
        // 註冊監聽器
        scheduler.listener(ctx -> {
            System.out.println(ctx.task().name() + " is complete");
        });
        // 啟動排程器
        scheduler.start();
    }
}

當程式碼升級任務需要增加減少時(或者變更排程時間),只需要遞增全域性版本號,現有的程式中的任務會自動被重新排程,那些沒有被註冊的任務(任務減少)會自動清除。新增的任務(新任務)在老程式碼的程式裡是不會被排程的(沒有新任務的程式碼無法排程),被清除的任務(老任務)在老程式碼的程式裡會被取消排程。

比如我們要取消 period2 任務,增加 period4 任務

class Demo {
    public static void main(String[] args) {
        var redis = new RedisStore();
        // sample 為任務分組名稱
        var store = new RedisTaskStore(redis, "sample");
        // 5s 為任務鎖壽命
        var scheduler = new DistributedScheduler(store, 5);
        // 註冊一個單次任務
        scheduler.register(Trigger.onceOfDelay(5), Task.of("once1", () -> {
            System.out.println("once1");
        }));
        // 註冊一個 CRON 任務
        scheduler.register(Trigger.cronOfMinutes(1), Task.of("cron3", () -> {
            System.out.println("cron3");
        }));
        // 註冊一個迴圈任務
        scheduler.register(Trigger.periodOfDelay(510), Task.of("period4", () -> {
            System.out.println("period4");
        }));
        // 遞增全域性版本號
        scheduler.version(2);
        // 註冊監聽器
        scheduler.listener(ctx -> {
            System.out.println(ctx.task().name() + " is complete");
        });
        // 啟動排程器
        scheduler.start();
    }
}

cron4j

<dependency>
    <groupId>it.sauronsoftware.cron4j</groupId>
    <artifactId>cron4j</artifactId>
    <version>2.2.5</version>
</dependency>

這個開源的 library 包含了基礎的 cron 表示式解析功能,它還提供了任務的排程功能,不過這裡並不需要使用它的排程器。我只會用到它的表示式解析功能,以及一個簡單的方法用來判斷當前的時間是否匹配表示式(是否該執行任務了)。

我們對 cron 的時間精度要求很低,1 分鐘判斷一次當前的時間是否到了該執行任務的時候就可以了。

class SchedulingPattern {
    // 表示式是否有效
    boolean validate(String cronExpr);
    // 是否應該執行任務了(一分鐘判斷一次)
    boolean match(long nowTs);
}

任務的互斥性

因為是分散式任務排程器,多程式環境下要控制同一個任務在排程的時間點只能有一個程式執行。使用 Redis 分散式鎖很容易就可以搞定。鎖需要保持一定的時間(比如預設 5s)。

所有的程式都會在同一時間排程這個任務,但是隻有一個程式可以搶到鎖。因為分散式環境下時間的不一致性,不同機器上的程式會有較小的時間差異視窗,鎖必須保持一個視窗時間,這裡我預設設定為 5s(可定製),這就要求不同機器的時間差不能超過 5s,超出了這個值就會出現重複排程。

public boolean grabTask(String name) {
    var holder = new Holder<Boolean>();
    redis.execute(jedis -> {
        var lockKey = keyFor("task_lock", name);
        var ok = jedis.set(lockKey, "true", SetParams.setParams().nx().ex(lockAge));
        holder.value(ok != null);
    });
    return holder.value();
}

全域性版本號

我們給任務列表附上一個全域性的版本號,當業務上需要增加或者減少排程任務時,透過變更版本號來觸發程式的任務重載入。這個重載入的過程包含輪詢全域性版本號(Redis 的一個key),如果發現版本號變動,立即重新載入任務列表配置並重新排程所有的任務。

private void scheduleReload() {
    // 1s 對比一次
    this.scheduler.scheduleWithFixedDelay(() -> {
        try {
            if (this.reloadIfChanged()) {
                this.rescheduleTasks();
            }
        } catch (Exception e) {
            LOG.error("reloading tasks error", e);
        }
    }, 01, TimeUnit.SECONDS);
}

重新排程任務先要取消當前所有正在排程的任務,然後排程剛剛載入的所有任務。

private void rescheduleTasks() {
    this.cancelAllTasks();
    this.scheduleTasks();
}

private void cancelAllTasks() {
    this.futures.forEach((name, future) -> {
        LOG.warn("cancelling task {}", name);
        future.cancel(false);
    });
    this.futures.clear();
}

因為需要將任務持久化,所以設計了一套任務的序列化格式,這個也很簡單,使用文字符號分割任務配置屬性就行。

// 一次性任務(startTime)
ONCE@2019-04-29T15:26:29.946+0800
// 迴圈任務,(startTime,endTime,period),這裡任務的結束時間是天荒地老
PERIOD@2019-04-29T15:26:29.949+0800|292278994-08-17T15:12:55.807+0800|5
// cron 任務,一分鐘一次
CRON@*/1 * * * *

$ redis-cli
127.0.0.1:6379> hgetall sample_triggers
1"task3"
2"CRON@*/1 * * * *"
3"task2"
4"PERIOD@2019-04-29T15:26:29.949+0800|292278994-08-17T15:12:55.807+0800|5"
5"task1"
6"ONCE@2019-04-29T15:26:29.946+0800"
7"task4"
8"PERIOD@2019-04-29T15:26:29.957+0800|292278994-08-17T15:12:55.807+0800|10"

執行緒池

時間排程會有一個單獨的執行緒(單執行緒執行緒池),任務的執行由另外一個執行緒池來完成(數量可定製)。

class DistributedScheduler {
    private ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
    private ExecutorService executor = Executors.newFixedThreadPool(threads);
}

之所以要將執行緒池分開,是為了避免任務的執行(IO)影響了時間的精確排程。

支援無互斥任務

互斥任務要求任務的單程式執行,無互斥任務就是沒有加分散式鎖的任務,可以多程式同時執行。預設需要互斥。

class Task {
    /**
     * 是否需要考慮多程式互斥(true表示不互斥,多程式能同時跑)
     */

    private boolean concurrent;
    private String name;
    private Runnable runner;
    ...
    public static Task of(String name, Runnable runner) {
        return new Task(name, false, runner);
    }

    public static Task concurrent(String name, Runnable runner) {
        return new Task(name, true, runner);
    }
}

增加回撥介面

考慮到排程器的使用者可能需要對任務執行狀態進行監控,這裡增加了一個簡單的回撥介面,目前功能比較簡單。能彙報執行結果(成功還是異常)和執行的耗時

class TaskContext {
    private Task task;
    private long cost;  // 執行時間
    private boolean ok;
    private Throwable e;
}

interface ISchedulerListener {
    public void onComplete(TaskContext ctx);
}

支援儲存擴充套件

目前只實現了 Redis 和 Memory 形式的任務儲存,擴充套件到 zk、etcd、關聯式資料庫也是可行的,實現下面的介面即可。

interface ITaskStore {
  public long getRemoteVersion();
  public Map<String, String> getAllTriggers();
  public void saveAllTriggers(long version, Map<String, String> triggers);
  public boolean grabTask(String name);
}

程式碼地址

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/31561269/viewspace-2646095/,如需轉載,請註明出處,否則將追究法律責任。

相關文章