Schedule定時任務在分布式產生的問題詳解
正文
定時任務的實現(xiàn)方式多種多樣,框架也是層出不窮。
本文所談及的是 SpringBoot 本身所帶有的@EnableScheduling 、 @Scheduled實現(xiàn)定時任務的方式。
以及采用這種方式,在分布式調度中可能會出現(xiàn)的問題,又針對為什么會發(fā)生這種問題?又該如何解決,做出了一些敘述。
為了適合每個階段的讀者,我把前面測試的代碼都貼出來啦~
確保每一步都是有跡可循的,希望大家不要嫌啰嗦,感謝
一、搭建基本環(huán)境
基本依賴
<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>
創(chuàng)建個啟動類及定時任務
@SpringBootApplication
public class ApplicationScheduling {
public static void main(String[] args) {
SpringApplication.run(ApplicationScheduling.class, args);
}
}
/**
* @description:
* @author: Ning Zaichun
* @date: 2022年09月06日 0:02
*/
@Slf4j
@Component
@EnableScheduling
public class ScheduleService {
// 每五秒執(zhí)行一次,cron的表達式就不再多說明了
@Scheduled(cron = "0/5 * * * * ? ")
public void testSchedule() {
log.info("當前執(zhí)行任務的線程號ID===>{}", Thread.currentThread().getId());
}
}
二、問題::執(zhí)行時間延遲和單線程執(zhí)行
按照上面代碼中給定的cron表達式@Scheduled(cron = "0/5 * * * * ? ")每五秒執(zhí)行一次,那么最近五次的執(zhí)行結果應當為:
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
如果定時任務中是執(zhí)行非??斓娜蝿盏模瑫r間非常非常短,確實不會有什么的延遲性。
上面代碼執(zhí)行結果:
2022-09-06 19:42:10.018 INFO 24496 --- [ scheduling-1] com.nzc.service.ScheduleService : 當前執(zhí)行任務的線程號ID===>64
2022-09-06 19:42:15.015 INFO 24496 --- [ scheduling-1] com.nzc.service.ScheduleService : 當前執(zhí)行任務的線程號ID===>64
2022-09-06 19:42:20.001 INFO 24496 --- [ scheduling-1] com.nzc.service.ScheduleService : 當前執(zhí)行任務的線程號ID===>64
2022-09-06 19:42:25.005 INFO 24496 --- [ scheduling-1] com.nzc.service.ScheduleService : 當前執(zhí)行任務的線程號ID===>64
2022-09-06 19:42:30.007 INFO 24496 --- [ scheduling-1] com.nzc.service.ScheduleService : 當前執(zhí)行任務的線程號ID===>64
如果說從時間上來看,說不上什么延遲性,但真實的業(yè)務場景中,業(yè)務的執(zhí)行時間可能遠比這里時間長。
我主動讓線程睡上10秒,讓我們再來看看輸出結果是如何的吧
@Scheduled(cron = "0/5 * * * * ? ")
public void testSchedule() {
try {
Thread.sleep(10000);
log.info("當前執(zhí)行任務的線程號ID===>{}", Thread.currentThread().getId());
} catch (Exception e) {
e.printStackTrace();
}
}
輸出結果
2022-09-06 19:46:50.019 INFO 27236 --- [ scheduling-1] com.nzc.service.ScheduleService : 當前執(zhí)行任務的線程號ID===>64
2022-09-06 19:47:05.024 INFO 27236 --- [ scheduling-1] com.nzc.service.ScheduleService : 當前執(zhí)行任務的線程號ID===>64
2022-09-06 19:47:20.016 INFO 27236 --- [ scheduling-1] com.nzc.service.ScheduleService : 當前執(zhí)行任務的線程號ID===>64
2022-09-06 19:47:35.005 INFO 27236 --- [ scheduling-1] com.nzc.service.ScheduleService : 當前執(zhí)行任務的線程號ID===>64
2022-09-06 19:47:50.006 INFO 27236 --- [ scheduling-1] com.nzc.service.ScheduleService : 當前執(zhí)行任務的線程號ID===>64
請注意兩個問題:
- 執(zhí)行時間延遲:從時間上可以明顯看出,不再是每五秒執(zhí)行一次,執(zhí)行時間延遲很多,造成任務的
- 單線程執(zhí)行:從始至終都只有一個線程在執(zhí)行任務,造成任務的堵塞.
三、為什么會出現(xiàn)上述問題?
問題的根本:線程阻塞式執(zhí)行,執(zhí)行任務線程數(shù)量過少。
那到底是為什么呢?
回到啟動類上,我們在啟動上標明了一個@EnableScheduling注解。
大家在看到諸如@Enablexxxx這樣的注解的時候,就要知道它一定有一個xxxxxAutoConfiguration的自動裝配的類。
@EnableScheduling也不例外,它的自動裝配的類是TaskSchedulingAutoConfiguration。
我們來看看它到底做了一些什么設置?我們如何修改?
@ConditionalOnClass(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()調用繼續(xù)看下去,
public ThreadPoolTaskScheduler build() {
return configure(new ThreadPoolTaskScheduler());
}
ThreadPoolTaskScheduler中,給定的線程池的核心參數(shù)就為1,這也表明了之前為什么只有一條線程在執(zhí)行任務。private volatile int poolSize = 1;
這一段是分開的用代碼不好展示,我用圖片標明出來。

主要邏輯在這里,創(chuàng)建線程池的時候,只使用了三個參數(shù),剩下的都是使用ScheduledExecutorService的默認的參數(shù)
protected ScheduledExecutorService createExecutor(
int poolSize, ThreadFactory threadFactory, RejectedExecutionHandler rejectedExecutionHandler)
而這默認參數(shù)是不行的,生產環(huán)境的大坑,阿里的 Java 開發(fā)手冊中也明確規(guī)定,要手動創(chuàng)建線程池,并給定合適的參數(shù)值~是為什么呢?
因為默認的線程池中, 池中允許的最大線程數(shù)和最大任務等待隊列都是Integer.MAX_VALUE.

大家都懂的,如果使用這玩意,只要出了問題,必定掛~
configure(new ThreadPoolTaskScheduler())這里就是構造,略過~
如果已經較為熟悉SpringBoot的朋友,現(xiàn)在已然明白解決當前問題的方式~
四、解決方式
1、@EnableConfigurationProperties(TaskSchedulingProperties.class) ,自動裝配類通常也都會對應有個xxxxProperties文件滴,TaskSchedulingProperties也確實可以配置核心線程數(shù)等基本參數(shù),但是無法配置線程池中最大的線程數(shù)量和等待隊列數(shù)量,這種方式還是不合適的。
2、可以手動異步編排,交給某個線程池來執(zhí)行。
3、將定時任務加上異步注解@Async,將其改為異步的定時任務,另外自定義一個系統(tǒng)通用的線程池,讓異步任務使用該線程執(zhí)行任務~
我們分別針對上述三種方式來實現(xiàn)一遍
4.1、修改配置文件
可以配置的就下面幾項~
spring:
task:
scheduling:
thread-name-prefix: nzc-schedule- #線程名前綴
pool:
size: 10 #核心線程數(shù)
# shutdown:
# await-termination: true #執(zhí)行程序是否應等待計劃任務在關機時完成。
# await-termination-period: #執(zhí)行程序應等待剩余任務完成的最長時間。
測試結果:
2022-09-06 20:49:15.015 INFO 7852 --- [ nzc-schedule-1] com.nzc.service.ScheduleService : 當前執(zhí)行任務的線程號ID===>64
2022-09-06 20:49:30.004 INFO 7852 --- [ nzc-schedule-2] com.nzc.service.ScheduleService : 當前執(zhí)行任務的線程號ID===>66
2022-09-06 20:49:45.024 INFO 7852 --- [ nzc-schedule-1] com.nzc.service.ScheduleService : 當前執(zhí)行任務的線程號ID===>64
2022-09-06 20:50:00.025 INFO 7852 --- [ nzc-schedule-3] com.nzc.service.ScheduleService : 當前執(zhí)行任務的線程號ID===>67
2022-09-06 20:50:15.023 INFO 7852 --- [ nzc-schedule-2] com.nzc.service.ScheduleService : 當前執(zhí)行任務的線程號ID===>66
2022-09-06 20:50:30.008 INFO 7852 --- [ nzc-schedule-4] com.nzc.service.ScheduleService : 當前執(zhí)行任務的線程號ID===>68
請注意:這里的配置并非是一定生效的,修改后有可能成功,有可能失敗,具體原因未知,但這一點是真實存在的。
不過從執(zhí)行結果中可以看出,這里的執(zhí)行的線程不再是孤單單的一個。
4.2、執(zhí)行邏輯改為異步執(zhí)行
首先我們先向Spring中注入一個我們自己編寫的線程池,參數(shù)自己設置即可,我這里比較隨意。
@Configuration
public class MyTheadPoolConfig {
@Bean
public TaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
//設置核心線程數(shù)
executor.setCorePoolSize(10);
//設置最大線程數(shù)
executor.setMaxPoolSize(20);
//緩沖隊列200:用來緩沖執(zhí)行任務的隊列
executor.setQueueCapacity(200);
//線程活路時間 60 秒
executor.setKeepAliveSeconds(60);
//線程池名的前綴:設置好了之后可以方便我們定位處理任務所在的線程池
// 這里我繼續(xù)沿用 scheduling 默認的線程名前綴
executor.setThreadNamePrefix("nzc-create-scheduling-");
//設置拒絕策略
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setWaitForTasksToCompleteOnShutdown(true);
return executor;
}
}
然后在定時任務這里注入進去:
/**
* @description:
* @author: Ning Zaichun
* @date: 2022年09月06日 0:02
*/
@Slf4j
@Component
@EnableScheduling
public class ScheduleService {
@Autowired
TaskExecutor taskExecutor;
@Scheduled(cron = "0/5 * * * * ? ")
public void testSchedule() {
CompletableFuture.runAsync(()->{
try {
Thread.sleep(10000);
log.info("當前執(zhí)行任務的線程號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 : 當前執(zhí)行任務的線程號ID===>66
2022-09-06 21:00:05.022 INFO 18356 --- [te-scheduling-2] com.nzc.service.ScheduleService : 當前執(zhí)行任務的線程號ID===>67
2022-09-06 21:00:10.013 INFO 18356 --- [te-scheduling-3] com.nzc.service.ScheduleService : 當前執(zhí)行任務的線程號ID===>68
2022-09-06 21:00:15.020 INFO 18356 --- [te-scheduling-4] com.nzc.service.ScheduleService : 當前執(zhí)行任務的線程號ID===>69
2022-09-06 21:00:20.026 INFO 18356 --- [te-scheduling-5] com.nzc.service.ScheduleService : 當前執(zhí)行任務的線程號ID===>70
可以看到雖然業(yè)務執(zhí)行時間比較長,但是木有再出現(xiàn),延遲執(zhí)行定時任務的情況。
4.3、異步定時任務
異步定時任務其實和上面的方式原理是一樣的,不過實現(xiàn)稍稍不同罷了。
在定時任務的類上再加一個@EnableAsync注解,給方法添加一個@Async即可。
不過一般@Async都會指定線程池,比如寫成這樣@Async(value = "taskExecutor"),
/**
* @description:
* @author: Ning Zaichun
* @date: 2022年09月06日 0:02
*/
@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("當前執(zhí)行任務的線程號ID===>{}", Thread.currentThread().getId());
} catch (Exception e) {
e.printStackTrace();
}
}
}
執(zhí)行結果:
2022-09-06 21:10:15.022 INFO 22760 --- [zc-scheduling-1] com.nzc.service.ScheduleService : 當前執(zhí)行任務的線程號ID===>66
2022-09-06 21:10:20.021 INFO 22760 --- [zc-scheduling-2] com.nzc.service.ScheduleService : 當前執(zhí)行任務的線程號ID===>67
2022-09-06 21:10:25.007 INFO 22760 --- [zc-scheduling-3] com.nzc.service.ScheduleService : 當前執(zhí)行任務的線程號ID===>68
2022-09-06 21:10:30.020 INFO 22760 --- [zc-scheduling-4] com.nzc.service.ScheduleService : 當前執(zhí)行任務的線程號ID===>69
2022-09-06 21:10:35.007 INFO 22760 --- [zc-scheduling-5] com.nzc.service.ScheduleService : 當前執(zhí)行任務的線程號ID===>70
結果顯而易見是可行的啦~
分析:
@EnableAsync注解相應的也有一個自動裝配類為TaskExecutionAutoConfiguration
也有一個TaskExecutionProperties配置類,可以在yml文件中對參數(shù)進行設置,這里的話是可以配置線程池最大存活數(shù)量的。
它的默認核心線程數(shù)為8,這里我不再進行演示了,同時它的線程池中最大存活數(shù)量以及任務等待數(shù)量也都為Integer.MAX_VALUE,這也是不建議大家使用默認線程池的原因。
4.4、小結
/** * 定時任務 * 1、@EnableScheduling 開啟定時任務 * 2、@Scheduled開啟一個定時任務 * 3、自動裝配類 TaskSchedulingAutoConfiguration * * 異步任務 * 1、@EnableAsync:開啟異步任務 * 2、@Async:給希望異步執(zhí)行的方法標注 * 3、自動裝配類 TaskExecutionAutoConfiguration */
實現(xiàn)方式雖不同,但從效率而言,并無太大區(qū)別,覺得那種合適使用那種便可。
不過總結起來,考查的都是對線程池的理解,對于線程池的了解是真的非常重要的,也很有用處。
五、分布式下的思考
針對上述情況而言,這些解決方法在不引入第三包的情況下是足以應付大部分情況了。
定時框架的實現(xiàn)有許多方式,在此并非打算討論這個。
在單體項目中,也許上面的問題是解決了,但是站在分布式的情況下考慮,就并非是安全的了。
當多個項目在同時運行,那么必然會有多個項目同時這段代碼。
思考:并發(fā)執(zhí)行
如果一個定時任務同時在多個機器中運行,會產生怎么樣的問題?
假如這個定時任務是收集某個信息,發(fā)送給消息隊列,如果多臺機器同時執(zhí)行,同時給消息隊列發(fā)送信息,那么必然導致之后產生一系列的臟數(shù)據(jù)。這是非常不可靠的
解決方式:分布式鎖
很簡單也不簡單,加分布式鎖~ 或者是用一些分布式調度的框架
如使用XXL-JOB實現(xiàn),或者是其他的定時任務框架。
大家在執(zhí)行這個定時任務之前,先去獲取一把分布式鎖,獲取到了就執(zhí)行,獲取不到就直接結束。
我這里使用的是 redission,因為方便,打算寫分布式鎖的文章,還在準備當中。
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
* @date: 2022年09月06日 9:31
*/
@Configuration
public class MyRedissonConfig {
/**
* 所有對Redisson的使用都是通過RedissonClient
* @return
* @throws IOException
*/
@Bean(destroyMethod="shutdown")
public RedissonClient redissonClient() throws IOException {
//1、創(chuàng)建配置
Config config = new Config();
// 這里規(guī)定要用 redis://+IP地址
config.useSingleServer().setAddress("redis://xxxxx:6379").setPassword("000415"); // 有密碼就寫密碼~ 木有不用寫~
//2、根據(jù)Config創(chuàng)建出RedissonClient實例
//Redis url should start with redis:// or rediss://
RedissonClient redissonClient = Redisson.create(config);
return redissonClient;
}
}
修改定時任務:
/**
* @description:
* @author: Ning Zaichun
* @date: 2022年09月06日 0:02
*/
@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 的看門狗機制(即自動續(xù)鎖機制)
lock.lock(10, TimeUnit.SECONDS);
Thread.sleep(10000);
log.info("當前執(zhí)行任務的線程號ID===>{}", Thread.currentThread().getId());
} catch (Exception e) {
e.printStackTrace();
} finally {
// 一定要記得解鎖~
lock.unlock();
}
}
}
這里只是給出個大概的實現(xiàn),實際上還是可以優(yōu)化的,比如在給定一個flag,在獲取鎖之前判斷。如果有人搶到鎖,就修改這個值,之后的請求,判斷這個flag,如果不是默認的值,則直接結束任務等等。
思考:繼續(xù)往深處思考,在分布式情況下如果一個定時任務搶到鎖,但是它在執(zhí)行業(yè)務過程中失敗或者是宕機了,這又該如何處理呢?如何補償呢?
個人思考:
失敗還比較好說,我們可以直接try{}catch(){}中進行通知告警,及時檢查出問題。
如果是掛了,我還沒想好怎么做。
后記
但實際上,我所闡述的這種方式,只能說適用于簡單的單體項目,一旦牽扯到動態(tài)定時任務,使用這種方式就不再那么方便了。
大部分都是使用定時任務框架集成了,尤其是分布式調度遠比單體項目需要考慮多的多。
以上就是Schedule定時任務在分布式產生的問題詳解的詳細內容,更多關于Schedule定時任務分布式的資料請關注腳本之家其它相關文章!
相關文章
Mybatis動態(tài)SQL?foreach批量操作方法
這篇文章主要介紹了Mybatis動態(tài)SQL?foreach批量操作方法,本文給大家介紹的非常詳細,對大家的學習或工作具有一定的參考借鑒價值,需要的朋友可以參考下2023-03-03
簡單了解JAVA SimpleDateFormat yyyy和YYYY的區(qū)別
這篇文章主要介紹了簡單了解JAVA SimpleDateFormat yyyy和YYYY的區(qū)別,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友可以參考下2020-03-03
使用Java將字符串在ISO-8859-1和UTF-8之間相互轉換
大家都知道在一些情況下,我們需要特殊的編碼格式,如:UTF-8,但是系統(tǒng)默認的編碼為ISO-8859-1,遇到這個問題,該如何對字符串進行兩個編碼的轉換呢,下面小編給大家分享下java中如何在ISO-8859-1和UTF-8之間相互轉換,感興趣的朋友一起看看吧2021-12-12

