Spring 事务:传播、回滚与资源管理
一段业务可以调用多个事务方法,但数据库未必开启多笔事务。Spring 的传播设置决定当前方法加入既有事务、另开事务、建立保存点,还是在无事务条件下执行。逻辑方法范围和物理连接上的事务必须分别理解。
在数据库上提交与回滚
事务管理器与数据访问组件
PlatformTransactionManager 定义获取事务、提交、回滚的统一入口,实现类负责具体资源。JDBC 可以使用 JdbcTransactionManager,JPA 使用匹配的 JpaTransactionManager,全局事务使用相应 JTA 协调器。ReactiveTransactionManager 则面向响应式事务,使用不同上下文模型。事务抽象
JdbcTemplate 通过 DataSourceUtils 取得连接,可以复用当前线程绑定的事务资源。直接从另一个 DataSource 取得连接不会自动加入当前 JDBC 事务;即使指向同一数据库,两条独立连接也可能具有不同提交点。
完整的第一次读写
事务实验源码使用 Spring 7.0.9、H2 2.4.240,只创建内存数据库。Linux 普通账号准备 Docker、unzip 和一个空目录,构建镜像为 Maven 3.9.12-eclipse-temurin-25,编译目标 Java 17。实验不是生产数据库配置,不要把连接地址替换成真实业务库。
mkdir spring-tx-lab
unzip spring-framework-transactions-lab.zip -d spring-tx-lab
cd spring-tx-lab
mkdir -p .m2
IMAGE=maven:3.9.12-eclipse-temurin-25
docker pull "$IMAGE"完整 src/main/java/example/FirstTransaction.java:
package example;
import org.h2.jdbcx.JdbcDataSource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.JdbcTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
public class FirstTransaction {
public static void main(String[] args) {
var ds = new JdbcDataSource();
ds.setURL("jdbc:h2:mem:first;DB_CLOSE_DELAY=-1");
var jdbc = new JdbcTemplate(ds);
jdbc.execute("create table notes(id int primary key, body varchar(40))");
var tx = new TransactionTemplate(new JdbcTransactionManager(ds));
tx.executeWithoutResult(status -> jdbc.update("insert into notes values(?,?)", 1, "kept"));
try {
tx.executeWithoutResult(status -> {
jdbc.update("insert into notes values(?,?)", 2, "removed");
throw new IllegalStateException("cancel");
});
} catch (IllegalStateException expected) {
if(!expected.getMessage().equals("cancel")) throw expected;
}
System.out.println("ids=" + jdbc.queryForList("select id from notes order by id", Integer.class));
jdbc.execute("shutdown");
}
}第一笔事务写入 1 并提交;第二笔写入 2 后抛异常,TransactionTemplate 回滚。查询放在两笔事务之后,读取数据库最终状态。shutdown 关闭这个进程内数据库。
pom.xml:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>example</groupId><artifactId>transactions-lab</artifactId><version>1.0.0</version>
<properties><maven.compiler.release>17</maven.compiler.release><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding></properties>
<dependencies>
<dependency><groupId>org.springframework</groupId><artifactId>spring-context</artifactId><version>7.0.9</version></dependency>
<dependency><groupId>org.springframework</groupId><artifactId>spring-jdbc</artifactId><version>7.0.9</version></dependency>
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><version>2.4.240</version></dependency>
</dependencies>
<build><plugins>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.14.1</version><configuration><parameters>true</parameters></configuration></plugin>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-dependency-plugin</artifactId><version>3.9.0</version></plugin>
</plugins></build>
</project>docker run --rm --user "$(id -u):$(id -g)" -e MAVEN_CONFIG=/tmp/maven \
-v "$PWD:/work" -v "$PWD/.m2:/cache" -w /work "$IMAGE" \
sh -ec 'mvn -B -ntp -Duser.home=/tmp -Dmaven.repo.local=/cache \
clean package dependency:build-classpath -Dmdep.outputFile=target/classpath.txt
java -cp "target/classes:$(cat target/classpath.txt)" example.FirstTransaction'输出 ids=[1]。Maven 构建容器已指定普通 UID/GID,缓存和输出挂载都需要可写。企业私服可通过 settings 和 -s 使用,离线运行需要预先准备镜像与完整依赖缓存;不在源码中写凭据。Maven 配置
TransactionTemplate 适合局部代码块边界,调用者直接看得到事务起止。声明式事务适合稳定应用服务方法,减少重复控制代码;两者使用同一管理器时可以协作,不是两套互不相通的数据库能力。编程式事务
接入声明式事务
实验第二个程序 TransactionLab.java 创建 DataSource、JdbcTemplate、JdbcTransactionManager、Inner 和 Outer Bean,并开启:
@Configuration(proxyBeanMethods = false)
@EnableTransactionManagement(proxyTargetClass = true)
class Config {
@Bean PlatformTransactionManager transactionManager(DataSource ds) {
return new JdbcTransactionManager(ds);
}
}这段是完整源码中的管理器配置片段,DataSource 和业务类见同一源码包。业务使用者应从 Context 获取 Outer,通过代理调用带 @Transactional 的方法。普通 new Outer(...) 不会自动应用注解。
TransactionAttributeSource 解析方法/类的事务属性;TransactionInterceptor 进入 TransactionAspectSupport,根据属性和限定符选择管理器,然后在目标调用外组织完成动作。声明式实现
方法与目标类 → 事务属性
→ 选择事务管理器
→ getTransaction:新建 / 参与 / 挂起 / 保存点
→ 调用目标
├── 抛异常:按回滚规则提交或回滚
└── 正常返回:请求提交,仍需检查 rollback-only
→ 完成同步回调、解绑并恢复资源对于本地 JDBC 新事务,管理器取得 Connection,设置事务相关属性并关闭自动提交,把 ConnectionHolder 绑定到线程。业务通过同一 DataSourceUtils 入口得到该连接。完成后恢复连接设置、释放资源;参与既有事务的内部作用域不会擅自关闭外层连接。JdbcTransactionManager
三种常用传播如何改变物理事务
REQUIRED:多个逻辑作用域共享提交点
Outer.required 写入 1,再通过另一个 Bean 的代理调用 Inner.required;内层写入 2 后抛运行时异常,外层捕获该异常并正常返回。
Outer REQUIRED ──────────────── 请求提交
Connection A ↓
Inner REQUIRED → 异常 → 共享事务 rollback-only
↓
物理回滚 + UnexpectedRollbackException每个事务方法有自己的逻辑范围,共享连接的物理事务却只有一笔。内层已经要求回滚,外层 catch 不会撤销那个标记。外层提交时抛 UnexpectedRollbackException,避免调用者误认为数据库已提交。传播语义
docker run --rm --user "$(id -u):$(id -g)" --network none \
-v "$PWD:/work:ro" -v "$PWD/.m2:/cache:ro" -w /work "$IMAGE" \
sh -ec 'java -cp "target/classes:$(cat target/classpath.txt)" example.TransactionLab required'mode=required rows=[] outcome=UnexpectedRollbackException
samePhysicalConnection=true程序比较外内层经 DataSourceUtils 取得的连接引用,并查询最终零行。共享连接和整体回滚由这两项观察共同确认。
如果内层失败应导致整个用例失败,就把异常传播给调用者;如果失败是预期分支,应在事务被标记回滚之前明确业务结果,并避免在已失败事务上继续写入。修改传播以“消除异常”会改变原子性,需要先明确哪些记录必须一起成功。
REQUIRES_NEW:外层挂起,内层独立完成
内层 new 模式使用 REQUIRES_NEW 写入 2 并提交,外层随后故意抛异常:
外层连接 A:写 1 → 挂起 ───────────→ 恢复 A → 回滚
内层连接 B:写 2 → 提交 → 释放 Bdocker run --rm --user "$(id -u):$(id -g)" --network none \
-v "$PWD:/work:ro" -v "$PWD/.m2:/cache:ro" -w /work "$IMAGE" \
sh -ec 'java -cp "target/classes:$(cat target/classpath.txt)" example.TransactionLab new'mode=new rows=[2] outcome=IllegalStateException
samePhysicalConnection=false内层记录保留,外层记录消失,两个作用域使用不同物理连接。外层挂起期间通常仍占用其连接,因此并发请求都持有外层连接并等待内层连接时,池可能耗尽。容量要同时考虑外层并发、尚未结束的嵌套层和其他工作,连接获取必须有界;“连接数多一条”只是特定竞争模型的最低条件,不是通用生产尺寸。
独立审计尝试记录可以允许外层失败后仍保存;扣库存和订单必须原子完成时,随意独立提交会破坏业务关系。REQUIRES_NEW 也可能等待外层持有的锁,即使池足够大仍会阻塞。
NESTED:同一连接上的保存点
nested 模式在外层写入 1 后建立保存点,内层写入 2 并失败,回滚到保存点,外层继续提交:
连接 A:写 1 → 保存点 S → 写 2 → 回滚到 S → 提交 Adocker run --rm --user "$(id -u):$(id -g)" --network none \
-v "$PWD:/work:ro" -v "$PWD/.m2:/cache:ro" -w /work "$IMAGE" \
sh -ec 'java -cp "target/classes:$(cat target/classpath.txt)" example.TransactionLab nested'mode=nested rows=[1] outcome=returned
samePhysicalConnection=true这里由支持保存点的 JDBC 管理器和 H2 实现。NESTED 不保证 JPA、JTA 或所有资源管理器都具备相同行为,也不会把内层内容提前独立提交。保存点只能撤销它覆盖的数据库工作,发出的邮件或远程 HTTP 副作用仍然存在。
其他传播选项
| 传播 | 没有既有事务 | 已有事务 |
|---|---|---|
| REQUIRED | 新建 | 参与 |
| REQUIRES_NEW | 新建 | 挂起外层,独立新建 |
| NESTED | 通常新建 | 支持时创建嵌套/保存点 |
| SUPPORTS | 可在无事务下执行 | 参与 |
| MANDATORY | 拒绝 | 参与 |
| NOT_SUPPORTED | 无事务执行 | 挂起后无事务执行 |
| NEVER | 无事务执行 | 拒绝 |
“无实际事务”仍可能有事务同步资源范围,尤其 SUPPORTS 的行为受管理器同步配置影响。不要仅通过某个 ThreadLocal 是否存在判断数据库已经开始事务。Propagation API
回滚规则、代理与提交后动作
异常必须按真实类型和位置判断
默认声明式规则对 RuntimeException 和 Error 回滚,检查异常默认不回滚。可以通过 rollbackFor/noRollbackFor 指定类型;字符串类名模式更容易意外匹配,优先使用类型。Framework 6.2 起可在 EnableTransactionManagement 配置 rollbackOn=ALL_EXCEPTIONS 统一调整默认规则,但应结合既有业务检查异常语义测试。回滚规则、事务注解配置
docker run --rm --user "$(id -u):$(id -g)" --network none \
-v "$PWD:/work:ro" -v "$PWD/.m2:/cache:ro" -w /work "$IMAGE" \
sh -ec 'CP="target/classes:$(cat target/classpath.txt)"
java -cp "$CP" example.TransactionLab checked
java -cp "$CP" example.TransactionLab checked-rollback'mode=checked rows=[1] outcome=Exception
mode=checked-rollback rows=[] outcome=Exception两次都向调用者抛检查异常,但只有声明 rollbackFor 的方法回滚。目标内部 catch 并正常返回也可能提交;如果底层已经使事务失败,正常返回仍不能恢复它。判断必须同时看拦截器接收的结果与资源状态。
Framework 对某些返回句柄也有特殊支持,例如方法返回时已经异常完成的 Future 可以触发回滚。它不会为了一个稍后失败的后台任务一直保留同步数据库事务。
self 调用没有新的传播范围
self() 内部调用同类 selfNew(),即使后者注解 REQUIRES_NEW,也不会重新经过代理:
docker run --rm --user "$(id -u):$(id -g)" --network none \
-v "$PWD:/work:ro" -v "$PWD/.m2:/cache:ro" -w /work "$IMAGE" \
sh -ec 'java -cp "target/classes:$(cat target/classpath.txt)" example.TransactionLab self'输出 mode=self rows=[] outcome=IllegalStateException,1 和 2 随外层一起回滚。修复需要通过协作 Bean 的代理进入新的事务方法,而不是只把方法改 public。
方法可见性取决于代理类型:接口代理的事务方法需要通过接口公开,类代理在较新 Framework 中支持部分非 public 方法,但 final/private 仍受代理机制限制。应用服务采用明确的外部调用方法更易测试。多个管理器时使用 @Transactional("ordersTransactionManager") 指定名称/限定符,避免“有事务但管理了另一个资源”。
命令式事务通常绑定线程,不会自动进入手工线程池。响应式事务使用 Reactor Context,并要求匹配 ReactiveTransactionManager 及返回类型;复制普通 ThreadLocal 不能替代响应式事务传播。响应式与声明式入口
afterCommit 抛异常时数据已经提交
TransactionSynchronization 可挂接 beforeCommit、afterCommit、afterCompletion 等阶段。afterCommit 执行失败不能撤销已经成功的数据库提交;此时还可能看到绑定资源,但继续写入不能期待原事务再提交一次,需要新的明确事务。同步回调契约
docker run --rm --user "$(id -u):$(id -g)" --network none \
-v "$PWD:/work:ro" -v "$PWD/.m2:/cache:ro" -w /work "$IMAGE" \
sh -ec 'java -cp "target/classes:$(cat target/classpath.txt)" example.TransactionLab after-commit'输出 mode=after-commit rows=[1] outcome=IllegalStateException。调用失败与数据库回滚是两个观察,客户端因此不能只按异常重做不可幂等写入。通过业务键查询结果,再决定重试或补偿。
提交后回调还存在进程在数据库提交后崩溃、回调未执行的窗口。可靠事件发布可把业务数据与 Outbox 同事务保存,由独立发布器重试;进程内事务事件适合阶段通知,不能替代持久投递。事务事件
隔离、超时与事务故障
新事务才有机会设置隔离、超时和只读属性;参与外层时通常采用外层属性。需要拒绝不兼容隔离或只读组合时,可在管理器设置 validateExistingTransaction。readOnly 通常是优化提示和资源设置,不是数据库写权限替代品;是否拒绝写入由数据库、驱动及管理器实现决定。
事务超时不是后台计时器随时终止 Java 方法。JDBC 路径把剩余预算应用到支持的语句操作,连接获取、锁等待和 HTTP 调用仍可能各有超时。请求整体期限要留出异常处理与回滚时间,避免业务结束前连接池等待已耗尽全部预算。DataSourceTransactionManager
长事务持续占用连接、锁和数据库版本。按批提交能降低单次资源占用,也把“整批原子提交”改成“每批原子提交”;必须有可重跑游标和幂等约束。死锁或序列化失败后的重试应在新事务中重新执行,不继续使用已经失败的物理事务。
| 现象 | 定位次序 | 修复后的观察 |
|---|---|---|
| 注解未回滚 | 代理引用 → 实际管理器 → 异常类型/捕获位置 | 同一输入的持久结果与预期异常同时正确 |
| UnexpectedRollbackException | 内层首次异常 → rollback-only 来源 | 整体失败明确返回,或重新设计独立业务事务 |
| NEW 等连接 | 外层活动连接 → pending → 内层获取超时 | 限制并发/嵌套并实测池容量,不仅扩大池 |
| 锁等待 | 数据库阻塞会话 → SQL → 事务持续时间 | 缩短持锁路径,相同负载无长期等待 |
| 提交后调用失败 | 数据库业务键 → 同步回调 → 下游结果 | 不重复已提交写入,补偿可重试 |
| 事务管错资源 | qualifier → DataSource identity →连接获取方式 | SQL 确实参与被选中管理器 |
开发期可启用 org.springframework.transaction.interceptor 与对应事务管理器的定向日志,再结合数据库会话和连接池 active/pending。日志应保留事务名、异常类型和持续时间,避免打印敏感 SQL 参数。把一次慢请求的连接等待、SQL 执行和提交时间放在同一个时间窗口比较,才能知道应修改哪一段。
这些模式每次启动独立内存库,结束关闭数据库与 Context,并检查事务资源已解绑。退出码非零时先保留模式和行集合,修正后重跑原模式及 REQUIRED 正常资源清理路径。
权威资料与规范地址
可按接口、注解和实现类查阅详细契约。
