從0到1,百億級任務調度平臺的架構與實現

文章很長,且持續更新,建議收藏起來,慢慢讀!瘋狂創客圈總目錄 博客園版 爲您奉上珍貴的學習資源 :

免費贈送 :《尼恩Java面試寶典》 持續更新+ 史上最全 + 面試必備 2000頁+ 面試必備 + 大廠必備 +漲薪必備
免費贈送 :《尼恩技術聖經+高併發系列PDF》 ,幫你 實現技術自由,完成職業升級, 薪酬猛漲!加尼恩免費領
免費贈送 經典圖書:《Java高併發核心編程(卷1)加強版》 面試必備 + 大廠必備 +漲薪必備 加尼恩免費領
免費贈送 經典圖書:《Java高併發核心編程(卷2)加強版》 面試必備 + 大廠必備 +漲薪必備 加尼恩免費領
免費贈送 經典圖書:《Java高併發核心編程(卷3)加強版》 面試必備 + 大廠必備 +漲薪必備 加尼恩免費領

免費贈送 資源寶庫: Java 必備 百度網盤資源大合集 價值>10000元 加尼恩領取


從0到1,百億級任務調度平臺的架構與實現

尼恩特別說明: 尼恩的文章,都會在 《技術自由圈》 公號 發佈, 並且維護最新版本。 如果發現圖片 不可見, 請去 《技術自由圈》 公號 查找

尼恩:百億級海量任務調度平臺起源

在40歲老架構師 尼恩的讀者交流羣(50+)中,經常性的指導小夥伴們改造簡歷。

經過尼恩的改造之後,很多小夥伴拿到了一線互聯網企業如得物、阿里、滴滴、極兔、有贊、希音、百度、網易、美團的面試機會,拿到了大廠機會。

這些機會的來源,主要是尼恩給小夥伴 改造了簡歷,植入了亮點項目、黃金項目。

尼恩的 亮點項目、黃金項目 需要持續迭代。

下一個亮點項目、黃金項目是:百億級海量任務調度平臺。

於是,尼恩組織小夥伴開始研究和 設計 《百億級海量任務調度平臺》,幫助大家打造一個新的黃金項目,實現大廠的夢想。

百億級海量任務調度平臺

海量億級任務調度平臺是爲了滿足各種業務異步定時執行需求而設計的一套分佈式任務調度系統。

它不僅能夠滿足傳統的定時任務執行需求,還可以應對海量任務、高可靠、低延遲等特點。

百億級海量任務調度平臺業務訴求:

在日常開發中會經常遇到一些需要異步定時執行的業務訴求,典型的使用場景如:

  • 超時未支付訂單關單
  • 每隔 2h 更新好友排行榜
  • 3.22 日 17 點《xx》劇上線等。

目前業務側多基於以下思路來快速搭建一個調度系統,mysql 或者 redis 隊列存儲待執行任務,通過 crontab 定時觸發應用完成“撈取、計算、執行等操作”。

不難看出存在幾類亟待解決問題:

1)缺少統一的調度平臺導致各業務重複開發;

2)簡易版調度實現在任務吞吐、調度時效上缺少保障;

3)業務和調度數據強耦合存儲給線上穩定性引入大 key、慢 sql 風險。

目前存在多類開源解決方案如 XXL-Job 、 Elastic-Job、quartz 調度等,但這些都屬於進程級調度平臺,很難滿足更細粒度的業務調用。

海量億級任務調度平臺到底長成什麼樣呢?

海量百億級任務調度平臺主要特點和功能:

海量百億級任務調度平臺主要特點和功能,大致如下。

  1. 分佈式架構: 任務調度平臺採用分佈式架構,可以橫向擴展以應對海量任務的調度需求。通過將任務分散到多個節點上執行,提高了系統的吞吐量和穩定性。
  2. 高可靠性: 平臺具有高可靠性,能夠保證任務的準確執行。採用主備節點、任務重試機制、任務監控和告警等方式確保任務不會丟失和錯過。
  3. 低延遲: 任務調度平臺能夠保證低延遲的任務執行。通過優化任務調度算法、減少任務執行的等待時間、提高任務執行的併發度等方式,降低了任務執行的延遲。
  4. 統一管理: 提供統一的任務管理界面和接口,方便業務方統一管理和監控各類任務。通過可視化界面展示任務執行情況、任務調度狀態等信息,提供給用戶更直觀的操作體驗。
  5. 靈活擴展: 平臺具有良好的擴展性,可以根據業務需求靈活定製任務調度策略和調度算法。支持定製化的任務調度器、觸發器、任務處理器等組件,滿足不同業務場景的需求。
  6. 多樣化任務類型: 平臺支持多樣化的任務類型,包括定時任務、週期任務、延時任務、異步任務等。能夠滿足不同業務場景下的任務調度需求。

海量億級任務調度平臺解決方案:

  1. 統一調度平臺: 搭建統一的任務調度平臺,爲各業務提供統一的任務調度服務,避免各業務重複開發調度功能,提高開發效率。
  2. 高可靠性設計: 在平臺設計中考慮高可靠性,採用集羣部署、主備機制、任務重試、監控告警等方式確保任務執行的穩定性和可靠性。
  3. 低延遲優化: 優化任務調度算法和執行流程,減少任務調度的等待時間和執行時間,提高任務執行的效率和響應速度。
  4. 解耦業務和調度: 對業務和調度進行解耦,採用消息隊列等方式將任務調度數據存儲與業務數據存儲分離,降低了業務和調度的耦合性,減少了大 key、慢 SQL 的風險。
  5. 技術選型: 綜合考慮開源解決方案如XXL-Job、Elastic-Job、Quartz等,並結合業務需求和現有技術棧選擇合適的調度平臺,以滿足海量任務調度的需求。

通過以上擴展,海量億級任務調度平臺能夠更好地滿足業務的高可靠性、低延遲、統一管理等需求,爲企業提供穩定可靠的任務調度服務。

海量百億級任務調度平臺的功能性訴求

任務管理:包括任務註冊、任務啓停、任務更新等,

任務查詢:主要用於任務追蹤、問題排查、調度統計等,

任務回調:由業務提供 任務spi回調實現,調度 平臺定時調用觸發

圖片

海量百億級任務調度平臺的非功能性訴求

海量億級任務調度平臺的非功能性保障:

  • 平臺化:支持多業務接入、百億級任務註冊

  • 易用性:自助化接入、運維,使用成本遠低自建

  • 高可靠:全年 3 個 9 可用性、p99(時延)<1s

  • 高性能:支持 100w+TPM 的任務觸發

綜合看需要 支持百億級任務量和百萬 TPM 併發執行,並在此基礎上滿足三個 SLA:

  1. 註冊\觸發可用性>99.95%
  2. 任務觸達率>99.99%
  3. p99(觸達延時)<1s

百億級任務量和百萬 TPM 併發的架構方案

數據存儲架構:

重點解決兩個問題數據可靠和海量存儲,可靠的存儲保障任務不丟、任務高觸達率,鑑於 mysql 在持久化以及 master-slave 部署架構對高可用支持表現,優先選用 mysql 作爲底層存儲;

但單 DB 在 TPS 性能、數據量上存在瓶頸,這裏選用分庫分表策略,通過增加數據庫實例打平數據分佈以提升整體性能和存儲上限;

實時性架構:

類似多級緩存的思路,爲保障任務觸發時效(p99<1s)這裏的設計思路“任務前置”,拆解任務觸發步驟,將任務撈取、計算工作儘量提前完成,通過毫秒級延遲的內存時間輪最終觸發,保障任務的觸發時效性;

高併發:

採用可伸縮架構設計,存儲層儘量拆分爲多個邏輯庫,前期通過合併部署降低成本但保留多個邏輯庫隔離能力,未來支持快速遷移獨立部署以提升性能;

應用層採用多級調度思路,按數據分片將大任務拆分成小粒度任務動態根據計算節點數完成分配,實現通過增加計算節點快速提升任務觸發能力;

高可用:

MTTR 分段治理思路,架構層在設計階段考慮到單點、單機房風險,不管是存儲層還是應用層都採用多機多活架構,並支持 HA 自動切換大大縮短 MTTF 時效;

立體化的監控+撥測能力,覆蓋從註冊到觸發全流程波動、成功率、耗時、延遲多維度監控,縮短 MTTI 時效;

MTTR 是指 Mean Time to Repair,即平均修復時間。

在系統運維和故障處理領域,MTTR 是一個重要的性能指標,用於衡量系統故障發生後,從故障發生到恢復正常運行所花費的平均時間。

MTTR 的計算方式爲:

𝑀𝑇𝑇𝑅=總的修復時間/發生故障次數

MTTR 的值越小越好,因爲它反映了系統修復故障的效率和速度。

較低的 MTTR 意味着系統能夠更快地從故障中恢復,減少了系統的停機時間,提高了系統的可用性和穩定性。

在實際運維工作中,降低 MTTR 可以通過以下方式實現:

  1. 自動化故障檢測和修復: 引入自動化工具和腳本,實現對常見故障的自動檢測和修復,減少人工干預的時間和錯誤。
  2. 監控和告警系統: 配置有效的監控系統,及時發現系統異常和故障,通過告警通知運維人員進行快速響應和處理。
  3. 故障排查和診斷: 提前準備好故障排查的工具和流程,快速定位故障原因,並採取有效的措施進行修復。

MTTF 是指 Mean Time to Failure,即平均故障間隔時間。它是指系統或設備在正常使用條件下,平均運行多長時間後出現故障的時間。MTTF 是一個重要的可靠性指標,用於評估系統或設備的可靠性和穩定性。

MTTF=總的運行時間/發生故障次數

MTTF 的值越大越好,因爲它表示系統或設備在正常運行下的平均故障間隔時間越長,說明系統的可靠性越高。

海量百億級任務調度平臺的實操

技術自由圈爲了幫助1000多會員打造頂級實操項目,計劃基於 XXL-Job,來一個海量百億級任務調度平臺的實操

基於 XXL-JOB 進行二次架構,二次定製,二次改進, 一步一步來。

大概得步驟如下:

  • XXL-JOB 入門學習
  • XXL-JOB 架構和源碼學習
  • XXL-JOB-Massive 海量任務調度平臺的架構和實現

"海量百億級任務調度平臺"的英文翻譯可以是 "Massive Hundred Billion-Level Task Scheduling Platform"。

XXL-JOB 入門學習

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

單體任務調度框架

項目中比如對賬單,日結、月結、放單短信、營銷類短信,等場景都需要任務調度單體系統中有許多實現任務調度的方式,如多線程方式、Timer 類、Spring Tasks 等等。

Springboot裏比較常用的是 Spring Tasks(通過 @EnableScheduling + @Scheduled 的註解可以自定義定時任務)

但是,在集羣服務下,如果還是使用每臺機器按照單體系統的任務調度實現方式實現的話,會出現下面問題:

  1. 怎麼做到對任務的控制(如何避免任務重複執行,單體項目集羣部署,會重複執行)。
  2. 如果某臺機器宕機了,會不會存在任務丟失。
  3. 如果要增加服務實例,怎麼做到彈性擴容。
  4. 如何做到對任務調度的執行情況統一監測。
  5. 如何做到高可用
  6. 如何做到動態配置
  7. 如何實現任務分片執行

分佈式任務調度框架

分佈式定時任務調度的框架可以選擇的有:quartz、elastic-job、xxl-job

功能 quartz elastic-job xxl-job
HA(高可用) 多節點部署,通過數據庫鎖來保證只有一個節點執行任務 通過zookeeper的註冊和發現,可以動態添加服務器,支持水平擴容 集羣部署
任務分片 不支持 支持 支持
文檔完善 完善 完善 完善
管理界面 沒有
難易程度 簡單 較複雜 簡單
公司 OpenSymphony 噹噹網 個人
缺點 沒有管理界面不支持任務分片,不適用於分佈式場景 需要引入zookeeper,增加系統複雜度,比較複雜 通過獲取數據庫鎖的方式 通過獲取數據庫鎖的方式,保證集羣中執行任務的唯一性,性能不好

quartz和xxl-job對比:

  • quartz採用api的方式調用任務,quartz不方便,但是xxl-job使用的是管理界面。
  • quartz比xxl-job代碼侵入更強
  • quartz調度邏輯和QuartzJobBean耦合在一個項目中,當任務增多,邏輯複雜的時候,性能會受到影響
  • quartz底層以搶佔式獲取db鎖並且由搶佔成功的節點運行,導致節點負載懸殊非常大;xxl-job通過執行器實現協同分配式運行任務,各個節點比較均衡。

方式一:手工部署XXL-Job 的5大步驟

步驟一:部署 XXL-Job

  1. 下載 XXL-Job: 訪問 XXL-Job 的官方網站(https://github.com/xuxueli/xxl-job/)下載最新版本的 XXL-Job。
  2. 解壓文件: 將下載的壓縮包解壓到你選擇的目錄中。
  3. 配置數據庫: 進入解壓後的目錄,找到 conf/application.properties 文件,配置數據庫連接信息,包括數據庫地址、用戶名、密碼等。
  4. 創建數據庫表: 運行 docs/sql/tables_xxl_job.sql 文件中的 SQL 腳本,在數據庫中創建 XXL-Job 所需的表結構。
  5. 啓動服務: 運行 bin/startup.sh(Linux/Mac)或 bin/startup.cmd(Windows)啓動 XXL-Job 服務。

安裝要點:初始化“調度數據庫”

下載 XXL-Job: 訪問 XXL-Job 的官方網站(https://github.com/xuxueli/xxl-job/)下載最新版本的 XXL-Job。

解壓,獲取 “調度數據庫初始化SQL腳本” 並執行即可。

“調度數據庫初始化SQL腳本” 位置爲:

/xxl-job/doc/db/tables_xxl_job.sql

調度中心支持集羣部署,集羣情況下各節點務必連接同一個mysql實例;

如果mysql做主從,調度中心集羣節點務必強制走主庫;

步驟二:訪問管理界面

  1. 打開瀏覽器,輸入 XXL-Job 的訪問地址,默認爲 http://localhost:8080/xxl-job-admin
  2. 使用默認的管理員賬號和密碼登錄,默認賬號密碼均爲 admin
  3. 登錄成功後,可以在管理界面中進行任務的創建、管理、監控等操作。

步驟三:創建任務

  1. 在管理界面中,點擊左側菜單欄的 "任務管理",然後點擊 "新增" 按鈕。
  2. 填寫任務信息,包括任務描述、任務執行器、任務參數等。
  3. 點擊 "保存" 按鈕完成任務的創建。

步驟四:監控任務執行情況

  1. 在管理界面中,點擊左側菜單欄的 "任務監控",可以查看任務的執行情況、調度情況、日誌等信息。
  2. 可以手動觸發任務執行,也可以查看任務的執行歷史記錄。

步驟五:擴展其他功能

除了基本的任務管理和監控功能外,XXL-Job 還提供了更多高級功能,如分片廣播任務、GLUE腳本任務、任務執行日誌清理等。你可以根據實際需求,進一步瞭解和使用這些功能。

以上就是 XXL-Job 的快速入門指南,希望能夠幫助你快速上手並使用 XXL-Job 進行任務調度管理。

方式二:通過 編譯源碼部署 XXL-Job 的2大步驟

下載 XXL-Job:訪問 XXL-Job 的官方網站(https://github.com/xuxueli/xxl-job/)下載最新版本的 XXL-Job。

步驟1:解壓的編譯源碼

解壓源碼,按照maven格式將源碼導入IDE, 使用maven進行編譯即可,

源碼結構如下:

xxl-job-admin:調度中心
xxl-job-core:公共依賴
xxl-job-executor-samples:執行器Sample示例(選擇合適的版本執行器,可直接使用,也可以參考其並將現有項目改造成執行器)

xxl-job-executor-sample-springboot:Springboot版本,通過Springboot管理執行器,推薦這種方式;

xxl-job-executor-sample-frameless:無框架版本;

步驟2:配置部署“調度中心”

調度中心作用:統一管理任務調度平臺上調度任務,負責觸發調度執行,並且提供任務管理平臺。

調度中心項目:xxl-job-admin

1:調度中心配置:

調度中心配置文件地址:

/xxl-job/xxl-job-admin/src/main/resources/application.properties

調度中心配置內容說明:

### 調度中心JDBC鏈接:鏈接地址請保持和 2.1章節 所創建的調度數據庫的地址一致
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/xxl_job?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai
spring.datasource.username=root
spring.datasource.password=root_pwd
spring.datasource.driver-class-name=com.mysql.jdbc.Driver

### 報警郵箱
spring.mail.host=smtp.qq.com
spring.mail.port=25
[email protected]
spring.mail.password=xxx
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true
spring.mail.properties.mail.smtp.starttls.required=true
spring.mail.properties.mail.smtp.socketFactory.class=javax.net.ssl.SSLSocketFactory

### 調度中心通訊TOKEN [選填]:非空時啓用;
xxl.job.accessToken=

### 調度中心國際化配置 [必填]: 默認爲 "zh_CN"/中文簡體, 可選範圍爲 "zh_CN"/中文簡體, "zh_TC"/中文繁體 and "en"/英文;
xxl.job.i18n=zh_CN

## 調度線程池最大線程配置【必填】
xxl.job.triggerpool.fast.max=200
xxl.job.triggerpool.slow.max=100

### 調度中心日誌表數據保存天數 [必填]:過期日誌自動清理;限制大於等於7時生效,否則, 如-1,關閉自動清理功能;
xxl.job.logretentiondays=30

2:部署調度項目:

如果已經正確進行上述配置,可將項目編譯打包部署。

調度中心訪問地址:http://localhost:8080/xxl-job-admin (該地址執行器將會使用到,作爲回調地址)

默認登錄賬號 “admin/123456”, 登錄後運行界面如下圖所示。

image.png

至此“調度中心”項目已經部署成功。

3:調度中心集羣部署(可選):

調度中心支持集羣部署,提升調度系統容災和可用性。

調度中心集羣部署時,幾點要求和建議:

  • DB配置保持一致;
  • 集羣機器時鐘保持一致(單機集羣忽視);

建議:

  • 推薦通過nginx爲調度中心集羣做負載均衡。

  • 通過nginx代理的地址,去進行 調度中心訪問、執行器回調配置、調用API服務等操作。

方式三:通過Docker 鏡像方式搭建調度中心

  • 下載鏡像
// Docker地址:https://hub.docker.com/r/xuxueli/xxl-job-admin/     (建議指定版本號)
docker pull xuxueli/xxl-job-admin
  • 創建容器並運行
/**
* 如需自定義 mysql 等配置,可通過 "-e PARAMS" 指定,參數格式 PARAMS="--key=value  --key2=value2" ;
* 配置項參考文件:/xxl-job/xxl-job-admin/src/main/resources/application.properties
* 如需自定義 JVM內存參數 等配置,可通過 "-e JAVA_OPTS" 指定,參數格式 JAVA_OPTS="-Xmx512m" ;
*/
docker run -e PARAMS="--spring.datasource.url=jdbc:mysql://127.0.0.1:3306/xxl_job?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai" -p 8080:8080 -v /tmp:/data/applogs --name xxl-job-admin  -d xuxueli/xxl-job-admin:{指定版本}

開發、配置、部署 XXl-JOB“執行器項目”

XXL-JOB 的 "執行器項目" 是指用於執行任務的項目,它是 XXL-JOB 的一部分,負責接收並執行任務調度中心下發的任務。執行器項目通常是一個獨立的 Java 項目,可以與 XXL-JOB 調度中心進行通信,接收任務執行請求,並執行相應的任務邏輯。

執行器項目的主要作用包括:

  1. 任務執行: 接收調度中心下發的任務執行請求,並執行相應的任務邏輯,例如調用接口、執行業務邏輯、發送消息等。
  2. 任務註冊: 將執行器項目註冊到 XXL-JOB 的調度中心,以便調度中心能夠向執行器發送任務執行請求。
  3. 執行器管理: 提供執行器項目的管理功能,包括啓動、停止、監控等。

“執行器”項目:

xxl-job-executor-sample-springboot (提供多種版本執行器供選擇,現以 springboot 版本爲例,可直接使用,也可以參考其並將現有項目改造成執行器)

可直接部署執行器,也可以將執行器集成到現有業務項目中。

下面是執行器項目的一般開發步驟:

  1. 創建執行器項目: 創建一個新的 Java 項目,作爲執行器項目。
  2. 引入 XXL-JOB 客戶端庫: 在項目中引入 XXL-JOB 客戶端庫,可以通過 Maven 或 Gradle 等依賴管理工具進行引入。
  3. 配置執行器參數: 在項目中配置執行器的參數,包括執行器名稱、註冊地址、執行器端口等。
  4. 編寫任務執行邏輯: 編寫任務執行器的邏輯代碼,接收任務執行請求並執行相應的任務邏輯。你可以根據任務類型和具體業務需求來編寫不同的任務執行邏輯。
  5. 註冊執行器: 在執行器項目中註冊執行器到 XXL-JOB 的調度中心,以便調度中心能夠向執行器發送任務執行請求。
  6. 啓動執行器: 啓動執行器項目,讓執行器能夠正常接收和執行任務。

步驟2:maven依賴

確認pom文件中引入了 “xxl-job-core” 的maven依賴;

步驟3:執行器配置

執行器配置,配置文件地址:

/xxl-job/xxl-job-executor-samples/xxl-job-executor-sample-springboot/src/main/resources/application.properties

執行器配置,配置內容說明:

### 調度中心部署根地址 [選填]:如調度中心集羣部署存在多個地址則用逗號分隔。執行器將會使用該地址進行"執行器心跳註冊"和"任務結果回調";爲空則關閉自動註冊;
xxl.job.admin.addresses=http://127.0.0.1:8080/xxl-job-admin

### 執行器通訊TOKEN [選填]:非空時啓用;
xxl.job.accessToken=

### 執行器AppName [選填]:執行器心跳註冊分組依據;爲空則關閉自動註冊
xxl.job.executor.appname=xxl-job-executor-sample
### 執行器註冊 [選填]:優先使用該配置作爲註冊地址,爲空時使用內嵌服務 ”IP:PORT“ 作爲註冊地址。從而更靈活的支持容器類型執行器動態IP和動態映射端口問題。
xxl.job.executor.address=
### 執行器IP [選填]:默認爲空表示自動獲取IP,多網卡時可手動設置指定IP,該IP不會綁定Host僅作爲通訊實用;地址信息用於 "執行器註冊" 和 "調度中心請求並觸發任務";
xxl.job.executor.ip=
### 執行器端口號 [選填]:小於等於0則自動獲取;默認端口爲9999,單機部署多個執行器時,注意要配置不同執行器端口;
xxl.job.executor.port=9999
### 執行器運行日誌文件存儲磁盤路徑 [選填] :需要對該路徑擁有讀寫權限;爲空則使用默認路徑;
xxl.job.executor.logpath=/data/applogs/xxl-job/jobhandler
### 執行器日誌文件保存天數 [選填] : 過期日誌自動清理, 限制值大於等於3時生效; 否則, 如-1, 關閉自動清理功能;
xxl.job.executor.logretentiondays=30

執行器組件配置 的加載,在下面XxlJobConfig ,路徑如

/xxl-job/xxl-job-executor-samples/xxl-job-executor-sample-springboot/src/main/java/com/xxl/job/executor/core/config/XxlJobConfig.java

XxlJobConfig 執行器組件,配置內容說明:

@Bean
public XxlJobSpringExecutor xxlJobExecutor() {
    logger.info(">>>>>>>>>>> xxl-job config init.");
    XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();
    xxlJobSpringExecutor.setAdminAddresses(adminAddresses);
    xxlJobSpringExecutor.setAppname(appname);
    xxlJobSpringExecutor.setIp(ip);
    xxlJobSpringExecutor.setPort(port);
    xxlJobSpringExecutor.setAccessToken(accessToken);
    xxlJobSpringExecutor.setLogPath(logPath);
    xxlJobSpringExecutor.setLogRetentionDays(logRetentionDays);

    return xxlJobSpringExecutor;
}

步驟4:編寫任務執行邏輯

編寫任務執行器的邏輯代碼,接收任務執行請求並執行相應的任務邏輯。

可以根據任務類型和具體業務需求來編寫不同的任務執行邏輯。

下面是源碼裏邊參考的執行器

步驟5:部署執行器項目:

如果已經正確進行上述配置,可將執行器項目編譯打部署,系統提供多種執行器Sample示例項目,選擇其中一個即可,各自的部署方式如下。

xxl-job-executor-sample-springboot:項目編譯打包成springboot類型的可執行JAR包,命令啓動即可;
xxl-job-executor-sample-frameless:項目編譯打包成JAR包,命令啓動即可;

至此“執行器”項目已經部署結束。

步驟6:執行器集羣(可選):

執行器支持集羣部署,提升調度系統可用性,同時提升任務處理能力。

執行器集羣部署時,幾點要求和建議:

  • 執行器回調地址(xxl.job.admin.addresses)需要保持一致;執行器根據該配置進行執行器自動註冊等操作。
  • 同一個執行器集羣內AppName(xxl.job.executor.appname)需要保持一致;調度中心根據該配置動態發現不同集羣的在線執行器列表。

開發第一個任務“Hello World”

XXL-Job 幾種不同的任務模式

XXL-Job 提供了幾種不同的任務模式,用於滿足不同場景下的任務調度需求。以下是 XXL-Job 的幾種任務模式:

  1. Bean模式(Spring Bean模式): Bean 模式是 XXL-Job 最簡單的任務模式之一,適用於基於 Spring 框架開發的應用。在 Bean 模式下,任務執行邏輯由一個 Spring Bean 實現,並通過 Spring 的依賴注入機制來管理任務實例。在 XXL-Job 的任務配置中,需要指定任務執行的 Bean 名稱和方法名稱。
  2. Script模式(GLUE腳本模式): Script 模式是 XXL-Job 提供的另一種靈活的任務模式,允許用戶通過腳本來定義任務執行邏輯。在 Script 模式下,任務執行邏輯可以使用多種腳本語言編寫,如 Java、Python、Shell 等。用戶可以在 XXL-Job 的任務配置中直接編寫腳本代碼,而無需提前編譯和打包。
  3. Remote模式(遠程調用模式): Remote 模式允許用戶將任務執行邏輯部署在獨立的遠程服務上,並通過遠程調用的方式來執行任務。在 Remote 模式下,XXL-Job 調度中心發送任務執行請求到遠程服務,遠程服務接收請求並執行任務邏輯,然後返回執行結果給調度中心。這種模式適用於任務邏輯較爲複雜或需要與其他系統進行交互的情況。
  4. Shell模式(Shell腳本模式): Shell 模式是一種簡單的任務執行模式,適用於需要執行 Shell 腳本的場景。在 Shell 模式下,XXL-Job 的任務配置中直接指定要執行的 Shell 命令或腳本文件路徑,XXL-Job 調度中心將執行任務的請求發送給執行器,並在執行器上執行相應的 Shell 命令。

每種任務模式都有其適用的場景和特點,用戶可以根據實際需求選擇合適的任務模式來實現任務調度和執行功能。

“Bean模式任務”需要在執行器項目開發部署上線, 然後接收調度命令進行調度

“GLUE模式(Java)”的執行代碼託管到調度中心在線維護, 用戶可以在 XXL-Job 的任務配置中直接編寫腳本代碼,而無需提前編譯和打包,直接在調度節點解釋執行。

xxl-job的GLUE模式,包括下面的 子模式:

  1. GLUE模式(Java):任務以源碼方式維護在調度中心;該模式的任務實際上是一段繼承自IJobHandler的Java類代碼並"groovy"源碼方式維護,它在執行器項目中運行,可使用@Resource/@Autowire注入執行器裏中的其他服務;
  2. GLUE模式(Shell):任務以源碼方式維護在調度中心;該模式的任務實際上是一段"shell"腳本;
  3. GLUE模式(Python):任務以源碼方式維護在調度中心;該模式的任務實際上是一段"python"腳本;
  4. GLUE模式(PHP):任務以源碼方式維護在調度中心;該模式的任務實際上是一段"php"腳本;
  5. GLUE模式(NodeJS):任務以源碼方式維護在調度中心;該模式的任務實際上是一段"nodejs"腳本;
  6. GLUE模式(PowerShell):任務以源碼方式維護在調度中心;該模式的任務實際上是一段"PowerShell"腳本;

“GLUE模式(Java)” 運行模式的開發第一個調度任務

總之,相比來說,“GLUE模式(Java)”更加簡便輕量,適合調試。

本示例以新建一個 “GLUE模式(Java)” 運行模式的任務爲例。

前提:請確認“調度中心”和“執行器”項目已經成功部署並啓動;

步驟一:新建任務:

登錄調度中心,點擊下圖所示“新建任務”按鈕,新建示例任務。

然後,參考下面截圖中任務的參數配置,點擊保存。
image.png
image.png

步驟二:“GLUE模式(Java)” 任務開發:

請點擊任務右側 “GLUE” 按鈕,進入 “GLUE編輯器開發界面” ,見下圖。

“GLUE模式(Java)” 運行模式的任務默認已經初始化了示例任務代碼,即打印Hello World。

“GLUE模式(Java)” 運行模式的任務實際上是一段繼承自IJobHandler的Java類代碼,它在執行器項目中運行,

image.png
image.png

實際上, “GLUE模式(Java)” 使用@Resource/@Autowire注入執行器裏中的其他服務

步驟三:觸發執行:

請點擊任務右側 “執行” 按鈕,可手動觸發一次任務執行(通常情況下,通過配置Cron表達式進行任務調度觸發)。

步驟四:查看日誌:

請點擊任務右側 “日誌” 按鈕,可前往任務日誌界面查看任務日誌。
在任務日誌界面中,可查看該任務的歷史調度記錄以及每一次調度的任務調度信息、執行參數和執行信息。運行中的任務點擊右側的“執行日誌”按鈕,可進入日誌控制檯查看實時執行日誌。
image.png

在日誌控制檯,可以Rolling方式實時查看任務在執行器一側運行輸出的日誌信息,實時監控任務進度;

image.png

大規模任務的分片調度

用一張圖來描述大規模任務的分片調度
image.png

調度中心會向當前任務的所有執行器節點都發起一個調度請求,並且帶上分片參數。

執行器在收到請求之後,可以獲取執行器的 index的值,以不同index的值來做分片策略。

image.png

對應的任務代碼:

    static Map<Integer, String> singleMachineMultiTasks = new HashMap<>();

    static {
        singleMachineMultiTasks.put(1, "武漢");
        singleMachineMultiTasks.put(2, "222");
        singleMachineMultiTasks.put(3, "北京");
        singleMachineMultiTasks.put(4, "444");
        singleMachineMultiTasks.put(5, "上海");
        singleMachineMultiTasks.put(6, "666");
    }

    /**
     * 分片任務
     * @return
     * @throws Exception
     */
    @XxlJob(value = "multiMachineMultiTasks", init = "init", destroy = "destroy")
    public ReturnT<String> multiMachineMultiTasks() throws Exception {
        String params =  XxlJobHelper.getJobParam();

        int n = XxlJobHelper.getShardTotal(); // 動態獲取所有實例數
        int i = XxlJobHelper.getShardIndex(); // 當前爲第i個序號

        List<Integer> ids =  singleMachineMultiTasks.keySet().stream().collect(Collectors.toList());

        IntStream.range(0, ids.size()).forEach(index -> {
            //使用取餘分片
            if (index % n == i) {
                int cityIndex = ids.get(index);
                String city = singleMachineMultiTasks.get(cityIndex);
                XxlJobHelper.log("實例【{}】執行【{}】,任務內容爲:{}", i, cityIndex, city);
            }
        });
        return ReturnT.SUCCESS;
    }

執行結果:

image.png

image.png

XXL-JOB的配置屬性說明

  1. 基礎配置:
  • 執行器:任務的綁定的執行器,任務觸發調度時將會自動發現註冊成功的執行器, 實現任務自動發現功能; 另一方面也可以方便的進行任務分組。每個任務必須綁定一個執行器, 可在 "執行器管理" 進行設置;
  • 任務描述:任務的描述信息,便於任務管理;
  • 負責人:任務的負責人;
  • 報警郵件:任務調度失敗時郵件通知的郵箱地址,支持配置多郵箱地址,配置多個郵箱地址時用逗號分隔;
  1. 觸發配置:
  • 調度類型:
    • 無:該類型不會主動觸發調度;
    • CRON:該類型將會通過CRON,觸發任務調度;
    • 固定速度:該類型將會以固定速度,觸發任務調度;按照固定的間隔時間,週期性觸發;
    • 固定延遲:該類型將會以固定延遲,觸發任務調度;按照固定的延遲時間,從上次調度結束後開始計算延遲時間,到達延遲時間後觸發下次調度;
  • CRON:觸發任務執行的Cron表達式;
  • 固定速度:固定速度的時間間隔,單位爲秒;
  • 固定延遲:固定延遲的時間間隔,單位爲秒;
  1. 任務配置:
  • 運行模式:
    • BEAN模式:以JobHandler方式維護在執行器端;需要結合 "JobHandler" 屬性匹配執行器中任務;
    • GLUE模式(Java):任務以源碼方式維護在調度中心;該模式的任務實際上是一段繼承自IJobHandler的Java類代碼並 "groovy" 源碼方式維護,它在執行器項目中運行,可使用@Resource/@Autowire注入執行器裏中的其他服務;
    • GLUE模式(Shell):任務以源碼方式維護在調度中心;該模式的任務實際上是一段 "shell" 腳本;
    • GLUE模式(Python):任務以源碼方式維護在調度中心;該模式的任務實際上是一段 "python" 腳本;
    • GLUE模式(PHP):任務以源碼方式維護在調度中心;該模式的任務實際上是一段 "php" 腳本;
    • GLUE模式(NodeJS):任務以源碼方式維護在調度中心;該模式的任務實際上是一段 "nodejs" 腳本;
    • GLUE模式(PowerShell):任務以源碼方式維護在調度中心;該模式的任務實際上是一段 "PowerShell" 腳本;
  • JobHandler:運行模式爲 "BEAN模式" 時生效,對應執行器中新開發的JobHandler類“@JobHandler”註解自定義的value值;
  • 執行參數:任務執行所需的參數;
  1. 高級配置:
  • 路由策略:當執行器集羣部署時,提供豐富的路由策略,包括;
    • FIRST(第一個):固定選擇第一個機器;
    • LAST(最後一個):固定選擇最後一個機器;
    • ROUND(輪詢):;
    • RANDOM(隨機):隨機選擇在線的機器;
    • CONSISTENT_HASH(一致性HASH):每個任務按照Hash算法固定選擇某一臺機器,且所有任務均勻散列在不同機器上。
    • LEAST_FREQUENTLY_USED(最不經常使用):使用頻率最低的機器優先被選舉;
    • LEAST_RECENTLY_USED(最近最久未使用):最久未使用的機器優先被選舉;
    • FAILOVER(故障轉移):按照順序依次進行心跳檢測,第一個心跳檢測成功的機器選定爲目標執行器併發起調度;
    • BUSYOVER(忙碌轉移):按照順序依次進行空閒檢測,第一個空閒檢測成功的機器選定爲目標執行器併發起調度;
    • SHARDING_BROADCAST(分片廣播):廣播觸發對應集羣中所有機器執行一次任務,同時系統自動傳遞分片參數;可根據分片參數開發分片任務;
  • 子任務:每個任務都擁有一個唯一的任務ID(任務ID可以從任務列表獲取),當本任務執行結束並且執行成功時,將會觸發子任務ID所對應的任務的一次主動調度。
  • 調度過期策略:
    • 忽略:調度過期後,忽略過期的任務,從當前時間開始重新計算下次觸發時間;
    • 立即執行一次:調度過期後,立即執行一次,並從當前時間開始重新計算下次觸發時間;
  • 阻塞處理策略:調度過於密集執行器來不及處理時的處理策略;
    • 單機串行(默認):調度請求進入單機執行器後,調度請求進入FIFO隊列並以串行方式運行;
    • 丟棄後續調度:調度請求進入單機執行器後,發現執行器存在運行的調度任務,本次請求將會被丟棄並標記爲失敗;
    • 覆蓋之前調度:調度請求進入單機執行器後,發現執行器存在運行的調度任務,將會終止運行中的調度任務並清空隊列,然後運行本地調度任務;
  • 任務超時時間:支持自定義任務超時時間,任務運行超時將會主動中斷任務;
  • 失敗重試次數;支持自定義任務失敗重試次數,當任務失敗時將會按照預設的失敗重試次數主動進行重試;

XXL-JOB實現原理

XXL-JOB 的實現原理主要涉及到調度中心和執行器兩個核心組件的工作原理:

1. 調度中心(JobAdmin):

  • 任務管理: 調度中心負責管理所有註冊的任務信息,包括任務的配置、調度策略、執行器信息等。
  • 任務調度: 調度中心根據配置的調度策略,定時觸發任務的執行請求,並將執行請求發送給相應的執行器。
  • 任務監控: 調度中心實時監控任務的執行情況,包括任務的執行狀態、執行日誌、執行結果等,提供給用戶進行查看和管理。

2. 執行器(JobExecutor):

  • 任務接收: 執行器接收調度中心下發的任務執行請求,並根據任務配置執行相應的任務邏輯。
  • 任務執行: 執行器執行任務邏輯,包括調用業務接口、執行腳本、執行 Shell 命令等,根據任務執行結果將執行結果返回給調度中心。
  • 任務線程池: 執行器維護一個任務線程池,用於執行任務邏輯。任務線程池的大小、任務執行策略等可以根據實際情況進行配置。

工作流程:

  1. 任務註冊: 執行器在啓動時向調度中心註冊自己的信息,包括執行器名稱、IP地址、端口號等。
  2. 任務調度: 調度中心根據任務的調度策略,定時觸發任務的執行請求,並將執行請求發送給相應的執行器。
  3. 任務執行: 執行器接收到任務執行請求後,根據任務配置執行相應的任務邏輯,並將執行結果返回給調度中心。
  4. 任務監控: 調度中心實時監控任務的執行情況,記錄任務的執行日誌和執行結果,提供給用戶進行查看和管理。

XXL-JOB 通過調度中心和執行器的協作,實現了任務的調度、執行和監控功能,爲用戶提供了一個簡單易用的任務調度平臺。

XXL-JOB 系統總體架構

如果自研一個xxl-job分析如下:

時序圖:
image.png

xxl-job架構圖
image.png
image.png
image.png

JobAdmin服務器啓動流程

XXL-JOB 的 JobAdmin 服務器啓動流程如下:

  1. 加載配置文件: JobAdmin 服務器啓動時,首先加載配置文件,包括數據庫連接配置、任務調度配置等。
  2. 初始化數據庫: JobAdmin 服務器會連接數據庫,並初始化數據庫表結構。如果數據庫中已經存在 XXL-JOB 相關的表結構,則會檢查表結構是否正確,如果表結構不正確,則會進行修復或升級。
  3. 啓動任務調度: JobAdmin 服務器啓動時,會啓動任務調度器,定時觸發任務的執行請求。任務調度器根據配置的調度策略,定時觸發任務的執行請求,並將執行請求發送給相應的執行器。
  4. 註冊執行器: JobAdmin 服務器會嘗試連接註冊在註冊中心的執行器,並獲取執行器的信息,包括執行器的名稱、IP地址、端口號等。如果註冊中心中不存在任何執行器,則 JobAdmin 服務器會等待執行器的註冊。
  5. 啓動Web服務: JobAdmin 服務器啓動後,會提供 Web 服務,包括任務管理、任務監控等功能。用戶可以通過瀏覽器訪問 JobAdmin 的 Web 界面,進行任務的配置、管理和監控。
  6. 啓動日誌服務: JobAdmin 服務器啓動後,會啓動日誌服務,負責記錄任務的執行日誌和執行結果。用戶可以通過 Web 界面查看任務的執行日誌和執行結果。
  7. 定時檢查任務狀態: JobAdmin 服務器會定時檢查任務的狀態,包括任務的執行狀態、執行結果等。如果發現任務執行異常或超時,則會記錄相應的日誌,併發送告警通知給相關人員。
  8. 提供服務: JobAdmin 服務器啓動完成後,開始提供服務,用戶可以通過 Web 界面進行任務的配置、管理和監控。

以上是 JobAdmin 服務器啓動流程的主要步驟,通過這些步驟,JobAdmin 服務器能夠正常運行,並提供任務調度和管理功能。

JobAdmin服務器初始化總體流程

首先找到配置類 XxlJobAdminConfig
image.png
可以發現該類實現 InitializingBean接口,這裏直接看 afterPropertiesSet方法即可。

    @Override
    public void afterPropertiesSet() throws Exception {
        //利用靜態聲明的只會加載一次的特性,初始化一個單例對象。
        adminConfig = this;

        //初始化xxjob調度器
        xxlJobScheduler = new XxlJobScheduler();
        xxlJobScheduler.init();
    }

初始化xxjob調度器中心用到的各個組件

    public void init() throws Exception {
        //1. init i18n
        initI18n();

        //2. admin trigger pool start 初始化下發任務(觸發器)線程池
        JobTriggerPoolHelper.toStart();

        //3. admin registry monitor run  初始化註冊中心線程池,心跳30秒一次
        JobRegistryHelper.getInstance().start();

        //4. admin lose-monitor run ( depend on JobTriggerPoolHelper ) 初始化失敗重試和告警(發郵件)線程,間隔10s執行一次
        JobFailMonitorHelper.getInstance().start();

        //5. admin lose-monitor run ( depend on JobTriggerPoolHelper ) 初始化回調線程池,初始化監控線程,記錄運行了10分鐘,執行器不在線的任務日誌
        JobCompleteHelper.getInstance().start();

        //6. admin log report start  6. 初始化統計日誌線程,間隔1分鐘執行一次,統計3天日誌,應該是管控臺要用,如果設置了日誌保留天數,清除日誌
        JobLogReportHelper.getInstance().start();

        // start-schedule  ( depend on JobTriggerPoolHelper ) 執行調度器
        JobScheduleHelper.getInstance().start();

        logger.info(">>>>>>>>> init xxl-job admin success.");
    }

該方法主要做了如下事情:

  1. 初始化 i18n
  2. 初始化下發任務(觸發器)線程池
  3. 初始化註冊中心線程池,心跳30秒一次
  4. 初始化失敗重試和告警(發郵件)線程,間隔10s執行一次
  5. 初始化回調線程池,初始化監控線程,記錄運行了10分鐘,執行器不在線的任務日誌
  6. 初始化統計日誌線程,間隔1分鐘執行一次,統計3天日誌,應該是管控臺要用,如果設置了日誌保留天數,清除日誌
  7. 執行調度器(下發任務)

初始化JobTriggerPool觸發器線程池

XXL-JOB 的 JobTriggerPool 是用於觸發任務執行請求的線程池,它負責根據任務的調度策略定時觸發任務的執行,並將任務執行請求發送給相應的執行器。以下是 JobTriggerPool 觸發器線程池的主要功能和特點:

  1. 任務調度觸發: JobTriggerPool 定期觸發任務的執行請求,根據任務的調度策略,按照設定的觸發頻率或觸發時間點觸發任務執行。
  2. 靈活配置: JobTriggerPool 的參數可以通過配置文件進行靈活配置,包括線程池大小、調度頻率、調度策略等,以滿足不同場景下的任務調度需求。
  3. 併發執行: JobTriggerPool 支持併發執行多個任務,根據任務的調度情況,同時觸發多個任務的執行請求,提高任務執行效率。
  4. 可靠性保障: JobTriggerPool 實現了任務調度的可靠性保障機制,保證任務的準時觸發和執行,同時支持任務調度的容錯和重試功能,確保任務的可靠執行。
  5. 監控和管理: JobTriggerPool 提供了監控和管理功能,可以實時監控觸發器線程池的運行狀態和任務執行情況,包括線程池的活躍線程數、任務調度情況、任務執行結果等。
  6. 異常處理: JobTriggerPool 實現了異常處理機制,當觸發器線程池發生異常時,可以進行相應的異常處理,如記錄日誌、發送告警等,保證系統的穩定運行。

JobTriggerPool 觸發器線程池能夠有效地實現任務的定時調度和觸發,保證任務的可靠執行,並提供監控和管理功能,幫助用戶更好地管理和運維任務調度系統。

    public static void toStart() {
        helper.start();
    }
 public void start(){
        //最大200線程,最多處理1000任務
        fastTriggerPool = new ThreadPoolExecutor(
                10,
                XxlJobAdminConfig.getAdminConfig().getTriggerPoolFastMax(),
                60L,
                TimeUnit.SECONDS,
                new LinkedBlockingQueue<Runnable>(1000),
                new ThreadFactory() {
                    @Override
                    public Thread newThread(Runnable r) {
                        return new Thread(r, "xxl-job, admin JobTriggerPoolHelper-fastTriggerPool-" + r.hashCode());
                    }
                });

        //最大100線程,最多處理2000任務
        //一分鐘內超時10次,則採用慢觸發器執行
        slowTriggerPool = new ThreadPoolExecutor(
                10,
                XxlJobAdminConfig.getAdminConfig().getTriggerPoolSlowMax(),
                60L,
                TimeUnit.SECONDS,
                new LinkedBlockingQueue<Runnable>(2000),
                new ThreadFactory() {
                    @Override
                    public Thread newThread(Runnable r) {
                        return new Thread(r, "xxl-job, admin JobTriggerPoolHelper-slowTriggerPool-" + r.hashCode());
                    }
                });
    }

注意:這裏分別初始化了2個線程池,一個快一個慢,

優先選擇快,當一分鐘以內任務超過10次執行時間超過500ms,則加入慢線程池執行。

初始化registryOrRemoveThreadPool 線程池,剔除超時主機,更新主機列表

XXL-JOB 中的 registryOrRemoveThreadPool 線程池主要用於執行器的註冊和註銷操作。下面是它的主要功能和特點:

  1. 執行器註冊: 當執行器啓動時,會向調度中心註冊自己的信息,包括執行器的名稱、IP 地址、端口號等。registryOrRemoveThreadPool 線程池負責處理執行器註冊的請求,確保註冊操作的可靠性和及時性。
  2. 執行器註銷: 當執行器停止或下線時,需要向調度中心註銷自己的信息,以便調度中心不再向該執行器發送任務執行請求。registryOrRemoveThreadPool 線程池也負責處理執行器註銷的請求,確保註銷操作的及時性和有效性。
  3. 併發處理: registryOrRemoveThreadPool 線程池支持併發處理多個註冊和註銷請求,根據系統的負載情況和註冊/註銷請求的數量,動態調整線程池的大小,以提高系統的併發處理能力。
  4. 任務調度保障: 由於執行器的註冊和註銷操作是任務調度的關鍵步驟之一,registryOrRemoveThreadPool 線程池的穩定運行對於保障任務調度的正常進行非常重要。它保證了註冊和註銷操作的及時處理,確保任務調度系統的可靠性和穩定性。
  5. 異常處理: registryOrRemoveThreadPool 線程池實現了異常處理機制,當註冊或註銷操作發生異常時,會進行相應的異常處理,如記錄日誌、發送告警等,保證系統的穩定運行。

通過以上功能和特點,registryOrRemoveThreadPool 線程池在 XXL-JOB 中起着至關重要的作用,它保證了執行器註冊和註銷操作的可靠性和及時性,是任務調度系統的重要組成部分。

初始化registryOrRemoveThreadPool 線程池,剔除超時主機,更新主機列表,這裏主要做3個工作,然後啓動線程剔除超時主機,更新主機列表

  1. 初始化註冊或者刪除線程池,主要負責客戶端註冊或者銷燬到xxl_job_registry表
  2. 剔除超時註冊機器
  3. 更新xxl_job_group執行器地址列表
public void start(){

		// for registry or remove
		//初始化註冊或者刪除線程池
		registryOrRemoveThreadPool = new ThreadPoolExecutor(
				2,
				10,
				30L,
				TimeUnit.SECONDS,
				new LinkedBlockingQueue<Runnable>(2000),
				new ThreadFactory() {
					@Override
					public Thread newThread(Runnable r) {
						return new Thread(r, "xxl-job, admin JobRegistryMonitorHelper-registryOrRemoveThreadPool-" + r.hashCode());
					}
				},
				//注意:這裏的拒絕策略就是再次執行...^_^'''
				new RejectedExecutionHandler() {
					@Override
					public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
						r.run();
						logger.warn(">>>>>>>>>>> xxl-job, registry or remove too fast, match threadpool rejected handler(run now).");
					}
				});

		// for monitor    30秒執行一次,維護註冊表信息, 判斷在線超時時間90s
		registryMonitorThread = new Thread(new Runnable() {
			@Override
			public void run() {
				while (!toStop) {
					try {
						// auto registry group
						//查詢自動註冊的數據
						//這裏如果沒添加自動註冊的數據,則不會進入該方法,然後刪除register表中超時註冊數據。
						List<XxlJobGroup> groupList = XxlJobAdminConfig.getAdminConfig().getXxlJobGroupDao().findByAddressType(0);
						if (groupList!=null && !groupList.isEmpty()) {

							// remove dead address (admin/executor)
							// 1):從註冊表中刪除超時90s的機器,不分是否自動註冊
							List<Integer> ids = XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().findDead(RegistryConfig.DEAD_TIMEOUT, new Date());
							if (ids!=null && ids.size()>0) {
								//從數據庫刪除註冊機器信息
								XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().removeDead(ids);
							}

							// fresh online address (admin/executor)
							// 獲取所有在線機器,註冊表: 見"xxl_job_registry"表, "執行器" 在進行任務註冊時將會週期性維護一條註冊記錄,
							// 即機器地址和AppName的綁定關係; "調度中心" 從而可以動態感知每個AppName在線的機器列表;
							HashMap<String, List<String>> appAddressMap = new HashMap<String, List<String>>();  //維護註冊表註冊key和註冊value
							// 不分是否自動註冊
							List<XxlJobRegistry> list = XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().findAll(RegistryConfig.DEAD_TIMEOUT, new Date());
							if (list != null) {
								for (XxlJobRegistry item: list) {
									// 2):將註冊類型爲EXECUTOR的XxlJobRegistry集合改裝成appname=>設置觸發器的ip地址
									if (RegistryConfig.RegistType.EXECUTOR.name().equals(item.getRegistryGroup())) {
										//AppName: 每個執行器機器集羣的唯一標示, 任務註冊以 "執行器" 爲最小粒度進行註冊; 每個任務通過其綁定的執行器可感知對應的執行器機器列表;
										String appname = item.getRegistryKey();
										List<String> registryList = appAddressMap.get(appname);
										if (registryList == null) {
											registryList = new ArrayList<String>();
										}

										if (!registryList.contains(item.getRegistryValue())) {
											registryList.add(item.getRegistryValue());
										}
										appAddressMap.put(appname, registryList);
									}
								}
							}

							// fresh group address
							// 3):更新xxl_job_group執行器地址列表
							for (XxlJobGroup group: groupList) {
								List<String> registryList = appAddressMap.get(group.getAppname());
								String addressListStr = null;//將所有配置觸發器的ip地址,使用,拼接
								if (registryList!=null && !registryList.isEmpty()) {
									Collections.sort(registryList);
									StringBuilder addressListSB = new StringBuilder();
									for (String item:registryList) {
										addressListSB.append(item).append(",");
									}
									addressListStr = addressListSB.toString();
									addressListStr = addressListStr.substring(0, addressListStr.length()-1);
								}
								group.setAddressList(addressListStr);//更新設置了觸發器的ip地址
								group.setUpdateTime(new Date());//更新修改時間

								//將註冊表中appname對應的多條ip地址,整成appname-> ips(IP1,IP2,IP3)格式存儲xxl_job_group表中,只針對自動註冊。
								XxlJobAdminConfig.getAdminConfig().getXxlJobGroupDao().update(group);
							}
						}
					} catch (Exception e) {
						if (!toStop) {
							logger.error(">>>>>>>>>>> xxl-job, job registry monitor thread error:{}", e);
						}
					}
					try {
						TimeUnit.SECONDS.sleep(RegistryConfig.BEAT_TIMEOUT);
					} catch (InterruptedException e) {
						if (!toStop) {
							logger.error(">>>>>>>>>>> xxl-job, job registry monitor thread error:{}", e);
						}
					}
				}
				logger.info(">>>>>>>>>>> xxl-job, job registry monitor thread stop");
			}
		});
		registryMonitorThread.setDaemon(true);
		registryMonitorThread.setName("xxl-job, admin JobRegistryMonitorHelper-registryMonitorThread");
		registryMonitorThread.start();
	}

執行失敗重試和告警(發郵件)

啓動監控線程,監控線程做了2件事,10秒間隔執行

  • 失敗重試
  • 告警,發送郵件
  1. 失敗重試

任務有兩個狀態

  • trigger_code:服務器下發任務的狀態,200表示成功,500失敗
  • handle_code:執行器執行任務的狀態,200表示成功,500失敗

這裏判斷失敗有2種情況

  • 第一種:trigger_code!=(0,200) 且 handle_code!=0
  • 第二種:handle_code!=200

image.png

告警(這裏可向spring注入JobAlarm)

public void start(){
		monitorThread = new Thread(new Runnable() {

			@Override
			public void run() {

				// monitor
				while (!toStop) {
					try {
						//獲取執行失敗的日誌 調度日誌表: 用於保存XXL-JOB任務調度的歷史信息,如調度結果、執行結果、調度入參、調度機器和執行器等等;
						List<Long> failLogIds = XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().findFailJobLogIds(1000);
						if (failLogIds!=null && !failLogIds.isEmpty()) {//1:執行觸發器成功,返回值失敗.2:觸發器失敗
							for (long failLogId: failLogIds) {

								// lock log   加鎖,樂觀修改alarm_status=-1
								int lockRet = XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().updateAlarmStatus(failLogId, 0, -1);
								if (lockRet < 1) {
									continue;
								}
								XxlJobLog log = XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().load(failLogId); //獲取失敗日誌具體信息
								XxlJobInfo info = XxlJobAdminConfig.getAdminConfig().getXxlJobInfoDao().loadById(log.getJobId()); //加載job信息

								// 1、fail retry monitor
								if (log.getExecutorFailRetryCount() > 0) { //若可重試次數>0,則再次執行觸發器
									JobTriggerPoolHelper.trigger(log.getJobId(), TriggerTypeEnum.RETRY, (log.getExecutorFailRetryCount()-1), log.getExecutorShardingParam(), log.getExecutorParam(), null);
									String retryMsg = "<br><br><span style=\"color:#F39C12;\" > >>>>>>>>>>>"+ I18nUtil.getString("jobconf_trigger_type_retry") +"<<<<<<<<<<< </span><br>";
									log.setTriggerMsg(log.getTriggerMsg() + retryMsg);
									XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().updateTriggerInfo(log); //修改觸發器執行信息
								}

								// 2、fail alarm monitor 失敗警告監視器
								int newAlarmStatus = 0;		// 告警狀態:0-默認、-1=鎖定狀態、1-無需告警、2-告警成功、3-告警失敗
								if (info!=null && info.getAlarmEmail()!=null && info.getAlarmEmail().trim().length()>0) {//若設置報警郵箱,則執行報警
									boolean alarmResult = XxlJobAdminConfig.getAdminConfig().getJobAlarmer().alarm(info, log);  //發送警報
									newAlarmStatus = alarmResult?2:3; //獲取警報執行狀態
								} else {//沒設置報警郵箱,則更改狀態爲不需要告警
									newAlarmStatus = 1;
								}
								//釋放鎖
								XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().updateAlarmStatus(failLogId, -1, newAlarmStatus);
							}
						}

					} catch (Exception e) {
						if (!toStop) {
							logger.error(">>>>>>>>>>> xxl-job, job fail monitor thread error:{}", e);
						}
					}

                    try {
                        TimeUnit.SECONDS.sleep(10);
                    } catch (Exception e) {
                        if (!toStop) {
                            logger.error(e.getMessage(), e);
                        }
                    }

                }

				logger.info(">>>>>>>>>>> xxl-job, job fail monitor thread stop");

			}
		});
		monitorThread.setDaemon(true);
		monitorThread.setName("xxl-job, admin JobFailMonitorHelper");
		monitorThread.start();
	}

在這裏可通過實現JobAlarm接口,注入spring容器執行報警。

boolean alarmResult = XxlJobAdminConfig.getAdminConfig().getJobAlarmer().alarm(info, log);
 public boolean alarm(XxlJobInfo info, XxlJobLog jobLog) {

        boolean result = false;
        if (jobAlarmList!=null && jobAlarmList.size()>0) {
            result = true;  // success means all-success
            for (JobAlarm alarm: jobAlarmList) {
                boolean resultItem = false;
                try {
                    resultItem = alarm.doAlarm(info, jobLog);
                } catch (Exception e) {
                    logger.error(e.getMessage(), e);
                }
                if (!resultItem) {
                    result = false;
                }
            }
        }

        return result;
    }

這裏jobAlarmList集合裏面有很多JobAlarm對象, 都是執行初始化方法從spring注入的,

也就是說擴展的話,只需要實現JobAlarm接口,注入spring即可。

image.png

初始化callbackThreadPool回調線程池,更改丟失主機的任務狀態爲失敗

這裏做了兩件事:

  • 初始化回調線程池
  • 啓動線程,每個1分鐘執行一次,將丟失主機信息的調度日誌更改狀態
public void start(){

		// for callback  針對回調函數處理的線程池
		callbackThreadPool = new ThreadPoolExecutor(
				2,
				20,
				30L,
				TimeUnit.SECONDS,
				new LinkedBlockingQueue<Runnable>(3000),
				new ThreadFactory() {
					@Override
					public Thread newThread(Runnable r) {
						return new Thread(r, "xxl-job, admin JobLosedMonitorHelper-callbackThreadPool-" + r.hashCode());
					}
				},
				new RejectedExecutionHandler() {
					@Override
					public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
						r.run();
						logger.warn(">>>>>>>>>>> xxl-job, callback too fast, match threadpool rejected handler(run now).");
					}
				});


		// for monitor
		monitorThread = new Thread(new Runnable() {

			@Override
			public void run() {

				// wait for JobTriggerPoolHelper-init
				try {
					TimeUnit.MILLISECONDS.sleep(50);
				} catch (InterruptedException e) {
					if (!toStop) {
						logger.error(e.getMessage(), e);
					}
				}

				// monitor
				while (!toStop) {
					try {
						// 任務結果丟失處理:調度記錄停留在 "運行中" 狀態超過10min,且對應執行器心跳註冊失敗不在線,則將本地調度主動標記失敗;
						Date losedTime = DateUtil.addMinutes(new Date(), -10); //調度日誌表: 用於保存XXL-JOB任務調度的歷史信息,如調度結果、執行結果、調度入參、調度機器和執行器等等;
						List<Long> losedJobIds  = XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().findLostJobIds(losedTime);

						if (losedJobIds!=null && losedJobIds.size()>0) {
							for (Long logId: losedJobIds) {

								XxlJobLog jobLog = new XxlJobLog();
								jobLog.setId(logId);

								jobLog.setHandleTime(new Date());
								jobLog.setHandleCode(ReturnT.FAIL_CODE);
								jobLog.setHandleMsg( I18nUtil.getString("joblog_lost_fail") );

								//更改處理狀態
								XxlJobCompleter.updateHandleInfoAndFinish(jobLog);
							}

						}
					} catch (Exception e) {
						if (!toStop) {
							logger.error(">>>>>>>>>>> xxl-job, job fail monitor thread error:{}", e);
						}
					}

                    try {
                        TimeUnit.SECONDS.sleep(60);
                    } catch (Exception e) {
                        if (!toStop) {
                            logger.error(e.getMessage(), e);
                        }
                    }

                }

				logger.info(">>>>>>>>>>> xxl-job, JobLosedMonitorHelper stop");

			}
		});
		monitorThread.setDaemon(true);
		monitorThread.setName("xxl-job, admin JobLosedMonitorHelper");
		monitorThread.start();
	}

任務結果丟失處理:調度記錄停留在 "運行中" 狀態超過10min,且對應執行器心跳註冊失敗不在線,則將本地調度主動標記失敗;

   public static int updateHandleInfoAndFinish(XxlJobLog xxlJobLog) {

        // finish 若父任務正常結束,則終止子任務,以及設置Childmsg
        finishJob(xxlJobLog);

        // text最大64kb 避免長度過長 截斷超過長度限制字符
        if (xxlJobLog.getHandleMsg().length() > 15000) {
            xxlJobLog.setHandleMsg( xxlJobLog.getHandleMsg().substring(0, 15000) );
        }

        // fresh handle 更新超時joblog
        return XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().updateHandleInfo(xxlJobLog);
    }

啓動統計日誌線程

  • 啓動線程,間隔1分鐘執行一次
  • 統計“前三天”失敗和成功的報表數據,管控臺要用
  • 如果設置了日誌保留天數,比如1天,清除超時日誌
  public void start(){
        logrThread = new Thread(new Runnable() {
            //每分鐘刷新一次
            @Override
            public void run() {

                // last clean log time   記錄上次清除日誌時間
                long lastCleanLogTime = 0;


                while (!toStop) {

                    // 1、log-report refresh: refresh log report in 3 days
                    try {

                        for (int i = 0; i < 3; i++) {

                            // today  分別統計今天,昨天,前天0~24點的數據
                            Calendar itemDay = Calendar.getInstance();
                            itemDay.add(Calendar.DAY_OF_MONTH, -i);
                            itemDay.set(Calendar.HOUR_OF_DAY, 0);
                            itemDay.set(Calendar.MINUTE, 0);
                            itemDay.set(Calendar.SECOND, 0);
                            itemDay.set(Calendar.MILLISECOND, 0);

                            Date todayFrom = itemDay.getTime();

                            itemDay.set(Calendar.HOUR_OF_DAY, 23);
                            itemDay.set(Calendar.MINUTE, 59);
                            itemDay.set(Calendar.SECOND, 59);
                            itemDay.set(Calendar.MILLISECOND, 999);

                            Date todayTo = itemDay.getTime();

                            // refresh log-report every minute
                            //設置默認值
                            XxlJobLogReport xxlJobLogReport = new XxlJobLogReport();
                            xxlJobLogReport.setTriggerDay(todayFrom);
                            xxlJobLogReport.setRunningCount(0);
                            xxlJobLogReport.setSucCount(0);
                            xxlJobLogReport.setFailCount(0);

                            //查詢失敗, 成功,總的調用次數
                            Map<String, Object> triggerCountMap = XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().findLogReport(todayFrom, todayTo);
                            if (triggerCountMap!=null && triggerCountMap.size()>0) {
                                int triggerDayCount = triggerCountMap.containsKey("triggerDayCount")?Integer.valueOf(String.valueOf(triggerCountMap.get("triggerDayCount"))):0;
                                int triggerDayCountRunning = triggerCountMap.containsKey("triggerDayCountRunning")?Integer.valueOf(String.valueOf(triggerCountMap.get("triggerDayCountRunning"))):0;
                                int triggerDayCountSuc = triggerCountMap.containsKey("triggerDayCountSuc")?Integer.valueOf(String.valueOf(triggerCountMap.get("triggerDayCountSuc"))):0;
                                int triggerDayCountFail = triggerDayCount - triggerDayCountRunning - triggerDayCountSuc;

                                xxlJobLogReport.setRunningCount(triggerDayCountRunning);
                                xxlJobLogReport.setSucCount(triggerDayCountSuc);
                                xxlJobLogReport.setFailCount(triggerDayCountFail);
                            }

                            // do refresh
                            //刷新調用次數,若找不到則默認都是0
                            int ret = XxlJobAdminConfig.getAdminConfig().getXxlJobLogReportDao().update(xxlJobLogReport);
                            if (ret < 1) {
                                //沒數據則保存
                                XxlJobAdminConfig.getAdminConfig().getXxlJobLogReportDao().save(xxlJobLogReport);
                            }
                        }

                    } catch (Exception e) {
                        if (!toStop) {
                            logger.error(">>>>>>>>>>> xxl-job, job log report thread error:{}", e);
                        }
                    }

                    // 2、log-clean: switch open & once each day
                    //設置了保留日誌天數且日誌保留了24小時,則進入
                    if (XxlJobAdminConfig.getAdminConfig().getLogretentiondays()>0
                            && System.currentTimeMillis() - lastCleanLogTime > 24*60*60*1000) {

                        // expire-time
                        //通過日誌保留天數算出清除log時間
                        Calendar expiredDay = Calendar.getInstance();
                        expiredDay.add(Calendar.DAY_OF_MONTH, -1 * XxlJobAdminConfig.getAdminConfig().getLogretentiondays());
                        expiredDay.set(Calendar.HOUR_OF_DAY, 0);
                        expiredDay.set(Calendar.MINUTE, 0);
                        expiredDay.set(Calendar.SECOND, 0);
                        expiredDay.set(Calendar.MILLISECOND, 0);
                        Date clearBeforeTime = expiredDay.getTime();

                        // clean expired log
                        List<Long> logIds = null;
                        do {
                            //這裏傳了3個0表示查詢所有,而不是單個任務id
                            logIds = XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().findClearLogIds(0, 0, clearBeforeTime, 0, 1000);
                            //刪除過期數據
                            if (logIds!=null && logIds.size()>0) {
                                XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().clearLog(logIds);
                            }
                        } while (logIds!=null && logIds.size()>0);

                        // update clean time
                        lastCleanLogTime = System.currentTimeMillis();
                    }

                    try {
                        TimeUnit.MINUTES.sleep(1);
                    } catch (Exception e) {
                        if (!toStop) {
                            logger.error(e.getMessage(), e);
                        }
                    }

                }

                logger.info(">>>>>>>>>>> xxl-job, job log report thread stop");

            }
        });
        logrThread.setDaemon(true);
        logrThread.setName("xxl-job, admin JobLogReportHelper");
        logrThread.start();
    }

啓動調度器

服務器調度器有兩個線程

  • scheduleThread:添加任務到時間輪
  • ringThread:執行任務的時間輪線程

1 添加任務到時間輪

這裏首先基於mysql數據庫,獲取分佈式鎖,然後查詢出下次執行時間在未來5秒以內的所有任務,

一次最多獲取6000條。

image.png

通過任務的下次觸發時間判斷任務是否過期,根據過期時間會分成三種對應處理。

  • 觸發器下次執行時間過期時間 > 5S
  • 觸發器下次執行時間過期時間 < 5S
  • 觸發器下次執行時間在未來5S以內。

具體如下:
image.png

源碼:

   public void start(){

        // schedule thread
        scheduleThread = new Thread(new Runnable() {
            @Override
            public void run() {

                try {  
                  //下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)  每個觸發器花費50ms,每個線程單位時間內處理20任務,最多同時處理300*20=6000任務
                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);
                        //獲取任務調度鎖表內數據信息,加寫鎖
                        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();   //獲取當前時間後5秒,同時最多負載的分頁數
                        List<XxlJobInfo> scheduleList = XxlJobAdminConfig.getAdminConfig().getXxlJobInfoDao().scheduleJobQuery(nowTime + PRE_READ_MS, preReadCount);
                        if (scheduleList!=null && scheduleList.size()>0) {
                            // 2、push time-ring
                            for (XxlJobInfo jobInfo: scheduleList) {

                                // time-ring jump
                                if (nowTime > jobInfo.getTriggerNextTime() + PRE_READ_MS) {  //觸發器過期時間>5s
                                    // 2.1、trigger-expire > 5s:pass && make next-trigger-time
                                    logger.warn(">>>>>>>>>>> xxl-job, schedule misfire, jobId = " + jobInfo.getId());

                                    // 1、misfire match
//                                    - 調度過期策略:
//                                    - 忽略:調度過期後,忽略過期的任務,從當前時間開始重新計算下次觸發時間;
//                                    - 立即執行一次:調度過期後,立即執行一次,並從當前時間開始重新計算下次觸發時間;
                                    MisfireStrategyEnum misfireStrategyEnum = MisfireStrategyEnum.match(jobInfo.getMisfireStrategy(), MisfireStrategyEnum.DO_NOTHING);
                                    if (MisfireStrategyEnum.FIRE_ONCE_NOW == misfireStrategyEnum) { //若過期策略爲FIRE_ONCE_NOW,則立即執行一次
                                        // 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()) { //觸發器過期時間<5s
                                    // 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 下次觸發時間在當前時間往後5秒範圍內
                                    if (jobInfo.getTriggerStatus()==1 && nowTime + PRE_READ_MS > jobInfo.getTriggerNextTime()) {

                                        // 1、make ring second 獲取下次執行秒
                                        int ringSecond = (int)((jobInfo.getTriggerNextTime()/1000)%60);

                                        // 2、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
                                    //未來五秒以內執行的所有任務添加到ringData
                                    // 1、make ring second
                                    int ringSecond = (int)((jobInfo.getTriggerNextTime()/1000)%60);

                                    // 2、push time ring
                                    pushTimeRing(ringSecond, jobInfo.getId());

                                    // 3、fresh next
                                    refreshNextValidTime(jobInfo, new Date(jobInfo.getTriggerNextTime()));

                                }

                            }

                            // 3、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 {

                        // commit
                        if (conn != null) {
                            try {
                                conn.commit();
                            } catch (SQLException e) {
                                if (!scheduleThreadToStop) {
                                    logger.error(e.getMessage(), e);
                                }
                            }
                            try {
                                conn.setAutoCommit(connAutoCommit);
                            } catch (SQLException e) {
                                if (!scheduleThreadToStop) {
                                    logger.error(e.getMessage(), e);
                                }
                            }
                            try {
                                conn.close();
                            } catch (SQLException e) {
                                if (!scheduleThreadToStop) {
                                    logger.error(e.getMessage(), e);
                                }
                            }
                        }

                        // close PreparedStatement
                        if (null != preparedStatement) {
                            try {
                                preparedStatement.close();
                            } catch (SQLException e) {
                                if (!scheduleThreadToStop) {
                                    logger.error(e.getMessage(), e);
                                }
                            }
                        }
                    }
                    long cost = System.currentTimeMillis()-start;


                    // Wait seconds, align second
                    if (cost < 1000) {  // scan-overtime, not wait
                        try {
                            // pre-read period: success > scan each second; fail > skip this period;
                            //若執行成功,下一秒繼續執行。執行失敗或沒查詢出數據則5秒執行一次。
                            TimeUnit.MILLISECONDS.sleep((preReadSuc?1000:PRE_READ_MS) - System.currentTimeMillis()%1000);
                        } catch (InterruptedException e) {
                            if (!scheduleThreadToStop) {
                                logger.error(e.getMessage(), e);
                            }
                        }
                    }

                }

                logger.info(">>>>>>>>>>> xxl-job, JobScheduleHelper#scheduleThread stop");
            }
        });
        scheduleThread.setDaemon(true);
        scheduleThread.setName("xxl-job, admin JobScheduleHelper#scheduleThread");
        scheduleThread.start();

    }

2 時間輪線程執行任務

當數據添加到ringData後,ringThread會一秒執行一次,然後從ringData獲取數據。

ringData本質是一個ConcurrentHashMap,容量60,key = 觸發器下次執行時間(秒爲單位)%60,value = 觸發器jobId。這裏採用了時間輪的思想,定時任務一秒執行一次,會以當前時間秒往前遞推2秒。

將數據從ringData取出來,然後執行任務。

比如現在時間爲0點0分0秒,當前時間秒爲0,則會將ringData中索引爲59和58的數據撈出來。

xxl-job這樣做是爲了避免處理耗時太長,所以會跨過刻度,多向前校驗一個刻度。

        // 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<>();
                        int nowSecond = Calendar.getInstance().get(Calendar.SECOND);   // 避免處理耗時太長,跨過刻度,向前校驗一個刻度;
                        for (int i = 0; i < 2; i++) {
                                                      //假設現在爲1秒,那麼執行任務之後,5秒之後的任務分別會添加到23456下標位置。
                            // i=1:(1+60-1)%60=0
                            // i=2:(1+60-2)%60=59
                            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();

服務器下發任務大致分爲兩種情況

  • 手動觸發:管控臺手動啓動一次任務
  • 自動觸發:根據觸發條件觸發任務,有時間輪線程處理

服務器下發任務整體流程的時序圖如下:

image.png

3 時間輪算法

時間輪這個技術其實出來很久了,在kafka、zookeeper、Netty、Dubbo等高性能組件中都有時間輪使
用的方式。

時間輪的數據結構, 其實類似hashmap,時間輪每一個時間刻度,可以理解爲一個槽位,同一時刻存在多個任務 ,放在雙向鏈表中。如下圖所示:
image.png
和hashmap不同的是,時間輪的key,是時間刻度值,並且,時間輪不做hash運算,直接使用時間作爲key,比如秒,時間輪在同一時刻存在多個任務時,只要把該刻度對應的鏈表全部遍歷一遍,執行其中的任務即可。

當然, 時鐘調度的線程,和 執行任務的線程,一般是需要解耦的。

爲什麼要採用時間輪算法?

考慮如果不用時間輪算法可以有兩個方案

  • 方案1:每個任務對應一個調度線程,然後讓線程等待間隔時間,然後執行,但是xxl-job任務太多的話,會創建太多線程,所以採用時間輪,也就是讓一個線程執行多個任務
  • 方案2:採用DelayQueue延遲隊列,大量入隊和出隊對性能影響應該比較大。

故此這裏採用簡易時間輪算法
image.png

在xxl-job中,作者是將時間輪分爲了60個槽,索引分別是0,1,2...,59,其實就是一分鐘的60s,

然後每一個槽對應着一個任務List集合(如上圖),通過(nextTriggerTime/1000)%60來找到每個任務對應的執行時間秒,由於這些任務都是在將來5秒內需要執行的,所以,這個時間輪中最多有4個槽有數據,這樣所有的待執行的任務都放入了時間輪中。

任務的執行,需要另一個線程來每秒撈一次時間輪,而作者避免處理耗時太長,導致有一秒的任務被遺落,所以每次都會向前校驗一個,比如說,當前需要撈取索引8的槽,那麼就會同時嘗試去撈取7,8兩個槽的數據,如果7號槽有數據證明前面的任務執行慢了導致7號被跳過,此時撈出來執行。

這樣一個簡易的時間輪就實現了。

之所以說這是一個簡易的時間輪是因爲,xxl-job中考慮的情況很簡單,因爲它是掃描的未來5秒內將要執行的任務,所以以秒爲最小單位就可以了,而且不需要考慮執行時間大於一輪的這種情況

執行器Executor 啓動流程

構建作業執行器,這裏將 com.xxl.job.core.executor.impl.XxlJobSpringExecutor 交由容器託管

    @Bean
    public XxlJobSpringExecutor xxlJobExecutor() {
        logger.info(">>>>>>>>>>> xxl-job config init.");
        XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();
        //xxjob管理地址
        xxlJobSpringExecutor.setAdminAddresses(adminAddresses);
        //註冊應用名稱
        xxlJobSpringExecutor.setAppname(appname);
        //xxl作業執行器註冊表地址
        xxlJobSpringExecutor.setAddress(address);
        xxlJobSpringExecutor.setIp(ip);
        xxlJobSpringExecutor.setPort(port);
        //註冊地址的token
        xxlJobSpringExecutor.setAccessToken(accessToken);
        //日誌路徑
        xxlJobSpringExecutor.setLogPath(logPath);
        //日誌保存天數
        xxlJobSpringExecutor.setLogRetentionDays(logRetentionDays);

        return xxlJobSpringExecutor;
    }

接口繼承了 ApplicationContext 對象。以及實現了 SmartInitializingSingleton 接口,實現該接口的當spring容器初始完成,緊接着執行監聽器發送監聽後,就會遍歷所有的Bean然後初始化所有單例非懶加載的bean,最後在實例化階段結束時觸發回調接口。
image.png

 // start
    @Override
    public void afterSingletonsInstantiated() {

        // init JobHandler Repository
        /*initJobHandlerRepository(applicationContext);*/

        // init JobHandler Repository (for method)   初始化調度器資源管理器
        initJobHandlerMethodRepository(applicationContext);

        // refresh GlueFactory 刷新GlueFactory
        GlueFactory.refreshInstance(1);

        // super start
        try {
            super.start();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

該方法主要做了如下事情:

  1. 初始化調度器資源管理器(從spring容器中將標記了XxlJob註解的方法,將其封裝並添加到map中。)
  2. 刷新GlueFactory
  3. 啓動服務,接收服務器請求。
 // start
    @Override
    public void afterSingletonsInstantiated() {

        // init JobHandler Repository
        /*initJobHandlerRepository(applicationContext);*/

        // init JobHandler Repository (for method)   初始化調度器資源管理器(從spring容器中將標記了XxlJob註解的方法,將其封裝並添加到map中。)
        initJobHandlerMethodRepository(applicationContext);

        // refresh GlueFactory 刷新GlueFactory
        GlueFactory.refreshInstance(1);

        // super start
        try {
            super.start();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

1 遍歷XxlJob註解查找方法

該方法主要做了如下事情:

  1. 從spring容器獲取所有對象,並遍歷查找方法上標記XxlJob註解的方法
  2. 將xxljob配置的jobname作爲key,對象,反射的執行,初始,銷燬方法作爲value註冊jobHandlerRepository 中
  private void initJobHandlerMethodRepository(ApplicationContext applicationContext) {
        if (applicationContext == null) {
            return;
        }
        // init job handler from method
        String[] beanDefinitionNames = applicationContext.getBeanNamesForType(Object.class, false, true);
        for (String beanDefinitionName : beanDefinitionNames) {//遍歷每個容器對象
            Object bean = applicationContext.getBean(beanDefinitionName);

            Map<Method, XxlJob> annotatedMethods = null;   // referred to :org.springframework.context.event.EventListenerMethodProcessor.processBean
            try { //獲取每個註解XxlJob方法
                annotatedMethods = MethodIntrospector.selectMethods(bean.getClass(),
                        new MethodIntrospector.MetadataLookup<XxlJob>() {
                            @Override
                            public XxlJob inspect(Method method) {
                                return AnnotatedElementUtils.findMergedAnnotation(method, XxlJob.class);
                            }
                        });
            } catch (Throwable ex) {
                logger.error("xxl-job method-jobhandler resolve error for bean[" + beanDefinitionName + "].", ex);
            }
            if (annotatedMethods==null || annotatedMethods.isEmpty()) {
                continue;
            }
            //遍歷標記了XxlJob註解的方法
            for (Map.Entry<Method, XxlJob> methodXxlJobEntry : annotatedMethods.entrySet()) {
                Method executeMethod = methodXxlJobEntry.getKey();
                XxlJob xxlJob = methodXxlJobEntry.getValue();
                if (xxlJob == null) {
                    continue;
                }

                String name = xxlJob.value();//獲取配置xxjob的觸發器名稱
                if (name.trim().length() == 0) {
                    throw new RuntimeException("xxl-job method-jobhandler name invalid, for[" + bean.getClass() + "#" + executeMethod.getName() + "] .");
                }
                if (loadJobHandler(name) != null) { //工作處理資源庫是否有相同命名
                    throw new RuntimeException("xxl-job jobhandler[" + name + "] naming conflicts.");
                }

                // execute method
                /*if (!(method.getParameterTypes().length == 1 && method.getParameterTypes()[0].isAssignableFrom(String.class))) {
                    throw new RuntimeException("xxl-job method-jobhandler param-classtype invalid, for[" + bean.getClass() + "#" + method.getName() + "] , " +
                            "The correct method format like \" public ReturnT<String> execute(String param) \" .");
                }
                if (!method.getReturnType().isAssignableFrom(ReturnT.class)) {
                    throw new RuntimeException("xxl-job method-jobhandler return-classtype invalid, for[" + bean.getClass() + "#" + method.getName() + "] , " +
                            "The correct method format like \" public ReturnT<String> execute(String param) \" .");
                }*/

                executeMethod.setAccessible(true);//設置可訪問,設置後可通過反射調用私有方法

                // init and destory
                Method initMethod = null;
                Method destroyMethod = null;

                if (xxlJob.init().trim().length() > 0) {
                    try {  //獲取XxlJob標記的方法,配置的init方法
                        initMethod = bean.getClass().getDeclaredMethod(xxlJob.init());
                        initMethod.setAccessible(true);
                    } catch (NoSuchMethodException e) {
                        throw new RuntimeException("xxl-job method-jobhandler initMethod invalid, for[" + bean.getClass() + "#" + executeMethod.getName() + "] .");
                    }
                }
                if (xxlJob.destroy().trim().length() > 0) {
                    try {//獲取XxlJob標記的方法,配置的destroy方法
                        destroyMethod = bean.getClass().getDeclaredMethod(xxlJob.destroy());
                        destroyMethod.setAccessible(true);
                    } catch (NoSuchMethodException e) {
                        throw new RuntimeException("xxl-job method-jobhandler destroyMethod invalid, for[" + bean.getClass() + "#" + executeMethod.getName() + "] .");
                    }
                }

                // registry jobhandler 將xxljob配置的jobname作爲key,對象,反射的執行,初始,銷燬方法作爲value註冊jobHandlerRepository中
                registJobHandler(name, new MethodJobHandler(bean, executeMethod, initMethod, destroyMethod));
            }
        }

    }

2 啓動客戶端執行器

執行器啓動,主要做了如下事情:

  1. 初始化日誌文件
  2. 封裝調度中心請求路徑,用於訪問調度中心
  3. 清除過期日誌
  4. 回調調度中心任務,彙報執行狀態
  5. 執行內嵌服務
    public void start() throws Exception {

        // init logpath
        XxlJobFileAppender.initLogPath(logPath);//初始化日誌文件

        // init invoker, admin-client
        initAdminBizList(adminAddresses, accessToken); //初始化admin鏈接路徑存儲集合


        // init JobLogFileCleanThread  清除過期日誌
        JobLogFileCleanThread.getInstance().start(logRetentionDays);

        // init TriggerCallbackThread
        TriggerCallbackThread.getInstance().start();

        // init executor-server 執行內嵌服務
        initEmbedServer(address, ip, port, appname, accessToken);
    }

2.1 初始化日誌文件

	public static void initLogPath(String logPath){
		// init
		if (logPath!=null && logPath.trim().length()>0) {
			logBasePath = logPath;
		}
		// mk base dir 日誌文件不存在則創建
		File logPathDir = new File(logBasePath);
		if (!logPathDir.exists()) {
			logPathDir.mkdirs();
		}
		logBasePath = logPathDir.getPath();

		// mk glue dir 創建glue目錄
		File glueBaseDir = new File(logPathDir, "gluesource");
		if (!glueBaseDir.exists()) {
			glueBaseDir.mkdirs();
		}
		glueSrcPath = glueBaseDir.getPath();
	}

2.2 封裝調度中心請求路徑,用於訪問調度中心

   private void initAdminBizList(String adminAddresses, String accessToken) throws Exception {
        if (adminAddresses!=null && adminAddresses.trim().length()>0) {
            for (String address: adminAddresses.trim().split(",")) { //多個admin地址以,分隔
                if (address!=null && address.trim().length()>0) {

                    AdminBiz adminBiz = new AdminBizClient(address.trim(), accessToken);

                    if (adminBizList == null) {
                        adminBizList = new ArrayList<AdminBiz>();
                    } //將admin地址以及token添加adminBiz中
                    adminBizList.add(adminBiz);
                }
            }
        }
    }

2.3 清除過期日誌

 public void start(final long logRetentionDays){

        // limit min value 日誌最大保存天數<3天,直接退出
        if (logRetentionDays < 3 ) {
            return;
        }
        //一天執行一次
        localThread = new Thread(new Runnable() {
            @Override
            public void run() {
                while (!toStop) {
                    try {
                        // clean log dir, over logRetentionDays 查詢目錄下所有子文件(包含目錄)
                        File[] childDirs = new File(XxlJobFileAppender.getLogPath()).listFiles();
                        if (childDirs!=null && childDirs.length>0) {

                            // today   獲取今天0點時間
                            Calendar todayCal = Calendar.getInstance();
                            todayCal.set(Calendar.HOUR_OF_DAY,0);
                            todayCal.set(Calendar.MINUTE,0);
                            todayCal.set(Calendar.SECOND,0);
                            todayCal.set(Calendar.MILLISECOND,0);

                            Date todayDate = todayCal.getTime();

                            for (File childFile: childDirs) {

                                // valid  不是目錄跳過
                                if (!childFile.isDirectory()) {
                                    continue;
                                }//查詢不到'-'則跳過
                                if (childFile.getName().indexOf("-") == -1) {
                                    continue;
                                }

                                // file create date  獲取文件創建時間,文件都是以年-月-日命名的
                                Date logFileCreateDate = null;
                                try {
                                    SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");
                                    logFileCreateDate = simpleDateFormat.parse(childFile.getName());
                                } catch (ParseException e) {
                                    logger.error(e.getMessage(), e);
                                }
                                if (logFileCreateDate == null) {
                                    continue;
                                }
                                //大於日誌最大存活時間則清除
                                if ((todayDate.getTime()-logFileCreateDate.getTime()) >= logRetentionDays * (24 * 60 * 60 * 1000) ) {
                                    FileUtil.deleteRecursively(childFile);//超過保存天數則清除日誌
                                }

                            }
                        }

                    } catch (Exception e) {
                        if (!toStop) {
                            logger.error(e.getMessage(), e);
                        }

                    }

                    try { //睡眠一天處理
                        TimeUnit.DAYS.sleep(1);
                    } catch (InterruptedException e) {
                        if (!toStop) {
                            logger.error(e.getMessage(), e);
                        }
                    }
                }
                logger.info(">>>>>>>>>>> xxl-job, executor JobLogFileCleanThread thread destory.");

            }
        });
        localThread.setDaemon(true);
        localThread.setName("xxl-job, executor JobLogFileCleanThread");
        localThread.start();
    }

2.4 回調調度中心,彙報執行狀態

 public void start() {

        // valid 是否有配置admin路徑
        if (XxlJobExecutor.getAdminBizList() == null) {
            logger.warn(">>>>>>>>>>> xxl-job, executor callback config fail, adminAddresses is null.");
            return;
        }

        // callback
        triggerCallbackThread = new Thread(new Runnable() {

            @Override
            public void run() {

                // normal callback
                while(!toStop){
                    try {
                        //獲取回調參數
                        HandleCallbackParam callback = getInstance().callBackQueue.take();
                        if (callback != null) {

                            // callback list param
                            List<HandleCallbackParam> callbackParamList = new ArrayList<HandleCallbackParam>();
                            //移除隊列中所有元素到callbackParamList中
                            int drainToNum = getInstance().callBackQueue.drainTo(callbackParamList);
                            callbackParamList.add(callback);

                            // callback, will retry if error 通知admin
                            if (callbackParamList!=null && callbackParamList.size()>0) {
                                doCallback(callbackParamList);
                            }
                        }
                    } catch (Exception e) {
                        if (!toStop) {
                            logger.error(e.getMessage(), e);
                        }
                    }
                }

                // last callback
                try {
                    List<HandleCallbackParam> callbackParamList = new ArrayList<HandleCallbackParam>();
                    int drainToNum = getInstance().callBackQueue.drainTo(callbackParamList);
                    if (callbackParamList!=null && callbackParamList.size()>0) {
                        doCallback(callbackParamList);
                    }
                } catch (Exception e) {
                    if (!toStop) {
                        logger.error(e.getMessage(), e);
                    }
                }
                logger.info(">>>>>>>>>>> xxl-job, executor callback thread destory.");

            }
        });
        triggerCallbackThread.setDaemon(true);
        triggerCallbackThread.setName("xxl-job, executor TriggerCallbackThread");
        triggerCallbackThread.start();


        // retry 回調調度中心,失敗的任務日誌重試
        triggerRetryCallbackThread = new Thread(new Runnable() {
            @Override
            public void run() {
                while(!toStop){
                    try {
                        retryFailCallbackFile();
                    } catch (Exception e) {
                        if (!toStop) {
                            logger.error(e.getMessage(), e);
                        }

                    }
                    try {
                        TimeUnit.SECONDS.sleep(RegistryConfig.BEAT_TIMEOUT);
                    } catch (InterruptedException e) {
                        if (!toStop) {
                            logger.error(e.getMessage(), e);
                        }
                    }
                }
                logger.info(">>>>>>>>>>> xxl-job, executor retry callback thread destory.");
            }
        });
        triggerRetryCallbackThread.setDaemon(true);
        triggerRetryCallbackThread.start();

    }

2.5 執行內嵌服務

內嵌服務主要做了如下事情:

  1. 使用netty開放端口,等待服務端調用
  2. 註冊到服務端(心跳30S)
  3. 向服務端申請剔除服務
    private void initEmbedServer(String address, String ip, int port, String appname, String accessToken) throws Exception {

        // fill ip port  若沒設置端口,則尋找可用端口
        port = port>0?port: NetUtil.findAvailablePort(9999);
        //若沒設置IP,則獲取本機Ip
        ip = (ip!=null&&ip.trim().length()>0)?ip: IpUtil.getIp(); 

        // generate address 構造地址,若沒設置地址,則將ip,port拼接成地址
        if (address==null || address.trim().length()==0) {
            String ip_port_address = IpUtil.getIpPort(ip, port);   // registry-address:default use address to registry , otherwise use ip:port if address is null
            address = "http://{ip_port}/".replace("{ip_port}", ip_port_address);
        }

        // accessToken
        if (accessToken==null || accessToken.trim().length()==0) {
            logger.warn(">>>>>>>>>>> xxl-job accessToken is empty. To ensure system security, please set the accessToken.");
        }

        // start  啓動嵌入服務器 ,向服務端註冊,以及監聽端口,主要服務於服務端調用。
        embedServer = new EmbedServer();
        embedServer.start(address, port, appname, accessToken);
    }

啓動內嵌服務

 public void start(final String address, final int port, final String appname, final String accessToken) {
        executorBiz = new ExecutorBizImpl();
        thread = new Thread(new Runnable() {

            @Override
            public void run() {

                // param
                EventLoopGroup bossGroup = new NioEventLoopGroup();
                EventLoopGroup workerGroup = new NioEventLoopGroup();
                ThreadPoolExecutor bizThreadPool = new ThreadPoolExecutor(
                        0,
                        200,
                        60L,
                        TimeUnit.SECONDS,
                        new LinkedBlockingQueue<Runnable>(2000),
                        new ThreadFactory() {
                            @Override
                            public Thread newThread(Runnable r) {
                                return new Thread(r, "xxl-rpc, EmbedServer bizThreadPool-" + r.hashCode());
                            }
                        },
                        new RejectedExecutionHandler() {
                            @Override
                            public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
                                throw new RuntimeException("xxl-job, EmbedServer bizThreadPool is EXHAUSTED!");
                            }
                        });


                try {
                    // start server
                    ServerBootstrap bootstrap = new ServerBootstrap();
                    bootstrap.group(bossGroup, workerGroup)
                            .channel(NioServerSocketChannel.class)
                            .childHandler(new ChannelInitializer<SocketChannel>() {
                                @Override
                                public void initChannel(SocketChannel channel) throws Exception {
                                    channel.pipeline()
                                            .addLast(new IdleStateHandler(0, 0, 30 * 3, TimeUnit.SECONDS))  // beat 3N, close if idle
                                            .addLast(new HttpServerCodec())
                                            .addLast(new HttpObjectAggregator(5 * 1024 * 1024))  // merge request & reponse to FULL
                                            .addLast(new EmbedHttpServerHandler(executorBiz, accessToken, bizThreadPool));
                                }
                            })
                            .childOption(ChannelOption.SO_KEEPALIVE, true);

                    // bind 異步綁定port上
                    ChannelFuture future = bootstrap.bind(port).sync();

                    logger.info(">>>>>>>>>>> xxl-job remoting server start success, nettype = {}, port = {}", EmbedServer.class, port);

                    // start registry  //註冊
                    startRegistry(appname, address);

                    // wait util stop
                    future.channel().closeFuture().sync();

                } catch (InterruptedException e) {
                    if (e instanceof InterruptedException) {
                        logger.info(">>>>>>>>>>> xxl-job remoting server stop.");
                    } else {
                        logger.error(">>>>>>>>>>> xxl-job remoting server error.", e);
                    }
                } finally {
                    // stop
                    try {
                        workerGroup.shutdownGracefully();
                        bossGroup.shutdownGracefully();
                    } catch (Exception e) {
                        logger.error(e.getMessage(), e);
                    }
                }

            }

        });
        thread.setDaemon(true);	// daemon, service jvm, user thread leave >>> daemon leave >>> jvm leave
        thread.start();
    }

向服務端註冊,默認30秒執行一次

    public void startRegistry(final String appname, final String address) {
        // start registry
        ExecutorRegistryThread.getInstance().start(appname, address);
    }
    public void start(final String appname, final String address){

        // valid appname不允許爲null
        if (appname==null || appname.trim().length()==0) {
            logger.warn(">>>>>>>>>>> xxl-job, executor registry config fail, appname is null.");
            return;
        }//服務端地址不能爲null
        if (XxlJobExecutor.getAdminBizList() == null) {
            logger.warn(">>>>>>>>>>> xxl-job, executor registry config fail, adminAddresses is null.");
            return;
        }

        registryThread = new Thread(new Runnable() {
            @Override
            public void run() {

                // registry
                while (!toStop) {
                    try {
                        RegistryParam registryParam = new RegistryParam(RegistryConfig.RegistType.EXECUTOR.name(), appname, address);
                        for (AdminBiz adminBiz: XxlJobExecutor.getAdminBizList()) {
                            try {
                                ReturnT<String> registryResult = adminBiz.registry(registryParam); //向server註冊服務(http請求),註冊內容appname,當前服務監聽地址
                                if (registryResult!=null && ReturnT.SUCCESS_CODE == registryResult.getCode()) { //訪問成功
                                    registryResult = ReturnT.SUCCESS;
                                    logger.debug(">>>>>>>>>>> xxl-job registry success, registryParam:{}, registryResult:{}", new Object[]{registryParam, registryResult});
                                    break;
                                } else {
                                    logger.info(">>>>>>>>>>> xxl-job registry fail, registryParam:{}, registryResult:{}", new Object[]{registryParam, registryResult});
                                }
                            } catch (Exception e) {
                                logger.info(">>>>>>>>>>> xxl-job registry error, registryParam:{}", registryParam, e);
                            }

                        }
                    } catch (Exception e) {
                        if (!toStop) {
                            logger.error(e.getMessage(), e);
                        }

                    }

                    try {
                        if (!toStop) { //心跳時間30秒
                            TimeUnit.SECONDS.sleep(RegistryConfig.BEAT_TIMEOUT);
                        }
                    } catch (InterruptedException e) {
                        if (!toStop) {
                            logger.warn(">>>>>>>>>>> xxl-job, executor registry thread interrupted, error msg:{}", e.getMessage());
                        }
                    }
                }

                // registry remove  刪除註冊
                try {
                    RegistryParam registryParam = new RegistryParam(RegistryConfig.RegistType.EXECUTOR.name(), appname, address);
                    for (AdminBiz adminBiz: XxlJobExecutor.getAdminBizList()) {
                        try {  
                            ReturnT<String> registryResult = adminBiz.registryRemove(registryParam);
                            if (registryResult!=null && ReturnT.SUCCESS_CODE == registryResult.getCode()) {
                                registryResult = ReturnT.SUCCESS;
                                logger.info(">>>>>>>>>>> xxl-job registry-remove success, registryParam:{}, registryResult:{}", new Object[]{registryParam, registryResult});
                                break;
                            } else {
                                logger.info(">>>>>>>>>>> xxl-job registry-remove fail, registryParam:{}, registryResult:{}", new Object[]{registryParam, registryResult});
                            }
                        } catch (Exception e) {
                            if (!toStop) {
                                logger.info(">>>>>>>>>>> xxl-job registry-remove error, registryParam:{}", registryParam, e);
                            }

                        }

                    }
                } catch (Exception e) {
                    if (!toStop) {
                        logger.error(e.getMessage(), e);
                    }
                }
                logger.info(">>>>>>>>>>> xxl-job, executor registry thread destory.");

            }
        });
        registryThread.setDaemon(true);
        registryThread.setName("xxl-job, executor ExecutorRegistryThread");
        registryThread.start();
    }

XXL-JOB的服務註冊

每間隔30秒,客戶端發送post請求訪問調度中心,上報心跳結果。

image.png

客戶端

com.xxl.job.core.thread.ExecutorRegistryThread#start
image.png

com.xxl.job.core.biz.client.AdminBizClient#registry

@Override
public ReturnT<String> registry(RegistryParam registryParam) {
    return XxlJobRemotingUtil.postBody(addressUrl + "api/registry", accessToken, timeout, registryParam, String.class);
}

服務端

com.xxl.job.admin.controller.JobApiController#api
image.png

AdminBizImpl#registry

    @Override
    public ReturnT<String> registry(RegistryParam registryParam) {
        return JobRegistryHelper.getInstance().registry(registryParam);
    }

JobRegistryHelper#registry

	public ReturnT<String> registry(RegistryParam registryParam) {

		// valid  校驗
		if (!StringUtils.hasText(registryParam.getRegistryGroup())
				|| !StringUtils.hasText(registryParam.getRegistryKey())
				|| !StringUtils.hasText(registryParam.getRegistryValue())) {
			return new ReturnT<String>(ReturnT.FAIL_CODE, "Illegal Argument.");
		}

		// async execute 異步註冊
		registryOrRemoveThreadPool.execute(new Runnable() {
			@Override
			public void run() { //更新修改時間
				int ret = XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().registryUpdate(registryParam.getRegistryGroup(), registryParam.getRegistryKey(), registryParam.getRegistryValue(), new Date());
				if (ret < 1) {//說明暫未數據,才新增
					XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().registrySave(registryParam.getRegistryGroup(), registryParam.getRegistryKey(), registryParam.getRegistryValue(), new Date());

					// fresh  空實現
					freshGroupRegistryInfo(registryParam);
				}
			}
		});

		return ReturnT.SUCCESS;
	}

  1. 客戶端接收響應

image.png

XXL-JOB的 執行任務

從調度中心到執行器執行一個任務的路徑如下:

  1. 調度中心向客戶端發起post請求
  2. client 通過內嵌服務netty接收,異步線程處理
  3. 找到job綁定的線程,將任務丟到阻塞隊列中。然後返回結果給調度中心。
  4. 調度中心更改任務狀態。
  5. 客戶端執行任務後,將執行結果丟到回調線程的阻塞隊列處理。
  6. 回調線程通過post請求訪問調度中心,調度中心更改job最終結果。
  7. 倘若超過10分鐘調度中心沒收到回調線程的請求,則設置job最終結果失敗。

調度中心下發任務到執行器時序圖如下:
image.png

服務端下發任務

觸發地址:com.xxl.job.admin.controller.JobInfoController#triggerJob

	@RequestMapping("/trigger")
	@ResponseBody
	//@PermissionLimit(limit = false)
	public ReturnT<String> triggerJob(int id, String executorParam, String addressList) {
		// force cover job param 設置默認值
		if (executorParam == null) {
			executorParam = "";
		}
		//觸發器類型,手動 ,重試次數,'執行器任務分片參數,格式如 1/2',任務參數,機器地址
		JobTriggerPoolHelper.trigger(id, TriggerTypeEnum.MANUAL, -1, null, executorParam, addressList);
		return ReturnT.SUCCESS;
	}

手動執行任務和通過調度自動執行任務最終都會走到這。

JobTriggerPoolHelper#trigger

public static void trigger(int jobId, TriggerTypeEnum triggerType, int failRetryCount, String executorShardingParam, String executorParam, String addressList) {
    helper.addTrigger(jobId, triggerType, failRetryCount, executorShardingParam, executorParam, addressList);
}

添加一個觸發任務丟到線程池執行,這裏jobTimeoutCountMap會記錄一分鐘以內每個job執行超過500ms的次數。

當某個job一分鐘以內多於10次時間超過500ms,則採用慢觸發器觸發。

這裏每分鐘會清除一次jobTimeoutCountMap。

這裏通過線程隔離的手段優化執行時間。

   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);
        //一分鐘內超時10次,則採用慢觸發器執行
        if (jobTimeoutCount!=null && jobTimeoutCount.get() > 10) {      // job-timeout 10 times in 1 min
            triggerPool_ = slowTriggerPool;
        }

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

                long start = System.currentTimeMillis();

                try {
                    // do trigger //執行觸發器
                    XxlJobTrigger.trigger(jobId, triggerType, failRetryCount, executorShardingParam, executorParam, addressList);
                } catch (Exception e) {
                    logger.error(e.getMessage(), e);
                } finally {

                    // check timeout-count-map  更新成爲下一分鐘
                    long minTim_now = System.currentTimeMillis()/60000;
                    if (minTim != minTim_now) {
                        minTim = minTim_now; //當達到下一分鐘則清除超時任務
                        jobTimeoutCountMap.clear();
                    }

                    // incr timeout-count-map
                    long cost = System.currentTimeMillis()-start;
                    if (cost > 500) {       // ob-timeout threshold 500ms
                        //執行時間超過500ms,則記錄執行次數
                        AtomicInteger timeoutCount = jobTimeoutCountMap.putIfAbsent(jobId, new AtomicInteger(1));
                        if (timeoutCount != null) {
                            timeoutCount.incrementAndGet();
                        }
                    }

                }

            }
        });
    }

XxlJobTrigger#trigger

    public static void trigger(int jobId,
                               TriggerTypeEnum triggerType,
                               int failRetryCount,
                               String executorShardingParam,
                               String executorParam,
                               String addressList) {

        // load data  從數據庫獲取任務詳情信息
        XxlJobInfo jobInfo = XxlJobAdminConfig.getAdminConfig().getXxlJobInfoDao().loadById(jobId);
        if (jobInfo == null) {
            logger.warn(">>>>>>>>>>>> trigger fail, jobId invalid,jobId={}", jobId);
            return;
        }
        if (executorParam != null) {
            //設置任務參數
            jobInfo.setExecutorParam(executorParam);
        }
        //獲取失敗重試次數
        int finalFailRetryCount = failRetryCount>=0?failRetryCount:jobInfo.getExecutorFailRetryCount();
        //獲取job分組信息
        XxlJobGroup group = XxlJobAdminConfig.getAdminConfig().getXxlJobGroupDao().load(jobInfo.getJobGroup());

        // cover addressList  設置地址集合
        if (addressList!=null && addressList.trim().length()>0) {
            group.setAddressType(1);
            group.setAddressList(addressList.trim());
        }

        // sharding param  拆分executorParam任務參數,填入shardingParam數組
        int[] shardingParam = null;
        if (executorShardingParam!=null){
            String[] shardingArr = executorParam.split("/");
            if (shardingArr.length==2 && isNumeric(shardingArr[0]) && isNumeric(shardingArr[1])) {
                shardingParam = new int[2];
                shardingParam[0] = Integer.valueOf(shardingArr[0]);
                shardingParam[1] = Integer.valueOf(shardingArr[1]);
            }
        } //如果路由策略是分片廣播模式,同時註冊地址不爲空
        if (ExecutorRouteStrategyEnum.SHARDING_BROADCAST==ExecutorRouteStrategyEnum.match(jobInfo.getExecutorRouteStrategy(), null)
                && group.getRegistryList()!=null && !group.getRegistryList().isEmpty()
                && shardingParam==null) {
            for (int i = 0; i < group.getRegistryList().size(); i++) {  //遍歷執行每個註冊地址,集羣廣播
                processTrigger(group, jobInfo, finalFailRetryCount, triggerType, i, group.getRegistryList().size());
            }
        } else {
            if (shardingParam == null) {  //當shardingParam爲空,設置默認值,分別標識
                shardingParam = new int[]{0, 1};
            } //執行觸發器
            processTrigger(group, jobInfo, finalFailRetryCount, triggerType, shardingParam[0], shardingParam[1]);
        }

    }

這裏會依據路由策略模式,選擇對應的路由方式處理,採用了策略模式.

  private static void processTrigger(XxlJobGroup group, XxlJobInfo jobInfo, int finalFailRetryCount, TriggerTypeEnum triggerType, int index, int total){

        // param  獲取阻塞處理策略
        ExecutorBlockStrategyEnum blockStrategy = ExecutorBlockStrategyEnum.match(jobInfo.getExecutorBlockStrategy(), ExecutorBlockStrategyEnum.SERIAL_EXECUTION);  // block strategy
        // route strategy 獲取路由策略,默認first
        ExecutorRouteStrategyEnum executorRouteStrategyEnum = ExecutorRouteStrategyEnum.match(jobInfo.getExecutorRouteStrategy(), null);
        String shardingParam = (ExecutorRouteStrategyEnum.SHARDING_BROADCAST==executorRouteStrategyEnum)?String.valueOf(index).concat("/").concat(String.valueOf(total)):null;

        // 1、save log-id 保存執行日誌
        XxlJobLog jobLog = new XxlJobLog();
        jobLog.setJobGroup(jobInfo.getJobGroup());
        jobLog.setJobId(jobInfo.getId());
        jobLog.setTriggerTime(new Date());
        XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().save(jobLog);
        logger.debug(">>>>>>>>>>> xxl-job trigger start, jobId:{}", jobLog.getId());

        // 2、init trigger-param
        TriggerParam triggerParam = new TriggerParam();
        triggerParam.setJobId(jobInfo.getId());
        triggerParam.setExecutorHandler(jobInfo.getExecutorHandler());
        triggerParam.setExecutorParams(jobInfo.getExecutorParam());
        triggerParam.setExecutorBlockStrategy(jobInfo.getExecutorBlockStrategy());
        triggerParam.setExecutorTimeout(jobInfo.getExecutorTimeout());
        triggerParam.setLogId(jobLog.getId());
        triggerParam.setLogDateTime(jobLog.getTriggerTime().getTime());
        triggerParam.setGlueType(jobInfo.getGlueType());
        triggerParam.setGlueSource(jobInfo.getGlueSource());
        triggerParam.setGlueUpdatetime(jobInfo.getGlueUpdatetime().getTime());
        triggerParam.setBroadcastIndex(index);
        triggerParam.setBroadcastTotal(total);

        // 3、init address  獲取觸發器執行地址
        String address = null;
        ReturnT<String> routeAddressResult = null;
        if (group.getRegistryList()!=null && !group.getRegistryList().isEmpty()) {  //如果是集羣廣播模式
            if (ExecutorRouteStrategyEnum.SHARDING_BROADCAST == executorRouteStrategyEnum) {
                if (index < group.getRegistryList().size()) {  //查詢匹配地址執行
                    address = group.getRegistryList().get(index);
                } else { //超過size,則默認執行第一個.
                    address = group.getRegistryList().get(0);
                }
            } else {
                //根據設置的路由策略,執行路由器,獲取返回結果   ,這裏用到了策略模式
                routeAddressResult = executorRouteStrategyEnum.getRouter().route(triggerParam, group.getRegistryList());
                if (routeAddressResult.getCode() == ReturnT.SUCCESS_CODE) {
                    address = routeAddressResult.getContent();
                }
            }
        } else { //獲取不到註冊地址,返回失敗值
            routeAddressResult = new ReturnT<String>(ReturnT.FAIL_CODE, I18nUtil.getString("jobconf_trigger_address_empty"));
        }

        // 4、trigger remote executor
        ReturnT<String> triggerResult = null;
        if (address != null) { //這裏真正的執行觸發器
            triggerResult = runExecutor(triggerParam, address);
        } else {//獲取不到執行地址直接返回
            triggerResult = new ReturnT<String>(ReturnT.FAIL_CODE, null);
        }

        // 5、collection trigger info
        StringBuffer triggerMsgSb = new StringBuffer();
        triggerMsgSb.append(I18nUtil.getString("jobconf_trigger_type")).append(":").append(triggerType.getTitle());
        triggerMsgSb.append("<br>").append(I18nUtil.getString("jobconf_trigger_admin_adress")).append(":").append(IpUtil.getIp());
        triggerMsgSb.append("<br>").append(I18nUtil.getString("jobconf_trigger_exe_regtype")).append(":")
                .append( (group.getAddressType() == 0)?I18nUtil.getString("jobgroup_field_addressType_0"):I18nUtil.getString("jobgroup_field_addressType_1") );
        triggerMsgSb.append("<br>").append(I18nUtil.getString("jobconf_trigger_exe_regaddress")).append(":").append(group.getRegistryList());
        triggerMsgSb.append("<br>").append(I18nUtil.getString("jobinfo_field_executorRouteStrategy")).append(":").append(executorRouteStrategyEnum.getTitle());
        if (shardingParam != null) {
            triggerMsgSb.append("("+shardingParam+")");
        }
        triggerMsgSb.append("<br>").append(I18nUtil.getString("jobinfo_field_executorBlockStrategy")).append(":").append(blockStrategy.getTitle());
        triggerMsgSb.append("<br>").append(I18nUtil.getString("jobinfo_field_timeout")).append(":").append(jobInfo.getExecutorTimeout());
        triggerMsgSb.append("<br>").append(I18nUtil.getString("jobinfo_field_executorFailRetryCount")).append(":").append(finalFailRetryCount);

        triggerMsgSb.append("<br><br><span style=\"color:#00c0ef;\" > >>>>>>>>>>>"+ I18nUtil.getString("jobconf_trigger_run") +"<<<<<<<<<<< </span><br>")
                .append((routeAddressResult!=null&&routeAddressResult.getMsg()!=null)?routeAddressResult.getMsg()+"<br><br>":"").append(triggerResult.getMsg()!=null?triggerResult.getMsg():"");

        // 6、save log trigger-info  更改執行日誌狀態
        jobLog.setExecutorAddress(address);
        jobLog.setExecutorHandler(jobInfo.getExecutorHandler());
        jobLog.setExecutorParam(jobInfo.getExecutorParam());
        jobLog.setExecutorShardingParam(shardingParam);
        jobLog.setExecutorFailRetryCount(finalFailRetryCount);
        //jobLog.setTriggerTime();
        jobLog.setTriggerCode(triggerResult.getCode());//設置執行觸發器返回值
        jobLog.setTriggerMsg(triggerMsgSb.toString());//設置返回結果信息
        XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().updateTriggerInfo(jobLog);

        logger.debug(">>>>>>>>>>> xxl-job trigger end, jobId:{}", jobLog.getId());
    }

路由策略

  • FIRST(第一個):固定選擇第一個機器;
  • LAST(最後一個):固定選擇最後一個機器;
  • ROUND(輪詢):;
  • RANDOM(隨機):隨機選擇在線的機器;
  • CONSISTENT_HASH(一致性HASH):每個任務按照Hash算法固定選擇某一臺機器,且所有任務均勻散列在不同機器上。
  • LEAST_FREQUENTLY_USED(最不經常使用):使用頻率最低的機器優先被選舉;
  • LEAST_RECENTLY_USED(最近最久未使用):最久未使用的機器優先被選舉;
  • FAILOVER(故障轉移):按照順序依次進行心跳檢測,第一個心跳檢測成功的機器選定爲目標執行器併發起調度;
  • BUSYOVER(忙碌轉移):按照順序依次進行空閒檢測,第一個空閒檢測成功的機器選定爲目標執行器併發起調度;
  • SHARDING_BROADCAST(分片廣播):廣播觸發對應集羣中所有機器執行一次任務,同時系統自動傳遞分片參數;可根據分片參數開發分片任務;

這裏默認固定選擇第一個機器 ExecutorRouteFirst#route

    @Override
    public ReturnT<String> route(TriggerParam triggerParam, List<String> addressList){
        return new ReturnT<String>(addressList.get(0));
    }

XxlJobTrigger#runExecutor執行觸發

    public static ReturnT<String> runExecutor(TriggerParam triggerParam, String address){
        ReturnT<String> runResult = null;
        try {
            ExecutorBiz executorBiz = XxlJobScheduler.getExecutorBiz(address);//獲取業務執行器地址,就執行器地址後面拼接token
          //通過post請求客戶端執行job
            runResult = executorBiz.run(triggerParam);
        } catch (Exception e) {
            logger.error(">>>>>>>>>>> xxl-job trigger error, please check if the executor[{}] is running.", address, e);
            runResult = new ReturnT<String>(ReturnT.FAIL_CODE, ThrowableUtil.toString(e));
        }
        //返回結果設置msg
        StringBuffer runResultSB = new StringBuffer(I18nUtil.getString("jobconf_trigger_run") + ":");
        runResultSB.append("<br>address:").append(address);
        runResultSB.append("<br>code:").append(runResult.getCode());
        runResultSB.append("<br>msg:").append(runResult.getMsg());

        runResult.setMsg(runResultSB.toString());
        return runResult;
    }

獲取客戶端地址,簡單拼接http+token地址。com.xxl.job.admin.core.scheduler.XxlJobScheduler#getExecutorBiz

public static ExecutorBiz getExecutorBiz(String address) throws Exception {
    // valid
    if (address==null || address.trim().length()==0) {
        return null;
    }

    // load-cache
    //從緩衝中通過地址獲取ExecutorBiz
    address = address.trim();
    ExecutorBiz executorBiz = executorBizRepository.get(address);
    if (executorBiz != null) {
        return executorBiz;
    }

    // set-cache 找不到就新建
    executorBiz = new ExecutorBizClient(address, XxlJobAdminConfig.getAdminConfig().getAccessToken());

    //添加緩存
    executorBizRepository.put(address, executorBiz);
    return executorBiz;
}

通過post請求客戶端執行job,ExecutorBizClient#run

  @Override
    public ReturnT<String> run(TriggerParam triggerParam) {
        return XxlJobRemotingUtil.postBody(addressUrl + "run", accessToken, timeout, triggerParam, String.class);
    }

客戶端處理任務

執行器執行任務時序圖:

image.png

客戶端的入站處理器:

EmbedServer#EmbedHttpServerHandler#channelRead0

   @Override
        protected void channelRead0(final ChannelHandlerContext ctx, FullHttpRequest msg) throws Exception {

            // request parse
            //final byte[] requestBytes = ByteBufUtil.getBytes(msg.content());    // byteBuf.toString(io.netty.util.CharsetUtil.UTF_8);
            String requestData = msg.content().toString(CharsetUtil.UTF_8);//解析請求數據
            String uri = msg.uri();//獲取uri,後面通過uri來處理不同的請求
            HttpMethod httpMethod = msg.method();//獲取請求方式,Post/Get
            boolean keepAlive = HttpUtil.isKeepAlive(msg);//保持長連接
            String accessTokenReq = msg.headers().get(XxlJobRemotingUtil.XXL_JOB_ACCESS_TOKEN);

            // invoke
            bizThreadPool.execute(new Runnable() {
                @Override
                public void run() {
                    // do invoke
                    Object responseObj = process(httpMethod, uri, requestData, accessTokenReq);

                    // to json  響應結果轉json
                    String responseJson = GsonTool.toJson(responseObj);

                    // write response
                    writeResponse(ctx, keepAlive, responseJson);
                }
            });
        }

執行觸發器

   private Object process(HttpMethod httpMethod, String uri, String requestData, String accessTokenReq) {

            // valid 不是POST直接返回異常
            if (HttpMethod.POST != httpMethod) {
                return new ReturnT<String>(ReturnT.FAIL_CODE, "invalid request, HttpMethod not support.");
            }
            if (uri==null || uri.trim().length()==0) { //校驗uri
                return new ReturnT<String>(ReturnT.FAIL_CODE, "invalid request, uri-mapping empty.");
            }//校驗token是否正確
            if (accessToken!=null
                    && accessToken.trim().length()>0
                    && !accessToken.equals(accessTokenReq)) {
                return new ReturnT<String>(ReturnT.FAIL_CODE, "The access token is wrong.");
            }

            // services mapping
            try {
                if ("/beat".equals(uri)) {
                    return executorBiz.beat();
                } else if ("/idleBeat".equals(uri)) {
                    IdleBeatParam idleBeatParam = GsonTool.fromJson(requestData, IdleBeatParam.class);
                    return executorBiz.idleBeat(idleBeatParam);
                } else if ("/run".equals(uri)) { //執行觸發器
                    TriggerParam triggerParam = GsonTool.fromJson(requestData, TriggerParam.class);//請求參數解析成TriggerParam
                    return executorBiz.run(triggerParam);
                } else if ("/kill".equals(uri)) {
                    KillParam killParam = GsonTool.fromJson(requestData, KillParam.class);
                    return executorBiz.kill(killParam);
                } else if ("/log".equals(uri)) {
                    LogParam logParam = GsonTool.fromJson(requestData, LogParam.class);
                    return executorBiz.log(logParam);
                } else {
                    return new ReturnT<String>(ReturnT.FAIL_CODE, "invalid request, uri-mapping("+ uri +") not found.");
                }
            } catch (Exception e) {
                logger.error(e.getMessage(), e);
                return new ReturnT<String>(ReturnT.FAIL_CODE, "request error:" + ThrowableUtil.toString(e));
            }
        }


接下來兩個工作

  1. 綁定作業到具體線程JobThread,啓動線程
  2. 任務丟入線程處理JobThread#triggerQueue
 @Override
    public ReturnT<String> run(TriggerParam triggerParam) {
        // load old:jobHandler + jobThread
        //獲取job綁定線程
        JobThread jobThread = XxlJobExecutor.loadJobThread(triggerParam.getJobId());
        //獲取作業處理器
        IJobHandler jobHandler = jobThread!=null?jobThread.getHandler():null;
        String removeOldReason = null;

        // valid:jobHandler + jobThread
        //獲取任務運行模式
        GlueTypeEnum glueTypeEnum = GlueTypeEnum.match(triggerParam.getGlueType());
        //任務以JobHandler方式維護在執行器端;需要結合 "JobHandler" 屬性匹配執行器中任務;
        if (GlueTypeEnum.BEAN == glueTypeEnum) {
            // new jobhandler  獲取job處理器,也就是方法聲明
            IJobHandler newJobHandler = XxlJobExecutor.loadJobHandler(triggerParam.getExecutorHandler());

            // valid old jobThread 如果job處理器不一樣,則kill舊處理器綁定的線程
            if (jobThread!=null && jobHandler != newJobHandler) {
                // change handler, need kill old thread
                removeOldReason = "change jobhandler or glue type, and terminate the old job thread.";

                jobThread = null;
                jobHandler = null;
            }

            // valid handler
            //執行到這兒,要麼新舊處理器不一致,要麼沒有綁定過任何線程
            if (jobHandler == null) {
                jobHandler = newJobHandler;
                //沒找到處理器,直接返回異常
                if (jobHandler == null) {
                    return new ReturnT<String>(ReturnT.FAIL_CODE, "job handler [" + triggerParam.getExecutorHandler() + "] not found.");
                }
            }

        } else if (GlueTypeEnum.GLUE_GROOVY == glueTypeEnum) {

            // valid old jobThread
            if (jobThread != null &&
                    !(jobThread.getHandler() instanceof GlueJobHandler
                        && ((GlueJobHandler) jobThread.getHandler()).getGlueUpdatetime()==triggerParam.getGlueUpdatetime() )) {
                // change handler or gluesource updated, need kill old thread
                removeOldReason = "change job source or glue type, and terminate the old job thread.";

                jobThread = null;
                jobHandler = null;
            }

            // valid handler
            if (jobHandler == null) {
                try {
                    IJobHandler originJobHandler = GlueFactory.getInstance().loadNewInstance(triggerParam.getGlueSource());
                    jobHandler = new GlueJobHandler(originJobHandler, triggerParam.getGlueUpdatetime());
                } catch (Exception e) {
                    logger.error(e.getMessage(), e);
                    return new ReturnT<String>(ReturnT.FAIL_CODE, e.getMessage());
                }
            }
        } else if (glueTypeEnum!=null && glueTypeEnum.isScript()) {

            // valid old jobThread
            if (jobThread != null &&
                    !(jobThread.getHandler() instanceof ScriptJobHandler
                            && ((ScriptJobHandler) jobThread.getHandler()).getGlueUpdatetime()==triggerParam.getGlueUpdatetime() )) {
                // change script or gluesource updated, need kill old thread
                removeOldReason = "change job source or glue type, and terminate the old job thread.";

                jobThread = null;
                jobHandler = null;
            }

            // valid handler
            if (jobHandler == null) {
                jobHandler = new ScriptJobHandler(triggerParam.getJobId(), triggerParam.getGlueUpdatetime(), triggerParam.getGlueSource(), GlueTypeEnum.match(triggerParam.getGlueType()));
            }
        } else {
            return new ReturnT<String>(ReturnT.FAIL_CODE, "glueType[" + triggerParam.getGlueType() + "] is not valid.");
        }

        // executor block strategy
        //獲取任務阻塞策略
        if (jobThread != null) {
            ExecutorBlockStrategyEnum blockStrategy = ExecutorBlockStrategyEnum.match(triggerParam.getExecutorBlockStrategy(), null);
            if (ExecutorBlockStrategyEnum.DISCARD_LATER == blockStrategy) {
                // discard when running
                if (jobThread.isRunningOrHasQueue()) {
                    return new ReturnT<String>(ReturnT.FAIL_CODE, "block strategy effect:"+ExecutorBlockStrategyEnum.DISCARD_LATER.getTitle());
                }
            } else if (ExecutorBlockStrategyEnum.COVER_EARLY == blockStrategy) {
                // kill running jobThread
                if (jobThread.isRunningOrHasQueue()) {
                    removeOldReason = "block strategy effect:" + ExecutorBlockStrategyEnum.COVER_EARLY.getTitle();

                    jobThread = null;
                }
            } else {
                // just queue trigger
            }
        }

        // replace thread (new or exists invalid)
        //作業沒綁定過線程,則綁定作業到具體線程
        if (jobThread == null) {
            jobThread = XxlJobExecutor.registJobThread(triggerParam.getJobId(), jobHandler, removeOldReason);
        }

        // push data to queue  任務丟入線程處理
        ReturnT<String> pushResult = jobThread.pushTriggerQueue(triggerParam);
        return pushResult;
    }

綁定作業到具體線程,啓動線程

    public static JobThread registJobThread(int jobId, IJobHandler handler, String removeOldReason){
        JobThread newJobThread = new JobThread(jobId, handler);//啓動新線程處理工作任務
        newJobThread.start();
        logger.info(">>>>>>>>>>> xxl-job regist JobThread success, jobId:{}, handler:{}", new Object[]{jobId, handler});
        //存儲jobId與綁定工作的線程
        JobThread oldJobThread = jobThreadRepository.put(jobId, newJobThread);	// putIfAbsent | oh my god, map's put method return the old value!!!
        if (oldJobThread != null) {//中斷並刪除舊線程
            oldJobThread.toStop(removeOldReason);
            oldJobThread.interrupt();
        }

        return newJobThread;
    }

任務丟入線程處理

	public ReturnT<String> pushTriggerQueue(TriggerParam triggerParam) {
		// avoid repeat  若包含,則說明重複執行
		if (triggerLogIdSet.contains(triggerParam.getLogId())) {
			logger.info(">>>>>>>>>>> repeate trigger job, logId:{}", triggerParam.getLogId());
			return new ReturnT<String>(ReturnT.FAIL_CODE, "repeate trigger job, logId:" + triggerParam.getLogId());
		}

		triggerLogIdSet.add(triggerParam.getLogId());
		triggerQueue.add(triggerParam);
        return ReturnT.SUCCESS;
	}

JobThread執行觸發器邏輯MethodJobHandler#execute方法

    public void execute() throws Exception {
        //利用反射調用方法
        Class<?>[] paramTypes = method.getParameterTypes();
        if (paramTypes.length > 0) {
            method.invoke(target, new Object[paramTypes.length]);       // method-param can not be primitive-types
        } else {
            method.invoke(target);
        }
    }

bean模式handler執行時利用反射調用方法。com.xxl.job.core.handler.impl.MethodJobHandler

@Override
public void execute() throws Exception {
    Class<?>[] paramTypes = method.getParameterTypes();
    if (paramTypes.length > 0) {
        method.invoke(target, new Object[paramTypes.length]);       // method-param can not be primitive-types
    } else {
        method.invoke(target);
    }
}

客戶端回調服務端

當執行器拿到調度任務後,放到隊列,任務去異步處理,結果轉成json,設置常長連接,刷入channel,直接返回
image.png
com.xxl.job.core.biz.impl.ExecutorBizImpl#run

	public ReturnT<String> pushTriggerQueue(TriggerParam triggerParam) {
		// avoid repeat  若包含,則說明重複執行
		if (triggerLogIdSet.contains(triggerParam.getLogId())) {
			logger.info(">>>>>>>>>>> repeate trigger job, logId:{}", triggerParam.getLogId());
			return new ReturnT<String>(ReturnT.FAIL_CODE, "repeate trigger job, logId:" + triggerParam.getLogId());
		}

		triggerLogIdSet.add(triggerParam.getLogId());
		triggerQueue.add(triggerParam);
        return ReturnT.SUCCESS;
	}

當客戶端執行完成後,會將處理回調參數任務加入觸發器回調處理線程
com.xxl.job.core.thread.JobThread#run

image.png

com.xxl.job.core.thread.TriggerCallbackThread#start
image.png

調用服務端回調com.xxl.job.core.thread.TriggerCallbackThread#doCallback

    private void doCallback(List<HandleCallbackParam> callbackParamList){
        boolean callbackRet = false;
        // callback, will retry if error 獲取admin地址
        for (AdminBiz adminBiz: XxlJobExecutor.getAdminBizList()) {
            try { //回調admin,返回執行結果
                ReturnT<String> callbackResult = adminBiz.callback(callbackParamList);
                if (callbackResult!=null && ReturnT.SUCCESS_CODE == callbackResult.getCode()) {
                    callbackLog(callbackParamList, "<br>----------- xxl-job job callback finish.");
                    callbackRet = true;
                    break;
                } else {
                    callbackLog(callbackParamList, "<br>----------- xxl-job job callback fail, callbackResult:" + callbackResult);
                }
            } catch (Exception e) {
                callbackLog(callbackParamList, "<br>----------- xxl-job job callback error, errorMsg:" + e.getMessage());
            }
        }
        if (!callbackRet) {
            appendFailCallbackFile(callbackParamList);
        }
    }

com.xxl.job.core.biz.client.AdminBizClient#callback

    public ReturnT<String> callback(List<HandleCallbackParam> callbackParamList) {
        return XxlJobRemotingUtil.postBody(addressUrl+"api/callback", accessToken, timeout, callbackParamList, String.class);
    }

調度中心處理回調

JobApiController#api
image.png

JobCompleteHelper#callback

	public ReturnT<String> callback(List<HandleCallbackParam> callbackParamList) {

		callbackThreadPool.execute(new Runnable() {
			@Override
			public void run() {
				for (HandleCallbackParam handleCallbackParam: callbackParamList) {
					ReturnT<String> callbackResult = callback(handleCallbackParam);
					logger.debug(">>>>>>>>> JobApiController.callback {}, handleCallbackParam={}, callbackResult={}",
							(callbackResult.getCode()== ReturnT.SUCCESS_CODE?"success":"fail"), handleCallbackParam, callbackResult);
				}
			}
		});

		return ReturnT.SUCCESS;
	}

	private ReturnT<String> callback(HandleCallbackParam handleCallbackParam) {
		// valid log item  加載log
		XxlJobLog log = XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().load(handleCallbackParam.getLogId());
		if (log == null) {  //日誌沒找到返回異常
			return new ReturnT<String>(ReturnT.FAIL_CODE, "log item not found.");
		}
		if (log.getHandleCode() > 0) { //說明重複執行
			return new ReturnT<String>(ReturnT.FAIL_CODE, "log repeate callback.");     // avoid repeat callback, trigger child job etc
		}

		// handle msg
		StringBuffer handleMsg = new StringBuffer();
		if (log.getHandleMsg()!=null) {
			handleMsg.append(log.getHandleMsg()).append("<br>");
		}
		if (handleCallbackParam.getHandleMsg() != null) {
			handleMsg.append(handleCallbackParam.getHandleMsg());
		}

		// success, save log  更改
		log.setHandleTime(new Date());
		log.setHandleCode(handleCallbackParam.getHandleCode()); //更改處理狀態,200正常,500錯誤
		log.setHandleMsg(handleMsg.toString());
		XxlJobCompleter.updateHandleInfoAndFinish(log);

		return ReturnT.SUCCESS;
	}

執行子任務

在服務器接收到任務執行結束的回調後,執行子任務

image.png

XxlJobCompleter#updateHandleInfoAndFinish

    public static int updateHandleInfoAndFinish(XxlJobLog xxlJobLog) {

        // finish 若父任務正常結束,則終止子任務,以及設置Childmsg
        finishJob(xxlJobLog);

        // text最大64kb 避免長度過長 截斷超過長度限制字符
        if (xxlJobLog.getHandleMsg().length() > 15000) {
            xxlJobLog.setHandleMsg( xxlJobLog.getHandleMsg().substring(0, 15000) );
        }

        // fresh handle 更新joblog
        return XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().updateHandleInfo(xxlJobLog);
    }

XxlJobCompleter#finishJob

private static void finishJob(XxlJobLog xxlJobLog){

        // 1、handle success, to trigger child job
        String triggerChildMsg = null;
        if (XxlJobContext.HANDLE_COCE_SUCCESS == xxlJobLog.getHandleCode()) {
            XxlJobInfo xxlJobInfo = XxlJobAdminConfig.getAdminConfig().getXxlJobInfoDao().loadById(xxlJobLog.getJobId());
            if (xxlJobInfo!=null && xxlJobInfo.getChildJobId()!=null && xxlJobInfo.getChildJobId().trim().length()>0) {
                triggerChildMsg = "<br><br><span style=\"color:#00c0ef;\" > >>>>>>>>>>>"+ I18nUtil.getString("jobconf_trigger_child_run") +"<<<<<<<<<<< </span><br>";
                //獲取子任務id
                String[] childJobIds = xxlJobInfo.getChildJobId().split(",");
                for (int i = 0; i < childJobIds.length; i++) {
                    int childJobId = (childJobIds[i]!=null && childJobIds[i].trim().length()>0 && isNumeric(childJobIds[i]))?Integer.valueOf(childJobIds[i]):-1;
                    if (childJobId > 0) {

                        //觸發子任務
                        JobTriggerPoolHelper.trigger(childJobId, TriggerTypeEnum.PARENT, -1, null, null, null);
                        ReturnT<String> triggerChildResult = ReturnT.SUCCESS;

                        // add msg
                        triggerChildMsg += MessageFormat.format(I18nUtil.getString("jobconf_callback_child_msg1"),
                                (i+1),
                                childJobIds.length,
                                childJobIds[i],
                                (triggerChildResult.getCode()==ReturnT.SUCCESS_CODE?I18nUtil.getString("system_success"):I18nUtil.getString("system_fail")),
                                triggerChildResult.getMsg());
                    } else {
                        triggerChildMsg += MessageFormat.format(I18nUtil.getString("jobconf_callback_child_msg2"),
                                (i+1),
                                childJobIds.length,
                                childJobIds[i]);
                    }
                }

            }
        }

        if (triggerChildMsg != null) {
            xxlJobLog.setHandleMsg( xxlJobLog.getHandleMsg() + triggerChildMsg );
        }

        // 2、fix_delay trigger next
        // on the way

    }

XXL-JOB的mysql分佈式鎖

當服務器存在集羣部署的時候,存在多個線程爭搶查詢數據庫,會造成下發任務重複執行。

爲了防止這種情況,設置手動提交(這裏spring會默認自動提交),查詢添加寫鎖。

在此期間其他線程訪問的時候都會阻塞等待。

這是mysql實現分佈式鎖的方式

這裏可能考慮易用性,xxljob採用的是mysql分佈式鎖,如果考慮性能可以考慮使用redis分佈式鎖。

JobScheduleHelper#start

image.png

表數據結構

當調度中心是集羣的情況下,是怎麼保證調度不會重複的呢?

在xxl-job 的官方文檔中是這樣說的

基於數據庫的集羣方案,數據庫選用Mysql;集羣分佈式併發環境中進行定時任務調度時,會在各個節點會上報任務,存到數據庫中,執行時會從數據庫中取出觸發器來執行,如果觸發器的名稱和執行時間相同,則只有一個節點去執行此任務。

其中表結構是這樣的

img

表結構如下

CREATE TABLE `xxl_job_lock` (
  `lock_name` varchar(50) NOT NULL COMMENT '鎖名稱',
  PRIMARY KEY (`lock_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

這個表裏只有一條記錄

img

然後到xxl的源碼中(因爲整個類代碼太長了,所以直截取了一小段)

public class JobScheduleHelper {
    private static Logger logger = LoggerFactory.getLogger(JobScheduleHelper.class);

    private static JobScheduleHelper instance = new JobScheduleHelper();
    public static JobScheduleHelper getInstance(){
        return instance;
    }

    public static final long PRE_READ_MS = 5000;    // pre read

    private Thread scheduleThread;
    private Thread ringThread;
    private volatile boolean scheduleThreadToStop = false;
    private volatile boolean ringThreadToStop = false;
    private volatile static Map<Integer, List<Integer>> ringData = new ConcurrentHashMap<>();

    public void start(){

        // schedule thread
        scheduleThread = new Thread(new Runnable() {
            @Override
            public void run() {

                try {
                    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);

                        preparedStatement = conn.prepareStatement(  "select * from xxl_job_lock where lock_name = 'schedule_lock' for update" );
                        preparedStatement.execute();

調度開始之前,這個調度鎖表就用到了.

這裏用到了數據庫的悲觀鎖,for update,防止了集羣同時調度的情況。

百億級海量任務調度平臺的架構與實現

對xxl-job進行大的架構改造,完成百億級海量任務調度平臺的架構與實現

具體請關注技術自由圈的後續文章........

說在最後:有問題找老架構取經

百億級海量任務調度平臺,一定是一個超級牛掰的簡歷亮點項目,黃金項目,稍微晚點把全量的架構方案和視頻進行發佈。

這個項目寫入簡歷,面試的時候如果大家能對答如流,如數家珍,基本上 面試官會被你 震驚到、吸引到。

最終,讓面試官愛到 “不能自已、口水直流”。offer, 也就來了。

在面試之前,建議大家系統化的刷一波 5000頁《尼恩Java面試寶典》V174,在刷題過程中,如果有啥問題,大家可以來 找 40歲老架構師尼恩交流。

另外,如果沒有面試機會,可以找尼恩來幫扶、領路。

  • 大齡男的最佳出路是 架構+ 管理
  • 大齡女的最佳出路是 DPM,

圖片

女程序員如何成爲DPM,請參見:

DPM (雙棲)陪跑,助力小白一步登天,升格 產品經理+研發經理

領跑模式,尼恩已經指導了大量的就業困難的小夥伴上岸。

前段時間,領跑一個40歲+就業困難小夥伴拿到了一個年薪100W的offer,小夥伴實現了 逆天改命

技術自由的實現路徑:

實現你的 架構自由:

喫透8圖1模板,人人可以做架構

10Wqps評論中臺,如何架構?B站是這麼做的!!!

阿里二面:千萬級、億級數據,如何性能優化? 教科書級 答案來了

峯值21WQps、億級DAU,小遊戲《羊了個羊》是怎麼架構的?

100億級訂單怎麼調度,來一個大廠的極品方案

2個大廠 100億級 超大流量 紅包 架構方案

… 更多架構文章,正在添加中

實現你的 響應式 自由:

響應式聖經:10W字,實現Spring響應式編程自由

這是老版本 《Flux、Mono、Reactor 實戰(史上最全)

實現你的 spring cloud 自由:

Spring cloud Alibaba 學習聖經》 PDF

分庫分表 Sharding-JDBC 底層原理、核心實戰(史上最全)

一文搞定:SpringBoot、SLF4j、Log4j、Logback、Netty之間混亂關係(史上最全)

實現你的 linux 自由:

Linux命令大全:2W多字,一次實現Linux自由

實現你的 網絡 自由:

TCP協議詳解 (史上最全)

網絡三張表:ARP表, MAC表, 路由表,實現你的網絡自由!!

實現你的 分佈式鎖 自由:

Redis分佈式鎖(圖解 - 秒懂 - 史上最全)

Zookeeper 分佈式鎖 - 圖解 - 秒懂

實現你的 王者組件 自由:

隊列之王: Disruptor 原理、架構、源碼 一文穿透

緩存之王:Caffeine 源碼、架構、原理(史上最全,10W字 超級長文)

緩存之王:Caffeine 的使用(史上最全)

Java Agent 探針、字節碼增強 ByteBuddy(史上最全)

實現你的 面試題 自由:

4800頁《尼恩Java面試寶典 》 40個專題

免費獲取11個技術聖經PDF:

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