專案終於用上了xxl-job,真香!

碼猿技術專欄發表於2023-04-27

大家好,我是不才陳某~

本篇文章主要記錄專案中遇到的 xxl-job 的實戰,希望能透過這篇文章告訴讀者們什麼是 xxl-job 以及怎麼使用 xxl-job 並分享一個實戰案例。

文章首發公眾號:碼猿技術專欄

關注公眾號:碼猿技術專欄,回覆關鍵詞:1111 獲取阿里內部java效能調優手冊!

那麼下面先說明什麼是 xxl-job 以及為什麼要使用它。

xxl-job 是什麼?

XXL-JOB 是一個分散式任務排程平臺,其核心設計目標是開發迅速、學習簡單、輕量級、易擴充套件。

設計思想 是將排程行為抽象形成 排程中心 平臺,平臺本身不承擔業務邏輯,而是負責發起 排程請求 後,由 執行器 接收排程請求並執行 任務,這裡的 任務 抽象為 分散的 JobHandler。透過這種方式即可實現 排程任務 相互解耦,從而提高系統整體的穩定性和擴充性。

為了更好理解,這裡放一張官網的架構圖:

任務排程是什麼?

在開發專案時大家是否也遇到過類似的場景問題:

  • 系統需要定時在每天0點進行資料備份。
  • 系統需要在活動開始前幾小時預熱執行一些前置業務。
  • 系統需要定時對 MQ 訊息表的傳送裝填,對傳送失敗的 MQ 訊息進行補償重新傳送。

這些場景問題都可以透過 任務排程 來解決,任務排程指的是系統在約定的指定時間自動去執行指定的任務的過程。

單體系統 中有許多實現 任務排程 的方式,如多執行緒方式、Timer 類、Spring Tasks 等等。這裡比較常用的是 Spring Tasks(透過 @EnableScheduling + @Scheduled 的註解可以自定義定時任務,有興趣的可以去了解一下)

為什麼需要分散式任務排程平臺?

分散式下,每個服務都可以搭建為叢集,這樣的好處是可以將任務切片分給每一個服務從而實現並行執行,提高任務排程的處理效率。那麼為什麼 分散式系統 不能使用 單體系統 的任務排程實現方式呢。

在叢集服務下,如果還是使用每臺機器按照單體系統的任務排程實現方式實現的話,會出現下面這四個問題:

  1. 怎麼做到對任務的控制(如何避免任務重複執行)。
  2. 如果某臺機器當機了,會不會存在任務丟失。
  3. 如果要增加服務例項,怎麼做到彈性擴容。
  4. 如何做到對任務排程的執行情況統一監測。

透過上面的問題可以瞭解到分散式系統下需要一個滿足高可用、容錯管理、負載均衡等功能的任務排程平臺來實現任務排程。分散式系統下,也有許多可以實現任務排程的第三方的分散式任務排程系統,如 xxl-job、Quartz、elastic-job 等等常用的分散式任務排程系統。

如何使用 xxl-job

作為開源軟體的 xxl-job,可以在 github 或 gitee上檢視和下載 xxl-job 的原始碼。

下面將介紹我使用 xxl-job 的流程(如果有操作不當的,可以檢視官方的中文文件:https://www.xuxueli.com/xxl-job

dokcer 下安裝 xxl-job

1、docker 下拉取 xxl-job 的映象(這裡使用 2.3.1 版本)

docker pull xuxueli/xxl-job-admin:2.3.1

2、建立對映容器的檔案目錄

mkdir -p -m 777 /mydata/xxl-job/data/applogs

3、在 /mydata/xxl-job 的目錄下建立 application.properties 檔案

由於 application.properties 的程式碼過長,這裡就不展示了,需要的可以去 gitee 上獲取,具體路徑如圖:

這裡需要注意資料庫位置的填寫:

如果還需要更改埠的可以更改這裡:

這裡還需要注意告警郵箱和訪問口令(後續Spring Boot配置用到):

4、將 tables_xxl-job.sql 檔案匯入上面步驟3指定的資料庫(自己填寫的那個資料庫)

同樣由於檔案程式碼過長,這裡展示 gitee 上獲取的路徑圖:

5、執行 docker 命令

注意這裡的 -p 8088:8088 是因為我更改了前面 application.porperties 檔案的埠號為 8088,所以這裡我執行的 docker 命令為 -p 8088:8088,如果沒有更改的這裡一定要改為 -p 8080:8080

docker run  -p 8088:8088 \
-d --name=xxl-job-admin --restart=always \
-v /mydata/xxl-job/application.properties:/application.properties \
-v /mydata/xxl-job/data/applogs:/data/applogs \
-e PARAMS='--spring.config.location=/application.properties' xuxueli/xxl-job-admin:2.3.1

執行後透過 docker ps 檢視是否成功執行,如果失敗可以透過 docker logs xxl-job-admin 檢視具體錯誤日誌。

6、透過 http://192.168.101.25:8088/xxl-job-admin/ 訪問(這裡ip和埠是自己的)

賬號:admin 密碼:123456

到這裡就算是完成了 xxl-job 在 docker 的搭建。

Spring Boot 專案整合 xxl-job

xxl-job 由 排程中心執行器 組成,上面已經完成了在 docker 上部署排程中心了,接下來介紹怎麼配置部署執行器專案

1、在 Spring Boot 專案中匯入 maven 依賴

<dependency>
    <groupId>com.xuxueli</groupId>
    <artifactId>xxl-job-core</artifactId>
    <version>2.3.1</version>
</dependency>

這裡需要注意版本號與 xxl-job 版本需要一致,這裡我配置的都是 2.3.1 版本。

2、在 Spring Boot 專案中配置 application.yml 檔案

xxl:
  job:
    admin:
      addresses: http://192.168.101.25:8088/xxl-job-admin
    executor:
      appname: media-process-service
      address:
      ip:
      port: 9999
      logpath: /data/applogs/xxl-job/jobhandler
      logretentiondays: 30
    accessToken: default_token
  • 這裡的 xxl.job.admin.addresses 用於指定排程中心的地址。
  • 這裡的 xxl.job.accessToken 用於指定訪問口令(也就是前面搭建 xxl-job 中步驟3指定的)。
  • 這裡的 xxl.job.executor.appname 用於指定執行器的名稱(需要與後續配置執行器的名稱一致)。
  • 這裡的 xxl.job.executor.port 用於指定執行器的埠(執行器實際上是一個內嵌的 Server,預設埠為9999,配置多個同一服務例項時需要指定不同的執行器埠,否則會埠衝突)。
  • 其他屬性只需要照著配置即可(想要了解屬性的具體含義可以檢視中文文件中的2.4配置部署執行器專案章節)。

3、編寫配置類

/**
 * XXL-JOB配置類
 *
 * @author 公眾號:碼猿技術專欄
 */
@Slf4j
@Configuration
public class XxlJobConfig {
    @Value("${xxl.job.admin.addresses}")
    private String adminAddresses;

    @Value("${xxl.job.accessToken}")
    private String accessToken;

    @Value("${xxl.job.executor.appname}")
    private String appname;

    @Value("${xxl.job.executor.address}")
    private String address;

    @Value("${xxl.job.executor.ip}")
    private String ip;

    @Value("${xxl.job.executor.port}")
    private int port;

    @Value("${xxl.job.executor.logpath}")
    private String logPath;

    @Value("${xxl.job.executor.logretentiondays}")
    private int logRetentionDays;

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

4、排程中心中新增執行器

執行器的配置屬性:

  • AppName: 每個執行器叢集的唯一標示 AppName,執行器會週期性以 AppName 為物件進行自動註冊。可透過該配置自動發現註冊成功的執行器,供任務排程時使用。
  • 名稱: 執行器的名稱(可以使用中文更好地體現該執行器是用來幹嘛的)。
  • 註冊方式:排程中心獲取執行器地址的方式(一般為了方便可以選用自動註冊即可)。

    • 自動註冊:執行器自動進行執行器註冊,排程中心透過底層登錄檔可以動態發現執行器機器地址。
    • 手動錄入:人工手動錄入執行器的地址資訊,多地址逗號分隔,供排程中心使用。
  • 機器地址:"註冊方式"為"手動錄入"時有效,支援人工維護執行器的地址資訊。

5、配置自定義任務

配置自定義任務有許多種模式,如 Bean模式(基於方法)、Bean模式(基於類)、GLUE模式等等。這裡介紹透過 Bean模式(基於方法) 是如何自定義任務的(對於其餘的模式可以參考官方文件)。

Bean模式(基於方法)也就是每個任務對應一個方法,透過新增 @XxLJob(value="自定義JobHandler名稱", init = "JobHandler初始化方法", destroy = "JobHandler銷燬方法") 註解即可完成定義。

/**
 * 任務處理類
 *
 * @author 公眾號:碼猿技術專欄
 */
@Component
public class TestJob {
    /**
     * 測試任務
     */
    @XxlJob("testHandler")
    public void testHandler() {
        XxlJobHelper.handleSuccess("本次測試任務排程成功");
    }
}
  • 透過註解也可以指定 初始化方法和銷燬方法,如果不填寫可以直接寫一個 自定義的JobHandler名稱 用於後面在排程中心中配置任務時對應任務的 JobHandler 屬性值。
  • 可以透過 XxlJobHelper.log 來列印日誌,透過排程中心可以檢視執行日誌的情況。
  • 可以透過 XxlJobHelper.handleFailXxlJobHelper.handleSuccess 手動設定任務排程的結果(不設定時預設結果為成功狀態,除非任務執行時出現異常)。

6、排程中心中新增任務

這裡主要注意 Cron 表示式的時間配置以及 JobHandler 的值需要與自定義任務方法的註解上的 value 屬性值一致即可。

關於高階配置這裡放一張中文文件的詳細說明(也可以直接去看文件):

需要搭建叢集或過期策略等高階玩法時可以進行配置。

到這裡就完成了 SpringBoot 整合 xxl-job 實現分散式任務排程的全過程了,接下來會透過一個實戰案例來具體看看 xxl-job 的用處。

xxl-job 實戰

下面透過一個最近自己在跟著做的學習專案中使用到 xxl-job 的場景案例來具體瞭解一下如何利用 xxl-job 來實現任務排程。

實戰背景

當前專案需要對上傳到分散式檔案系統 minio 中的影片檔案進行統一格式的影片轉碼操作,由於本身影片轉碼操作會帶了很大的時間消耗以及 CPU 的開銷,所以考慮叢集服務下使用 xxl-job 的方式以任務排程的方式定時處理影片轉碼操作。

這樣可以帶來兩個好處:① 以任務排程的方式,可以使得影片轉碼操作不會阻塞主執行緒,避免影響主要業務的吞吐量; ② 以叢集服務分片接收任務的方式,可以將任務均分給每個機器使得任務排程可以並行執行,提高總任務處理時間以及降低單臺機器 CPU 的開銷;

xxl-job 執行流程圖

xxl-job實戰.png

怎麼將任務均分給每臺伺服器?

由於任務執行時間過長,需要搭建叢集服務來做到並行任務排程,從而減小 CPU 的開銷,那麼怎麼均分任務呢?

利用 xxl-job 在叢集部署時,配置路由策略中選擇 分片廣播 的方式,可以使一次任務排程會廣播觸發叢集中所有的執行器執行一次任務,並且可以向系統傳遞分片引數。

利用這一特性可以根據 當前執行器的分片序號和分片總數 來獲取對應的任務記錄。

先來看看 Bean 模式下怎麼獲取分片序號和分片總數:

// 分片序號(當前執行器序號)
int shardIndex = XxlJobHelper.getShardIndex();
// 分片總數(執行器總數)
int shardTotal = XxlJobHelper.getShardTotal();

有了這兩個屬性,當執行器掃描資料庫獲取記錄時,可以根據 取模 的方式獲取屬於當前執行器的任務,可以這樣編寫 sql 獲取任務記錄:

select * from media_process m
where m.id % #{shareTotal} = #{shareIndex}  
  and (m.status = '1' or m.status = '3')
  and m.fail_count &lt; 3
limit #{count}

掃描任務表,根據任務 id 對分片總數 取模 來實現對所有分片的均分任務,透過判斷是否是當前分片序號,並且當前任務狀態為 1(未處理)或 3(處理失敗)並且當前任務失敗次數小於3次時可以取得當前任務。每次掃描只取出 count 個任務數(批次處理)。

因此透過 xxl-job 的分片廣播 + 取模 的方式即可實現對叢集服務均分任務的操作。

怎麼確保任務不會被重複消費?

由於影片轉碼本身處理時間就會比較長,所以更不允許服務重複執行,雖然上面透過分片廣播+取模的方式提高了任務不會被重複執行的機率,但是依舊存在如下情況:

如下圖,有三臺叢集機器和六個任務,剛開始分配好了每臺機器兩個任務,執行器0正準備執行任務3時,剛好執行器2當機了,此時執行器1剛好執行一次任務,因為分片總數減小,導致執行器1重新分配到需要執行的任務正好也是任務3,那麼此時就會出現執行器0和執行器1都在執行任務3的情況。

那麼這種情況就需要實現冪等性了,冪等性有很多種實現方法,有興趣瞭解的可以參考:介面冪等性的實現方案

這裡使用樂觀鎖的方式實現冪等性,具體 sql 如下:

update media_process m
set m.status = '2'
where (m.status = '1' or m.status = '3')
  and m.fail_count &lt; 3
  and m.id = #{id}

這裡只需要依靠任務的狀態即可實現(未處理1;處理中2;處理失敗3;處理成功4),可以看到這裡類似於 CAS 的方式透過比較和設定的方式只有在狀態為未處理或處理失敗時才能設定為處理中。這樣在併發場景下,即使多個執行器同時處理該任務,也只有一個任務可以設定成功進入處理任務階段。

為了真正達到冪等性,還需要設定一下 xxl-job 的排程過期策略和阻塞處理策略來保證真正的冪等性。分別設定為 忽略(排程過期後,忽略過期的任務,從當前時間開始重新計算下次觸發時間) 和 丟棄後續排程(排程請求進入單機執行器後,發現執行器存在執行的排程任務,本次請求將會被丟棄並標記為失敗)。


編寫完成該功能所需的所有任務

1、分片影片轉碼處理

程式碼(這裡的程式碼只展示部分核心步驟程式碼):

/**
 * 影片轉碼處理任務
 * 公眾號:碼猿技術專欄
 */
@XxlJob("videoTranscodingHandler")
public void videoTranscodingHandler() throws InterruptedException {
    // 1. 分片獲取當前執行器需要執行的所有任務
    List<MediaProcess> mediaProcessList = mediaProcessService.getMediaProcessList(shardIndex, shardTotal, count);
    // 透過JUC工具類阻塞直到所有任務執行完
    CountDownLatch countDownLatch = new CountDownLatch(mediaProcessList.size());
    // 遍歷所有任務
    mediaProcessList.forEach(mediaProcess -> {
        // 以多執行緒的方式執行所有任務
        executor.execute(() -> {
            try {
                // 2. 嘗試搶佔任務(透過樂觀鎖實現)
                boolean res = mediaProcessService.startTask(id);
                if (!res) {
                    XxlJobHelper.log("任務搶佔失敗,任務id{}", id);
                    return;
                }
                
                // 3. 從minio中下載影片到本地
                File file = mediaFileService.downloadFileFromMinIO(bucket, objectName);
                // 下載失敗
                if (file == null) {
                    XxlJobHelper.log("下載影片出錯,任務id:{},bucket:{},objectName:{}", id, bucket, objectName);
                    // 出現異常重置任務狀態為處理失敗等待下一次處理
                    mediaProcessService.saveProcessFinishStatus(id, Constants.MediaProcessCode.FAIL.getValue(), fileId, null, "下載影片到本地失敗");
                    return;
                }
                
                // 4. 影片轉碼
                String result = videoUtil.generateMp4();
                if (!result.equals("success")) {
                    XxlJobHelper.log("影片轉碼失敗,原因:{},bucket:{},objectName:{},", result, bucket, objectName);
                    // 出現異常重置任務狀態為處理失敗等待下一次處理
                    mediaProcessService.saveProcessFinishStatus(id, Constants.MediaProcessCode.FAIL.getValue(), fileId, null, "影片轉碼失敗");
                    return;
                }
                
                // 5. 上傳轉碼後的檔案
                boolean b1 = mediaFileService.addMediaFilesToMinIO(new_File.getAbsolutePath(), "video/mp4", bucket, objectNameMp4);
                if (!b1) {
                    XxlJobHelper.log("上傳 mp4 到 minio 失敗,任務id:{}", id);
                    // 出現異常重置任務狀態為處理失敗等待下一次處理
                    mediaProcessService.saveProcessFinishStatus(id, Constants.MediaProcessCode.FAIL.getValue(), fileId, null, "上傳 mp4 檔案到 minio 失敗");
                    return;
                }

                // 6. 更新任務狀態為成功
                mediaProcessService.saveProcessFinishStatus(id, Constants.MediaProcessCode.SUCCESS.getValue(), fileId, url, "建立臨時檔案異常");
                
            } finally {
                countDownLatch.countDown();
            }
        });
    });
    // 阻塞直到所有方法執行完成(30min後不再等待)
    countDownLatch.await(30, TimeUnit.MINUTES);
}

核心任務 - 分片獲取任務後執行影片轉碼任務,步驟如下:

  • 透過 分片廣播拿到的引數以取模的方式 獲取當前執行器所屬的任務記錄集合
  • 遍歷集合,以 多執行緒的方式 併發地執行任務
  • 每次執行任務前需要先透過 資料庫樂觀鎖的方式 搶佔當前任務,搶佔到才能執行
  • 執行任務過程分為 分散式檔案系統下載需要轉碼的影片檔案 -> 影片轉碼 -> 上傳轉碼後的影片 -> 更新任務狀態(處理成功)
  • 使用JUC工具類 CountDownLatch 實現所有任務執行完後才退出方法
  • 中間使用 xxl-job 的日誌記錄錯誤資訊和執行結果

2、清理任務表中轉碼成功的任務的記錄並將其插入任務歷史表

由於任務表處理完任務後只是更新任務狀態,這樣隨著任務增多會導致檢索起來時間消耗過大,所以使用任務排程的方式定期掃描任務表,將任務狀態為處理成功的任務刪除並重新插入任務歷史表中留存(由於程式碼過於簡單,這裡就不做展示了)。

主要實現兩個功能:① 清理任務表中已成功處理的任務; ② 將處理成功的任務記錄插入歷史表中;

3、影片補償機制

由於使用樂觀鎖會將任務狀態更新為處理中,如果此時執行任務的執行器(服務)當機了,會導致該任務記錄一直存在,因為樂觀鎖的原因別的執行器也無法獲取,這個時候同樣需要使用任務排程的方式,定期掃描任務表,判斷任務是否處於處理中狀態並且任務建立時間遠大於30分鐘,則說明任務超時了,則是使用任務排程的方式重新更新任務的狀態為未處理,等待下一次影片轉碼任務的排程處理。此外影片補償機制任務排程還需要檢查是否存在任務最大次數已經大於3次的,如果存在則交付給人工處理(由於程式碼過於簡單,這裡就不做展示了)。

主要實現兩個功能:① 處理任務超時情況下的任務,做出補償; ② 處理失敗次數大於3次的任務,做出補償;

測試並檢視日誌

準備好的任務表記錄:

啟動三臺媒資伺服器,並開啟任務:

可以單獨檢視每個任務的日誌:

透過日誌中的執行日誌檢視具體日誌資訊:

可以看到直接為了測試改錯的路徑導致下載影片出錯:

檢視資料庫表的變化:

到這裡可以看到核心的影片轉碼任務執行成功,並且邏輯正確,能夠起到分散式任務排程的作用。

總結

這就是本次 xxl-job 實戰的全部內容了,寫這篇文章主要是為了記錄一下專案中是如何使用 xxl-job 的,並且提供一種分片廣播均分任務的思路以及冪等性問題如何處理,具體使用 xxl-job 還需根據自己專案的需求,遇到問題可以參考官網

最後說一句(別白嫖,求關注)

陳某每一篇文章都是精心輸出,如果這篇文章對你有所幫助,或者有所啟發的話,幫忙點贊在看轉發收藏,你的支援就是我堅持下去的最大動力!

關注公眾號:【碼猿技術專欄】,公眾號內有超讚的粉絲福利,回覆:加群,可以加入技術討論群,和大家一起討論技術,吹牛逼!

相關文章