Spring Boot 从切面统一控制事务

Spring Boot 从切面统一控制事务:告别满屏 @Transactional 的实战方案

本文用一个自定义 AOP 切面接管 Spring Boot 项目里所有 Service 层的事务控制,覆盖注解驱动与包级切点两种实现、切面排序、只读事务、回滚规则,最后附 7 个真实踩坑。代码基于 Spring Boot 3.x / 4.x(jakarta 命名空间),可直接复制运行。

前言

@Transactional 用久了,几个典型痛点一定会冒出来:

  • 漏标注解 :新同事写了个 Service 方法忘了加 @Transactional,上线后一半数据写进去了,一半没有;
  • 回滚规则不统一 :有人写了 rollbackFor = Exception.class,有人没写,受检异常的回滚行为各处不一致;
  • 代码侵入:业务方法上挂满事务属性,事务策略散落在几十个注解里,想统一调整传播行为、超时时间要改 N 处。

一个更工程化的思路是:把事务控制从业务代码里抽出来,交给一个统一的 AOP 切面。本文就来完整实现这个方案。

先说清楚一件事:Spring 的声明式事务(@Transactional)底层本来就是 AOP 实现的。我们要做的,本质上是手写一个"事务切面",把事务策略从注解分散配置收敛为切面里的集中配置。

一、核心原理

自定义事务切面的工作模型:

text 复制代码
调用方 → 代理对象 → 自定义事务切面(@Around)
                        │ 1. 开启事务(getTransaction)
                        │ 2. 执行目标方法 proceed()
                        │ 3a. 正常返回 → commit
                        │ 3b. 抛出异常 → rollback
                        └→ 目标 Service 方法

切面里操作事务有两个主流工具:

工具 用法 适用场景
PlatformTransactionManager getTransaction() / commit() / rollback() 手动控制 需要精细控制事务生命周期
TransactionTemplate execute(status -> {...}) 回调式 Spring 官方推荐的编程式事务写法,代码更简洁

Spring 官方文档明确建议:命令式流程的编程式事务管理优先使用 TransactionTemplate。本文两种写法都会给出。

二、环境准备

xml 复制代码
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <!-- 3.5.x 是 3.x 系列最后一个开源维护线;新项目可直接上 4.1.x(基于 Spring Framework 7) -->
    <version>3.5.16</version>
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <!-- AOP 支持:提供 AspectJ 注解能力 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-aop</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>com.mysql</groupId>
        <artifactId>mysql-connector-j</artifactId>
        <scope>runtime</scope>
    </dependency>
</dependencies>

关键点:spring-boot-starter-aop 必须引,否则 @Aspect 不生效。引入 starter-data-jpa 后,Spring Boot 会自动配置 JpaTransactionManager(即 PlatformTransactionManager 的实现),切面里直接注入即可,不需要手写任何事务管理器配置

三、方案一:自定义注解 + 切面(推荐)

思路:定义一个 @Tx 注解标记需要事务的方法,切面只拦带注解的方法,事务属性全部由注解参数控制。相比 @Transactional 的好处是:默认回滚规则由我们自己定,一次配置全局生效

3.1 定义注解

java 复制代码
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Tx {

    /** 传播行为,默认 REQUIRED */
    Propagation propagation() default Propagation.REQUIRED;

    /** 隔离级别,默认用数据库自身的 */
    Isolation isolation() default Isolation.DEFAULT;

    /** 只读事务,查询方法设为 true 可让框架做优化 */
    boolean readOnly() default false;

    /** 超时时间(秒),默认不限制 */
    int timeout() default -1;

    /** 触发回滚的异常类型,默认所有 Exception(含受检异常) */
    Class<? extends Throwable>[] rollbackFor() default {Exception.class};

    /** 不回滚的异常类型,优先级高于 rollbackFor */
    Class<? extends Throwable>[] noRollbackFor() default {};
}

3.2 切面实现(TransactionTemplate 版)

java 复制代码
@Aspect
@Component
@Order(0)   // 见第五节:事务切面的排序问题
public class TxAspect {

    private final TransactionTemplate transactionTemplate;

    public TxAspect(PlatformTransactionManager txManager) {
        this.transactionTemplate = new TransactionTemplate(txManager);
    }

    @Around("@annotation(tx)")
    public Object around(ProceedingJoinPoint pjp, Tx tx) throws Throwable {
        // 1. 把注解参数映射到事务定义
        transactionTemplate.setPropagationBehavior(tx.propagation().value());
        transactionTemplate.setIsolationLevel(tx.isolation().value());
        transactionTemplate.setReadOnly(tx.readOnly());
        if (tx.timeout() > 0) {
            transactionTemplate.setTimeout(tx.timeout());
        }

        // 2. 回调式执行:异常自动回滚,正常返回自动提交
        try {
            return transactionTemplate.execute(status -> {
                try {
                    return pjp.proceed();
                } catch (Throwable e) {
                    // 3. 按注解配置的回滚规则判断
                    if (shouldRollback(e, tx)) {
                        status.setRollbackOnly();   // 标记回滚
                    }
                    throw new RuntimeException(e);  // 抛出去让 execute 感知异常
                }
            });
        } catch (RuntimeException e) {
            throw e.getCause() != null ? e.getCause() : e;   // 还原原始异常
        }
    }

    private boolean shouldRollback(Throwable ex, Tx tx) {
        for (Class<? extends Throwable> noRollback : tx.noRollbackFor()) {
            if (noRollback.isInstance(ex)) {
                return false;
            }
        }
        for (Class<? extends Throwable> rollback : tx.rollbackFor()) {
            if (rollback.isInstance(ex)) {
                return true;
            }
        }
        return false;
    }
}

关键行解释

  • status.setRollbackOnly():不直接调 rollback,而是标记事务为回滚状态,TransactionTemplate 退出回调时会执行回滚------这是回调式事务里唯一正确的回滚姿势;
  • 异常被包了一层 RuntimeException 才能穿透 lambda,所以出口处要 getCause() 还原,否则调用方 catch 不到原始异常类型。

3.3 业务代码使用

java 复制代码
@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final StockService stockService;

    // 构造器注入省略

    @Tx(timeout = 10)   // 事务策略一目了然,漏不了
    public void createOrder(Order order) {
        orderRepository.save(order);
        stockService.deduct(order.getProductId(), order.getQuantity());
        // 任一步抛异常,整体回滚
    }

    @Tx(readOnly = true)
    public List<Order> listOrders(Long userId) {
        return orderRepository.findByUserId(userId);
    }
}

四、方案二:包级切点,全局兜底

如果团队约定"Service 层所有写方法默认要有事务",可以直接按包名切,一个注解都不用写:

java 复制代码
@Aspect
@Component
@Order(0)
public class GlobalTxAspect {

    private final PlatformTransactionManager txManager;

    public GlobalTxAspect(PlatformTransactionManager txManager) {
        this.txManager = txManager;
    }

    // 拦截 service 包下所有 public 方法
    @Pointcut("execution(public * com.example.demo.service..*(..))")
    public void serviceLayer() {}

    @Around("serviceLayer()")
    public Object around(ProceedingJoinPoint pjp) throws Throwable {
        // 已有事务上下文(嵌套调用)时不重复开新事务
        if (TransactionSynchronizationManager.isActualTransactionActive()) {
            return pjp.proceed();
        }

        DefaultTransactionDefinition def = new DefaultTransactionDefinition();
        // 方法名约定:查询类方法走只读事务
        if (isReadMethod(pjp.getSignature().getName())) {
            def.setReadOnly(true);
        }
        def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);

        TransactionStatus status = txManager.getTransaction(def);
        try {
            Object result = pjp.proceed();
            txManager.commit(status);
            return result;
        } catch (Throwable e) {
            txManager.rollback(status);
            throw e;
        }
    }

    private boolean isReadMethod(String methodName) {
        return methodName.startsWith("get") || methodName.startsWith("find")
            || methodName.startsWith("list") || methodName.startsWith("query")
            || methodName.startsWith("count");
    }
}

关键行解释

  • TransactionSynchronizationManager.isActualTransactionActive():判断当前线程是否已有活动事务,避免嵌套调用时重复开启/提交(REQUIRED 语义的手工实现);
  • getTransaction / commit / rollback 三件套是 PlatformTransactionManager 的标准编程式用法,控制粒度比 TransactionTemplate 更细。

两种方案怎么选?

维度 方案一(注解 + TransactionTemplate) 方案二(包级切点 + 事务管理器)
控制粒度 方法级,可按注解定制属性 包级统一策略
可读性 显式标注,意图清晰 约定式,需要团队共识
漏配置风险 忘加注解就没事务 全覆盖,不会漏
推荐场景 事务策略差异大的项目 事务策略高度统一的 CRUD 项目

我的建议 :方案一为主,个别需要全兜底的模块叠加方案二,并用 isActualTransactionActive() 防重复。

五、切面排序:最容易忽略的细节

自定义事务切面通常会和日志、鉴权等其他切面共存,执行顺序直接决定行为正确性

java 复制代码
@Aspect
@Component
@Order(0)   // 数值越小,越靠外层
public class TxAspect { ... }

@Aspect
@Component
@Order(10)  // 在事务切面内层执行
public class LogAspect { ... }

@Order 数值小的切面在外层,环绕通知的调用栈呈"洋葱模型":

text 复制代码
TxAspect.before → LogAspect.before → 目标方法 → LogAspect.after → TxAspect.after(commit)

为什么事务切面要在最外层? 以操作日志为例:如果日志切面在外层、事务切面在内层,业务回滚后日志记录也跟着丢了;反过来,事务在外层,日志切面内发生的异常不影响事务判断,且日志写入可以走自己的 REQUIRES_NEW 事务。原则:事务边界应该包住一切需要"同生共死"的逻辑。

六、常见问题与避坑

坑一:同类内部方法调用,切面不生效

典型现象:createOrder() 内部调用本类的 saveDetail(),后者标了 @Tx 却没进事务。原因是切面基于代理,内部调用走的是 this 而非代理对象。解法 :拆到不同类中调用;或注入自身代理(@Lazy 注入 self)。

坑二:切面里 catch 了异常,事务不回滚

java 复制代码
try {
    Object result = pjp.proceed();
    txManager.commit(status);
    return result;
} catch (Exception e) {
    log.error("出错了", e);   // 只记日志没 rollback!
    return null;
}

解法 :catch 块里必须先 txManager.rollback(status) 再决定异常是吞掉还是上抛;用 TransactionTemplate 则要保证异常能穿透回调。

坑三:受检异常默认不回滚

无论是 @Transactional 还是手写切面,Spring 默认只对 RuntimeExceptionError 回滚。业务抛 IOException 这类受检异常时数据照样提交。解法 :本文的 @Tx 注解已把 rollbackFor 默认设为 Exception.class,这正是自定义切面的价值之一。

坑四:自定义切面与 @Transactional 混用,事务边界混乱

同一个方法既有 @Transactional 又被自定义切面拦截,会出现两层事务逻辑叠加,嵌套语义难以预测。解法 :项目内二选一,并在代码规范里写死;迁移期可用 isActualTransactionActive() 做兜底判断。

坑五:TransactionTemplate 共享实例的状态污染

TransactionTemplate 本身是线程安全的,但它的传播行为、只读等属性是实例级状态。如果多个方法并发调用同一个实例且属性不同,会互相覆盖。解法 :要么每个切点配置新建模板,要么像方案一那样在 @Around 入口按当前注解重设属性(注意高并发下仍有竞争,严谨做法是每次 new TransactionTemplate(txManager) 后配置)。

坑六:只读事务里执行写操作

readOnly = true 的事务里做 insert/update,部分数据库驱动会直接报错,部分会静默成功但拿不到优化收益。解法:只读标记只给纯查询方法;命名约定(get/find/list)要严格执行。

坑七:长事务把连接池拖垮

切面包住的方法里混入了 RPC 调用、文件上传等耗时 IO,事务期间数据库连接一直被占用。解法 :事务方法内禁止远程调用,先拿结果再进事务;必要时给 @Tx(timeout = n) 设超时兜底。

七、总结

  • 自定义事务切面的本质 是手写 Spring 声明式事务的底层机制:@Around + PlatformTransactionManager(或 TransactionTemplate),把事务策略从分散的注解收敛到集中的切面配置。
  • 两种落地方式 :注解驱动(@Tx + TransactionTemplate,粒度细、意图清晰)和包级切点(全局兜底、不会漏),可按项目约定组合使用。
  • 切面排序@Order 控制,事务切面放最外层,让日志等横切逻辑跑在事务边界之内。
  • 三个最高频的坑:同类自调用不走代理catch 异常忘了 rollback受检异常默认不回滚
  • 事务方法里保持纯粹:只做数据库操作,耗时 IO 挪到事务外,配合超时时间防止连接池耗尽。

参考资料:Spring Framework 官方文档 Programmatic Transaction Management、Aspect Oriented Programming with Spring

相关推荐
Flynt1 小时前
Java并行流,让我debug了一整天
java
DFT计算杂谈1 小时前
FeSe超薄膜在CaF2衬底上的电子结构DFT研究
java·服务器·前端
circuitsosk1 小时前
长文本与高并发下的Token“瘦身”策略:Prompt压缩与上下文窗口优化
java·前端·python·prompt·上下文窗口·token优化
岁岁养乐多1 小时前
LangChain4j 工厂模式
java·开发语言
小小小米粒2 小时前
idea常用搭配
java
吃饱了得干活2 小时前
Java Map 核心原理:从数据结构到 put/get 执行,一篇彻底讲透
java·后端
晚安code2 小时前
Java并发集合详解:从HashMap到ConcurrentHashMap
java
不才不才不不才2 小时前
Spring 源码系列(17): HandlerMapping 与 HandlerAdapter 两大体系
java·后端·spring
lisin-lee-cooper2 小时前
JVM知识体系
java·jvm