同一个注解为何产生不同事务

一个订单服务在类上声明只读事务,在写入方法上另选事务管理器。外部调用写入方法时,数据库能够提交;目标对象用 this 调用另一个事务方法时,却可能没有事务。排查这种差异,需要分别回答三个问题:调用是否进入代理、方法最终解析出什么属性、选中的管理器实际控制哪份资源。

注解只是方法元数据。第 19 篇的 JDBC 事务由管理器取得连接、绑定线程、完成提交或回滚;声明式入口增加了一层拦截逻辑,把方法元数据转换成管理器参数。数据库提交并不发生在读取注解时,也不发生在目标方法的最后一行,而发生在代理控制的调用边界上。

本文固定 Spring Framework 6.2.11、JDK 21,源码提交 4c134254642d88e058aa004bdaf44168e1be7bb2。实验使用 PostgreSQL 18.0、pgjdbc 42.7.7、HikariCP 5.0.1。两个连接池连接同一实验数据库,用于辨认管理器选择;它们不是分布式事务,也不模拟两个数据库的原子提交。

从方法入口到事务属性

TransactionInterceptor.invoke 把方法、目标类型和继续执行原方法的回调交给 TransactionAspectSupport.invokeWithinTransaction。后者通过 TransactionAttributeSource 查询属性,再选择管理器。命令式事务路径随后调用 createTransactionIfNecessary,进入目标方法,最后按返回或异常处理事务。TransactionInterceptor 源码、TransactionAspectSupport 源码

属性解析需要目标类型,因为代理入口的 Method 可能来自接口,而事务注解放在实现方法上。AbstractFallbackTransactionAttributeSource.computeTransactionAttribute 先得到最具体方法,尝试方法上的属性,然后尝试其声明类;未命中且原始方法不同,再回退到原始方法及原始声明类。这个次序解释了实现方法如何覆盖接口或类上的默认值。解析结果按方法和目标类型组合缓存,不能把“接口 Method 没注解”等同于“没有事务属性”。属性查找源码

本实验的类属性设置 primaryTx、readOnly=true、timeout=9;override() 方法只声明 secondaryTx。实际解析结果中,方法的只读值恢复为 false,超时为默认值 -1。方法注解形成自己的事务属性,不会按字段把没有显式填写的值从类注解继承过来。

下面摘录完整工程的 override() 方法;其所属类带有前述类级注解,secondary 是构造器注入的数据源。

1
2
3
4
5
6
7
8
9
@Transactional(transactionManager = "secondaryTx")
public boolean override() {
var jdbc = new JdbcTemplate(secondary);
TxChapterSupport.identity(jdbc, "secondary/method");
jdbc.update("insert into spring_ch20_rows values ('method')");
return !TransactionSynchronizationManager.isCurrentTransactionReadOnly()
&& TransactionSynchronizationManager.hasResource(secondary)
&& !TransactionSynchronizationManager.hasResource(primary);
}

上游 AnnotationTransactionAttributeSourceTests 的 transactionAttributeOnTargetClassMethodOverridesAttributeOnInterfaceMethod、defaultsToClassTransactionAttribute 分别检查实现方法优先级和类级回退。本实验另外通过真实容器取得 TransactionAttributeSource,直接断言 9 → -1,避免只根据配置外观推断属性合并行为。属性解析测试

管理器名称必须落实到连接资源

@Transactional 的 value 与 transactionManager 是别名。本实验显式指定名称,分别解析到两个 DataSourceTransactionManager。在 6.2.11 的选择路径里,事务属性的 qualifier 优先于后续默认管理器查找;还存在目标类型 qualifier、配置的管理器名称及默认实例等分支。没有填写名称时,仍需依赖当前容器的候选解析,不能在多个管理器之间假定一个固定的任意顺序。管理器选择源码

实验不只打印名称。defaults() 执行 SQL,检查主数据源存在当前线程资源、副数据源不存在,且事务只读标志为真。override() 反向检查资源绑定、写入一行,然后正常返回。外部观察连接在两个容器场景都结束后读取到两条 method 记录。这条证据链覆盖“解析名称 → 绑定数据源 → 执行 SQL → 可见提交”。

两个管理器即使连向同一个 JDBC URL,也不会因为地址相同就自动共享 Spring 的线程资源。资源绑定使用数据源键,事务管理器拥有明确的资源边界。若业务方法使用另一个未纳入当前事务的数据源,方法上存在事务并不能证明那些写入也受它控制。这里的 hasResource 是用于区分两个已知数据源的实验观察,不能替代数据库最终状态。

readOnly 同样需要说明观察层次。实验断言的是 Spring 当前事务的只读元数据,且执行路径确实取得 JDBC 连接;它没有尝试证明所有数据库都会禁止写入。管理器、驱动与数据库对只读标记的实现,应通过写入反例单独验收。把 readOnly=true 当成应用权限控制会混淆优化提示与访问控制职责。

外部调用与 this 调用

Visibility 没有类级事务注解。它的 external() 带注解,self() 不带注解并直接调用 external()。容器返回的代理上执行 external() 时,线程报告事务激活;执行 self() 时,目标对象内部的调用不再经过代理,报告未激活。

这个结果依赖外层没有事务。如果给 self() 加上事务注解,内部 external() 的 isActualTransactionActive() 可能变成真,但依然不能证明内层注解得到解释。那只是外部调用进入 self() 时建立的事务继续存在。验证内层独立属性,必须记录隔离、只读、管理器资源或物理事务身份,不能只看一个布尔值。

由此产生一类常见误判:在有事务的集成测试里调用 this 方法,查询到事务激活,便认定注解生效;换成没有测试事务的入口后,才出现数据部分提交。实验入口从普通 main 开始,每次代理调用结束还检查调用线程的事务状态已清理,使外部环境无法无意提供事务。

public 规则受代理方式与配置共同约束

Spring 6.2.11 的 AbstractTransactionManagementConfiguration.transactionAttributeSource() 创建 AnnotationTransactionAttributeSource(false),允许识别非 public 方法。对于类代理,外部调用可覆盖的 protected、同包可见方法能够进入事务。JDK 代理的调用接口则必须公开暴露相应方法;实现类增加一个 protected 方法,不会在代理接口中产生新入口。事务配置源码、官方方法可见性说明

两个测试容器分别使用 proxyTargetClass=true 和 false。实现 Api 的 Service 分别得到类代理和 JDK 动态代理,两种情况下实现方法上的事务属性都可解析。没有接口的 Visibility 在两个容器中都使用类代理;因此它的 protected/package 验证不能被误报为“JDK 代理支持非 public 方法”。

实验从同包 main 外部调用 protectedCall()、packageCall(),均检测到事务。私有方法则由没有事务的 reachPrivate() 在目标内部调用,检测到未激活。这个私有方法用例同时涉及不可覆盖和自调用,不能单独据此推断所有可见性组合;源码中的类代理覆盖限制与外部非 public 正例共同确定适用边界。final 方法同样无法被子类代理覆盖,不能用修改 public 修饰符解决。

显式注册 AnnotationTransactionAttributeSource(true) 会恢复仅 public 的属性识别策略。故障排查应记录实际 Bean 与代理配置,不能把某一版本、某一种基础设施构造方式下的默认值概括成永久规则。

运行与验收

完整源码包括 Chapter20.java、TxChapterSupport.java、Checks.java、LabDatabase.java;统一工程位于仓库 examples/spring-framework-lab。可从系列实验工程下载,当前篇入口另附 Chapter20.java。JDK 必须为 21;数据库先按工程 README 启动 PostgreSQL 18.0 实验实例,默认地址 127.0.0.1:55432/spring_lab、用户 spring_lab,仅用于本机合成数据。

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

程序创建并清空 spring_ch20_rows,最后在 finally 删除该实验表。不要连接含同名业务表的数据库。日志中的 TX 行包含线程、PostgreSQL 后端 PID 和事务号,ATTRIBUTE 行显示解析属性,OBSERVER 行来自独立观察连接。原始输出与退出码在 evidence/20/local-20261002/,正常运行退出码为 0。

对照 实际观察 可支持的判断
类属性与方法属性 只读/管理器变化,超时 9 与 -1 方法级属性覆盖类级默认
外部调用与 this true 与 false 无外层事务时,自调用未被拦截
类代理与 JDK 代理 两种代理都解析实现方法属性 注解不必放在接口才能工作
protected/package/private true、true、false 类代理入口需满足可覆盖与外部调用条件
两次写入后的独立查询 [method, method] 两个显式选择的事务都已提交

反例与练习

反例题:给 self() 添加事务,观察内部返回 true,能否证明 external() 的超时设置生效?不能。整个调用只经过 self() 的代理入口,内部布尔值只说明当前存在事务。

可执行练习:把 override() 注解改成 @Transactional(transactionManager="secondaryTx", timeout=9) 后重跑。原来的 timeout == -1 断言应失败,日志应显示方法自身的 9;将断言改为 9 再跑即可验证。另一个练习是在 ClassConfig 注册仅 public 的属性源,保留 protected/package 正例断言,确认它们失败的位置。练习必须同步记录“配置变化、预期失败、修订后通过”,不能只把失败断言删除。

[PATTERN] 声明式能力的验收可以沿 实际引用 → 属性解析 → 管理器 → 资源 → 终态 逐层建立证据。缓存和异步同样需要先证明进入了拦截入口;数据库事务还要继续确认受控制的连接与最终提交状态。

参考资料