跨服务一致性: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 幂等消费(去重表 / 版本号)
架构上要定清楚四件事:
- 谁做中继:应用内轮询(简单,但要防多实例重复发送,用行锁或
SKIP LOCKED抢占)、独立发送服务(职责单一,推荐)、binlog 订阅(不改业务代码,但依赖 DBA 与复制拓扑)。 - 顺序:按聚合 ID 固定分区,才能保证同一聚合的事件有序;跨聚合不保证,业务侧不能依赖(分区保序与幂等消费的工程细节见第 12 章)。
- 清理:outbox 会持续增长,按状态与时间归档,别让它变成库里最大的表。
- 投递语义是「至少一次」:消费端幂等是硬要求,不是优化项。
Outbox 只解决传递可靠,不解决业务是否该撤。需要回退时,Outbox 之上还要叠 Saga。
四、TCC:强一致的代价主要在业务改造
TCC 把锁的粒度从数据库行移到业务资源上,一致性窗口通常只有毫秒级,因此适合库存扣减、余额冻结、优惠券占用这类能定义预留语义的场景。架构上先看这几个判断点:
- 预留语义是否存在:如果业务只能说「已经扣了」,没有「先占着」这个中间状态,TCC 落不了地。
- 改造范围:每个参与方要额外提供 Confirm 与 Cancel,接口数量翻三倍,还要考虑老版本调用方的兼容。
- 锁的时长:预留期间资源不能被别的业务使用。Try 的成功率必须与并发模型一起设计,否则会出现「库存明明有、却全是预占」。
- 经典三坑(一句话版):空回滚——Cancel 先于 Try 到达,要有事务控制记录判定「Try 没来过」;悬挂——Try 在 Cancel 之后才到达,必须识别并拒绝;幂等——三个接口都可能被重复调用。
TCC 适合**单笔金额大、不允许中间态可见、参与方少(2~3 个)**的场景;参与方一多,Try 的协调成本会指数级上升。
五、补偿的幂等与「不可补偿」操作
补偿逻辑写不对,一致性方案只是把错误推迟暴露。三条硬规则:
- 正向操作必须幂等。重试、超时重发、消息重复投递都会让正向操作执行多次;正向不幂等,补偿的语义无从定义。
- 补偿必须幂等且可重复执行。用业务唯一键加状态机守卫(只有
PAID才能退成REFUNDED),而不是无条件更新。 - 补偿失败要有终态。重试 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 保一致性窗口够短,三者常组合使用。选择的依据是业务前提——有没有可用的补偿动作、能不能表达预留语义、能不能接受中间态。把「一致性归属在哪个服务、补偿失败谁接手、不可逆动作放在哪一跳」写进架构决策,比选哪个框架重要得多。