09-Spring事务与代理边界
Transactional 为什么没生效?代理、异常与事务边界
方法已经加了 Transactional,失败后数据却没有回滚。最值得先问的是:调用是否经过代理,异常是否穿过事务边界,以及几次写入是否使用同一个事务资源。注解只能参与机制,不能替代这些条件。
涉及 Spring 的示例沿用 Java 17、Spring Boot 3.5.3;不同示例项目独立阅读,文件名与依赖在相应代码段说明。
1. 事务增强发生在哪个对象上
先区分代理入口与目标对象,再看自调用。
事务注解如何变成数据库行为?
在常见的代理模式下,Spring 为符合条件的 Bean 建立代理。外部调用进入代理后,事务拦截器按配置取得或创建事务,再执行目标方法,最后决定提交还是回滚。
JdbcTemplate 通过 Spring 的数据访问设施使用连接,才能与相应事务管理器协同。自己另外创建不受管理的连接,或者使用另一个数据源,不能自动认为也参加了当前事务。
这里讨论 Java 17、Spring Boot 3.5.3 管理的 Spring Framework 6.2.8,以及 JDBC 本地事务。响应式事务、JTA 分布式事务有不同的资源与上下文机制。Spring 事务实现
自调用究竟绕过了什么?
外部拿到 Bean 代理,调用它的方法,可以进入事务拦截器。但目标对象在方法内部执行 this.otherMethod(),通常直接调用自己的方法,没有再次经过代理。
如果外层没有事务,内层注解不能因为自调用而启动事务。如果外层已经有事务,内层 JDBC 操作仍可能使用现有事务,只是内层注解自己的传播、超时或回滚配置没有被重新应用。
因此,“自调用一定没有事务”是不准确的;准确说法是默认代理模式下,自调用不会触发那个方法的独立事务拦截。代理调用边界
AOP 给业务调用增加了什么?
AOP 把日志、事务等横切逻辑应用于符合条件的调用。目标对象负责原业务,代理在入口组织增强逻辑,再决定是否以及怎样调用目标。
常见术语可以对应到实际问题:
| 术语 | 在业务代码中的含义 |
|---|---|
| Target | 执行原业务的目标对象 |
| Proxy | 调用方持有的代理对象 |
| Join point | Spring AOP 支持的连接点,主要是方法执行 |
| Pointcut | 哪些方法符合增强条件 |
| Advice | 在符合条件的调用前后执行什么 |
| Aspect | 组织切点与增强的模块 |
Spring AOP 的代理机制不等于完整的 AspectJ 字节码织入。不能因为使用了 AspectJ 注解,就推断字段访问、构造器或对象内部的所有调用都能被拦截。Spring AOP 概念
JDK 代理与类代理如何区别?
JDK 动态代理实现接口,调用方通常通过接口调用。它不成为目标实现类的子类,因此不能随意把代理强转成目标实现类。
Spring 的类代理通常通过 CGLIB 创建目标类的子类,可以暴露可代理的类方法。final 类不能被继承,private 方法不能被覆盖,final 方法也不能通过覆盖方式拦截。
特别要注意:目标实现方法是 final,不必然意味着 JDK 接口代理无法增强它。接口代理拦截的是接口入口,再调用目标方法,下面会实际验证这个差异。代理机制
自调用为什么不进入第二次增强?
sequenceDiagram
participant C as 调用方
participant P as 代理
participant T as 目标对象
C->>P: outer()
P->>P: 执行 outer 的增强
P->>T: outer()
T->>T: this.render()
T-->>P: 返回结果
P-->>C: 返回结果
目标对象里的 this 指向目标对象自身,内部调用 render 没有回到代理。因此切点即使匹配 render,这次内部调用也不会触发它的独立增强。
外层增强仍可能影响内部行为。例如外层事务已经建立,内部数据库访问可以使用它;只是内部方法注解自己的传播配置没有因为自调用而重新应用。
默认代理类型要看哪一层配置?
本例使用显式的 EnableAspectJAutoProxy,因此两种设置容易比较。Spring Boot 的 AOP 自动配置通常选择类代理,也支持通过 spring.aop.proxy-target-class=false 选择接口代理。
不能把“实现了接口就一定使用 JDK 代理”用于所有 Boot 项目。不同自动配置、显式设置或其他代理需求可能改变选择,应检查最终 Bean 并结合配置判断。Boot AOP 配置
注入时使用稳定接口,有助于避免代码依赖某种代理实现。调用方也不应通过生产代码取出目标对象,再绕开代理调用。
2. 异常怎样影响提交与回滚
异常类型、捕获位置与传播行为都参与结果。
异常类型和异常位置都影响回滚
未改变默认规则时,RuntimeException 和 Error 通常触发回滚,受检异常通常不会。可以使用 rollbackFor 指定受检异常,或按版本与项目配置改变默认策略。
Spring 6.2 支持通过 @EnableTransactionManagement(rollbackOn = ALL_EXCEPTIONS) 改变全局默认行为。下面没有启用这一配置,因此实验展示的是传统默认规则。事务回滚规则
如果方法自己捕获异常并正常返回,事务拦截器通常只能看到成功返回;但如果事务早已被内部参与者标记为 rollback-only,捕获异常也不能让它恢复成可提交状态。
3. 用完整程序观察不同调用路径
示例限定自己的数据库与运行条件,不把观察扩大为全部环境保证。
完整实验配置
示例使用 H2 内存数据库,便于直接复现,不需要配置 MySQL。它验证 Spring 调用边界和 JDBC 提交结果,不用 H2 的行为代替 MySQL 锁或隔离级别结论。
创建 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>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.3</version>
<relativePath/>
</parent>
<groupId>demo</groupId>
<artifactId>transaction-demo</artifactId>
<version>1.0.0</version>
<properties><java.version>17</java.version></properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId><artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
<build><plugins><plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin></plugins></build>
</project>
创建 src/main/java/demo/Main.java:
package demo;
import java.io.IOException;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.annotation.Transactional;
@SpringBootApplication
public class Main {
public static void main(String[] args) {
try (var context = SpringApplication.run(Main.class, args)) {
// 实验结束后关闭数据库连接池和应用上下文。
}
}
public static class InnerService {
private final JdbcTemplate jdbc;
public InnerService(JdbcTemplate jdbc) { this.jdbc = jdbc; }
@Transactional
public void fail() {
jdbc.update("insert into tx_item(label) values (?)", "inner");
throw new IllegalStateException("inner failed");
}
}
public static class DemoService {
private final JdbcTemplate jdbc;
private final InnerService inner;
public DemoService(JdbcTemplate jdbc, InnerService inner) {
this.jdbc = jdbc;
this.inner = inner;
}
@Transactional
public void runtimeFailure() {
insert("runtime");
throw new IllegalStateException("failed");
}
public void selfInvocation() {
runtimeFailure();
}
@Transactional
public void checkedFailure() throws IOException {
insert("checked");
throw new IOException("failed");
}
@Transactional(rollbackFor = IOException.class)
public void checkedRollback() throws IOException {
insert("checked-rule");
throw new IOException("failed");
}
@Transactional
public void swallowed() {
insert("swallowed");
try {
throw new IllegalStateException("local failure");
} catch (IllegalStateException ignored) {
// 用于演示吞掉异常的提交结果。
}
}
@Transactional
public void caughtInnerFailure() {
insert("outer");
try {
inner.fail();
} catch (IllegalStateException ignored) {
// 内层代理已把共享事务标记为 rollback-only。
}
}
private void insert(String label) {
jdbc.update("insert into tx_item(label) values (?)", label);
}
}
@Bean
InnerService innerService(JdbcTemplate jdbc) { return new InnerService(jdbc); }
@Bean
DemoService demoService(JdbcTemplate jdbc, InnerService inner) {
return new DemoService(jdbc, inner);
}
@FunctionalInterface
interface Action { void run() throws Exception; }
private static void report(JdbcTemplate jdbc, String name, Action action) {
jdbc.update("delete from tx_item");
String failure = "none";
try {
action.run();
} catch (Exception ex) {
failure = ex.getClass().getSimpleName();
}
Integer rows = jdbc.queryForObject("select count(*) from tx_item", Integer.class);
System.out.println(name + ": rows=" + rows + ", exception=" + failure);
}
@Bean
CommandLineRunner experiment(JdbcTemplate jdbc, DemoService service, InnerService inner) {
return args -> {
jdbc.execute("create table tx_item(label varchar(40))");
report(jdbc, "external-runtime", service::runtimeFailure);
report(jdbc, "self-invocation", service::selfInvocation);
report(jdbc, "checked-default", service::checkedFailure);
report(jdbc, "checked-rule", service::checkedRollback);
report(jdbc, "swallowed", service::swallowed);
report(jdbc, "unmanaged-object", new DemoService(jdbc, inner)::runtimeFailure);
report(jdbc, "required-inner-caught", service::caughtInnerFailure);
};
}
}
在项目目录执行 mvn spring-boot:run。除启动日志外,实验输出为:
external-runtime: rows=0, exception=IllegalStateException
self-invocation: rows=1, exception=IllegalStateException
checked-default: rows=1, exception=IOException
checked-rule: rows=0, exception=IOException
swallowed: rows=1, exception=none
unmanaged-object: rows=1, exception=IllegalStateException
required-inner-caught: rows=0, exception=UnexpectedRollbackException
完整程序已经本地运行核对。每组先清空数据,再通过数据库实际记录数判断结果,避免只凭异常日志猜测提交状态。
七组结果分别说明什么?
外部运行时异常调用经过代理,回滚后没有记录。自调用的外层方法没有事务,内层注解未被拦截,插入以自动提交方式完成。
受检异常实验确实有事务,但默认规则提交了数据;加上 IOException 回滚规则后,同类异常使数据回滚。这种情况不是“事务没有启动”,而是回滚规则不同。
吞异常组在目标方法内正常返回,也没有参与者提前设置 rollback-only,因此提交。手动 new 出来的 DemoService 没有经过 Spring 代理,即使类中写了注解也不会自动变成事务对象。
最后一组的内层是另一个 Bean,调用经过代理。默认 REQUIRED 参与外层事务,内层异常把共享物理事务标记为 rollback-only。外层捕获异常后尝试提交,收到 UnexpectedRollbackException,最终两条记录都未保留。REQUIRED 的传播语义
4. 修复需要重新划定业务边界
明确服务分工、跨线程与外部副作用,避免只在同类内移动注解。
如何修复,而不是绕开问题?
自调用问题通常可以把事务业务操作拆到另一个 Bean,由编排层调用;或者使用 TransactionTemplate 显式界定事务范围。优先让调用结构与业务边界一致,而不是为了代理而引入难以理解的循环依赖。
异常处理应明确业务希望回滚哪些失败。需要回滚时保留异常传播,或显式设置回滚状态;需要继续执行时,不能假定已标记回滚的事务还可提交。
REQUIRES_NEW 可以建立独立事务,但意味着独立提交与额外连接需求,也可能改变整体原子性。它不是吞异常问题的通用修复按钮。
事务还有哪些边界?
类代理不能拦截 private 方法,也无法覆盖 final 方法;接口代理要求调用处于接口暴露的范围。Spring 6 的类代理可以支持某些非 public 方法,不能把旧版本的“只能 public”当作所有配置通用结论。
常见 JDBC 事务与当前线程关联。把数据库写入放到新线程或异步任务中,不会自动继承调用线程的本地事务。
本地数据库事务也不能直接回滚已经发送的 HTTP 请求、邮件或消息。数据库提交与外部副作用需要单独设计一致性方案。
排查时按什么顺序看?
先复现一个明确的失败场景并查询实际数据,再检查 Bean 是否由容器管理、入口是否经过代理、事务管理器是否匹配数据源、连接是否参与事务。
随后检查异常类型、捕获位置、回滚配置以及是否已经 rollback-only。最后才讨论传播行为和不同资源之间的关系。
测试自身若带有事务并在结束时自动回滚,也可能掩盖真实提交行为。验证业务事务时,需要明确测试事务与业务事务的关系。Spring 测试事务
5. 设计判断与适用边界
事务边界围绕需要一致提交的业务事实确定。数据库两次写入可以纳入一个本地事务,但远程模型、消息和第三方接口不能自然一起回滚。把外部调用长期放在事务里,还会扩大锁和连接占用。
捕获异常后返回正常值,可能让事务机制误认为工作完成。业务需要恢复时,明确哪些失败可以处理、哪些必须向外传播。对数据库异常后的事务状态保持谨慎,不在已失败事务里继续假装正常工作。
6. 问答
问:有 @Transactional 就一定启动事务吗?
不是。代理、事务基础设施、方法条件和实际调用路径都必须满足。
问:自调用时,数据库操作一定没有事务吗?
不一定。它可能运行在外层已有事务中,只是没有应用内层注解的独立配置。
问:对象是 Spring Bean,就一定有 AOP 代理吗?
不是。还需要相应基础设施和匹配的增强条件。
问:自调用为什么没有切面日志?
目标对象内部没有再次经过代理入口。