任务运行25秒,每10秒调度意味着什么

一条任务配置每10秒执行,实际运行25秒。下一次开始于第10秒、第25秒还是第35秒?回答取决于时间基准和执行器。固定频率描述计划时刻,固定延迟描述前一次完成之后的等待时间;计划到期也不保证立即拿到工作线程。

第二个问题同样需要限定条件:线程池有两条线程,同一个调度方法会不会重叠?单个周期注册与同一方法上的两个独立注册具有不同的约束。把“方法相同”误当成“注册相同”,容易遗漏并发执行。

实验 blog.spring.Chapter32 固定 Spring Framework 6.2.11 和 JDK 21。时间公式使用固定 Instant、固定 Clock 与 Asia/Shanghai 时区;实际调度器用门闩控制重叠,用有界等待观察结束。没有依靠机器负载估计时间精度。

计划时刻、完成时刻与独立注册

注解注册发生在容器阶段

@EnableScheduling 注册负责扫描的 ScheduledAnnotationBeanPostProcessor。它查找适用方法,将同步方法转换为可调用的调度任务,交给注册器及调度器。Bean 初始化只是发现和注册的一部分,实际运行时点仍取决于容器事件、调度时间与执行资源。

这里没有从调用方取得一个调度代理再反复调用。调度器持有注册的 Runnable,时间到达后执行它。@Scheduled 与 @Async 可以出现在同一套应用里,却不是同一个基础设施入口。若业务还经过其他代理,应进一步检查注册时保存的 Bean 引用及调用路线。注册处理器源码

实验显式提供名为 taskScheduler 的 ThreadPoolTaskScheduler,池大小2。这样可以直接观察独立注册的并发,而不把默认单线程导致的串行误认为业务方法具有互斥约束。

同步 Runnable 在该调度器的调度线程上执行。任务里发生阻塞数据库查询时,调度线程就被占用;同池其他到期任务可能被推迟。为调度器增加线程数可以改变资源竞争,却不能消除同一业务数据上的并发冲突。

fixedRate 与 fixedDelay 的两个基准

PeriodicTrigger 接收前一次计划时刻、实际开始和完成时刻。实验把计划时刻设为 00:00:00Z,完成时刻设为25秒以后,周期为10秒。

固定频率计算得到 00:00:10Z,即前一次计划时刻加周期。固定延迟得到 00:00:35Z,即前一次完成时刻加延迟。这是时间计算断言,不能把已经落在过去的计划值理解为真正穿越回第10秒执行。

1
2
CHECK PASS 32 fixedRate based on scheduled time
CHECK PASS 32 fixedDelay based on completion

真实执行器还要决定何时运行到期任务。对于 JDK ScheduledThreadPoolExecutor 的同一个周期任务,连续执行不会彼此重叠;一次运行较久,会推迟后续实际开始。固定频率可以使后续执行紧接着发生,但不会因为错过两个周期就为同一注册同时创建两次执行。JDK 21 调度线程池契约

时间计算实验与后面的实际调度实验分别证明两件事:公式使用哪个历史时刻,以及选定执行器怎样处理 Runnable。不能用公式单独证明线程数,也不能仅凭一次线程日志推导公式。

cron 需要固定时区和错过触发点的策略

实验使用 0 0 9 * * *,并显式指定 Asia/Shanghai。固定当前时刻为 2026-10-02T00:00:00Z,下一次执行为 2026-10-02T01:00:00Z,对应当地09:00。

1
CHECK PASS 32 cron explicit Shanghai zone

表达式是否正确与时区是否正确需要分别检查。部署环境的系统时区发生变化,如果配置仍隐含依赖系统默认值,业务“每天九点”就可能改变实际时刻。固定时区也没有消除夏令时地区的时间跳跃,应针对业务使用的地区验证边界日期。

CronTrigger 的常规构造器采用完成时间相关的宽松执行策略,长任务可能跳过期间错过的触发点;6.2.11 还提供固定执行工厂方法。需要补跑时,应明确选择和验证对应策略,不能把“cron 表达式相同”当成补偿语义相同。CronTrigger 6.2.11 API

本篇实际验证了指定时区下的下一时刻,没有执行跨夏令时、停机补跑或恢复调度实验。业务若要求补偿账务任务,还需要持久化完成进度,避免只凭 JVM 内存中的下一次调度时间恢复工作。

两个注册可以同时调用同一个方法

Jobs.twice() 同时标注一条 fixedDelay 和一条 fixedRate。两者的间隔都设为100000毫秒,使本实验主要观察首次执行,而不会在检查途中进入下一轮。

每次进入方法就增加 active 计数、更新最大值,并减少一个初始为2的门闩。随后阻塞在 release 上。主线程只有等到两次执行都已进入,才读取最大并发值。

1
CHECK PASS 32 two registrations overlap on pool two

这个值等于2,证明同一对象的同一方法确实可以被两个独立注册同时调用。方法身份没有提供全局锁。若该方法用普通字段累计账单或修改共享集合,需要自身的并发协议;更换成更大的调度池可能只是让此前隐藏的竞争更容易出现。

只有一个注册时,后续周期运行受到该注册的串行约束。实验另起一个池大小2的调度器,仅注册一个 fixedRate 任务,记录至少三次运行后的最大 active 值为1。这个样本与 JDK 契约一致;真正的保证来自选定实现的契约,不能根据三个样本声称任意调度器都不会重叠。

多进程部署又多了一层边界。每个应用实例都会独立注册任务,本篇没有集群协调者,也没有跨进程锁。单进程串行不能推出集群只执行一次。需要抢占、租约、幂等或调度平台时,应直接围绕业务完成条件建立协议。

异常如何影响下一轮

裸 JDK 周期任务的异常策略与 Spring 包装后的行为不能混用。Spring 调度器可以为任务包装错误处理器。实验显式设置一个只计数、不重新抛出的 ErrorHandler,任务每轮都抛 IllegalStateException。

门闩等到三次进入后取消 Future,关闭调度器,再断言错误计数至少为3。顺序很重要:如果进入第三轮就马上检查错误计数,第三次异常处理可能还没有执行,测试会把观测竞态误认为错误丢失。

1
2
CHECK PASS 32 suppressing handler permits subsequent executions
CHECK PASS 32 future cancelled

不重新抛出的处理器使周期链可以继续,并不意味着失败被恢复。目标代码可能只完成了一半业务动作;下一轮重试同一批数据可能重复执行已经完成的部分。错误处理与业务幂等承担不同职责。

本实验用自定义处理器明确固定策略,没有以默认日志文本作为验收条件。若换成重新抛出异常的处理器,应预期周期 Future 异常终止,再通过 Future 的完成状态和后续执行计数验证,而不是等待控制台多打印几行。

关闭需要观察线程终态

释放两个注解任务的门闩后,实验关闭 ApplicationContext,并检查 active 已为0、底层调度线程池已经 terminated。独立调度器则先取消周期 Future,再 shutdown,并在5秒内等待 termination。

1
2
3
CHECK PASS 32 annotated tasks released
CHECK PASS 32 context closes scheduler
CHECK PASS 32 standalone scheduler terminated

这里证明的是任务可响应当前清理流程。若任务忽略中断并永久等待外部系统,调用 close 本身不能制造业务完成。生产关闭协议需要让任务能退出,并按资源所有权停止提交、结束在途任务、释放连接;单独把关闭等待时间加长不会修复不响应终止的代码。

运行与预测练习

在 JDK 21 下运行完整实验,不需要数据库:

1
./mvnw -q compile exec:java -Dexec.mainClass=blog.spring.Chapter32

本次10条断言全部通过,退出码0,原始日志为 evidence/32/local-20261002/run.txt。上游 PeriodicTriggerTests 及调度注解处理器测试仅做源码对照,没有运行 Framework 自身的 Gradle 测试集。

预测题:把注解调度器池大小改成1,保留等待两个任务都进入的门闩,会发生什么?第一条执行阻塞后占住唯一线程,第二条无法进入,测试会触发5秒超时。这是实验条件变更造成的必然等待,不是调度注解失效。

改动练习:新增一个池大小1的独立对照,先等待第一条进入,再由主线程释放它,最后等待第二条。分别记录最大 active 值和完成数量,要求最大值1且两个注册都完成。不要通过删掉超时或删除断言来“修复”原实验。

参考资料

系列入口与完整实验工程