任务幂等、重试、补偿与锁:重复执行怎样收敛为一个结果
库存同步任务加了一把分布式锁,线上仍出现旧实例覆盖新结果。原因并不神秘:实例 A 获得锁后发生长暂停,租约过期;实例 B 获得新租约并完成;A 恢复后继续写入。锁避免的是同一租约期内并发进入,无法让已经拿到资源句柄的旧执行者失忆。只有下游在写入时比较单调递增的 fencing token,过期 attempt 才会被真正拒绝。
调度框架和平台都允许失败后再次尝试,Quartz 的恢复执行、平台的失败重试以及执行器超时重派都会制造新 attempt。重试是可用性策略,锁是并发互斥工具,幂等是业务语义;三者不能互相冒充。
从现场还原真正的运行链
任务账本以 operationId 和 payloadHash 建唯一约束,状态至少有 ACCEPTED、RUNNING、COMMITTED、RETRYABLE、UNKNOWN、COMPENSATING、COMPENSATED 与 FAILED_FINAL。领取时创建 attemptId、leaseUntil 和 fence;续租只允许当前 attempt 以版本条件更新。业务写入与账本终态尽量处于同一事务;无法同库事务时,用 Outbox 或可查询 operationId 建立对账,不用“先写成功再标记”假装原子。
最危险的提交与恢复窗口
重试策略先按阶段分型:领取前失败可安全重派;业务事务回滚后可有限重试;提交响应丢失进入 UNKNOWN,只能先查询;参数错误和权限拒绝直接终态。退避与 jitter 受 deadline 和 attempt 上限约束,重试 owner 只能有一个。补偿不是反向 SQL,而是新的有审计业务动作;它也有 operationId、前置状态和幂等要求,并承认外部通知、资金与人工决策可能不可逆。
沿六个状态节点逐段验证
构造 OperationId
来到“构造 OperationId”时,先记录输入标识、状态版本、owner、剩余 deadline 和允许的下一迁移,再执行外部动作。正向实验断言计划、attempt 与领域结果守恒;反向实验在状态写入前后分别制造崩溃、超时或租约切换,确认迟到写被拒绝,UNKNOWN 能通过 operationId 对账,而不是被无条件重跑掩盖。
领取租约与 Fence
来到“领取租约与 Fence”时,先记录输入标识、状态版本、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 对账,而不是被无条件重跑掩盖。
先把四个标识拆开,故障才有名字
一次 fire 的最小记录应包含 plannedAt、actualAt、deadline、configVersion、shardPlan、attemptOwner 和终态摘要;业务账本保存 operationId、payloadHash、权威结果和版本。调度数据库负责控制面进度,业务数据库负责领域事实。二者无法原子提交时,承认 UNKNOWN 并提供查询与对账,不用一个绿色回调覆盖分布式提交窗口。
到点、开始、提交和确认是四个不同时间
plannedAt 只表示计划希望何时触发,actualAt 受调度扫描、线程池、数据库锁和网络影响;startedAt 还要等待执行器 admission;committedAt 才代表业务权威状态改变;acknowledgedAt 是控制面得知结果。schedule lag、queue wait、run duration 和 callback lag 必须分别测量。把它们合成“任务耗时”,既无法定位慢在中心还是执行器,也会把已经提交但回调迟到误判为可重试失败。
状态迁移要能拒绝迟到写
可观测性必须能重建一次执行
日志统一携带 jobId、scheduleId、fireId、attemptId、operationId、shard、configVersion 和 fence;指标至少拆分 trigger_total、started_total、terminal_total、schedule_lag、queue_wait、run_duration、lease_conflict、unknown_total、retry_total 与 checkpoint_age。高基数标识进入 Trace 或日志索引,不直接塞入指标标签。一次事故应能从理论窗口定位所有 attempt,再从 attempt 找到业务账本、外部调用和最终产物。
“没有失败日志”不是成功证据。还要核对计划窗口是否产生 fire、fire 是否开始、RUNNING 是否有心跳、终态是否落库、产物计数是否满足不变量。僵尸任务表现为 lease 过期但资源仍在写,静默漏跑表现为 planned window 存在却没有 fire,假成功表现为控制面 SUCCESS 而领域账本缺少 operationId。
停机和发布要先处理调度所有权
一个会稳定暴露问题的反向实验
只用 jobId 作为幂等键会把每个周期都误判为重复,只用 fireTime 又会因时区和 Misfire 改写产生新键;正确键来自业务窗口和对象集合。finally 中无条件释放锁可能删掉别人的新租约,必须比较 owner 与 token。无限自动重试会让永久错误吞掉执行槽,并使旧任务越过新数据版本;队列年龄、attempt 数和租约争抢都应触发终止或人工接管。 复现时固定任务定义、输入快照、时钟源和线程池容量,保存首次失败的控制记录与领域账本;修复后用同一故障点重放,并同时证明正常路径没有新增重复、等待和资源泄漏。
两个 Java 17 模型把不变量钉在输出上
下面的模型不模拟完整框架,而是把最容易被产品日志掩盖的状态转折压缩成确定性见证。它们执行真实计算和断言,输出发生变化就说明执行语义被改写。
javac --release 17 -Xlint:all -Werror examples/backend-development/scheduling-async/job-idempotency-retry/JobLedgerDemo.java examples/backend-development/scheduling-async/job-idempotency-retry/LeaseFenceDemo.java
java -cp examples/backend-development/scheduling-async/job-idempotency-retry JobLedgerDemo
java -cp examples/backend-development/scheduling-async/job-idempotency-retry LeaseFenceDemodeliveries=3 attempts=2 businessWrites=1 states=[RUNNING,UNKNOWN,COMMITTED] replay=COMMITTED converged=true
leaseA=7 leaseB=8 writeB=ACCEPTED lateWriteA=REJECTED finalVersion=8 stalePrevented=true第一行验证正常时间或覆盖模型,第二行专门验证恢复、重派、租约或取消窗口。工程接入后,用真实数据库唯一约束、执行器故障和平台回调替换内存状态,但保留相同 operationId、状态迁移和输出不变量。
架构取舍不是功能数量比赛
数据库唯一键适合单库副作用,状态账本适合需要返回原结果和追踪 UNKNOWN 的关键任务,乐观版本适合可比较新旧结果的聚合更新,租约加 fence 适合长时间独占资源。若任务天然可从源快照全量重算,也可以接受至少一次并让最终覆盖收敛;但必须证明重算成本、读写隔离和旧版本拒绝条件。
发布门禁:证明执行收敛,而不是证明按钮可用
发布时必须守住“任意重派、超时和进程暂停后,旧 attempt 都不能制造第二个权威副作用”。验收证据至少包含一轮正常 fire、一轮执行中强杀、一轮回调丢失或租约过期、一轮停机恢复;核对 planned、started、terminal、businessWrites、unknown 与 retry 的守恒关系。配置例外必须有 owner、影响窗口、补偿控制和到期条件,过期自动阻断。
