跨服务的数据一致性是分布式架构最难啃的骨头。数据库事务能保证单机原子性,却管不了分布在多个服务、多个数据库里的数据。SAGA 把"一个大事务"拆成"一串本地事务 + 补偿动作",用最终一致性换取可用性与吞吐。本文覆盖两种实现、补偿设计、可靠性保障、与 2PC 的对比,以及失败恢复的完整链路。
1. 为什么需要 SAGA
1.1 分布式事务的困境
下单流程横跨订单、库存、账户三个服务、三个数据库。任何一个本地事务都保证自身原子性,但没有一个全局事务能保证三者要么全成、要么全败。
1.2 强一致协议的代价
要让三者原子提交,可选 2PC:准备阶段锁定全部资源、提交阶段统一裁决。代价是同步阻塞、单点协调、长时间持锁——在跨服务、高并发场景下吞吐和可用性都不可接受。
1.3 SAGA 的思路
SAGA 放弃"瞬时全成",接受"每个本地事务都成功,失败则用补偿回滚已完成的动作"——保证最终一致,且全程无全局锁。
一次下单 = 多个本地事务串联:
创建订单 → 扣减库存 → 扣减余额 → 发送短信
若"扣减余额"失败:
倒序执行补偿:
恢复余额 ← 恢复库存 ← 取消订单
| 特性 | 数据库事务 | 2PC | SAGA |
|---|---|---|---|
| 原子性 | 瞬时 | 瞬时(有风险) | 最终一致 |
| 全局锁 | 无(单库) | 有(阻塞) | 无 |
| 跨服务 | 不支持 | 支持 | 支持 |
| 吞吐 | 高 | 低 | 高 |
| 一致性窗口 | 无 | 极短 | 明显存在 |
一句话:SAGA 用**“补偿代替回滚”**换掉全局锁——业务明确接受一段时间的中间状态,换取跨服务的可用性与吞吐。
2. SAGA 的核心概念
2.1 本地事务与全局事务
- 本地事务:单个服务内、单个数据库内的 ACID 事务;
- 全局事务(SAGA):一系列有序执行的本地事务,由流程定义连接。
2.2 补偿动作 Compensating Action
补偿是 SAGA 的灵魂:每个正向动作都对应一个语义相反、能抵消其效果的动作。
| 正向动作 | 补偿动作 | 补偿前提 |
|---|---|---|
| 创建订单 | 取消订单(改状态) | 订单未发货 |
| 扣减库存 | 加回库存 | 库存预占未出库 |
| 扣减余额 | 加回余额 | 资金未清分 |
| 核销优惠券 | 恢复优惠券 | 券未使用 |
补偿不是"回滚数据库",而是执行一个新的正向业务操作来抵消效果——这是它与 2PC 回滚的本质区别。
2.3 正向与补偿的边界
- 已完成的动作必须能补偿;
- 补偿动作本身要幂等(重复执行结果一致);
- 补偿失败要有人工介入或重试通道。
一句话:SAGA 的可信度 = 每个正向动作都有可抵消的补偿 + 补偿本身幂等——缺一不可。
3. Choreography 实现事件驱动编排
3.1 原理
没有中央协调器,每个服务在本地事务提交后发布事件,监听相关事件的服务执行下一步并发布后续事件。
订单服务 ──[订单已创建]──→ 库存服务
库存服务 ──[库存已扣减]──→ 账户服务
账户服务 ──[余额已扣减]──→ 通知服务
账户服务 ──[余额扣减失败]──→ 订单服务(触发补偿链)
3.2 实现示例
// 订单服务监听失败事件,启动补偿
@EventListener
public void onBalanceDeductionFailed(BalanceDeductionFailedEvent e) {
compensationService.cancelOrder(e.getOrderId()); // 取消订单
compensationService.restoreStock(e.getOrderId()); // 恢复库存
}
3.3 优劣
| 优点 | 缺点 |
|---|---|
| 无单点、实现简单 | 流程分散在多个服务,难追踪 |
| 新增环节只加订阅者 | 事件环风险(A→B→A 循环) |
| 天然并发 | 一致性窗口更大 |
| 与事件驱动架构契合 | 补偿逻辑难集中审计 |
一句话:Choreography 适合环节少、事件语义清晰、团队能达成事件契约的流程;环节一多,追踪与排障成本会指数上升。
4. Orchestration 实现中央协调器
4.1 原理
引入协调器(Orchestrator/Saga Execution Coordinator),它显式定义每一步、调用各服务、记录状态,并在失败时决定补偿。
Saga 协调器
│ 1. 创建订单(调订单服务)
│ 2. 扣减库存(调库存服务)
│ 3. 扣减余额(调账户服务) ← 失败
│ 4. 补偿:恢复库存(调库存服务)
│ 5. 补偿:取消订单(调订单服务)
▼
状态表:每步记录 SUCCESS/FAILED
4.2 协调器的状态机
协调器维护一个 SAGA 状态机,持久化每一步结果:
状态:INIT → STEP_1_OK → STEP_2_OK → COMPLETED
↘ STEP_3_FAIL → COMPENSATING → COMPENSATED
4.3 实现要点
- 协调器可基于流程引擎(Camunda、Temporal、自建状态机);
- 每一步调用要幂等,协调器按状态表驱动,不依赖内存;
- 协调器本身要高可用,状态持久化到数据库。
一句话:Orchestration 把流程、状态、补偿全部集中到一个可审计的协调器——复杂度集中了,但排查与管控也集中了,适合复杂长流程。
5. 两种实现怎么选
5.1 对比
| 维度 | Choreography | Orchestration |
|---|---|---|
| 控制权 | 分散 | 集中 |
| 流程可见性 | 靠事件日志拼 | 状态表一眼可见 |
| 补偿编排 | 分散在各服务 | 协调器统一 |
| 新增环节 | 加订阅者 | 改协调器 |
| 单点风险 | 无 | 协调器是单点(需高可用) |
| 适用 | 简单、快速、事件驱动成熟 | 复杂、长流程、强管控 |
5.2 落地建议
- 环节 ≤ 3、事件语义稳定 → Choreography;
- 环节多、有分支回滚、需审批审计 → Orchestration;
- 大多数生产系统最终选择 Orchestration(配合流程引擎),因为它把"业务流程"还原成显式代码,而非散落的事件。
5.3 协调器驱动代码示例
// Orchestration:协调器显式驱动,失败走补偿分支
public void runSaga(String orderId) {
Order o = orderService.createOrder(orderId); // 步骤1
try {
stockService.reserveStock(o); // 步骤2
accountService.deductBalance(o); // 步骤3
sagaState.markComplete(orderId); // 全部成功
} catch (BalanceDeductFailedException e) {
// 倒序补偿
compensationService.restoreStock(o);
compensationService.cancelOrder(o);
sagaState.markCompensated(orderId);
}
}
一句话:选型不是对错之争——Choreography 输在追踪,Orchestration 赢在可控,复杂流程优先集中化。
6. 补偿事务的设计
6.1 补偿设计三原则
- 语义补偿:补偿是业务动作,不是数据库回滚;
- 幂等:补偿重复执行结果相同;
- 可重试:补偿失败要有重试与死信通道。
6.2 设计示例
// 正向:扣减库存(预占)
public void reserveStock(String orderId, String sku, int qty) {
int updated = jdbc.update(
"UPDATE stock SET reserved = reserved + ? " +
"WHERE sku = ? AND available >= ?", qty, sku, qty);
if (updated == 0) throw new InsufficientStockException();
publish(new StockReserved(orderId, sku, qty));
}
// 补偿:释放库存(幂等,reserved 不能为负)
public void releaseStock(String orderId, String sku, int qty) {
jdbc.update(
"UPDATE stock SET reserved = reserved - ? " +
"WHERE sku = ? AND reserved >= ?", qty, sku, qty);
}
6.3 常见补偿模式
| 模式 | 做法 | 场景 |
|---|---|---|
| 状态机回滚 | 订单状态 CANCEL/CLOSED | 订单类 |
| 资源释放 | 库存回补、余额解冻 | 资源类 |
| 反交易 | 生成一笔退款流水 | 资金类 |
| 幂等替换 | 用幂等键覆盖旧结果 | 通用 |
一句话:补偿设计的成败在于**“能不能把已发生的效果干净地抵消”**——正向设计时就要想好补偿路径,别等失败时再发明。
7. 可靠性持久化重试与隔离性
7.1 消息持久化与本地消息表
Choreography 的事件、Orchestration 的指令都需持久化,防止进程崩溃丢消息。常用本地消息表 + 事务内写表保证"业务与消息同生共死":
本地事务:写业务数据 + 写 outbox 消息表(同一数据库事务)
后台任务:扫描 outbox → 发布消息 → 标记已发送
7.2 重试与幂等消费
消息消费要"至少一次 + 幂等":消费者用消息唯一 ID 去重,重试指数退避,重试 N 次后进死信队列人工处理。
// 消费端幂等
public void onStockReserved(StockReservedEvent e) {
if (idempotencyStore.exists(e.getEventId())) return; // 已处理
process(e);
idempotencyStore.mark(e.getEventId());
}
7.3 隔离性 Isolation 问题
SAGA 允许中间状态,会带来脏读问题:步骤 2 已提交、步骤 3 还在执行时,外部可能读到"已扣库存但未扣款"的中间态。缓解手段:
| 手段 | 说明 |
|---|---|
| 状态标记 | 订单显式标记 PENDING/IN_PROGRESS |
| 语义锁 | 冻结金额/预占库存,禁止他人动 |
| 乐观锁 | 版本号冲突即重试 |
| 读取修复 | 读到中间态时延迟或提示"处理中" |
7.4 失败恢复流程
崩溃、重启、超时都是常态,SAGA 要靠状态机驱动恢复,而不是靠人工记忆:
协调器崩溃 → 重启后读状态表
若停在 STEP_2_OK:从 STEP_3 继续正向
若停在 COMPENSATING:继续执行未完成的补偿
超时的步骤:按重试策略重试,超过阈值转人工
- 正向恢复:已成功的步骤不再重复,未完成的继续;
- 补偿恢复:补偿中断时继续补偿,直到 COMPENSATED;
- 人工通道:死信与超限步骤进入补偿台,业务人员手工兜底。
一句话:SAGA 的可靠性 = 本地消息表保证不丢 + 幂等保证不重 + 补偿保证可逆,隔离性靠语义锁与状态标记缓解而非消除;恢复则靠持久化状态机,崩溃后从状态表续跑而不是从头再来。
8. 踩坑清单
| 坑 | 现象 | 对策 |
|---|---|---|
| 补偿不是幂等 | 重复补偿双倍扣减 | 补偿唯一键 + 去重 |
| 消息丢失 | 链断裂无人继续 | 本地消息表 + outbox |
| 无持久化状态 | 协调器重启流程丢了 | 状态表落库 |
| 正向无补偿设计 | 失败后无法抵消 | 正向设计时同步设计补偿 |
| 事件环 | A→B→A 死循环 | 事件语义收敛 + 环路检测 |
| 中间态被外部读到 | 脏读订单 | 状态标记 + 语义锁 |
| 补偿顺序错 | 先恢复库存再取消订单 | 严格倒序执行 |
| 死信无人管 | 卡住的订单堆积 | 死信队列 + 人工补偿台 |
9. 总结
| 维度 | 结论 |
|---|---|
| 适用场景 | 跨服务长流程、要求最终一致 |
| 推荐实现 | 复杂流程 Orchestration,简单流程 Choreography |
| 核心能力 | 补偿 + 幂等 + 持久化 + 重试 |
| 对比 2PC | SAGA 无锁高吞吐,2PC 强一致低吞吐 |
| 隔离性 | 语义锁缓解,不追求事务级隔离 |
一句话记住:SAGA 是用"补偿"换"无锁"的分布式事务——把大事务拆成本地事务串,失败倒序补偿,靠幂等与持久化把一致性收敛到最终一致。
延伸阅读
- 分布式数据一致性 — 2PC/TCC/Saga 的完整对比
- 事件驱动架构 — Choreography 的消息基础
- 高可用架构与故障容错 — 协调器高可用设计
- CQRS 与事件溯源 — 事件驱动写入端
- 服务网格与 Istio — 跨服务通信治理
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。