Java EE 企业应用 18:本地事务与 Jakarta Transactions 谁负责提交
两张表一起写,提交权只能交给一个边界
采购申请要插入 purchase_request 和多条 purchase_line。如果申请落库后明细插入失败,留下的申请既无法计算金额,也不该被审批。try-with-resources 保证连接和语句被关闭,并不保证两次写入构成一个事务。更容易忽略的是:在应用服务器里,拿到同一个 DataSource 的连接,也不能推断代码有权调用它的 commit()。提交权属于事务边界,而不属于“最后一个执行 SQL 的类”。
本系列的基线是 Java 21、Jakarta EE 11、Open Liberty 26.0.0.5、PostgreSQL 16.15、pgJDBC 42.7.7。累计工程的 examples/javaee-enterprise/application/src/main/java/blog/javaee/application/ProcurementUseCases.java 已用 @Transactional 包住 draft;adapters-jdbc 模块的 JdbcRequestStore.insert 用受管 DataSource 先插申请再批量插明细,借到的连接由 try-with-resources 关闭。webapp 中仅本地隔离实验开放 /api/lab/requests/abort;X-Lab-Tenant 是可伪造的测试输入,不代表认证或授权。
不受管的 JDBC:一个连接、一个提交者
独立命令行程序直接使用 DataSource 时,可以由调用者决定一个物理连接上的本地事务。以下类可在 Java 21 编译,是独立 JDBC 设计示意,未加入累计工程,NOT_RUN;调用它之前仍须自己提供指向隔离库的 DataSource,并创建本文所述的测试表:
1 | |
此代码只说明控制边界,并非现有 JdbcRequestStore 的实现;RETURNING 是 PG16 语法,不保证换库通用。两个语句必须使用同一个 connection;如果每个 DAO 各调用一次 getConnection(),业务方法自己在第三条连接上 commit() 毫无作用。自动提交默认值及关闭时未结束事务的处理不要靠猜:开始前显式设置,失败时显式回滚,再用独立连接观察最终行。异常若在提交阶段发生,调用者未必知道提交是否已生效,不能无条件再发一次创建订单请求;第 22 篇处理这种未知结果。
受管调用:让事务拦截器完成提交
累计工程采用另一种提交者:受管 CDI 方法的 jakarta.transaction.Transactional 拦截器在方法外建立或加入事务,正常结束时由容器完成事务;JdbcRequestStore 只拿连接、执行 SQL、关闭借用句柄。DataSource 必须配置成可参与该运行时事务,不能以注解存在替代实际验证。数据库的 javax.sql.DataSource 是 Java SE API;jakarta.transaction.Transactional 是 Jakarta API,两者的包名不相互替代。
在现有代码中,draftThenAbortForLab 本身带 @Transactional,内部调用 draft 后抛 IllegalStateException。内部 draft(...) 调用并不是一次穿过代理的独立入口,不能据此宣称两个事务或者 REQUIRES_NEW;外层受管调用仍包住 SQL,未经捕获的运行时异常使事务回滚。对照 JPA 的 flush 与 commit 区别可读JPA 的 flush 与回滚,JPA 的 JTA 入口与本地事务分野见JPA 事务与冲突。这里仍以直接 JDBC SQL 为主线,不把 EntityManager 引进累计工程。
UserTransaction 可用于支持编程式划界的组件,但不能在已有容器事务里再手工 begin/commit 以“补强” @Transactional;会话 Bean 的容器管理事务也不能任意改成 Bean 管理事务。要改边界,先确定入口和组件允许的事务管理模式,再选一种所有权模型。单一 PostgreSQL 库内的受管提交不等于两个资源上的 XA;XA 与恢复留给选修 E05。
正常、失败与证据
现有脚本只在隔离 javaee_lab、本地回环部署、JAVAEE_DEMO_MODE=true 下使用。先按 examples/javaee-enterprise/README.md 创建专用库、部署 WAR、配置本地凭证;勿在生产启用教学接口。以下是已实现的复跑命令,执行前自行设置密钥,不把密钥写入文件:
1 | |
正常脚本完成采购故事,并查询数据库确认申请、明细和订单;失败脚本调用 /procurement/api/lab/requests/abort,预期 HTTP 500 和该新建测试租户的申请行数 0。后者还观察 PostgreSQL 的 identity sequence 增加,佐证 INSERT 到达数据库;序列号回滚不复原,序列空洞不是提交成功的证据。已归档的受管故障实验见 writing-plans/javaee-enterprise/verification/20261004T063400Z-pg16-jta-rollback/RUN.md:500、独立连接计数 0、申请和明细序列推进,脚本退出码 0。这支持此隔离版本的单库回滚路径;本章未重新部署或运行。独立 JDBC 片段、UserTransaction、双资源 XA、超时和提交响应丢失均 NOT_RUN。如何记录命令和结果见本篇实验卡。
为什么连接关闭不等于事务结束的证明
可以把一次创建申请拆成五个时点:受管入口建立事务、DAO 借连接、两张表分别执行 INSERT、方法返回、事务管理器完成提交。异常发生在第三步与第四步之间时,方法返回失败,受管入口有机会标记回滚;如果连接已经关闭,归还的只是应用持有的逻辑句柄,连接池仍可以维持底层物理连接。不能从“方法里的 close() 已执行”推出数据库已经提交或者回滚。反过来,方法正常返回也不是向客户端承诺“数据库已经提交”:拦截器的提交发生在方法体退出之后,提交期间的网络错误、唯一约束冲突或服务器失效仍会改变对外结果。
这个时序解释了为什么不该把事务注解加在每个 DAO 方法上再希望它们天然拼成一个整体。业务原子单位是“申请和明细”,不是“插申请”和“插一条明细”。当外层已经存在事务时,使用默认的参与式属性,多个 DAO 操作可以加入同一边界;若有人在其中一段改为独立新事务,即使都调用受管方法,也可能使申请先提交、明细后失败。设计评审时,应从外部入口沿调用链标出第一个启动事务的方法、每个被调用方法的传播属性、每次获取资源的配置以及实际提交点,而不是只数有多少个注解。当前工程没有给申请与明细分别配置独立事务,以上是变更时需防范的反例,并未实测。
本地 JDBC 的“同一个连接”还意味着失败期间必须维持连接所有权:调用者不能在 DAO 写入后立即释放,再指望隔壁 DAO 借回池中碰巧同一条物理连接。连接池可能交还完全不同的连接,连接对象就算看起来相同也没有可移植的事务连续性保证。直接管理本地事务时可显式把 Connection 当参数传递给多个存储操作,并由上层唯一地提交、回滚;但若把这一写法原封不动带进容器管理资源,会出现所有权冲突。工程主线选择受管事务和 DataSource,示意的 LocalDraft 故意放在累计工程以外,不是建议在 JdbcRequestStore 里增加 setAutoCommit(false)。
数据库回滚的范围也需要逐项列出。purchase_request 与 purchase_line 属于同一 PostgreSQL 数据库,故障注入演示的是这两个写入的终态;已分配的序列号在 PG16 不随事务回滚。日志输出、进程内计数器和已经调用的外部接口更不在这次回滚范围内。即使 scenarios/18-lab-rollback.sh 返回退出码 0,也只表示脚本的几个断言成立;它没有证明所有相关数据都不会留下副作用。涉及外部副作用时,要在同库留下可恢复任务,或者建立跨资源协调及明确的补偿协议,不能把本地回滚扩充成“整个世界恢复原状”。
将正常和失败观察写成可判定的表
正常路径至少看三件事:独立连接能找到申请、它的明细条数及金额与输入一致、申请状态和订单引用符合状态机。脚本 09 已覆盖一段采购故事,但本篇要的事务证据还应绑定同一个测试租户与申请 ID 保存每一步 SQL 行集合,避免旧数据让统计数看起来合理。对于失败路径,选择临时唯一租户,确认服务端确实执行了插入再抛错,脚本 18 从另一连接查已提交申请数 0,随后核对明细表按该租户申请关联也没有孤儿记录。因为 purchase_line.request_id 有外键,孤儿记录本来也不应存在;这个检查只能辅助定位,不能替代申请表终态和故障位置的证据。
一种有效但本章没有执行的边界测试,是在明细的第二条 INSERT 处注入数据库约束错误:先在申请事务里插入有效第一条,再让第二条违反 quantity > 0。预期由受管边界统一撤销申请及第一条明细,并保存导致故障的约束名、事务异常、第三连接查询值。其定位价值与简单的“所有插入完成后主动抛错”不同:前者覆盖 SQL 本身报错、异常包装和数据库已中止事务的路径,后者只检验写后 Java 运行时异常。这一用例目前没有代码或脚本,写在实验卡作为后续可实现的失败合同,不能拿已有序列推进记录充数。
另一组正常与边界对照是显式编程式 UserTransaction。如果实验组件允许调用它,可以在受控入口手动 begin、执行两张表写入、commit;在第二次写入前抛错时执行 rollback,并用第三连接检查终态。关键对照不是“手写的事务性能如何”,而是业务方法是否同时被 @Transactional 包围,以及资源到底加入哪一个事务。保留两套边界会让异常处理甚至提交责任都说不清,不能作为生产模式。是否允许某个组件调用 UserTransaction 要按组件规则与实际服务器环境核实;目前没有该组件或日志,状态保持 NOT_RUN。
为什么历史证据需要限定到确切版本
已归档的回滚记录同时保存构建、服务器输出、脚本代码哈希和第二次加入序列检查后的脚本哈希。两次脚本字节不同,不能拿第一次的 SHA 声称第二次也运行了同样字节;数据库终态与“INSERT 曾触发序列”需要分别看两次记录。更重要的是,今天源码存在不等于归档 WAR 一定由今天的工作树构建。复跑时先记当前构建哈希和服务器加载的 WAR,确认 JAVAEE_DEMO_MODE、JDBC 数据源库名、隔离租户和 HTTP 目标都指向同一次测试,再看输出;否则成功可能来自旧部署,失败也可能只是连到别的环境。此处说明复核方法,不声称本轮重新部署。
判断事务是否“已完成”最终仍取决于用例级契约:对 draft,成功意味着申请与全部明细都能由新事务读到;对故障入口,成功意味着两个表的相关写入都不可见。两个判据应绑定同一个业务 ID 和版本,不能只写“HTTP 返回 201/500”。如果业务在 order 内先生成订单再迁移申请状态,事务的覆盖集合还应包括 purchase_order;本章的故障注入只覆盖申请/明细,不替订单回滚做证明。后续每增加一个写入资源,都需要重新画边界和扩展终态查询,而不是沿用旧的“通过”标签。
再补一个判断提交阶段错误的方法:把“方法返回前的失败”和“事务完成时的失败”分开记录。前者通常有清楚的 Java 抛错位置,服务端可以请求回滚;后者发生在受管方法体之外,即使代码日志显示“准备返回订单 ID”,客户端最终仍可能收到失败或者断线。不要把打印了一行“完成业务方法”误写成数据库已提交,也不能把 commit 抛异常概括成肯定无行。必须以新连接核对结果,必要时按第 22 篇的稳定业务操作身份回查,才能决定是返回旧结果还是发起新动作。反复调用没有操作身份的创建接口并不能让未知结果自动消失。
若复跑发现申请表为零但明细表有记录,先检查是否查错了测试租户、用了不同数据库,或者明细外键是否和本文假设一致;这不是可以随手改成“预期残留”的实验结果。故障注入的前提是新建数据只属于这次测试,在生产库里混入历史行会让零行断言丧失意义。把数据准备、故障位置和终态查询写在同一张实验记录里,才有资格比较另一次运行是否保持相同原子性。
两道练习与答案
练习一: 一个服务方法依次调用两个 DAO,DAO 各自借连接,服务方法拿到第三个连接执行 commit()。明细插入失败,能否保证申请回滚?
解: 不行。本地 JDBC 事务附着在特定连接上,第三条连接的提交既不能提交也不能撤销前两条连接的 SQL。独立程序应由调用者向两个 DAO 传递同一连接并统一 commit/rollback;当前受管工程则通过受管入口和参与事务的 DataSource 统一划界,不在 DAO 里提交。两种模式不能拼接。
练习二: 故障接口返回 500,数据库主键序列推进,却查询不到对应申请。应判定“数据库没有执行 INSERT”还是“申请曾写入但未提交”?
解: 在本次已记录的 PG16 场景,序列推进和服务器故障位置支持后者;另一连接终态 0 支持事务回滚。单看 HTTP 500 或序列空洞都不足以独立证明是哪种结果,仍要核对隔离租户、执行日志及最终行。不能从这一例推断提交阶段异常也必然回滚。
限制与版本资料
事务原子性只覆盖实际参与同一事务的数据库写入;短信、远程 HTTP 和消息队列不是自动附带的同一提交。数据库约束与业务规则仍要独立验证。版本依据:Jakarta Transactions 2.0 规范、Jakarta EE Platform 11、Java SE 21 Connection Javadoc、PostgreSQL 16 事务隔离。本地实验只能证明冻结组合的有限路径,不替代规范、其它应用服务器或生产环境验收。






