當前位置: 妍妍網 > 碼農

@Schedule定時任務+分布式環境,這些坑你一定得註意!!!

2024-07-17碼農

來源:juejin.cn/post/7155872110252916766

👉 歡迎 ,你將獲得: 專屬的計畫實戰 / 1v1 提問 / Java 學習路線 / 學習打卡 / 每月贈書 / 社群討論

  • 新計畫: 【從零手擼:仿小紅書(微服務架構)】 正在持續爆肝中,基於 Spring Cloud Alibaba + Spring Boot 3.x + JDK 17..., ;

  • 【從零手擼:前後端分離部落格計畫(全棧開發)】 2期已完結,演示連結: http://116.62.199.48/ ;

  • 截止目前, 累計輸出 50w+ 字,講解圖 2200+ 張,還在持續爆肝中.. 後續還會上新更多計畫,目標是將 Java 領域典型的計畫都整一波,如秒殺系統, 線上商城, IM 即時通訊,Spring Cloud Alibaba 等等,

    定時任務的實作方式多種多樣,框架也是層出不窮。

    本文所談及的是 SpringBoot 本身所帶有的 @EnableScheduling @Scheduled 實作定時任務的方式。

    以及采用這種方式,在分布式排程中可能會出現的問題,又針對為什麽會發生這種問題?又該如何解決,做出了一些敘述。

    為了適合每個階段的讀者,我把前面測試的程式碼都貼出來啦~

    確保每一步都是有跡可循的,希望大家不要嫌啰嗦,感謝

    一、搭建基本環境

    基本依賴

     <parent>
    <artifactId>spring-boot-parent</artifactId>
    <groupId>org.springframework.boot</groupId>
    <version>2.7.2</version>
     </parent>
     <dependencies>
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot</artifactId>
    </dependency>
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter</artifactId>
    </dependency>
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
    <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    </dependency>
    </dependencies>

    建立個啟動類及定時任務

    @SpringBootApplication
    public class ApplicationScheduling {
    public static void main(String[] args) {
    SpringApplication.run(ApplicationScheduling. class, args);
    }
    }
    /**
     * @description:
     * @author: Ning Zaichun
     */
    @Slf4j
    @Component
    @EnableScheduling
    public class ScheduleService {
    // 每五秒執行一次,cron的運算式就不再多說明了
    @Scheduled(cron = "0/5 * * * * ? ")
    public void testSchedule() {
    log.info("當前執行任務的執行緒號ID===>{}", Thread.currentThread().getId());
    }
    }

    二、問題::執行時間延遲和單執行緒執行

    按照上面程式碼中給定的cron運算式 @Scheduled(cron = "0/5 * * * * ? ") 每五秒執行一次,那麽最近五次的執行結果應當為:

     2022-09-06 00:21:10
     2022-09-06 00:21:15
     2022-09-06 00:21:20
     2022-09-06 00:21:25
     2022-09-06 00:21:30

    如果定時任務中是執行非常快的任務的,時間非常非常短,確實不會有什麽的延遲性。

    上面程式碼執行結果:

     2022-09-06 19:42:10.018 INFO 24496 --- [ scheduling-1] com.nzc.service.ScheduleService : 當前執行任務的執行緒號ID===>64
     2022-09-06 19:42:15.015 INFO 24496 --- [ scheduling-1] com.nzc.service.ScheduleService : 當前執行任務的執行緒號ID===>64
     2022-09-06 19:42:20.001 INFO 24496 --- [ scheduling-1] com.nzc.service.ScheduleService : 當前執行任務的執行緒號ID===>64
     2022-09-06 19:42:25.005 INFO 24496 --- [ scheduling-1] com.nzc.service.ScheduleService : 當前執行任務的執行緒號ID===>64
     2022-09-06 19:42:30.007 INFO 24496 --- [ scheduling-1] com.nzc.service.ScheduleService : 當前執行任務的執行緒號ID===>64

    如果說從時間上來看,說不上什麽延遲性,但真實的業務場景中,業務的執行時間可能遠比這裏時間長。

    我主動讓執行緒睡上10秒,讓我們再來看看輸出結果是如何的吧

    @Scheduled(cron = "0/5 * * * * ? ")
    public void testSchedule() {
    try {
    Thread.sleep(10000);
    log.info("當前執行任務的執行緒號ID===>{}", Thread.currentThread().getId());
    } catch (Exception e) {
    e.printStackTrace();

    }

    輸出結果

     2022-09-06 19:46:50.019 INFO 27236 --- [ scheduling-1] com.nzc.service.ScheduleService : 當前執行任務的執行緒號ID===>64
     2022-09-06 19:47:05.024 INFO 27236 --- [ scheduling-1] com.nzc.service.ScheduleService : 當前執行任務的執行緒號ID===>64
     2022-09-06 19:47:20.016 INFO 27236 --- [ scheduling-1] com.nzc.service.ScheduleService : 當前執行任務的執行緒號ID===>64
     2022-09-06 19:47:35.005 INFO 27236 --- [ scheduling-1] com.nzc.service.ScheduleService : 當前執行任務的執行緒號ID===>64
     2022-09-06 19:47:50.006 INFO 27236 --- [ scheduling-1] com.nzc.service.ScheduleService : 當前執行任務的執行緒號ID===>64

    請註意兩個問題:

  • 執行時間延遲: 從時間上可以明顯看出,不再是每五秒執行一次,執行時間延遲很多,造成任務的

  • 單執行緒執行: 從始至終都只有一個執行緒在執行任務,造成任務的堵塞.

  • 三、為什麽會出現上述問題?

    問題的根本:執行緒阻塞式執行,執行任務執行緒數量過少。

    那到底是為什麽呢?

    回到啟動類上,我們在啟動上標明了一個 @EnableScheduling 註解。

    大家在看到諸如 @Enablexxxx 這樣的註解的時候,就要知道它一定有一個 xxxxxAutoConfiguration 的自動裝配的類。

    @EnableScheduling 也不例外,它的自動裝配的類是 TaskSchedulingAutoConfiguration

    我們來看看它到底做了一些什麽設定?我們如何修改?

    @ConditionalOn class(ThreadPoolTaskScheduler. class)
    @Configuration(proxyBeanMethods = false)
    @EnableConfigurationProperties(TaskSchedulingProperties. class)
    @AutoConfigureAfter(TaskExecutionAutoConfiguration. class)
    public class TaskSchedulingAutoConfiguration {
    @Bean
    @ConditionalOnBean(name = TaskManagementConfigUtils.SCHEDULED_ANNOTATION_PROCESSOR_BEAN_NAME)
    @ConditionalOnMissingBean({ SchedulingConfigurer. class, TaskScheduler. class, ScheduledExecutorService. class })
    public ThreadPoolTaskScheduler taskScheduler(TaskSchedulerBuilder builder) {
    return builder.build();
    }
    // ......
    }

    可以看到它也是構造了一個 執行緒池註入到Spring 中

    build() 呼叫繼續看下去,

     public ThreadPoolTaskScheduler build() {
    return configure(new ThreadPoolTaskScheduler());
     }
    ThreadPoolTaskScheduler`中,給定的執行緒池的核心參數就為1,這也表明了之前為什麽只有一條執行緒在執行任務。`private volatile int poolSize = 1;

    這一段是分開的用程式碼不好展示,我用圖片標明出來。

    圖片

    主要邏輯在這裏,建立執行緒池的時候,只使用了三個參數,剩下的都是使用 ScheduledExecutorService 的預設的參數

    protected ScheduledExecutorService createExecutor(
    int poolSize, ThreadFactory threadFactory, RejectedExecutionHandler rejectedExecutionHandler) 

    而這預設參數是不行的,生產環境的大坑,阿裏的 Java 開發手冊中也明確規定,要手動建立執行緒池,並給定合適的參數值~是為什麽呢?

    因為預設的執行緒池中, 池中允許的最大執行緒數和最大任務等待佇列都是 Integer.MAX_VALUE .

    圖片

    大家都懂的,如果使用這玩意,只要出了問題,必定掛~

    configure(new ThreadPoolTaskScheduler()) 這裏就是構造,略過~

    如果已經較為熟悉SpringBoot的朋友,現在已然明白解決當前問題的方式~

    四、解決方式

    1、 @EnableConfigurationProperties(TaskSchedulingProperties. class) ,自動裝配類通常也都會對應有個 xxxxProperties 檔滴, TaskSchedulingProperties 也確實可以配置核心執行緒數等基本參數,但是無法配置執行緒池中最大的執行緒數量和等待佇列數量,這種方式還是不合適的。

    2、可以手動異步編排,交給某個執行緒池來執行。

    3、將定時任務加上異步註解 @Async ,將其改為異步的定時任務,另外自訂一個系統通用的執行緒池,讓異步任務使用該執行緒執行任務~

    我們分別針對上述三種方式來實作一遍

    4.1、修改配置檔

    可以配置的就下面幾項~

     spring:
    task:
    scheduling:
    thread-name-prefix: nzc-schedule- #執行緒名字首
    pool:
    size: 10 #核心執行緒數
    # shutdown:
    # await-termination: true #執行程式是否應等待計劃任務在關機時完成。
    # await-termination-period: #執行程式應等待剩余任務完成的最長時間。

    測試結果:

     2022-09-06 20:49:15.015 INFO 7852 --- [ nzc-schedule-1] com.nzc.service.ScheduleService : 當前執行任務的執行緒號ID===>64
     2022-09-06 20:49:30.004 INFO 7852 --- [ nzc-schedule-2] com.nzc.service.ScheduleService : 當前執行任務的執行緒號ID===>66
     2022-09-06 20:49:45.024 INFO 7852 --- [ nzc-schedule-1] com.nzc.service.ScheduleService : 當前執行任務的執行緒號ID===>64
     2022-09-06 20:50:00.025 INFO 7852 --- [ nzc-schedule-3] com.nzc.service.ScheduleService : 當前執行任務的執行緒號ID===>67
     2022-09-06 20:50:15.023 INFO 7852 --- [ nzc-schedule-2] com.nzc.service.ScheduleService : 當前執行任務的執行緒號ID===>66
     2022-09-06 20:50:30.008 INFO 7852 --- [ nzc-schedule-4] com.nzc.service.ScheduleService : 當前執行任務的執行緒號ID===>68

    請註意:這裏的配置並非是一定生效的,修改後有可能成功,有可能失敗,具體原因未知,但這一點是真實存在的。

    不過從執行結果中可以看出,這裏的執行的執行緒不再是孤單單的一個。

    4.2、執行邏輯改為異步執行

    首先我們先向Spring中註入一個我們自己編寫的執行緒池,參數自己設定即可,我這裏比較隨意。

     @Configuration
     public class MyTheadPoolConfig {
    @Bean
    public TaskExecutor taskExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    //設定核心執行緒數
    executor.setCorePoolSize(10);
    //設定最大執行緒數
    executor.setMaxPoolSize(20);
    //緩沖佇列200:用來緩沖執行任務的佇列
    executor.setQueueCapacity(200);
    //執行緒活路時間 60 秒
    executor.setKeepAliveSeconds(60);
    //執行緒池名的字首:設定好了之後可以方便我們定位處理任務所在的執行緒池
    // 這裏我繼續沿用 scheduling 預設的執行緒名字首
    executor.setThreadNamePrefix("nzc-create-scheduling-");
    //設定拒絕策略
    executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
    executor.setWaitForTasksToCompleteOnShutdown(true);
    return executor;
    }
     }

    然後在定時任務這裏註入進去:

     /**
    * @description:
    * @author: Ning Zaichun
    */
     @Slf4j
     @Component
     @EnableScheduling
     public class ScheduleService {
    @Autowired
    TaskExecutor taskExecutor;
    @Scheduled(cron = "0/5 * * * * ? ")
    public void testSchedule() {
    CompletableFuture.runAsync(()->{
    try {
    Thread.sleep(10000);
    log.info("當前執行任務的執行緒號ID===>{}", Thread.currentThread().getId());
    } catch (Exception e) {
    e.printStackTrace();

    },taskExecutor);
    }
     }

    測試結果:

     2022-09-06 21:00:00.019 INFO 18356 --- [te-scheduling-1] com.nzc.service.ScheduleService : 當前執行任務的執行緒號ID===>66
     2022-09-06 21:00:05.022 INFO 18356 --- [te-scheduling-2] com.nzc.service.ScheduleService : 當前執行任務的執行緒號ID===>67
     2022-09-06 21:00:10.013 INFO 18356 --- [te-scheduling-3] com.nzc.service.ScheduleService : 當前執行任務的執行緒號ID===>68
     2022-09-06 21:00:15.020 INFO 18356 --- [te-scheduling-4] com.nzc.service.ScheduleService : 當前執行任務的執行緒號ID===>69
     2022-09-06 21:00:20.026 INFO 18356 --- [te-scheduling-5] com.nzc.service.ScheduleService : 當前執行任務的執行緒號ID===>70

    可以看到雖然業務執行時間比較長,但是木有再出現,延遲執行定時任務的情況。

    4.3、異步定時任務

    異步定時任務其實和上面的方式原理是一樣的,不過實作稍稍不同罷了。

    在定時任務的類上再加一個 @EnableAsync 註解,給方法添加一個 @Async 即可。

    不過一般@Async都會指定執行緒池,比如寫成這樣 @Async(value = "taskExecutor") ,

     /**
    * @description:
    * @author: Ning Zaichun
    */
     @Slf4j
     @Component
     @EnableAsync
     @EnableScheduling
     public class ScheduleService {
    @Autowired
    TaskExecutor taskExecutor;
    @Async(value = "taskExecutor")
    @Scheduled(cron = "0/5 * * * * ? ")
    public void testSchedule() {
    try {
    Thread.sleep(10000);
    log.info("當前執行任務的執行緒號ID===>{}", Thread.currentThread().getId());
    } catch (Exception e) {
    e.printStackTrace();

    }
     }

    執行結果:

     2022-09-06 21:10:15.022 INFO 22760 --- [zc-scheduling-1] com.nzc.service.ScheduleService : 當前執行任務的執行緒號ID===>66
     2022-09-06 21:10:20.021 INFO 22760 --- [zc-scheduling-2] com.nzc.service.ScheduleService : 當前執行任務的執行緒號ID===>67
     2022-09-06 21:10:25.007 INFO 22760 --- [zc-scheduling-3] com.nzc.service.ScheduleService : 當前執行任務的執行緒號ID===>68
     2022-09-06 21:10:30.020 INFO 22760 --- [zc-scheduling-4] com.nzc.service.ScheduleService : 當前執行任務的執行緒號ID===>69
     2022-09-06 21:10:35.007 INFO 22760 --- [zc-scheduling-5] com.nzc.service.ScheduleService : 當前執行任務的執行緒號ID===>70

    結果顯而易見是可行的啦~

    分析:

    @EnableAsync`註解相應的也有一個自動裝配類為`TaskExecutionAutoConfiguration

    也有一個 TaskExecutionProperties 配置類,可以在yml檔中對參數進行設定,這裏的話是可以配置執行緒池最大存活數量的。

    它的預設核心執行緒數為8,這裏我不再進行演示了,同時它的執行緒池中最大存活數量以及任務等待數量也都為 Integer.MAX_VALUE ,這也是不建議大家使用預設執行緒池的原因。

    4.4、小結

     /**
    * 定時任務
    * 1、@EnableScheduling 開啟定時任務
    * 2、@Scheduled開啟一個定時任務
    * 3、自動裝配類 TaskSchedulingAutoConfiguration
    *
    * 異步任務
    * 1、@EnableAsync:開啟異步任務
    * 2、@Async:給希望異步執行的方法標註
    * 3、自動裝配類 TaskExecutionAutoConfiguration
    */

    實作方式雖不同,但從效率而言,並無太大區別,覺得那種合適使用那種便可。

    不過總結起來,考查的都是對執行緒池的理解,對於執行緒池的了解是真的非常重要的,也很有用處。

    五、分布式下的思考

    針對上述情況而言,這些解決方法在不引入第三包的情況下是足以應付大部份情況了。

    定時框架的實作有許多方式,在此並非打算討論這個。

    在單體計畫中,也許上面的問題是解決了,但是站在分布式的情況下考慮,就並非是安全的了。

    當多個計畫在同時執行,那麽必然會有多個計畫同時這段程式碼。

    思考:並行執行

    如果一個定時任務同時在多個機器中執行,會產生怎麽樣的問題?

    假如這個定時任務是收集某個資訊,發送給訊息佇列,如果多台機器同時執行,同時給訊息佇列發送資訊,那麽必然導致之後產生一系列的臟數據。這是非常不可靠的

    解決方式:分布式鎖

    很簡單也不簡單,加分布式鎖~ 或者是用一些分布式排程的框架

    如使用XXL-JOB實作,或者是其他的定時任務框架。

    大家在執行這個定時任務之前,先去獲取一把分布式鎖,獲取到了就執行,獲取不到就直接結束。

    我這裏使用的是 redission,因為方便,打算寫分布式鎖的文章,還在準備當中。

    加入依賴:

     <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
     </dependency>
     <dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.17.6</version>
     </dependency>

    按照文件說的,編寫配置類,註入 RedissonClient redisson 的全部操作都是基於此。

     /**
    * @description:
    * @author: Ning Zaichun
    */
     @Configuration
     public class MyRedissonConfig {
    /**
    * 所有對Redisson的使用都是透過RedissonClient
    * @return
    * @throws IOException
    */
    @Bean(destroyMethod="shutdown")
    public RedissonClient redissonClient() throws IOException {
    //1、建立配置
    Config config = new Config();
    // 這裏規定要用 redis://+IP地址
    config.useSingleServer().setAddress("redis://xxxxx:6379").setPassword("000415"); // 有密碼就寫密碼~ 木有不用寫~
    //2、根據Config建立出RedissonClient例項
    //Redis url should start with redis:// or rediss://
    RedissonClient redissonClient = Redisson.create(config);
    return redissonClient;
    }
     }

    修改定時任務:

     /**
    * @description:
    * @author: Ning Zaichun
    */
     @Slf4j
     @Component
     @EnableAsync
     @EnableScheduling
     public class ScheduleService {
    @Autowired
    TaskExecutor taskExecutor;
    @Autowired
    RedissonClient redissonClient;
    private final String SCHEDULE_LOCK = "schedule:lock";
    @Async(value = "taskExecutor")
    @Scheduled(cron = "0/5 * * * * ? ")
    public void testSchedule() {
    //分布式鎖
    RLock lock = redissonClient.getLock(SCHEDULE_LOCK);
    try {
    //加鎖 10 為時間,加上時間 預設會去掉 redisson 的看門狗機制(即自動續鎖機制)
    lock.lock(10, TimeUnit.SECONDS);
    Thread.sleep(10000);
    log.info("當前執行任務的執行緒號ID===>{}", Thread.currentThread().getId());
    } catch (Exception e) {
    e.printStackTrace();
    } finally {
    // 一定要記得解鎖~
    lock.unlock();
    }
    }
     }


    這裏只是給出個大概的實作,實際上還是可以最佳化的,比如在給定一個flag,在獲取鎖之前判斷。如果有人搶到鎖,就修改這個值,之後的請求,判斷這個flag,如果不是預設的值,則直接結束任務等等。

    思考:繼續往深處思考,在分布式情況下如果一個定時任務搶到鎖,但是它在執行業務過程中失敗或者是宕機了,這又該如何處理呢?如何補償呢?

    個人思考:

    失敗還比較好說,我們可以直接 try{}catch(){} 中進行通知告警,及時檢查出問題。

    如果是掛了,我還沒想好怎麽做。

    後記

    但實際上,我所闡述的這種方式,只能說適用於簡單的單體計畫,一旦牽扯到動態定時任務,使用這種方式就不再那麽方便了。

    大部份都是使用定時任務框架整合了,尤其是分布式排程遠比單體計畫需要考慮多的多。

    希望大家有所收獲

    👉 歡迎 ,你將獲得: 專屬的計畫實戰 / 1v1 提問 / Java 學習路線 / 學習打卡 / 每月贈書 / 社群討論

  • 新計畫: 【從零手擼:仿小紅書(微服務架構)】 正在持續爆肝中,基於 Spring Cloud Alibaba + Spring Boot 3.x + JDK 17..., ;

  • 【從零手擼:前後端分離部落格計畫(全棧開發)】 2期已完結,演示連結: http://116.62.199.48/ ;

  • 截止目前, 累計輸出 50w+ 字,講解圖 2200+ 張,還在持續爆肝中.. 後續還會上新更多計畫,目標是將 Java 領域典型的計畫都整一波,如秒殺系統, 線上商城, IM 即時通訊,Spring Cloud Alibaba 等等,


    1. 

    2. 

    3. 

    4. 

    最近面試BAT,整理一份面試資料Java面試BATJ通關手冊,覆蓋了Java核心技術、JVM、Java並行、SSM、微服務、資料庫、數據結構等等。

    獲取方式:點「在看」,關註公眾號並回復 Java 領取,更多內容陸續奉上。

    PS:因公眾號平台更改了推播規則,如果不想錯過內容,記得讀完點一下在看,加個星標,這樣每次新文章推播才會第一時間出現在你的訂閱列表裏。

    「在看」支持小哈呀,謝謝啦