引言
微服务拆分之后,一个业务动作往往要跨多个服务:下单要扣库存、扣余额、创建订单、发通知。这些操作分属不同的数据库,本地事务帮不上忙。传统答案两阶段提交(2PC)在分布式环境下代价太高:协调者单点、参与者阻塞、锁持有时间长,几乎没人敢在互联网业务里用它。
Saga 是更现实的答案:把一个大事务拆成 N 个本地事务,每个本地事务都配一个「补偿动作」;任何一步失败,就按逆序执行前面已成功步骤的补偿动作。它放弃原子性(中间状态对外可见),换取可用性与可伸缩性,属于最终一致性的范畴。
Saga 的难点不在概念,而在细节:补偿动作怎么写才算正确、补偿本身失败了怎么办、重复投递导致补偿被执行两次怎么办、补偿动作执行时数据已经被别人改了怎么办。这些问题在业务量小时不会暴露,等到日订单百万级时集中爆发。
本文先讲清楚 Saga 与 2PC、TCC 的取舍,再拆解补偿的语义与设计原则,然后给出编排式与协同式两种实现的完整代码,接着讲幂等、空回滚、悬挂这三个经典陷阱,最后讲补偿失败的处理与对账兜底。想先看整体选型框架的读者,可以从 工作流引擎全景与选型 开始。
目录
- 分布式事务的三种解法
- 2PC 为什么不适合互联网业务
- TCC 与 Saga 的差异
- 编排式与协同式 Saga
- 补偿的语义与三个约束
- 补偿动作的设计原则
- 编排式 Saga 的完整实现
- 协同式 Saga 与事件驱动
- 用 outbox 保证消息与状态一致
- 用状态机实现 Saga
- Seata 的 AT、TCC 与 Saga 模式
- 空回滚与悬挂
- 幂等与重试的边界
- 补偿失败与人工兜底
- 超时与长事务的边界
- 与工作流引擎的结合
- 监控与对账
- 落地路线图
- 权衡取舍
- 常见坑清单
- 小结
1. 分布式事务的三种解法
跨服务事务有三条主流路径,各自的适用面不同:
| 方案 | 一致性 | 隔离性 | 性能 | 侵入性 |
|---|---|---|---|---|
| 2PC / XA | 强一致 | 强(阻塞式) | 差 | 低(数据库支持) |
| TCC | 最终一致 | 靠资源预留实现 | 中 | 高(三接口) |
| Saga | 最终一致 | 无(中间态可见) | 好 | 中(两接口) |
选择的判断依据是「业务能否接受中间状态可见」。余额扣减通常不能接受「钱扣了但订单没建」,所以要用 TCC 的预留机制;而「订单已创建但优惠券未核销」这类中间态业务上可接受,用 Saga 就够了。
还有一个常被忽略的选项:把跨服务事务改成单服务事务。如果扣库存与创建订单能合并到一个服务(同一个数据库),那就不需要分布式事务。很多「分布式事务难题」的根源是服务边界划错了,而不是技术选型错了。
2. 2PC 为什么不适合互联网业务
2PC 的流程是:协调者先向所有参与者发 prepare,全部同意后再发 commit。问题有三个。
第一,阻塞。参与者 prepare 之后必须持有锁直到收到 commit 或 abort,如果协调者挂了,锁会被持有到超时。一个慢参与者会拖垮整条链路。
第二,协调者单点。协调者宕机后参与者会一直等待,虽然有恢复协议,但恢复期间业务不可用。
第三,性能。两次网络往返加两阶段锁,吞吐远低于单库事务。XA 在 MySQL 里的表现尤其差,长事务会导致 undo log 膨胀与主从延迟。
所以 2PC 的合理使用场景是「低频、短事务、参与者少」的内部操作,比如跨两个库的数据同步。高频业务链路应该用 Saga 或 TCC。
3. TCC 与 Saga 的差异
TCC(Try-Confirm-Cancel)把每个服务的能力拆成三个接口:
Try :检查并预留资源(冻结 100 元余额)
Confirm :确认使用预留资源(扣掉冻结的 100 元)
Cancel :释放预留资源(解冻 100 元)
Saga 只有两个接口:正向操作与补偿操作。两者的关键差异是「资源是否预留」:
- TCC 在 Try 阶段就把资源锁定(冻结),所以不会出现「确认时余额不够」的情况。
- Saga 在正向阶段就直接扣减,补偿阶段再退回来,中间可能出现「余额被别的请求花掉」导致补偿失败。
代价上,TCC 需要业务实现三个接口并处理「空回滚、悬挂、幂等」,改造量大;Saga 只需两个接口,但需要业务能接受中间态与补偿失败的风险。
Saga 的资金链路:
扣余额(100) -> 建订单 -> 发券 -> (失败) 退券 -> 删订单 -> 退余额(100)
TCC 的资金链路:
冻结余额(100) -> 建订单 -> 发券 -> (失败) 取消发券 -> 删订单 -> 解冻余额(100)
4. 编排式与协同式 Saga
Saga 有两种组织方式。
编排式(Orchestration):有一个中心协调者,它知道完整的流程,负责按顺序调用各服务并在失败时触发补偿。协调者可以是一个 Saga 编排器、一个工作流引擎、或者一个状态机。
协同式(Choreography):没有中心协调者,各服务通过事件互相触发。服务 A 完成后发事件,服务 B 监听并执行,失败时发失败事件,各服务监听失败事件执行自己的补偿。
| 维度 | 编排式 | 协同式 |
|---|---|---|
| 流程可见性 | 集中,一处可读 | 分散在各服务 |
| 耦合度 | 服务只依赖协调者 | 服务间事件耦合 |
| 复杂度 | 协调者复杂 | 整体复杂,难追踪 |
| 新增步骤 | 改协调者 | 改多个服务的订阅 |
| 适合步骤数 | 5 到 20 步 | 3 到 5 步 |
经验阈值:步骤少于 5 步用协同式,超过 5 步用编排式。超过 10 步几乎必须用编排式,否则「这个订单为什么卡住了」这个问题会没人能回答。
5. 补偿的语义与三个约束
补偿不是「回滚」,因为数据已经被其他事务看到甚至修改过。它的准确定义是「发布一个抵消前面正向操作效果的新操作」。这个定义带来三个约束。
第一,补偿必须幂等。消息重投、超时重试都会导致补偿被执行多次,第二次执行必须不产生额外效果。做法是用补偿动作的业务键做去重。
第二,补偿必须可交换。如果补偿 A 和补偿 B 都修改同一条记录,执行顺序不同可能得到不同结果。设计上应避免多个补偿动作操作同一份数据。
第三,补偿必须处理「状态已变」。正向操作把库存从 10 减到 8,补偿时库存可能已经被别人买到 5,此时「加回 2」而不是「设为 10」才是正确的补偿。
-- 正确:增量补偿,与并发安全
UPDATE stock SET available = available + 2 WHERE sku = ?;
-- 错误:绝对值补偿,会覆盖别人的修改
UPDATE stock SET available = 10 WHERE sku = ?;
6. 补偿动作的设计原则
四条实践原则:
- 补偿动作用增量而不是绝对值(如上)。
- 补偿动作不依赖正向操作的返回值,只依赖业务键与已知的补偿量。因为补偿可能在正向操作的响应丢失后执行,此时拿不到返回值。
- 补偿动作要考虑「正向操作其实没执行」的情况(空补偿),要能识别并直接返回成功。
- 补偿动作本身要记录状态,便于失败后重试与人工介入。
public void refund(String orderId, BigDecimal amount) {
// 1. 幂等:已退过就直接返回
if (refundRepo.existsByOrderId(orderId)) {
log.info("already refunded, skip: {}", orderId);
return;
}
// 2. 空补偿:正向扣款根本没成功
if (!paymentRepo.existsByOrderId(orderId)) {
log.info("no payment record, skip refund: {}", orderId);
refundRepo.markSkipped(orderId);
return;
}
// 3. 执行退款并记录
paymentClient.refund(orderId, amount);
refundRepo.insert(orderId, amount, "SUCCESS");
}
第 2 步的「空补偿」判断是很多人会漏掉的:如果正向扣款请求超时但实际没执行,补偿会被触发,此时不能凭空退款。
7. 编排式 Saga 的完整实现
一个最小可用的编排器:把步骤与补偿注册成有序列表,顺序执行,失败时逆序补偿。
public class SagaOrchestrator {
private final List<SagaStep> steps = new ArrayList<>();
private final int currentIndex = 0;
public SagaOrchestrator step(Runnable action, Runnable compensation) {
steps.add(new SagaStep(action, compensation));
return this;
}
public void execute() {
int completed = -1;
try {
for (int i = 0; i < steps.size(); i++) {
steps.get(i).action().run();
completed = i;
}
} catch (Exception e) {
compensate(completed);
throw new SagaFailedException(e, completed);
}
}
private void compensate(int fromIndex) {
for (int i = fromIndex; i >= 0; i--) {
try {
steps.get(i).compensation().run();
} catch (Exception ce) {
// 补偿失败不中断后续补偿,但要记录待人工处理
log.error("compensation failed at step {}", i, ce);
deadLetterRepo.save(new CompensationTask(i, ce.getMessage()));
}
}
}
}
两个关键设计:补偿失败不中断后续补偿(否则一个卡点会让整个 Saga 无法回滚);补偿失败进入死信表等待人工处理,而不是静默丢弃。
实际生产中,SagaStep 的 action 与 compensation 应该是持久化的记录(存步骤状态),这样进程崩溃后能从中断处继续补偿。
8. 协同式 Saga 与事件驱动
协同式 Saga 用事件串起各服务。以下单为例:
OrderService : 创建订单(PENDING) -> 发 OrderCreated
StockService : 监听 OrderCreated -> 扣库存 -> 发 StockLocked / StockFailed
PaymentService : 监听 StockLocked -> 扣款 -> 发 PaymentDone / PaymentFailed
OrderService : 监听 PaymentDone -> 订单置 PAID
监听 StockFailed / PaymentFailed -> 取消订单
StockService : 监听 OrderCanceled -> 恢复库存
PaymentService : 监听 OrderCanceled -> 退款
协同式的优点是服务之间没有中心依赖,每个服务只需关心自己的事件与补偿。缺点是流程逻辑分散在多个服务里,「这个订单为什么卡住」需要跨服务追踪。
这类模式的架构基础在 事件驱动架构 里有完整讨论。一个必要的纪律是「事件命名用过去式、事件只描述事实不描述命令」,否则各服务会因为「谁该做什么」的语义模糊而重复处理。
9. 用 outbox 保证消息与状态一致
Saga 的每个步骤都涉及「本地事务提交」与「发消息」两个动作,两者必须一致。直接用「先提交再发消息」会在发消息前崩溃时丢消息,用「先发消息再提交」会在提交失败时产生幽灵消息。
Outbox 模式的解法是把消息写进本地事务的一部分:
CREATE TABLE outbox (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
aggregate_id VARCHAR(64) NOT NULL,
event_type VARCHAR(64) NOT NULL,
payload JSON NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_status_created (status, created_at)
);
@Transactional
public void lockStock(String orderId, String sku, int qty) {
stockRepo.decrease(sku, qty); // 本地事务
outboxRepo.insert(orderId, "StockLocked", // 同事务写 outbox
Map.of("orderId", orderId, "sku", sku, "qty", qty));
}
一个独立的 relay 进程轮询 outbox 表把 PENDING 记录投递到消息队列,投递成功后标记为 SENT。因为是「至少一次」投递,下游必须幂等。这套模式在 Kafka 生产者 的幂等与事务配置里有更细的讨论。
10. 用状态机实现 Saga
Saga 本质上是一个状态机:每个步骤是一个状态,正向推进与补偿回退都是转移。用状态机实现的好处是「流程状态可查询」,运维能回答「这个 Saga 现在在补偿的哪一步」。
LOCKING -> LOCKED -> PAYING -> PAID -> DONE
| | | |
v v v v
FAILED -> COMPENSATING -> COMPENSATED
CREATE TABLE saga_instance (
saga_id VARCHAR(64) PRIMARY KEY,
biz_id VARCHAR(64) NOT NULL,
current_step VARCHAR(32) NOT NULL,
state VARCHAR(16) NOT NULL, -- RUNNING / COMPENSATING / DONE / FAILED
context JSON NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
把 context 存成 JSON 是刻意的:补偿时需要知道「正向操作做了什么」,而这个信息必须在正向操作时记录下来。如果补偿依赖回查业务表,会遇到「数据已被修改」的问题。状态机的完整讨论见 状态机引擎与状态流转
。
11. Seata 的 AT、TCC 与 Saga 模式
Seata 是 Java 生态里最常用的分布式事务框架,提供三种模式:
- AT 模式:基于 SQL 解析自动生成「反向 SQL」作为补偿,业务代码零侵入(只加
@GlobalTransactional)。代价是需要额外的 undo_log 表,且对复杂 SQL(多表关联更新)支持有限。 - TCC 模式:手写三个接口,控制力最强。
- Saga 模式:用状态机 DSL 定义流程与补偿,适合长流程。
# Seata 配置片段
seata:
enabled: true
application-id: order-service
tx-service-group: my_tx_group
service:
vgroup-mapping:
my_tx_group: default
@GlobalTransactional(timeoutMills = 30000, name = "create-order")
public void createOrder(OrderCmd cmd) {
stockClient.decrease(cmd); // 分支事务 1
paymentClient.charge(cmd); // 分支事务 2
orderRepo.insert(cmd); // 分支事务 3
}
AT 模式的限制要提前评估:它通过解析 SQL 生成补偿,对「批量更新」「跨库 join」支持不好;undo_log 会随业务量增长,需要定期清理;全局锁在高并发下会成为瓶颈。它的适用场景是「已有大量 MyBatis 代码、想低侵入接入」的项目。
12. 空回滚与悬挂
这两个是 Saga/TCC 里最经典的陷阱。
空回滚(Empty Rollback):补偿被触发,但正向操作从未执行(请求超时或未到达)。如果补偿不做判断,就会凭空退款或凭空恢复库存。解法是补偿前检查正向操作是否真的执行过(查业务记录或事务状态表)。
悬挂(Hanging):正向操作的请求因为网络延迟,在补偿执行之后才到达。此时系统会「先补偿、后正向」,导致资源被扣减却永远不会被补偿。解法是给每个分支事务记录状态,正向操作执行前先检查「是否已经有补偿记录」,有则拒绝执行。
public void decrease(String txId, String sku, int qty) {
// 防悬挂:已经回滚过就拒绝执行
if (txStateRepo.isRollbacked(txId)) {
throw new IllegalStateException("transaction already rolled back: " + txId);
}
stockRepo.decrease(sku, qty);
txStateRepo.markExecuted(txId);
}
这两个问题在 TCC 里更突出,因为 TCC 的 Try 阶段有资源预留。Saga 里同样存在,只是表现形式不同(悬挂表现为「补偿后又被扣了一次」)。
13. 幂等与重试的边界
Saga 的所有网络调用都可能超时,超时后的重试会导致重复执行。幂等的实现方式有三种:
-- 方式一:唯一索引去重
INSERT INTO payment (idem_key, order_id, amount)
VALUES (?, ?, ?) ON CONFLICT (idem_key) DO NOTHING;
-- 方式二:状态前置条件(只允许从特定状态进入)
UPDATE orders SET status = 'PAID'
WHERE id = ? AND status = 'PENDING';
-- 方式三:业务去重表
INSERT INTO processed_event (event_id, processed_at)
VALUES (?, NOW()) ON CONFLICT DO NOTHING;
方式二最优雅,因为它把幂等与状态校验合二为一。方式是「让操作本身具备幂等语义」:比如「设置订单为已支付」是幂等的,而「累加积分」不是幂等的(需要幂等键)。
幂等键的生成要稳定:用「业务单号 + 操作类型 + 序号」,不要用随机 UUID(重试时 UUID 会变,去重失效)。细节见 重试幂等与补偿设计 。
14. 补偿失败与人工兜底
补偿也可能失败:下游服务挂了、余额被冻结、数据被人工修改。处理策略有三层:
- 自动重试:补偿动作配重试策略(指数退避),覆盖大部分临时故障。
- 死信队列:重试耗尽后进死信,由对账任务定期扫描重试。
- 人工介入:死信超过阈值后告警,进入运维工单流程。
CREATE TABLE compensation_task (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
saga_id VARCHAR(64) NOT NULL,
step VARCHAR(32) NOT NULL,
attempts INT NOT NULL DEFAULT 0,
last_error TEXT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
next_retry TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
关键原则:补偿失败绝不能被静默吞掉。它意味着「钱可能没退」「库存可能没恢复」,必须有可观测的状态与明确的负责人。人工兜底不是失败,而是分布式系统的正常组成部分。
15. 超时与长事务的边界
Saga 的每一步都是本地事务,但整个 Saga 可能持续很久(等人工审批、等对账窗口)。这带来两个问题。
第一,Saga 的上下文(补偿所需的参数)必须持久化,因为内存里的编排器状态会在进程重启后丢失。所有编排式实现都要把步骤状态落库。
第二,长时间的「中间态可见」会带来业务复杂度。一个订单在「已扣款、未建单」的状态停留 3 天,客服与对账都要能识别并处理。所以 Saga 的中间态应该有明确的命名与超时策略。
建议给每个 Saga 实例设置总超时(比如 24 小时),超时后进入 MANUAL_REVIEW 状态并告警,而不是让它无限期悬挂。
16. 与工作流引擎的结合
生产系统里很少手写 Saga 编排器,更多是用工作流引擎承载:
- Temporal 的
Saga类提供补偿注册与逆序执行,且天然持久化,参考 Temporal 与持久化执行 。 - Camunda 的补偿事件(CompensateEvent)用 BPMN 图形表达补偿,参考 BPMN 2.0 与 Camunda 实战 。
- 状态机适合步骤少、状态可枚举的场景。
用引擎的好处是「补偿的顺序、重试、持久化、可观测」都由框架承担,业务只需实现正向与补偿两个动作。代价是引入引擎的复杂度与学习成本。判断标准是「Saga 的步骤数」:少于 5 步手写编排器更简单,超过 5 步用引擎更划算。
17. 监控与对账
Saga 的监控要覆盖四类指标:
saga_started_total{saga_type} # 启动量
saga_completed_total{saga_type} # 成功量
saga_compensated_total{saga_type} # 补偿量(反映失败率)
saga_compensation_failed_total{saga_type} # 补偿失败(必须告警)
saga_duration_seconds{saga_type} # 时长分布
saga_stuck_total{saga_type} # 长时间未完成的实例数
其中 saga_compensation_failed_total 与 saga_stuck_total 是最重要的两个告警指标,它们直接对应「钱可能有问题」的场景。
除了指标,还必须有对账:每天定时比对各服务的业务数据,找出不一致。比如「支付表有扣款记录但订单表没有对应订单」的悬挂记录。对账是最终一致性的最后一道防线,任何声称「不需要对账」的分布式事务方案都不可信。
18. 落地路线图
- 第 1 周:选一个「3 到 5 步、跨 2 到 3 个服务」的链路,画出正向流程与补偿流程,与业务方确认「哪些中间态可接受」。
- 第 2 周:实现幂等键与 outbox,把「本地事务 + 发消息」的一致性做扎实。
- 第 3 周:实现编排器与补偿,加上补偿失败的死信表与重试。
- 第 4 周:接入监控与对账,做一次故障注入演练(让第二步必失败,验证补偿链路)。
演练时要特别验证「补偿执行到一半进程被杀」的场景,确认重启后能从断点继续补偿。这个场景在真实故障里出现的概率远高于「补偿逻辑写错」。
19. 权衡取舍
| 选择 | 收益 | 代价 |
|---|---|---|
| 2PC / XA | 强一致,业务无侵入 | 阻塞、性能差、协调者单点 |
| TCC | 无中间态可见,可控性强 | 三接口改造量大,需处理空回滚与悬挂 |
| Saga | 实现简单,性能好 | 中间态可见,补偿可能失败 |
| 编排式 Saga | 流程集中可读 | 协调者成为复杂度中心 |
| 协同式 Saga | 服务解耦 | 流程分散,难追踪 |
| outbox 模式 | 消息与状态强一致 | 需要 relay 进程与幂等消费 |
| 用工作流引擎 | 补偿与重试由框架承担 | 引入引擎复杂度 |
| 手写编排器 | 无额外依赖 | 持久化、重试、观测都要自己做 |
20. 常见坑清单
- 补偿动作写成绝对值赋值(
SET stock = 10),覆盖了并发修改,应该用增量。 - 补偿不做空回滚判断,正向操作未执行时凭空退款。
- 没有防悬挂机制,延迟到达的正向请求在补偿之后执行,资源被扣却不会补偿。
- 补偿依赖正向操作的返回值,响应丢失时拿不到参数导致补偿失败。
- 「先提交事务再发消息」,发消息前崩溃导致消息永久丢失。
- 补偿失败被 catch 后静默吞掉,钱没退但没人知道。
- 幂等键用随机 UUID,重试时键变了,去重完全失效。
- Saga 上下文只存内存,进程重启后无法继续补偿。
- 中间态没有命名与超时策略,实例永久悬挂且无人发现。
- 用 AT 模式处理跨库 join 或批量更新,undo_log 生成的补偿 SQL 不正确。
- 只监控成功与失败,不监控「补偿失败」与「卡住实例」,问题靠用户投诉发现。
- 认为有了 Saga 就不需要对账,长期积累的差异永远无法发现。
21. 小结
Saga 的核心不是「回滚」,而是「用补偿动作抵消已发生的事实」。这个定义决定了它的所有工程约束:补偿要幂等、要用增量、要能处理空回滚与悬挂、失败要有人工兜底。放弃原子性换来的可用性,代价是把复杂度从「锁」转移到了「补偿逻辑的正确性」上。
落地时按这个顺序推进:先把幂等与 outbox 做扎实(这是所有后续工作的地基),再实现编排与补偿,最后补监控与对账。对账不是可选项,它是最终一致性系统里唯一能发现「未知的未知」的手段。
如果链路步骤超过 5 步或需要跨天等待,建议直接上工作流引擎而不是手写编排器,参考 Temporal 与持久化执行 ;如果补偿涉及资金或额度,应该考虑 TCC 的资源预留模型,把「补偿失败」的概率降到最低。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。