从零开始 Spring Boot 40:定时任务
图源:简书 (jianshu.com)
定时任务是一种很常见的需求,比如我们可能需要应用定期去执行一些清理工作,再比如可能需要定期检查一些外部服务的可用性等。
fixedDelay
要在 Spring 中开启定时任务相关功能,需要在任意的配置类上添加上@EnableScheduling
:
@Configuration
@EnableScheduling
public class WebConfig {
}
之后就可以在 Spring Bean 中定义一个定时任务对应的方法:
@Component
public class MySchedule {
@Scheduled(fixedDelay = 1000)
public void sayHello() {
LocalDateTime now = LocalDateTime.now();
System.out.println("hello, now is %s".formatted(now));
}
}
在这个示例中,@Scheduled(fixedDelay = 1000)
表示所标记的方法将每隔1000毫秒(1秒)执行一次。fixedDelay
中的值代表下一次任务将在前一次任务执行完毕后的若干毫秒后执行。
所以这个示例执行后能看到类似下面的输出:
hello, now is 2023-06-14T15:27:21.026108300
hello, now is 2023-06-14T15:27:22.034320100
hello, now is 2023-06-14T15:27:23.038852900
hello, now is 2023-06-14T15:27:24.054180300
hello, now is 2023-06-14T15:27:25.065741600
要注意的是,定时任务对应的方法返回值必须是
void
,且不能包含任何参数。
fixedRate
除了上边介绍的方式外,还可以指定“每间隔若干时间后执行定时任务”这种模式:
@Component
public class MySchedule {
@Scheduled(fixedRate = 1000)
public void sayHello() {
LocalDateTime now = LocalDateTime.now();
System.out.println("hello, now is %s".formatted(now));
}
}
@Scheduled
的fixedRate
属性可以设置每次任务执行间隔的毫米数。
fixedDelay vs fixedRate
fixedDelay
和fixedRate
是有区别的,虽然上面的两个例子输出看起来一样,但这是因为任务执行的时间太短造成的,我们将任务执行的时间放长:
@Component
public class MySchedule {
@Scheduled(fixedDelay = 1000)
public void sayHello() throws InterruptedException {
LocalDateTime now = LocalDateTime.now();
System.out.println("hello, now is %s".formatted(now));
Thread.sleep(2000);
}
}
输出:
hello, now is 2023-06-14T15:34:22.876346500
hello, now is 2023-06-14T15:34:25.895804500
hello, now is 2023-06-14T15:34:28.908301500
hello, now is 2023-06-14T15:34:31.937000600
hello, now is 2023-06-14T15:34:34.948180300
@Component
public class MySchedule {
@Scheduled(fixedRate = 1000)
public void sayHello() throws InterruptedException {
LocalDateTime now = LocalDateTime.now();
System.out.println("hello, now is %s".formatted(now));
Thread.sleep(2000);
}
}
输出:
hello, now is 2023-06-14T15:35:22.794709900
hello, now is 2023-06-14T15:35:24.798962500
hello, now is 2023-06-14T15:35:26.806299700
hello, now is 2023-06-14T15:35:28.811730
hello, now is 2023-06-14T15:35:30.817310200
可以看到,使用fixedDelay
时任务每隔3秒执行一次,使用fixedRate
时任务每隔2秒执行一次。
这是因为任务本身的执行时长是2秒(由Thread.sleep
决定),而如果定时任务设置为fixedDelay = 1000
,那么下一次任务就会在上一次任务结束的1000毫秒后执行,所以两次任务之间的间隔是3秒。
如果定时任务设置为fixedRate = 1000
,这本来应该意味着定时任务时间间隔是1000毫秒,但这个示例中,任务本身的时长(2秒)超过了这里指定的任务间隔时长(1秒),因此调度器将会在上一次任务执行完毕后立即执行下次任务,因此此时任务之间的实际执行间隔就是任务本身的时长(2秒)。
并发的 fixedRate 任务
之所以fixedRate
表现如此,是因为默认的定时任务调度器是一个单线程,因此所有的定时任务只能顺序执行。换言之,如果我们想要让设置为fixedRate
的定时任务并发,可以:
@Configuration
@EnableScheduling
@EnableAsync
public class WebConfig {
}
@Component
public class MySchedule {
@Async
@Scheduled(fixedRate = 1000)
public void sayHello() throws InterruptedException {
LocalDateTime now = LocalDateTime.now();
System.out.println("hello, now is %s".formatted(now));
Thread.sleep(2000);
}
}
输出:
hello, now is 2023-06-14T15:45:44.853280700
hello, now is 2023-06-14T15:45:45.851481600
hello, now is 2023-06-14T15:45:46.863900700
hello, now is 2023-06-14T15:45:47.858285100
hello, now is 2023-06-14T15:45:48.864664300
hello, now is 2023-06-14T15:45:49.857035800
可以看到,无论前一个任务有没有执行完毕,下一个任务也会在固定时间(这里是1秒)后被执行。
对
fixedDelay
也可以使用@Async
让其并发执行,但这样做不符合fixedDelay
的意义,会让其“变成”fixedRate
,因此应该避免这么做。
initialDelay
可以让定时任务延迟执行,比如:
@Component
public class MySchedule {
@Scheduled(fixedDelay = 1000, initialDelay = 2000)
public void sayHello() throws InterruptedException {
LocalDateTime now = LocalDateTime.now();
System.out.println("hello, now is %s".formatted(now));
Thread.sleep(2000);
}
}
示例中的定时任务第一次执行时将延迟2秒后执行,之后的每次任务将遵循fixedDelay
设置,即后一次任务在前一次任务执行完毕后间隔1秒执行。
cron 表达式
除了设置时间间隔,还可以使用 cron 表达式让定时任务在特定时间执行,比如:
@Component
public class MySchedule {
@Scheduled(cron = "0 */5 * * * *")
public void sayHello() throws InterruptedException {
// ...
}
}
输出:
hello, now is 2023-06-14T16:00:00.007956400
hello, now is 2023-06-14T16:05:00.009349900
示例中的定时任务将在“每个小时的0分钟、5分钟、10分钟…”被执行。
*/5
表示可以被5整除的分钟。
这里的 cron 表达式和 Linux 中的写法是类似的,区别是 Linux 的格式是"分钟 小时 日 月 周",而这里是“秒 分钟 小时 日 月 周”,所以这里多了一个秒钟的设置项。
关于 Linux 中的 cron,可以参考Linux 之旅 14:任务计划(crontab) - 红茶的个人站点 (icexmoon.cn)。
外部配置
可以通过外部配置来设置定时任务的执行时间,比如:
@Component
public class MySchedule {
@Scheduled(cron = "${my.schedule.cron.expression}")
public void sayHello() throws InterruptedException {
// ...
}
}
对应的配置文件:
my.schedule.cron.expression=0 */5 * * * *
动态设置延迟
可以通过编程的方式动态控制定时任务的执行延迟:
@Component
public class DelayService {
private int delaySeconds = 1;
@Synchronized
public int getDelaySeconds() {
int delaySeconds = this.delaySeconds;
if (this.delaySeconds <= 1) {
this.delaySeconds++;
} else {
this.delaySeconds--;
}
return delaySeconds;
}
}
@Configuration
@Log4j2
public class MyScheduleConfig implements SchedulingConfigurer {
@Autowired
private DelayService delayService;
@Bean
public Executor executor() {
return Executors.newSingleThreadScheduledExecutor();
}
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
taskRegistrar.setScheduler(executor());
taskRegistrar.addTriggerTask(() -> {
LocalDateTime now = LocalDateTime.now();
System.out.println("hello, now is %s".formatted(now));
}, triggerContext -> {
Instant instant = triggerContext.lastCompletion();
if (instant == null) {
instant = new Date().toInstant();
}
int delay = delayService.getDelaySeconds();
instant = instant.plus(delay, ChronoUnit.SECONDS);
return instant;
});
}
}
输出:
hello, now is 2023-06-14T16:41:25.525521800
hello, now is 2023-06-14T16:41:27.534739700
hello, now is 2023-06-14T16:41:28.544731100
hello, now is 2023-06-14T16:41:30.554797300
hello, now is 2023-06-14T16:41:31.564520600
DelayService
的作用是返回一个时间间隔,返回的时间间隔会随着调用在1和2之间转换。
考虑到可能的多线程调用,这里的
DelayService
是线程安全的,具体通过 Lombok 的@Synchronized
注解保证这一点,关于 Lombok 的@Synchronized
注解,可以阅读我的这篇文章。
为了能够实现动态调整定时任务的触发,我们需要让配置类实现SchedulingConfigurer
接口,这样就可以在configureTasks
方法中通过ScheduledTaskRegistrar.addTriggerTask
方法添加一个定时任务(Runnable
)和其对应的触发器(Trigger
),这个触发器需要实现的方法最终会返回一个时间点(Instant
),一起添加的定时任务就会在该时间点被执行。
实际上
Trigger
包含两个可以覆盖的方法,其中一个返回Date
类型,另一个返回Instant
类型,如果你返回的是后者,实际上Trigger
自己会转换成Date
(通过default
方法)。
可以看到,最终的效果就是这个定时任务的时间间隔会在1秒和2秒间来回跳转。
并发
前面我们说过,默认情况下执行定时任务的是一个单线程的任务调度器,每一个定时任务都需要等待前边的定时任务执行完毕后才能开始执行。
比如下面这个示例:
@Component
public class MySchedule {
@Scheduled(fixedRate = 1000)
public void sayHello() throws InterruptedException {
LocalDateTime now = LocalDateTime.now();
System.out.println("hello, now is %s".formatted(now));
Thread.sleep(2000);
}
@Scheduled(fixedRate = 1000)
public void sayHello2() throws InterruptedException {
LocalDateTime now = LocalDateTime.now();
System.out.println("hello2, now is %s".formatted(now));
Thread.sleep(2000);
}
}
输出:
hello, now is 2023-06-14T17:06:12.414944600
hello2, now is 2023-06-14T17:06:14.416957900
hello, now is 2023-06-14T17:06:16.423
hello2, now is 2023-06-14T17:06:18.433155400
hello, now is 2023-06-14T17:06:20.442051500
hello2, now is 2023-06-14T17:06:22.443233500
可以看到,第一个任务sayHello
被执行后,sayHello2
并不会被立即执行,必须要等到第一个任务执行完毕(2秒后)才开始执行。而sayHello
的第二次执行也被推迟到sayHello2
的第一次执行完毕后才能开始,此时已经离sayHello
第一次执行过去了4秒。
因此,如果我们有多个定时任务,且这些定时任务可以并行执行,那么就可以考虑进行优化。
可以通过自定义任务调度器来改变这一行为:
@Configuration
@EnableScheduling
@EnableAsync
public class WebConfig {
@Bean
public TaskScheduler taskScheduler(){
ThreadPoolTaskScheduler threadPoolTaskScheduler = new ThreadPoolTaskScheduler();
threadPoolTaskScheduler.setPoolSize(5);
threadPoolTaskScheduler.setThreadNamePrefix("ThreadPoolTaskScheduler");
return threadPoolTaskScheduler;
}
}
为此我们需要创建一个TaskScheduler
类型的 bean,在示例中使用了ThreadPoolTaskScheduler
这个实现类,并通过ThreadPoolTaskScheduler.setPoolSize
方法指定了固定的线程池大小(实际使用中应当结合现实情况设置)。此外,还通过ThreadPoolTaskScheduler.setThreadNamePrefix
设置了相关线程名称的前缀。
关于
TaskScheduler
的更多说明,见这篇文章。
现在的输出:
hello, now is 2023-06-14T17:13:34.312232900
hello2, now is 2023-06-14T17:13:36.305893100
hello, now is 2023-06-14T17:13:36.321635900
hello2, now is 2023-06-14T17:13:38.318501500
hello, now is 2023-06-14T17:13:38.333777300
hello2, now is 2023-06-14T17:13:40.333169
可以看到,sayHello
和sayHello2
两个任务几乎同时启动,因为它们使用了ThreadPoolTaskScheduler
中两个不同的线程进行执行,所以互相并不影响。
在 Spring Boot 中,实际上并不需要自行定义TaskScheduler
类型的 bean,可以通过修改配置的方式设置默认TaskScheduler
的线程池大小和线程名称前缀:
spring.task.scheduling.thread-name-prefix=ThreadPoolTaskScheduler
spring.task.scheduling.pool.size=5
效果是相同的。
The End,谢谢阅读。
本文所有的示例代码可以从这里获取。
参考资料
- A Guide to the Spring Task Scheduler | Baeldung
- The @Scheduled Annotation in Spring | Baeldung
- 任务执行和调度 (springdoc.cn)