相关推荐recommended
spring调度注解@Scheduled(含分布式)
作者:mmseoamin日期:2023-12-20

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) 如果任务有严格的并行限制,可以通过分布式锁防护一下。