Saga 与分布式事务补偿

本文系统讲解 Saga 模式与分布式事务补偿的工程落地,回答跨服务事务该用 2PC、TCC 还是 Saga、补偿动作该怎么设计、补偿失败如何处理。覆盖编排式与协同式两种 Saga、补偿的语义约束、空回滚与悬挂的成因、Seata 三种模式、幂等与重试、对账兜底与人工介入,并给出 Java 编排器与 outbox 的真实代码。

引言

微服务拆分之后,一个业务动作往往要跨多个服务:下单要扣库存、扣余额、创建订单、发通知。这些操作分属不同的数据库,本地事务帮不上忙。传统答案两阶段提交(2PC)在分布式环境下代价太高:协调者单点、参与者阻塞、锁持有时间长,几乎没人敢在互联网业务里用它。

Saga 是更现实的答案:把一个大事务拆成 N 个本地事务,每个本地事务都配一个「补偿动作」;任何一步失败,就按逆序执行前面已成功步骤的补偿动作。它放弃原子性(中间状态对外可见),换取可用性与可伸缩性,属于最终一致性的范畴。

Saga 的难点不在概念,而在细节:补偿动作怎么写才算正确、补偿本身失败了怎么办、重复投递导致补偿被执行两次怎么办、补偿动作执行时数据已经被别人改了怎么办。这些问题在业务量小时不会暴露,等到日订单百万级时集中爆发。

本文先讲清楚 Saga 与 2PC、TCC 的取舍,再拆解补偿的语义与设计原则,然后给出编排式与协同式两种实现的完整代码,接着讲幂等、空回滚、悬挂这三个经典陷阱,最后讲补偿失败的处理与对账兜底。想先看整体选型框架的读者,可以从 工作流引擎全景与选型 开始。

目录

  1. 分布式事务的三种解法
  2. 2PC 为什么不适合互联网业务
  3. TCC 与 Saga 的差异
  4. 编排式与协同式 Saga
  5. 补偿的语义与三个约束
  6. 补偿动作的设计原则
  7. 编排式 Saga 的完整实现
  8. 协同式 Saga 与事件驱动
  9. 用 outbox 保证消息与状态一致
  10. 用状态机实现 Saga
  11. Seata 的 AT、TCC 与 Saga 模式
  12. 空回滚与悬挂
  13. 幂等与重试的边界
  14. 补偿失败与人工兜底
  15. 超时与长事务的边界
  16. 与工作流引擎的结合
  17. 监控与对账
  18. 落地路线图
  19. 权衡取舍
  20. 常见坑清单
  21. 小结

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. 常见坑清单

  1. 补偿动作写成绝对值赋值(SET stock = 10),覆盖了并发修改,应该用增量。
  2. 补偿不做空回滚判断,正向操作未执行时凭空退款。
  3. 没有防悬挂机制,延迟到达的正向请求在补偿之后执行,资源被扣却不会补偿。
  4. 补偿依赖正向操作的返回值,响应丢失时拿不到参数导致补偿失败。
  5. 「先提交事务再发消息」,发消息前崩溃导致消息永久丢失。
  6. 补偿失败被 catch 后静默吞掉,钱没退但没人知道。
  7. 幂等键用随机 UUID,重试时键变了,去重完全失效。
  8. Saga 上下文只存内存,进程重启后无法继续补偿。
  9. 中间态没有命名与超时策略,实例永久悬挂且无人发现。
  10. 用 AT 模式处理跨库 join 或批量更新,undo_log 生成的补偿 SQL 不正确。
  11. 只监控成功与失败,不监控「补偿失败」与「卡住实例」,问题靠用户投诉发现。
  12. 认为有了 Saga 就不需要对账,长期积累的差异永远无法发现。

21. 小结

Saga 的核心不是「回滚」,而是「用补偿动作抵消已发生的事实」。这个定义决定了它的所有工程约束:补偿要幂等、要用增量、要能处理空回滚与悬挂、失败要有人工兜底。放弃原子性换来的可用性,代价是把复杂度从「锁」转移到了「补偿逻辑的正确性」上。

落地时按这个顺序推进:先把幂等与 outbox 做扎实(这是所有后续工作的地基),再实现编排与补偿,最后补监控与对账。对账不是可选项,它是最终一致性系统里唯一能发现「未知的未知」的手段。

如果链路步骤超过 5 步或需要跨天等待,建议直接上工作流引擎而不是手写编排器,参考 Temporal 与持久化执行 ;如果补偿涉及资金或额度,应该考虑 TCC 的资源预留模型,把「补偿失败」的概率降到最低。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「工作流引擎」更多文章

  1. 工作流成本优化
  2. 执行器与资源隔离
  3. 调度、回填与补数