深入 Spring 21:事务传播、保存点与连接池等待
内层成功不等于整个业务成功
外层事务先写订单,内层写审计记录。内层正常返回后,外层抛出异常。审计记录是否保留,取决于内部范围是否拥有独立物理事务:REQUIRED 的写入随外层一起撤销;REQUIRES_NEW 可以已经提交;NESTED 即使成功释放保存点,也仍受外层最终回滚约束。
方法嵌套只描述 Java 调用结构。解释事务传播,还要同时画出逻辑范围、物理事务、数据库连接三条线。一条连接在外层挂起期间仍被借出,内部的新逻辑范围也不必然对应新连接。混淆这两个方向,会同时误判数据原子性和连接池容量。
本文固定 Spring Framework 6.2.11、源码提交 4c134254642d88e058aa004bdaf44168e1be7bb2,使用 JDK 21、PostgreSQL 18.0、pgjdbc 42.7.7、HikariCP 5.0.1。实验通过 TransactionTemplate 直接指定传播,排除自调用是否穿过代理这一变量。第 20 篇声明式入口在解析属性后调用同一个事务管理器;以下结论仍要求那个入口真实生效。
三条时间线如何对应
AbstractPlatformTransactionManager.getTransaction 先检查是否存在事务;已有事务时进入 handleExistingTransaction。REQUIRED 参与现有事务,REQUIRES_NEW 保存并挂起现有资源后开始新事务,NESTED 在允许嵌套且采用保存点的管理器里建立保存点。传播分支源码
逻辑范围仍然有自己的 TransactionStatus,因此“共用物理事务”不等于“共用一个状态对象”。内层状态可以表示参与者,也可以持有保存点;外层状态负责最终的物理提交。判断方法应看 isNewTransaction()、hasSavepoint()、回滚标志及实际数据库身份,不能只数 execute() 被调用的次数。
| 外层已有 JDBC 事务 | 内层物理事务 | 连接使用 | 内层成功的持久性 |
|---|---|---|---|
| REQUIRED | 参与外层同一事务 | 使用外层连接 | 仍等待外层最终提交 |
| REQUIRES_NEW | 新事务 | 外层连接保留,再借一条 | 内层提交可以独立保留 |
| NESTED | 外层事务内的保存点范围 | 同一连接 | 保存点释放后仍受外层控制 |
这里的 NESTED 明确指 DataSourceTransactionManager 与支持 JDBC 保存点的 PostgreSQL 组合。该管理器构造时允许嵌套事务;抽象管理器的所有子类并不共享同一支持能力。没有外层事务时,NESTED 会建立普通新事务,不能凭注解名称推断一定存在保存点。JDBC 管理器源码
REQUIRED:捕获异常后仍可能回滚
第一组实验外层插入 required-outer,内层 REQUIRED 插入 required-inner 后抛出 Abort。内层模板把异常交给管理器执行回滚处理;由于它是现有事务的参与者,默认处理会将共享事务标为 rollback-only。外层捕获异常并正常结束回调,管理器仍在外层提交边界抛出 UnexpectedRollbackException。
数据库观察连接最终查询到空表。程序还在内层退出后检查外层 isRollbackOnly() 为真;外部异常、共享状态、数据库终态三项相互吻合。只记录“业务代码 catch 了异常”无法说明事务结果,因为回滚标志已经由内层事务边界写入。参与事务回滚路径
原始日志里,外层和内层的 PostgreSQL PID、txid_current() 完全相同。后者用于辨认当前顶层事务,不应被解释为保存点或子事务的独立编号。日志中的具体 PID 和事务号每次执行都会变化,断言比较的是同一次执行中的相等关系。
这组结论依赖默认的参与失败处理策略。管理器提供 globalRollbackOnParticipationFailure 等开关,但调整它不代表数据库能够从任意语句失败继续工作;例如数据库自身可能已将事务置为失败状态。应用若要继续一个局部失败后的业务流程,应先选择可恢复的数据库边界,而不是只禁止框架设置回滚标志。
REQUIRES_NEW:挂起后连接仍被占用
第二组外层插入 new-outer,内层 REQUIRES_NEW 插入 new-inner 并提交,外层随后抛出 Abort。内层开始时 PID 与事务号均不同;内层结束后,外层恢复到之前的 PID 和事务号。独立观察连接在外层尚未结束时已能读到 new-inner,最终外层回滚后它仍存在。
DataSourceTransactionManager.doSuspend 从当前线程解绑外层资源,把连接持有者交给挂起状态;doResume 重新绑定。解绑不等于关闭或归还连接:外层事务尚未完成,连接必须保留才能恢复。只有内层事务自己的清理会归还内层借来的连接。挂起、恢复与清理源码
这使独立审计具有明确业务代价:外层业务失败时,内部记录可能已经永久提交。若内部记录描述的是“订单创建成功”,就会留下与订单终态不一致的事件。传播配置只能定义提交边界,不能自动保证记录的业务措辞或后续补偿正确。需要跟随订单原子提交的数据不应仅为逃避回滚而改成 REQUIRES_NEW。
内层也看不到外层尚未提交的普通写入。本文用不同主键、没有外键的实验表,避免把连接等待与行锁等待混在同一用例里。在真实模型中,内层若需要外层持有的锁,增加连接也不一定能完成它;那属于数据库依赖与锁等待,必须另设 SQL 超时和锁观测。
NESTED:局部撤销与最终提交分开
第三组外层插入 nested-outer,内层建立保存点后插入 nested-inner 并抛出 Abort。内层的 hasSavepoint() 为真,PID 和顶层事务号与外层相同。管理器回滚到保存点后,外层回滚标志仍为假,能够继续插入 nested-after 并提交。最终表中只保留这两条外层记录。
保存点回滚把撤销范围缩小,但没有产生新的持久提交。反向实验令内层正常结束、外层失败,表仍为空。这个反例排除了“内层不抛异常就已提交”的解释。保存点状态实现
数据库回到保存点,也不会自动回滚 Java 对象字段、集合内容或外部 HTTP 调用。一次局部失败如果已经修改内存对象,再重新执行时仍需明确对象状态。保存点适合数据库范围内可局部撤销的操作;它不能被扩大成所有副作用的嵌套原子性。
上游 DataSourceTransactionManagerTests.testExistingTransactionWithPropagationNestedAndRollback 验证 rollback(savepoint)、释放保存点、外层提交和连接关闭;testPropagationRequiresNewWithExistingTransaction 验证独立完成与清理。它们使用模拟连接检查调用协议。本实验补充真实数据库记录、PID、事务号及池占用,不把上游 mock 测试当成 PostgreSQL 终态证明。上游 JDBC 事务测试
两个外层事务为何会用完两条连接
连接池实验固定两个工作线程、池容量 2、获取连接超时 1500 毫秒。每个线程先进入 REQUIRED,执行 SQL 取得一条真实连接,然后在屏障等待。主线程确认 active=2,才放行两个内部 REQUIRES_NEW 请求。此时两个外层连接仍未归还,池不能提供第三条连接,内部请求都进入等待。
为了让超时结果可重复,内部失败后仍保留外层事务,直到两个尝试都结束。若第一个线程一超时就释放外层连接,第二个请求可能赶在自己的截止时间前取得连接,导致“只超时一次”的合法竞态。第二道释放屏障刻意固定了资源持有状态,实验因此证明两个等待者同时受容量限制,而不是依赖偶然调度。
实际日志记录如下,耗时包含调度和异常构造开销,不作为精确计时承诺:
1 | |
Spring 对无法开始事务的情况抛出 CannotCreateTransactionException,最深原因是 Hikari 的 SQLTransientConnectionException。这与事务注解的 timeout 不同:前者是等候连接的期限;事务超时约束已经开始的事务,并通过相应资源机制落实。本文模板设为 10 秒,屏障和 Future 等待设为 8 秒,连接获取设为 1.5 秒,避免无限等待。
无截止时间时,这种“外层持有全部连接、内层等待新连接、外层等待内层结束”的关系无法自行推进。带获取期限的实验会失败返回,因此观察到的是有界资源等待及超时,不是进程永久死锁。官方文档也特别指出 REQUIRES_NEW 的额外连接需求。官方传播说明
容量修复能保证什么
对照把容量改为 3,保持两个外层事务和相同屏障。内部事务有一条额外连接可以依次借用。日志显示两个内部操作使用同一个数据库 PID,但事务号不同;两个内部记录都提交,外层仍按实验设定回滚。这证明连接被复用时,不能只用 PID 区分两次事务。
对于本文的单层 REQUIRES_NEW、每个内层只需一条连接且不等待外层锁的结构,外层并发数 + 1 能提供内部推进所需的一个空位。它不保证所有内层同时无等待,也不涵盖多层嵌套、额外数据源、其他共享池用户或数据库锁依赖。需要全部内层同时取得连接时,容量需求不同;生产值必须依据这些真实持有关系计算,不能把实验中的 3 直接变成通用配置。
限制外层并发也能恢复余量,例如容量 2 时只允许一个外层事务进入该范围。限制点必须位于取得外层连接之前;等持有连接以后才排队,仍会保留资源。若把业务拆成前后两个独立事务,先完成外层再运行后续操作,也会改变事务原子性与失败恢复协议,不能作为纯性能开关无条件替换。
| 调整 | 资源影响 | 语义代价 |
|---|---|---|
| 增加容量 | 为内层提供额外连接 | 提高数据库连接预算,不修复锁依赖 |
| 限制外层并发 | 减少同时持有的外层连接 | 改变排队位置与吞吐上限 |
| 改为 NESTED | 复用一条连接 | 内层不再独立持久提交 |
| 拆分业务事务 | 后续执行时无需保留外层事务 | 整体原子性改变,需要恢复协议 |
程序在两种容量场景都断言 activeConnections=0、threadsAwaitingConnection=0,工作线程事务状态清除、执行器在期限内终止;还通过同一个池再提交 recovery 并由独立连接读回。归零证明释放,后续提交证明资源还能继续服务,两者覆盖不同故障。
完整实验与重跑
源码位于 examples/spring-framework-lab/src/main/java/blog/spring/Chapter21.java,另附 Chapter21.java。完整依赖、Maven Wrapper 和辅助类在实验工程。使用 JDK 21,先按工程 README 启动 PostgreSQL 18.0,默认实验地址为 127.0.0.1:55432/spring_lab、用户 spring_lab。
1 | |
运行创建、清空并在 finally 删除 spring_ch21_rows,只允许在实验库执行。所有内部行使用不同主键,观察查询由池外的新连接执行。SQL、事务身份、线程名、连接池状态及断言保存在 evidence/21/local-20261002/run.txt,退出码另存 exit-code.txt;数据库终态在删除表之前已经读回并断言。
反例与可执行练习
反例题:把内层 REQUIRED 改成 NESTED,外层捕获异常,是否保证业务继续成功?只能保证本例 JDBC 保存点可撤销内部写入;如果内层另有外部副作用或更早已标记整个事务回滚,结论不成立。必须重新观测状态和终态。
练习一:将 poolCase(3, false) 改为容量 2,并保留“两个内部提交”的预期。失败应发生在超时数量或提交数量断言,日志应出现两个等待者;恢复容量 3 后应通过。练习二:把 propagation() 中 NESTED 的内部异常删除,并修改终态断言为包含内部记录;再让外层抛出 Abort,独立观察应得到空表。练习三:保留容量 2,将工作线程、屏障和预期计数一致改为 1,验证限制外层并发后的资源恢复。不要只延长超时来让测试等待更久。
[PATTERN] 分析传播时同时保存 逻辑范围 → 物理事务身份 → 借用连接数 → 最终数据 四类证据。逻辑范围解释谁申请回滚,物理身份解释哪些写入共同提交,借用数量解释等待条件,数据库终态解释业务实际留下什么。
参考资料
- Spring 6.2 事务传播文档:文档随 6.2 分支滚动,本文实现细节按 6.2.11 固定源码解释。
- 固定版本 TransactionTemplate:回调异常与管理器回滚的连接。
- 固定版本 DataSourceTransactionManagerTests:相关上游测试已阅读,未运行上游完整测试套件。

