第二个提交失败,第一次提交怎么办

订单数据库提交成功,库存数据库的连接随即断开。此时依次执行两个本地事务的代码,已经无法靠第二个 rollback 撤销第一个 commit。即使外层方法使用一个 Spring 事务注解,两个独立资源也不会因此自动获得同一个提交决定。

本篇分别运行 XA 两阶段提交和 outbox。前者让两个参与资源在最终决定前进入可恢复的 prepared 状态;后者先在一个本地事务内提交业务和待发送记录,再通过可重试投递推进接收端。它们约束的边界不同,验收结果也必须分别写出数据库、投递和接收端终态。

XA 实验使用 JDK 21、pgJDBC 42.7.7、PostgreSQL 18.0,在独立集群中创建两个数据库。二者使用不同 XAConnection,驱动 isSameRM 返回 false。它们是同一 PostgreSQL 进程里的两个数据库资源,没有模拟跨机网络分区或独立服务器故障。

协调代码只支持固定的两个分支和明确的进程中断窗口。它直接使用 PGXADataSource、XAResource 与文件中的提交决定,是用于观察协议的教学协调器;没有实现生产 JTA 协调器,也没有声称本篇执行过 JtaTransactionManager 的整合恢复。

两阶段提交与 outbox 各自覆盖的状态边界

JTA、Spring 管理器与 XAResource 的职责

应用层需要决定事务范围,协调器需要收集资源投票并保存最终决定,资源管理器需要执行 prepare、commit、rollback 和恢复扫描。JTA 提供 Java 事务管理接口;XAResource 连接协调器与具体参与资源;Spring 的 JtaTransactionManager 将 Spring 事务抽象适配到底层 JTA 服务。

这些接口不能互相替代。JtaTransactionManager 本身不把两个普通 DataSource 的顺序提交转换成可恢复的两阶段协议。实际系统还需要事务协调器、可正确登记的 XA 连接、稳定资源身份、持久化事务日志和恢复配置。Spring 6.2.11 JtaTransactionManager 文档

第 20 篇中选择不同本地事务管理器,只改变当前代理使用哪个管理器。两个本地管理器先后提交,第二个失败后再补偿第一个,是另一种业务协议;只有补偿语义被明确设计、执行并验收后,才能讨论其一致性承诺。

本实验将协调器行为显式写出来,使 prepare 与最终决定之间的窗口可以被观察。应用代码直接调用这些接口只适合教学;生产应用不应靠复制本篇的固定分支程序获得可靠的事务恢复服务。

prepare 后,写入还不能被普通查询看见

每个分支依次执行 start、写入、end、prepare。两个分支都投票 XA_OK 后,独立观察连接仍然查不到主键 4。prepared 代表资源承诺能够服从后续决定,不代表该资源已经对普通会话提交了业务数据。

1
2
3
A.start → INSERT → A.end → A.prepare = XA_OK
B.start → INSERT → B.end → B.prepare = XA_OK
独立观察:A.count = 0,B.count = 0

PostgreSQL 需要允许 prepared transaction。本实验专用集群使用 max_prepared_transactions=10,运行证据保存实际版本与参数输出。原有的 55432 集群没有被重启或修改;专用实例使用 55433,整个实验结束后关闭。PostgreSQL PREPARE TRANSACTION

prepared 状态仍然占据数据库资源,尤其可能长期持有锁。协调进程退出并不等于参与资源可以自行猜测 rollback。若最终决定已经持久化为 commit,资源随后擅自回滚就会破坏全局决定。恢复因此是协议的一部分,不能作为“以后人工处理”的附加功能。

分支身份包含格式标识、全局事务 ID 和分支限定符。本实验全局 ID 为 spring-e04,分支号为 1、2,恢复只处理这组 ID。扫描到其他 prepared 事务时不得顺手提交。驱动还会核对 start/end/prepare 中的 Xid,本代码复用同一分支标识对象,避免把两个没有值相等实现的临时对象当成同一个分支。

强制结束进程后,新的 JVM 完成剩余提交

协调程序在两个投票成功后,将 COMMIT 决定写入文件并调用 FileChannel.force(true)。然后只提交 A 分支,再次独立查询得到 A=1、B=0,最后通过 Runtime.halt(17) 立即结束这个专用 JVM。shell 只把退出码 17 识别为预设故障点,其他非零退出码都使实验失败。

恢复阶段是另一个 Java 进程。它重新读取决定文件、创建新的 XAConnection、调用 recover,找到 B 的剩余 prepared 分支,再按已经保存的决定提交。最终独立查询两边均为 1,pg_prepared_xacts 为零。

1
2
3
4
5
COMMIT 决定已 force
A 已提交 / B 仍 prepared
JVM halt(17)
新 JVM recover → B branch=[2]
A.count = 1 / B.count = 1 / prepared = 0

这是真实的跨进程恢复,不是同一个 catch 块里重试第二个 commit。数据库在进程中断期间保留了待决事务,新的连接能扫描并完成它。驱动固定实现中的 recover 从 PostgreSQL prepared 事务记录重建 Xid,commit 的两阶段路径最终提交对应 prepared transaction。pgJDBC 固定实现

实验验证的故障模型仅为协调 JVM 进程中断。它没有验证断电时目录项的持久性、磁盘损坏、日志备份丢失、多个协调器争用恢复、启发式结果或资源长时间不可达。固定日志也没有完整写前日志状态机。恢复成功不能扩写成“这个协调器已经可以用于生产”。

额外负例让第三个分支先 prepare,再执行 rollback,独立查询主键 5 为零。它用来区分两种合法终态:准备完成以后,资源仍需服从协调决定;prepared 本身不等于必然提交。

outbox 把原子范围收回一个数据库

outbox 场景在 A 库的一个本地事务内插入主键 6 的业务订单与同 ID 的发送记录,然后 commit。独立查询两条记录都存在。另一个事务同时写入主键 7 的订单与发送记录,再 rollback,两个查询都为空。

这个原子范围完全位于一个数据库。它保证“业务记录与发送意图一起持久化”,并不保证消息已经抵达远端。若发送程序暂时不可用,outbox 可以积压,而本地业务已经提交。

实验随后启动真实本机 HTTP 接收端。第一次 POST 后接收端返回 applied,但发送端故意不更新 outbox.sent,用以构造“接收端已处理,发送方确认尚未记录”的窗口。这里没有真实杀掉发送进程;故障注入的具体动作就是省略确认落库,不能与前面的实际 JVM halt 混称。

第二次 POST 重发同一 ID。接收端返回 duplicate,发送端随后将 sent 标记为 true。传输计数为 2,而业务效果计数为 1。客户端看见第一次成功响应也不能消除这个窗口,因为响应与本地确认更新之间仍可能发生进程故障。

幂等键必须和业务效果同事务

接收端 B 库先向 inbox 插入 ID,使用主键约束与 ON CONFLICT DO NOTHING 判断首次处理。只有插入行数为 1 时才写入 effects 表,两个动作在同一个本地事务内 commit。

若把去重键先提交,再在另一个事务里写业务效果,中间失败后,重试会因为 inbox 已存在而跳过效果,形成“去重成功但业务丢失”。反过来先提交业务效果再记录 inbox,则可能在重试中重复执行。幂等要求覆盖实际副作用边界,而非只在内存 Set 中记下请求 ID。

本实验的业务效果是同一 B 库的一行记录,因此可以和 inbox 共享事务。如果真实效果是外部扣款或第三方 HTTP 调用,这个本地约束不能包住远端结果,必须继续分析那个系统提供的幂等键、查询与重试协议。

最终证据分为三项:A 库业务与 outbox 已提交且确认已记录;HTTP 确实投递两次;B 库 inbox 一条、effects 一条。不能将它压缩成“消息恰好投递一次”,因为传输层明明发生了重复。

两种协议怎样比较

XA 把多个支持协议的资源纳入一个持久化决定,恢复要处理 prepared 状态;outbox 把业务与发送意图留在一个本地提交范围,跨服务完成依赖后续投递与幂等处理。选择依据应是实际资源能力和业务允许的中间状态,而非事务注解是否方便。

观察窗口 XA 实验 outbox 实验
数据库业务提交 两个分支最终服从 COMMIT 决定 先提交 A 的订单和发送意图
进程故障恢复 新 JVM 扫描 prepared 分支 重试未确认的发送记录
重复处理 恢复按分支身份处理 真实 HTTP 重复,由 B 的唯一键去重
业务完成证据 两库独立查询与 prepared 清零 本地状态、投递次数、接收效果分别读回

复现与预测

第 00 篇下载工程中的 consistency-lab/run.sh 接受 JDK 21、PostgreSQL 18 源码构建目录 PG_SRC 和安装 share 目录 PG_SHARE。脚本创建自己的临时集群,固定使用 55433;该端口必须空闲。完整命令与目录要求写在模块 README。

1
sh consistency-lab/run.sh

原始证据保存到 evidence/E04/local-20261002/,包含启动参数、决定文件、预期崩溃退出码 17、恢复和 outbox 输出、最终退出码 0及数据库关闭记录。

修改练习:保留第二次 POST,但移除接收端的主键去重分支,预测传输次数和业务效果是否还相同。另一个练习是在两个 prepare 完成后暂停,使用独立会话查询业务表和 pg_prepared_xacts;预期业务数据尚不可见,而 prepared 记录存在。不要对未知 prepared 事务执行手工提交或回滚,实验只操作自己创建的两个数据库与固定 ID。

前置阅读:深入 Spring 19:编程式事务、连接绑定与提交结果 与 深入 Spring 40:观测事件、资源等待与业务终态。

参考资料