跨服务一致性:Saga、Outbox 与 TCC 的取舍

一个业务动作跨三个服务,本地事务就失效了:ACID 管不到别人的库。这时能选的不是「要不要一致性」,而是把一致性降到什么程度、由谁承担不一致窗口。Saga、Outbox、TCC 是三把用途不同的刀,混着用就会既复杂又不可靠。

一、先分清三者各自解决的问题

方案真正解决的问题一致性强度实现复杂度对业务侵入失败处理
Outbox(发件箱)/ 本地消息表本地事务与消息发送的原子性:避免「业务写成功但事件没发」最终一致(只保证可靠传递)低到中:一张表 + 一个中继低:写侧多写一张表中继重试,消费端幂等去重
Saga长事务的分步提交与反向补偿最终一致,有可见中间态中到高:状态机 + 补偿逻辑高:每个参与方都要提供正向与补偿接口补偿重试,补偿失败转人工
TCC(Try-Confirm-Cancel)用资源预留换取短窗口内的一致性接近强一致(业务级)高:三套接口 + 事务协调高:业务必须能表达「预留」语义空回滚、悬挂、幂等三件套

一句话定位:Outbox 是传递层方案,Saga 是流程层方案,TCC 是资源层方案。它们不是竞争关系——Saga 的每一步通常靠 Outbox 保证事件不丢,TCC 的 Try 也需要可靠通知协调者。

各自的前提比实现更重要:

  • Outbox 的前提是有一个可靠消息中间件,且消费端可幂等。
  • Saga 的前提是每个正向操作都存在业务上说得通的补偿动作。
  • TCC 的前提是资源可以「预占后释放」,且预留期间业务能接受这部分资源被锁定。

前提不成立时方案直接出局,这不是「实现得不够努力」。

二、Saga:编排还是协同

维度编排式(Orchestration)协同式(Choreography)
控制点一个编排者(Saga 状态机)显式驱动步骤各参与方订阅事件自行推进
流程可见性强:状态机就是流程文档,可查进度弱:流程散落在各服务的订阅关系里
耦合参与方只依赖编排者的接口契约参与方依赖彼此的事件契约,链路隐式耦合
加一步/改顺序改状态机,参与方不动要同时改多个服务的发布与订阅
适用步骤较多(4 步以上)、有分支或人工介入步骤少(2~3 步)、参与方天然事件驱动
          OrderSaga(编排者,状态持久化)
            │
   1 预留库存 ├──────────► InventoryService ──► 成功
            │
   2 扣款    ├──────────► PaymentService   ──► 成功
            │
   3 核销券  ├──────────► CouponService    ──► 失败
            │
   反向补偿  ├── 退款 ───► PaymentService
            ├── 释放库存 ► InventoryService
            └── 补偿仍失败则标记「待人工介入」

Saga 的架构代价必须写进设计文档,而不是靠实现者临场发挥:

  • 没有隔离性。中间态对外可见:库存已预留但还没扣款。要么把它显式建模成状态字段(RESERVED 而不是直接减库存),要么用语义锁(semantic lock)挡住别人的修改,要么接受它。
  • 补偿不是回滚。补偿是新的正向操作,会留下痕迹:退款流水、库存变动记录,财务对账要预留这些痕迹的位置。
  • 补偿必须幂等,且补偿失败要有终态处理(重试上限 + 人工队列),不能无限重试。
  • 编排者是有状态服务,它的持久化、超时判定与崩溃恢复能力决定整条流程的可靠性。

一句话对照:middleware-interview 的分布式事务章节回答「有哪些方案」,这里回答「选定方案后,职责与补偿边界怎么分」。

三、Outbox:把「写库」和「发消息」变成一个原子动作

-- 业务写入与事件写入在同一个本地事务中提交
BEGIN;
UPDATE inventory SET qty = qty - 1 WHERE sku = 'S1' AND qty >= 1;
INSERT INTO outbox (id, aggregate_id, type, payload, status, created_at)
VALUES ('evt_01H8...', 'S1', 'StockReserved', '{"sku":"S1","qty":1}', 'NEW', NOW());
COMMIT;
业务写入 ──同一事务──► outbox 表 ──中继轮询 / binlog 订阅──► MQ ──► 消费方
                         │                                      │
                    status = NEW                        幂等消费(去重表 / 版本号)

架构上要定清楚四件事:

  1. 谁做中继:应用内轮询(简单,但要防多实例重复发送,用行锁或 SKIP LOCKED 抢占)、独立发送服务(职责单一,推荐)、binlog 订阅(不改业务代码,但依赖 DBA 与复制拓扑)。
  2. 顺序:按聚合 ID 固定分区,才能保证同一聚合的事件有序;跨聚合不保证,业务侧不能依赖(分区保序与幂等消费的工程细节见第 12 章)。
  3. 清理:outbox 会持续增长,按状态与时间归档,别让它变成库里最大的表。
  4. 投递语义是「至少一次」:消费端幂等是硬要求,不是优化项。

Outbox 只解决传递可靠,不解决业务是否该撤。需要回退时,Outbox 之上还要叠 Saga。

四、TCC:强一致的代价主要在业务改造

TCC 把锁的粒度从数据库行移到业务资源上,一致性窗口通常只有毫秒级,因此适合库存扣减、余额冻结、优惠券占用这类能定义预留语义的场景。架构上先看这几个判断点:

  • 预留语义是否存在:如果业务只能说「已经扣了」,没有「先占着」这个中间状态,TCC 落不了地。
  • 改造范围:每个参与方要额外提供 Confirm 与 Cancel,接口数量翻三倍,还要考虑老版本调用方的兼容。
  • 锁的时长:预留期间资源不能被别的业务使用。Try 的成功率必须与并发模型一起设计,否则会出现「库存明明有、却全是预占」。
  • 经典三坑(一句话版):空回滚——Cancel 先于 Try 到达,要有事务控制记录判定「Try 没来过」;悬挂——Try 在 Cancel 之后才到达,必须识别并拒绝;幂等——三个接口都可能被重复调用。

TCC 适合**单笔金额大、不允许中间态可见、参与方少(2~3 个)**的场景;参与方一多,Try 的协调成本会指数级上升。

五、补偿的幂等与「不可补偿」操作

补偿逻辑写不对,一致性方案只是把错误推迟暴露。三条硬规则:

  1. 正向操作必须幂等。重试、超时重发、消息重复投递都会让正向操作执行多次;正向不幂等,补偿的语义无从定义。
  2. 补偿必须幂等且可重复执行。用业务唯一键加状态机守卫(只有 PAID 才能退成 REFUNDED),而不是无条件更新。
  3. 补偿失败要有终态。重试 N 次仍失败就落到「待人工处理」队列并告警,让人接手,不要用无限重试假装系统自愈。
// 补偿入口的统一形状:先查状态,再判断是否真的需要执行
public void cancel(String bizId) {
    Order o = repo.find(bizId);
    if (o == null || o.state == CANCELLED) return;            // 空回滚:Try 没来过
    if (o.state != RESERVED) { alert("unexpected: " + o.state); return; } // 悬挂 / 乱序
    payment.refund(o);                                        // 退款单号唯一,自身幂等
    o.state = CANCELLED;                                      // 状态守卫 + 落库同事务
    repo.save(o);
}

不可补偿操作最容易埋雷:已发出的短信、已出库的实物、已提现的资金都撤不回来。架构上有四种处理方式:

  • 挪到最后一跳:所有不可逆动作放在流程末尾,前面全部做成可逆的预留。
  • 改造成可补偿:把「发货」拆成「创建发货单 → 出库」,出库前仍可取消。
  • 显式转人工:补偿链路上遇到不可逆节点,直接抛给客服系统并附完整上下文。
  • 用对账兜底:以外部渠道(支付、物流)的对账文件为准修正本地状态,这是最后一道防线,且必须有人订阅对账差集。

选型顺序建议:能在单库内用本地事务解决的,绝不跨服务;必须跨服务时,先问业务能否接受最终一致——能,就用 Outbox 加事件(必要时叠 Saga);不能接受中间态可见、且参与方少,才考虑 TCC。

小结:Outbox 保事件不丢,Saga 保流程能退回,TCC 保一致性窗口够短,三者常组合使用。选择的依据是业务前提——有没有可用的补偿动作、能不能表达预留语义、能不能接受中间态。把「一致性归属在哪个服务、补偿失败谁接手、不可逆动作放在哪一跳」写进架构决策,比选哪个框架重要得多。

笔记加载中…