采购库提交了,外部结算库还停在 prepare

采购订单在数据库 A,供应商结算记录在数据库 B。两个普通 JDBC 连接分别 commit(),即使都写在一个 Java 方法里,A 成功、B 抛错时也无法撤销 A。把业务代码包上 @Transactional,如果连接本身不是可登记到同一事务协调器的 XA 资源,仍不能声称两个资源原子提交。本篇实验必须使用两个真正不同的资源管理器,例如两个隔离 PostgreSQL 16 实例、各自 XA-capable DataSource,由同一个 Jakarta Transactions 协调器管理;主线只有一个 jdbc/Procurement 普通数据源,至今没有第二库、XA 配置或协调器恢复日志,故所有 XA 路径 NOT_RUN。

XA 提供的是协调两个事务分支的协议,不保证每一步都不会失败。对每一订单,应用先在 A 写 purchase_order 或其财务提交标志,在 B 写 supplier_settlement,两条 SQL 都绑定同一幂等业务键,且两边最终行与协调器结果可关联。结算通知邮件或 HTTP 供应商接口不自动变成 XA 资源;Mail、普通 REST 与本地事务不能算作第二 XA 参与者。若业务主要需求只是可靠异步通知,主线订单+outbox 同库事务更简单;E05 讨论确实要求两项独立 XA 资源一起提交时需要付出的恢复成本,不推荐把一切外围系统包成 XA。

从调用入口到两条分支

Jakarta Transactions 2.0 的 @Transactional 或受管 Bean 事务入口控制事务边界;由服务端配置并实际登记的 XADataSource 给出参与 XA 的连接。应用不能自己在 JDBC 连接上 setAutoCommit(false)、分别调用两个 commit(),再称之为 JTA 事务。XAResource 的 start/end/prepare/commit/rollback/recover 由容器与驱动协作完成。拿到第二条连接却发现它未 enlist(例如配置成非 XA 或直接 DriverManager.getConnection),事务协调器只看见一个分支,可能走单阶段提交优化;HTTP 200 不足以区分真实两阶段与单资源事务。

目标公共服务方法如下;@Resource 的具体 JNDI 名必须在隔离服务器分别绑定两项真实 XA 数据源,两个表和角色须在不同 PostgreSQL 实例建立。片段没有相关服务器配置,不能独立部署或证明 enlistment。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package blog.javaee.optional;

import jakarta.annotation.Resource;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;

@ApplicationScoped
public class TwoResourcePosting {
@Resource(lookup = "jdbc/PurchaseXA") DataSource purchase;
@Resource(lookup = "jdbc/SettlementXA") DataSource settlement;

@Transactional(rollbackOn = SQLException.class)
public void post(long orderId, String businessKey) throws SQLException {
try (Connection first = purchase.getConnection();
Connection second = settlement.getConnection();
PreparedStatement order = first.prepareStatement(
"INSERT INTO posting(order_id,business_key) VALUES (?,?)");
PreparedStatement ledger = second.prepareStatement(
"INSERT INTO settlement(order_id,business_key) VALUES (?,?)")) {
order.setLong(1, orderId);
order.setString(2, businessKey);
ledger.setLong(1, orderId);
ledger.setString(2, businessKey);
order.executeUpdate();
ledger.executeUpdate();
}
}
}

代码以受管外层调用和实际 XA 注册为前提;示意中的 rollbackOn = SQLException.class 要求遇到该受检异常时回滚,仍须以实际两库行集合验证,而不能凭注解证明“两端均回滚”。两个库须各有独立唯一约束 UNIQUE(business_key),重复调用不能造成两次结算。javax.sql.DataSource 属于 Java SE,迁移到 Jakarta 命名空间后仍叫这个名字;它的 Java 类型不证明底层是 XA。可在服务器资源诊断和驱动类核对实际上使用 PostgreSQL 的 XA 实现,不要把“名字带 XA”当证据。

prepare 之前、之中、之后的不同结局

一次全局事务内,协调器为每项资源分配可关联的分支标识。先写入两库并不等于两库对外可见:未提交数据由各自隔离级别控制;prepare 会使数据库保留“已准备但还未收到最终决议”的资源,应用请求可能已经返回超时,数据库锁却尚未释放。系统须监测 prepared 分支持续时间与数量。如果只有一个资源真正登记成功,协调器可能优化成单阶段提交,正常回包与两阶段看起来相同;实验要从事务日志/驱动行为或故障窗口核对确实存在两个分支,不能靠故意抛异常后看到两库零行就断言真实 XA。

prepare 前某个业务 SQL 失败属于尚可整体回滚的普通阶段;两端都准备成功后,协调器必须在持久记录决定之后再指挥提交,否则宕机后会遗忘决议。协调器把“全局决定已提交”和“所有参与者都确认提交”作为不同状态保留,第二个确认长时间未到时不能随手从日志里抹掉事务。recover 只枚举参与者仍记得的分支,不是无条件替应用恢复订单 HTTP 响应;HTTP 客户端对结果不确定时依旧要按业务键查订单,避免同一笔业务又开新全局事务。

协调器首先结束每条工作分支,并问参与者能否准备提交。两个分支都投票可提交后,协调器必须持久保存可恢复的决议/日志,再通知各方提交。A 准备成功、B 准备失败时,应对已准备的 A 发送回滚,B 也回滚;两库最终均无该业务键。A 与 B 都已准备但进程在持久决议之前崩溃,恢复要读取协调器日志确认如何处理;若已经记录全局提交决定,在部分参与者收到 commit 后崩溃,另一个分支可能暂时仍处于 in-doubt,重启时协调器应以相同 Xid 重放决议并让两边收敛。不能仅看应用发出 commit 时抛不抛异常就判断“XA 保证同步同时可见”:准备后的锁可能一直占据资源,恢复窗口里下游查询观察到暂时不一致,协调器恢复之前不得人工猜测结果。

PostgreSQL 的 max_prepared_transactions 若为 0,无法执行准备事务;两个隔离实例均需在受控预算下启用并监测残留。pg_prepared_xacts 可显示处于 prepared 状态的数据库事务,但该视图本身不告诉应用全局决议;必须与协调器持久日志、两个数据库业务行和 XA 分支 ID 对照。不要在实验失败后直接人工执行 COMMIT PREPARED 或 ROLLBACK PREPARED 来“恢复”某个分支:如果与协调器决议相反,会制造启发式混合结局。手工修复只能在冻结两边日志、确定全局决议、核对外部副作用后以严格运维流程实施,且要留存因果链。

恢复不是一句 finally

XA 的代价也在资源占用。长事务从第一次数据库写入到最终决定都可能持有行锁和连接,若应用先访问远端慢 HTTP 再进入 prepare,会增加两库阻塞和死锁风险。协调器事务超时、数据库语句超时和连接池借用超时不是同一个计时器;超时后某分支已 prepared 时需要恢复决议,不能像释放普通 Java Connection 那样保证清理完成。评估是否引入 XA 时应列出两个系统是否真支持同一套受管协议、恢复日志是否有可靠存储、运维是否能查 Xid 并重连原资源;缺一项就不要把它放入订单主链。

如果两个数据库实际上连到同一 PostgreSQL 实例的同一资源管理器,服务端可能将其识别为相同资源并优化提交,无法作为“两个不同资源管理器”的证据。实验采用两个独立 PostgreSQL 进程与数据目录,各自可被独立停止/恢复,分别统计 pg_prepared_xacts;只给两个 JDBC JNDI 名但都指向一个本地库不满足 E05 约束。恢复日志既包含事务决议也可能含资源标识和敏感地址,需限制读写权限并备份在崩溃后可用的位置;故障注入只能作用于专用测试环境,绝不可停共享数据库。

finally 只会在进程还活着且正在执行 Java 代码时运行;SIGKILL、断电、内核失联都不会等应用收尾。恢复依赖协调器的持久日志位置、资源管理器 ID 和重启后可再次连接到原来那两个数据库实例;更换资源名称、清空事务日志或销毁数据库后拿一份新空库,哪怕新请求成功也不能证明旧 Xid 已获处理。原数据库凭证变更时协调器可能无法 recover,prepared 分支继续占有锁并阻塞新订单。需要明确故障注入与恢复的操作者权限、超时窗口、监测阈值和禁止擅自删除 prepared 记录的运维步骤。

还有一种需要单独分类的异常是启发式结果:某个参与者在未与协调器达成一致前单独作出提交或回滚,而另一方采取相反结果。协调器记录了提交意图不等于两个资源都实际提交;出现 heuristic mixed/hazard 时必须停止自动重复业务请求,保存协调器与驱动错误链,人工逐库核对并补偿业务,而不是把账本里的两行硬说成原子提交。即使两库成功一致,也只是两项数据库资源上的本次实验;不自动覆盖跨数据中心故障、邮件送达或外部供应商 HTTP 调用。

真正的 XA 最小实验

准备两个相互独立的 PostgreSQL 16 实例(不同数据目录与端口),分别建 posting 和 settlement 表;冻结两个 pgJDBC 42.7.7 XA 连接源、Open Liberty 26.0.0.5 的 XA 资源及事务日志配置、max_prepared_transactions、隔离级别、事务超时和应用部署版本。正常路径经受管入口调用 post,在提交后分别用独立连接查询两库同一键各一行,协调器无遗留 Xid;对照路径使第二库明确拒绝写入,验证 A/B 都无新行,并核对异常是否触发回滚。若 SQL 异常依旧允许提交 A,就是回滚规则或登记配置有误,不应调整预期为“部分成功也算通过”。

崩溃路径要求可定位在 prepare/commit 边界的故障注入或可观察的协调器阶段钩子,不等同于应用在两个普通 JDBC commit 之间自行停止。分开在准备前、首个参与者准备后、全局决议持久化后而另一分支未确认时注入并重启同一配置协调器,记录每阶段的事务日志、XA 分支 ID、pg_prepared_xacts、两端最终行集合与恢复耗时。若没有阶段钩子和恢复日志,只能报告“未能覆盖 prepare 边界”,不能编出 in-doubt 示例。再故意使一库重启滞后,观察 in-doubt 保留与恢复;恢复之前不能清理这个库。现有工程只有一个普通 DataSource,第二资源、XA enlistment、阶段故障与协调器日志全部 NOT_RUN。

验证还需有反事实对照:故意把 B 的 XA 数据源错误配置为普通非 XA 连接,观察服务器在受管事务中是拒绝、只登记 A,还是报明确资源异常;不同产品行为须以实际日志为准,不能事先写死 HTTP 状态。恢复原配置后重新执行新的业务键,把普通非 XA 对照与真正双 XA 运行记录分开放置。再延迟重启 B、先恢复协调器,检查 prepared 分支保持到 B 可联通才完成决议;若拿不到协调器持久日志,即便最后两库碰巧各有一条记录,也不能报告“prepare 边界崩溃恢复 PASS”。所有上述对照仍为 NOT_RUN。

两阶段提交和业务可重复调用仍是不同问题。协调器恢复决定的是这次全局事务的分支结局;如果客户端超时后换新请求号再次下单,会产生第二个全局事务,XA 不会自动知道两次是同一笔采购。两个库应各自以稳定业务键设唯一约束,上层在接到不确定响应时按键查结果,再决定是否返回已完成订单或尝试补偿。否则“两个库始终一起提交”与“用户只产生一份订单”可能前者成立、后者失败。

对于恢复验收,准备阶段杀掉应用进程与杀掉整个服务器的差异也要记录:仅客户端连接断开,服务器协调器可能继续正常提交;真正的崩溃需要让持有协调器日志的进程在目标时刻停止并从持久日志恢复。运行脚本必须能标记阶段触发点,数据库两边快照应包含业务键、分支状态和行集合。无法精确停在 prepare 边界就诚实记为 NOT_RUN,不要凭一个通用 SIGKILL 的随机成功称“XA 恢复已通过”。

prepare 被拒时协调器需要回滚之前已经准备的分支:B 的 SQL 错误发生在写入阶段,和 B 在 prepare 阶段投票失败不是同一实验。前者只能验证异常回滚链,后者才覆盖两阶段决议的失败分支。若缺少可控的 prepare 拒绝注入能力,应把该格单独保留 NOT_RUN;不得用一次 SQLException 直接把准备阶段验收标为通过。运行时还要核对原 A 分支不残留在 pg_prepared_xacts,而不只是检查申请行消失。

两道带答案的练习

练习一: 工程师把 DataSource A 与 B 的 JDBC 连接各设置 autoCommit(false),先提交 A 后提交 B;B 报错,于是称“这也是两资源 XA,只是失败分支需要补偿”。哪里错?

解: 两条本地事务没有一个全局协调器,也没有 prepare 与恢复记录;A 已提交不能用 B 的 rollback 撤销。这是两个本地事务加业务补偿,可能是特定场景的设计,但不是 XA。需改为两项真正 XADataSource,经同一 JTA 事务受管入口 enlist,保留协调器日志并在失败后读两端最终行和 prepared 状态;本实验尚未做。

练习二: A、B 都已 prepare,协调器发给 A 的 commit 成功后宕机,B 仍在 pg_prepared_xacts。运维人员想对 B 执行 ROLLBACK PREPARED 释放锁。应如何处理?

解: 不能仅为释放锁而回滚 B,否则 A 已提交/B 回滚形成不一致。先保护日志与 XA Xid,恢复同一协调器和 B 的连接,按协调器已持久化的全局决议完成 B 的分支;如果日志缺失或出现启发式结果,停止自动操作、记录两端业务行并人工对账。本文没有实际 prepared 分支,结论是应执行的验证步骤而非现场记录。

规范与证据

官方:Jakarta Transactions 2.0、Jakarta EE 11 Platform、Java SE 21 XADataSource、PostgreSQL 16 PREPARE TRANSACTION 及 PostgreSQL 16 pg_prepared_xacts。规范里的两阶段模型、pgJDBC 驱动能力、Open Liberty 当前配置及本地两资源恢复证据是四种不同层级;没有后两者绝不宣称 XA 已验收。