1 简述
任务调度就是在给定的时间或固定频率,执行业务逻辑,是比较常见的功能需求。解决方案有jdk原生的Timer、ScheduledThreadPoolExecutor等,这些类常适用于一些内嵌的业务逻辑场景,本文主要介绍注解@Scheduled,以上都是单进程解决方案,经过适当改造,也可以适用于分布式场景,可以满足大多数调度业务场景,具体实现思路下面会做简单叙述。
2 配置
2.1 开启
项目开启调度功能,需要先添加注解@EnableScheduling,否则调度注解@Scheduled就不起作用。
2.2 线程池
既然是任务运行,就会涉及线程处理,如果有不同类型的任务,也会出现并行处理,对线程的合理管理,就离不开线程池,以下是线程池配置整理
(1) 不配置(默认)
如果不做任何配置处理,spring-boot 会默认自动构建一个ThreadPoolTaskScheduler线程池类bean, 来管理这些运行任务的线程,默认线程池的具体参数值,可参考TaskSchedulingProperties类定义的默认值,如下:
// pool
private int size = 1;
// thread
private String threadNamePrefix = "scheduling-";
通过源码知道,这个默认线程池,内部实际由jdk的ScheduledThreadPoolExecutor类处理,该类采用无限容量队列,这也就限制了它的最大线程数不会超过1个,如果有耗时的并行任务,就不能满足要求,通常情况下,需要根据业务场景重新配置这些参数。
(2) spring配置
spring-boot项目已提供TaskSchedulingAutoConfiguration类,由它自动加载线程池配置参数,并构建ThreadPoolTaskScheduler线程池类bean,以下是约定的配置项:
spring:
task:
scheduling:
threadNamePrefix: my-scheduler-task-
pool:
size: 3
线程池的大小,依据配置调度注解@Scheduled任务的数量,原则上有几种任务就需要几个线程,否则就会出现相互影响,长耗时任务占用线程,导致短耗时任务不能正常运行。
(3) java代码配置
调度任务不像@Async异常处理,它只有一个线程池,一般情况不用这种配置方式,以下是简单例子。
@Configuration
public class ScheduleConfig {
private static final String THREAD_NAME_PREFIX = "my-scheduler-task-";
@Bean("myTaskScheduler")
public ThreadPoolTaskScheduler getThreadPoolTaskScheduler() {
ThreadPoolTaskScheduler result = new ThreadPoolTaskScheduler();
result.setThreadNamePrefix(THREAD_NAME_PREFIX);
result.setPoolSize(3);
return result;
}
}
2.3 调度规则
@Scheduled包含参数:
cron:定时任务,按cron表达式规则,定时运行任务,例如,每5分钟运行一次: 0/5 * * * * ?
fixedDelay:按固定间隔执行,就是两个相邻任务,前一个任务结束到下一个任务开始的间隔时间,单位: 毫秒。
fixedRate:按固定频率执行任务,单位: 毫秒。
initialDelay:系统启动后,延时多长时间运行第一次任务,单位: 毫秒。
其中:cron, fixedDelay, fixedRate 配置参数,只能三选一。
3 分布式
现在系统大多在分布式环境部署,就需要考虑多实例部署如何协调执行任务问题,以下是常见的解决方案,以及个人的思考。
3.1 第三方
目前第三方的开源方案,有早期比较经典的Quartz,近几年版本迭代不太活跃,也有后起之秀XXL-JOB 版本迭代比较活跃,也是目前很多公司推崇的解决方案,对任务的管理、监控、日志等功能比较齐全,可以参考其官方,这里就不再多述。
3.2 自处理
尽管上面开源的第三方解决方案,已经足够成熟、完善,但相对来说,还是有些重,对于一些系统规模不是很大,一些简单的任务调度需求,完全可以进行简单改造来满足这些任务调度功能。
尽管简单,它一样可以很实用、很健壮,以下是2种借助redis的处理思路。
(1) @Scheduled为主,redis为辅
通过@Scheduled注解的调度任务,在分布式环境运行,一个明显的问题,就是同一个任务,可能会在多个机器同时并发执行,如何避免,很自然就想到通过redis分布式锁处理,来避免任务并发执行,锁定时间可以设置0.75个执行周期,以下是伪码:
@Scheduled(fixedDelay = 60000, initialDelay = 1000)
public void task1() {
// 锁定
boolean isLock = redisLock.lock("my-task-1", 60000 * 0.75);
if (!isLock) return;
// 任务逻辑
doSomething();
}
可以看出,这种方式,任务周期误差比较大,比较粗糙,特点就是逻辑简单,适用于精度要求较低的场景。
(2) redis为主,@Scheduled为辅
由于通过@Scheduled来配置执行周期,在分布式环境,很难保证周期的精度,这时候可以把@Scheduled仅作为尝试申请执行的一个定时扫描任务,真实的执行周期由redis的过期时间来管理,这种方式,任务周期精度就会好很多,以下是伪码:
按固定频率执行:
/*
* redis为主,@Scheduled为辅(按固定频率执行任务)
*
* note:
* a. @Scheduled注解中fixedDelay,该参数仅作为尝试申请执行任务, 通常可以设置小些。
* b. 任务执行周期或间隔,值为redisLock锁定的时间。
*
*/
@Scheduled(fixedDelay = 5000, initialDelay = 1000)
public void task2() {
// 锁定
boolean isLock = redisLock.lock("my-task-2", 真实任务周期);
if (!isLock) return;
// 任务逻辑
doSomething();
}
按固定间隔执行:
/*
* redis为主,@Scheduled为辅(按固定间隔执行)
*
* note:
* a. @Scheduled注解中fixedDelay,该参数仅作为尝试申请执行任务, 通常可以设置小些。
* b. 任务执行周期或间隔,值为redisLock锁定的时间。
*
*/
@Scheduled(fixedDelay = 5000, initialDelay = 1000)
public void task3() {
// 锁定1: 避免任务并行
boolean isLock = redisLock.lock("my-task-3", 真实任务间隔);
if (!isLock) return;
// 任务逻辑
doSomething();
// 锁定2: 间隔时间
redisLock.expire("my-task-3", 真实任务间隔);
}
按cron表达式执行:可通过注解@Scheduled参数fixedDelay,来调整周期精度。
/*
* redis为主,@Scheduled为辅(cron表达)
*
* note:
* a. @Scheduled注解中fixedDelay,该参数仅作为尝试申请执行任务, 通常可以设置小些。
* b. 任务执行周期或间隔,值为redisLock锁定的时间。
* c. 由CronHelper解析cron表达式,计算下一次运行间隔时间
*/
@Scheduled(fixedDelay = 5000, initialDelay = 1000)
public void task4() {
// 锁定
boolean isLock = redisLock.lock("my-task-4", CronHelper.getNextDelayTime());
if (!isLock) return;
// 任务逻辑
doSomething();
}
以上只伪码,可以看出改造成本比较少,也足够灵活,其中RedisLock可以参考前面整理的文章:"分布式锁-java",至于CronHelper类,网上应该有类似资源,也不妨自己实现一下,应该比排序算法有趣的多。
再就是任务的运行,不能保证负载均衡,如果的确有这方面需求,通过redis队列也可以实现,逻辑也不会太复杂。
个人认为:这种自处理方式,借助redis还是可以保障它的高可用性、并发性能,它的主要缺陷,就是代码语义不够清晰,在维护上,容易受注解@Scheduled定时参数影响,实际业务场景,尽量封装一下,提高可读性。
4 常见问题
(1) 线程池的大小,建议几种任务就几个线程,多了也浪费,如果太小,任务耗时长时,就会出现任务间干扰。
(2) 如果任务有严格的并行限制,可以通过分布式锁防护一下。