长任务状态、取消与可观测:执行跨过重启后怎样继续
导出任务跑了两个小时,控制台点下终止后显示 CANCELED,数据库连接却继续占用,临时文件还在增长。问题在于控制面把“已发送取消命令”当成“执行已经停止”。对长任务而言,取消是一段协议:记录请求、执行者观察、停止接新批次、完成或回滚当前原子块、保存检查点、释放资源,最后才能确认终态。
Java Future.cancel、线程中断和调度框架的终止命令都只是取消请求;Spring 生命周期与执行器关闭也只能传播停止信号。长任务是否安全停止,取决于处理器能否在明确边界观察请求、关闭资源并保存可验证检查点。
从现场还原真正的运行链
状态机至少区分 QUEUED、RUNNING、PAUSE_REQUESTED、PAUSED、CANCEL_REQUESTED、CANCELED、SUCCEEDED、RETRYABLE、FAILED_FINAL 与 UNKNOWN。每次领取产生 attemptId 和 lease,心跳携带 stateVersion、checkpointVersion、processed、failed、lastProgressAt。控制命令使用条件更新,旧执行者不能覆盖新 attempt;终态一旦提交不可被迟到心跳改回 RUNNING。
最危险的提交与恢复窗口
检查点必须指向已提交且可重放的边界,例如最后一个完整主键区间、对象存储已确认分片或状态机阶段,不保存“处理到第 57%”这种不可恢复数字。批次提交顺序是:准备结果、提交业务、原子推进 checkpoint,再报告进度。若业务提交与检查点不共事务,恢复先按 operationId 查询已写结果。取消检查放在批次边界和阻塞调用前后,捕获 InterruptedException 后恢复中断标记或转换为明确取消,不能吞掉后继续。
沿六个状态节点逐段验证
领取 Attempt
来到“领取 Attempt”时,先记录输入标识、状态版本、owner、剩余 deadline 和允许的下一迁移,再执行外部动作。正向实验断言计划、attempt 与领域结果守恒;反向实验在状态写入前后分别制造崩溃、超时或租约切换,确认迟到写被拒绝,UNKNOWN 能通过 operationId 对账,而不是被无条件重跑掩盖。
加载检查点
来到“加载检查点”时,先记录输入标识、状态版本、owner、剩余 deadline 和允许的下一迁移,再执行外部动作。正向实验断言计划、attempt 与领域结果守恒;反向实验在状态写入前后分别制造崩溃、超时或租约切换,确认迟到写被拒绝,UNKNOWN 能通过 operationId 对账,而不是被无条件重跑掩盖。
执行原子批次
来到“执行原子批次”时,先记录输入标识、状态版本、owner、剩余 deadline 和允许的下一迁移,再执行外部动作。正向实验断言计划、attempt 与领域结果守恒;反向实验在状态写入前后分别制造崩溃、超时或租约切换,确认迟到写被拒绝,UNKNOWN 能通过 operationId 对账,而不是被无条件重跑掩盖。
推进状态版本
来到“推进状态版本”时,先记录输入标识、状态版本、owner、剩余 deadline 和允许的下一迁移,再执行外部动作。正向实验断言计划、attempt 与领域结果守恒;反向实验在状态写入前后分别制造崩溃、超时或租约切换,确认迟到写被拒绝,UNKNOWN 能通过 operationId 对账,而不是被无条件重跑掩盖。
响应取消请求
来到“响应取消请求”时,先记录输入标识、状态版本、owner、剩余 deadline 和允许的下一迁移,再执行外部动作。正向实验断言计划、attempt 与领域结果守恒;反向实验在状态写入前后分别制造崩溃、超时或租约切换,确认迟到写被拒绝,UNKNOWN 能通过 operationId 对账,而不是被无条件重跑掩盖。
恢复与验收
来到“恢复与验收”时,先记录输入标识、状态版本、owner、剩余 deadline 和允许的下一迁移,再执行外部动作。正向实验断言计划、attempt 与领域结果守恒;反向实验在状态写入前后分别制造崩溃、超时或租约切换,确认迟到写被拒绝,UNKNOWN 能通过 operationId 对账,而不是被无条件重跑掩盖。
先把四个标识拆开,故障才有名字
到点、开始、提交和确认是四个不同时间
deadline 约束整个 attempt,不能在下发、排队和业务调用各自重新计时。到达执行器时剩余预算不足,应拒绝并返回 EXPIRED;业务已进入不可取消提交段,则切换到 UNKNOWN/RECONCILING,不能强行把超时包装成回滚成功。所有重试与补跑都消耗容量预算,恢复流量和在线流量必须隔离或限速。
状态迁移要能拒绝迟到写
状态机不只用来画图,它决定并发更新的合法性。领取使用版本条件把 READY 改为 RUNNING 并写入 attemptId、leaseUntil 与 fence;心跳只能由当前 attempt 推进;完成更新要求版本和 owner 同时匹配。租约过期后新 attempt 获得更高 fence,旧执行者即使从暂停中恢复,也必须在业务资源层被拒绝。仅靠控制表 CAS 而下游不检查 fence,仍挡不住旧连接继续提交。
可观测性必须能重建一次执行
停机和发布要先处理调度所有权
回滚不能只回应用包。若任务定义、Cron、分片规则或账本 Schema 已变更,需要判断旧版本能否读取新状态、是否会重新生成已完成窗口,以及在途 attempt 由谁接管。门禁使用影子计划或受限 fire 验证触发数、执行数、业务写入数和终态数之间的守恒关系。
一个会稳定暴露问题的反向实验
只看心跳超时会把长 GC、网络分区和进程死亡混在一起;重派前必须让租约过期并依靠 fence 拒绝迟到写。进度按 processed/estimatedTotal 计算时,动态新增数据会倒退或超过百分之百,应该同时展示已完成量、剩余估计、吞吐与最后推进时间。日志按批次采样并关联 jobId/fireId/attemptId,不能每条数据一行把日志系统变成新瓶颈。 复现时固定任务定义、输入快照、时钟源和线程池容量,保存首次失败的控制记录与领域账本;修复后用同一故障点重放,并同时证明正常路径没有新增重复、等待和资源泄漏。
两个 Java 17 模型把不变量钉在输出上
下面的模型不模拟完整框架,而是把最容易被产品日志掩盖的状态转折压缩成确定性见证。它们执行真实计算和断言,输出发生变化就说明执行语义被改写。
javac --release 17 -Xlint:all -Werror examples/backend-development/scheduling-async/longjob-state-observability/LongJobCheckpointDemo.java examples/backend-development/scheduling-async/longjob-state-observability/CancellationStateDemo.java
java -cp examples/backend-development/scheduling-async/longjob-state-observability LongJobCheckpointDemo
java -cp examples/backend-development/scheduling-async/longjob-state-observability CancellationStateDemoitems=100 batch=20 crashAfter=60 resumedFrom=60 finalProcessed=100 duplicates=0 checkpointSafe=true
requestAtBatch=3 observedAtBatch=3 committedBatches=3 state=RUNNING->CANCEL_REQUESTED->CANCELED resourcesClosed=true第一行验证正常时间或覆盖模型,第二行专门验证恢复、重派、租约或取消窗口。工程接入后,用真实数据库唯一约束、执行器故障和平台回调替换内存状态,但保留相同 operationId、状态迁移和输出不变量。
架构取舍不是功能数量比赛
秒级幂等任务失败后整体重跑最简单;分钟级批处理用固定大小 checkpoint;小时级多阶段任务需要持久状态机、租约、暂停恢复与产物清理;包含人工审批和跨日等待时,信号已经指向工作流引擎而非继续扩张调度器。迁移信号来自状态复杂度和恢复责任,不来自任务耗时的某个万能阈值。
发布门禁:证明执行收敛,而不是证明按钮可用
发布时必须守住“重启从最后一个权威检查点继续,取消只有在执行停止且资源释放后才成为终态”。验收证据至少包含一轮正常 fire、一轮执行中强杀、一轮回调丢失或租约过期、一轮停机恢复;核对 planned、started、terminal、businessWrites、unknown 与 retry 的守恒关系。配置例外必须有 owner、影响窗口、补偿控制和到期条件,过期自动阻断。
