实现补偿机制,核心是保证每个正向步骤都有可逆向、幂等、可重试的补偿操作,并用持久化日志与状态机记录进度。补偿失败时不能放任,要用指数退避重试、死信队列隔离、告警人工介入,再借定时对账兜底修复。下文拆解设计要点与失败处理。
一、补偿不是”反向调一遍”那么简单
Saga 把一个长事务拆成多个本地事务,某步失败就逆向执行已成功步骤的补偿。但补偿本身是一次真实的数据写操作,同样会超时、会重复、会失败。如果补偿没设计好,系统会停在”部分已撤销、部分没撤销”的中间态,比不补偿更危险。补偿失败最贵的代价不是重试次数,而是停在中间态的那段时间——资金可能已经扣了,库存却没释放,占用额度卡住后续交易。
二、设计补偿必须盯住的五个问题
| 关注点 | 正向操作 | 补偿操作的要求 |
|---|---|---|
| 幂等 | 可被重试 | 同参数重复调用结果一致 |
| 可重试 | 失败即中止 | 失败后能安全重跑 |
| 顺序 | 正向依次执行 | 严格反向撤销 |
| 隔离 | 提交后即生效 | 用语义锁避免脏读 |
| 持久化 | 内存状态 | 落库日志,崩溃可恢复 |
2.1 幂等与可重试
补偿会因为它自己失败而被反复触发,也会因消息重发被调用多次。每个补偿步骤用”全局事务 ID + 业务 ID”做去重键,执行前先查是否已补偿过,已做则直接返回成功。
2.2 补偿顺序必须反向
正向是创建订单 → 扣库存 → 扣款,补偿就该退款 → 恢复库存 → 取消订单。顺序错了会破坏数据,例如先取消订单再去退一笔根本没完成的支付。
2.3 隔离要靠语义锁
Saga 没有 ACID 的隔离性,补偿执行期间别的服务可能读到中间态数据。常用四招:语义锁(把记录标成 pending)、交换更新(设计可任意顺序执行的更新)、悲观视图(读未提交时带告警)、重读校验(提交前确认数据没被改过)。
2.4 补偿也要能补偿
理想情况下补偿是”简单可逆”的,比如退款、恢复库存。但像”已发货”这种物理动作难以回滚,应在设计阶段就改成可补偿的形态,或把不可逆步骤排到流程末尾。
2.5 进度必须落库
用一张事务日志表记录每一步的状态与游标,进程崩溃重启后从断点续跑,而不是从头再来一遍。
三、一个带重试与死信的补偿器
def compensate(saga):
saga.state = "COMPENSATING"
save(saga)
for step in reversed(done_steps(saga)):
for attempt in range(1, 6): # 指数退避重试
try:
if not step.compensated:
step.compensate
step.compensated = True
save(saga)
break
except Exception as e:
if attempt == 5:
send_to_dead_letter(saga, step, e) # 转入死信,等人工
saga.state = "FAILED"
alert(f"补偿失败 saga={saga.id} step={step.name}")
sleep(2 ** attempt)
四、补偿失败了怎么办
补偿失败分两类,处理方式不同:
- 瞬时故障(网络抖动、锁等待):用指数退避自动重试,多数能自己恢复;
- 永久故障(数据已不一致、下游宕机):停止重试,把该事务丢进死信队列,触发告警让人工介入,同时冻结相关资源避免扩大损失。
定时对账是兜底防线:扫描长时间停在 COMPENSATING / FAILED 的事务,比对各服务真实状态,自动补跑补偿或人工修复。对账不依赖补偿链路的运气,是真正的兜底。死信队列里的记录要带足上下文(saga_id、失败步骤、异常堆栈),运维接单后能直接定位,而不是重新翻全量日志。
五、补偿与正向的隔离冲突
Saga 没有 ACID 隔离,补偿执行期间别的服务可能在读中间态数据。除语义锁外,还要把订单状态机锁在 COMPENSATING,禁止对该订单发起新的正向操作,避免正向写入与补偿并发打架,制造更难排查的脏数据。
六、用对账 SQL 兜底
定时任务扫出卡在中间态的事务,交给对账处理器补跑或转人工。
-- 找出卡在中间态超过 10 分钟的事务
SELECT saga_id, state, step_index, updated_at
FROM saga_order_tx
WHERE state NOT IN ('DONE', 'FAILED')
AND updated_at < NOW - INTERVAL 10 MINUTE;
七、生产检查清单
- 每个正向步骤都有对应补偿,且补偿幂等、可重试;
- 补偿顺序严格反向,参数能从持久化日志恢复;
- 补偿失败进死信并告警,对账任务每日跑。
八、隔离四招再展开
语义锁把记录标成 pending,别的服务读到就跳过或等待;交换更新设计成可任意顺序执行的操作,顺序错了也能收敛;悲观视图读未提交数据时带告警;重读校验在提交前确认数据没被别人改过。四招按业务代价选,不必全用。
常见问题(FAQ)
Q1:补偿和正向都要幂等吗?
都要。补偿会被重试和重发,靠事务 ID 去重防重复。
Q2:补偿一直失败会怎样?
转死信队列并告警,人工介入,对账任务兜底修复。
Q3:不可逆操作怎么补偿?
尽量改成可补偿形态,或放到流程末尾,失败时人工处理。