为什么旧回调会伤害新状态:从重复消息到 ABA 问题
看懂延迟回调、过期定时任务、竞态条件与 fencing token
分布式系统最危险的假设,不是“请求会失败”,而是“收到的请求一定属于当前这轮业务”。
🎯 一个经典场景:支付回调撞上订单取消
假设用户创建了一笔订单,系统正在等待支付平台的异步回调:
订单创建
↓
等待支付
↓
支付平台 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_id | API 幂等与重复请求 |
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 通常需要同时满足:
- 携带目标操作的唯一身份;
- 执行前读取最新业务状态;
- 在锁或事务内再次核对身份;
- 已失效时幂等退出;
- 抢锁失败时由服务端持久化重试,而不是悄悄丢失。
取消定时任务只是优化,不能作为唯一正确性保障。因为取消请求本身也可能失败,任务也可能已经被 Worker 领取。
真正的安全线仍然是:旧任务即使执行,也必须发现自己已经过期。
🔍 “整个对象的版本”也可能太严格
既然版本能区分新旧,是否直接要求 callback 携带的订单版本等于当前版本就够了?
不一定。
用户等待支付期间,可能只是修改了收货备注:
订单 version=10:待支付,备注为空
订单 version=11:待支付,备注改为“放在门口”
支付 callback 携带 version=10
如果要求整个订单版本完全一致,这条仍然有效的支付 callback 会被错误拒绝。
问题在于,订单版本包含了太多互不相关的变化。支付 callback 真正关心的是:
当前是否仍在等待同一个 payment_attempt_id?
一个好的授权身份应该满足:
- 相关业务被取消或替换时,它会变化;
- 无关字段更新时,它保持不变;
- 不会轻易被下一轮业务重复使用。
这就是为什么“检查最新状态”并不等于“要求所有字段一模一样”。我们要比较的是最小、准确的业务身份。
⚠️ 安全性与可用性是两个不同问题
假设 callback 与另一个操作同时抢锁,callback 没抢到,服务返回 409 Conflict。
这通常保证了安全性:它没有在错误状态下继续写入。但业务是否最终完成,还取决于重试机制。
| 请求来源 | 常见可靠性做法 |
|---|---|
| 外部 callback | 调用方按约定重试,或入口先持久化再异步处理 |
| 消息队列 | 消费失败不 ack,稍后重新投递 |
| 内部 timeout | 重新放回延迟队列并增加退避时间 |
| 用户请求 | 返回冲突,由客户端刷新状态后重试 |
因此,系统设计需要分别回答:
- Safety: 会不会执行错误的动作?
- Liveness: 正确的动作最终会不会完成?
锁、状态核对、fencing token 主要保护 Safety;持久队列、重试、退避和死信处理主要保障 Liveness。
只解决其中一半,系统仍然不完整。
📝 一套实用的设计检查表
遇到 callback、Webhook、异步任务或 timeout 时,可以依次问:
消息身份
- 每一次业务动作是否有唯一 ID?
- 重试是否沿用同一个 ID?
- 下一轮业务是否会生成新 ID?
当前状态
- 消息到达时是否读取最新状态?
- 哪个字段真正代表“这项操作仍然有效”?
- 无关更新是否会错误地让有效消息失效?
并发控制
- 检查与修改是否位于同一事务或锁内?
- 拿锁后是否重新读取了状态?
- 多实例环境下,旧锁持有者是否需要 fencing token?
重试与幂等
- 重复执行会不会产生第二次副作用?
- 抢锁失败后由谁负责重试?
- 重试记录是否持久化,还是只存在进程内存?
过期任务
- 旧 timeout 即使无法取消,执行时能否自行识别过期?
- 资源进入下一轮生命周期后,旧任务会不会误伤新状态?
📝 总结
异步系统里最重要的事实有三个:
消息可能迟到或重复
状态可能在消息到达前已经变化
相同的表面状态可能属于不同业务轮次
对应的解决工具也各有分工:
| 工具 | 主要解决什么 |
|---|---|
| 幂等键 | 同一个请求被重复执行 |
| 最新状态核对 | 消息已经不再适用于当前业务 |
| 锁内重读 | 检查后、执行前状态发生变化 |
| Operation ID / Generation | 相同资源进入了新一轮生命周期 |
| Fencing token | 旧 Worker 或旧锁持有者恢复执行 |
| 持久化重试 | 正确操作因暂时冲突而永久丢失 |
锁不会自动识别旧消息,版本也不应该粗暴地拒绝所有状态变化。真正稳健的设计,是找到那份最小而准确的业务身份,并在持有并发控制权后,用最新状态再次验证它。
当你开始把 callback 和 timeout 都看成“可能迟到、可能重复的普通消息”,很多看似诡异的后端 bug,就会变成一组可以系统分析的问题。