把调用移进同一个类之后:一次 Spring 事务回滚反例
同一段插入代码,同一个 @Transactional 方法,插入后同样抛出 IllegalStateException。从代理外部调用时,表里剩零行;经过同类的另一个方法调用它,表里却剩一行。两条路径在同一次 javac 中都能编译,Java 编译器没有理由阻止后者。
这是本文实际运行的最小反例:Spring Framework 5.3.9、H2 2.1.214、Java 17.0.12,单进程内存数据库,没有真实订单、账号、外部服务或生产数据。它回答一个具体的重构验收问题:Coding Agent 把调用搬进同一个类后,怎样证明它还保留了原先的事务行为?
先看异常之后留下什么

四条路径都进入同一个 write,区别仅在调用是否重新经过 Spring 代理,以及是否故意抛异常。每条路径执行前清空表,执行后在事务调用之外重新查询。结果来自下文完整代码的实际输出:
| 入口 | 插入后抛异常 | 方法内事务活跃 | 最后持久可见行数 |
|---|---|---|---|
外部调用 worker.write |
是 | 是 | 0 |
外部调用 worker.write |
否 | 是 | 1 |
外部调用 worker.self,内部 this.write |
是 | 否 | 1 |
外部调用 worker.self,内部 this.write |
否 | 否 | 1 |
最有区分力的是第一行和第三行:异常都真实抛到调用者,异常类和消息相同,但数据库状态不同。只断言"调用抛异常",这个反例里的两条路径都会过关。只测试成功路径,两条路径也都留下一行,仍然看不出事务边界已经消失。
第三行中的一行不代表"Spring 回滚失灵"。在这个没有外层事务的配置里,this.write 没有经过事务拦截器;H2 使用自动提交,插入在异常抛出前已经生效。程序同时检查代理类型、入口处没有环境事务、方法内活性和最终行数,避免把这一现象误认成纯 Java 的异常处理差别。
方法上的注解还在,经过代理的路径却变了

这里使用接口代理。Spring 容器返回的 Operations 是 JDK 动态代理,目标对象是 Worker。外部调用 write 时,代理读取目标方法上的事务元数据,事务管理器先开启事务,再执行插入;未捕获的运行时异常越过代理边界,触发默认回滚。
self 本身没有事务注解。外部调用它虽然先经过代理,但这次入口没有事务属性;进入目标对象后,this.write 是目标对象对自身的普通调用,不会再绕回代理。方法名、参数、方法体和注解全都还在,少掉的是那次拦截机会。
这正是 Spring 5.3.9 固定版本事务文档说明的默认代理模式限制:只有经过代理的外部调用会被拦截,自调用不会因此启用被调方法上的事务。文档也区分代理模式和 AspectJ weaving;本文没有使用后者。
所以,重构评审里"注解没有删除""接口签名没变""所有调用都能解析"都不足以回答这个问题。真正需要保持的是从实际入口到受管代理再到目标方法的运行路径,以及失败之后的状态。对会编排数据库工具的 Coding Agent,这种差异会直接影响一次失败是否留下半步业务变更。
一个文件保留两条路径

保存为 ProxyTransactionDemo.java。完整示例使用 Spring 容器创建代理,JdbcTemplate 和事务管理器使用同一个 DataSource;没有手写假代理或用布尔变量模拟事务。静态 observedActive 仅为这份单线程测试记录观测值,不是业务实现方式。
java
import javax.sql.DataSource;
import org.h2.jdbcx.JdbcDataSource;
import org.springframework.aop.support.AopUtils;
import org.springframework.context.annotation.*;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.*;
import org.springframework.transaction.support.TransactionSynchronizationManager;
public class ProxyTransactionDemo {
public interface Operations {
void write(boolean fail);
void self(boolean fail);
}
public static class Worker implements Operations {
private final JdbcTemplate jdbc;
static boolean observedActive;
Worker(JdbcTemplate jdbc) { this.jdbc = jdbc; }
@Transactional
public void write(boolean fail) {
observedActive = TransactionSynchronizationManager.isActualTransactionActive();
System.out.print("tx_active=" + observedActive);
jdbc.update("insert into events(id) values (1)");
if (fail) throw new IllegalStateException("after_insert");
}
public void self(boolean fail) { this.write(fail); }
}
@Configuration(proxyBeanMethods = false)
@EnableTransactionManagement(proxyTargetClass = false)
public static class Config {
@Bean DataSource dataSource() {
JdbcDataSource ds = new JdbcDataSource();
ds.setURL("jdbc:h2:mem:proxy_demo;DB_CLOSE_DELAY=-1");
return ds;
}
@Bean JdbcTemplate jdbc(DataSource ds) { return new JdbcTemplate(ds); }
@Bean PlatformTransactionManager transactionManager(DataSource ds) {
return new DataSourceTransactionManager(ds);
}
@Bean Operations worker(JdbcTemplate jdbc) { return new Worker(jdbc); }
}
public static void main(String[] args) throws Exception {
try (AnnotationConfigApplicationContext ctx =
new AnnotationConfigApplicationContext(Config.class)) {
Operations worker = ctx.getBean(Operations.class);
JdbcTemplate jdbc = ctx.getBean(JdbcTemplate.class);
if (!AopUtils.isJdkDynamicProxy(worker)) throw new AssertionError("proxy missing");
System.out.println("spring=" + org.springframework.core.SpringVersion.getVersion());
try (var connection = ctx.getBean(DataSource.class).getConnection()) {
System.out.println("h2=" + connection.getMetaData().getDatabaseProductVersion());
if (!connection.getAutoCommit()) throw new AssertionError("autocommit changed");
}
System.out.println("jdk_proxy=true");
jdbc.execute("create table events(id int primary key)");
for (boolean self : new boolean[]{false, true}) {
for (boolean fail : new boolean[]{true, false}) {
jdbc.update("delete from events");
if (TransactionSynchronizationManager.isActualTransactionActive())
throw new AssertionError("ambient transaction");
System.out.print("route=" + (self ? "self" : "external") + " fail=" + fail + " ");
boolean thrown = false;
try {
if (self) worker.self(fail); else worker.write(fail);
} catch (IllegalStateException e) {
if (!"after_insert".equals(e.getMessage())) throw e;
thrown = true;
}
int rows = jdbc.queryForObject("select count(*) from events", Integer.class);
System.out.println(" exception=" + thrown + " rows=" + rows);
if (Worker.observedActive == self || thrown != fail || rows != ((!self && fail) ? 0 : 1))
throw new AssertionError("unexpected persisted state");
if (TransactionSynchronizationManager.isActualTransactionActive())
throw new AssertionError("transaction leaked");
}
}
}
System.out.println("assertions=ok");
}
}
依赖固定为八个 Spring 5.3.9 JAR:spring-context、spring-aop、spring-beans、spring-core、spring-expression、spring-jcl、spring-tx、spring-jdbc,另加 H2 2.1.214。本次复用本机只读 Maven 缓存,未运行 Maven 构建,没有下载或把 JAR、class 提交进内容仓库。5.3.9 是为复现而锁定的历史版本,不是新项目选型建议。
目录中的 run_proxy_demo.py 会逐个检查这九个固定依赖是否存在,记录 JAR SHA-256、实际 classpath、Java/javac 版本、源码 SHA、命令和返回码,并将 class 写入临时目录后清理。缺依赖直接停止;不会静默换版本或在线补依赖。运行方式为:
bash
python3 run_proxy_demo.py
若只手工执行 Java,可使用下述等价命令。命令面向 macOS/Linux;要求 Java 17 和上述固定版本已在 Maven 缓存,本地 class 放进临时目录:
bash
CP=""
for a in context aop beans core expression jcl tx jdbc; do
JAR="$HOME/.m2/repository/org/springframework/spring-$a/5.3.9/spring-$a-5.3.9.jar"
test -f "$JAR" || exit 1
CP="${CP:+$CP:}$JAR"
done
JAR="$HOME/.m2/repository/com/h2database/h2/2.1.214/h2-2.1.214.jar"
test -f "$JAR" || exit 1
CP="$CP:$JAR"
CLASSES=$(mktemp -d)
trap 'rm -rf "$CLASSES"' EXIT
javac -cp "$CP" -d "$CLASSES" ProxyTransactionDemo.java &&
java -cp "$CLASSES:$CP" ProxyTransactionDemo
本次实际编译退出码为 0,运行退出码为 0。实际 stdout:
text
spring=5.3.9
h2=2.1.214 (2022-06-13)
jdk_proxy=true
route=external fail=true tx_active=true exception=true rows=0
route=external fail=false tx_active=true exception=false rows=1
route=self fail=true tx_active=false exception=true rows=1
route=self fail=false tx_active=false exception=false rows=1
assertions=ok
assertions=ok 的范围有限但具体:Bean 确实是 JDK 代理,初始连接自动提交为真,四条路径的事务活性、异常和落库行数符合预期,调用前后没有遗留活跃事务。它没有证明真实项目中的锁竞争、跨数据源一致性或吞吐。
把验收从"抛异常"改成"失败状态保持"
如果原入口通过另一个受管 Bean 调用这个事务方法,重构后却把入口和方法合进一个类,首先应保留这条失败路径的回归测试。测试要从实际受管入口调用,触发写入后的异常,再从调用之外读取目标状态。这样才能捕获"正常分支都过、失败时留下写入"的变化。
直接 new Worker 再测试目标方法无法验证容器代理;只检查 @Transactional 注解是否存在,也无法验证调用真的经过代理。它们各自可以是局部测试,但不能冒充这次行为保持的证据。
对本例,保留独立受管 Bean 之间的事务调用就是最小修复方向。把 @Transactional 加到 self 也会改变事务范围,但那不是无条件等价修复:如果入口将来含校验、远程调用或别的写操作,这些操作会一起被包进事务。是否扩大边界,要由原来需要保护的业务状态决定,不能只为让这张表变成零行而移动注解。
也可以显式使用 TransactionTemplate 表达所需范围,但这又是另一种实现选择。本例没有测试那些替代补丁,更没有要求为一个反例引入自注入、暴露代理或 AspectJ。读者可以先把四路径对照带进自己的目标测试,找到调用边界,再决定最小修复。
顺着同一份 DataSource 追到真正写入的位置
这个例子有意让事务管理器和 JdbcTemplate 接收同一个 DataSource。否则读者可能看见方法内事务活跃为真,却仍然发现某条写入没有跟着回滚,随后把所有问题都归因于自调用。这里的布尔观测只回答当前线程上是否有实际事务,不能单独证明任何数据库连接都参与了它。示例将这层配置收紧,是为了让四行结果主要反映调用路径。
外部失败路径中,先执行的是事务代理的前置处理,之后才进入打印与插入语句。异常到达 main 的 catch 之前,调用已经经过代理的退出处理;main 再通过 JdbcTemplate 查到零行。因此,零行是调用退出后重新读取的状态,不是刚开始时表为空的截图。若在目标方法尚未退出时做查询,看到的可能只是当前事务内的临时状态,回答不了失败之后是否保留写入。
自调用失败路径中,目标方法执行的是相同 SQL,主键值也相同,但调用前先清过表,所以不存在"上一条成功路径造成主键冲突"的解释。插入后的异常消息由例子固定为 after_insert;测试只接受这一种异常,避免把容器未启动、SQL 拼错或依赖缺失误认为预期的业务失败。若异常在插入前就抛出,两条路径都可能是零行,表面上反而掩盖了要观察的差异。
每条用例之前的 delete 没有包在被测事务里。这是测试安排:它准备干净起点,不是被测业务的一部分。对于真实项目,清理测试数据和验证业务回滚也要分清。若测试自身带着外层事务,自动清理可能替业务抹掉本应暴露的持久化差异;若与共享环境共用表,清理又可能破坏其他任务。本例只使用固定内存库,所以既不借用外层测试事务,也不接触外部数据。
一份异常断言,为什么还要搭配最终状态

在这份程序里,thrown 和 rows 分别观察了两种结果。thrown 说明调用者看到了预期异常,rows 说明数据库在调用结束后留下什么。二者不能互相替代:调用者得知失败之后,已经自动提交的写入不会因为这个通知而倒退;数据库恢复为零行,也不自动说明调用者收到了它需要的错误信息。
把这两个观察带回一次真实重构评审,可以先找变更前已有的失败语义。例如某个流程承诺"预约创建失败时不能留下半份占位记录",回归断言应该查占位记录,而不只是验证方法抛出异常。这里的预约只是解释断言位置的虚构场景,示例没有实现预约服务、消息发送或补偿动作。
测试入口同样属于断言的前提。从 Spring 容器拿到 Operations,可以覆盖本例容器创建和代理选择的实际结果;绕过容器拿到目标对象,则测试的是另一条调用路径。另一方面,拿到一个代理也不代表它所有方法都有事务。self 的确通过代理进入,但 self 没有事务属性,真正带注解的 write 又在目标对象内部被调用。只打印"这是代理对象"仍然缺少关键一步。
程序还检查调用前后没有活跃事务。调用前检查用来排除上游环境已经替本例开启事务;调用后检查用来发现资源状态没有正常离开当前线程的异常。这些检查使反例更容易解释,但它们不是一个通用连接池泄漏测试,也没有覆盖线程池复用。真实系统若跨线程继续执行工具,就要另外确认事务上下文和连接的实际边界,不能拿这段单线程输出作担保。
当一份补丁只移动调用,评审应读哪部分差异
假设原先的入口调用另一个受管对象上的 write,现在为了减少类之间的跳转,入口和 write 被移进一个目标对象,随后改成 this.write。Java 层面可以保留方法签名、参数类型和抛出的运行时异常,编译器仍然满意。行为差异位于框架为某条入口附加的处理,而不在方法体语句能否执行。这个假设解释了反例对应的重构类型;本文没有对某个真实仓库提交这样的补丁。
因此,代码差异的阅读重点应是调用表达式的接收者来自哪里。它是注入的受管接口,还是目标对象自身;新入口有没有事务属性;这次移动有没有同时改变异常传播。只比较 write 方法体的前后文本,会漏掉这些信息。最小验证可以很短,但必须把从入口到写入后的失败分支走完。
保留分离的受管调用并不意味着类越多越安全。如果一个业务操作本来应该在一个事务边界里完成,盲目拆 Bean 也可能让读者误以为跨对象就自动形成理想边界。应先确认想保护的数据库变化是哪一段,再比较代理入口与实际状态。本文所能证明的是两条已指定路径不同,不替读者决定业务应该分成几个服务。
把 self 也加上事务注解,可能使当前失败分支回到零行,但那项修复没有在本次运行中验证。它还可能把入口处原本不在事务内的读取或其他写入包进去。原先边界之外的远程等待若也被覆盖,资源持有时间就需要重新检查。反例中没有远程等待和性能测量,因此不能写成"移动注解更快"或"拆 Bean 必然更可靠"。
锁版本是为了让不同结果有同一个起点
运行器逐个指定九个 JAR,而不是从 Maven 缓存里把所有版本拼进 classpath。一个机器上可以同时存在多个 Spring 版本;若 classpath 不明确,先被加载的类可能由路径顺序偶然决定。此时复现者说"同样是 Spring"还不够,必须确认实际版本输出和依赖摘要。本文的 receipt 保存的是那一次真实执行使用的路径与 SHA,不要求读者把机器上的整个缓存打包。
编译退出零说明完整源码被这套依赖接受;运行退出零和 assertions=ok 才说明四条观测都符合程序断言。反过来,读者在不同环境运行时如果缺了指定 JAR,应把结果停在环境缺失,而不是静默换一个版本再声称复现相同条件。可以开展新的版本对照,但那会是新增实验,需要单独保留输入和输出。
H2 的内存库也限定了观察的含义。它让插入、异常和重新查询真实执行,同时不把真实业务数据带入测试;它没有代表某种线上数据库在锁、隔离级别、网络断连和提交结果不明时的行为。代理路径的局部反例成立,不等于业务集成验证已经完成。读者将断言移植到实际技术栈时,应保留原业务状态定义,而不是机械要求所有表都只剩零行。
有外层事务时,要换一个断言
自调用不触发被调方法的事务增强,不等于它永远运行在事务之外。若外部入口本来已经开启事务,内部方法仍可能在那个既有事务里执行;此时本例的 tx_active=false 不应被照抄为所有项目的预期。本文明确让 self 没有注解,也在每次调用前断言没有环境事务,才隔离出这组差异。
同样,本例只抛未捕获的 IllegalStateException。检查型异常、catch 后吞掉异常、手工标记 rollback-only、不同传播级别或其他数据访问方式,会带来额外条件。Spring 5.3.9 的默认回滚规则说明了运行时异常与检查型异常的差别,不能从这四行扩展出"任何异常都自动回滚"。
这份产物的价值,是让"编译通过但事务语义变了"成为一个能当场复算的失败对照。下一次 Agent 提交方法移动补丁,验收者可以先问:失败之后,原本应该不存在的那条写入,现在仍然不存在吗?