Skip to content

在Spring Boot中实现定时任务

本文主要介绍如何在在Spring Boot中实现定时任务。通过注解和代码两种方式实现。

1. 基础使用

首先在任意的配置类上使用注解@EnableScheduling开启定时任务支持,这里我们选择在主类上开启:

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

然后,通过注解@Scheduled标注在组件里的方法,使得该方法成为定时任务:

java
@Component
public class ScheduleTask {

    @Scheduled(fixedRate = 1000)
    public void run() {
        System.out.println("ScheduleTask---The time is now " +
                LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
    }
}

关键点:

  • 使用@Component将类放入容器中,使得组件可以被管理调度;
  • 使用@Scheduled(fixedRate = 1000)指定方法为定时任务(以下都称被@Schedule标注的方法为任务),必须指定调度模式;

之后,启动程序就可以看到每秒在控制台上打印提示信息了。

2. 定时任务的调度模式

@Schedule注解中,存在几个调度模式,决定了任务以什么频率执行。

2.1 initialDelay

initialDelay决定了任务第一次调度执行前需要等待的时间,单位为毫秒。如果只指定该模式,那么任务只会执行一次。

java
@Scheduled(initialDelay = 1000)

2.2 fixedRate

fixedRate决定了两次任务开始执行的间隔时间,单位为毫秒。如:

java
@Scheduled(fixedRate = 1000)
public void run() {
        System.out.println("ScheduleTask---The time is now " +
                LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
}

即假设第一次任务执行的时间在10:10:01,那么第二次任务的执行时间在10:10:02

假设任务的执行时间超过了fixedRate所指定的间隔时间,那么第二次任务该如何执行呢?我们可以做个实验,让任务休眠3秒:

java
@Scheduled(fixedRate = 1000)
public void run() throws InterruptedException {
    Thread.sleep(3000);
    System.out.println("ScheduleTask---The time is now " +
            LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
}

再次运行程序,发现提示信息是以3秒的间隔时间打印的。

所以如果任务的执行时间超过了调度的间隔时间,那么下一次任务调度会等到上一次任务结束。

2.4 fixedDelay

fixedDelay指定了下一次的任务开始时间,距离上一次任务的结束时间的时间间隔,单位为毫秒。我们以下面的例子做实验:

java
@Scheduled(fixedDelay = 1000)
public void run() throws InterruptedException {
    Thread.sleep(3000);
    System.out.println("ScheduleTask---The time is now " +
            LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
}

可以看到提示信息是以4秒的间隔时间打印的。因为任务本身执行需要耗时3秒,然后fixedDelay指定了下一次任务调度需要延时1秒,所以总共4秒打印一次。

2.5 cron

cron表示通过cron表达式控制任务的调度时间。例如:

java
@Scheduled(cron = "* * * * * *")
public void run() throws InterruptedException {
    System.out.println("begin ScheduleTask---The time is now " + LocalDateTime.now());
  	Thread.sleep(3000);
  	System.out.println("end ScheduleTask---The time is now " + LocalDateTime.now());
}

在上面的例子中,cron表达式是* * * * * *,表示每秒执行一次。注意,任务调度存在延迟,即假如第一次任务开始于20:01:01,任务本身耗时3秒,那么下一次任务应该开始于20:01:04,但下一次任务时间开始于20:01:05

什么是cron表达式?参考:https://docs.spring.io/spring-framework/reference/integration/scheduling.html#scheduling-cron-expression

Cron表达式 是一种用于描述时间间隔的字符串,它在Unix系统上广泛用于调度任务。通过Cron表达式,你可以精确地定义一个任务在何时执行,比如每天的某个时间,每周的某一天,或者每月的一定日期。

Cron表达式的组成

一个Cron表达式通常由6个字段组成,分别表示:

  • 秒(Seconds):0-59之间的整数
  • 分(Minutes):0-59之间的整数
  • 小时(Hours):0-23之间的整数
  • 日(Day of month):1-31之间的整数
  • 月(Month):1-12之间的整数或JAN-DEC
  • 星期(Day of week):0-7之间的整数(0或7表示星期日)或SUN-SAT

这些字段之间用空格分隔。

image-20241223191222682

Cron表达式中可以使用一些特殊的字符来表示范围、列表、步长等:

  • ***或?:**表示匹配该字段的任意值。
  • -: 表示一个范围。
  • ,: 表示枚举多个值。
  • /: 表示步长。

示例:

  • 每秒钟执行一次: * * * * * *
  • 每天凌晨2点执行一次: 0 0 2 * * *
  • 每月第一天凌晨0点执行一次: 0 0 0 1 * *
  • 每周一、三、五的10点执行一次: 0 0 10 * * 1,3,5
  • 每隔5秒钟执行一次: */5 * * * * *

我们可以使用spring提供的CronExpression.isValidExpression()静态方法来校验字符串是否为正确的cron表达式:

java
@Test
void test01(){
    // 下面是正确的
    System.out.println(CronExpression.isValidExpression("* * * * * *"));
    System.out.println(CronExpression.isValidExpression("0 0 2 * * *"));
    System.out.println(CronExpression.isValidExpression("0 0 0 1 * *"));
    System.out.println(CronExpression.isValidExpression("0 0 10 * * 1,3,5,7"));
    System.out.println(CronExpression.isValidExpression("*/5 * * * * 0,2,4,6"));
    System.out.println(CronExpression.isValidExpression("0-5 * * * * *"));

    // 下面是错误的
    System.out.println(CronExpression.isValidExpression("* * * * *"));
    System.out.println(CronExpression.isValidExpression("* * * * * * *"));
    System.out.println(CronExpression.isValidExpression("60 * * * * *"));
    System.out.println(CronExpression.isValidExpression("* 60 * * * *"));
    System.out.println(CronExpression.isValidExpression("* * 25 * * *"));
    System.out.println(CronExpression.isValidExpression("* * * 32 * *"));
    System.out.println(CronExpression.isValidExpression("* * * * 13 *"));
    System.out.println(CronExpression.isValidExpression("* * * * * 8"));
}

3. 并发执行定时任务

在spring中,默认只使用一个线程来调度任务执行。我们可以通过如下案例演示:

java
@Component
public class ScheduleTask {

    @Scheduled(fixedDelay = 1000)
    public void run1() throws InterruptedException {
        String threadName = Thread.currentThread().getName();
        System.out.println(threadName + " begin ScheduleTask---The time is now " + LocalDateTime.now());
        Thread.sleep(5000);
        System.out.println(threadName + " end ScheduleTask---The time is now " + LocalDateTime.now());
    }

    @Scheduled(fixedRate = 2000)
    public void run2() {
        String threadName = Thread.currentThread().getName();
        System.out.println(threadName + " ScheduleTask2---The time is now " + LocalDateTime.now());
    }
}

任务一每秒钟执行一次,但是需要耗时5秒。任务二每两秒执行一次,耗时忽略不计。如果是并发执行,那么在任务一执行期间,应该能看到任务二打印的信息。但是结果如下:

txt
scheduling-1 begin ScheduleTask---The time is now 2024-12-23T19:20:11.020438
scheduling-1 end ScheduleTask---The time is now 2024-12-23T19:20:16.024205
scheduling-1 ScheduleTask2---The time is now 2024-12-23T19:20:16.024657
scheduling-1 ScheduleTask2---The time is now 2024-12-23T19:20:16.024811
scheduling-1 ScheduleTask2---The time is now 2024-12-23T19:20:17.017654
----这条线是手动加的,方便观看分析---
scheduling-1 begin ScheduleTask---The time is now 2024-12-23T19:20:17.026388
scheduling-1 end ScheduleTask---The time is now 2024-12-23T19:20:22.028724
scheduling-1 ScheduleTask2---The time is now 2024-12-23T19:20:22.029476
scheduling-1 ScheduleTask2---The time is now 2024-12-23T19:20:22.029620
scheduling-1 ScheduleTask2---The time is now 2024-12-23T19:20:23.015557

可以看到在任务一执行期间,任务二并没有执行,反而积压到任务一执行完后才执行。并且,线程的名字相同。

如果我们想让多个任务并行执行,那么我们可以设置调度线程池大小。

方式一 修改配置文件:

yaml
spring:
  task:
    scheduling:
      pool:
        size: 5

方式二 注册组件:

java
@Configuration
@EnableScheduling // 启用定时任务
public class SchedulingConfig implements SchedulingConfigurer {

    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
        taskRegistrar.setScheduler(taskExecutor());
    }

    @Bean
    public Executor taskExecutor() {
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(5); // 设置线程池大小
        scheduler.setThreadNamePrefix("MyScheduled-"); // 设置线程名前缀
        return scheduler;
    }
}

两种方式均可,现在我们通过方式二的方式,再次运行上面的测试程序,结果如下:

txt
MyScheduled-1 ScheduleTask2---The time is now 2024-12-23T19:32:43.199881
MyScheduled-4 begin ScheduleTask---The time is now 2024-12-23T19:32:43.203226
MyScheduled-3 ScheduleTask2---The time is now 2024-12-23T19:32:45.199786
MyScheduled-3 ScheduleTask2---The time is now 2024-12-23T19:32:47.199859
MyScheduled-4 end ScheduleTask---The time is now 2024-12-23T19:32:48.208159
MyScheduled-3 ScheduleTask2---The time is now 2024-12-23T19:32:49.194922
MyScheduled-1 begin ScheduleTask---The time is now 2024-12-23T19:32:49.209076
MyScheduled-5 ScheduleTask2---The time is now 2024-12-23T19:32:51.195891
MyScheduled-5 ScheduleTask2---The time is now 2024-12-23T19:32:53.194892
MyScheduled-1 end ScheduleTask---The time is now 2024-12-23T19:32:54.212709

可以看到,在任务一的执行期间,任务二也执行了,并且线程名不同,说明使用了不同的线程。

4. 如何使用代码执行定时任务

我们可以抛弃注解的方式,使用代码的方式来手动指定定时任务,spring提供的ThreadPoolTaskScheduler提供了一些方法。

首先我们在程序主类中设置ThreadPoolTaskScheduler,注意使用代码的方式不开启@EnableScheduling

java
@SpringBootApplication
//@EnableScheduling
public class SpringbootDemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(SpringbootDemoApplication.class, args);
    }

    @Bean
    public TaskScheduler taskScheduler() {
        ThreadPoolTaskScheduler threadPoolTaskScheduler = new ThreadPoolTaskScheduler();
        threadPoolTaskScheduler.setPoolSize(5);
        threadPoolTaskScheduler.setThreadNamePrefix("ThreadPoolTaskScheduler");
        return threadPoolTaskScheduler;
    }

}

ThreadPoolTaskScheduler中,提供了以schedule起始的相关方法,其入参分别是定时任务和调度模式,例如:

  • schedule(Runnable task, Date startTime):在某个时间点调度任务执行;
  • schedule(Runnable task, Trigger trigger):通过指定CRON表达式调度任务执行,见CronTrigger实现;
  • scheduleAtFixedRate(Runnable task, long period):如@Scheduled(fixedRate = xxx)
  • scheduleWithFixedDelay(Runnable task, long delay):如@Scheduled(fixedDelay = xxx)

我们可以创建一个控制器Controller,通过调接口的方式手动调度任务执行:

java
@RestController
public class TaskSchedulerController {

    @Resource
    private TaskScheduler threadPoolTaskScheduler;

    @GetMapping("/start")
    String start(){
        threadPoolTaskScheduler.scheduleAtFixedRate(()->System.out.println(LocalDateTime.now()), 1000);
        return "ok";
    }

}

注意,第5行类型应声明为TaskScheduler

然后调接口就能看到控制台以每秒中的频率输出时间信息了。

5. 如何关闭和启动定时任务

如果我们通过ThreadPoolTaskScheduler来调度定时任务,那么这些方法会返回ScheduledFuture<?>对象,我们可以使用该对象来关闭定时任务,如下:

java
@RestController
public class TaskSchedulerController {

    @Resource
    private TaskScheduler threadPoolTaskScheduler;

    private ScheduledFuture<?> scheduledFuture;

    @GetMapping("/start")
    String start(){
        if(scheduledFuture == null || scheduledFuture.isCancelled()) {
            scheduledFuture = threadPoolTaskScheduler.scheduleAtFixedRate(() -> System.out.println(LocalDateTime.now()), 1000);
        }
        return "ok";
    }

    @GetMapping("/stop")
    String stop(){
        if (scheduledFuture != null && !scheduledFuture.isCancelled()) {
            scheduledFuture.cancel(false);  // 参数为 false,不会中断正在执行的任务
            System.out.println("Task has been canceled.");
        }
        return "ok";
    }

}

然后我们就可以通过/start接口启动定时任务,通过/stop接口关闭定时任务了。

6. 定时任务的管理

首先定义一个定时任务抽象类ScheduledTask,其中包括开启定时任务和关闭定时任务的方法,以及抽象的定时任务(待子类实现):

java
/**
 * 定时任务抽象类
 */
public abstract class ScheduledTask {

    @Resource
    private ThreadPoolTaskScheduler taskScheduler;

    private ScheduledFuture<?> scheduledFuture;

    public void startTask(Date date) {
        if(scheduledFuture == null || scheduledFuture.isCancelled() || scheduledFuture.isDone()) {
            scheduledFuture = taskScheduler.schedule(this::run, date);
        }
    }

    public void startTaskAtFixedRate(long period) {
        if(scheduledFuture == null || scheduledFuture.isCancelled() || scheduledFuture.isDone()) {
            scheduledFuture = taskScheduler.scheduleAtFixedRate(this::run, period);
        }
    }

    public void startTaskWithFixedDelay(long delay) {
        if(scheduledFuture == null || scheduledFuture.isCancelled() || scheduledFuture.isDone()) {
            scheduledFuture = taskScheduler.scheduleWithFixedDelay(this::run, delay);
        }
    }

    public void startTaskWithCron(String cron) {
        if(scheduledFuture == null || scheduledFuture.isCancelled() || scheduledFuture.isDone()) {
            scheduledFuture = taskScheduler.schedule(this::run, new CronTrigger(cron));
        }
    }

    /**
     * 定义实际的定时任务方法
     */
    public abstract void run();

    /**
     * 取消任务
     */
    public void cancelTask() {
        if (scheduledFuture != null && !scheduledFuture.isCancelled()) {
            scheduledFuture.cancel(false);  // 参数为 false,不会中断正在执行的任务
            System.out.println("Task has been canceled.");
        }
    }

}

然后子类继承抽象类ScheduledTask,定义具体的定时任务:

java
@Component
public class ScheduledTaskManagerOne extends ScheduledTask {

    @Override
    public void run() {
        System.out.println(LocalDateTime.now()+ "-------");
    }

}


@Component
public class ScheduledTaskManagerTwo extends ScheduledTask {

    @Override
    public void run() {
        System.out.println("第二个任务开始:" + LocalDateTime.now());
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("第二个任务结束:"+ LocalDateTime.now());
    }
}

新增一个定时任务配置类,为每个定时任务取个名字并以Map的形式放入容器:

java
@Configuration
public class ScheduleConfig {

    @Resource
    private ScheduledTaskManagerOne scheduledTaskManagerOne;
    @Resource
    private ScheduledTaskManagerTwo scheduledTaskManagerTwo;

    @Bean
    Map<String, ScheduledTask> scheduledTaskMap(){
        return new HashMap<String, ScheduledTask>(){{
            put("taskOne", scheduledTaskManagerOne);
            put("taskTwo", scheduledTaskManagerTwo);
        }};
    }
}

此处后续可考虑以反射的方式自动发现定时任务类,不用手动声明。

然后我们即可定义接口来动态管理定时任务的开启和关闭了:

java
@RestController
public class ScheduleController {

    @Resource
    private Map<String, ScheduledTask> scheduledTaskMap;

    @GetMapping("/startAnother")
    public String start(@Valid StartScheduledTaskRequest startScheduledTaskRequest){
        ScheduledTask scheduledTaskManager = scheduledTaskMap.get(startScheduledTaskRequest.getName());
        if(scheduledTaskManager == null)
            return "fail";

        switch (startScheduledTaskRequest.getMode()) {
            case 1:
                scheduledTaskManager.startTask(startScheduledTaskRequest.getDate());
                break;
            case 2:
                scheduledTaskManager.startTaskAtFixedRate(startScheduledTaskRequest.getPeriod());
                break;
            case 3:
                scheduledTaskManager.startTaskWithFixedDelay(startScheduledTaskRequest.getDelay());
                break;
            case 4:
                scheduledTaskManager.startTaskWithCron(startScheduledTaskRequest.getCron());
                break;
        }

        return "success";
    }

    @GetMapping("/stopAnother")
    public String stop(@RequestParam("name") String name){
        ScheduledTask scheduledTaskManager = scheduledTaskMap.get(name);
        if(scheduledTaskManager == null)
            return "fail";

        scheduledTaskManager.cancelTask();

        return "success";
    }

}
java
@Data
public class StartScheduledTaskRequest {

    @NotNull(message = "任务名为空!")
    private String name;

    @Min(value = 1, message = "调度模式应为 1-定时,2-fixedRate,3-delay,4-cron")
    @Max(value = 4, message = "调度模式应为 1-定时,2-fixedRate,3-delay,4-cron")
    @NotNull(message = "调度模式应为 1-定时,2-fixedRate,3-delay,4-cron")
    private Integer mode;

    private Long period;

    private Long delay;

    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private Date date;

    private String cron;
}