调度执行:@Scheduled 语义、异常吞噬、关闭与 JMS 集成边界
fixedDelay 与 fixedRate 差一个字,过载时行为相反;任务抛异常"没人知道"是因为调度器把它交给了 ErrorHandler 默认记日志;JMS 集成本票按官方契约推演(无 broker 实跑)。固定基线 Framework 6.1.12 / JDK 17。@Async/TaskExecutor 的隔离、上下文与取消由异步执行正文权威讲授;线程池本身的队列/拒绝语义归线程池票(U09 基线)。
@Scheduled 的三组语义
@Scheduled(fixedDelay = 1000) // 上次【结束】到下次【开始】间隔 1s:不叠加
@Scheduled(fixedRate = 1000) // 上次【开始】到下次【开始】间隔 1s:可排队追赶
@Scheduled(cron = "0 0 2 * * *", zone = "Asia/Shanghai") // cron 不可搭配 initialDelay| 配置 | 任务耗时 < 周期 | 任务耗时 > 周期 |
|---|---|---|
| fixedDelay | 本轮完成后再等间隔;起跑间距=耗时+间隔 | 仍在完成后等间隔,不因落后而追赶 |
| fixedRate | 到期点按起点+整数倍周期推进 | 本轮结束后,逾期的下一轮可立即执行并连续追赶;同一注册任务不重叠 |
| 默认 CronTrigger | 在配置时区按日历规则取下一次触发 | 默认按完成时刻计算下一点,错过的日历点可被跳过;不是持久回填 |
这里固定 ThreadPoolTaskScheduler 委托 JDK ScheduledThreadPoolExecutor 的平台线程路径。它默认 poolSize=1,多个任务会互相阻塞;同一注册周期任务各轮不重叠是底层调度契约,增大 poolSize 也不使该任务自身重叠,不同任务则可并行。两个独立注册的任务即使调用同一个方法,也不自动共享单飞锁;叠加 @Async 又增加了一次异步提交边界。
fixedRate 的下一到期时间按旧到期点加 period,不是按本轮完成时间重置。第一轮慢、后续变快时可连续追赶;若每轮都比周期慢,则一直落后,不能把“不重叠”说成“错过即跳过”。OpenJDK 固定 jdk-17.0.12+7 的 setNextRunTime/run和Framework v6.1.12 的委托入口支持这个区分;这不是本次压测结论。默认 cron 的完成时点语义见同版本 CronTrigger.nextExecution。
initialDelay 只适用于 fixedRate/fixedDelay 或单次任务。cron 搭配 initialDelay 会在注解处理时失败:ScheduledAnnotationBeanPostProcessor v6.1.12 L429–443明确检查该组合。需要延后首次 cron 任务时,应设计启用时点/业务门禁,不把不支持的参数写进去。时区显式声明;不同实例的时区和时钟可能改变日历触发结果。
异常吞噬与生命周期
在本篇重复任务使用默认抑制型ErrorHandler的前提下,任务异常交给handler记录后返回,后续周期可继续;这不等于每个错误策略都能继续,也不等于自动告警:
原码连续摘录(仅统一公共缩进,截取方法局部):DelegatingErrorHandlingRunnable.java,v6.1.12,L52–L62。
public void run() {
try {
this.delegate.run();
}
catch (UndeclaredThrowableException ex) {
this.errorHandler.handleError(ex.getUndeclaredThrowable());
}
catch (Throwable ex) {
this.errorHandler.handleError(ex);
}
}异常被捕获交给 errorHandler;重复任务的默认抑制型实现记录后返回,使本分支的周期继续。包装器没有再包一层catch捕获handler自身异常:自定义handler若重新抛出,异常会到下层调度器,底层周期future可以停止后续执行。因此线程仍存活不证明周期任务还有效,应同时观察future状态、最后成功时间与失败计数。生产可自定义计数/告警或明确停止策略;“连续失败一周没人发现”不能归咎于框架承诺了业务告警。
关闭必须分开两个开关:waitForTasksToCompleteOnShutdown 决定采用哪种关闭策略,awaitTerminationSeconds 决定当前关闭调用最多等待多久。v6.1.12 的前者默认 false、后者默认 0,不是“默认一直等任务完成”。原码连续节选:ExecutorConfigurationSupport.shutdown L326–339。
public void shutdown() {
if (logger.isDebugEnabled()) {
logger.debug("Shutting down ExecutorService" + (this.beanName != null ? " '" + this.beanName + "'" : ""));
}
if (this.executor != null) {
if (this.waitForTasksToCompleteOnShutdown) {
this.executor.shutdown();
}
else {
for (Runnable remainingTask : this.executor.shutdownNow()) {
cancelRemainingTask(remainingTask);
}
}
awaitTerminationIfNecessary(this.executor);- 显式
waitForTasksToCompleteOnShutdown=true调用shutdown(),允许已提交的普通任务和队列任务继续;本实验再配置awaitTerminationSeconds=5,使调用方有界等待。周期/延迟任务还受底层调度器的 shutdown policies 控制,不能把普通队列四任务的结果扩成“所有周期任务无限跑完”。 - 默认 false 走
shutdownNow(),对在跑线程发出中断请求,清空并取消队列剩余任务。任务是否退出取决于协作处理;收到中断不等于外部副作用回滚。 awaitTerminationSeconds即使单独设置,也只是等待选定策略结束,不会把 false 改成 true;设 true 却不配置等待时间,则任务可与后续容器销毁并行,不能假定其依赖资源仍可用。
容器关闭还有 SmartLifecycle 提前停止、ContextClosedEvent 与最终销毁阶段,不能把手工调用 shutdown 的结果无条件套到每个生命周期阶段。Boot 4 的执行器/HTTP 各自预算和真实在途请求验证见Boot 运行与资源。生产要先停接纳,决定已接纳任务是完成、合作取消还是登记未知结果,给每个等待设置上限,再验证资源关闭与恢复责任。
观测:TaskScheduler/Executor 指标经 Micrometer(任务耗时/队列/活跃数)归观测票(U64);调度层至少要有每次执行的成败计数与最后成功时间。
JMS 集成(契约推演,无 broker 实跑)
F09 的 JMS 条目按官方文档推演,本票未运行任何 broker(本机无 ActiveMQ/Artemis;Spring Kafka/RabbitMQ 实测归 U48/U57/U58)。边界记录:
JmsTemplate.convertAndSend:同步发送,QoS 由deliveryMode/timeToLive/确认模式决定;与 REST 客户端一样,发送成功≠消费成功。- 监听容器(
DefaultMessageListenerContainer):并发消费者数、事务与确认模式(CLIENT_ACKNOWLEDGE/事务性会话)决定重投语义;异常抛出触发 redelivery(受 broker redeliveryPolicy 约束),必须配死信与幂等(回指消息可靠性权威)。 - 与 Spring 事务:监听器内的 JMS 事务是会话本地事务,不自动与 JDBC 事务联动;跨资源一致性走 JTA 或"消费+outbox",归分布式事务票(U61)。
错误输入与排障链
| 症状 | 原因 | 验证 | 处置 |
|---|---|---|---|
| 多个任务互相拖慢 | 默认单线程调度器 | 任务开始/结束时间线 | 独立调度器/线程池+上限 |
| fixedRate 持续迟到/影响其他任务 | 任务耗时长期大于周期或共享池过载 | 对照计划到期点、实际开始/结束与池占用 | 减少耗时、放宽周期、隔离不同任务;追赶不等于持久回填 |
| 任务失败无人知 | 异常进默认 ErrorHandler 只记日志 | 错误日志/无告警 | 自定义 ErrorHandler+指标 |
| 关闭后任务中断或队列任务未执行 | 默认false的shutdownNow策略 | 区分在途/队列计数与中断标志 | 按业务选择等待式+等待上限或合作取消,复验结果与资源关闭 |
| cron 到点没跑 | 单线程被长任务占住/时区 | 调度器活跃任务 | 隔离池+显式 zone |
| JMS 重投风暴 | 监听器抛异常+无死信 | broker 队列计数 | 死信+幂等(消息票权威) |
条件变化与边界
| 条件 | 复核点 |
|---|---|
| 集群多实例 | @Scheduled 每实例都跑:分布式锁/分片归协调票(U39/U40) |
| Boot 3.3.3 | task.scheduling.pool.* 属性与自动配置的单线程默认 |
| Boot虚拟线程(JDK21+) | 调度器/执行器可换成SimpleAsync实现,pool属性不再生效;见Boot运行正文,不外推本篇平台池结论 |
| Framework 7 | 按新基线重核(本文固定 6.1.12) |
| K8s 滚动更新 | 优雅停机窗口与任务最长耗时匹配 |
实验:输入、步骤、预期与边界
labs/spring-senior ScheduleCacheLab.java(run.sh schedule 组的 scheduled/shutdown 模式,JDK17 + 真实 ThreadPoolTaskScheduler):
| 场景 | 输入 | 预期 |
|---|---|---|
| fixedDelay vs fixedRate | 两任务共用单线程,任务各耗时200ms、参数100ms,等待6次回调 | fixedDelay本轮结束到下轮开始≥约100ms;fixedRate相邻轮不重叠。耗时200ms时起跑不可能每100ms一次;本实验不测逾期后任务变快的追赶轨迹 |
| 异常吞噬后继续 | 每 2 次抛一次异常,共 5 次 | 抛异常不影响后续执行,总执行数仍 5(自定义 ErrorHandler 记到计数器) |
| 等待式关闭 | 一个300ms+三个50ms普通任务,显式true、await=5s | 四个已提交任务均完成,调用等待约450ms以上;不丢弃这三个队列任务 |
| 默认/不等待式关闭 | 确认一个任务已进入,再排两个普通任务;false | 队列任务未运行;shutdownNow会请求中断在途线程,原U21仅断言队列计数,不能用输出文字替代独立中断观测 |
| 单线程阻塞 | 两任务:A 300ms 周期 50ms,B 到点一次 | B 的开始时间被 A 推迟(默认单线程实证) |
本机已运行记录保留为2026-10-25的历史结果,见原始证据。2026-10-26按固定源码和该实验实际断言纠正了此前的fixedRate/关闭表述,未改写历史日志。该实验没有验证cron配置拒绝、时区、fixedRate任务变快后的追赶或生产停机;这些结论按上文固定源码推演,Boot4新实验另行记录,不冒充旧运行结果。集群锁与JMS仍未运行。
追问链
第一层:fixedRate 追赶与业务回填有什么不同?
fixedRate按计划到期点连续推进,逾期后可顺序追赶,仍不会让同一注册任务并发重叠。任务每轮都慢于周期时无法追平;进程退出后这些内存调度状态也不自动恢复。需要按业务窗口精确回填、去重与重启恢复时,必须有持久调度账本(数据方向票的DAG/回填),不能用“它会追赶”推出业务窗口恰好执行一次。
第二层:调度器异常处理的"安全默认"牺牲了什么?
默认重复任务抑制型handler记录后返回,使周期继续,但日志不等于告警。自定义handler若重抛可终止底层周期,策略必须显式选择;线程活着也不能代替future状态与最后成功时间。补计数、告警、按错误率停用及恢复复验,而不是把“吞异常”写成全部handler的保证。
第三层:停机时在途任务怎么处置才对?
先停接单,再按任务性质选择true的继续执行或false的中断/丢弃,并分别配置终止等待上限。超时或取消后核对真实副作用:可重跑需幂等,提交未知需登记和对账。HTTP请求结束也不证明其提交的后台任务完成,与异步超时正文采用同一恢复责任模型。
技术案例
- 报表定时任务:独立调度器+单飞(同一任务不重叠,运行标志或分布式锁),失败计数>3 停用并告警,最后成功时间暴露为指标。
- 缓存预热:@Scheduled(fixedDelay) 预热 + 缓存票的单飞语义(见缓存抽象)。
- JMS 消费:并发消费者+死信+幂等键,重投语义按 broker 文档单独核实。
来源与版本
| 来源 | 版本 | 用途 |
|---|---|---|
| Spring Framework Reference: Task Execution & Scheduling | 6.1 | @Scheduled/TaskScheduler/JMS 官方语义 |
| DelegatingErrorHandlingRunnable.java | v6.1.12 | 异常→ErrorHandler(L52–L62) |
| Spring JMS Reference | 6.1 | JmsTemplate/监听容器契约(推演,未实跑) |
本地 ScheduleCacheLab | Framework 6.1.12 / JDK 17.0.19 | fixedDelay/fixedRate/异常吞噬/关闭实测 |
访问日期:2026-10-25。
闭卷验收
本页 6 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。