1. 事务失效的底层逻辑与排查思路
1.1 为什么加了注解事务还是没生效
很多开发者第一次遇到@Transactional失效时,反应都是“我明明加了注解啊”。这个反应本身就说明了一个问题:大家把注解当成了“开关”,以为贴上就万事大吉。实际上@Transactional更像是一张“委托书”,它委托 Spring 的事务基础设施去帮你管理连接、提交和回滚。既然是委托,就有委托不出去的情况。
事务能生效,需要同时满足几个条件:Spring 容器接管了这个 Bean、调用经过了代理对象、事务管理器配置正确、异常类型匹配回滚规则、数据库引擎本身支持事务。任何一环断了,注解就变成了一句注释。
我在实际项目里排查这类问题,习惯按一条链路走:先确认代理有没有生成,再确认调用有没有走代理,然后看事务管理器和数据源,最后看异常和传播行为。这条链路能覆盖九成以上的失效场景,剩下的基本是数据库层面的问题。
1.2 一张排查链路图(文字版)
与其一上来就翻源码,不如先按下面这个顺序快速定位:
- 看日志里有没有
Creating new transaction或Participating in existing transaction这类输出,没有就说明事务根本没启动。 - 确认当前类是不是被 Spring 管理的 Bean,
new出来的对象一定没有事务。 - 确认调用方是不是从容器里拿的代理对象,而不是原始对象。
- 确认方法是不是
public,以及是不是被final或static修饰。 - 确认异常有没有被自己
catch掉,或者抛出的异常类型是否在回滚规则内。 - 确认数据库表引擎是不是 InnoDB,MyISAM 不支持事务。
- 确认是不是同一个类内部方法自调用。
这七步走完,基本就能锁定问题。下面我把这七种原因逐一拆开讲,每一种都会说清楚“为什么失效”和“怎么改”。
2. 七种典型失效原因逐一拆解
2.1 原因一:类没有被 Spring 容器管理
这是最基础也最容易被忽略的一条。@Transactional是基于 Spring AOP 的代理机制实现的,如果一个类没有被 Spring 扫描到、没有注册成 Bean,那它就是一个普通对象,注解对它没有任何约束力。
常见的情况有几种:类上没有加@Service、@Component、@Repository这类注解;包路径不在启动类的扫描范围内;用了new关键字手动创建对象;或者类被定义在了一个不被扫描的模块里。
// 错误示范:手动 new 出来的对象,事务不生效 public class OrderService { @Transactional public void createOrder() { // 这里的数据库操作不会在事务中执行 } } // 调用方 OrderService service = new OrderService(); service.createOrder(); // 事务失效正确的做法是让 Spring 管理这个 Bean,通过依赖注入获取代理对象:
@Service public class OrderService { @Transactional public void createOrder() { // 事务正常生效 } }注意:如果你在某个工具类里通过
new创建了 Service 对象,即使这个 Service 类上写了@Transactional,事务也不会生效。这是新手最容易踩的坑之一。
2.2 原因二:方法不是 public 修饰
Spring 的事务代理默认只对public方法生效。如果你把@Transactional加在了private、protected或者包级私有的方法上,代理逻辑不会拦截这个调用,事务自然就不会启动。
这个限制来自 Spring AOP 的设计:JDK 动态代理只能代理接口的 public 方法,CGLIB 虽然能代理更多方法,但 Spring 的事务拦截器默认也只处理 public 方法。
@Service public class UserService { // 错误:private 方法上的事务注解无效 @Transactional private void updateUser() { // 事务不生效 } // 正确:public 方法 @Transactional public void updateUserPublic() { // 事务生效 } }如果你确实需要在一个非 public 方法上控制事务,可以考虑把这部分逻辑抽到另一个 Bean 里,通过 public 方法暴露出去。或者使用编程式事务,手动调用TransactionTemplate。
实操心得:有些团队会在代码规范里强制要求
@Transactional只能加在 public 方法上,并且在 CI 阶段用静态检查工具扫描,避免这类低级错误流入生产环境。
2.3 原因三:同类内部方法自调用
这是最经典、也是面试里被问得最多的一种失效场景。当一个类的方法 A 调用了同一个类里的方法 B,而方法 B 上标注了@Transactional,这时候方法 B 的事务是不会生效的。
原因在于:Spring 的事务是通过代理对象实现的。外部调用先经过代理,代理在方法执行前后开启和提交事务。但如果是类内部的方法 A 直接调用方法 B,这个调用走的是this引用,也就是原始对象,而不是代理对象,所以代理逻辑根本没有机会介入。
@Service public class PaymentService { public void pay() { // 自调用,this.createRecord() 不走代理 this.createRecord(); } @Transactional public void createRecord() { // 事务失效 } }解决办法有几种。第一种是把方法 B 抽到另一个 Bean 里,通过注入调用。第二种是通过AopContext.currentProxy()获取当前代理对象再调用,但需要开启exposeProxy配置。第三种是直接注入自己。
@Service public class PaymentService { @Autowired private PaymentService self; public void pay() { // 通过代理对象调用,事务生效 self.createRecord(); } @Transactional public void createRecord() { // 事务正常 } }注意:自己注入自己这种方式在 Spring Boot 2.6 以后需要注意循环依赖的问题,建议优先考虑拆分 Bean 的方案。
2.4 原因四:异常被内部捕获没有抛出
事务回滚的触发条件是方法抛出异常,并且这个异常符合回滚规则。如果你在方法内部把异常catch了,没有继续往外抛,Spring 的事务拦截器就感知不到异常,它会认为方法正常执行完毕,于是提交事务。
@Transactional public void transfer() { try { // 数据库操作 int result = accountDao.deduct(); if (result == 0) { throw new RuntimeException("扣款失败"); } } catch (Exception e) { // 异常被吞掉,事务不会回滚 log.error("转账失败", e); } }正确的做法是要么不捕获,让异常自然抛出;要么捕获后重新抛出一个运行时异常:
@Transactional public void transfer() { try { int result = accountDao.deduct(); if (result == 0) { throw new RuntimeException("扣款失败"); } } catch (Exception e) { log.error("转账失败", e); // 重新抛出,触发回滚 throw new RuntimeException("转账异常", e); } }实操心得:我见过不少项目在全局异常处理器里统一捕获异常返回给前端,但那个处理器是在 Controller 层,Service 层的异常如果被提前 catch 了,事务就已经提交了。所以 Service 层要么不 catch,要么 catch 后必须重新抛出。
2.5 原因五:异常类型不在回滚规则内
Spring 默认只对RuntimeException和Error进行回滚,对受检异常(Exception及其非运行时子类)默认不回滚。如果你抛的是一个IOException或者自定义的受检异常,事务不会回滚。
@Transactional public void readFile() throws IOException { // 数据库操作 throw new IOException("文件读取失败"); // 默认不回滚 }解决办法是在注解上显式指定回滚的异常类型:
@Transactional(rollbackFor = Exception.class) public void readFile() throws IOException { // 现在 IOException 也会触发回滚 }注意:
rollbackFor = Exception.class是很多团队的标配写法,因为它能覆盖受检异常。但也要注意,如果异常被 catch 了,这个配置也没用,两者需要配合使用。
2.6 原因六:传播行为设置不当
事务的传播行为决定了方法在已有事务上下文中如何执行。如果设置不当,可能导致事务没有按预期启动或者加入了不该加入的事务。
常见的传播行为有:
| 传播行为 | 含义 | 适用场景 |
|---|---|---|
| REQUIRED | 有事务就加入,没有就新建 | 默认值,大多数场景 |
| REQUIRES_NEW | 总是新建事务,挂起当前事务 | 独立提交的场景 |
| NESTED | 嵌套事务,可部分回滚 | 复杂业务分支 |
| SUPPORTS | 有事务就加入,没有就以非事务执行 | 查询方法 |
| NOT_SUPPORTED | 以非事务方式执行,挂起当前事务 | 不需要事务的操作 |
| MANDATORY | 必须在事务中执行,否则抛异常 | 强制要求事务 |
| NEVER | 必须在非事务中执行,否则抛异常 | 强制禁止事务 |
如果你把传播行为设成了NOT_SUPPORTED或者NEVER,事务自然不会生效。另外,REQUIRES_NEW如果使用不当,可能导致外层事务和内层事务的数据不一致。
@Transactional(propagation = Propagation.NOT_SUPPORTED) public void updateData() { // 以非事务方式执行,注解形同虚设 }实操心得:
REQUIRES_NEW在日志记录、审计等场景很有用,因为它可以独立于主业务事务提交。但要注意它会挂起当前事务,如果主事务后面回滚了,内层已经提交的数据不会回滚,需要业务上能接受这种不一致。
2.7 原因七:数据库引擎不支持事务
这一条经常被忽略,因为它不在 Java 代码层面,而在数据库层面。MySQL 的 MyISAM 存储引擎不支持事务,只有 InnoDB 支持。如果你建表时没有指定引擎,或者从旧系统迁移过来的表用的是 MyISAM,那无论你怎么配置,事务都不会生效。
-- 查看表的存储引擎 SHOW TABLE STATUS LIKE 'order_table'; -- 修改存储引擎 ALTER TABLE order_table ENGINE = InnoDB;注意:修改存储引擎是一个需要谨慎操作的动作,尤其是大表,可能会锁表。建议在业务低峰期执行,并提前做好备份。
3. 容易被忽视的进阶失效场景
3.1 多数据源下的事务管理器不匹配
在使用了多数据源的系统里,每个数据源通常对应一个事务管理器。如果你在操作数据源 A 的方法上标注了@Transactional,但 Spring 默认注入的是数据源 B 的事务管理器,那事务就会作用在错误的数据源上,表现为“注解加了但没效果”。
解决办法是在注解上指定事务管理器:
@Transactional(transactionManager = "orderTransactionManager") public void createOrder() { // 使用指定的事务管理器 }或者在配置类里通过@Primary指定默认的事务管理器,避免每次都要手动指定。
实操心得:多数据源场景下,我建议在项目初期就统一规范:每个数据源的事务管理器命名清晰,并且在代码审查时重点检查
@Transactional有没有指定正确的事务管理器。这个坑一旦踩了,排查起来非常费时间,因为代码看起来完全正确。
3.2 事务超时设置不合理导致提前回滚
@Transactional有一个timeout属性,单位是秒。如果方法执行时间超过了这个值,事务会被强制回滚。有些开发者为了“保险”设了一个很小的值,结果正常业务也被回滚了。
@Transactional(timeout = 1) // 1秒超时,稍微复杂的业务就会失败 public void batchProcess() { // 批量处理,可能执行好几秒 }注意:
timeout的默认值是 -1,表示不限制。如果你的业务确实需要设置超时,建议根据实际压测结果来定,不要拍脑袋写一个数字。
3.3 只读事务里执行了写操作
@Transactional(readOnly = true)是一个优化提示,它告诉数据库这个事务只读。但如果你在只读事务里执行了写操作,不同的数据库驱动表现不一样:有的会直接抛异常,有的会静默忽略,有的会正常执行但不提交。这种不确定性会导致事务行为不符合预期。
@Transactional(readOnly = true) public void queryAndUpdate() { // 查询操作正常 // 写操作可能被忽略或抛异常 userDao.update(user); }实操心得:
readOnly = true应该只用在纯查询方法上。如果一个方法既有查询又有写入,就不要加这个属性。我见过有人为了“优化性能”给所有方法都加了readOnly = true,结果写操作全部失效,排查了半天才发现是这个属性搞的鬼。
4. 常见问题速查表与排查技巧
4.1 事务失效问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 注解加了但无事务日志 | 类未被 Spring 管理 | 检查类注解和包扫描路径 | 加上 @Service 等注解 |
| 方法调用后数据未回滚 | 异常被 catch | 检查 try-catch 块 | 重新抛出异常 |
| 自调用方法事务失效 | 同类内部调用 | 检查调用链路 | 拆分 Bean 或注入自身 |
| 受检异常不回滚 | 默认回滚规则限制 | 查看异常类型 | 设置 rollbackFor |
| 非 public 方法无效 | 代理机制限制 | 检查方法修饰符 | 改为 public |
| 多数据源下失效 | 事务管理器不匹配 | 检查事务管理器配置 | 指定 transactionManager |
| 数据库操作无事务 | 存储引擎不支持 | 查看表引擎 | 改为 InnoDB |
4.2 排查事务问题的三个实用技巧
第一个技巧是打开 Spring 的事务调试日志。在配置文件里把org.springframework.transaction的日志级别设为DEBUG,这样每次事务的开启、提交、回滚都会有详细输出。如果日志里没有事务相关的记录,说明事务根本没启动,问题在代理或配置层面;如果有记录但行为不对,问题在传播行为或异常处理层面。
logging: level: org.springframework.transaction: DEBUG第二个技巧是在事务方法里打印当前事务的状态:
@Transactional public void doSomething() { boolean actualTransactionActive = TransactionSynchronizationManager.isActualTransactionActive(); log.info("当前是否有活动事务: {}", actualTransactionActive); // 业务逻辑 }第三个技巧是使用单元测试验证事务行为。写一个测试方法,在事务方法里故意抛异常,然后断言数据库里的数据没有变化。这种方式比在线上环境排查要安全得多。
@Test @Transactional @Rollback public void testTransactionRollback() { try { service.methodThatShouldRollback(); } catch (Exception e) { // 预期异常 } // 断言数据未变更 assertNull(dao.findById(1L)); }注意:测试类上的
@Transactional和@Rollback会让测试方法本身在一个回滚的事务里执行,这可能会影响你对业务事务行为的判断。建议在测试事务失效场景时,去掉测试类上的@Transactional,用编程式的方式清理数据。
4.3 一个真实项目的排查记录
之前在一个订单系统里遇到过一个问题:用户下单后扣减库存成功,但订单状态没有更新,而且库存也没有回滚。代码看起来没问题,@Transactional加在了createOrder方法上,异常也抛出了。
排查过程是这样的:先看日志,发现事务确实开启了,但提交了而不是回滚。然后检查异常类型,发现抛的是一个自定义的受检异常BusinessException extends Exception。因为默认只回滚运行时异常,所以这个受检异常没有触发回滚。
解决方案是在注解上加上rollbackFor = Exception.class。改完之后问题解决。这个案例告诉我们,自定义异常如果继承的是Exception而不是RuntimeException,一定要显式配置回滚规则。
5. 如何从设计层面规避事务失效
5.1 统一事务注解的使用规范
与其每次出问题再排查,不如在团队里定一套规范。我总结了几条在实际项目中比较有效的规则:
- 所有
@Transactional注解必须显式指定rollbackFor = Exception.class,不依赖默认值。 - 事务方法必须是
public,且不能被final或static修饰。 - 禁止同类内部自调用事务方法,需要复用的逻辑抽到独立的 Bean。
- 多数据源场景必须指定
transactionManager。 - 事务方法内不允许吞异常,catch 后必须重新抛出。
- 只读方法统一加
readOnly = true,但确保方法内没有写操作。
这些规则可以写进代码规范文档,也可以用静态检查工具来自动化校验。比如用 ArchUnit 或者自定义的 Checkstyle 规则来扫描。
5.2 事务边界的合理划分
事务不是越大越好。一个大事务会持有数据库连接很长时间,增加锁竞争,降低系统吞吐量。合理的做法是把事务边界控制在必要的范围内,把不需要事务的操作(比如远程调用、文件读写、消息发送)放到事务外面。
public void processOrder() { // 事务外:远程调用 String result = remoteService.check(); // 事务内:数据库操作 orderService.createOrder(result); // 事务外:发送消息 messageService.send(result); }实操心得:远程调用放在事务里是一个常见的反模式。如果远程服务响应慢,数据库连接会被长时间占用,高并发下容易导致连接池耗尽。我一般建议把远程调用前置或后置,确保事务内只包含数据库操作。
5.3 编程式事务作为补充手段
注解式事务虽然方便,但在某些场景下不够灵活。比如你只想让方法中的某几行代码在事务里执行,或者需要动态决定是否开启事务,这时候可以用TransactionTemplate。
@Autowired private TransactionTemplate transactionTemplate; public void complexBusiness() { // 非事务操作 prepareData(); // 编程式事务 transactionTemplate.execute(status -> { try { orderDao.insert(order); stockDao.deduct(order.getProductId()); return true; } catch (Exception e) { status.setRollbackOnly(); throw e; } }); // 非事务操作 sendNotification(); }编程式事务的好处是边界清晰、控制灵活,缺点是需要手动管理回滚状态。我一般建议在核心业务链路用注解式事务,在复杂的补偿逻辑或批处理场景用编程式事务。
6. 几个容易混淆的概念澄清
6.1 @Transactional 和 TransactionTemplate 的区别
@Transactional是声明式事务,基于 AOP 代理,代码侵入性小,但控制粒度粗,受代理机制限制。TransactionTemplate是编程式事务,控制粒度细,不受代理限制,但代码侵入性大,需要手动处理回滚。
两者不是替代关系,而是互补关系。大部分业务方法用@Transactional就够了,只有在需要精细控制事务边界或者绕过代理限制时才用TransactionTemplate。
6.2 事务回滚和异常传播的关系
事务回滚的前提是异常能传播到事务拦截器。如果异常在到达拦截器之前被处理掉了,回滚就不会发生。这包括两种情况:一是被catch了没有重新抛出,二是被全局异常处理器提前拦截了。
全局异常处理器(@ControllerAdvice)是在 Controller 层生效的,Service 层的异常如果抛到了 Controller,事务拦截器已经在 Service 方法返回时处理过了。所以全局异常处理器不会影响 Service 层的事务回滚,除非你在 Service 层就把异常 catch 了。
6.3 事务提交和数据库写入的关系
事务提交后,数据不一定立即持久化到磁盘。数据库有 redo log 和 binlog 的写入策略,innodb_flush_log_at_trx_commit参数控制着提交时日志的刷盘行为。如果这个参数设为 0 或 2,在数据库崩溃时可能会丢失最近的事务。
这不是 Java 层面的事务失效,而是数据库层面的持久性问题。但在对数据一致性要求极高的场景下,需要关注这个参数的配置。
注意:
innodb_flush_log_at_trx_commit = 1是最安全的配置,每次事务提交都刷盘,但性能最低。如果业务能容忍极端情况下丢失少量数据,可以设为 2 来提升性能。这个选择需要根据业务场景来权衡。
7. 写在最后的一些个人体会
事务这个问题,说复杂也复杂,说简单也简单。复杂在于它的失效场景多,涉及 Java 代理、Spring 容器、数据库引擎等多个层面;简单在于只要你理解了“代理 + 异常 + 数据库”这条核心链路,大部分问题都能定位。
我自己踩过最深的坑是同类自调用和异常被吞这两个。前者是因为当时对 AOP 代理机制理解不够,后者是因为代码里有一个全局的 try-catch 把异常吃掉了。这两个问题都不是靠看文档能解决的,必须实际踩一次才能记住。
现在我在做代码审查时,看到@Transactional就会条件反射地检查几件事:方法是不是 public、有没有自调用、异常有没有被吞、rollbackFor 有没有配。这几项检查花不了几秒钟,但能避免很多线上问题。
另外,事务日志是个好东西。很多人排查事务问题时不看日志,直接翻代码,效率很低。打开 DEBUG 日志,事务有没有开启、有没有提交、有没有回滚,一目了然。这个习惯建议大家都养成。
最后说一个容易被忽略的点:事务方法里尽量不要做耗时操作。事务持有数据库连接,连接是稀缺资源。一个事务执行 10 秒,在高并发下就是灾难。把远程调用、文件操作、复杂计算挪到事务外面,事务里只留数据库操作,这是提升系统吞吐量最直接的手段之一。