企业应用架构18:Saga补偿与持久化恢复
设备预留已经提交,扣费却被拒绝,合同需要释放预留。释放也可能失败,甚至已经释放成功但协调器还没记录结果就退出。把这段逻辑写成一个 try-catch,只能处理仍在运行的调用栈,无法回答新进程该从哪一步继续。
本篇用三个独立 H2 数据库分别保存协调状态、库存操作和收费结果,主动在参与者提交后终止 JVM。恢复进程根据持久记录继续补偿,检查重复释放、迟到成功事件以及交付以后不能自动撤销的边界。
补偿是另一次业务操作
Saga 将长事务分成可分别提交的事务,并为需要撤销的步骤安排补偿。前面已经提交的结果不会因为后续某个进程抛异常而自动回滚,补偿也可能需要新的尝试。原始Sagas论文;Saga模式
设备预留的补偿可以是释放预留,但这并不等于时间倒流。预留期间其他合同可能已被拒绝,业务系统也可能产生审计或通知。实验只检验资源持有状态与释放次数,不声称所有外部观察都恢复到预留前。
协调器保存 R1、R2 的当前状态和每次迁移原因。库存数据库保存资源 held、releases 及操作编号;收费数据库保存操作编号和整数金额。三者通过各自 JDBC 连接提交,不共享一个能够整体回滚的数据库事务。
flowchart LR
C[协调器独立JVM步骤] --> D[协调数据库 状态与迁移]
C --> I[库存本地事务]
I --> ID[库存数据库 预留和释放]
C --> B[收费本地事务]
B --> BD[收费数据库 结果和操作号]
I -->|提交后halt73| X[协调记录尚未更新]
X --> R[新JVM查询与幂等重试]
R --> C
这里的参与者是本机独立数据库,调用由顺序启动的 Java 进程直接执行,没有外部支付网关、仓库设备或远程 RPC。每个参与者的事务和提交窗口是真实的,金额二十五则是教学整数单位。物理交付以已确认状态作为测试前提,没有发生真实货物运输。
先处理预留成功后的崩溃窗口
R1 的第一次预留在库存库中插入操作 R1/reserve,并创建 held 为真的资源记录。事务提交以后,程序调用 Runtime.halt,以七十三退出,协调器仍保存 STARTED。运行器期待这个退出码;如果进程正常结束,反而说明故障注入没有执行到指定位置。
新的 JVM 再次执行预留时,先根据稳定操作号检查库存记录。已有记录使操作返回“已处理”,不会创建第二份预留。协调器据此从 STARTED 迁移到 RESERVED,并在同一个协调事务中写入迁移历史。
稳定操作号必须对应同一业务含义。把每次网络尝试都生成成新的 reserve 编号,会绕过参与者去重。反过来,拿同一个编号执行不同设备的预留,也需要载荷冲突检查;本样本每个合同固定一份资源,没有覆盖变更设备载荷的情况。
崩溃窗口的意义在于区分两个事实:参与者已经做了什么,协调器已经知道什么。恢复不能只看协调状态 STARTED 就假定预留没发生,也不能仅凭“上次调用超时”判定失败。第17篇的未知结果,在这里具体表现为一个尚待核对的本地提交。
扣费失败以后持久化补偿意图
扣费拒绝在收费库中保存独立操作结果,没有新增收费行。协调器先记录 CHARGE_FAILED,再进入 COMPENSATING。中间状态及迁移原因保存在数据库中,进程退出后仍能解释为什么需要释放资源。
第一次释放尝试先修改 held,再显式回滚库存事务,模拟参与者处理失败。协调器将状态写成 RETRY_COMPENSATION,并读回确认。没有释放成功的记录,因此失败不能被吞掉后直接宣称合同已经补偿完成。
stateDiagram-v2
STARTED --> RESERVED: 预留结果确认
RESERVED --> CHARGE_FAILED: 扣费拒绝
CHARGE_FAILED --> COMPENSATING: 持久化释放意图
COMPENSATING --> RETRY_COMPENSATION: 释放失败
RETRY_COMPENSATION --> COMPENSATED: 幂等释放结果确认
RESERVED --> CHARGED: 接受正常扣费成功事件
CHARGED --> DELIVERED: 已确认交付
DELIVERED --> MANUAL_REVIEW: 收到取消要求
第二次释放在库存事务里同时保存 R1/release 操作号、清除 held 并将 releases 加一。提交后再以七十三终止 JVM,协调器尚未进入 COMPENSATED。这次故障与第一次释放失败不同:库存已经完成释放,缺失的是协调器确认。
恢复进程重新执行释放时命中参与者操作记录,然后完成协调状态迁移。再调用一次补偿入口,已完成状态直接返回。最终数据库断言 R1 不再持有资源,释放次数恰好为一,协调器为 COMPENSATED,收费行数为零。
样本将重试调度写在运行器中,以便精确排列故障窗口。它没有后台重试线程、指数退避或人工工单系统,因此“可恢复”表示新进程能够根据持久记录继续,不表示无人值守的生产调度已经完整实现。
迟到事件经过状态判断
协调器的 chargeSucceeded 入口先按 event_id 查已收事件,再读取 Saga 当前状态。只有 RESERVED 才允许用带状态条件的 SQL 更新到 CHARGED,并把迁移和接收记录一起提交。正常 R2 扣费成功事件从这条路径推进流程。
R2 的相同事件再次输入时,入口核对它仍属于同一 Saga,然后返回已有决定,不新增迁移。event_id 相同而 Saga 不同会被拒绝。这个处理展示事件身份与业务身份的双重校验,实际运行覆盖的是同一身份重复,并未覆盖不同 Saga 冲突的负例。
R1 已经 COMPENSATED 之后,测试向同一个入口输入迟到的 charge-success 事件。当前持久状态导致决定为 IGNORE_TERMINAL,不推进 Saga;同一事件重复输入也不新增接收记录。读回中正常与迟到事件合计两行,R1 保持已补偿。
迟到输入是为了验证协调器不会被旧结果重新推进,没有在收费库补写一笔假装到账的金额。真实系统若发现补偿完成后资金确实到账,还需要退款或对账异常流程;“忽略状态推进”不能等价于忽略真实资金差异。本文的收费断言明确保持 R1 没有收费行。
其他未允许状态会记录 IGNORE_UNEXPECTED_STATE,保留调查入口。是否应该隔离、告警还是重新查询,取决于服务协议和操作序列。实验只让正常 RESERVED 和终态 COMPENSATED 两个分支实际接收事件,其余分支不计入已验证场景。
已交付的合同进入人工处理
R2 依次完成预留、整数金额扣费和交付状态确认,再提出取消。协调器迁移到 MANUAL_REVIEW,库存仍保留资源持有记录,释放次数为零,收费总额保持二十五。这样可以防止软件仅修改几行状态便宣称已经取回客户手中的实物。
人工处理状态也不是故障垃圾桶。真实流程需要说明负责人、处理期限、可执行动作和最终凭证;可能的后续操作是退还设备、退款或协商,而不一定是前向步骤的逐项逆操作。本样本停在明确人工边界,没有模拟人工已经完成这些动作。
独立负例在 DELIVERED 状态尝试自动取消,直接抛出禁止自动撤销的异常并退出一。正常路径则通过 MANUAL_REVIEW 保存待处理状态。两者共同说明自动化边界,而不是只用一个异常替代长期可追踪的业务记录。
迁移日志如何支持恢复判断
协调器每次更改状态都带预期状态条件,更新一行后再追加迁移历史。十一条持久迁移记录包含初始接受、预留确认、扣费失败、补偿重试、补偿完成及 R2 的交付和人工处理。它们在真实数据库读回后逐条打印,便于复核流程经过的路径。
历史记录和参与者操作记录用途不同。历史解释协调器做过哪些决定,参与者记录证明本地副作用是否提交。只保留前者,恢复时仍可能重复释放;只保留后者,则无法说明为什么某个合同尚在等待人工处理。两类记录都应有稳定的业务关联。
多协调器竞争、租约过期、跨主机网络分区和操作载荷升级没有纳入这次实验。条件更新能够检测样本中的非法状态迁移,但它不是完整的分布式调度方案。扩展之前应先确定谁有权推进同一 Saga,以及崩溃后该权利如何转移。
复跑三个数据库的提交窗口
下载 实验包,保留目录结构并使用 Java 21。入口严格编译后依次启动独立进程,捕获两个预期七十三退出,再执行恢复和终态检查。
1 | |
正常入口退出零;追加 negative 会在已交付取消分支退出一。证据目录包含协调、库存和收费三个 SQL 快照,以及每一步的命令、原始输出和退出码。临时数据库工作目录会保留,路径写在运行报告中,便于进一步查询;运行器只负责结束自己的运行进程。
复查时可以从 R1 的释放次数一开始,沿操作号找到参与者提交,再沿迁移历史找到新进程确认。只有这条证据链同时成立,才能把“补偿完成”理解为已经观察到的恢复结果,而不是流程图中画出的理想路径。






