同一个方法里的两条写入,为什么只回滚一条

一个方法先用 JdbcTemplate 插入订单,再从连接池直接取得连接插入审计记录,最后标记回滚。订单消失,审计记录仍在。这不需要跨数据库才能发生:同一连接池提供的两条物理连接,就足以产生两个不同的事务结果。

事务管理器协调具体资源。代码位置相邻、使用相同 JDBC URL、位于同一个 Spring Bean,都不能证明两个操作参加了同一个事务。检查事务时,需要把管理器、资源键、连接取得路径以及数据库会话对应起来。

本文固定 Spring Framework 6.2.11、JDK 21.0.11、PostgreSQL 18.0、pgJDBC 42.7.7、HikariCP 5.0.1。ORM 使用独立工程冻结 Hibernate ORM 6.6.15.Final 和 Jakarta Persistence 3.1.0,避免把 ORM 依赖加入全部基础章节。Hibernate 6.6 的官方兼容矩阵列出 Jakarta Persistence 3.1 和 Java 21;这些版本在本章实际组合运行,不表示使用了最新版本。Hibernate 6.6 兼容矩阵

资源键、JDBC连接与EntityManager的事务归属

模板取得连接时多做了哪一步

JdbcTemplate 经由 DataSourceUtils.getConnection() 取得连接。DataSourceUtils 先按 DataSource 资源键查询线程绑定的 ConnectionHolder;找到当前事务持有的连接时,返回该连接。没有可复用资源时,才从 DataSource 取新连接,并视同步状态登记资源。

普通 HikariDataSource 的 getConnection() 负责借出池连接,并不知道调用者需要查找 Spring 线程绑定的资源。因此,在已经持有事务连接的线程里直接调用池的 getConnection,可能得到第二条连接。这里强调普通连接池,是因为 TransactionAwareDataSourceProxy 专门提供了另一种适配路径,不能把所有 DataSource 实现都当成同一种行为。连接查找源码

Chapter24.java 先通过模板写编号 1,然后从同一个池直接借连接写编号 2。程序查询两条连接的 pg_backend_pid(),断言后端编号不同,并检查直接连接的 autoCommit 为 true。随后把模板事务设为 rollback-only。独立连接最终读到编号 1 为零行、编号 2 为一行。

这组结果解释了两条写入的差异:模板写入在当前事务内被回滚,直接连接写入按照它自己的自动提交设置完成。连接池本身没有“破坏回滚”,错误在于第二条操作从未参加原来的事务。

相应的资源释放也不能随意混用。直接借用的连接可以由 try-with-resources 关闭并归还池;通过事务感知工具取得的连接,需要遵守工具的释放协议,避免在事务结束之前主动关闭共享连接。JdbcTemplate 封装的价值不仅是减少 PreparedStatement 样板,也包括这部分连接生命周期处理。

readOnly 的三层含义必须拆开

Spring 的事务定义保存 readOnly 标志;JDBC 驱动收到 Connection.setReadOnly 提示;数据库会话是否真正限制写入,则取决于驱动和管理器进一步采取的动作。把这三层合并为“只读事务一定禁止写入”,会遗漏配置差异。

在本章默认 pgJDBC 设置下,Spring 只读事务中执行 SHOW transaction_read_only 返回 on,插入普通实验表被数据库拒绝。这说明此驱动配置把只读提示传到了数据库,并不证明 JDBC 的通用提示对所有驱动具有相同强制力。

程序随后创建第二种 DataSource,URL 增加 readOnlyMode=ignore。保持 Spring readOnly=true,但数据库的 transaction_read_only 变为 off,编号 7 插入成功,独立连接能看到记录。只改变驱动对提示的处理,就改变了写入结果。pgJDBC 连接参数

DataSourceTransactionManager 还提供 setEnforceReadOnly(true)。在开启只读事务时,它主动执行 SET TRANSACTION READ ONLY。即使驱动设置为忽略 JDBC readOnly 提示,这条 SQL 仍让数据库拒绝编号 8 的插入。程序在两种驱动配置下都验证了强制路径。管理器实现

Spring 定义 驱动模式 enforceReadOnly 普通表写入
readOnly=true 默认 transaction false 被拒绝
readOnly=true 默认 transaction true 被拒绝
readOnly=true ignore false 编号 7 提交
readOnly=true ignore true 被拒绝

表格范围是冻结版本和本地 PostgreSQL 普通持久表。它不是数据库权限审计,也没有覆盖临时表等数据库特例。需要防止某个账号写入时,仍应配置数据库权限;事务只读模式不能替代权限边界。

ORM 事务还管理持久化上下文

JPA 代码通常先改变实体状态。Hibernate 的持久化上下文保存受管对象,flush 把待执行的更改同步为数据库操作。这个过程与提交不同:flush 可以已经发送 INSERT,事务仍然可以回滚。

JpaTransactionManager 以 EntityManagerFactory 为核心资源键取得或创建 EntityManager,把对应资源绑定到当前线程,再通过 JPA 的 EntityTransaction 开始、提交或回滚事务。它不是把 DataSourceTransactionManager 换了一个名称。SharedEntityManagerCreator 创建的代理会把当前调用委托给正确的事务 EntityManager。JPA 管理器源码

独立 orm-lab 工程使用 LocalContainerEntityManagerFactoryBean 与 HibernateJpaVendorAdapter,扫描一个实体 Entry。实体的主键由程序显式提供,避免自增主键为了取得编号而提前执行 INSERT,干扰“何时 flush”的观察。程序中每个编号都对应独立检查。

1
2
3
4
5
tx.executeWithoutResult(status -> {
em.persist(new Entry(1, "commit"));
em.flush();
// 当前事务内查询为一行,独立连接查询为零行。
});

这是完整实验的核心片段。flush 后,程序用当前 EntityManager 执行原生查询,确认自身事务中编号 1 已存在;另开 JDBC 连接仍读到零行。模板正常返回后再独立查询,得到一行。这三次观测依次区分:对象状态已进入数据库事务、其他事务尚不可见、物理提交已完成。

编号 2 则在显式 flush 后标记回滚,独立查询仍为零。SQL 日志出现 INSERT 并不能证明最终提交。把“Hibernate 打印过语句”作为保存成功指标,会在回滚、提交异常和连接故障时误报成功。

自动脏检查与显式 flush 的边界

第三个 ORM 场景读取编号 1 的实体,修改其 name 字段,不调用 persist,也不调用显式 flush。正常提交后,独立 JDBC 查询读到 dirty。Hibernate SQL 日志出现 UPDATE,证明该受管实体的变化在提交过程中被同步。

这个结果的前提包括:实体属于当前持久化上下文,事务允许更新,当前 provider 配置使用正常的 flush 行为。脱离持久化上下文的普通对象字段变化不会自动变成 SQL;手动 flush mode 或 provider 的只读优化也会改变结果。不能把这一例写成“任何 Java 对象改字段都会保存”。

Spring 的 HibernateJpaDialect 会参与事务开始与 flush mode 准备;JPA 管理器把 commit 委托给 provider,provider 执行持久化同步和数据库提交。异常可能来自 flush,也可能来自真正的提交。定位约束冲突时,应保留异常链并辨认发生阶段,不应只把异常归因于调用 em.persist 的那一行。

ORM 只读场景再创建一个 readOnly=true 的新事务。程序把当前 EntityManager 解包为 Hibernate Session,检查 flush mode 为 MANUAL,且默认实体只读标志为 true。随后加载编号 1 的实体,把 name 改成 ignored-change;正常结束事务后,独立 JDBC 查询仍得到 dirty。这里观测的是 Hibernate 持久化上下文的只读优化,它与前面的数据库禁止 INSERT 是两种机制。不能从这次字段变化未保存,推导绕过 ORM 执行的任意 SQL 都会被拒绝。

上游 JpaTransactionManagerTests.testTransactionFlush() 验证 flush 委托与提交、关闭行为;testTransactionCommit() 和 testTransactionRollback() 则检查对应分支。这些测试依赖替身对象,本文阅读了相关断言但没有运行上游测试套件。真实 PostgreSQL 上的可见性和终态,由本章独立实验提供。上游管理器测试

两个管理器没有自动组成全局事务

程序为两个 HikariDataSource 分别创建 DataSourceTransactionManager。即使两者连接同一个数据库,它们仍持有不同资源。外层通过管理器 A 写编号 5,内层通过管理器 B 写编号 6,内层正常返回并提交,外层最后回滚。

独立查询结果是编号 5 消失、编号 6 保留。第二个管理器的 REQUIRED 是在它能够识别的资源范围内决定是否参加;它不会因为线程里存在任何一种事务就自动参加那个事务。不同 DataSource 的资源键、不同物理连接和不同提交动作,构成两个本地事务。

因此,选择管理器必须和访问路径相匹配。JPA 操作需要能管理相应 EntityManagerFactory 的管理器;JDBC 操作需要使用能参加该事务的 DataSource 和连接取得方式。一个注解中选择了 transactionManager,不意味着方法里任意第三方客户端都被自动纳入。

JpaTransactionManager 可以在适当 JpaDialect 支持下,把底层 JDBC 连接暴露给同一 DataSource 的 Spring JDBC 访问,让两种 API 共用一个本地事务。这个能力要求明确的资源对应,不能推广成任意 JDBC 和任意 ORM 自动原子提交。本章没有实测这种混合访问路径,因此不把它计入已通过场景。JPA 管理器契约

跨两个独立资源需要原子提交时,应另外研究 JTA/XA、资源协调与恢复。选择 outbox 等方式时,则要承认并设计异步一致性,而不是把两个本地管理器嵌套调用命名为全局事务。

重现与反例练习

从系列实验工程目录运行 JDBC 场景,再运行独立 ORM 工程:

1
2
3
4
export JAVA_HOME="$HOME/.sdkman/candidates/java/21.0.11-amzn"
./mvnw -q compile exec:java -Dexec.mainClass=blog.spring.Chapter24
./mvnw -f orm-lab/pom.xml compile exec:java \
-Dexec.mainClass=blog.spring.orm.Chapter24Orm

数据库配置沿用第 23 篇本地实验库。JDBC 程序只创建并清空 spring_ch24_resource;ORM 使用 create-drop,仅对其唯一映射的 spring_ch24_orm 表建表并在关闭时删表。这个工程不应指向业务库,也不要让其他练习共用同名表。

原始证据在 examples/spring-framework-lab/evidence/24/:run.txt 包含 11 条 JDBC 断言,orm-run.txt 包含 7 条 ORM 断言,两个 exit-code 文件均为零。依赖树单独保存为 orm-dependencies.txt。实验通过 PostgreSQL 18.0,不以 H2 或模拟连接代替数据库结果。

练习:把直接连接路径换成 DataSourceUtils,并按它的释放协议管理连接,预测编号 2 的最终行数。第二题是在 ORM 场景中保留 flush、改为 rollback-only,观察 SQL 日志是否仍然出现 INSERT。第三题交换两个本地管理器的提交顺序,列出其中一个失败时另一个已完成的动作。这三题都要求使用独立查询验收,不能只检查方法是否抛异常。