Spring事务失效的8种场景全解析:从自调用到异常捕获,原理与排查实践
2026/9/24 21:24:55 网站建设 项目流程

上周代码评审,一个同事指着自己的Service方法问我:“我明明加了@Transactional,结果接口报错了,数据却还是进去了,事务根本没用啊。”我扫了一眼代码——方法写在Service类里,是public,没有被catch,表是InnoDB,配置看起来也没问题。最后定位到问题根源:他在同一个类里的另一个方法里,直接调用了这个带注解的方法,也就是所谓的“自调用”。事务在那一瞬间就已经注定失效了。

这个例子特别典型,因为它代表了一个普遍认知误区:很多人把@Transactional当成一个“开关”,以为只要方法上挂了注解,事务就一定生效。但Spring声明式事务的底牌是AOP动态代理,不是“魔法”。不搞懂这条主线,今天你避开这8个场景,明天还会踩第9个坑。

这篇文章我直接按场景拆,每个场景都讲清楚三件事:它为什么会失效、底层机制是什么、怎么改才是对的。文章偏实战,适合正在用Spring Boot写业务、以及面试前想彻底搞懂事务失效原理的同学。

1. 先拆掉底牌:声明式事务靠的是AOP代理,不是注解魔法

1.1 数据库事务原本是什么样

先回到最底层。在JDBC操作里,一段事务代码长这样:

Connection conn = dataSource.getConnection(); try { conn.setAutoCommit(false); // 执行SQL preparedStatement.executeUpdate(); // 其他SQL conn.commit(); } catch (Exception e) { conn.rollback(); throw e; } finally { conn.close(); }

核心就三件事:关掉自动提交、执行SQL、最后commit或rollback。数据库事务本身并没有多神秘,它就是围绕Connection做的状态管理。

1.2 Spring把“事务”翻译成环绕通知

Spring做的事情,是把上面这段样板代码抽出来,做成一个统一的处理逻辑。当你在方法上加了@Transactional,Spring会为这个Bean生成一个代理对象。代理对象在执行你的业务方法之前,先开启事务;业务方法正常return,就commit;方法抛异常,就rollback。

这个逻辑在我的理解里,就是一个加了事务逻辑的AOP环绕通知。链路是这样的:

外部调用者 -> 代理对象 -> 开启事务 -> 执行业务方法 -> 正常/异常 -> commit/rollback

如果外部调用者拿到的不是代理对象,那所有这些“开启事务、回滚”的逻辑就全部不存在。这就是绝大多数事务失效问题的总根源。

1.3 代理对象和原始对象,到底差在哪里

Spring容器里保存的Bean,默认是一个代理对象,而不是你写的那个类的原始实例。当你调用bean.someMethod()时,执行的是代理对象里被增强过的方法。

判断一个Bean是不是代理对象,最简单的办法是在事务方法里打个断点,然后看this.getClass()。如果是类似com.sun.proxy.$Proxy121(JDK动态代理)或者com.demo.service.OrderService$$EnhancerBySpringCGLIB$$xxx(CGLIB代理),说明是代理类;如果就是OrderService本身,说明这个Bean根本没有被代理,事务必然不生效。

Spring Boot 2.x之后,默认使用CGLIB代理,不管类有没有实现接口都会生成代理子类。Spring Framework老项目里,如果类实现了接口,默认走JDK动态代理,只能代理接口方法。这个差异在排查问题时偶尔会碰到,后面我会细说。

2. 八种失效场景,逐个拆给你看(一):调用链问题

2.1 场景一:同一个类里自己调自己,绕过了代理

这是我在实际项目里见过最多的坑,没有之一。看这段代码:

@Service public class OrderService { public void order() { // 业务逻辑:校验、计算价格等 this.createOrder(); // 直接调用本类方法 } @Transactional public void createOrder() { // 写订单表、扣库存、扣余额 } }

order()方法里通过this调用了createOrder()。这个this是谁?是原始对象,不是代理对象。因为Spring的代理机制只对“从外部进入Bean的调用”生效,内部方法之间通过this调用是绕过了代理的。

英文里这叫self-invocation,中文社区一般叫“自调用”或“同类内部调用”,是事务失效场景里的头号种子选手。

解决办法有三种,我按推荐程度排序:

  • 拆分到另一个Service(最推荐,职责也清晰)
@Service public class OrderService { @Autowired private OrderCreateService orderCreateService; public void order() { orderCreateService.createOrder(); } }
  • 注入自身代理对象
@Service public class OrderService { @Autowired private OrderService self; public void order() { self.createOrder(); // 调用的是代理对象 } }

注意这里自己注入自己会有循环依赖,Spring Boot 2.6+默认禁止循环依赖,建议加@Lazy,或者干脆用第一种方法。

  • 使用AopContext暴露的代理对象
public void order() { ((OrderService) AopContext.currentProxy()).createOrder(); }

使用这种方式要在启动类上配置@EnableAspectJAutoProxy(exposeProxy = true),代码里也有点“魔法味道”,我一般不太推荐。

2.2 场景二:方法不是public,官方明确不支持

@Transactional注解官方文档明确写着:

When using proxies, you should apply the @Transactional annotation only to methods with public visibility.

翻译过来就是:利用代理实现事务时,只支持public方法。如果你把注解加在private、protected或者默认包可见性的方法上,事务不会生效。

这是由代理机制决定的。JDK动态代理是基于接口,CGLIB是基于类继承生成子类,都会受限于Java访问控制。Spring在创建代理时会判断目标方法是否可被代理,非public方法不会进入事务增强逻辑。

还有个细节值得注意:如果方法虽然是public的,但类本身是final的,那么CGLIB无法生成子类;如果方法是final的,CGLIB无法重写这个方法。这两种情况下代理都无法增强,事务也会静默失效。

IDE其实已经能帮你发现问题:在IDEA或STS里,把一个非public方法加上@Transactional,IDE会给出警告“Method annotated with @Transactional is not public...”,只是很多人没注意。

2.3 场景三:final方法或static方法,代理根本增强不了

这个场景和上一个密切相关,我单独拎出来说。

CGLIB是通过生成目标类的子类来实现代理的,子类必须能重写父类方法。但如果方法被final修饰,子类没法重写;如果类被final修饰,连子类都生成不了。static方法更不用说了,它属于类而不是实例,代理机制完全管不到。

实际代码中的表现是:你在一个static方法上加@Transactional,编译器不报错,运行时不报错,但它就是什么都没发生。数据照样写进去,异常照样不会回滚。

还有一点容易忽略:当你把一个类标注为final时,Spring Boot 2.x默认的CGLIB代理方式会直接启动报错(比如无法生成代理子类),但方法级final不会报错,它只是“静默失效”。

我自己的经验是,在Service层基本不需要写final方法或static方法,如果确实有工具类的需求,把方法放到独立的Util类里,不要和事务方法混在一起。

3. 八种失效场景,逐个拆给你看(二):异常传播问题

3.1 场景四:try-catch把异常吞了,事务压根不知道

再看一个在真实项目里反复出现的写法:

@Transactional public void createOrder() { try { orderDao.insert(order); stockService.deduct(); throw new RuntimeException("库存不足"); } catch (Exception e) { log.error("下单失败", e); // 没有重新抛出 } }

这段代码的结果是:订单写进去了,库存扣了,然后一切被当作“正常流程”提交了。因为你把异常捕获后没有重新抛出,Spring事务的环绕通知根本感知不到方法内部发生了什么,它看到的返回值是正常的,自然就commit了。

很多同事和我讨论这个问题时都会说:“我明明在catch里打了日志,知道出错了,为什么没回滚?”原因就是你没把异常还给框架。

修复的核心原则只有一个:让异常能够从这个方法抛出去。有两种修法:

  • 捕获后重新抛出RuntimeException:
catch (Exception e) { log.error("下单失败", e); throw new RuntimeException(e); }
  • 手动把事务标记为回滚:
catch (Exception e) { TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); log.error("下单失败", e); }

第二种方式适合“我确实需要捕获异常做一些事情,但又不希望事务提交”的场景。注意setRollbackOnly之后,如果方法最后继续抛出异常没问题,如果没抛异常,事务也会回滚。

3.2 场景五:抛的是checked异常,默认不回滚

这个问题在面试里被问烂了,但业务代码里还是会反复踩。

Spring默认的事务回滚规则是:只对RuntimeException(运行时异常)和Error进行回滚,对checked异常(受检异常)不回滚

所以下面这段代码并不会回滚:

@Transactional public void createOrder() throws Exception { orderDao.insert(order); throw new Exception("库存不足"); // checked异常,默认不回滚! }

Spring的默认行为从设计初衷上看,是认为“受检异常代表一种可以被业务处理的预期情况,比如余额不足、库存为0,这些可能希望流程继续或者做补偿操作,而不是直接回滚已执行的SQL”。但这种设计在国内的业务系统里经常被误解,绝大多数场景下,我们就是希望所有异常都能回滚。

正确写法是显式指定回滚类型:

@Transactional(rollbackFor = Exception.class) public void createOrder() throws Exception { orderDao.insert(order); throw new Exception("库存不足"); }

更推荐的做法是:开启事务时,一行rollbackFor写全:

@Transactional(rollbackFor = Exception.class)

这样无论RuntimeException还是checked异常,只要方法没接住,统统回滚。

3.3 场景六:传播行为配置不当,事务被“挂起”或被忽略

Spring事务传播行为是另一个经常被配置错的点。它描述的是:当一个事务方法去调用另一个事务方法时,这个新方法该怎么处理事务。

默认的传播行为是REQUIRED,含义是:如果当前已经有事务,就加入;没有就新建一个。这个默认值在绝大多数场景下是对的,问题往往出在有人“主动”修改了传播行为。

下面这张表是几个最常用的传播行为:

传播行为含义事务是否生效
REQUIRED(默认)有事务就加入,没有就新建正常
REQUIRES_NEW挂起当前事务,无论有没有都新建一个新方法独立事务
SUPPORTS有事务就加入,没事务就以非事务方式执行可能失效
NOT_SUPPORTED以非事务方式执行,有事务则挂起事务被忽略
NEVER必须以非事务方式执行,有事务则抛异常事务被抛异常打断
NESTED有事务则嵌套事务,没有则新建嵌套回滚点

前一段时间遇到一个业务场景,同事在一个大事务方法里调用了一个子方法,为了“独立”,把子方法配成了REQUIRES_NEW。结果子方法抛异常后,只有子方法自己的数据回滚了,外层大事务之前做的其他操作却提交了。业务上看就是“部分成功”,找了大半天。

这类问题的排查思路是:确认你配的传播行为,到底是你真正想要的。如果只是为了让事务生效,用默认REQUIRED就行,不要随手改。

4. 八种失效场景,逐个拆给你看(三):环境与隔离问题

4.1 场景七:数据库引擎不支持事务

这个场景在现代化项目里已经比较少见,但在老系统里仍然能遇到。MySQL的MyISAM引擎,根本不支持事务。你加不加@Transactional,都不会有回滚。

判断方法很简单:

SHOW TABLE STATUS WHERE Name = 'user'; -- 看Engine字段是MyISAM还是InnoDB

或者直接看建表语句:

SHOW CREATE TABLE user;

如果Engine是MyISAM,解决办法就是把表转换成InnoDB:

ALTER TABLE user ENGINE = InnoDB;

还有一个容易被忽略的边界:MySQL的DDL语句(比如CREATE、ALTER、DROP、TRUNCATE)本身不支持事务,就算你在事务方法里写了这些操作,一旦执行就无法回滚。这不是Spring的问题,是数据库层面的能力边界。代码里如果事务里混有DDL,不要对它抱有任何“出错了能回滚”的期望。

4.2 场景八:多线程环境下事务被拆散

Spring事务和线程是强绑定的。底层实现里,事务上下文(就是那个Connection)是放在ThreadLocal里的。这意味着,主线程开启的事务,子线程拿不到;子线程里执行的数据库操作,天然不在主线程事务的保护范围内。

典型的错误代码:

@Transactional public void createOrder() { orderDao.insert(order); new Thread(() -> { stockService.deduct(); // 这里不在主事务里 }).start(); // 主事务回滚了,子线程操作不会回滚 }

这种问题在异步化改造、消息推送、报表导出场景里很容易出现。你说它“事务失效”吧,主线程的事务其实是生效的;但子线程里数据没被保护,从业务结果上看就是“回滚不完整”。

正确的姿势是:要么把子线程里的数据库操作移回主线程执行;要么让子线程自己开启独立事务(比如在子线程里单独注入一个Service方法,配REQUIRED自己开事务)。如果是Spring @Async异步方法,同样要注意,异步线程和调用线程之间没有任何事务传递关系。

4.3 配置层面的“伪失效”:事务管理器没配对

严格来说这不算“事务失效”,但我在排查中经常需要确认这一点,所以放在一起说。

Spring Boot里,如果你只配置了一个数据源,DataSourcTransactionManager会被自动配置,@Transactional直接生效。但在多数据源项目里,每个数据源需要对应独立的事务管理器,@Transactional默认按名字查找,如果没找到就会按类型匹配,类型有多个时就可能选错。

如果事务配置不对,现象通常是:某个方法加了@Transactional完全没反应,但其他方法又是好的。

多数据源下的正确写法是:

@Transactional(transactionManager = "orderTransactionManager") public void createOrder() { // ... }

需要先声明两个事务管理器:

@Bean public PlatformTransactionManager orderTransactionManager(@Qualifier("orderDataSource") DataSource ds) { return new DataSourceTransactionManager(ds); } @Bean public PlatformTransactionManager userTransactionManager(@Qualifier("userDataSource") DataSource ds) { return new DataSourceTransactionManager(ds); }

还有一类“伪失效”来自老Spring项目:如果项目是纯Spring MVC,没有开启<tx:annotation-driven/>@EnableTransactionManagement,那么@Transactional也是不生效的。Spring Boot因为自动配置了,这个问题不常见,但排查事务问题时值得花30秒确认一下。

5. 事务失效的排查方法论:5分钟定位问题

5.1 一个能证明事务是否生效的最小Demo

遇到“事务不生效”的投诉,我第一件事不是改代码,而是写一个最小Demo去验证当前环境到底有没有问题。下面这个类可以直接扔进项目里跑:

@Service public class TxCheckService { @Autowired private JdbcTemplate jdbcTemplate; @Transactional(rollbackFor = Exception.class) public void shouldRollback() { jdbcTemplate.update("insert into tx_demo(name) values (?)", "rollback-test"); throw new RuntimeException("故意失败,看数据是否回滚"); } }

调用这个方法后,去数据库查:

SELECT * FROM tx_demo WHERE name = 'rollback-test';

如果查不到记录,说明事务基础能力正常;如果记录存在,说明事务并没有生效,问题一定出在“代理未开启”“方法调用链异常”“数据源配置有问题”中的某一个环节。

5.2 排查链路:从环境到代理到异常

我把平时排查“事务回滚失败”的检查顺序列成了下面这个清单,按这个顺序操作,90%的问题能在五分钟内定位。

  1. 确认表引擎:查SHOW TABLE STATUS,Engine是不是InnoDB。MyISAM直接排除。
  2. 确认Bean被Spring管理:类上有没有@Service/@Component,没加的话Spring容器里压根没有这个Bean,代理更不存在。
  3. 确认方法可见性和修饰符:方法是不是public、是不是final、是不是static。
  4. 确认调用链路:是不是同类内this调用,或通过final/static方法间接调了事务方法。
  5. 确认异常真的抛出去了:内部有没有catch包住不抛,有没有在catch里吞掉。
  6. 确认异常类型:如果抛的是Exception,有没有配rollbackFor。
  7. 确认事务管理器配置:多数据源时,transactionManager有没有指定对。
  8. 确认代理方式:如果应用里配置了Spring AOP和切面,也可能影响代理方式,这一步看启动日志或断点判断。

这8步做完,剩下的就是一些冷门边界问题,比如自定义了拦截器提前提交事务、代理对象类型与注入类型不匹配等,这些属于少数情况,可以单独深入排查。

5.3 调试技巧:看一眼案发现场

排查过程中,最有用的一个调试技巧是——在事务方法的第一行打一个断点,然后看this.getClass()的运行时类型

  • 如果是xxx$$EnhancerBySpringCGLIB$$xxx,说明这是CGLIB代理,代理链路正常。
  • 如果是com.sun.proxy.$Proxyxxx,说明是JDK动态代理,正常。
  • 如果this就是你的类本身(比如OrderService@1234),说明这个Bean根本没有被代理,接下来要查调用方拿到的到底是不是Spring容器里的Bean。

另一个有用的角度是看调用栈。在事务方法内打一个断点,查看当前线程的调用栈,如果能看到TransactionInterceptorTransactionAspectSupport.invokeWithinTransaction,说明代理逻辑已经介入;看不到的话,说明事务根本没有插进来。

6. 工程实践:让事务在真实项目里“耐用”

6.1 事务该放哪一层

事务最好只放在Service层,理由有三个。

第一,Controller层是处理请求和参数的地方,不该关心事务细节。第二,DAO层的方法粒度太细,单条SQL操作本身就带自己的锁和提交行为,在DAO层加事务很容易导致事务范围过窄,真正需要原子性的业务操作没被覆盖。第三,事务放在Service层,能够让“业务操作”与“事务边界”保持一致,一个用例一个事务。

正确的分层习惯是:Controller接收参数、做参数校验,Service负责业务编排和事务控制,DAO只做数据读写。不要把事务注解加到DAO层,更不要加到Controller上。

6.2 事务粒度:不要写大事务

一个真实案例:同事负责的导入功能,一次导入一万条数据,每条数据要检查、转换、写入、更新关联表。他在导入方法上直接加了@Transactional,结果压测时数据库连接池被打爆了。

原因是:一个事务内执行了一万条数据的几十次SQL,Connection一直被占用,事务时间太长,连接池里的连接都被占满,后续请求全部排队。

这里有两个建议:

  • 能缩小事务范围就缩小。不需要在同一个事务里的操作(比如远程调用、文件解析、网络IO)移到事务外。
  • 大批量数据不要“循环内一条条开事务”,也不要“所有数据共享一个大事务”。更好的方式是分批处理,每批一个事务,失败后记录失败明细。

还有一类代码要注意,和自调用是“兄弟”问题:

public void batchCreate(List<Order> orders) { for (Order order : orders) { this.createOrder(order); // 自调用,createOrder上的事务全部失效 } }

解决方案是把createOrder拆分到另一个Service。拆分之后要意识到:现在是每循环一次都开一个新事务。如果数据量大,可以考虑自己控制批量边界,比如每100条作为一个事务批次,这样性能和可靠性可以兼顾。

6.3 事务和锁的配合

事务里如果要用悲观锁(SELECT ... FOR UPDATE)或乐观锁(version字段),要注意事务边界和锁释放时机。

悲观锁依赖事务提交或回滚来释放锁,所以锁定的查询必须和后续的更新操作放在同一个事务里,否则锁被提前释放或者根本不会释放。乐观锁则要确保更新时带上版本号条件,否则update影响行数为0时要知道是冲突了。

在实际项目里,事务+锁最容易出现的错误是“查了没锁,更新时却发现已经被别人改过了”,或者“锁的范围太大,拖慢整个表”。这两种问题不在事务失效的范畴,但排查事务问题时经常被一起提出来,这里提醒一句。

6.4 事务与异步、多线程的边界

前面聊过,Spring事务绑定在当前线程,@Async方法、new Thread手动创建的线程都拿不到调用方的事务。

真实项目里更容易出问题的是:在事务方法里发了MQ消息,事务方法执行完后,MQ的消费方立刻去查数据库。如果事务还没提交,消费方可能查不到数据。这不是事务失效,而是事务和消息不在同一个“一致性边界”里。解决方案通常是把消息发送放到事务提交后,比如Spring的事务同步器TransactionSynchronizationManager.registerSynchronization,在afterCommit里再发送消息。

类似的边界还有:事务方法里做了缓存更新,如果事务回滚了,但缓存已经更新了,就会出现数据不一致。处理办法是保证缓存更新也放在事务提交后执行,或者接受最终一致性的方案。

7. 给新人的Final建议

把八种失效场景和背后的原理重新过一遍,你会发现真正需要记的东西其实就两条:第一,调用者拿到的Bean到底是不是代理对象;第二,异常有没有正确传播到代理的环绕通知里。这两条理解了,事务失效问题可以覆盖90%以上。

我自己的习惯是,写完一个带事务的方法后,顺手做三件事:确认方法是不是public、确认有没有同类自调用、确认异常有没有被吞掉。这个习惯帮我提前拦截了不少上线后才暴露的问题。如果项目里用了MyBatis-Plus、JPA这类框架,事务注解的用法基本一致,但它们的一些内部方法调用(比如同一Mapper里方法互调)也可能踩到类似的坑,排查思路是一样的。

最后,如果你还在看这篇文章,大概率已经被事务失效折腾过几次了。没关系,这个坑不是只有你踩过。把原理吃透,把排查流程记牢,后面再遇到事务问题,你会感谢今天花时间的自己。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询