Spring 事务为什么会失效?结合真实代码讲清几个常见坑

0 阅读9分钟

摘要

很多人都遇到过这种情况:代码明明加了 @Transactional,但数据还是提交了,或者异常发生后并没有按预期回滚。本文结合真实开发中最常见的几个场景,梳理 Spring 事务失效的典型原因,包括同类方法内部调用、异常被吞掉、非运行时异常不回滚、方法访问权限问题以及异步场景下事务边界变化等,并给出对应的正确写法和排查思路。

前言

在日常开发里,Spring 事务几乎是最常用的能力之一。

很多业务代码都会这么写:

@Transactional(rollbackFor = Exception.class)
public void doBusiness() {
    // 业务处理
}

看起来很简单,好像只要加上这个注解,事务问题就解决了。

但真实开发里,事务相关的问题其实非常多,最常见的一类就是:

代码明明加了 @Transactional,但事务并没有按预期生效。

这种问题很容易让人误判,因为表面上看代码没问题,日志里也未必直接报错,但结果就是:

  • 该回滚的时候没回滚
  • 该整体成功的时候只成功了一部分
  • 事务边界和自己想的不一样

这篇文章我就结合几个很常见的场景,整理一下 Spring 事务为什么会失效,以及实际开发中该怎么避免这些坑。

一、为什么会感觉事务“加了但没生效”

很多人第一次遇到事务问题时,都会有一种感觉:

我都已经加注解了,为什么还是不对?

原因就在于,Spring 事务并不是“看到注解就一定生效”,它本质上依赖的是 AOP 代理机制

也就是说,事务能否生效,和下面这些因素都有关系:

  • 方法是不是通过 Spring 代理对象调用的
  • 异常有没有真正抛出去
  • 抛出的异常类型是不是默认支持回滚
  • 方法访问权限是否符合代理要求
  • 当前执行线程是不是还在原来的事务上下文里

所以事务问题很多时候并不是“注解没写”,而是:

事务的触发条件和你的实际调用方式不一致。

二、同类方法内部调用,事务为什么会失效

这是最经典、也最容易踩的坑。

错误写法

@Service
public class OrderService {

    public void createOrder() {
        saveMainOrder();
    }

    @Transactional(rollbackFor = Exception.class)
    public void saveMainOrder() {
        // 保存主单
        // 保存明细
        throw new RuntimeException("模拟异常");
    }
}

很多人会觉得,saveMainOrder() 已经加了事务,执行异常应该回滚。

但如果 createOrder() 是在当前类内部直接调用 saveMainOrder(),事务很可能不会生效。

为什么会失效

因为 Spring 事务依赖代理对象。

而同类内部调用,本质上是 this.saveMainOrder(),没有经过 Spring 代理,自然也就绕过了事务增强。

正确写法一:把事务方法拆到另一个 Bean

@Service
public class OrderService {

    @Resource
    private OrderTxService orderTxService;

    public void createOrder() {
        orderTxService.saveMainOrder();
    }
}

@Service
public class OrderTxService {

    @Transactional(rollbackFor = Exception.class)
    public void saveMainOrder() {
        // 保存主单
        // 保存明细
        throw new RuntimeException("模拟异常");
    }
}

正确写法二:通过代理对象调用

这种方式能用,但不如拆 Bean 清晰,日常开发里我更推荐第一种。

一句话总结

同类内部直接调用,事务不会经过 Spring 代理,注解等于白加。

三、异常被捕获但没有继续抛出,为什么不会回滚

这个场景也特别常见。

错误写法

@Transactional(rollbackFor = Exception.class)
public void updateData() {
    try {
        // 更新表A
        // 更新表B
        int i = 1 / 0;
    } catch (Exception e) {
        log.error("执行异常", e);
    }
}

很多人会以为这里出异常了,事务就会自动回滚。

其实不一定。

为什么会失效

Spring 事务默认是通过“方法抛出异常”来感知是否需要回滚的。

如果你把异常 catch 住了,又没有继续往外抛,那对 Spring 来说,这个方法就是“正常结束”的,它自然会提交事务。

正确写法一:捕获后重新抛出

@Transactional(rollbackFor = Exception.class)
public void updateData() {
    try {
        // 更新表A
        // 更新表B
        int i = 1 / 0;
    } catch (Exception e) {
        log.error("执行异常", e);
        throw new RuntimeException(e);
    }
}

正确写法二:手动标记回滚

@Transactional(rollbackFor = Exception.class)
public void updateData() {
    try {
        // 更新表A
        // 更新表B
        int i = 1 / 0;
    } catch (Exception e) {
        log.error("执行异常", e);
        TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
    }
}

一句话总结

异常被吞掉,Spring 感知不到失败,事务通常不会回滚。

四、抛出的不是运行时异常,默认为什么不回滚

这个坑很多人也是在踩过之后才知道。

错误写法

@Transactional
public void handleData() throws Exception {
    // 业务处理
    throw new Exception("模拟受检异常");
}

很多人会觉得这里抛异常了,事务应该回滚。

但默认情况下,不一定。

为什么会失效

Spring 默认只会对:

  • RuntimeException
  • Error

进行回滚。

像 Exception 这种受检异常,如果你没有显式指定,默认是不回滚的。

正确写法

@Transactional(rollbackFor = Exception.class)
public void handleData() throws Exception {
    // 业务处理
    throw new Exception("模拟受检异常");
}

一句话总结

默认回滚的是运行时异常,不是所有异常。

五、方法不是 public,事务为什么可能不生效

这个坑属于不太起眼,但也确实会遇到(在 IntelliJ IDEA 这类的工具中,会有明显的编译错误)。

错误写法

@Transactional(rollbackFor = Exception.class)
private void doUpdate() {
    // 业务处理
}

或者:

@Transactional(rollbackFor = Exception.class)
protected void doUpdate() {
    // 业务处理
}

为什么会失效

在常见的 Spring 代理模式下,事务增强一般是针对 public 方法生效的。

如果方法不是 public,很多场景下事务代理根本不会生效。

正确写法

@Transactional(rollbackFor = Exception.class)
public void doUpdate() {
    // 业务处理
}

一句话总结

事务方法尽量用 public,不要把关键事务逻辑放在 private 方法上。

六、异步或多线程场景下,事务边界为什么会变

这个坑在复杂业务里特别容易被忽略。

典型场景

@Transactional(rollbackFor = Exception.class)
public void process() {
    // 主线程更新数据

    CompletableFuture.runAsync(() -> {
        // 子线程更新数据
    });

    throw new RuntimeException("模拟异常");
}

很多人会以为,外层方法有事务,里面异步线程的操作也应该跟着一起回滚。

实际上通常不是这样。

为什么会失效

Spring 事务是和当前线程绑定的。

你在主线程里开启的事务,并不会自动传播到新的异步线程里。

也就是说:

  • 主线程有主线程的事务上下文
  • 子线程是新的执行线程
  • 子线程里的数据库操作,默认不在主线程事务里

所以你主线程抛异常回滚,并不代表子线程里的操作也会跟着回滚。

正确思路

异步场景下不要想当然地把它当成“还是同一个事务”。

通常应该:

  • 明确主线程和子线程的事务边界
  • 子线程里如果有独立数据库操作,需要自己定义事务策略
  • 对一致性要求高的场景,不要轻易混用事务和异步

一句话总结

Spring 事务默认是线程级别的,跨线程后事务上下文就变了。

七、真实开发里,我一般怎么排查事务问题

事务问题有时候比普通 bug 更绕,因为代码表面上看不一定有错。

我一般会按下面几个方向排查。

1. 先看调用方式有没有经过 Spring 代理

先确认是不是:

  • 同类内部调用
  • this.xxx() 调用
  • 非 Spring 管理对象调用

这类问题出现概率非常高。

2. 再看异常有没有真正抛出去

重点确认:

  • 是不是 try-catch 后吞掉了
  • 有没有只是打日志没抛异常
  • 有没有手动 return 掉

3. 看异常类型是否支持默认回滚

如果抛的是受检异常,要确认有没有写:

@Transactional(rollbackFor = Exception.class)

4. 看执行线程有没有变化

如果中间有这些场景,要特别小心:

  • @Async
  • CompletableFuture
  • 线程池
  • 消息异步消费

很多事务问题本质上不是“事务失效”,而是“你已经不在原来的事务线程里了”。

5. 最后看日志和数据库结果

事务问题不能只看代码推断,最好结合:

  • SQL 执行日志
  • 异常日志
  • 数据库最终结果

因为事务最终有没有生效,还是要看数据结果。

八、我自己总结的几个经验

1. 不要把事务想得太“自动化”

很多人会下意识觉得,只要加了 @Transactional,Spring 就会自动处理好一切。

其实不是。

事务生效有前提,回滚也有条件。

2. 事务代码要尽量清晰,不要写得太绕

事务方法里如果:

  • 又有内部调用
  • 又有异步
  • 又有异常吞掉
  • 又有多层封装

那后期出问题会很难查。

3. 对一致性要求高的代码,最好显式设计事务边界

不要把“事务应该会生效吧”当成理所当然。

重要链路最好一开始就把事务边界想清楚。

4. 真正判断事务有没有生效,最终还是看数据

代码只是“理论”,数据库结果才是“事实”。

总结

Spring 事务本身并不复杂,真正复杂的是:

业务代码的调用方式、异常处理方式以及执行线程,往往会让事务边界和想象中不一致。

这篇文章总结了几个最常见的事务失效场景:

  • 同类方法内部调用
  • 异常被捕获但没有继续抛出
  • 抛出的不是运行时异常
  • 方法不是 public
  • 异步或多线程场景下事务边界变化

如果你也遇到过“明明加了事务但还是不回滚”的问题,可以先按这几个方向排查,通常能比较快缩小范围。

很多时候,事务不是没加,而是:

加了,但没有在正确的调用链路里生效。