《Java 消息中间件·可靠消息与消费治理》9.1 Saga:编排与协同

Saga 把跨服务的长事务拆成一组可补偿的本地动作。本篇用 libraryd 借阅场景实现一个可运行的编排式 Saga:创建借阅、预留库存、冻结额度三步,在第二步和第三步各失败一次,贴出本机真实的补偿轨迹与最终状态,并给出状态机表、崩溃恢复分析与落地清单。

一个借阅要跨三个服务

libraryd 的借阅操作看起来是一个动作,落到系统里至少跨三个服务:

  1. 借阅服务创建 Loan,状态为 CREATED。
  2. 库存服务把某一本的 available 减一、reserved 加一。
  3. 会员服务冻结该会员的一份借阅额度。

三步各自是本地事务,都能单独提交,但没有一个横跨三者的数据库事务。如果第 2 步失败,第 1 步已经提交的借阅必须被撤销;如果第 3 步失败,前两步都要撤销。Saga 就是把这个「撤销」显式建模成一组反向动作,由编排器按逆序调用。

先划边界:Saga 不是回滚。回滚依赖数据库的 undo 日志,能把未提交的修改抹掉;Saga 的每一步都已经提交,补偿只能再写一笔「反向业务动作」,中间状态对其他读者是可见的。库存被预留的那几百毫秒里,别的请求会看到 available 少了一本。

两种形态:编排与协同

Saga 有两种组织方式,差别在于「谁决定下一步」。

维度编排式(orchestration)协同式(choreography)
控制点一个编排器按顺序调用参与者每个服务订阅事件,自己决定下一步
流程可见性编排器一张状态表能看到全流程流程散落在各服务的订阅关系里
耦合参与者只依赖编排器的命令契约参与者互相依赖对方的事件
新增一步改编排器,参与者不动要接一个新的订阅者,还要改上游发事件
失败定位看 saga_step 表即可要沿事件链把多个服务的日志串起来
单点风险编排器本身要持久化、要高可用无中心,但没人对全流程负责
适合步骤多、补偿复杂、需要人工介入步骤少、事件语义清晰、团队边界强

一个常见的误判是「用了 Kafka 就必须用协同式」。Kafka 只是命令和事件的传输层,编排器完全可以通过命令主题调用参与者,再通过回复主题收集结果。反过来,协同式也可以在事件里带 sagaId 和 causationId,让链路可追踪。

本书的建议是按步骤数分:

  • 2–3 步、补偿简单:协同式,事件本身就是业务事实,不值得为它养一个编排器。
  • 4 步以上、补偿有依赖、需要人工兜底:编排式,状态机是主要资产。
  • 混合:聚合内部用编排式,聚合之间用协同式。借阅聚合的 Saga 由编排器管,借阅完成后发 library.loan.created,下游订阅即可。

步骤与补偿:每个正向动作都要有反向动作

把借阅 Saga 拆开,每一步都要回答三个问题:正向做什么、反向做什么、失败时状态是否可判定。

步骤正向动作补偿动作补偿的边界
1. createLoan插入借阅,status=CREATEDstatus -> CANCELLED借阅已存在时不能再创建;取消要带条件
2. reserveInventoryavailable-1、reserved+1available+1、reserved-1不能把别人的预留还回去
3. freezeQuotafrozen+1frozen-1不能把冻结减成负数

补偿动作不是「删除」,而是「再写一笔可审计的反向记录」。cancelLoan 把状态改成 CANCELLED 而不是 DELETE,原因是:撤销本身也需要被追踪,事后要能回答「这笔借阅为什么没成」。如果直接删行,补偿日志和业务行就对不上了。

还有一个容易漏的点:失败的那一步自己也可能产生副作用。第 2 步如果是在「发出预留请求后超时」,库存服务可能已经预留成功,只是回复丢了。此时编排器看到的是失败,但实际状态是未知。正确处理不是直接补偿前一步,而是先用业务幂等键查询第 2 步的真实状态,再决定是补第 2 步还是补前一步。本实验为了把轨迹打印清楚,让失败发生在「请求发出前」;生产实现必须把「超时」当成「可能成功」处理。

编排器的状态机

编排器不能只活在内存里。它至少需要两张表:

CREATE TABLE saga_instance (
  saga_id    VARCHAR(32) PRIMARY KEY,
  loan_id    VARCHAR(32) NOT NULL,
  status     VARCHAR(16) NOT NULL,   -- RUNNING / COMMITTED / ABORTED
  started_at TIMESTAMP   NOT NULL
);

CREATE TABLE saga_step (
  saga_id  VARCHAR(32) NOT NULL,
  seq      INT NOT NULL,
  step     VARCHAR(32) NOT NULL,
  action   VARCHAR(16) NOT NULL,     -- forward / compensate
  outcome  VARCHAR(16) NOT NULL,     -- OK / FAIL
  detail   VARCHAR(200),
  at       TIMESTAMP NOT NULL
);

status 只允许四个值:RUNNING、COMMITTED、ABORTED,以及补偿失败后的 MANUAL_PENDING(9.2 展开)。状态迁移必须是单向的,不能从 COMMITTED 回到 RUNNING。

崩溃点决定了恢复动作:

崩溃位置已持久化的信息恢复动作
调用第 i 步之前第 i 步没有记录重做第 i 步(正向动作必须幂等)
第 i 步成功、写日志之前业务已变,日志说没做先查业务状态,再决定重做还是记成功
补偿成功、写日志之前业务已撤销,日志说没补重放补偿(补偿必须幂等,见 9.2)
补偿全部完成、改状态之前步骤日志齐,实例仍 RUNNING扫日志,补写 ABORTED

第二行是最危险的:「动作」和「日志」不在同一个事务里,因为动作在远端服务。可靠的做法是写前日志(write-ahead):调用前先插一条 outcome=STARTED 的意图,调用后更新为 OK 或 FAIL。本实验为了输出简洁,在动作完成后才写日志;生产实现应把顺序反过来,否则崩溃恢复只能靠查业务状态。

完整实现:编排器只做三件事

参与者用三个独立的 H2 内存库模拟三套服务,编排器只负责「按顺序调用、记录、失败时逆序补偿」。核心接口小到可以贴完:

@FunctionalInterface
interface Action {
    void run() throws Exception;
}

record Step(String name, Action forward, Action compensation) {}

编排循环:

List<Step> done = new ArrayList<>();
for (int i = 0; i < steps.size(); i++) {
    Step step = steps.get(i);
    try {
        step.forward().run();
        done.add(step);
        logStep(sagaId, i + 1, step.name(), "forward", "OK", "done");
    } catch (Exception e) {
        logStep(sagaId, i + 1, step.name(), "forward", "FAIL", e.getMessage());
        for (int j = done.size() - 1; j >= 0; j--) {
            Step prev = done.get(j);
            try {
                prev.compensation().run();
                logStep(sagaId, j + 1, prev.name(), "compensate", "OK", "undone");
            } catch (Exception ce) {
                // 单个补偿失败不能阻塞其余补偿
                logStep(sagaId, j + 1, prev.name(), "compensate", "FAIL", ce.getMessage());
            }
        }
        return ABORTED;
    }
}
return COMMITTED;

三个细节值得单独说:

  1. 补偿只遍历 done,也就是只补已完成的步骤。失败的那一步如果没产生副作用,不在 done 里;如果可能已产生副作用,要先查询再决定(见上一节)。
  2. 补偿失败不 break。一个库存服务挂了,不应该让借阅取消也不执行;先把能补的补完,再把失败的补偿交给人工队列。
  3. ABORTED 只在补偿循环结束后写。如果补偿过程中崩溃,实例还是 RUNNING,重启后的恢复任务能扫到它。

三个正向动作都用一条带条件的 UPDATE 实现,把「检查 + 修改」放进同一条 SQL:

static void reserveInventory(String bookId) throws SQLException {
    if (bookId.startsWith("B-FAIL")) {
        throw new IllegalStateException(
                "InventoryServiceUnavailable: timeout calling inventory for " + bookId);
    }
    try (Connection c = open(INVENTORY_DB);
         PreparedStatement ps = c.prepareStatement(
                 "UPDATE inventory SET available = available - 1, reserved = reserved + 1 "
                         + "WHERE book_id = ? AND available > 0")) {
        ps.setString(1, bookId);
        if (ps.executeUpdate() != 1) {
            throw new IllegalStateException("OutOfStock: " + bookId);
        }
    }
}

补偿是它的反向:

static void releaseInventory(String bookId) throws SQLException {
    try (Connection c = open(INVENTORY_DB);
         PreparedStatement ps = c.prepareStatement(
                 "UPDATE inventory SET available = available + 1, reserved = reserved - 1 "
                         + "WHERE book_id = ? AND reserved > 0")) {
        ps.setString(1, bookId);
        ps.executeUpdate();
    }
}

WHERE reserved > 0 只是防止减成负数,它并不能保证补偿幂等:如果 reserved 里还有别的 Saga 的预留,重复执行仍会多扣。9.2 会用实验说明这一点,并给出令牌表方案。

本机实测:三组场景

环境:Temurin 25.0.5+8,H2 2.3.232,三个参与者库都是内存库,单次运行。命令:

$ export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
$ javac --release 25 -cp h2-2.3.232.jar -d out src/SagaLab.java
$ ./run.sh SagaLab all

./run.sh 只是拼好 classpath 后 exec java,不引入任何框架。

场景一:三步全成功

[saga S-9001] start loan=L-9001 book=B-101 member=M-201
[step 1/3] createLoan       ... loan=L-9001 status=CREATED; OK
[step 2/3] reserveInventory ... B-101 available-1 reserved+1; OK
[step 3/3] freezeQuota      ... M-201 frozen+1; OK
[saga S-9001] COMMITTED steps=3 compensated=0

场景二:第 2 步失败,补偿 1 步

让库存服务对 B-FAIL 直接抛「服务不可用」,模拟请求超时:

[saga S-9002] start loan=L-9002 book=B-FAIL member=M-202
[step 1/3] createLoan       ... loan=L-9002 status=CREATED; OK
[step 2/3] reserveInventory ... FAIL IllegalStateException: InventoryServiceUnavailable: timeout calling inventory for B-FAIL
[saga S-9002] compensating 1 completed step(s) in reverse order
[compensate 1/1] createLoan       ... loan=L-9002 status->CANCELLED affected=1; OK
[saga S-9002] ABORTED failed_at_step=2 compensated=1

轨迹里有三件事值得确认:compensated=1 说明只补了已完成的一步;affected=1 说明取消命中了那行 CREATED;failed_at_step=2 被写进实例状态,事后不用翻日志猜。

场景三:第 3 步失败,补偿 2 步

让会员服务对 M-FAIL 超时,此时前两步都已提交,必须逆序补两次:

[saga S-9003] start loan=L-9003 book=B-103 member=M-FAIL
[step 1/3] createLoan       ... loan=L-9003 status=CREATED; OK
[step 2/3] reserveInventory ... B-103 available-1 reserved+1; OK
[step 3/3] freezeQuota      ... FAIL IllegalStateException: MemberServiceUnavailable: timeout calling member for M-FAIL
[saga S-9003] compensating 2 completed step(s) in reverse order
[compensate 1/2] reserveInventory ... B-103 available+1 reserved-1 affected=1; OK
[compensate 2/2] createLoan       ... loan=L-9003 status->CANCELLED affected=1; OK
[saga S-9003] ABORTED failed_at_step=3 compensated=2

顺序是 reserveInventory 先、createLoan 后,正好与正向相反。如果把顺序倒过来,先取消借阅再释放库存,中间会出现「借阅已取消、库存仍被占」的窗口,虽然最终也能补齐,但排障时状态更难读。

最终状态

[summary] S-9001 COMMITTED steps_done=3 compensated=0
[summary] S-9002 ABORTED steps_done=1 compensated=1
[summary] S-9003 ABORTED steps_done=2 compensated=2

[state] loans: L-9001=CREATED L-9002=CANCELLED L-9003=CANCELLED
[state] inventory: B-101(available=1,reserved=1) B-103(available=2,reserved=0) B-FAIL(available=2,reserved=0)
[state] members: M-201(frozen=1) M-202(frozen=0) M-FAIL(frozen=0)

B-103 的 available=2、reserved=0 与初始值一致,说明第 2 步的副作用被完整撤销;B-FAIL 没有被碰过;M-FAIL 的 frozen=0,说明第 3 步在抛异常前没有写库。成功场景的 B-101 则是 available=1、reserved=1,库存确实被占住了。

saga_step 表的原始记录(按 saga、序号、动作排序):

[saga_step] S-9002 | 1 | createLoan | compensate | OK | undone
[saga_step] S-9002 | 1 | createLoan | forward | OK | done
[saga_step] S-9002 | 2 | reserveInventory | forward | FAIL | InventoryServiceUnavailable: timeout calling inventory for B-FAIL
[saga_step] S-9003 | 1 | createLoan | compensate | OK | undone
[saga_step] S-9003 | 1 | createLoan | forward | OK | done
[saga_step] S-9003 | 2 | reserveInventory | compensate | OK | undone
[saga_step] S-9003 | 2 | reserveInventory | forward | OK | done
[saga_step] S-9003 | 3 | freezeQuota | forward | FAIL | MemberServiceUnavailable: timeout calling member for M-FAIL

这张表是 Saga 相比协同式最实在的收益:一次查询就能还原「做到哪、补了哪、哪一步没补成」,不需要把三个服务的日志按时间轴拼起来。

从轨迹里读出的四件事

第一,补偿的粒度是步骤,不是字段。 releaseInventory 一次把 available 和 reserved 都改回去,而不是分别补偿两个字段。步骤内部的多个写操作应该放在同一个本地事务里,这样补偿也只需要一个反向事务。

第二,补偿的可见性无法消除。 场景三里,B-103 在补偿完成前有几十毫秒处于 available=1、reserved=1。如果有并发请求在这段时间查库存,会看到「少了一本」。Saga 提供的是最终一致,不是隔离性;需要强隔离的查询应该读一个明确的「已确认」视图,而不是直接读库存表。

第三,补偿失败必须有去处。 本实验的三次补偿都成功,所以只打印了 OK。一旦库存服务在补偿时也挂了,编排器不能无限重试,也不能把 saga 标成 ABORTED 假装干净;正确状态是 MANUAL_PENDING,把补偿命令、失败原因、重试次数写进人工队列。这条路径在 9.2 用实验走一遍。

第四,正向动作必须幂等,补偿才谈得上可靠。 编排器在第 i 步超时后无法区分「没做」和「做了但回复丢了」,只能重试。如果正向动作不幂等,重试会重复创建借阅;如果补偿不幂等,重试会多还库存。幂等键要由业务生成,比如 sagaId + step,不能靠数据库自增 ID。

协同式长什么样

同样的借阅流程,协同式没有编排器:

  1. 借阅服务创建 Loan,发 library.loan.created。
  2. 库存服务订阅它,预留库存,成功发 library.inventory.reserved,失败发 library.inventory.rejected。
  3. 会员服务订阅 library.inventory.reserved,冻结额度。
  4. 任一环节失败,前序服务订阅对应的失败事件,执行自己的补偿。

它把「下一步是什么」编码进了订阅关系。好处是新增一个「发短信通知」的订阅者不需要改任何现有服务;坏处是流程一长,事件链会变成一张没人能完整说清的网。更麻烦的是循环依赖:借阅服务既要订阅 rejected 来取消借阅,又要发 created,服务之间的启动顺序、事件重放都会影响状态。

一个折中的做法是编排器只做决策,不做业务:编排器持有状态机,通过命令主题发指令;参与者执行本地事务并回复。这样既有集中可观测性,又没有把业务逻辑塞进编排器。缺点是命令主题和回复主题都要处理重复与超时,成本不低于直接 RPC。

本实验的边界

必须说清楚哪些是实验简化,哪些结论可以直接搬:

实验里的做法生产里的做法影响
参与者在同进程直接调用命令主题 + 回复主题,或 RPC超时与重复的形态不同,编排逻辑不变
H2 内存库各服务自己的数据库本地事务语义一致
动作完成后写日志动作前写 STARTED 意图崩溃恢复的判定方式不同
失败发生在写库前超时可能已写库必须先查状态再补偿
补偿只重试一次重试预算 + 退避 + 人工队列9.2 展开
单线程顺序执行并发 Saga 共享参与者需要按 sagaId 隔离预留

尤其注意最后一行:本实验里每个 bookId 只有一行库存,两个 Saga 同时预留同一本书会互相干扰。生产实现要么在预留行上带 saga_id,要么用「可用量 + 预留明细」两张表,让每条预留都能被单独释放。这正是 9.2 要解决的问题。

落地清单

  1. 先画补偿表,再写代码:每个正向动作旁边必须写出反向动作;写不出反向动作的步骤,放到流程最后。
  2. 状态机落库:saga_instance + saga_step,状态单向迁移,崩溃后可恢复。
  3. 写前日志:调用前记 STARTED,调用后记 OK/FAIL,不要只记结果。
  4. 幂等键贯穿全链路:sagaId + step 作为业务键,正向和补偿都用它。
  5. 补偿逆序、失败不阻塞:一个补偿失败,继续补其余步骤,最后统一进人工队列。
  6. 超时按「可能成功」处理:先查状态,再决定补哪一步。
  7. 把「补偿失败率」做成指标:它比 Saga 成功率更早暴露参与者的问题。

本节判据

  1. Saga 和数据库回滚的本质区别是什么? 答:Saga 的每一步都已提交,补偿是新的反向业务动作,中间状态对外可见;回滚抹掉未提交的修改。
  2. 编排式和协同式各自的最大代价是什么? 答:编排式多一个必须持久化、高可用的中心;协同式把流程散进订阅关系,长链路难观测、难改。
  3. 第 3 步失败时为什么要逆序补偿? 答:逆序让中间状态始终接近「未开始」,减少「已取消但仍占用」这类难读的窗口。
  4. 补偿失败后应该把 Saga 标成什么? 答:不能标 ABORTED,应标 MANUAL_PENDING,把补偿命令与失败原因写进人工队列。
  5. 为什么正向动作也必须幂等? 答:超时后编排器无法区分「没做」和「做了但回复丢了」,只能重试;正向不幂等会产生重复业务。

阅读导航:上一节 8.3 Schema Registry 的取舍 | 下一节 9.2 补偿事务的幂等

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收