bigywater.tech
分布式事务 · 9

Saga 的补偿:补偿失败怎么办

Saga 靠补偿代替回滚。补偿本身失败了怎么办?怎么重试?补偿能把世界恢复原样吗?永远失败的补偿放哪?不可补偿的动作该排在哪一步?这篇用订单、库存、支付三个服务,把这五个问题逐个跑出来。

9 / 400
补偿失败被吞掉时,400 单里留下“已取消却仍占库存”的订单数
21 对 7
服务挂 2 秒时,补偿重试的调用次数:固定间隔 100 ms 对指数退避
81 / 0
300 个 saga 里,通知放在最前面与放在支付成功之后,“下单成功”通知发出却被取消的订单数

补偿失败别吞掉

实测 · 本机执行输出
  补偿 stock_release 失败:Transient stock_release 注入的故障,编排器记了一行日志就往下走
  结果: CANCELLED 订单 {'o-1': 'CANCELLED'},库存 99(预留中 1),余额 50(已扣款 0 笔)
  对账:订单已经 CANCELLED 但库存预留还是 RESERVED 的有 1 笔 ['o-1'] —— 这件商品被一个已取消的订单占着
  放大到 400 单:20% 支付失败,每次释放库存有 10% 概率遇到临时错误:
  400 单里支付失败 94 单,其中补偿失败 9 单;对账出来“已取消但仍占库存”的 9 单(期望值约 8)

补偿遇到临时错误,朴素写法只记日志就继续,订单取消了、预留还在。必须把补偿当成一个需要保证完成的动作。

重试与幂等

实测 · 本机执行输出
  前 3 次调用失败,指数退避(50 ms 起,每次翻倍):
    第 1 次:临时错误,50.0 ms 后重试,距开始 0.0 ms
    第 2 次:临时错误,100.0 ms 后重试,距开始 55.1 ms
    第 3 次:临时错误,200.0 ms 后重试,距开始 157.5 ms
    第 4 次:成功(RELEASED),距开始 363.0 ms
  结果: 订单 {'o-r': 'PENDING'},库存 100(预留中 0),余额 1000000(已扣款 0 笔)
  服务整体挂 2 秒后恢复,对比两种重试策略(从补偿开始算起):固定间隔 100 ms vs 指数退避 50 ms 起翻倍、上限 1 s
  重试策略          | 服务挂 2 秒期间共调用次数 | 服务恢复后多久补偿成功
  --------------+----------------+------------
  固定间隔 100 ms   | 21             | 82 ms      
  指数退避 50 ms 翻倍 | 7              | 576 ms     
  固定间隔重试在这 2 秒里调用了 21 次,指数退避只调用了 7 次;代价是服务恢复之后,指数退避最多要多等一个退避间隔才发现
  补偿重试意味着同一个补偿可能被执行多次(比如第一次其实成功了但响应丢了),所以补偿要幂等。对比:
  带状态检查的释放(RESERVED → RELEASED,只改一次)调用 3 次后: 库存 100(预留中 0)
  不检查状态、直接 qty = qty + 1 的释放调用 3 次后: 库存 102(预留中 1) (预留了 1 件,释放了 3 次,库存比初始多了 2)

补偿用指数退避重试到成功;服务恢复后可能多等一个退避间隔,但不会把恢复中的服务打满。重试意味着补偿会被多次执行,所以要幂等。

补偿不等于回滚

实测 · 本机执行输出
  被动过的东西   | Saga 之前 | 补偿之后 | 差别               
  ---------+---------+------+------------------
  库存       | 100     | 100  | 相同               
  用户余额     | 10000   | 9999 | 少了 1(手续费不退)      
  已发出的短信条数 | 0       | 2    | 多了 2 条(发出去的撤不回来) 
  支付记录行数   | 0       | 1    | 留下一行 REFUNDED(审计)
  扣款后余额 9919,退款后 9999;补偿把能撤的撤回来了(库存),撤不回来的(手续费、已发出的短信)只能用新的动作去抵消或解释

手续费不退、短信发出就撤不回,补偿只能用新的动作去抵消,留下审计记录。Saga 之后的世界和之前不完全相同。

永久失败进人工队列

实测 · 本机执行输出
  300 个 saga(25% 支付失败;补偿遇到 15% 的临时错误、4% 的永久失败):
  saga 最终状态           | 个数  | 补偿尝试次数合计
  --------------------+-----+---------
  CANCELLED           | 65  | 83      
  COMPENSATION_FAILED | 3   | 3       
  CONFIRMED           | 232 | 0       
  人工处理队列(manual_queue)里的内容:
  saga  | 步骤            | 错误                       | 尝试次数
  ------+---------------+--------------------------+-----
  s-51  | stock_release | 永久失败:stock_release 注入的故障 | 1   
  s-89  | stock_release | 永久失败:stock_release 注入的故障 | 1   
  s-146 | stock_release | 永久失败:stock_release 注入的故障 | 1   
  manual_queue 共 3 行;对账:订单停在 CANCELLING 且库存仍被占着的 3 笔 —— 和队列行数一致,这部分要靠人(或专门的修复任务)收尾,而不是让 saga 无限重试

永远不会成功的补偿(账户注销等)重试没有意义,要判定为永久失败,放进人工处理队列,并让对账能找到它们。

不可补偿的步骤放后面

实测 · 本机执行输出
  步骤顺序           | saga 数 | 最终 CONFIRMED | 发了“下单成功”却被取消的订单 | CONFIRMED 却没收到通知 | 通知平均尝试次数
  ---------------+--------+--------------+-----------------+------------------+---------
  A:通知 → 库存 → 支付 | 300    | 219          | 81              | 0                | 1.42    
  B:库存 → 支付 → 通知 | 300    | 219          | 0               | 0                | 1.42    
  A 里通知一旦发出就撤不回来,后面支付失败时只能认了;B 把不可补偿的步骤放在不会失败的位置之后(支付成功是“转折点”),它自己失败就一直重试,不再回头补偿别的步骤

把发通知这类不可补偿的动作放在转折点(支付成功)之后,它失败只需向前重试,不会出现“通知发了但订单取消”。

分步演示

Saga 的补偿
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / MySQL 26.7.0 与 PostgreSQL 16.15(Homebrew,每个实验自己 initdb、自己起实例,端口 9150 以上,只监听 127.0.0.1)/ Python 3.14.8 + PyMySQL 1.2.3 + psycopg 3.3.6 / Redis 8.10.2 / Kafka 4.3.1 / RabbitMQ 4.3.6 / etcd 3.7.2(按各篇注明用到哪些);不连任何外部服务,不碰本机已有的数据库实例;故障用 kill -9 进程、让连接断开或在代码里的指定位置抛异常来制造;计时用 time.perf_counter,数字每次运行会有小幅抖动。本篇三个服务的库是同一个 MySQL 26.7.0 实例里的 schema,编排器自己的 saga_log、manual_queue 在 saga_db;临时错误与永久失败是用 shop.inject() 在服务调用里按次数或概率注入的(固定随机种子);“服务挂 2 秒”是让服务在 2 秒内抛临时错误;重试间隔用的是真实 sleep
    • 没有验证:编排器自己崩溃后,从 saga_log 恢复并继续补偿(第 30 篇再做);补偿的并行执行与顺序依赖;真实支付渠道的退款规则;重试风暴下游被打满的真实表现(这里只数了调用次数);各概率(15% 临时、4% 永久、25% 支付失败)是我设的,不是真实业务统计
    京ICP备2026067502号-1京公网安备11010502063487号