跳到正文
分布式定时任务重复执行故障复盘与幂等设计

分布式定时任务重复执行故障复盘与幂等设计

从商户日结任务重复执行出发,分析故障转移、分布式锁、批次标记、状态机 CAS、唯一约束与跨系统幂等的职责边界。

故障概述#

一个商户日结任务部署在三台执行节点上。某天调度中心日志显示只触发了一次,但其中两台节点都完整执行了全量结算,导致结算单重复生成、部分账户重复收款,资金流水无法对齐。

“触发一次却执行两次”通常不是分片不平衡,而是超时故障转移、广播路由或重派策略与业务幂等同时失守。调度平台只能降低重复执行概率,不能替业务层提供最终正确性。

常见的三层防御方案#

第一层:业务分级,区分任务保障等级#

  • 核心强一致任务:比如资金结算、账单生成、库存对账,必须做单次执行强管控,零容忍重复运行。
  • 非核心常规任务:比如过期数据清理、缓存预热、日志归档,允许短时间重复执行,业务侧自带轻量幂等即可,不做过度设计。

第二层:执行管控与兜底校验#

  • 分布式锁防重:核心任务启动前先抢占全局分布式锁,同一时间仅一个节点能获取锁执行业务,并配合锁续签机制,避免任务执行中超时释放。
  • 执行记录幂等:所有任务落地执行日志表,执行前先校验当日任务是否已完成,确认未执行再跑业务逻辑,操作与日志写入保证原子性。
  • 分片策略固化:多节点并行任务明确配置分片参数,按节点 ID 拆分数据范围,禁止全量任务无分片集群运行。

第三层:架构层优化,从根源降低调度风险#

  • 调度中心集群部署:避免单点故障引发任务漏发、重发。
  • 调度日志持久化:留痕可追溯。
  • 失败重试分级管控:业务异常不轻易重试,仅网络、节点故障类异常触发有限次数重试,避免业务问题被重复放大。
  • 核心场景熔断:大促、账期等关键窗口期,核心定时任务切换为单节点专属执行模式,叠加双重幂等校验,宁可牺牲执行效率,也要保证数据零差错。

这套方案覆盖了业务分级、执行管控和运营兜底,但正确性仍然必须落到逐条幂等、唯一约束和跨系统幂等键上。


深入分析与方案校正#

先纠正常见误诊:“两台全量执行”不是“分片没平衡”#

把问题归因于“分片策略没平衡”——方向偏了。分片不平衡(如 3 个分片给到 1/1/97)的后果是某节点扛 97% 负载、跑得慢,不是”两台各跑一次全量”。“两台都完整执行了全量”是选择/故障转移问题,不是 balance 问题。

调度中心只触发一次、三台里两台全量执行,最贴合的真实链路是 timeout failover:

  1. 09
    调度中心触发一次,派给节点 A;
  2. 结算逻辑重(几千商户、写明细、调账户),A 跑得久;
  3. A 心跳/执行超过调度平台的”超时阈值”,平台判 A 失联/超时,触发 failover,把同一任务再派给节点 B;
  4. 但 A 其实还活着,继续跑完写盘;B 也开跑、也写盘 → A、B 都全量完成,第三台 C 空闲。

次常见的是路由策略配错(广播路由当分片用、failover 路由让目标跑整份),本质还是同一种错。

“触发一次”不等于“执行一次”#

调度中心的”trigger once”是调度层概念;任务的执行模型可能是 fan-out 到多 worker(failover、广播、分片重派)。保证只触发一次,根本不能反推只执行一次。

更狠的认知:“exactly-once 执行”在有 crash 可能的分布式系统里根本不存在(两将军 / FLP 那条线)。调度层永远无法区分一个节点是”写完盘后 crash”还是”写盘前 crash”——所以任何重试都面临”不重试→漏,重试→重”的两难。“靠调度框架保证只执行一次”是个伪命题。 问题的关键在于:如果试图从调度层寻找最终正确性保证,结论一定会落空。

正解:不必单节点,靠业务幂等做到“效果上”的 Exactly-Once#

“难道所有定时任务都只部署单节点?”——不必,而且单节点是治标的下策。单节点只是把”谁执行”的选择问题退化掉(简单),代价是单点故障 + 重启/migrate 时仍有 failover 重复窗口。真正答案是:

多节点 + 业务层幂等(自然键唯一约束 + 状态机 CAS),让”重复执行”在效果上坍缩成 no-op。分布式锁 / 分片只是降低重复概率与浪费,不是正确性保证。

层次划分,各司其职:

机制干什么能保证什么
选择层Redisson 锁(watchdog 续期)多实例里只一个进临界区通常只一个跑(降低重复概率)
削减层执行前查 settle:done:{date} marker已完成则秒退不重复做无用功
正确性层(真正保证)幂等键 (merchant_id, settle_date) UNIQUE + 状态机 CAS UNSETTLED→SETTLING→SETTLED重复写盘被唯一约束拒 / CAS 只一个生效无论执行几次,效果只一次

锁会因 TTL 过期 / 持锁节点卡死而失效——watchdog 续期能缓解但救不了边界(持锁节点 GC 卡死,锁续期停,TTL 到,第二节点抢锁开跑,然后卡死节点醒来继续写)。只有正确性层的唯一约束 + CAS 在所有情况下兜住。 这就是 Kafka 为什么要做 idempotent producer + 事务,而非吹”broker 保证 once”——同一个道理。

对三层方案的拆解:一处承重墙缺位,两处方向相反#

前面的三层方案是一份 Senior 级运营 Checklist——业务分级、失败重试分级、关键窗口退单节点这三条是真的好,尤其第二层(c)“业务异常不轻易重试”是易被漏掉的关键认知(重试一个有 bug 的业务逻辑 = 复制坏数据,比漏跑更危险)。但若把它当完整方案封箱,有这些问题:

承重墙缺位

,没点透。

第二层(b)「执行前先校验当日任务是否已完成,操作与日志写入保证原子性」——这套只挡得住”整批已完成被重踢”(done-marker 查到已完成→秒退)。挡不住的是整批中途 crash 被重跑:

几万商户的批量结算,跑到第 5000 家 crash。done-marker 还没写(批次没完成)。failover 重跑从第 1 家开始——这时候前面 5000 家已经被置 SETTLING、明细已写。唯一能救命的是逐条 CAS UNSETTLED→SETTLING→SETTLED(受影响 0 行=已被别人结算)+ 明细表 UNIQUE(merchant_id, settle_date) 冲突拒。

而「逐条 CAS + 明细唯一键」恰恰是原方案里最没着墨、被压缩进”执行记录幂等”四个字的那一项。结果就是台面上摆的都是只能减概率 / 降风险的东西(锁、调度中心集群、分片固化),真正在所有 crash 模式下保正确的那层被藏起来。重心倒置。

锐化一句:锁减概率 → done-marker 挡整批复跑 → 唯独逐条幂等(唯一键+CAS)在每种故障下保正确。三层框架应该以最后一句为地基往上砌,而不是把它埋掉。

方向反一

(a)「调度中心集群部署避免漏发/重发」。

调度中心自己集群后,反而冒出”哪个调度中心实例该触发”的选主一致性问题——HA 解决漏发(中心不死了),但不解决重发(重发仍要靠业务幂等兜)。把它列在”从根源降低重发风险”里,锅甩错方向了。

方向反二

(d)「关键窗口退单节点+双重幂等」姿态是撤退。

这其实是在说”我不完全信任分布式正确性,所以关键时刻退回单点”。诚实的工程取舍没错,但它反衬出对幂等没完全拿捏。更自信的版本是:有逐条幂等兜底,单节点 vs 多节点只是成本 / 效率选择,不是正确性选择;资金窗口选单节点是为”省心、降操作面”,不是因为它在正确性上更稳。把它写成”为正确性而退”,等于自承底气不足。

一个真实缺口:界外调用#

资金结算最难的一段原方案完全没覆盖——调账户系统 / 银行通道是跨系统、异步、会超时,DB 唯一约束管不到界外。界外只能靠幂等流水号 + 对账兜底。资金任务若不覆盖这一段,前面三层做得再齐,最后一公里照样重打款。这一条是资金类定时任务和普通对账任务的分水岭。

完整的分层方案#

把前面的运营三层 + 逐条幂等才是保证 + 修掉方向反两处 + 补界外调用,合起来比单独任一版都完整:

机制性质
0 业务分级核心(资金/库存对账)强一致 vs 非核心(清理/预热)轻幂等抵御过度设计
1 选择层Redisson 锁 + watchdog 续期减概率
2 批次层done-marker settle:done:{date} 整批已完成秒退挡整批复跑
3 记录层(正确性地基)逐条 CAS 状态机 + 明细 UNIQUE(merchant_id, settle_date)所有 crash 下保正确
4 界外层账户 / 通道幂等流水号 + 对账兜底跨系统那段
5 运营层失败重试分级(业务异常不重试)+ 调度中心 HA(只防漏发)+ 关键窗口单节点(省心非正确性)工程取舍

结算场景落地配方#

每天 02:00 调度触发 → 任一节点抢 lock:settle:{settle_date}(watchdog 续期)
├─ 抢到 → CHECK settle:done:{settle_date} ? 秒退(整个批次已完成)
│ ├─ 否 → 开始批量结算,逐商户:
│ │ CHECK merchant.settle_status==UNSETTLED (短路)
│ │ CAS status UNSETTLED→SETTLING (WHERE settle_status=UNSETTLED)
│ │ 受影响0 → 已被别人结算,跳过(幂等)
│ │ 受影响1 → 写结算明细 UNIQUE(merchant_id, settle_date)
│ │ → 冲突 → no-op 跳过(幂等)
│ │ → 调账户(账户侧也带幂等键)
│ │ → CAS status SETTLING→SETTLED
│ └─ 全部完成 → SET settle:done:{settle_date}=1 (短 TTL 防误判当批内)
└─ 没抢到 → 退出(下一周期或failover再来时靠done marker/唯一约束兜)

关键三件套:整批 marker + 逐项 CAS 状态机 + 明细唯一约束。缺任何一个,在锁失效 + failover 同时发生时都会重。财务那天的惨剧,缺的就是第三件——明细表没有 (merchant_id, settle_date) 唯一约束,所以两个全量执行都写成功了。


技术词汇解释与举例#

一、调度模型与路由#

分片(Sharding) 把一份全量任务按规则切成多份,每个节点只处理自己那片。按节点 ID / hash / 数据范围切。3 个节点 10000 商户 → A 跑 1-3333、B 跑 3334-6666、C 跑 6667-10000,各自不重叠。 正确配置:shardTotal=3, shardIndex=nodeId%3,每节点只取自己分片的数据。

分片不平衡(Shard Imbalance) 分片切歪了,如 3 个分片分配成 1/1/97,后果是 97 那台节点扛绝大负载、跑得久。注意:这只导致”慢”,不导致”重复执行”。把“两台全量执行”归因为“分片不平衡”是误诊——不平衡不会让两个节点都跑全量。

路由策略(Routing Strategy) 调度中心派任务给执行节点的方式,常见有:

  • FIRST/轮询:按顺序挑一个节点。
  • FAILover:挑一个跑,超时/失联再换一个重派 —— 案例的元凶。
  • 广播(BROADCAST):每个节点都跑一遍 —— 清缓存预热用,跑资金结算就是灾难。
  • 分片(SHARDING):每节点跑一片。 误配(广播当分片用、failover 让目标跑整份)是重复执行的常见来源。

Failover(故障转移) 节点 A 跑任务超时或心跳丢了,调度平台判它失联,把同一任务再派一份给节点 B。问题:A 其实还活着,只是慢;A 跑完写盘、B 也跑完写盘 → 双写。这是案例”两台全量执行”的最贴合链路。

Timeout(超时阈值) 调度平台等节点回报的最长时间。超了就当失败、可能触发 failover。定太短:正常重任务被判超时 → failover 重派 → 重复执行。定太长:真挂了也迟迟不补救。资金类重任务该往宽定 + 别急着 failover。

心跳(Heartbeat) 执行节点周期性向调度中心上报”我还活着”。心跳丢了,平台判节点失联。节点 GC 卡死、网络抖动都可能丢心跳——节点其实没死,只是”看起来死了”,failover 就错了。

二、触发与执行#

Trigger Once ≠ Execute Once 调度中心”只触发一次”是调度层概念。任务的执行模型可能 fan-out 到多 worker(failover、广播、分片重派)。保证只触发一次,根本不能反推只执行一次。 这是整个故障模型的关键。

Exactly-Once(精确一次) “无论发生什么,任务效果上只执行一次”的保证。残酷真相:在有 crash 可能的分布式系统里,调度层做不到 exactly-once(两将军问题 / FLP 不可能定理那条线)。节点”写完盘后 crash”和”写盘前 crash”调度层无法区分——不重试→漏,重试→重,两难。所以”靠调度框架保证只执行一次”是伪命题,正确性必须靠业务层幂等。

At-Most-Once / At-Least-Once / Exactly-Once

  • At-Most-Once:最多一次,可能漏(不重试)。
  • At-Least-Once:至少一次,可能重(失败就重试)——调度层默认这个。
  • Exactly-Once:效果上一次——靠业务幂等把”至少一次”坍缩成”效果一次”。定时任务要追求的是这个,且只在业务层实现。

三、分布式锁#

分布式锁(Distributed Lock) 跨节点的互斥锁,同一时间只有一个节点能持有。Redis/Redisson/Zookeeper 实现。定时任务用它让多实例里只有一个进临界区跑业务。 注意性质:它降低”重复执行概率”,不保证正确性——锁会因 TTL 过期 / 持锁节点卡死而失效。

Redisson Redis 的 Java 客户端 + 一组分布式工具(锁、限流、延迟队列)。RLock 是它的可重入分布式锁实现。

Watchdog(看门狗续期) Redisson 锁的自动续期机制:拿到锁后后台线程周期性(默认每 10s)把锁 TTL 续回 30s,防止业务没跑完锁就过期。缓解但不救命:持锁节点 GC 卡死 → 续期线程也卡 → TTL 到 → 第二节点抢锁 → 卡死节点醒来双写。边界场景 watchdog 兜不住。

锁续签 / 锁续期(Lock Renewal) 同 watchdog。任务执行中持续延长锁寿命,避免长任务跑到一半锁被别人抢走。

持锁节点卡死(Stale Lock Holder) 节点拿到锁后因 GC pause / STW / 死循环卡住,业务没跑完也不释放锁。锁 TTL 到期后别人能抢,但卡死节点醒来会继续写——这是锁失效最难缠的边界。

四、幂等与正确性地基#

幂等(Idempotency) 同一操作做一次和做多次效果相同。重复执行坍缩成 no-op。这是分布式定时任务正确性的真正来源——不是锁,是幂等。

幂等键 / 自然键(Idempotency Key / Natural Key) 业务上能唯一标识一次操作的字段。结算场景是 (merchant_id, settle_date)——同商户同一天只该有一笔结算。靠它做唯一约束挡重复写。

UNIQUE 唯一约束 数据库层保证某组合字段不重复。明细表 UNIQUE(merchant_id, settle_date) 让第二次写入直接被 DB 拒(冲突),无论调度层重派几次,盘上只一份。这是所有 crash 模式下兜底正确性的那一层。

CAS(Compare-And-Swap)状态机 UPDATE ... SET status=SETTLING WHERE id=? AND status=UNSETTLED——只有当前状态符合预期才更新,受影响 0 行说明已被别人处理过。用状态迁移 UNSETTLED→SETTLING→SETTLED 做逐条幂等:重复执行时 CAS 受影响 0 行 → no-op 跳过。 对比 set-age=UPDATE SET age=age+1 这种非条件更新——跑两次就 +2,不幂等。

Done-Marker(批次完成标记) 整批任务跑完后写一个标记 settle:done:{date}=1,下次重跑前先查它,已完成秒退。只挡”整批已完成被重踢”,挡不住”整批中途 crash 被重跑”(marker 还没写)。是削减层,不是正确性层。

原子性(Atomicity) 操作与日志写入要么都成要么都不成。原方案强调“操作与日志写入保证原子性”——但事务原子性挡不住跨事务的重复执行(crash 在事务之间),所以它本身不等于幂等,得配唯一约束 / CAS。

五、重试与运营#

失败重试分级 不同异常不同对待:

  • 网络 / 节点故障类(连接超时、节点失联):有限次数重试,值得重试。
  • 业务异常(参数错、余额不足、业务规则违反):不重试——重试一个有 bug / 不满足前置的业务逻辑 = 复制坏数据,比漏跑更危险。

调度中心集群部署 调度中心自己多实例防单点。解决漏发(中心不死所以不漏触发),不解决重发(重发仍要靠业务幂等兜)。还有个副作用:多实例后冒出”哪个实例该触发”的选主一致性问题。

关键窗口退单节点 大促 / 账期等关键期,核心任务切到单节点专属跑 + 双重幂等。姿态上是”不完全信任分布式正确性所以退回单点”。诚实的工程取舍,但更自信的版本是:有逐条幂等兜底,单节点 vs 多节点只是成本 / 效率选择,不是正确性选择。

六、界外调用#

界外调用(Out-of-System Call) 任务执行中调其他系统(账户服务、银行通道、第三方 API)。DB 唯一约束管不到界外——你这边幂等了,对方系统可能重打款。资金类任务最难的一段。

幂等流水号(Idempotency Request Key) 调外部系统时带的唯一请求号,对方系统凭它识别”这是重试不是新请求”,返回上次的结果而不是再执行一遍。相当于把幂等契约延伸到界外。

对账兜底(Reconciliation) 定期比对双方流水(如每天对账文件),发现不一致就人工 / 自动修复。界外调用唯一可靠的最终防线——因为对方系统是否真的幂等你控制不了,只能靠事后对账抓差异。资金类定时任务和普通对账任务的分水岭就在这一段。

版权许可

CC BY-NC-SA 4.0 本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。

相关文章

s1oopX

登录 s1oopX