Spring Scheduler 与 Quartz:一次触发怎样成为可恢复执行
凌晨账务汇总偶尔整天没跑,重启后却连续执行两次。第一反应往往是 Cron 写错或线程池太小,可真正决定结果的是三件事:触发记录是否持久化、哪个实例取得 fire、业务提交后回调前宕机会怎样恢复。把这三层压成一个 @Scheduled 方法,平时看起来最简单,事故时却没有任何可对账状态。
Spring Framework 的 TaskScheduler 把触发时刻映射为 Runnable,fixed-rate 从计划起点推进,fixed-delay 从上次完成后推进;Quartz 则把 JobDetail、Trigger、Calendar 与执行状态交给 JobStore。两者都只承诺调度动作,不替业务提交提供 exactly-once。
从现场还原真正的运行链
ScheduledAnnotationBeanPostProcessor 在容器启动时发现注解方法,把它注册给 ScheduledTaskRegistrar;真正等待时间的是 TaskScheduler,业务方法只是到点后的回调。单线程调度器里一个慢任务会拖迟其他触发;扩大线程池只能提高并行度,无法补上持久 fire、集群唯一领取和崩溃恢复。Quartz 的 JDBCJobStore 把 NEXT_FIRE_TIME、TRIGGER_STATE 与 fired-trigger 记录放进数据库,集群节点竞争取得触发权,但业务数据库提交仍在另一事务边界。
最危险的提交与恢复窗口
一次可靠执行要拆成 scheduleId、fireId、attemptId 与 operationId。scheduleId 表示长期计划,fireId 表示某个理论触发时刻,attemptId 表示一次领取后的运行尝试,operationId 表示业务副作用。节点 A 取得 fire 后提交业务,尚未来得及更新调度结果就崩溃;节点 B 恢复 attempt 时,fire 可以重复,operationId 必须让账本返回既有结果。反过来,如果只把 Quartz fired-trigger 当业务幂等记录,清理或重建调度表会破坏业务正确性。
沿六个状态节点逐段验证
声明计划语义
来到“声明计划语义”时,先记录输入标识、状态版本、owner、剩余 deadline 和允许的下一迁移,再执行外部动作。正向实验断言计划、attempt 与领域结果守恒;反向实验在状态写入前后分别制造崩溃、超时或租约切换,确认迟到写被拒绝,UNKNOWN 能通过 operationId 对账,而不是被无条件重跑掩盖。
计算下一时刻
来到“计算下一时刻”时,先记录输入标识、状态版本、owner、剩余 deadline 和允许的下一迁移,再执行外部动作。正向实验断言计划、attempt 与领域结果守恒;反向实验在状态写入前后分别制造崩溃、超时或租约切换,确认迟到写被拒绝,UNKNOWN 能通过 operationId 对账,而不是被无条件重跑掩盖。
持久化 Trigger
来到“持久化 Trigger”时,先记录输入标识、状态版本、owner、剩余 deadline 和允许的下一迁移,再执行外部动作。正向实验断言计划、attempt 与领域结果守恒;反向实验在状态写入前后分别制造崩溃、超时或租约切换,确认迟到写被拒绝,UNKNOWN 能通过 operationId 对账,而不是被无条件重跑掩盖。
竞争领取 Fire
来到“竞争领取 Fire”时,先记录输入标识、状态版本、owner、剩余 deadline 和允许的下一迁移,再执行外部动作。正向实验断言计划、attempt 与领域结果守恒;反向实验在状态写入前后分别制造崩溃、超时或租约切换,确认迟到写被拒绝,UNKNOWN 能通过 operationId 对账,而不是被无条件重跑掩盖。
执行业务账本
来到“执行业务账本”时,先记录输入标识、状态版本、owner、剩余 deadline 和允许的下一迁移,再执行外部动作。正向实验断言计划、attempt 与领域结果守恒;反向实验在状态写入前后分别制造崩溃、超时或租约切换,确认迟到写被拒绝,UNKNOWN 能通过 operationId 对账,而不是被无条件重跑掩盖。
回写与恢复
来到“回写与恢复”时,先记录输入标识、状态版本、owner、剩余 deadline 和允许的下一迁移,再执行外部动作。正向实验断言计划、attempt 与领域结果守恒;反向实验在状态写入前后分别制造崩溃、超时或租约切换,确认迟到写被拒绝,UNKNOWN 能通过 operationId 对账,而不是被无条件重跑掩盖。
先把四个标识拆开,故障才有名字
到点、开始、提交和确认是四个不同时间
状态迁移要能拒绝迟到写
可观测性必须能重建一次执行
停机和发布要先处理调度所有权
一个会稳定暴露问题的反向实验
用 RAMJobStore 做关键任务时,进程退出会丢失计划;用 JDBCJobStore 却没有给数据库连接池留出调度元数据连接,会让触发领取与业务查询互相饥饿。更隐蔽的是周期任务抛出未处理异常:ScheduledThreadPoolExecutor 会抑制该周期任务的后续执行,日志里只有第一次堆栈,监控若只看失败率,之后没有样本反而恢复绿色。 复现时固定任务定义、输入快照、时钟源和线程池容量,保存首次失败的控制记录与领域账本;修复后用同一故障点重放,并同时证明正常路径没有新增重复、等待和资源泄漏。
两个 Java 17 模型把不变量钉在输出上
下面的模型不模拟完整框架,而是把最容易被产品日志掩盖的状态转折压缩成确定性见证。它们执行真实计算和断言,输出发生变化就说明执行语义被改写。
javac --release 17 -Xlint:all -Werror examples/backend-development/scheduling-async/scheduler-quartz/FixedRateDelayDemo.java examples/backend-development/scheduling-async/scheduler-quartz/QuartzRecoveryDemo.java
java -cp examples/backend-development/scheduling-async/scheduler-quartz FixedRateDelayDemo
java -cp examples/backend-development/scheduling-async/scheduler-quartz QuartzRecoveryDemoperiodMs=100 taskMs=160 fixedRateStarts=[0,160,320] fixedDelayStarts=[0,260,520] overlap=false
fireId=invoice@T1 attempts=2 businessWrites=1 first=COMMITTED recovery=REPLAY resultKnown=true第一行验证正常时间或覆盖模型,第二行专门验证恢复、重派、租约或取消窗口。工程接入后,用真实数据库唯一约束、执行器故障和平台回调替换内存状态,但保留相同 operationId、状态迁移和输出不变量。
架构取舍不是功能数量比赛
进程内 @Scheduled 适合允许随实例启停、可从源数据重算、无需集中运营的轻任务;Quartz 适合计划需要持久化、动态管理、Misfire 和有限集群恢复的应用内任务;跨团队编排、海量执行器、权限审计和统一运营才是调度平台的信号。不要因为已有数据库就默认 Quartz,也不要因为任务重要就立即引入平台:先看触发持久性、所有权、恢复责任和运营边界。
发布门禁:证明执行收敛,而不是证明按钮可用
发布时必须守住“触发可以迟到或重放,业务 operationId 仍只产生一个权威结果”。验收证据至少包含一轮正常 fire、一轮执行中强杀、一轮回调丢失或租约过期、一轮停机恢复;核对 planned、started、terminal、businessWrites、unknown 与 retry 的守恒关系。配置例外必须有 owner、影响窗口、补偿控制和到期条件,过期自动阻断。
