《Spring Boot 实战》13.1 调度方案选型

从图书借阅服务的逾期提醒出发,讲清 @Scheduled 的 fixedRate、fixedDelay、cron 三者真实差异,单线程调度器为何互相拖累,如何用 SchedulingConfigurer 换线程池,并横向对比 Quartz 与 XXL-JOB,最后给出一张按场景选型的决策表。

本节目标:搞清 Spring Boot 里做定时任务的几种方案各自的边界——先吃透 @Scheduled 的真实语义与坑,再决定要不要上 Quartz 或调度中心,最后用一张表按场景选型。
适用版本:Spring Boot 4.1.x(Java 21)

13.1 调度方案选型

图书借阅服务跑起来以后,很快会冒出「不靠人触发也要做的事」:给快到期的借阅发提醒、每晚结算逾期罚金、每小时同步一次外部书目。这类需求统称调度,看起来就是「加个定时注解」,但生产上翻车往往出在选型——工具用错了,后面的重跑、分片、补偿都要推倒重来。

本节先吃透 Spring 自带的 @Scheduled(它覆盖的场景比多数人想象的多),再看它撑不住时该换什么,最后给出一张决策表。至于大批量数据处理,留给 13.2 的 Spring Batch;多实例下的重复执行与幂等,留给 13.3。

13.1.1 Spring Boot 不会替你打开调度

一个常见的误解是「引了 Spring Boot 就能写 @Scheduled」。事实上 Spring Boot 的 TaskSchedulingAutoConfiguration 只负责提供一个 TaskScheduler bean,它不会去扫描和处理 @Scheduled 注解。要真正启用,必须在某个配置类或主类上显式加 @EnableScheduling:

import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

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

@Component
class OverdueReminder {

    private final LoanService loanService;

    OverdueReminder(LoanService loanService) {
        this.loanService = loanService;
    }

    @Scheduled(fixedDelay = 5, timeUnit = java.util.concurrent.TimeUnit.MINUTES)
    void remind() {
        loanService.sendOverdueReminders();
    }
}

@EnableScheduling 会注册 ScheduledAnnotationBeanPostProcessor,它在容器启动后扫描所有 bean,把带 @Scheduled 的方法登记到 TaskScheduler 上。三条硬约束必须记住:

  • 方法所在类必须是 Spring bean(@Component 之类),自己 new 出来的对象不会被处理。
  • 方法不能有参数,返回值必须是 void。
  • 方法可以是 private/包级,但不要依赖代理语义——@Scheduled 由框架反射调用,把 @Transactional 直接标在 @Scheduled 方法上并不可靠。稳妥做法是把带事务的逻辑放进一个独立的 service 方法,让调度方法去调用它。

示例输出(未做任何调度配置时的启动日志,与站点实测的纯应用启动格式一致):

2026-10-02T10:00:03.114+08:00  INFO 51240 --- [           main] com.example.library.LibraryApplication  : Starting LibraryApplication using Java 21.0.12.1 with PID 51240
2026-10-02T10:00:03.902+08:00  INFO 51240 --- [           main] com.example.library.LibraryApplication  : Started LibraryApplication in 0.884 seconds (process running for 1.512)

如果忘了 @EnableScheduling,应用会正常启动、没有任何报错,只是任务永远不跑——这是最容易踩的「静默失效」,排查时先看这一条。

13.1.2 fixedRate、fixedDelay、cron 的真实差异

三者常被当成「都是定时」混用,但计时基准完全不同。看下面三个任务:

@Component
class LoanSchedules {

    // 固定频率:从「上一次开始执行」的时刻计时
    @Scheduled(fixedRate = 5, timeUnit = TimeUnit.MINUTES)
    void remindByFixedRate() { /* 扫描逾期并发送提醒 */ }

    // 固定延迟:从「上一次执行结束」的时刻计时
    @Scheduled(fixedDelay = 5, timeUnit = TimeUnit.MINUTES)
    void remindByFixedDelay() { /* 同上,但两次之间强制间隔 */ }

    // cron:按日历时刻触发,秒级字段在最前面(6 段)
    @Scheduled(cron = "0 0 2 * * *", zone = "Asia/Shanghai")
    void settleDailyFine() { /* 每天凌晨 2:00 结算罚金 */ }
}
属性计时基准会重叠吗适合
fixedRate上一次开始时刻 + 周期同一任务不重叠(见 13.1.5)要求「平均每 N 时间跑一次」,可接受追赶
fixedDelay上一次结束时刻 + 间隔不重叠,且两次之间必有间隔任务耗时不固定、不希望背靠背连跑
cron按日历时刻同一任务不重叠「每天几点」「每周一几点」这类自然语言需求

两个容易忽略的细节:

其一,timeUnit 属性。 老写法是 fixedRate = 300000(毫秒),难读也易错。Spring Framework 为 @Scheduled 提供了 timeUnit 属性(本节所用 Framework 7.0.9 已核实存在),写成 fixedRate = 5, timeUnit = TimeUnit.MINUTES 语义一目了然。

其二,字符串属性支持占位符与 Duration 格式。 fixedRateString、fixedDelayString、initialDelayString 三个字符串版本都支持 ${...} 占位符,且值可以写成 ISO-8601 Duration(PT5M)或简单格式(5m):

@Scheduled(fixedDelayString = "${loan.reminder.interval:5m}")
void remindConfigurable() { /* 间隔由配置决定,默认 5 分钟 */ }

还有一个常被忽略的属性是 initialDelay(字符串版 initialDelayString):它控制应用启动后第一次执行前的等待。不写它时,fixedRate/fixedDelay 任务会在容器启动后立刻跑第一次,多个任务会在启动瞬间一起涌出,抢连接池、抢外部配额。给非紧急任务加一个初始延迟,能把启动尖峰摊开:

@Scheduled(initialDelay = 30, fixedDelay = 5,
           timeUnit = TimeUnit.MINUTES)
void warmUpThenRemind() { /* 启动 30 秒后才开始,避开启动期 */ }

注意 cron 任务没有 initialDelay 的概念——它本就按日历触发,第一次执行时刻由表达式决定。

cron 用的是 Spring 的 6 段格式(秒 分 时 日 月 周),与 Linux crontab 的 5 段不同——直接抄 crontab 表达式会解析失败。zone 不写时用 JVM 默认时区;跨机房部署时务必显式写 zone,否则「凌晨 2 点结算」在不同时区的机器上会落在不同时刻。

13.1.3 单线程调度器:默认只有一个线程

这是 @Scheduled 最经典的生产事故。Spring Boot 自动配置的 TaskScheduler 是一个 ThreadPoolTaskScheduler,其线程池大小默认是 1——即所有 @Scheduled 任务共享同一个线程。该默认值来自配置属性 spring.task.scheduling.pool.size(默认 1,本机 wiki 的配置变更记录可核实)。

后果是:任何一个任务跑得久,都会把其它任务全部堵住。 设想借阅服务里有三个任务:逾期提醒要跑 3 分钟、日志清理要跑 5 分钟、每 5 分钟一次的库存同步只该跑几秒。单线程下,库存同步会被前面两个任务推后,实际触发间隔变得不可预测。

示例输出(任务互相阻塞时,日志里会看到执行时刻与预期明显错位):

10:00:00.021 [scheduling-1] 逾期提醒开始
10:03:01.482 [scheduling-1] 逾期提醒结束
10:03:01.483 [scheduling-1] 日志清理开始
10:08:02.914 [scheduling-1] 日志清理结束
10:08:02.915 [scheduling-1] 库存同步开始        <- 本该在 10:00、10:05 各跑一次

判断方法很简单:看日志里的线程名。所有任务都是 scheduling-1,就说明它们共用一根线程。这一条比读任何文档都直观。

13.1.4 用 SchedulingConfigurer 换掉调度线程池

两种修法。轻量做法是只调大小:

spring:
  task:
    scheduling:
      pool:
        size: 4

这行配置够解决「互相阻塞」,但无法设置错误处理器、无法控制停机行为。需要更细的控制时,实现 SchedulingConfigurer:

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.SchedulingConfigurer;
import org.springframework.scheduling.config.ScheduledTaskRegistrar;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;

@Configuration
class SchedulingConfig implements SchedulingConfigurer {

    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(4);                          // 按任务数与耗时估算,不要盲目开大
        scheduler.setThreadNamePrefix("loan-sched-");      // 线程名带业务前缀,日志好认
        scheduler.setErrorHandler(t -> log.error("定时任务异常", t));
        scheduler.setWaitForTasksToCompleteOnShutdown(true); // 停机时等正在跑的任务收尾
        scheduler.setAwaitTerminationSeconds(30);            // 最多等 30 秒
        scheduler.initialize();
        taskRegistrar.setTaskScheduler(scheduler);
    }
}

setPoolSize、setThreadNamePrefix、setErrorHandler、setWaitForTasksToCompleteOnShutdown、setAwaitTerminationSeconds 均为 ThreadPoolTaskScheduler 及其父类 ExecutorConfigurationSupport 的公开方法(本机 spring-context 7.0.9 已核实)。

setPoolSize 该开多大?没有万能值,判断依据是「同一时刻最多有几个任务会真的并行跑」——如果任务都很短、错峰触发,2~4 就够;如果多个任务可能同时运行且都耗时,再相应放大。不要因为「线程越多越快」就开到几十——这些任务通常会争抢数据库连接和外部接口配额,池子开大反而更容易打爆下游。具体值要靠观察任务实际并发数来确定,没有可以照抄的数字。

setWaitForTasksToCompleteOnShutdown(true) 很关键:默认情况下应用停机时,正在执行的定时任务会被直接中断,一次跑到一半的结算可能只写了一半数据。开启等待后,停机流程会留出收尾时间——这与第 15 章讲的优雅停机是同一套思路。

13.1.5 任务耗时超过周期时会怎样

三种语义下的行为不同,且与直觉有出入:

属性单次耗时 > 周期时的行为
fixedRate下一次立即在上一次结束后开始(背靠背),不会重叠,实际频率退化为「约等于任务耗时」
fixedDelay不受影响:始终是「结束 + 间隔」后才跑,周期被拉长但语义不变
cron当前执行未结束时,该任务的后续触发被跳过,直到本次结束才重新按日历排下一次

「不重叠」这一保证来自底层:fixedRate 走 ScheduledExecutorService.scheduleAtFixedRate,其契约明确「同一任务的多次执行不会并发」;cron 由 ReschedulingRunnable 在上一次完成后才排下一次。所以同一个任务不会自己压自己——真正会互相阻塞的是不同任务之间(见 13.1.3)。

另一个必须知道的事实:任务抛异常不会停掉调度。 对周期性任务,Spring 会用一个「记录并吞掉异常」的错误处理器把任务包起来(TaskUtils 对 repeating 任务返回 LOG_AND_SUPPRESS_ERROR_HANDLER,本机 spring-context 7.0.9 已核实),异常被写进日志后,下一次触发照常进行。这有两面性:好处是不会因为一次异常整个调度停摆;坏处是如果任务每次都失败,日志会被刷屏而没人发现——所以错误处理器里除了记日志,还应该接入告警(见 13.3)。

13.1.6 横向对比:Quartz 与中心化调度

@Scheduled 的能力边界是「进程内、无持久化、无集群协调」。跨过这条线,就该考虑更重的工具。

Quartz 是「进程内 + 可选持久化 + 集群」的方案。它把任务(Job)与触发器(Trigger)分离,触发器状态可以存进数据库(JobStore 用 JDBC),多实例共享同一套触发器,靠数据库行锁保证同一个触发只被一个实例执行;还提供错过触发策略(misfire instruction)——比如应用停机期间错过了一次触发,是立刻补跑、还是跳过、还是只补一次。Spring Boot 通过 spring-boot-starter-quartz 集成,配置以 spring.quartz.* 为前缀。代价是要维护一批 Quartz 元数据表,且「改一次 cron」往往要动数据库记录或重新部署。

中心化调度(XXL-JOB、ElasticJob、PowerJob 等)把「什么时候触发」从业务进程里彻底拿出来,交给一个独立的调度中心:业务侧只注册成「执行器」,调度中心按配置触发、并负责可视化、手动重跑、失败重试、分片广播、执行日志。这类工具解决的是 @Scheduled 完全给不了的东西——运维视角的可观测与可干预。代价是引入一个额外的中间件、一套部署与账号体系,小团队为几个定时任务上它是明显的过度设计。

维度@ScheduledQuartz中心化调度(XXL-JOB 类)
触发位置应用进程内应用进程内(状态可入库)独立调度中心
持久化触发无有(JDBC JobStore)有
集群去重无(需自己加锁)有(数据库锁)有
错过补偿无有(misfire 策略)有
可视化 / 手动重跑无弱强
引入成本零中(一批元数据表)高(独立中间件)

13.1.7 按场景选型

把上面的结论收敛成一张决策表:

场景建议
单机、任务短(秒级)、偶发失败可接受@Scheduled,零成本
单机、任务长或多个任务并存@Scheduled + 自定义线程池(SchedulingConfigurer)
多实例部署、任务不能重复跑@Scheduled + 分布式锁(见 13.3)
需要可视化、手动重跑、分片、失败重试中心化调度(XXL-JOB 等)
需要持久化触发与错过补偿策略Quartz
大批量数据的分批处理Spring Batch(13.2)

这张表的落点是一句话:「单机 @Scheduled + 分布式锁」能覆盖绝大多数团队的需求,不必一上来就上调度中心。 判断依据是「你到底缺的是执行能力,还是运维能力」——缺的是「让任务跑起来」,@Scheduled 加一把锁就够了;只有当缺的是「让运维能看见、能干预、能重跑」,调度中心才值回它的复杂度。很多团队引入 XXL-JOB 的动机其实是「想要一个能看到任务成功失败的面板」,而这用一张任务执行记录表加告警(13.3)就能满足。

小结

  • Spring Boot 不会自动启用调度,必须显式加 @EnableScheduling;忘了它是「静默失效」,应用照常启动但任务永不执行。
  • @Scheduled 方法必须是 Spring bean 的无参 void 方法;由框架反射调用,方法上直接标 @Transactional 不可靠。
  • fixedRate 从「上次开始」计时、fixedDelay 从「上次结束」计时、cron 按日历触发;cron 是 Spring 的 6 段格式,跨时区务必显式写 zone。
  • timeUnit 属性让周期可读;fixedRateString 等字符串属性支持占位符与 ISO-8601 Duration。
  • 调度线程池默认只有 1 个线程(spring.task.scheduling.pool.size),多任务会互相阻塞;用该属性或 SchedulingConfigurer 调整,并配 setErrorHandler 与优雅停机。
  • 同一任务不会与自己重叠;任务耗时超过周期时,fixedRate 背靠背、fixedDelay 顺延、cron 跳过当次。
  • 周期任务抛异常会被记录并吞掉,调度不会停——所以失败必须靠告警发现,不能靠「任务停了」发现。
  • 选型先问「缺执行能力还是运维能力」:前者用 @Scheduled + 分布式锁,后者才上 Quartz 或调度中心。

13.2 会把视角从「定时触发」换到「批量处理」:当一次任务要处理几十万行借阅记录时,@Scheduled 里写个循环就不再合适,该请出 Spring Batch 了。

阅读导航:上一节:12.3 分布式事务 · 下一节:13.2 Spring Batch 实战 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计