@Transactional 下的事务管理以及该注解失效的常见原因
做后端开发的朋友对@Transactional应该都不陌生,可以说它是 Spring 生态里出场率最高的注解之一。但越是常见的东西,越容易让人掉以轻心。我见过不少项目里,开发同事信心满满地在方法上标了@Transactional,结果线上数据出现半截写入、状态不一致,查了半天最后发现注解压根就没生效,或者事务根本没按预期回滚。这篇文章把我这些年在这块踩过的坑、梳理过的原理和排查思路整理出来,分几个维度说清楚事务是怎么运作的,以及注解为什么“失效”。
内容适合正在用 Spring Boot 做后端开发、想系统搞懂事务机制的读者,不管你是刚接触声明式事务的初级开发,还是已经在项目里被事务问题折磨过几回的进阶选手,这篇文章都会给你一些能直接落地的参考。
1. 事务管理的核心机制:先搞清楚 @Transactional 到底做了什么
1.1 一个注解背后的 AOP 代理机制
很多人把@Transactional当成“魔法”:方法一标,出异常就自动回滚。但 Spring 从来没有用过魔法,它只是用了一个非常经典的设计——AOP(面向切面编程)。
Spring 容器在启动的时候,会扫描带有@Transactional注解的 bean,然后为这些 bean 生成代理对象。当外部调用这个 bean 的方法时,实际上调用的不是原始方法,而是代理对象里的增强方法。这个增强方法在目标方法执行前开启事务,在目标方法正常返回时提交事务,在目标方法抛出异常时回滚事务。整套逻辑封装在一个叫TransactionInterceptor的拦截器里。
所以在使用@Transactional时,有一个前提必须刻在脑子里:调用的入口必须经过 Spring 代理对象。如果绕过了代理,注解就不会生效。这个前提直接引出了后面要讲的一大类失效问题。
这里还要区分两个概念:@Transactional可以加在类上,也可以加在方法上。加在类上意味着这个类的所有公共方法默认都启用事务,方法上的标注可以覆盖类级别的配置。但需要注意,覆盖只是针对事务属性,比如传播行为、隔离级别、超时时间,而不是把事务取消。
1.2 事务生命周期:开启、提交、回滚是三个独立环节
一次完整的事务生命周期可以拆成几个阶段:
- 获取连接:事务管理器从数据源中获取一个数据库连接。
- 关闭自动提交:将连接的
autoCommit设置为 false,这是开启数据库事务的关键步骤。 - 执行业务方法:所有 SQL 操作都在这个连接上执行,共享同一个事务上下文。
- 正常提交或异常回滚:方法正常返回则
commit(),方法抛出异常则rollback()。 - 清理资源:关闭连接、恢复
autoCommit状态(如果是连接池复用的话)。
这里面有一个经常被忽略的细节:Spring 的事务管理默认是线程绑定的,也就是说事务状态是存在ThreadLocal里的。换句话说,同一个事务里的所有数据库操作,必须发生在同一个线程中。明白了这一点,后面聊多线程场景下的失效问题时,你就很容易理解了。
1.3 事务管理器与数据源的绑定关系
Spring 通过PlatformTransactionManager接口来管理事务。在 Spring Boot 项目中,如果没有特意配置,会自动装配DataSourceTransactionManager,它内部的DataSource和我们配置连接池用的是同一个。这意味着事务管理只对当前数据源生效。
如果你的项目里配置了多个数据源,而事务管理器只绑定了其中一个,那么在另一个数据源上的操作就不受当前事务控制。这是一个非常隐蔽的问题,后面我会在常见问题里单独展开。
提示:Spring Boot 自动装配的事务管理器适用于单数据源场景。多数据源必须手动创建对应的
DataSourceTransactionManager,并且要清楚地知道每个事务注解绑定的是哪个管理器。
2. 注解失效的高频原因:七类场景逐一拆解
2.1 self-invocation:同类内部方法调用导致事务失效
这是我在项目里见到最多的一种失效场景,而且特别容易发生在代码重构之后。看一个典型的例子:
@Service public class OrderService { public void createOrder(OrderDTO orderDTO) { // 业务代码... this.updateStock(orderDTO.getProductId()); } @Transactional(rollbackFor = Exception.class) public void updateStock(Long productId) { // 扣减库存逻辑... } }调用方在外部注入OrderService并调用createOrder方法时,Spring 注入的是OrderService的代理对象。但createOrder方法体内通过this.updateStock(...)调用的却是this指向的原始对象的方法。这个原始对象没有被增强过,updateStock上的@Transactional自然就不生效。
为什么会这样?原因很简单——Spring 的 AOP 代理机制只能拦截“从外部进入代理对象”的调用。内部this调用走的是原生 Java 方法调用,跟代理完全无关。这种问题防不胜防,因为代码看起来逻辑完全正确,编译器不会报错,运行也不报异常,就是事务静悄悄没生效。
解决办法有几个:
- 方案一:把需要事务保证的方法拆到另一个 Service 类中,由当前 Service 注入它再调用。
- 方案二:在类中注入自身代理,用代理对象调用事务方法。Spring Boot 支持循环依赖(默认允许),可以这样写:
@Service public class OrderService { @Autowired private OrderService self; public void createOrder(OrderDTO orderDTO) { // 业务代码... self.updateStock(orderDTO.getProductId()); } }方案三:用
ApplicationContext.getBean(OrderService.class)手动获取代理对象,再进行调用。方案四:如果是同一个类内部必须调用,可以考虑把事务方法提取到独立类中,这是结构上最清晰的做法。
方案二目前是实践中性价比最高的,代码改动量小,语义清晰,但需要留意循环依赖问题在较新的 Spring 版本中是否被限制。
2.2 方法访问权限问题:private、protected 与 final 方法上的注解没有意义
@Transactional注解加在private方法上,看起来是“局部控制更精准”,但它对 Spring 来说等于白写。
Spring 的默认代理方式是 CGLIB,它通过生成目标类的子类来创建代理。CGLIB 能增强的是非私有、非 final 的公共方法。private方法不会被子类继承,protected 和包内可见的方法能否被增强取决于代理方式和调用点,但规范上 Spring 官方明确只有外部可见的公共方法适合加@Transactional。
至于final方法,CGLIB 子类无法重写它,所以注解同样失效。
还有一个很基础的常识要强调:如果类本身没有被 Spring 管理,也就是说类上没有@Service、@Component之类的注解,或者是在非 Spring 管理的类中手动new出来的对象,那方法上的@Transactional完全不会被解析。这种问题常出现在工具类、静态方法或非 Spring 环境下调用的业务类中。
注意:不要把
@Transactional加在接口方法上,然后在实现类中不加。Spring 是基于 bean 的实际类来处理注解的,接口注解在部分代理模式下能被识别,但容易出现不确定性,直接在实现类上标注是最稳妥的做法。
2.3 异常被吞掉:事务无法感知错误,自然谈不上回滚
事务回滚的触发条件是“目标方法抛出了符合回滚规则的异常”。但如果方法内自己 try-catch 把异常吞掉了,Spring 完全感知不到,就会正常提交事务。
看下面这个反面案例:
@Transactional(rollbackFor = Exception.class) public void transfer(TransferDTO transferDTO) { try { accountDao.decrease(transferDTO.getFromUserId(), transferDTO.getAmount()); accountDao.increase(transferDTO.getToUserId(), transferDTO.getAmount()); } catch (Exception e) { log.error("转账失败", e); // 注意:这里吞掉了异常,事务会正常提交 } }这里如果增加金额操作失败,异常虽然被记录了,但方法正常返回,事务提交,结果就是扣款成功、加款失败,账务不平。这个问题在真实项目中造成的事故非常多。
正确的做法有两种:
- 做法一:不在方法内捕获异常,直接向上抛出,让事务拦截器感知并进行回滚。
- 做法二:如果业务上必须捕获异常,捕获后手动触发回滚:
@Transactional(rollbackFor = Exception.class) public void transfer(TransferDTO transferDTO) { try { // 业务代码... } catch (Exception e) { TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); log.error("转账失败,事务已标记回滚", e); } }setRollbackOnly()会把当前事务标记为“只能回滚”,即使方法正常返回,事务也会在提交阶段被拦截并回滚。这个 API 在事务嵌套场景中也很常用,后面讲传播行为时会再提到。
2.4 rollbackFor 配置不准:默认情况下受检异常不会触发回滚
这一个知识点值得单独拿出来讲,因为理解它的人真的不多。
Spring 的@Transactional默认回滚规则是:只回滚RuntimeException和Error,不回滚受检异常(即Exception的非运行时子类)。也就是说,如果你的业务代码抛出的是IOException、SQLException这类受检异常,而且没有在rollbackFor里声明,事务会正常提交。
这其实是 Spring 的设计取舍——早期 EJB 时代默认回滚所有异常,导致许多正常返回的业务场景被误回滚。Spring 选择了更保守的策略,但这也导致了不少开发者的困惑。
规范做法是:在需要事务的方法上明确指定回滚异常类型。
@Transactional(rollbackFor = Exception.class) public void businessMethod() { // ... }如果业务中抛出了自定义异常,而你的自定义异常继承自Exception而不是RuntimeException,那一定要记得在上面配置里把自定义异常显式加进去:
@Transactional(rollbackFor = { BusinessException.class, Exception.class })经验之谈:既然Exception.class能覆盖所有异常,干脆在所有写事务注解的地方统一使用rollbackFor = Exception.class,避免团队成员认知差异带来的隐患。
2.5 数据库层面不支持事务:引擎不对一切白搭
有一次帮一个团队排查问题,数据库表数据在程序报错后依然被写入,代码里的@Transactional配置、异常抛出逻辑全部正常。最后查了一圈才发现,他们的 MySQL 表用的是 MyISAM 存储引擎,而 MyISAM 本身不支持事务。
这个问题在运维层面比较隐蔽,因为单条数据的增删改查完全正常,只有涉及多步操作时才会暴露“无法回滚”的问题。排查手段很简单,查询建表语句或引擎即可:
SHOW CREATE TABLE your_table_name;如果是 MyISAM,需要改造成 InnoDB 才能获得事务支持。
提示:新写的业务表建议直接使用 InnoDB。老系统迁移时,记得把回滚依赖的关键表也一并检查一遍。
2.6 多线程与事务传播:子线程中的操作不在同一个事务里
前面提到,Spring 事务状态绑定在线程上。一旦你在事务方法里开启了异步线程或线程池,子线程里的数据库操作就不在父线程的事务范围内。即使子线程方法上也标注了@Transactional,那也是新开了一个独立事务(取决于传播行为),和父事务没有关系。
看一下面上常见的错误写法:
@Transactional(rollbackFor = Exception.class) public void processBatch(List<Item> items) { for (Item item : items) { asyncExecutor.execute(() -> { processItem(item); // 子线程中操作数据库 }); } }这段代码看起来“有事务保护”,实际上父线程只负责循环提交任务,子线程里如果有一条数据失败,整个批次不会回滚,已成功写入的数据依然保留。这不是@Transactional失效,而是设计上就违反了单线程事务的约束。
对这种情况,有几个方向可选:
- 方案一:把批量任务拆小,每个任务独立事务,失败只回滚单个任务,整体做失败记录和补偿。
- 方案二:使用编程式事务,在子线程中通过
TransactionTemplate手动控制子任务的提交和回滚。 - 方案三:不做异步化,用同步循环统一在一个大事务中执行。但如果数据量大,大事务会有锁持有时间过长的问题,要谨慎评估。
2.7 事务传播行为设置不当:NESTED 与 REQUIRES_NEW 的陷阱
Spring 定义了七种事务传播行为,最常用的是REQUIRED,也就是默认值。方法A没有事务时开启新事务,有事务时加入当前事务。大多数业务场景用这个就够了。
容易踩坑的是REQUIRES_NEW和NESTED之间的混淆。
先看REQUIRES_NEW:无论当前是否存在事务,都会挂起当前事务,新开一个独立事务。两个事务互不影响,内层提交成功不依赖外层,外层回滚不会带动内层回滚;反之,内层回滚也不会让外层回滚。比如日志记录、审计表这类“就算主流程失败也要写成功”的场景,适合用REQUIRES_NEW。
再看NESTED:它使用数据库的 Savepoint(保存点)机制,内层事务回滚时只回滚到保存点,外层仍可继续;但如果外层最终回滚,内层也会跟着回滚。它和REQUIRED的差异在于内层有独立的回滚边界,和REQUIRES_NEW的差异在于外层能控制它。
很多人在实现“错一个就回滚当前条,但不影响整体”的重试逻辑时,误用了REQUIRES_NEW,结果导致某条失败后,后续操作无法感知、整体回滚失效。这种问题一般很难靠代码 review 发现,必须通过实际故障或压测才能暴露。
3. 排查事务问题的实操流程与关键技巧
3.1 第一步:确认代理是否生效
拿到一个“事务失效”的问题,第一件事不是去查 SQL 或数据库,而是确认目标类是否真的被 Spring 代理了。
一个很有用的排查技巧:在调用方代码里临时打印一下注入对象的 class,看看是不是 CGLIB 代理类。
System.out.println(orderService.getClass().getName()); // 输出类似:com.example.service.OrderService$$EnhancerBySpringCGLIB$$abc123如果输出的是com.example.service.OrderService而不是含$$EnhancerBySpringCGLIB的类名,说明当前注入的 bean 没有被代理。常见原因包括:类未被 Spring 管理、被final修饰、或手动 new 出来的实例。
3.2 第二步:核对异常类型与事务拦截器配置
确认代理生效后,下一步是看异常是否触发了回滚。
这里推荐开启 Spring 的日志级别,看事务拦截器的完整日志:
logging: level: org.springframework.transaction.interceptor: DEBUG org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG开启后,事务提交或回滚时会出现关键日志。观察是否有“Initiating transaction rollback”或“Committing JDBC transaction”的语句,能快速判断事务是提交了还是回滚了。
3.3 第三步:检查事务管理器与数据源是否匹配
在单数据源场景下,Spring Boot 自动装配的事务管理器一般能正常工作。但如果你手动配置了多个数据源或使用了动态数据源,就要重点检查:
- 每个数据源是否都配置了对应的
PlatformTransactionManager。 @Transactional是否有指定使用哪个事务管理器。
@Transactional(transactionManager = "secondaryTransactionManager", rollbackFor = Exception.class) public void writeToSecondaryDb() { // 操作第二个数据源 }一个常见的坑:事务管理器 A 绑定的是数据源 1,但代码中事务方法同时操作数据源 1 和数据源 2,此时数据源 2 的操作并不受事务控制。像这种跨数据源的强一致性场景,靠@Transactional是解决不了问题的,需要考虑分布式事务方案,比如本地消息表、可靠消息最终一致性等模式。
3.4 快速验证代码的调试方法
如果条件允许,直接在本地写一个最小可复现的 Demo 项目来验证事务行为,是最节省时间的做法。
我常用的调试模板大概是这样的:
@SpringBootTest public class TransactionDebugTest { @Autowired private ApplicationContext context; @Test void debugTransactionProxy() { Object bean = context.getBean("orderService"); // 检查是否为代理对象 System.out.println(bean.getClass()); // 直接调用测试 orderService.createOrder(...); } }在测试类里可以通过Proxy.isProxyClass(bean.getClass())或类名检查来判断是否被代理。这一步能过滤掉很大一部分“注解失效”其实是“代理没生成”的问题。
3.5 排查路径速查表
| 症状 | 优先怀疑对象 | 快速确认方法 |
|---|---|---|
| 方法标了注解但未回滚 | self-invocation | 检查是否有this调用事务方法 |
| 异常被捕获后事务仍提交 | 方法内 try-catch | 查看日志是否有异常打印但方法正常返回 |
| 抛了自定义异常仍提交 | rollbackFor 配置 | 确认异常类型和 rollbackFor 的匹配关系 |
| 代理对象正常但仍不回滚 | 数据库引擎 | 查表引擎是否为 InnoDB |
| 异步线程操作失败未回滚 | 线程与事务绑定 | 确认是否跨线程调用 |
| 跨数据源部分成功 | 多数据源事务管理 | 核对事务管理器绑定的数据源 |
4. 事务传播行为的实际应用:到底怎么选才不出错
4.1 REQUIRED(默认)在什么场景最安全
绝大多数业务都应该使用默认的REQUIRED。它的语义简单直接:有事务就加入,没有就新建。不管是新增、修改、删除的多表操作,还是领域服务间的互相调用,在一个调用链中所有数据库操作都汇聚到同一个事务中,整体成功或整体失败。
举一个实际场景:下单操作涉及订单表、订单明细表、库存表、账户流水表。整条链路用REQUIRED,任何一个环节抛异常,所有表的数据写操作全部回滚,不会出现“订单建了但流水没记”的情况。
4.2 REQUIRES_NEW 用于日志与审计场景
日志记录是一个经典的反例:如果日志写入和业务操作在同一个事务里,业务失败回滚时日志也没有了,那还排查什么问题?
这种场景更适合REQUIRES_NEW。即使业务事务回滚,日志记录事务也已经独立提交,保留下了当时的错误现场。
但要注意REQUIRES_NEW的代价:它需要挂起当前事务,意味着当前事务持有的数据库连接要暂时释放回连接池,再由新事务获取新的连接。在高并发场景下,频繁挂起和恢复事务会造成连接池压力,也可能导致死锁风险增加,所以不要滥用。
4.3 NESTED 用于批量处理中的单条隔离
NESTED的常见使用场景是批量导入:有一批数据要插入,某一条格式错误,希望只跳过这一条,不影响其他数据。
如果每条数据处理逻辑用REQUIRED,那么错误会直接让整个大事务回滚;如果用REQUIRES_NEW,虽然单条错误不影响整体,但“整体最终回滚时所有数据都得滚”这个约束也没了。
NESTED的保存点机制刚好能拆成“每一小步都可以局部回滚,但整体仍然由外层控制”。实际项目中这种需求不在少数,只是很多人因为不熟悉NESTED,硬是用REQUIRES_NEW去凑,结果把事务边界完全打乱了。
4.4 传播行为对比总结
| 传播行为 | 当前有事务时的行为 | 无事务时的行为 | 典型场景 |
|---|---|---|---|
| REQUIRED | 加入当前事务 | 开启新事务 | 一般业务操作 |
| REQUIRES_NEW | 挂起当前事务,开启新事务 | 开启新事务 | 日志记录、发送通知 |
| NESTED | 基于保存点嵌套事务 | 开启新事务 | 批量处理单条隔离 |
| MANDATORY | 加入当前事务 | 抛异常 | 不允许独立运行的操作 |
| NEVER | 抛异常 | 无事务执行 | 强制无事务上下文 |
| SUPPORTS | 加入当前事务 | 无事务执行 | 只读查询辅助方法 |
| NOT_SUPPORTED | 挂起当前事务,无事务执行 | 无事务执行 | 事务外执行耗时操作 |
注意:
NESTED依赖于数据库的 Savepoint 支持,MySQL 的 InnoDB 支持,部分数据库可能不提供该能力,使用前需要确认。
5. 一个完整案例:从表象到根因的排查过程
最后用一个过去遇到过的真实问题作为整体收尾,这个案例综合了上面提到的多个知识点。
模拟项目 X 是一个订单服务,线上突然出现了一个现象:用户发起退款后,订单状态更新成了“已退款”,但是库存回补没有执行,而且没有任何报错日志。
初看代码,退款方法长这样:
@Transactional(rollbackFor = Exception.class) public void refund(Long orderId) { orderDao.updateStatus(orderId, OrderStatus.REFUNDED); stockService.restoreStock(orderId); // 调用另一个服务回补库存 }stockService.restoreStock内部做了数据库操作,并且标注了@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)。
表面上看配置很“安全”:退款失败了就整体回滚,而库存回补独立提交。问题出在哪?
现场排查后,发现问题出现在stockService.restoreStock抛出了一个受检异常InsufficientStockException,而且它被上层调用方 catch 住了,只在日志里记录了一行error。注意,restoreStock上确实标注了rollbackFor = Exception.class,但由于它是REQUIRES_NEW——它确实回滚了自己的子事务——但库存回补这个子事务是个独立的数据库事务,它回滚以后,它自己在数据库里执行的 SQL 也被撤销了。唯一的问题是,网络调用已经执行了,库存数据库操作发生在事务中,回滚之后库存数据没变,但日志只记了异常,没人知道它回滚了。
后来,进一步定位发现实际根因更复杂:在refund方法的orderDao.updateStatus执行之后,stockService.restoreStock内部又调用了一个远程接口同步库存到仓储系统,远程接口超时导致InsufficientStockException被抛出。因为外层refund方法自身没有捕获异常,异常继续向上抛出,外层事务应该回滚——但由于外层事务和库存回补是REQUIRES_NEW两个独立事务,外层回滚了订单状态更新,而内层新事务因受检异常也回滚了库存,表面上是“订单未退款,库存未回补”,这看起来是回滚正常了,但实际上系统状态是“两边都没改”,符合预期。
真正的问题出现在一层更高的调用方:在 controller 层,有一个全局异常处理把InsufficientStockException吃掉了,并且向用户返回了“退款成功”。也就是说,异常链在到达事务拦截器之前已经被截断处理?不对,这里有个关键细节:事务拦截器是在方法调用栈内部的,全局异常处理器在外部接收异常时,异常已经穿过事务方法了,所以事务肯定触发了回滚。但是因为REQUIRES_NEW的库存子事务回滚了,订单状态回滚了,用户层面却收到了“退款成功”的提示,等于系统提示和数据库状态严重不一致。
这次事故暴露了两个层面的问题:
- 事务隔离设计不当:库存回补用了
REQUIRES_NEW,导致它与订单状态更新不在同一个一致性边界内。正确的做法应当让库存回补和订单状态更新加入同一个事务(默认REQUIRED),这样两者要么都成功要么都失败。 - 接口异常提示与数据状态割裂:全局异常处理将底层异常吞掉并返回“成功”,即使事务正确回滚,用户感知也是错误的。
这个案例的教训是:事务方案选型时,不能只看注解配置,更要想清楚事务边界和一致性边界是否匹配。如果需要更新的多个业务状态必须保持一致,那么它们必须落在同一个事务边界内;如果需要各自独立,那就要明确接受部分成功部分失败的最终一致性,而不是靠日志和人工去对账。
结尾:关于 @Transactional 的几点个人体会
踩过这么多坑之后,我现在写项目里的事务代码,基本会默认执行这么几条规则:
第一,所有需要事务保护的方法,统一用rollbackFor = Exception.class,减少团队成员对受检异常和运行时异常差异的理解门槛。
第二,凡是事务方法内部有同类调用,都先停下来想一想这个this调用会不会绕过代理。这个问题出现频率远比你想象的高。
第三,捕获异常的 try-catch 只放在不需要事务保护的外层切面中,比如 controller 层的统一异常处理,事务方法内部尽量保持异常向上抛出。
第四,多数据源或者跨系统业务,不要试图用一个大事务包住所有操作,从一开始就按最终一致性的思路来设计,引入本地消息表或者重试补偿机制。
最后再分享一个小技巧:写事务相关代码前,先问自己一个问题——“如果这里数据库写入失败,业务上应该呈现什么状态?”答案如果是“整体都不要变”,那就用默认的事务;如果答案是“某些操作必须保留”,那你需要的可能是REQUIRES_NEW或者补偿机制。把这个问题想清楚,绝大多数的@Transactional误用都可以提前避免。