为什么旧回调会伤害新状态:从重复消息到 ABA 问题

看懂延迟回调、过期定时任务、竞态条件与 fencing token

August 14, 2026·5 min read·Yimin
#分布式系统#并发#幂等性#ABA#后端开发

分布式系统最危险的假设,不是“请求会失败”,而是“收到的请求一定属于当前这轮业务”。

🎯 一个经典场景:支付回调撞上订单取消

假设用户创建了一笔订单,系统正在等待支付平台的异步回调:

订单创建
  ↓
等待支付
  ↓
支付平台 callback
  ↓
订单改为“已支付”并开始发货

正常情况下,这条链路没有问题。但网络世界不会总按正常顺序运行。

用户完成付款后,支付平台的 callback 因网络拥塞迟迟没有到达。用户以为支付失败,于是取消订单。几秒后,旧 callback 才抵达后端:

时间轴 →

支付平台: [支付成功] ------------------------ [callback 到达]
用户:                 [取消订单]
订单状态: [待支付] → [已取消] --------------- [?]

此时后端必须回答一个问题:

这条 callback 还可以改变订单吗?

如果只相信 callback 自己携带的“支付成功”,订单就可能从“已取消”重新变成“已支付”,甚至触发发货。

这不是支付业务独有的问题。Webhook、消息队列、审批回调、库存释放、定时任务,都会遇到同一种风险。


🧠 根本原因:消息世界和状态世界各自前进

一次异步操作包含两条独立时间线:

消息世界:请求发出 → 网络延迟 → 重试 → 最终到达

状态世界:待支付 → 修改收货地址 → 取消订单 → 退款

消息在路上时,业务状态不会停下来等它。

因此,callback 到达时携带的只是“过去发生过什么”,并不能直接证明“现在还允许做什么”。最终决定权必须来自后端当前的持久化状态。

可以把它总结成一句话:

消息提供事实,状态决定权限。

支付平台可以证明某次支付成功,但订单系统仍要根据当前订单状态,决定是确认订单、发起退款,还是记录异常等待人工处理。


🏛️ 第一类问题:重复投递与幂等性

网络超时时,调用方通常不知道请求究竟有没有成功。

支付平台发送 callback
        ↓
后端成功处理,但响应在网络中丢失
        ↓
支付平台认为失败,再发一次

因此,大多数可靠消息系统提供的是 at-least-once delivery:消息至少送达一次,但可能重复。

如果每次收到 callback 都执行一遍副作用,就可能出现:

  • 重复扣减库存;
  • 重复发放优惠券;
  • 重复发送通知;
  • 重复创建退款单。

解决它需要幂等性。常见做法是给每次业务动作一个稳定、唯一的 operation_id

第一次收到 operation_id=pay-789 → 执行并记录
第二次收到 operation_id=pay-789 → 发现已处理,直接返回已有结果

幂等不是“不允许重试”,而是让重试变得安全。

机制回答的问题
Idempotency key这是不是同一个请求的重复投递?
当前业务状态这个请求现在是否仍被允许执行?

两者缺一不可。即使请求不是重复的,也可能已经过期;即使请求仍然有效,也可能被重复发送。


🔍 第二类问题:TOCTOU——检查正确,执行时却错了

假设后端已经知道要检查订单状态,但检查和修改没有放在同一个临界区:

Callback: 读取订单,状态是“待支付”
Cancel:   把订单改成“已取消”
Callback: 根据刚才读到的旧状态,改成“已支付”

这叫 TOCTOU(Time Of Check To Time Of Use):检查状态时是正确的,真正使用它时,状态已经改变。

错误顺序是:

读取状态
→ 判断可以执行
→ 获取锁
→ 修改

正确顺序应该是:

获取锁
→ 重新读取最新状态
→ 再次判断
→ 修改并提交
→ 释放锁
┌─────────────────────────────────────────────┐
│              同一笔订单的临界区             │
│                                             │
│   获取锁 → 重读状态 → 检查 → 修改 → 提交     │
│                                             │
└─────────────────────────────────────────────┘
              ↑                    ↑
          支付 Callback          Cancel
          (二者只能有一个进入)

锁的价值不是让系统“永不冲突”,而是把并发操作排列成一个明确顺序。

如果 Cancel 先拿到锁,callback 随后重读时会看到“已取消”;如果 callback 先拿到锁,它会先完成支付确认,Cancel 再根据“已支付”状态决定是否还能取消。


⚠️ 锁不是万能的:旧任务可以在未来合法地拿到锁

锁只能阻止两个操作同时修改数据,不能判断一个操作是不是来自过去。

考虑电影院座位预占:

用户 A 预占 seat-42
系统创建“10 分钟后释放 seat-42”的任务

后来用户 A 的预占结束,用户 B 又预占了同一个座位。此时旧释放任务因为队列延迟才开始执行:

时间轴 →

seat-42: [A 预占] → [空闲] → [B 预占]
旧任务:  [创建释放 A 的任务] ---------------- [现在才执行]

旧任务成功拿到锁,读取后发现 seat-42 确实处于“已预占”,于是把它释放了。

整个执行过程没有并发写,也没有违反锁规则,但结果仍然是错的:它本来应该释放 A 的预占,却误伤了 B 的预占。

这就是经典的 ABA 问题

状态最初是 A
后来变成 B
最后又变回 A

观察者只看当前值,会以为“还是 A,没有变化”,但它已经不是原来的那一轮 A。


🧠 不要只标识资源,还要标识这一轮操作

旧释放任务的问题在于,它只记住了资源:

seat_id = seat-42

但真正应该记住的是某一次预占:

seat_id = seat-42
reservation_id = reservation-A

用户 B 预占后,当前状态会变成:

seat_id = seat-42
reservation_id = reservation-B

旧任务执行时,不只检查座位是否被预占,还检查当前 reservation_id 是否仍然等于 reservation-A

旧任务目标:reservation-A
当前预占:  reservation-B
结果:不匹配,旧任务直接退出

这类标识有很多名字:

名称常见场景
operation_idAPI 幂等与重复请求
attempt_id支付、上传、任务重试
reservation_id库存、座位、额度预占
generation / version同一资源的多轮生命周期
fencing token分布式锁、租约与旧 Worker 隔离

它们的共同目的都是:不要只问“是不是同一个资源”,还要问“是不是同一轮操作”。


🏛️ Fencing token:给每一任持有者发递增号码

分布式锁还有一个经典问题:旧 Worker 可能暂停太久,租约已经过期,却不知道自己失去了锁。

Worker A 获得租约
→ A 因 GC 或网络问题暂停
→ 租约过期
→ Worker B 获得新租约并完成写入
→ A 恢复,继续拿旧结果写入

仅靠“锁已经过期”无法阻止恢复后的 A。因为 A 可能早已通过了本地检查。

Fencing token 会给每次新租约一个严格递增的号码:

Worker A:token = 41
Worker B:token = 42

存储层记住自己见过的最大 token。B 使用 42 写入后,A 再携带 41 写入时会被拒绝。

收到 token=42 → 接受
后来收到 token=41 → 这是旧持有者,拒绝

锁解决“现在谁能进入”,fencing token 解决“过去的持有者还能不能回来”。


⚙️ 定时任务也是消息,不是时间魔法

很多系统会把 timeout 理解成一个准时触发的本地闹钟。实际上,在分布式系统中,它通常是一条延迟消息:

创建预占
→ 写入一条未来可消费的任务
→ Worker 到期后领取任务
→ 读取业务状态
→ 决定是否执行释放

这条任务同样可能:

  • 延迟执行;
  • 重复执行;
  • 执行时资源已经进入下一轮生命周期;
  • 与用户操作同时竞争。

因此,可靠 timeout 通常需要同时满足:

  1. 携带目标操作的唯一身份;
  2. 执行前读取最新业务状态;
  3. 在锁或事务内再次核对身份;
  4. 已失效时幂等退出;
  5. 抢锁失败时由服务端持久化重试,而不是悄悄丢失。

取消定时任务只是优化,不能作为唯一正确性保障。因为取消请求本身也可能失败,任务也可能已经被 Worker 领取。

真正的安全线仍然是:旧任务即使执行,也必须发现自己已经过期。


🔍 “整个对象的版本”也可能太严格

既然版本能区分新旧,是否直接要求 callback 携带的订单版本等于当前版本就够了?

不一定。

用户等待支付期间,可能只是修改了收货备注:

订单 version=10:待支付,备注为空
订单 version=11:待支付,备注改为“放在门口”
支付 callback 携带 version=10

如果要求整个订单版本完全一致,这条仍然有效的支付 callback 会被错误拒绝。

问题在于,订单版本包含了太多互不相关的变化。支付 callback 真正关心的是:

当前是否仍在等待同一个 payment_attempt_id?

一个好的授权身份应该满足:

  • 相关业务被取消或替换时,它会变化;
  • 无关字段更新时,它保持不变;
  • 不会轻易被下一轮业务重复使用。

这就是为什么“检查最新状态”并不等于“要求所有字段一模一样”。我们要比较的是最小、准确的业务身份。


⚠️ 安全性与可用性是两个不同问题

假设 callback 与另一个操作同时抢锁,callback 没抢到,服务返回 409 Conflict

这通常保证了安全性:它没有在错误状态下继续写入。但业务是否最终完成,还取决于重试机制。

请求来源常见可靠性做法
外部 callback调用方按约定重试,或入口先持久化再异步处理
消息队列消费失败不 ack,稍后重新投递
内部 timeout重新放回延迟队列并增加退避时间
用户请求返回冲突,由客户端刷新状态后重试

因此,系统设计需要分别回答:

  1. Safety: 会不会执行错误的动作?
  2. Liveness: 正确的动作最终会不会完成?

锁、状态核对、fencing token 主要保护 Safety;持久队列、重试、退避和死信处理主要保障 Liveness。

只解决其中一半,系统仍然不完整。


📝 一套实用的设计检查表

遇到 callback、Webhook、异步任务或 timeout 时,可以依次问:

消息身份

  • 每一次业务动作是否有唯一 ID?
  • 重试是否沿用同一个 ID?
  • 下一轮业务是否会生成新 ID?

当前状态

  • 消息到达时是否读取最新状态?
  • 哪个字段真正代表“这项操作仍然有效”?
  • 无关更新是否会错误地让有效消息失效?

并发控制

  • 检查与修改是否位于同一事务或锁内?
  • 拿锁后是否重新读取了状态?
  • 多实例环境下,旧锁持有者是否需要 fencing token?

重试与幂等

  • 重复执行会不会产生第二次副作用?
  • 抢锁失败后由谁负责重试?
  • 重试记录是否持久化,还是只存在进程内存?

过期任务

  • 旧 timeout 即使无法取消,执行时能否自行识别过期?
  • 资源进入下一轮生命周期后,旧任务会不会误伤新状态?

📝 总结

异步系统里最重要的事实有三个:

消息可能迟到或重复
状态可能在消息到达前已经变化
相同的表面状态可能属于不同业务轮次

对应的解决工具也各有分工:

工具主要解决什么
幂等键同一个请求被重复执行
最新状态核对消息已经不再适用于当前业务
锁内重读检查后、执行前状态发生变化
Operation ID / Generation相同资源进入了新一轮生命周期
Fencing token旧 Worker 或旧锁持有者恢复执行
持久化重试正确操作因暂时冲突而永久丢失

锁不会自动识别旧消息,版本也不应该粗暴地拒绝所有状态变化。真正稳健的设计,是找到那份最小而准确的业务身份,并在持有并发控制权后,用最新状态再次验证它。

当你开始把 callback 和 timeout 都看成“可能迟到、可能重复的普通消息”,很多看似诡异的后端 bug,就会变成一组可以系统分析的问题。