Schedule定時任務在分布式產(chǎn)生的問題詳解
正文
定時任務的實現(xiàn)方式多種多樣,框架也是層出不窮。
本文所談及的是 SpringBoot 本身所帶有的@EnableScheduling 、 @Scheduled
實現(xiàn)定時任務的方式。
以及采用這種方式,在分布式調(diào)度中可能會出現(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í)行結(jié)果應當為:
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í)行非常快的任務的,時間非常非常短,確實不會有什么的延遲性。
上面代碼執(zhí)行結(jié)果:
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秒,讓我們再來看看輸出結(jié)果是如何的吧
@Scheduled(cron = "0/5 * * * * ? ") public void testSchedule() { try { Thread.sleep(10000); log.info("當前執(zhí)行任務的線程號ID===>{}", Thread.currentThread().getId()); } catch (Exception e) { e.printStackTrace(); } }
輸出結(jié)果
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
。
我們來看看它到底做了一些什么設置?我們?nèi)绾涡薷模?/p>
@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(); } // ...... }
可以看到它也是構(gòu)造了一個 線程池注入到Spring 中
從build()
調(diào)用繼續(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ù)是不行的,生產(chǎn)環(huán)境的大坑,阿里的 Java 開發(fā)手冊中也明確規(guī)定,要手動創(chuàng)建線程池,并給定合適的參數(shù)值~是為什么呢?
因為默認的線程池中, 池中允許的最大線程數(shù)和最大任務等待隊列都是Integer.MAX_VALUE
.
大家都懂的,如果使用這玩意,只要出了問題,必定掛~
configure(new ThreadPoolTaskScheduler())
這里就是構(gòu)造,略過~
如果已經(jīng)較為熟悉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í)行程序是否應等待計劃任務在關(guān)機時完成。 # await-termination-period: #執(zhí)行程序應等待剩余任務完成的最長時間。
測試結(jié)果:
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í)行結(jié)果中可以看出,這里的執(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); } }
測試結(jié)果:
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í)行結(jié)果:
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
結(jié)果顯而易見是可行的啦~
分析:
@EnableAsync
注解相應的也有一個自動裝配類為TaskExecutionAutoConfiguration
也有一個TaskExecutionProperties
配置類,可以在yml文件中對參數(shù)進行設置,這里的話是可以配置線程池最大存活數(shù)量的。
它的默認核心線程數(shù)為8,這里我不再進行演示了,同時它的線程池中最大存活數(shù)量以及任務等待數(shù)量也都為Integer.MAX_VALUE
,這也是不建議大家使用默認線程池的原因。
4.4、小結(jié)
/** * 定時任務 * 1、@EnableScheduling 開啟定時任務 * 2、@Scheduled開啟一個定時任務 * 3、自動裝配類 TaskSchedulingAutoConfiguration * * 異步任務 * 1、@EnableAsync:開啟異步任務 * 2、@Async:給希望異步執(zhí)行的方法標注 * 3、自動裝配類 TaskExecutionAutoConfiguration */
實現(xiàn)方式雖不同,但從效率而言,并無太大區(qū)別,覺得那種合適使用那種便可。
不過總結(jié)起來,考查的都是對線程池的理解,對于線程池的了解是真的非常重要的,也很有用處。
五、分布式下的思考
針對上述情況而言,這些解決方法在不引入第三包的情況下是足以應付大部分情況了。
定時框架的實現(xiàn)有許多方式,在此并非打算討論這個。
在單體項目中,也許上面的問題是解決了,但是站在分布式的情況下考慮,就并非是安全的了。
當多個項目在同時運行,那么必然會有多個項目同時這段代碼。
思考:并發(fā)執(zhí)行
如果一個定時任務同時在多個機器中運行,會產(chǎn)生怎么樣的問題?
假如這個定時任務是收集某個信息,發(fā)送給消息隊列,如果多臺機器同時執(zhí)行,同時給消息隊列發(fā)送信息,那么必然導致之后產(chǎn)生一系列的臟數(shù)據(jù)。這是非常不可靠的
解決方式:分布式鎖
很簡單也不簡單,加分布式鎖~ 或者是用一些分布式調(diào)度的框架
如使用XXL-JOB實現(xiàn),或者是其他的定時任務框架。
大家在執(zhí)行這個定時任務之前,先去獲取一把分布式鎖,獲取到了就執(zhí)行,獲取不到就直接結(jié)束。
我這里使用的是 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
,如果不是默認的值,則直接結(jié)束任務等等。
思考:繼續(xù)往深處思考,在分布式情況下如果一個定時任務搶到鎖,但是它在執(zhí)行業(yè)務過程中失敗或者是宕機了,這又該如何處理呢?如何補償呢?
個人思考:
失敗還比較好說,我們可以直接try{}catch(){}中進行通知告警,及時檢查出問題。
如果是掛了,我還沒想好怎么做。
后記
但實際上,我所闡述的這種方式,只能說適用于簡單的單體項目,一旦牽扯到動態(tài)定時任務,使用這種方式就不再那么方便了。
大部分都是使用定時任務框架集成了,尤其是分布式調(diào)度遠比單體項目需要考慮多的多。
以上就是Schedule定時任務在分布式產(chǎn)生的問題詳解的詳細內(nèi)容,更多關(guān)于Schedule定時任務分布式的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Mybatis動態(tài)SQL?foreach批量操作方法
這篇文章主要介紹了Mybatis動態(tài)SQL?foreach批量操作方法,本文給大家介紹的非常詳細,對大家的學習或工作具有一定的參考借鑒價值,需要的朋友可以參考下2023-03-03Java實現(xiàn)bmp和jpeg圖片格式互轉(zhuǎn)
本文主要介紹了Java實現(xiàn)bmp和jpeg圖片格式互轉(zhuǎn),文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧2023-04-04圖解Java經(jīng)典算法快速排序的原理與實現(xiàn)
快速排序是基于二分的思想,對冒泡排序的一種改進。主要思想是確立一個基數(shù),將小于基數(shù)的數(shù)放到基數(shù)左邊,大于基數(shù)的數(shù)字放到基數(shù)的右邊,然后在對這兩部分進一步排序,從而實現(xiàn)對數(shù)組的排序2022-09-09Java的內(nèi)存區(qū)域與內(nèi)存溢出異常你了解嗎
這篇文章主要為大家詳細介紹了Java的內(nèi)存區(qū)域與內(nèi)存溢出異常,文中示例代碼介紹的非常詳細,具有一定的參考價值,感興趣的小伙伴們可以參考一下,希望能夠給你帶來幫助2022-03-03簡單了解JAVA SimpleDateFormat yyyy和YYYY的區(qū)別
這篇文章主要介紹了簡單了解JAVA SimpleDateFormat yyyy和YYYY的區(qū)別,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友可以參考下2020-03-03使用Java將字符串在ISO-8859-1和UTF-8之間相互轉(zhuǎn)換
大家都知道在一些情況下,我們需要特殊的編碼格式,如:UTF-8,但是系統(tǒng)默認的編碼為ISO-8859-1,遇到這個問題,該如何對字符串進行兩個編碼的轉(zhuǎn)換呢,下面小編給大家分享下java中如何在ISO-8859-1和UTF-8之間相互轉(zhuǎn)換,感興趣的朋友一起看看吧2021-12-12