09-Spring事务与代理边界

O泡李华 8

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 代理吗?

不是。还需要相应基础设施和匹配的增强条件。

问:自调用为什么没有切面日志?

目标对象内部没有再次经过代理入口。

参考资料