Skip to content

Spring 定时任务与任务调度

Spring 项目里,定时任务非常常见:

  1. 定时关闭超时订单
  2. 周期同步报表
  3. 延迟补偿失败任务
  4. 定时清理临时数据

不过在归类上要先分清:这条能力虽然常在 Spring Boot 项目里使用,但底层归属其实是 Spring Framework,尤其是 spring-context 里的 scheduling 支持。

但真正要解决的问题,不只是“让一段代码每隔多久跑一次”,而是:

  1. 任务应该按什么节奏执行
  2. 任务代码如何自然接入 Spring Bean
  3. 多个任务并存时,线程资源怎么管理
  4. 集群部署后,任务会不会被重复执行

这篇文章重点讲:

  1. Spring 定时任务到底是什么
  2. @EnableScheduling@Scheduled 怎么用
  3. fixedRatefixedDelaycron 有什么区别
  4. 为什么很多项目要自定义调度线程池
  5. Spring 定时任务的边界和常见误区是什么

1. Spring 为什么还要单独抽象定时任务

如果只用 Java 原生线程池,也能做延迟任务和周期任务。

但在 Spring 项目里,更常见的诉求通常是:

  1. 任务本身就是一个 Spring Bean
  2. 任务里要直接注入 Service、Repository、配置项
  3. 任务调度规则最好能写在注解或配置里
  4. 应用启动后,任务能自动注册和执行

所以 Spring 定时任务更适合先理解成:把进程内调度能力和 Spring 容器、Bean 管理、配置体系接起来。

🌟 这里有个很重要的边界:Spring 定时任务默认解决的仍然是单应用实例里的任务调度,不直接等于分布式调度系统。


2. 最小可运行的 Spring 定时任务长什么样

最常见的入口组合是:

  1. @EnableScheduling
  2. @Scheduled

@EnableScheduling 用来开启定时任务能力;
@Scheduled 用来声明某个方法应该按什么节奏执行。

例如:

java
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

/**
 * Spring Boot 应用入口,开启定时任务能力。
 */
@SpringBootApplication
@EnableScheduling
public class SchedulingApplication {

    public static void main(String[] args) {
        SpringApplication.run(SchedulingApplication.class, args);
    }
}
java
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

/**
 * 超时订单关闭任务。
 */
@Component
public class OrderTimeoutCloseJob {

    /**
     * 每轮执行结束后等待 30 秒,再扫描一次超时未支付订单。
     */
    @Scheduled(initialDelay = 10_000, fixedDelay = 30_000)
    public void closeTimeoutOrders() {
        // findTimeoutUnpaidOrders();
        // closeOrder(orderId);
    }
}

这段代码已经能帮助理解 Spring 定时任务的主线:

  1. 方法本身属于普通 Spring Bean
  2. Spring 在应用启动后会把这个方法注册成调度任务
  3. 到达触发时间后,框架会帮你调用它

这里还有两个容易忽略的小限制:

  1. @Scheduled 方法通常不接收入参
  2. 返回值不会作为调度结果继续流转

也就是说,它更适合表达“到点执行一个动作”,而不是“像普通业务方法那样带参数返回结果”。


3. fixedRatefixedDelaycron 到底怎么选

这是 Spring 定时任务里最容易混的地方。

方式基准点更适合什么场景
fixedRate以上一次计划开始时间为基准指标采集、固定频率刷新
fixedDelay以上一次执行结束时间为基准扫描补偿、批处理、轮询
cron按日历时间表达式触发每天、每小时、工作日等规则

把这 3 种触发方式分开:

  1. 想要执行节奏更固定,用 fixedRate
  2. 想要上一轮做完再等下一轮,用 fixedDelay
  3. 想要“每天几点”这类日历语义,用 cron

例如下面这三个任务,表达的就是 3 种不同思路:

java
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

/**
 * 演示三种常见调度方式。
 */
@Component
public class ScheduleModeDemo {

    /**
     * 更关注固定节奏,例如每 10 秒采集一次指标。
     */
    @Scheduled(fixedRate = 10_000)
    public void collectMetrics() {
        // collectJvmMetrics();
    }

    /**
     * 更关注上一轮做完后再继续,例如补偿扫描。
     */
    @Scheduled(initialDelay = 5_000, fixedDelay = 20_000)
    public void retryFailedTasks() {
        // loadFailedTasks();
        // retry(taskId);
    }

    /**
     * 更关注日历时间,例如每 5 分钟做一次报表同步。
     */
    @Scheduled(cron = "0 0/5 * * * ?")
    public void syncReport() {
        // syncIncrementalReport();
    }
}

4. cron 表达式到底在描述什么

cron 更适合描述“按时间表触发”的任务。

在 Spring 里,最常见的 6 段式写法就是:

text
秒 分 时 日 月 周

例如:

表达式含义
0 0 * * * ?每小时整点执行
0 0 2 * * ?每天凌晨 2 点执行
0 0/10 * * * ?每 10 分钟执行一次
0 0 9 ? * MON-FRI工作日早上 9 点执行

例如一个日报生成任务:

java
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

/**
 * 日报生成任务。
 */
@Component
public class DailyReportJob {

    /**
     * 每天凌晨 2 点生成前一天的运营日报。
     */
    @Scheduled(cron = "0 0 2 * * ?")
    public void buildDailyReport() {
        // loadYesterdayStats();
        // generateReportFile();
        // notifyOperators();
    }
}

如果任务受时区影响比较明显,还要注意明确 zone,避免不同部署环境下触发时间不一致。

例如:

java
@Scheduled(cron = "0 0 9 * * ?", zone = "Asia/Shanghai")
public void sendMorningDigest() {
    // sendDigest();
}

5. 为什么很多项目不建议直接依赖默认调度线程

很多人第一次写 @Scheduled,觉得能跑起来就够了。

但真实项目里,一个高频问题是:默认调度线程模型太保守,多个任务容易互相影响。

如果没有显式配置,Spring 常见的默认行为可以近似理解成:先给你一个比较轻量的调度器。

这在简单项目里够用,但如果:

  1. 定时任务不止一个
  2. 某个任务执行时间较长
  3. 任务里还有网络调用、数据库扫描、文件处理

那更常见的做法是显式配置调度线程池。

例如:

java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;

/**
 * 定时任务调度器配置。
 */
@Configuration
public class SchedulingConfig {

    /**
     * 提供一个可复用的定时任务线程池,避免多个任务全部挤在同一条线程上。
     *
     * @return Spring 使用的任务调度器
     */
    @Bean
    public ThreadPoolTaskScheduler taskScheduler() {
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(4);
        scheduler.setThreadNamePrefix("schedule-");
        scheduler.setWaitForTasksToCompleteOnShutdown(true);
        scheduler.setAwaitTerminationSeconds(30);
        scheduler.setErrorHandler(ex -> System.err.println("schedule task failed: " + ex.getMessage()));
        return scheduler;
    }
}

🌟 这段配置最核心的价值不是“把线程池写出来”,而是:把调度线程数、关闭行为和异常处理变成可控项。


6. Spring 定时任务最容易踩的坑是什么

6.1 以为 @Scheduled 天然适合所有任务

不是。

它更适合:

  1. 进程内轻量调度
  2. 规则比较固定的后台任务
  3. 直接依赖 Spring Bean 的任务逻辑

如果你需要:

  1. 任务持久化
  2. 失败重试编排
  3. 可视化管理后台
  4. 集群下稳定抢占执行

那通常要继续考虑更重的任务调度方案。

6.2 以为集群部署后任务也只会执行一份

这也是高频误区。

如果应用部署了多个实例,而每个实例都启用了同一个 @Scheduled 任务,那么它们通常都会各自执行一遍。

也就是说:

  1. 单机上没问题
  2. 到了集群就可能重复跑
  3. 下游如果不是幂等的,就可能出问题

所以真正进入多实例场景时,往往还要补:

  1. 分布式锁
  2. 幂等控制
  3. 独立调度中心

6.3 以为定时任务只是“写个注解”

真实工程里,除了触发规则,还要一起想清楚:

  1. 任务执行时间会不会过长
  2. 任务失败后怎么补偿
  3. 多个任务会不会争用线程
  4. 任务是否允许重入
  5. 下游接口是否支持幂等

7. Spring 定时任务、Java 原生定时任务和调度平台怎么选

可以按下面这条线判断:

7.1 只在当前进程里做轻量调度

优先考虑 ScheduledExecutorService

它更适合:

  1. 不依赖 Spring 容器的普通 Java 程序
  2. 轻量延迟任务
  3. 简单周期轮询

7.2 在 Spring 应用里做常规定时任务

优先考虑 @Scheduled

它更适合:

  1. 任务逻辑本身就是 Spring Bean
  2. 想直接注入 Service、配置项、数据访问组件
  3. 调度规则比较固定

7.3 已经进入分布式调度和任务治理场景

这时通常要继续考虑:

  1. Quartz
  2. XXL-JOB
  3. 分布式锁配合调度任务

更贴近工程现实的是:Spring 定时任务适合应用内调度,Quartz / XXL-JOB 更偏任务平台和任务治理。

如果你想继续顺着看,可以接着读:

  1. 并发编程、JUC 与线程安全:Java 定时任务
  2. 分布式锁、ID 与任务调度

8. 一句话总结

Spring 定时任务本质上就是:把进程内调度能力接进 Spring 容器,用更自然的方式管理任务触发规则、任务 Bean 和调度线程,但它默认仍然不是分布式任务调度系统。

基于 VitePress 构建的个人技术笔记。