20 張圖告訴你,如何實現一個任務調度系統

寫這篇文章,想和大家從頭到腳說說任務調度,希望大家讀完之後,能夠理解實現一個任務調度系統的核心邏輯。

1 Quartz

Quartz 是一款 Java 開源任務調度框架,也是很多 Java 工程師接觸任務調度的起點。

下圖顯示了任務調度的整體流程:

Quartz 的核心是三個組件。

上圖代碼中 Quartz 的 JobStore 是 RAMJobStore,Trigger 和 Job 存儲在內存中。

執行任務調度的核心類是 QuartzSchedulerThread

  1. 調度線程從 JobStore 中獲取需要執行的的觸發器列表,並修改觸發器的狀態;

  2. Fire 觸發器,修改觸發器信息 (下次執行觸發器的時間,以及觸發器狀態),並存儲起來。

  3. 最後創建具體的執行任務對象,通過 worker 線程池執行任務。

接下來再聊聊 Quartz 的集羣部署方案。

Quartz 的集羣部署方案,需要針對不同的數據庫類型 (MySQL ,  ORACLE) 在數據庫實例上創建 Quartz 表,JobStore 是:  JobStoreSupport

這種方案是分佈式的,沒有負責集中管理的節點,而是利用數據庫行級鎖的方式來實現集羣環境下的併發控制。

scheduler 實例在集羣模式下首先獲取 {0}LOCKS 表中的行鎖,Mysql 獲取行鎖的語句:

{0} 會替換爲配置文件默認配置的QRTZ_。sched_name 爲應用集羣的實例名,lock_name 就是行級鎖名。Quartz 主要有兩個行級鎖觸發器訪問鎖 (TRIGGER_ACCESS) 和 狀態訪問鎖(STATE_ACCESS)。

這個架構解決了任務的分佈式調度問題,同一個任務只能有一個節點運行,其他節點將不執行任務,當碰到大量短任務時,各個節點頻繁的競爭數據庫鎖,節點越多性能就會越差。

2 分佈式鎖模式

Quartz 的集羣模式可以水平擴展,也可以分佈式調度,但需要業務方在數據庫中添加對應的表,有一定的強侵入性。

有不少研發同學爲了避免這種侵入性,也探索出分佈式鎖模式

業務場景:電商項目,用戶下單後一段時間沒有付款,系統就會在超時後關閉該訂單。

通常我們會做一個定時任務每兩分鐘來檢查前半小時的訂單,將沒有付款的訂單列表查詢出來,然後對訂單中的商品進行庫存的恢復,然後將該訂單設置爲無效。

我們使用 Spring Schedule 的方式做一個定時任務。

@Scheduled(cron = "0 */2 * * * ? ")
public void doTask() {
   log.info("定時任務啓動");
   //執行關閉訂單的操作
   orderService.closeExpireUnpayOrders();
   log.info("定時任務結束");
 }

在單服務器運行正常,考慮到高可用,業務量激增,架構會演進成集羣模式,在同一時刻有多個服務執行一個定時任務,有可能會導致業務紊亂。

解決方案是在任務執行的時候,使用 Redis 分佈式鎖來解決這類問題。

@Scheduled(cron = "0 */2 * * * ? ")
public void doTask() {
    log.info("定時任務啓動");
    String lockName = "closeExpireUnpayOrdersLock";
    RedisLock redisLock = redisClient.getLock(lockName);
    //嘗試加鎖,最多等待3秒,上鎖以後5分鐘自動解鎖
    boolean locked = redisLock.tryLock(3, 300, TimeUnit.SECONDS);
    if(!locked){
        log.info("沒有獲得分佈式鎖:{}" , lockName);
        return;
    }
    try{
       //執行關閉訂單的操作
       orderService.closeExpireUnpayOrders();
    } finally {
       redisLock.unlock();
    }
    log.info("定時任務結束");
}

Redis 的讀寫性能極好,分佈式鎖也比 Quartz 數據庫行級鎖更輕量級。當然 Redis 鎖也可以替換成 Zookeeper 鎖,也是同樣的機制。

在小型項目中,使用:定時任務框架(Quartz/Spring Schedule)和 分佈式鎖(redis/zookeeper)有不錯的效果。

但是呢?我們可以發現這種組合有兩個問題:

  1. 定時任務在分佈式場景下有空跑的情況,而且任務也無法做到分片;

  2. 要想手工觸發任務,必須添加額外的代碼才能完成。

3 ElasticJob-Lite 框架

ElasticJob-Lite 定位爲輕量級無中心化解決方案,使用 jar 的形式提供分佈式任務的協調服務。

應用內部定義任務類,實現 SimpleJob 接口,編寫自己任務的實際業務流程即可。

public class MyElasticJob implements SimpleJob {
    @Override
    public void execute(ShardingContext context) {
        switch (context.getShardingItem()) {
            case 0:
                // do something by sharding item 0
                break;
            case 1:
                // do something by sharding item 1
                break;
            case 2:
                // do something by sharding item 2
                break;
            // case n: ...
        }
    }
}

舉例:應用 A 有五個任務需要執行,分別是 A,B,C,D,E。任務 E 需要分成四個子任務,應用部署在兩臺機器上。

應用 A 在啓動後, 5 個任務通過 Zookeeper 協調後被分配到兩臺機器上,通過 Quartz Scheduler 分開執行不同的任務。

ElasticJob 從本質上來講 ,底層任務調度還是通過 Quartz ,相比 Redis 分佈式鎖 或者 Quartz 分佈式部署 ,它的優勢在於可以依賴 Zookeeper 這個大殺器 ,將任務通過負載均衡算法分配給應用內的 Quartz Scheduler 容器。

從使用者的角度來講,是非常簡單易用的。但從架構來看,調度器和執行器依然在同一個應用方 JVM 內,而且容器在啓動後,依然需要做負載均衡。應用假如頻繁的重啓,不斷的去選主,對分片做負載均衡,這些都是相對比較的操作。

ElasticJob 的控制檯通過讀取註冊中心數據展現作業狀態,更新註冊中心數據修改全局任務配置。從一個任務調度平臺的角度來看,控制檯功能還是偏孱弱的。

4 中心化流派

中心化的原理是:把調度和任務執行,隔離成兩個部分:調度中心和執行器。調度中心模塊只需要負責任務調度屬性,觸發調度命令。執行器接收調度命令,去執行具體的業務邏輯,而且兩者都可以進行分佈式擴容。

4.1 MQ 模式

先談談我在藝龍促銷團隊接觸的第一種中心化架構。

調度中心依賴 Quartz 集羣模式,當任務調度時候,發送消息到 RabbitMQ 。業務應用收到任務消息後,消費任務信息。

這種模型充分利用了 MQ 解耦的特性,調度中心發送任務,應用方作爲執行器的角色,接收任務並執行。

但這種設計強依賴消息隊列,可擴展性和功能,系統負載都和消息隊列有極大的關聯。這種架構設計需要架構師對消息隊列非常熟悉。

4.2 XXL-JOB

XXL-JOB 是一個分佈式任務調度平臺,其核心設計目標是開發迅速、學習簡單、輕量級、易擴展。現已開放源代碼並接入多家公司線上產品線,開箱即用。

我們重點剖析下架構圖 :

▍ 網絡通訊 server-worker 模型

調度中心和執行器 兩個模塊之間通訊是 server-worker 模式。調度中心本身就是一個 SpringBoot 工程,啓動會監聽 8080 端口。

執行器啓動後,會啓動內置服務( EmbedServer )監聽 9994 端口。這樣雙方都可以給對方發送命令。

那調度中心如何知道執行器的地址信息呢 ?上圖中,執行器會定時發送註冊命令 ,這樣調度中心就可以獲取在線的執行器列表。

通過執行器列表,就可以根據任務配置的路由策略選擇節點執行任務。常見的路由策略有如下三種:

▍ 調度器

調度器是任務調度系統裏面非常核心的組件。XXL-JOB 的早期版本是依賴 Quartz。

但在 v2.1.0 版本中完全去掉了 Quartz 的依賴,原來需要創建的 Quartz 表也替換成了自研的表。

核心的調度類是:JobTriggerPoolHelper 。調用 start 方法後,會啓動兩個線程:scheduleThread 和 ringThread 。

首先 scheduleThread 會定時從數據庫加載需要調度的任務,這裏從本質上還是基於數據庫行鎖保證同時只有一個調度中心節點觸發任務調度。

Connection conn = XxlJobAdminConfig.getAdminConfig()
                  .getDataSource().getConnection();
connAutoCommit = conn.getAutoCommit();
conn.setAutoCommit(false);
preparedStatement = conn.prepareStatement(
"select * from xxl_job_lock where lock_name = 'schedule_lock' for update");
preparedStatement.execute();
# 觸發任務調度 (僞代碼)
for (XxlJobInfo jobInfo: scheduleList) {
  // 省略代碼
}
# 事務提交
conn.commit();

調度線程會根據任務的「下次觸發時間」,採取不同的動作:

已過期的任務需要立刻執行的,直接放入線程池中觸發執行 ,五秒內需要執行的任務放到 ringData 對象裏。

ringThread 啓動後,定時從 ringData 對象裏獲取需要執行的任務列表 ,放入到線程池中觸發執行。

5 自研在巨人的肩膀上

2018 年,我有一段自研任務調度系統的經歷。

背景是:兼容技術團隊自研的 RPC 框架,技術團隊不需要修改代碼,RPC 註解方法可以託管在任務調度系統中,直接當做一個任務來執行。

自研過程中,研讀了 XXL-JOB 源碼,同時從阿里雲分佈式任務調度 SchedulerX 吸取了很多營養。

SchedulerX 1.0 架構圖

我們模仿了 SchedulerX 的模塊,架構設計如下圖:

我選擇了 RocketMQ 源碼的通訊模塊 remoting 作爲自研調度系統的通訊框架。基於如下兩點:

  1. 我對業界大名鼎鼎的 Dubbo 不熟悉,而 remoting 我已經做了多個輪子,我相信自己可以搞定;

  2. 在閱讀 SchedulerX 1.0 client 源碼中,發現 SchedulerX 的通訊框架和 RocketMQ Remoting 很多地方都很類似。它的源碼裏有現成的工程實現,完全就是一個寶藏。

我將 RocketMQ remoting 模塊去掉名字服務代碼,做了一定程度的定製。

在 RocketMQ 的 remoting 裏,服務端採用 Processor 模式。

調度中心需要註冊兩個處理器:回調結果處理器 CallBackProcessor 和心跳處理器 HeartBeatProcessor 。執行器需要註冊觸發任務處理器 TriggerTaskProcessor 。

public void registerProcessor(
             int requestCode,
             NettyRequestProcessor processor,
             ExecutorService executor);

處理器的接口:

public interface NettyRequestProcessor {
 RemotingCommand processRequest(
                 ChannelHandlerContext ctx,
                 RemotingCommand request) throws Exception;
 boolean rejectRequest();
}

對於通訊框架來講,我並不需要關注通訊細節,只需要實現處理器接口即可。

以觸發任務處理器 TriggerTaskProcessor 舉例:

搞定網絡通訊後,調度器如何設計 ?最終我還是選擇了 Quartz 集羣模式。主要是基於以下幾點原因:

  1. 調度量不大的情況下 ,Quartz 集羣模式足夠穩定,而且可以兼容原來的 XXL-JOB 任務;

  2. 使用時間輪的話,本身沒有足夠的實踐經驗,擔心出問題。另外,如何讓任務通過不同的調度服務(schedule-server)觸發, 需要有一個協調器。於是想到 Zookeeper。但這樣的話,又引入了新的組件。

  3. 研發週期不能太長,想快點出成果。

自研版的調度服務花費一個半月上線了。系統運行非常穩定,研發團隊接入也很順暢。調度量也不大 ,四個月總共接近 4000 萬到 5000 萬之間的調度量。

坦率的講,自研版的瓶頸,我的腦海裏經常能看到。數據量大,我可以搞定分庫分表,但 Quartz 集羣基於行級鎖的模式 ,註定上限不會太高。

爲了解除心中的困惑,我寫一個輪子 DEMO 看看可否 work:

  1. 去掉外置的註冊中心,調度服務(schedule-server)管理會話;

  2. 引入 zookeeper,通過 zk 協調調度服務。但是 HA 機制很粗糙,相當於一個任務調度服務運行,另一個服務 standby;

  3. Quartz 替換成時間輪 (參考 Dubbo 裏的時間輪源碼)。

這個 Demo 版本在開發環境可以運行,但有很多細節需要優化,僅僅是個玩具,並沒有機會運行到生產環境。

最近讀阿里雲的一篇文章《如何通過任務調度實現百萬規則報警》,SchedulerX2.0 高可用架構見下圖:

文章提到:

每個應用都會做三備份,通過 zk 搶鎖,一主兩備,如果某臺 Server 掛了,會進行 failover,由其他 Server 接管調度任務。

這次自研任務調度系統從架構來講,並不複雜,實現了 XXL-JOB 的核心功能,也兼容了技術團隊的 RPC 框架,但並沒有實現工作流以及 mapreduce 分片。

SchedulerX 在升級到 2.0 之後基於全新的 Akka 架構,這種架構號稱實現高性能工作流引擎,實現進程間通信,減少網絡通訊代碼。

在我調研的開源任務調度系統中,PowerJob 也是基於 Akka 架構,同時也實現了工作流和 MapReduce 執行模式。

我對 PowerJob 非常感興趣,也會在學習實踐後輸出相關文章,敬請期待。

6 技術選型

首先我們將任務調度開源產品和商業產品 SchedulerX 放在一起,生成一張對照表:

Quartz 和 ElasticJob 從本質上還是屬於框架的層面。

中心化產品從架構上來講更加清晰,調度層面更靈活,可以支持更復雜的調度(mapreduce 動態分片,工作流)。

XXL-JOB 從產品層面已經做到極簡,開箱即用,調度模式可以滿足大部分研發團隊的需求。簡單易用 + 能打,所以非常受大家歡迎。

其實每個技術團隊的技術儲備不盡相同,面對的場景也不一樣,所以技術選型並不能一概而論。

不管是使用哪種技術,在編寫任務業務代碼時,還是需要注意兩點:

7 寫到最後

2015 年其實是非常有趣的一年。ElasticJob 和 XXL-JOB 這兩種不同流派的任務調度項目都開源了。

在 XXL-JOB 源碼裏,至今還保留着許雪裏老師在開源中國的一條動態截圖:

剛寫的任務調度框架 ,Web 動態管理任務,實時生效,熱乎的。沒有意外的話,明天中午推送到 git.osc 上去。哈哈,下樓炒個面加個荷包蛋慶祝下。

看到這個截圖,內心深處竟然會有一種共情,嘴角不自禁的上揚。

我又想起:2016 年,ElasticJob 的作者張亮老師開源了 sharding-jdbc 。我在 github 上創建了一個私有項目,參考 sharding-jdbc 的源碼,自己實現分庫分表的功能。第一個類名叫:ShardingDataSource,時間定格在 2016/3/29。

我不知道如何定義 “有創造力的軟件工程師”,但我相信:一個有好奇心,努力學習,樂於分享,願意去幫助別人的工程師,運氣肯定不會太差。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/q7Hu3_a-UFITCW1qM_8hPg