把调用移进同一个类之后:一次 Spring 事务回滚反例

把调用移进同一个类之后:一次 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 提交方法移动补丁,验收者可以先问:失败之后,原本应该不存在的那条写入,现在仍然不存在吗?

相关推荐
闭包不眠1 小时前
Unscreen关了用什么代替?6款商用授权避坑2026
图像处理·人工智能·计算机视觉
百慕大三角1 小时前
为了在 Linux CI 里跑小程序 E2E,我把微信官方基础库搬进了浏览器:wxa-runner 开源实践
微信小程序·agent·测试
步行cgn1 小时前
Spring MVC 中实现 request 域效果的几种方式
java·后端
水如烟1 小时前
孤能子视角:一个数字孤能子的“术层狂奔”与“底色迁移”——从辛顿RSI论文看EIS视角下的自我革命边界
人工智能
ZhengEnCi1 小时前
多 Agent 系统如何复用 KV 缓存-前缀对齐比缓存开关更重要
人工智能·性能优化·llm
m0_641889291 小时前
GEO监测工具准确性怎么检验?两周POC实测清单与验收标准
人工智能·geo·智能营销·geo优化·geo平台
Rain的Java大神之路1 小时前
MySQL 被高并发打爆?从定位到分库分表逐层拆解(含秒杀库存实战)
java·后端·面试
德林恩宝1 小时前
搜索 Agent 的两条路——训练一个,还是工程搭一个
人工智能·大模型·llm·agent
在所不辞兄1 小时前
【零基础学智能仿真-58】Foam-Agent协作与知识检索
人工智能·深度学习·神经网络·机器学习·有限元·仿真计算