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 把不可补偿的步骤放在不会失败的位置之后(支付成功是“转折点”),它自己失败就一直重试,不再回头补偿别的步骤
把发通知这类不可补偿的动作放在转折点(支付成功)之后,它失败只需向前重试,不会出现“通知发了但订单取消”。