抛出异常与数据库回滚不是同一个结果

事务方法插入一行后抛出 checked exception,调用方捕获到异常,数据库却保留了那一行。另一个事务方法捕获内部异常并正常返回,调用方反而收到 UnexpectedRollbackException,数据库没有留下记录。两个结果都符合 Spring 的默认行为,它们分别涉及回滚规则与共享事务的 rollback-only 状态。

默认规则把 RuntimeException 和 Error 视为回滚原因,普通 checked exception 不触发回滚。这个规则处理的是穿过事务拦截边界的异常;在方法内部已捕获的异常不会自动传递给拦截器。同时,方法正常返回也不能清除已存在的 rollback-only 标志。默认事务属性源码

本文固定 Spring Framework 6.2.11、JDK 21,源码提交 4c134254642d88e058aa004bdaf44168e1be7bb2。PostgreSQL 18.0、pgjdbc 42.7.7、HikariCP 5.0.1 提供真实数据库与连接池。每个场景都通过独立连接读取已提交记录,避免把同一事务里暂时可见的数据当成提交证据。

异常在哪个边界被观察

命令式路径的 TransactionAspectSupport 在目标调用抛出异常时进入 completeTransactionAfterThrowing。若 TransactionAttribute.rollbackOn(ex) 为真,调用管理器 rollback;否则调用 commit。异常本身仍会向调用方传播。因此“捕获到了异常”和“执行了提交”可以同时成立。异常完成路径源码

commit 也不是无条件执行 JDBC commit 的同义词。抽象事务管理器在实际提交之前会检查本地与全局回滚状态;共享事务已被标记回滚时,提交请求转成回滚处理。异常规则回答“这次异常要求什么动作”,事务状态回答“这个事务还能否提交”,两者缺一不可。

本文的 BusinessException 直接继承 Exception,BusinessRuntime 直接继承 RuntimeException,没有包装层。每次方法先执行 INSERT,再触发预设异常。表里只写场景名称,不依赖日志文案或返回布尔值作为业务成功证据。

默认、显式与最具体规则

默认场景中,runtime() 抛出 BusinessRuntime 后表为空;checked() 抛出 BusinessException 后,观察连接读到 checked。第三个方法指定 rollbackFor = BusinessException.class,同样的 checked exception 就会撤销对应写入。第四个指定 noRollbackFor = BusinessRuntime.class,异常仍向外抛出,但 noRuntime 被提交。

这些场景使用两个维度:异常的 Java 类型,以及方法实际解析出的事务规则。业务命名不参与默认判定;把异常类命名为“业务失败”不会自动决定它是否应该回滚。适合提交还是回滚,需要由业务契约明确表示,再映射到异常类型或显式规则。

RuleBasedTransactionAttribute.rollbackOn 遍历规则,计算异常到匹配类型的继承深度,选择最近的匹配。若没有规则命中,才回退到默认的 unchecked 规则。实验 specificNo() 同时设置 rollbackFor=Exception.class 与 noRollbackFor=BusinessException.class,抛出后提交记录,因为直接匹配的禁止回滚规则比父类规则更具体。规则选择源码

字符串形式的 rollbackForClassName 还有不同风险:它是异常类名的模式匹配,不要求精确全限定名,也不提供通配符语法。过宽的名称片段可能匹配带后缀的另一个异常或嵌套类。本实验采用类型字面量,避免把字符串匹配巧合混入继承深度的验证。需要跨依赖边界使用类名规则时,应增加带相似名称的反例。RollbackRuleAttribute 源码

上游 RuleBasedTransactionAttributeTests 的 defaultRule、ruleForRollbackOnChecked、ruleForCommitOnUnchecked 与 ruleForCommitOnSubclassOfChecked 覆盖这几类解析结果。本文阅读这些测试,并用真实代理、管理器和数据库补充最终提交行为;未运行上游完整测试套件。上游规则测试

两个 catch 的位置不同

caughtLocal() 在自己的方法体里抛出并捕获 BusinessRuntime。事务拦截器只观察到正常返回,且事务没有其他失败状态,因此 caughtLocal 记录被提交。Java 的 try/catch 改变异常传播,框架并没有订阅每一次 throw 指令。

caughtInner() 则调用另一个容器 Bean Inner 的代理。内层 REQUIRED 写入 inner 后抛出异常,先经过内层事务拦截器。它将共享事务标成 rollback-only,之后外层方法才捕获异常。外层中的状态检查已为真,无法因为 catch 成功就恢复可提交状态。

外层方法体执行结束后,其代理尝试提交。管理器回滚共享事务,并抛出 UnexpectedRollbackException,使调用方不能误以为已经提交。表中既没有 outer,也没有 inner。第 21 篇的传播模型在这里提供了必要前提:两个逻辑范围共用一个物理事务,内部失败会影响外部提交结果。

下面是完整源码中外层方法的摘录,inner 是构造器注入的代理 Bean:

1
2
3
4
5
6
7
8
9
10
11
@Transactional
public void caughtInner() {
put("outer");
try {
inner.fail();
} catch (BusinessRuntime expected) {
System.out.println("CAUGHT after inner transaction interceptor");
}
check(TransactionAspectSupport.currentTransactionStatus().isRollbackOnly(),
"inner REQUIRED has already marked shared transaction rollback-only");
}

两个 catch 的差异可通过调用路径直接预测:方法内 throw → 方法内 catch → 外层代理 没有向代理暴露异常;内层 throw → 内层代理 → 外层 catch → 外层代理 已经越过一个事务完成边界。是否打印错误日志、是否重新包装为返回码,都不会自动撤销该边界产生的状态变化。

这种测试不能把内层方法改成 this.fail() 后仍沿用结论。自调用可能绕过内层事务拦截器,异常尚未触发内部回滚标记就被外层捕获;数据库结果会改变。拆分 Bean 是本实验固定代理边界的手段,不能把两个不同调用结构混成一种回滚规则。

显式标记回滚可以正常返回

localRollbackOnly() 写入数据后调用 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(),随后正常返回。它直接告诉当前事务范围要回滚,因而方法调用可以正常结束而不留下那条记录。此时不要求出现 UnexpectedRollbackException:当前外层范围已经主动要求回滚,不能把该结果与“外层想提交却发现内部已标记回滚”混为一谈。提交前状态检查源码

这种 API 会把业务代码与 Spring 当前事务上下文耦合。它适用于确实需要返回正常业务结果、又明确要求撤销当前写入的情形;调用方必须从结果协议知道操作未完成。若代码在没有声明式事务的入口使用 currentTransactionStatus(),也不能假定总能得到状态对象。

把异常转成返回码属于业务接口选择,数据库提交策略需要另行设计。若 catch 后返回成功,但数据库已回滚,就形成语义错误;若 checked exception 表示订单无法完成,却因默认规则提交了半成品,也需要修改契约或规则。两者都不能仅靠“统一捕获全部 Exception”解决。

6.2 的全局回滚默认值

实验第二个独立容器使用 @EnableTransactionManagement(rollbackOn = RollbackOn.ALL_EXCEPTIONS)。相同的 checked() 方法无需局部添加 rollbackFor,就会回滚;specificNo() 的更具体禁止回滚规则仍使该记录提交。

固定源码中的 AbstractTransactionManagementConfiguration 给属性源增加默认回滚规则。默认规则补充到事务属性中,仍参与规则匹配,而不会强制抹掉方法自己的例外。这个开关属于 Spring Framework 6.2 的配置能力,不能复制到更早版本后假定编译或行为相同。全局默认配置源码

全局改变默认值会影响未明确指定回滚规则的方法。迁移前需要识别那些故意通过 checked exception 通知调用方、同时保留事务成果的接口;它们的可见异常不变,数据库结果却可能变化。本文只证明实验容器的两组对照,不把该配置当成无条件升级建议。

数据库终态矩阵

方法场景 外部观察到的调用结果 本场景写入是否保留
默认 RuntimeException 原异常 否
默认 checked exception 原异常 是
checked + rollbackFor 原异常 否
runtime + noRollbackFor 原异常 是
Exception 回滚 + 具体 checked 不回滚 原异常 是
方法内部捕获本地异常 正常返回 是
捕获另一个 REQUIRED 代理的异常 UnexpectedRollbackException 外层、内层都不保留
外层自己 setRollbackOnly 正常返回 否
ALL_EXCEPTIONS + checked 原异常 否
ALL_EXCEPTIONS + 具体 noRollbackFor 原异常 是

每次调用后独立观察连接都执行查询,断言完整有序行集,而非只查询“刚才那一行不存在”。完整集合能发现错误提交、错误回滚或上一场景污染;调用线程事务状态与池活动连接数也在相应边界检查。

运行、反例与练习

完整源码位于 examples/spring-framework-lab/src/main/java/blog/spring/Chapter22.java,另附 Chapter22.java;统一实验工程包含辅助类和冻结依赖。先使用 JDK 21,按 README 启动 PostgreSQL 18.0 实验库,再执行:

1
2
cd examples/spring-framework-lab
./mvnw -q compile exec:java -Dexec.mainClass=blog.spring.Chapter22

默认连接 127.0.0.1:55432/spring_lab,用户 spring_lab。程序只使用合成表 spring_ch22_rows,每次开始清空,最终在 finally 删除,不可对业务库运行。日志在 evidence/22/local-20261002/run.txt,退出码在相邻 exit-code.txt。观察发生在事务结束后、清理表之前,程序正常退出不代表所有方法都提交。

反例题:把外层的 @Transactional 改成 noRollbackFor=BusinessRuntime.class,能否确保捕获内层失败后提交?不能;内层 REQUIRED 已经把共享事务标为回滚,外层规则不负责清除这一标志。

可执行练习:先删除 explicitChecked() 的 rollbackFor,保留原有终态断言,重跑应因多出 explicitChecked 行而失败;恢复规则后通过。再给 caughtLocal() 的 catch 分支增加 setRollbackOnly(),将预期行集去掉 caughtLocal,观察正常返回而写入撤销。最后保持全局 ALL_EXCEPTIONS,删除 specificNo() 的 noRollbackFor,确认其写入不再保留。

[PATTERN] 回滚排查沿 异常经过的代理边界 → 命中规则 → 共享回滚状态 → 外部异常和数据库终态 进行。默认规则、异常包装与 catch 位置分别改变不同环节;先定位改变的位置,再选择修复方式。

参考资料