深入 Spring 19:编程式事务、连接绑定与提交结果
回调成功,订单可能没有提交
一次下单操作在事务回调里写入订单,返回金额 25.00。单元测试看到返回值正确,日志显示 INSERT 执行成功,另一个数据库会话却查不到订单。这些观察可以同时成立:回调可能设置了 rollback-only,也可能只是参加外层尚未完成的事务。方法返回、SQL完成和事务提交是三个不同的时点。
另一种更危险的情况是订单被回滚,通知记录却留在数据库。假如通知代码从原始连接池另取了一条开启 autocommit 的连接,它就没有使用当前事务绑定的连接。两个方法在同一个线程里运行,不足以证明它们属于同一个物理事务。
本篇通过普通 JDBC 和 TransactionTemplate 对照这两个问题。基线为 Spring Framework 6.2.11,源码提交 4c134254642d88e058aa004bdaf44168e1be7bb2;应用使用 JDK 21、pgJDBC 42.7.7、HikariCP 5.0.1 与真实 PostgreSQL 18.0。所有数据库结果均由独立 DriverManager 连接在 READ COMMITTED 下查询。没有用 H2 或假事务管理器替代提交与回滚。
完整入口为实验工程中的 blog.spring.Chapter19,数据库帮助类为 LabDatabase。第 00 篇的下载工程包含完整 import、异常处理和清理路径。正文的 SQL 与运行摘录用于解释证据,完整 Java 程序以工程文件为准。
先建立 JDBC 的物理边界
JDBC 连接默认通常开启 autocommit,但这由 DataSource 配置决定,不能当成所有连接的固定状态。本实验的 Hikari 配置保留 autocommit 默认值;普通 JDBC 对照从池中借出一条连接,显式执行 setAutoCommit(false),之后用 commit() 或 rollback() 完成当前事务。
记录1被 INSERT 后,执行该语句的连接已经能看到它,独立连接仍读取0行。调用 commit() 后,独立连接读取1行。接着同一写入连接添加记录2并调用 rollback(),独立连接始终读不到记录2。这三个断言给出了本篇后续推理的物理基础。PostgreSQL 事务
READ COMMITTED 是观察条件的一部分。在 PostgreSQL 中,每条语句使用相应的已提交可见性;如果观察者长期保持 REPEATABLE READ 事务,即使写入已提交,旧快照也可能继续看不到数据。本实验每次查询都新建观察连接,避免把旧快照误当成未提交。观察者不从 Spring 的事务资源表取连接,也不复用写入会话。
Hikari 返回的是连接租约。Java 包装对象的身份、数据库后端会话身份和事务身份并非同一层。源码实验同时打印 pg_backend_pid()、pg_current_xact_id_if_assigned() 和 JDBC 隔离级别。后端 PID 在连接复用时可能保持,新的事务ID却会变化;一个包装对象的 toString() 不能单独证明两次业务属于同一物理事务。
一次真实运行打印:
1 | |
这里的 2 是 JDBC 的 TRANSACTION_READ_COMMITTED 常量。PID 与事务ID只说明这次运行的状态,下次无需重现同一数字。pg_current_xact_id_if_assigned() 也不保证事务刚开始就有值:本例在写入之后读取,已经产生事务ID;没有写入时返回空值不能据此断定“没有事务”。
Template 管范围,管理器管资源
TransactionTemplate 的职责不是生成 SQL,也不是维护连接池。它把业务回调包在一个事务完成边界内。execute() 先调用管理器的 getTransaction(this) 取得 TransactionStatus,再调用回调。如果回调抛出 RuntimeException 或 Error,进入回滚路径后传播异常;正常返回则调用管理器的 commit(status),完成之后才把回调结果返回调用者。固定版本 execute 实现
这里需要保持两个对象的区别。TransactionDefinition 描述期望的传播、隔离、只读和超时等属性,Template 自身携带这些配置;TransactionStatus 描述这一次调用取得的状态,包括是否新事务、是否持有保存点、是否被标记回滚以及是否已经结束。配置可以复用,状态属于一次调用,不能把同一 status 留到下一次提交。
本例选择 DataSourceTransactionManager,只管理一个 JDBC DataSource。没有现有事务时,管理器借出连接,准备隔离与只读设置,必要时关闭 autocommit,并把 ConnectionHolder 绑定到当前线程。绑定的键是 DataSource,不是方法名或业务对象名。doBegin 与清理实现
这条调用链包含可观察的状态变化:无 holder → 持有连接 → autocommit=false → 线程资源已绑定 → 回调执行 → 物理完成 → 解除绑定和归还租约。决定分支是“当前资源是否已经参加事务”和“本次状态是否代表新事务”。外部后果则是实际连接上提交还是回滚,以及独立会话能看到哪些记录。
在回调里,两次 DataSourceUtils.getConnection(pool) 返回同一个绑定连接。实验还检查 hasResource(pool) 与 isActualTransactionActive() 均为 true。后者是 Spring 的线程事务元数据,不能代替数据库终态;与实际连接及独立查询一起使用,才形成完整的证据链。连接获取实现
JdbcTemplate 内部采用这套资源获取与释放机制,因此本例的 INSERT 使用当前绑定连接。直接使用 JDBC 的代码也可以通过 DataSourceUtils 参加事务,但释放时应对应调用 releaseConnection();把事务拥有的连接直接放进普通 try-with-resources 并关闭,可能提前结束其他参与者还要使用的连接。
[PATTERN] 判断事务归属时,沿 DataSource → holder → Connection 追踪资源,不能只沿 Service → Repository 追踪方法调用。
内层返回为何没有第二次提交
外层 Template 写入记录3,再调用采用默认 REQUIRED 的内层 Template 写入记录4。内层 isNewTransaction() 为 false,取得的连接与外层相同。内层返回后,外层还拥有该物理事务的完成责任。
AbstractPlatformTransactionManager 在取得状态时区分新建与参加现有事务,在 commit() 时又检查 rollback-only、保存点和 isNewTransaction()。所以“调用了 manager.commit”不等于“执行了 Connection.commit”。参与者完成逻辑范围时,通常不会提交外层拥有的物理事务。管理器的状态分派
实验用管理器子类在物理 begin、commit、rollback 和 cleanup 成功之后记录计数与线程。两层 REQUIRED 最终只产生一次物理 begin 和一次物理 commit,独立观察者在外层完成后同时读到记录3、4。下面是日志节选:
1 | |
这段输出不声称日志能解决所有提交不确定性。数据库已经提交而网络确认丢失,调用者可能仍观察到异常;这类故障不在本篇实验窗口内。本例的“confirmed”表示本地 JDBC commit 调用已经正常返回,并有独立连接读到记录,不能推广成跨服务业务全部成功。
三种回滚路径给出不同的观察
第一条路径在回调插入记录5后抛出 IllegalStateException。Template 在异常穿过边界时请求管理器回滚,然后向调用者传播异常。独立连接查询结果为0,物理 rollback 计数增加,线程资源被解除绑定。SQL成功和业务失败之间没有矛盾。
第二条路径插入记录6,执行 status.setRollbackOnly(),随后正常返回字符串 accepted-by-callback。管理器在 commit() 入口发现本地回滚标记,转入 rollback 流程。Template 返回该字符串,独立连接依然读取0行。这是“正常返回不能证明提交”的可执行反例。
这里的本地回滚标记和内层参与者造成的全局回滚标记需要分开。后者可能在外层尝试提交时抛出 UnexpectedRollbackException。不能由本例的正常返回推断所有 rollback-only 都悄悄返回,也不能由另一个传播实验的异常推断 setRollbackOnly() 必然抛异常。第21、22篇分别深化传播和回滚规则。
第三条路径在外层事务写入记录8,然后从原始 pool.getConnection() 取得另一条连接。该连接的 autocommit 为 true,isConnectionTransactional(direct,pool) 为 false,它独立插入记录7。外层随后抛异常,记录8回滚而记录7保留;同一回调中的内存计数器也保持递增后的1。
| 情况 | Template 调用者观察 | 独立数据库观察 |
|---|---|---|
| 正常执行新事务 | 返回金额25.00 | 记录3、4存在 |
| 未捕获 RuntimeException | 收到 IllegalStateException | 记录5不存在 |
| 本地 rollback-only | 收到正常字符串 | 记录6不存在 |
| 原始连接独立写入,外层异常 | 收到 IllegalStateException | 记录7存在,记录8不存在 |
“同线程”在第三行资源关联之外还需要正确的取连接入口。DataSourceTransactionManager 不会改变所有 JDBC 连接的行为;使用 TransactionAwareDataSourceProxy 时,普通 getConnection() 可以经过另一个适配边界,这也说明应追踪实际 DataSource 类型,不能把本例对原始 Hikari 池的结论推广到所有代理 DataSource。
[PATTERN] 回滚范围由实际参加的资源决定。数据库事务不会自动恢复 Java 字段、另一条 autocommit 连接或远端已经接收的请求。
完成后的资源状态也属于验收
doCleanupAfterCompletion() 解除新 holder 的线程绑定,恢复此前改动的连接状态,再归还连接。这里恢复 autocommit 的条件取决于管理器开始事务时是否负责改变它,隔离级别和只读标记也需要按先前状态处理。不能简单假设事务完成后所有连接都强行变成同一设置。
本实验最终检查三项:没有 pool 对应的绑定 holder,Hikari 的 active connections 为0,下一次租约的 autocommit 与 READ COMMITTED 均符合池配置。active=0证明租约已归还,不能证明所有物理连接都已经销毁;整个池的关闭由最外层 try-with-resources 完成。
上游 DataSourceTransactionManagerTests 中的 testTransactionCommitWithAutoCommitTrue 和 testTransactionRollbackWithAutoCommitTrue 用 mock 核对关闭 autocommit、完成事务、恢复 autocommit 和 close 的顺序。本篇读取这些测试,但没有运行上游 Gradle。实际实验增加了真实 PostgreSQL 的独立可见性;源码测试与数据库实验各自证明不同层面的行为。相关上游测试
复现、反例题与改动练习
先按工程 db/README.md 启动独立 PostgreSQL。默认地址为 127.0.0.1:55432,数据库和用户都是 spring_lab。示例表使用 spring_ch19_orders,数量约束为正,金额为 numeric(12,2),主键用于区分各个场景。运行前指定 JDK21;本机默认 Java8不能编译此工程。
1 | |
完整输出保存在 evidence/19/local-20261002/run.txt,退出码为0,共21条 CHECK PASS。日志中的 SLF4J 无 provider 提示表示本工程没有绑定日志实现,事务事件由实验显式记录,不据此判断框架没有执行相关操作。
反例题:把原始 pool.getConnection() 换成 DataSourceUtils.getConnection(pool),同时把普通 close 换成对应 release,外层仍抛异常,记录7还会保留吗?在同线程、同原始DataSource、同一个外层事务内,新的取连接入口会复用绑定连接,记录7、8都会回滚。不要只改获取而继续提前关闭事务连接。
改动练习:把记录6场景的 setRollbackOnly() 删除,再运行全部断言。旧断言应失败,独立查询应读取1行;按新的业务契约修改这一断言之后,其余场景应继续通过。这个练习把“修改配置看日志”变成“明确预测终态并验证”,也能检查回归断言是否真的约束了行为。
| 判断目标 | 最小证据 | 常见误判 |
|---|---|---|
| SQL是否执行 | 受影响行数与异常 | 当成已提交 |
| 是否参加事务 | holder、实际连接和status | 只看方法注解或线程 |
| 是否物理提交 | manager完成、独立连接终态 | 只看内层返回 |
| 是否清理资源 | holder解除、租约归还、池关闭 | 把active=0当成物理连接全关闭 |
参考资料
- 编程式事务契约:滚动6.2文档,内部实现以固定SHA核验。
- PostgreSQL18事务说明:提交、回滚和会话可见性的数据库边界。
- 固定6.2.11 TransactionTemplate:回调、异常与完成的次序。
- 固定6.2.11 JDBC事务管理器:连接获取、绑定、物理完成和清理。
- 固定6.2.11上游测试:连接状态与参与者回滚的测试结构。
