一个借阅要跨三个服务
libraryd 的借阅操作看起来是一个动作,落到系统里至少跨三个服务:
- 借阅服务创建
Loan,状态为CREATED。 - 库存服务把某一本的
available减一、reserved加一。 - 会员服务冻结该会员的一份借阅额度。
三步各自是本地事务,都能单独提交,但没有一个横跨三者的数据库事务。如果第 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=CREATED | status -> CANCELLED | 借阅已存在时不能再创建;取消要带条件 |
2. reserveInventory | available-1、reserved+1 | available+1、reserved-1 | 不能把别人的预留还回去 |
3. freezeQuota | frozen+1 | frozen-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;
三个细节值得单独说:
- 补偿只遍历
done,也就是只补已完成的步骤。失败的那一步如果没产生副作用,不在done里;如果可能已产生副作用,要先查询再决定(见上一节)。 - 补偿失败不
break。一个库存服务挂了,不应该让借阅取消也不执行;先把能补的补完,再把失败的补偿交给人工队列。 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。
协同式长什么样
同样的借阅流程,协同式没有编排器:
- 借阅服务创建
Loan,发library.loan.created。 - 库存服务订阅它,预留库存,成功发
library.inventory.reserved,失败发library.inventory.rejected。 - 会员服务订阅
library.inventory.reserved,冻结额度。 - 任一环节失败,前序服务订阅对应的失败事件,执行自己的补偿。
它把「下一步是什么」编码进了订阅关系。好处是新增一个「发短信通知」的订阅者不需要改任何现有服务;坏处是流程一长,事件链会变成一张没人能完整说清的网。更麻烦的是循环依赖:借阅服务既要订阅 rejected 来取消借阅,又要发 created,服务之间的启动顺序、事件重放都会影响状态。
一个折中的做法是编排器只做决策,不做业务:编排器持有状态机,通过命令主题发指令;参与者执行本地事务并回复。这样既有集中可观测性,又没有把业务逻辑塞进编排器。缺点是命令主题和回复主题都要处理重复与超时,成本不低于直接 RPC。
本实验的边界
必须说清楚哪些是实验简化,哪些结论可以直接搬:
| 实验里的做法 | 生产里的做法 | 影响 |
|---|---|---|
| 参与者在同进程直接调用 | 命令主题 + 回复主题,或 RPC | 超时与重复的形态不同,编排逻辑不变 |
| H2 内存库 | 各服务自己的数据库 | 本地事务语义一致 |
| 动作完成后写日志 | 动作前写 STARTED 意图 | 崩溃恢复的判定方式不同 |
| 失败发生在写库前 | 超时可能已写库 | 必须先查状态再补偿 |
| 补偿只重试一次 | 重试预算 + 退避 + 人工队列 | 9.2 展开 |
| 单线程顺序执行 | 并发 Saga 共享参与者 | 需要按 sagaId 隔离预留 |
尤其注意最后一行:本实验里每个 bookId 只有一行库存,两个 Saga 同时预留同一本书会互相干扰。生产实现要么在预留行上带 saga_id,要么用「可用量 + 预留明细」两张表,让每条预留都能被单独释放。这正是 9.2 要解决的问题。
落地清单
- 先画补偿表,再写代码:每个正向动作旁边必须写出反向动作;写不出反向动作的步骤,放到流程最后。
- 状态机落库:
saga_instance+saga_step,状态单向迁移,崩溃后可恢复。 - 写前日志:调用前记
STARTED,调用后记OK/FAIL,不要只记结果。 - 幂等键贯穿全链路:
sagaId + step作为业务键,正向和补偿都用它。 - 补偿逆序、失败不阻塞:一个补偿失败,继续补其余步骤,最后统一进人工队列。
- 超时按「可能成功」处理:先查状态,再决定补哪一步。
- 把「补偿失败率」做成指标:它比 Saga 成功率更早暴露参与者的问题。
本节判据
- Saga 和数据库回滚的本质区别是什么? 答:Saga 的每一步都已提交,补偿是新的反向业务动作,中间状态对外可见;回滚抹掉未提交的修改。
- 编排式和协同式各自的最大代价是什么? 答:编排式多一个必须持久化、高可用的中心;协同式把流程散进订阅关系,长链路难观测、难改。
- 第 3 步失败时为什么要逆序补偿? 答:逆序让中间状态始终接近「未开始」,减少「已取消但仍占用」这类难读的窗口。
- 补偿失败后应该把 Saga 标成什么? 答:不能标
ABORTED,应标MANUAL_PENDING,把补偿命令与失败原因写进人工队列。 - 为什么正向动作也必须幂等? 答:超时后编排器无法区分「没做」和「做了但回复丢了」,只能重试;正向不幂等会产生重复业务。
阅读导航:上一节 8.3 Schema Registry 的取舍 | 下一节 9.2 补偿事务的幂等
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。