SpringBoot4系列11 - 使用定时任务Schedule

上一章的 Spring Batch 解决的是「算得完」,这一篇解决「准时跑」。场景还是订单业务:每天凌晨扫对账文件,每隔几秒把超时未支付的订单取消掉,固定节拍刷新一次报表快照。这类活的共同点是间隔固定、单次很轻、一个都不能长期缺席。

定时任务最伤人的两种故障都是静默的。一种是慢任务拖垮全局:黑名单同步调了一次超时的远端接口,执行线程被占住 8 秒,这 8 秒里取消订单、扫对账文件、刷新报表全部排队。业务侧看到的现象是取消动作集体迟到十几秒,对账文件晚了一个刻度才处理,日志里没有任何报错,线程名看起来一切正常。另一种是 cron 写错:表达式合法、应用正常启动、任务一声不吭地按错误的节奏跑,或者要等到凌晨三点才轮到它。说实话,我第一次帮人排查这类问题时盯着启动日志翻了半个钟头,异常一条都没有,只有节奏错了。看不见的故障,比当场炸掉的难缠得多。

https://static.xiongneng.me/schedule-single-thread-stall-20260922014350.png

Spring 的调度编程模型用 @EnableScheduling 加 @Scheduled 两个注解解决「怎么定义任务」,用可替换的 TaskScheduler 解决「在哪儿跑」。这篇全按 Spring Boot 4.1.1 加 Framework 7.0.9 的写法来,所有行为、包名、属性前缀我都在这个组合上实测过,每段代码配的数字都来自同一份验证脚本一口气打出来的原始记录。

工程在 springboot4-schedule 目录,18 个测试全连真实 MySQL。三个业务任务加四类 HTTP 探针,把停摆、异常、动态改节奏这些运行期行为全部做成可以当场复现的演示。我边跑边看,比干讲机制踏实。

编程模型与自动配置的事实

写代码前先确认 import 和依赖。定时任务在 Boot 4 里的第一件事实:没有专属 starter。我去 Maven Central 上探测 4.1.1 版本,spring-boot-starter-task 返回 404,独立的 spring-boot-task 模块也不存在。调度能力由两部分组成,常规 web 工程零额外依赖:

1
2
3
4
5
6
7
8
编程模型   @EnableScheduling / @Scheduled / @Async
          org.springframework.scheduling.annotation     (spring-context 7.0.9)

自动配置   TaskSchedulingAutoConfiguration / TaskExecutionAutoConfiguration
          org.springframework.boot.autoconfigure.task   (spring-boot-autoconfigure 4.1.1)

定制点     ThreadPoolTaskSchedulerBuilder / ThreadPoolTaskSchedulerCustomizer
          org.springframework.boot.task                  (spring-boot 4.1.1)

三个包各有分工。annotation 包里是应用天天写的注解与扫描它们的 ScheduledAnnotationBeanPostProcessor;autoconfigure.task 包里是 Boot 的装配逻辑与两个属性类;boot.task 包里是构建器与定制器,线程池的定制动作全部发生在这个包。写 import 时按这张清单核对,资料里的包名与它对不上就以它为准。

第二件事实是自动配置的装配条件。TaskSchedulingAutoConfiguration 本体只注册一个 LazyInitializationExcludeFilter,作用是让标注了 @Scheduled 的 bean 在 lazy 应用里不被跳过初始化,它生效的条件是容器里已经出现调度注解处理器,也就是说应用加了 @EnableScheduling 它才装配。创建调度线程池的是内部的 DefaultTaskSchedulerConfiguration,按平台线程与虚拟线程两条分支出 taskScheduler bean:

1
2
PLATFORM(默认)  ThreadPoolTaskScheduler,池大小取 spring.task.scheduling.pool.size
VIRTUAL          SimpleAsyncTaskScheduler,spring.threads.virtual.enabled=true 时启用

属性前缀没有改名,spring.task.scheduling 与 spring.task.execution 的注解定义在字节码里原样可查。调度侧的全部属性一览:

1
2
3
4
5
spring.task.scheduling.pool.size                 默认 1
spring.task.scheduling.thread-name-prefix        默认 scheduling-
spring.task.scheduling.simple.concurrency-limit  虚拟线程实现的并发上限
spring.task.scheduling.shutdown.await-termination
spring.task.scheduling.shutdown.await-termination-period

pool.size 的默认值我要单独点一次:属性类的构造器里写的是常数 1,所有任务串行共用一根线程,这就是停摆问题的根源。thread-name-prefix 默认是 scheduling-,挺有意思的一个细节:多线程接管之后把它换成带业务含义的前缀,日志里一眼就能认出哪根线程在跑哪个任务,排查省力不少。

任务的路由由 Framework 7 的 TaskSchedulerRouter 承担。它实现 TaskScheduler 接口,按 @Scheduled(scheduler = "beanName") 的限定符把不同任务派给不同的线程池,不写限定符就走名字叫 taskScheduler 的默认池。@Scheduled 注解上还有一个少有人用的常量:cron 属性填短横线字符即整体禁用该任务,配置中心下发开关时可以借用这个约定。

https://static.xiongneng.me/schedule-model-map-20260922014350.png

依赖和配置

工程的依赖清单里没有任何调度坐标,web 与 JDBC 就够了:

1
2
3
4
5
6
7
8
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>

三个业务任务的数据都落 MySQL:订单表给超时取消任务,对账文件登记表给扫描任务,日报表给统计任务。表结构由 schema.sql 幂等创建,IF NOT EXISTS 保证重复启动不出事。配置文件里与调度相关的只有三处,全部是自定义属性,调度本身的默认值一个都不覆盖:

1
2
3
4
5
6
7
8
app:
  scheduler:
    pool-size: ${SCHEDULER_POOL:1}
    thread-name-prefix: order-sched-
  order:
    timeout-seconds: 15
  chaos:
    slow-millis: 8000

pool-size 留了环境变量入口,我的停摆实验就是靠 SCHEDULER_POOL=1 与 SCHEDULER_POOL=4 两次启动对比做出来的。超时阈值演示用 15 秒,生产里的同类业务一般是 15 分钟,改一个属性值就行。慢调用耗时收进配置,停摆实验的时长可控。演示任务的间隔全部用秒级,生产的定时任务一般是分钟级或小时级,节奏值换成 cron 表达式即可,机制不变。

三种触发方式怎么选

@Scheduled 的计时属性有三个,语义差别在「上一次执行的哪一刻」算起:

1
2
3
4
5
6
7
8
@Scheduled(cron = "0/5 * * * * *")
public void scanReconFiles() { ... }

@Scheduled(fixedDelay = 3000, initialDelay = 4000)
public void cancelTimeoutOrders() { ... }

@Scheduled(fixedRate = 4000, initialDelay = 6000)
public void refreshDailyReport() { ... }

cron 按墙钟对齐,Spring 支持 6 域表达式(秒 分 时 日 月 周),秒位是 0 和 5 的倍数才触发,错过的刻度不补。fixedDelay 从上一次执行「结束」时刻起算,执行再久也不会与自己重入,适合清理型任务。fixedRate 按「开始」时刻对齐节拍,被别的任务拖住时顺延但不并发重入,适合有节拍语义的统计型任务。三个属性写在一个注解里互斥,一个方法只能选一种。

https://static.xiongneng.me/schedule-trigger-timeline-20260922014350.png

几个辅助属性按需叠加。initialDelay 让任务避开应用启动高峰,报表任务延迟 6 秒是给数据源连接留时间;这两个属性都有 String 变体 initialDelayString / fixedDelayString,值可以从配置文件注入;timeUnit 改变数值单位;zone 只对 cron 生效,跨时区部署的对账任务要显式指定。

选型的判断依据是任务的性质。对账扫描要「每 5 秒看一眼有没有新文件」,cron 最直观;超时取消要「清完一轮歇 3 秒再来」,fixedDelay 不会在慢查询时堆积;报表要「固定节拍出数」,fixedRate 对齐感最强。三种写法在本工程里各占一个任务,每一轮的运行期行为我都留了原始记录,逐项可查。

线程池停摆与接管

默认配置下三个任务挤在一根线程上,这个现象可以精确复现。我先用默认配置启动,让黑名单同步任务睡 8 秒,然后量对账扫描任务两次执行的间隔:正常节奏是 5 秒,实测最大间隔 11.5 秒,18 秒的窗口里出现 2 次 8 秒级的断档。同一轮里全部任务的执行线程集合只有一个元素:order-sched-1。三个任务在轮流用同一根轨道,这就是铁证。

解法是手工声明一个同名的 taskScheduler bean,把池大小、线程名前缀、异常处理器三件事一次配齐:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@Configuration
@EnableScheduling
public class SchedulerConfig {

    @Bean("taskScheduler")
    public ThreadPoolTaskScheduler taskScheduler(
            @Value("${app.scheduler.pool-size:1}") int poolSize,
            @Value("${app.scheduler.thread-name-prefix:order-sched-}") String threadNamePrefix) {
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(poolSize);
        scheduler.setThreadNamePrefix(threadNamePrefix);
        scheduler.setWaitForTasksToCompleteOnShutdown(true);
        scheduler.setAwaitTerminationSeconds(5);
        scheduler.setErrorHandler(new SchedulerErrorHandler());
        return scheduler;
    }
}

bean 名字取 taskScheduler 有两层作用。一是自动配置声明了 @ConditionalOnMissingBean,手工 bean 一出现整套默认装配立刻退让;二是 Framework 7 的 TaskSchedulerRouter 默认就把任务派给这个名字的池,不用再做任何绑定。@EnableScheduling 也放在这个类上,开关与池在一个地方,看配置时不用翻第二个文件。

接管之后用同样的实验重跑:黑名单任务照旧睡 8 秒,对账扫描的两次执行最大间隔回到 5.0 秒,一次断档都没有;执行记录里的线程集合变成 order-sched-1 到 order-sched-4 四个。慢任务还在慢,但它只堵住自己的那条轨道。这个结果比我预期的还干净,四根线程一上,停摆直接消失。

https://static.xiongneng.me/schedule-pool-customize-20260922014350.png

不想整池接管还有一条轻量的路:ThreadPoolTaskSchedulerCustomizer。注册一个实现 customize(ThreadPoolTaskScheduler) 的 bean,自动配置出的池会把它收集进去,改池大小、换线程名都够用。构建器 ThreadPoolTaskSchedulerBuilder 上的能力更全,awaitTermination、taskDecorator、customizers 一串链式方法,适合在别的配置类里组装自己的池。两条路的边界在 ErrorHandler:customizer 路径拿不到 setErrorHandler 的时机,要挂自定义异常处理器就得像上面那样整池接管。我一开始想走 customizer 偷懒,翻到这条边界才回头整池重写,闹了个小笑话。

异常处理:不重试,也不停摆

任务方法抛出异常后框架做什么,是定时任务上线前必须想清楚的问题。答案在调度器的装饰链里:每次触发被包成 DelegatingErrorHandlingRunnable,异常交给 ErrorHandler 处理完就结束,没有重试,也没有任何取消动作,下一次调度按原计划触发。默认的处理器行为是记一条日志然后放过,任务在框架眼里不会「失败到停机」。

默认行为能兜住存在性,兜不住可见性:异常只有一条框架日志,应用侧拿不到钩子,失败的次数、类型、分布全都要靠人翻日志。自己声明 taskScheduler 之后可以换成自己的实现:

1
2
3
4
5
6
7
8
9
public class SchedulerErrorHandler implements ErrorHandler {

    private static final Logger log = LoggerFactory.getLogger(SchedulerErrorHandler.class);

    @Override
    public void handleError(Throwable t) {
        log.warn("SCHED_TASK_ERROR 定时任务执行失败,等待下一次调度: {}", t.toString());
    }
}

处理器的契约决定了语义:方法返回即视为处理完毕,调度器继续排下一次。日志级别打 WARN 是本工程的选择,失败次数与类型已经落在执行记录里,由监控体系去消费,异常不污染应用的 ERROR 指标。

工程里用一个故障注入任务实证这套语义:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@Scheduled(cron = "0/6 * * * * *")
public void poisonProbe() {
    runPoisonOnce("cron");
}

public void runPoisonOnce(String trigger) {
    if (!poison) {
        return;
    }
    RuntimeException failure = new IllegalStateException("演示异常:远端对账接口 503");
    journal.record("poison", trigger, thread, "failed", failure.toString(), cost);
    throw failure;
}

失败先落执行记录再抛出,顺序有讲究:抛出之后代码不会回来,记录动作必须放在前面。开关打开 16 秒,cron 每 6 秒一次,执行记录里落了 3 条 failed,两条相邻失败的时间间隔实测 6.0 秒和 6.0 秒,与 cron 节奏严丝合缝。同一轮的应用日志 ERROR 0 行,SCHED_TASK_ERROR 的 WARN 恰好 3 行,两个口径互相印证。16 秒里没有任何一次多余的执行,框架没有重试,也没有漏掉一次触发。这个结果挺有意思:异常发生了三次,调度节奏一丝没乱,「不重试也不停摆」几个字全在数据里。

https://static.xiongneng.me/schedule-error-flow-20260922014350.png

顺带把 @Async 的位置说清:它与 @Scheduled 同在 org.springframework.scheduling.annotation 包,共用 spring.task.execution 那一套执行器属性,但触发机制截然不同,一个是事件驱动一次一跑,一个是时间驱动按表触发。两套线程池在 Boot 里是两个 bean,池大小分别用 spring.task.execution.pool.* 与 spring.task.scheduling.pool.* 控制,不要混着调。

动态 cron 与手动触发

注解上的 cron 在启动期定死,要运行期改节奏就不能把表达式写死在注解里。做法是 SchedulingConfigurer 注册 TriggerTask,cron 值放进一个可替换的持有者:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public class DynamicCronTrigger implements Trigger {

    private final AtomicReference<String> cron;

    public DynamicCronTrigger(String initialCron) {
        this.cron = new AtomicReference<>(validate(initialCron));
    }

    public String update(String newCron) {
        String validated = validate(newCron);
        cron.set(validated);
        return validated;
    }

    @Override
    public Instant nextExecution(TriggerContext triggerContext) {
        return new CronTrigger(cron.get()).nextExecution(triggerContext);
    }
}

Trigger 接口只在调度器需要排下一次时被调用,每次都按「当前值」解析,HTTP 接口改完值,下一次调度立即按新节奏算。update 里先过一遍 CronExpression.parse,非法表达式当场抛 IllegalArgumentException,Controller 层把它翻成 400。AtomicReference 保证读写两侧不撞车,改值的请求与调度线程的读取并发进行也没有中间态。

任务的注册与注解方式并存:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@Configuration
public class ReconSchedulingConfig implements SchedulingConfigurer {

    @Bean
    public DynamicCronTrigger reconCronTrigger() {
        return new DynamicCronTrigger("0/5 * * * * *");
    }

    @Override
    public void configureTasks(ScheduledTaskRegistrar registrar) {
        registrar.addTriggerTask(() -> reconJob.runScanOnce("cron"), reconCronTrigger());
    }
}

configureTasks 里不用再设置调度器,SchedulerConfig 声明的线程池会被框架自动接上。手动触发走另一条更简单的路:任务类的业务方法独立成 runScanOnce(trigger) 这样的公开方法,注解方法与 HTTP 探针都调它:

1
2
3
4
5
6
7
8
9
@PostMapping("/trigger/{job}")
public Map<String, Object> trigger(@PathVariable String job) {
    switch (job) {
        case "recon" -> result = reconJob.runScanOnce("manual") + " files";
        case "cancel" -> result = orderJobs.runCancelOnce("manual") + " cancelled";
        case "report" -> result = orderJobs.runReportOnce("manual").toString();
        ...
    }
}

调度触发与人工触发共用同一段代码,执行记录里的 trigger 字段区分来源。探针接口顺带返回执行线程名与耗时,我在验证里判断「手动触发跑在 HTTP 线程、调度触发跑在 order-sched 线程」,靠的就是这两个字段。

完整案例,跑起来看

以 4 线程池启动,从 HTTP 走一遍完整流程:

1
2
3
curl -i -X POST "http://localhost:18110/api/orders" \
     -H "Content-Type: application/json" \
     -d '{"orderNo":"SO90001","amount":"199.50"}'

返回 201,created_at 是 23:53:34.196。超时阈值 15 秒,取消任务每 3 秒扫一轮,18 秒后再查:

https://static.xiongneng.me/schedule-order-cancel-journal-20260919235357.png

状态变成 CANCELLED,cancel_time 是 23:53:49.669,从下单到取消 15.5 秒,与阈值和轮询间隔的叠加一致。面板里第三块是手动触发的报表统计,thread 字段显示 http-nio-18110-exec-7:任务代码跑在 HTTP 线程上,人工触发与调度触发共用同一段业务方法,订单数 1、总额 199.50 落进当日报表。

探针面板能一次看清两类运行期证据。poison 的执行记录里 3 条 failed,异常类型与失败线程一一在列;对账扫描的记录里线程名在 order-sched-2 与 order-sched-4 之间切换,多线程并行取任务的判断直接可查;动态 cron 的 200 响应与非法表达式的 400 problem+json 同框:

https://static.xiongneng.me/schedule-probe-panels-20260919235419.png

两轮对照的关键数字再摆一次。单线程轮:执行线程集合 {order-sched-1},黑名单慢执行 8009 与 8001 毫秒,对账扫描最大间隔 11.5 秒;四线程轮:同样的慢任务照跑,对账扫描最大间隔 5.0 秒,线程集合四个元素。两轮的应用日志 ERROR 都是 0 行。停摆从「听说」变成三个可复查的数:断档次数、最大间隔、线程集合。

执行记录是这套演示的中枢,每条记录五个字段:任务名、触发方式、执行线程、结果、耗时。它由一个容量 400 的内存环形队列承载,HTTP 接口随时吐最近一段,任务行为的变化当场就能对出来。

这一篇的测试怎么写

18 个用例全连真实 MySQL,调度相关的断言集中在三类:配置事实、业务行为、异常语义:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@SpringBootTest(properties = "app.scheduler.pool-size=3")
class SchedulerConfigTest {

    @Autowired
    @Qualifier("taskScheduler")
    private TaskScheduler taskScheduler;

    @Test
    void taskSchedulerIsConfigurableThreadPool() {
        assertThat(taskScheduler).isInstanceOf(ThreadPoolTaskScheduler.class);
        ThreadPoolTaskScheduler scheduler = (ThreadPoolTaskScheduler) taskScheduler;
        assertThat(scheduler.getPoolSize()).isEqualTo(3);
        assertThat(scheduler.getThreadNamePrefix()).isEqualTo("order-sched-");
    }
}

这个用例把「手工 bean 接管了自动配置」钉在测试里:池大小是自定义属性注入的 3 而非默认的 1,前缀是 order-sched-。谁要是把 bean 名字改掉让自动配置偷偷回来,这里第一个失败。

异常语义的用例不打时间的主意,断言全部确定性:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@Test
void manualTriggerOfPoisonJobDoesNot500() throws Exception {
    chaosJobs.setPoison(true);
    try {
        mockMvc.perform(post("/api/scheduler/trigger/poison"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.status").value("failed"))
                .andExpect(jsonPath("$.result").value("failed: 演示异常:远端对账接口 503"));
    } finally {
        chaosJobs.setPoison(false);
    }
}

故障开着的时候手动触发不抛 500,响应体明确写 failed 与异常消息;开关关掉后同一个任务恢复 no-op。ErrorHandler 吞异常的契约单独一个用例,直接调 handleError 断言不向外抛。

其余用例的分布:订单接口 4 个(下单 201、校验 400、查询 404、手动触发取消),对账流 5 个(登记 201、重复 409、扫描 DONE、动态 cron 生效与非法 400),报表与慢调用 3 个(聚合正确、未生成 404、慢调用耗时下限),动态 cron 触发器 2 个纯单元测试(改值生效、非法拒绝)。全部用例走 MockMvc 打真实 HTTP 面,测试里操练的路径与生产同一条。

避坑指南

坑一,池大小默认 1,停摆从第一个任务就开始埋着。 属性类构造器里写死的常数,本地开发任务少看不出问题,任务一多互相拖慢是常态。线上定时任务莫名互相拖延,九成是这个原因。凡是超过一个定时任务的工程,声明自己的 taskScheduler 把池大小显式定下来,顺手把线程名前缀和 ErrorHandler 一起配掉。判断有没有踩坑的最快办法:grep 一下日志里的线程名,定时任务的执行线程始终是同一根就是实锤。

坑二,异常处理指望不上调度注解。 @Scheduled 方法抛出的异常默认只有一条框架 ERROR 日志,重试、告警、失败计数框架一概不管。要么在方法里自己兜住并记录,要么挂自定义 ErrorHandler。没有自声明线程池时自定义 ErrorHandler 挂不上,这是两条路的硬边界。失败要不要重试,是业务策略,归业务代码或重试框架管,调度器只负责准时叫人。

坑三,cron 表达式合法不代表语义正确。 非法表达式启动期就会抛 IllegalArgumentException,拦得住;危险的是合法但不符合预期的表达式,6 域写成 7 域的变体、时区理解错位,应用照常启动,任务按错误节奏跑。改完 cron 用探针看一眼下一次触发时间,再上生产。

坑四,fixedRate 不保证严格节拍。 单线程池里执行时长超过间隔时不会并发重入,只顺延;错过几个周期就少跑几次。要「执行完再等」用 fixedDelay,要「补跑错过的次数」得在业务侧自己处理,注解层面没有补跑语义。这个坑在「整点必须出数」的报表场景里最疼,节拍型任务单独给一条线程是底线。

坑五,任务方法必须无参。 带参数的 @Scheduled 方法在启动期直接失败,框架对方法签名的校验比大多数注解严格。参数要么走配置属性注入,要么在方法体里自己读,别把业务参数留在签名上。方法返回值同样被忽略,返回 Future 不会让任务变成异步。

坑六,JdbcClient 的 optionalValue 是单列映射。 按主键查一整行时用它,多列结果直接抛 IncorrectResultSetColumnCountException。查单行走 listOfRows() 取第一条,本工程的两个查询服务都是这么写的。我测试就栽在这个方法名上:以为是「可选的一行」,跑起来才知道语义是「可选的一列」。

坑七,虚拟线程开关会换掉调度器实现。 spring.threads.virtual.enabled=true 时 taskScheduler 从 ThreadPoolTaskScheduler 换成 SimpleAsyncTaskScheduler,池大小语义随实现变化。依赖具体实现类的方法之前先确认当前用的是哪一个,测试里 instanceof 断言一遍最稳妥。

坑八,优雅停机要在 bean 上声明。 setWaitForTasksToCompleteOnShutdown 加 setAwaitTerminationSeconds 让在跑的任务把当前一轮执行完再退。定时任务写到一半被 kill,落库落在半截,重启后的那一轮会把半截数据再处理一遍,幂等要在业务侧自己保证。取消订单这类天然幂等的任务无所谓,累加型的统计任务必须防重复。

小结

定时任务的选型建议可以压成三句话。任务少且不怕串行,默认配置零依赖直接用;只要超过一个定时任务,就手工声明 taskScheduler,池大小、线程名前缀、异常处理器三件事一次配齐;要挂自定义 ErrorHandler 或精细控制优雅停机,整池接管是唯一完整的路,customizer 只适合轻改。节奏怎么挑看任务性质:清理型用 fixedDelay,节拍型用 fixedRate,要按墙钟对齐就用 cron。

没解决的事也有两件。一是动态调度只覆盖了单实例:多实例部署时改节奏要逐个实例调接口,表达式也没有持久化,重启后回到初始值,这块要么接配置中心,要么配分布式锁,超出这一篇的范围。二是虚拟线程分支我只核实了装配条件,没有做吞吐对比,池大小语义在 SimpleAsyncTaskScheduler 上怎么变,等实际用上虚拟线程时我再补测。

参考链接