分片、Misfire 与时区:调度语义怎样在多节点和日历中保持稳定
月底结算在停机维护后一次性补跑了几十个窗口,把数据库打满;另一个任务在夏令时回拨区间执行两次。它们不是同一种故障:前者是 Misfire 策略没有容量上限,后者是本地日历时间到 Instant 的映射不唯一。只有把计划时刻、实际领取时刻、业务窗口和幂等键分别保存,才知道该补什么、跳过什么。
Spring CronTrigger 可显式指定 zone,Quartz CronTrigger 提供不同 Misfire 指令,集群通过共享 JDBCJobStore 竞争触发。官方机制决定如何产生和领取 fire,却不替应用定义停机期间每个错过窗口究竟应跳过、合并还是逐个补齐。
从现场还原真正的运行链
Cron 是从日历规则计算候选时刻的函数,zone 是函数输入的一部分,不能默认为每台机器的系统时区。保存表达式、IANA zone、规则版本、plannedAt 与 windowStart/windowEnd;执行和排序使用 Instant,展示才转换本地时间。时钟回拨可能让一个本地时间对应两个 Instant,跳时可能让某个本地时间不存在,业务必须明确 FIRST、SECOND、SKIP 或 NEXT_VALID。
最危险的提交与恢复窗口
Misfire 首先判断 plannedAt 与 now 的偏差是否超过阈值,再按任务语义选择 DO_NOTHING、FIRE_NOW、CATCH_UP_EACH 或 COALESCE。逐个补齐保持窗口完整但会形成追赶风暴;合并补跑节省容量,却要求处理器能覆盖多个窗口且账本记录范围。分片则用稳定业务键计算 bucket,不把当前 worker 数直接写进永久游标;每个 fire 保存 shardPlanVersion 和覆盖集合,扩缩容只影响下一计划。
沿六个状态节点逐段验证
解析 Zone 规则
来到“解析 Zone 规则”时,先记录输入标识、状态版本、owner、剩余 deadline 和允许的下一迁移,再执行外部动作。正向实验断言计划、attempt 与领域结果守恒;反向实验在状态写入前后分别制造崩溃、超时或租约切换,确认迟到写被拒绝,UNKNOWN 能通过 operationId 对账,而不是被无条件重跑掩盖。
生成 Planned Fire
来到“生成 Planned Fire”时,先记录输入标识、状态版本、owner、剩余 deadline 和允许的下一迁移,再执行外部动作。正向实验断言计划、attempt 与领域结果守恒;反向实验在状态写入前后分别制造崩溃、超时或租约切换,确认迟到写被拒绝,UNKNOWN 能通过 operationId 对账,而不是被无条件重跑掩盖。
判定 Misfire
来到“判定 Misfire”时,先记录输入标识、状态版本、owner、剩余 deadline 和允许的下一迁移,再执行外部动作。正向实验断言计划、attempt 与领域结果守恒;反向实验在状态写入前后分别制造崩溃、超时或租约切换,确认迟到写被拒绝,UNKNOWN 能通过 operationId 对账,而不是被无条件重跑掩盖。
固化分片计划
来到“固化分片计划”时,先记录输入标识、状态版本、owner、剩余 deadline 和允许的下一迁移,再执行外部动作。正向实验断言计划、attempt 与领域结果守恒;反向实验在状态写入前后分别制造崩溃、超时或租约切换,确认迟到写被拒绝,UNKNOWN 能通过 operationId 对账,而不是被无条件重跑掩盖。
执行窗口账本
来到“执行窗口账本”时,先记录输入标识、状态版本、owner、剩余 deadline 和允许的下一迁移,再执行外部动作。正向实验断言计划、attempt 与领域结果守恒;反向实验在状态写入前后分别制造崩溃、超时或租约切换,确认迟到写被拒绝,UNKNOWN 能通过 operationId 对账,而不是被无条件重跑掩盖。
校验覆盖集合
来到“校验覆盖集合”时,先记录输入标识、状态版本、owner、剩余 deadline 和允许的下一迁移,再执行外部动作。正向实验断言计划、attempt 与领域结果守恒;反向实验在状态写入前后分别制造崩溃、超时或租约切换,确认迟到写被拒绝,UNKNOWN 能通过 operationId 对账,而不是被无条件重跑掩盖。
先把四个标识拆开,故障才有名字
调度系统中最容易被混淆的是计划、触发、尝试和业务动作。scheduleId 是长期规则;fireId 对应一个理论触发窗口;attemptId 是某个执行者的一次领取;operationId 才是业务副作用的稳定身份。正常路径里它们看似一一对应,一旦遇到超时、重派、Misfire、扩缩容或进程重启,映射立即变成一对多。日志、指标和状态表若只有 jobId,事故现场只能看到“同一个任务跑了很多次”,无法判断哪次是补触发、恢复 attempt 或非法重复。
到点、开始、提交和确认是四个不同时间
状态迁移要能拒绝迟到写
可观测性必须能重建一次执行
停机和发布要先处理调度所有权
一个会稳定暴露问题的反向实验
把 shardIndex/shardTotal 用作数据取模,在执行器从 4 台扩到 5 台时,尚未完成的旧 fire 会改变归属;如果重试读取新总数,部分记录漏掉,部分重复。集群节点时钟偏差还会影响触发扫描和故障检测,数据库时间也不能神奇解决所有业务时区问题。需要分别监控 clock skew、schedule lag、misfire count、catch-up backlog、shard coverage 与重复吸收。 复现时固定任务定义、输入快照、时钟源和线程池容量,保存首次失败的控制记录与领域账本;修复后用同一故障点重放,并同时证明正常路径没有新增重复、等待和资源泄漏。
两个 Java 17 模型把不变量钉在输出上
下面的模型不模拟完整框架,而是把最容易被产品日志掩盖的状态转折压缩成确定性见证。它们执行真实计算和断言,输出发生变化就说明执行语义被改写。
javac --release 17 -Xlint:all -Werror examples/backend-development/scheduling-async/sharding-misfire-timezone/ShardCoverageDemo.java examples/backend-development/scheduling-async/sharding-misfire-timezone/MisfirePolicyDemo.java
java -cp examples/backend-development/scheduling-async/sharding-misfire-timezone ShardCoverageDemo
java -cp examples/backend-development/scheduling-async/sharding-misfire-timezone MisfirePolicyDemorecords=100 buckets=8 completedBuckets=8 duplicates=0 missing=0 rebalanceNextFire=true coverageComplete=true
missed=12 capacityPerRun=3 policy=COALESCE emittedRuns=1 coveredWindows=12 catchupStorm=false第一行验证正常时间或覆盖模型,第二行专门验证恢复、重派、租约或取消窗口。工程接入后,用真实数据库唯一约束、执行器故障和平台回调替换内存状态,但保留相同 operationId、状态迁移和输出不变量。
架构取舍不是功能数量比赛
面向业务日的财务任务选择显式 zone 与窗口账本;固定间隔的技术任务优先使用 Duration 和单调时间概念;大窗口可用稳定 bucket 分片并在每个 fire 固化计划;可重算报表可以合并补跑,外部账单则常需逐窗口对账。任何策略都要先计算最坏补跑量与下游容量,再允许从 PAUSED 恢复。
发布门禁:证明执行收敛,而不是证明按钮可用
发布时必须守住“每个业务窗口的覆盖集合可证明,节点数量和本地日历变化不能静默改写结果”。验收证据至少包含一轮正常 fire、一轮执行中强杀、一轮回调丢失或租约过期、一轮停机恢复;核对 planned、started、terminal、businessWrites、unknown 与 retry 的守恒关系。配置例外必须有 owner、影响窗口、补偿控制和到期条件,过期自动阻断。
