一文搞懂springboot定时任务

Introduction

在springboot中自带了一个轻量级的调度系统。如果我们希望在特定的时间或者以特定的时间间隔完成某些任务,那么它完全能够满足需求,不需要再额外引入像Quartz这种略显沉重的调度框架。下面我们就来介绍springboot中@scheduled 注解的用法。

环境:springboot 2.2.2

常用简单定时任务

首先,为了使用springboot中的定时任务,需要在springboot应用中加入 @EnableScheduling 注解。该注解开启对定时任务的支持,而且确保使用单线程创建任务执行器(task executor):

@SpringBootApplication
@EnableScheduling
public class SpringbootSchedulerApplication {
 public static void main(String[] args) {
     SpringApplication.run(SpringbootSchedulerApplication.class, args);
 }
}

然后我们就可以在一个被 @Component 标注的类(表示被纳入spring的管理)中加入我们的需要调度的任务:

@Component
@Slf4j
public class ScheduledTask {
    private static final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss");
    public void scheduleTaskWithFixedRate() {}
    public void scheduleTaskWithFixedDelay() {}
    public void scheduleTaskWithInitialDelay() {}
    public void scheduleTaskWithCronExpression() {}
}

下面来具体看看它支持的各种类型的调度任务。

Fixed Rate调度任务:

  @Scheduled(fixedRate = 2000)
  public void scheduleTaskWithFixedRate() {
      logger.info("Fixed Rate Task :: Execution Time - {}", dateTimeFormatter.format(LocalDateTime.now()) );
  }

@Scheduled 注解中,用 fixedRate 参数可以使得该任务以一个指定的时间间隔运行,单位为毫秒。在上例中,任务每两秒钟运行一次,并且即使上一次对任务的调用没有完成,也会按指定的间隔调用fixedRate 任务。
输出:

2019-12-29 17:45:52 [main] INFO | Started SpringbootSchedulerApplication in 1.028 seconds (JVM running for 1.513)
2019-12-29 17:45:52 [scheduling-1] INFO | Fixed Rate Task :: Execution Time - 17:45:52
2019-12-29 17:45:54 [scheduling-1] INFO | Fixed Rate Task :: Execution Time - 17:45:54
2019-12-29 17:45:56 [scheduling-1] INFO | Fixed Rate Task :: Execution Time - 17:45:56
...

Fixed Delay调度

    @Scheduled(fixedDelay = 2000)
    public void scheduleTaskWithInitialDelay() {
        log.info("Fixed Delay Task :: Execution Time - {}", dateTimeFormatter.format(LocalDateTime.now()));
        try {
            TimeUnit.SECONDS.sleep(3);
        } catch (InterruptedException ex) {
            log.error("Ran into an error {}", ex);
            throw new IllegalStateException(ex);
        }
    }

输出:

2019-12-29 17:46:58 [main] INFO | Started SpringbootSchedulerApplication in 0.983 seconds (JVM running for 1.416)
2019-12-29 17:46:58 [scheduling-1] INFO | Fixed Delay Task :: Execution Time - 17:46:58
2019-12-29 17:47:03 [scheduling-1] INFO | Fixed Delay Task :: Execution Time - 17:47:03
2019-12-29 17:47:08 [scheduling-1] INFO | Fixed Delay Task :: Execution Time - 17:47:08
...

fixedDelay 参数可以使得您可以在上一次调用完成与下一次调用开始之间以固定的延迟执行任务。这种方式适合于下一次的任务对上一次任务的结果有依赖的情况。从输出也可以看到,日志输出的间隔时间是任务执行时间加上fixedDelay 参数所指定的时间。

Initial Delay

这种情况是是和上面两种情况搭配使用的,就是不管是使用 fixedRate 参数还是 fixedDelay 都可以使用该参数使得任务的初次执行得到延缓。如下例所示:

    @Scheduled(fixedRate = 2000, initialDelay = 10000)
    public void scheduleTaskWithInitialDelay() {
        log.info("Fixed Rate Task with Initial Delay :: Execution Time - {}", dateTimeFormatter.format(LocalDateTime.now()));
    }

输出:

2019-12-29 17:47:46 [main] INFO | Started SpringbootSchedulerApplication in 1.081 seconds (JVM running for 1.642)
2019-12-29 17:47:56 [scheduling-1] INFO | Fixed Rate Task with Initial Delay :: Execution Time - 17:47:56
2019-12-29 17:47:58 [scheduling-1] INFO | Fixed Rate Task with Initial Delay :: Execution Time - 17:47:58
2019-12-29 17:48:00 [scheduling-1] INFO | Fixed Rate Task with Initial Delay :: Execution Time - 17:48:00

在输出中我特意把日志框架打印日志的时间戳给了出来,可以看到 SpringbootSchedulerApplication 启动之后隔了十秒钟(正是initialDelay所指定的延迟时间),调度任务才开始按正常的频率执行。

Cron表达式任务

既然是定时任务,又怎么少得了强大的Cron表达式呢。比如在某一个实际应用场景中,我需要在每天早上九点把应用的统计信息发到工作群里面,那么我就可以如下配置定时任务:

    @Scheduled(cron = "0 0 9 * * ?")
    public void sendMsgTask() {
        log.info("sending statistic message with scheduled task");
        sendMsgMethod();
    }

参数化定时任务

把定时任务的参数硬编码到代码中固然简单,但是当我们需要更改这些参数的时候就需要更改代码然后重新编译部署。这种时候把定时任务参数中写到配置文件中,然后使用Spring表达式注入到代码中是更好的选择。

// A fixedDelay task:
@Scheduled(fixedDelayString = "${fixedDelay.in.milliseconds}")

// A fixedRate task:
@Scheduled(fixedRateString = "${fixedRate.in.milliseconds}")

// A cron expression based task:
@Scheduled(cron = "${cron.expression}")

spring直接支持的简单定时任务就已经讲完了。
其中被 @Scheduled 标注的调度方法有两个需要注意的点是:

  1. 返回类型应该是 void
  2. 方法不能有入参

多线程定时任务

从上面例子中的输出日志可以看到,调度任务的执行是在一个默认线程名为 scheduling-1 的独立线程中执行的。事实上,通过这种方式配置的定时任务默认是在单线程中运行的。这意味着什么呢?意味着如果当前任务任务没有运行完,可能下一个任务不会开始,即被阻塞。同一个定时任务的上一次运行或者多个定时任务之前都可能发生阻塞。在上面介绍 fixedRate 任务的时候,我们说到:

即使上一次对任务的调用没有完成,也会按指定的间隔调用 fixedRate 任务
但是实验发现,这可以说只是它的“设计目标”,实际上指定间隔到了之后,如果上一次的任务还没有执行完会发生什么呢?

    @Scheduled(fixedRate = 2000)
    public void scheduleTaskWithFixedRate() {
        log.info("Fixed Rate Task :: Execution Time - {}", dateTimeFormatter.format(LocalDateTime.now()) );
        try {
            TimeUnit.SECONDS.sleep(3);
        } catch (Exception e) {
        }
    }

输出:

2019-12-29 18:43:19 [scheduling-1] INFO | Fixed Rate Task :: Execution Time - 18:43:19
2019-12-29 18:43:22 [scheduling-1] INFO | Fixed Rate Task :: Execution Time - 18:43:22
2019-12-29 18:43:25 [scheduling-1] INFO | Fixed Rate Task :: Execution Time - 18:43:25

可以看到,fixedRate虽然指定的是两秒,但是执行结果却是三秒一次,也就是每次任务的执行时间(用sleep表示)。如果是多个不同的定时任务,如果其中一个定时任务指定完之后已经过了另外一个任务应该执行的时间点了,那么这个“定时”就已经不再是准确意义上的定时了,我们把这称为问题B。这两种情况总结成如下问题定义

  • 问题A 同一个定时任务多次运行,按时间排队运行;
  • 问题B 多个定时任务时间冲突,排队运行。

这两个问题发生的根本原因就是 @EnableScheduling 开启的调度默认是使用单线程(更严谨一点是线程数为1的线程池)执行 task 的,任务排队调用。
那么就可以自然而然地想到,解决方式就是使用线程池。

定时任务指定线程池

在jdk中,我们使用 Timer 来执行定时任务,spring 为这种调度提供了自己的抽象–TaskScheduler,提供如下抽象方法及多个重载:

schedule()
scheduleAtFixedRate()
scheduleWithFixedDelay()

这和我们之前用注解的方式实现的调度任务对比一下就能发现他们之间的联系:注解的底层就是用这个抽象来实现功能的。
我们可以通过为 spring 提供配置过的 TaskScheduler 的 bean 来覆盖默认的单线程配置。而 TaskScheduler 是一个接口,其实现类 ThreadPoolTaskScheduler 才是我们通常使用的目标,实现 SchedulingConfigurer 接口就能对其进行配置 :

@Configuration
public class SchedulerConfig implements SchedulingConfigurer {
    @Override
    public void configureTasks(ScheduledTaskRegistrar scheduledTaskRegistrar) {
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(10);
        scheduler.setThreadNamePrefix("schedule-task-pool-");
        scheduler.initialize();
        scheduledTaskRegistrar.setTaskScheduler(scheduler);
    }
}

可以看到,这里可以通过 setPoolSize() 方法设置线程池的大小,并且可以通过 setThreadNamePrefix() 方法设置线程池名字的前缀,方便跟踪调试。需要注意的是配置好参数只会一定要调用 initialize() 方法,否则会报错:

java.lang.IllegalStateException: ThreadPoolTaskScheduler not initialized

如果需要对线程池进行更精细的控制,ThreadPoolTaskScheduler 本身的类方法还不够,但是可以通过 spring 封装的线程池类 ThreadPoolTaskExecutor 进行间接设置:

 @Override
    public void configureTasks(ScheduledTaskRegistrar scheduledTaskRegistrar) {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setThreadNamePrefix("scheduled-pool-");
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        scheduler.setThreadFactory(executor);
        scheduler.initialize();
        scheduledTaskRegistrar.setTaskScheduler(scheduler);
    }

或者也可以直接通过提供 ThreadPoolTaskExecutor 的 bean 配置线程池:

@Configuration
public class ThreadPoolConfig {
    @Bean
    public ThreadPoolTaskExecutor getExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(10);
        return executor;
    }
}

配置好线程池之后,我们来运行一下同时包含上述 fixedRatefixedDelay 两个定时任务的应用,输出如下:

2019-12-30 18:01:35 [schedule-task-pool-1] INFO | Fixed Rate Task :: Execution Time - 18:01:35
2019-12-30 18:01:35 [schedule-task-pool-2] INFO | Fixed Delay Task :: Execution Time - 18:01:35
2019-12-30 18:01:37 [schedule-task-pool-1] INFO | Fixed Rate Task :: Execution Time - 18:01:37
2019-12-30 18:01:39 [schedule-task-pool-3] INFO | Fixed Rate Task :: Execution Time - 18:01:39
2019-12-30 18:01:40 [schedule-task-pool-1] INFO | Fixed Delay Task :: Execution Time - 18:01:40

这时,不同的定时任务可以在不同的线程中得到并发。上述问题B得到了解决。
再来看问题A,配置了线程池之后再重新运行以下代码:

    @Scheduled(fixedRate = 2000)
    public void scheduleTaskWithFixedRate() {
        log.info("Fixed Rate Task :: Execution Time - {}", dateTimeFormatter.format(LocalDateTime.now()) );
        try {
            TimeUnit.SECONDS.sleep(3);
        } catch (Exception e) {
        }
    }

输出:

2019-12-30 18:12:25 [schedule-task-pool-1] INFO | Fixed Rate Task :: Execution Time - 18:12:25
2019-12-30 18:12:28 [schedule-task-pool-2] INFO | Fixed Rate Task :: Execution Time - 18:12:28
2019-12-30 18:12:31 [schedule-task-pool-1] INFO | Fixed Rate Task :: Execution Time - 18:12:31
2019-12-30 18:12:34 [schedule-task-pool-3] INFO | Fixed Rate Task :: Execution Time - 18:12:34

很不幸的是,从输出可以看出虽然每次执行都可能使用不同的线程,但是依然会等到上一次任务执行完成之后才会开始下一次执行,因为间隔时间是3秒而不是2秒。这说明即使配置了线程池也无法让同一个定时任务的多次运行之间并发
实现同一个任务的并发这个目的,需要使用异步执行。首先像上面的 @EnableScheduling 一样,使用 @EnableAsync 开启对异步的支持。然后在调度任务上使用 @Async 注解表示该方法是个异步任务:

    @Async("taskExecutor")  // 可以根据bean name指定线程池
    @Scheduled(fixedRate = 2000)
    public void scheduleTaskWithFixedRate() {
        log.info("Fixed Rate Task :: Execution Time - {}", dateTimeFormatter.format(LocalDateTime.now()) );
        try {
            TimeUnit.SECONDS.sleep(3);
        } catch (Exception e) {
        }
    }

输出:

2019-12-30 18:59:04 [taskExecutor-1] INFO | Fixed Rate Task :: Execution Time - 18:59:04
2019-12-30 18:59:06 [taskExecutor-2] INFO | Fixed Rate Task :: Execution Time - 18:59:06
2019-12-30 18:59:08 [taskExecutor-3] INFO | Fixed Rate Task :: Execution Time - 18:59:08
...

这次可以看到该任务的每次运行使用不同的线程,并且可以做到并发。这样就解决了上述的问题B
进一步地,如果并发任务可以通过 @Async 指定某个具体的线程池的话,那么可以提供多线程池的bean,然后通过 @Async 把不同的任务分配到不同的线程池中去执行。
对于 @Async 标注的方法,有两点需要注意的:

  1. 它只能用于 public 方法;
  2. 如果它被类内部的方法调用的话,异步功能无法生效。
    这两点其实在使用代理实现功能的注解上都需要注意,比如说用于缓存的 @Cacheable 注解。因为方法只有是 public 的才能被正确代理,并且如果调用来自类内的话,就会越过代理而调用真实方法,无法实现注解所表示的功能。

总结

本文梳理了springboot自带的定时任务的基本用法及他们之间的区别,对于简单的业务场景,这种 out-of-box 的工具对用户是非常友好的。同时也指出了其默认使用单线程处理同一个任务的多次运行以及多个任务的运行,这样对于较为耗时的操作可能会导致阻塞。然后针对这两种情况给出了使用多线程运行定时任务的方案。

文中代码可在GitHub找到。


文章作者: 木白
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 木白 !
  目录