声明式事务是日常开发里最容易被“想当然”对待的一块内容。很多人写完@Transactional就默认它一定生效,结果上线后遇到数据不一致,回头排查半天才发现事务压根没起作用。我这些年接手过的项目里,因为事务不生效导致的对账问题、脏数据问题,少说也见过十几次。这篇就围绕“声明式事务不生效”这个主题,把最常见的三类坑掰开揉碎讲清楚——每一类都从现象入手,讲清楚底层为什么,再给出可复现的排查路径和修复方案。不管你是刚接触事务注解的新手,还是已经踩过几次坑的老手,都能从里面找到可以直接抄作业的排查思路。
1. 先搞清楚事务代理到底在什么时候生效
在讲具体的坑之前,得先把一个前提说透:声明式事务本质上是基于代理实现的。你写的@Transactional注解,最终是被一个代理对象拦截后,由事务拦截器去开启、提交或回滚事务的。理解这一点,后面三类坑的根因就都能串起来了。
1.1 代理对象拦截的是“外部调用”
Spring 的事务代理有两种实现方式:JDK 动态代理和 CGLIB 代理。前者要求目标类实现接口,后者通过生成子类来增强。无论哪种方式,代理对象能拦截的前提是——调用必须经过代理对象。
这句话听起来像废话,但它是绝大多数事务失效问题的总根源。你可以把代理对象想象成一个门卫:外部的人要进大楼,必须经过门卫登记;但如果大楼内部的人自己走内部通道串门,门卫根本看不见。方法内部的自调用,走的就是“内部通道”。
@Service public class OrderService { public void createOrder(Order order) { // 这里直接调用本类方法,不经过代理 saveOrder(order); } @Transactional public void saveOrder(Order order) { // 事务注解在这里,但自调用时不会生效 orderMapper.insert(order); } }上面这段代码里,createOrder调用saveOrder时,this指向的是原始对象而不是代理对象,所以@Transactional完全被绕过。这是第一类坑的核心,后面会展开讲。
1.2 事务拦截器的工作链路
一次正常的事务方法调用,链路大致是这样的:调用方拿到代理对象 → 代理对象调用事务拦截器 → 拦截器根据注解配置决定是否开启事务 → 执行业务方法 → 根据执行结果提交或回滚。
这条链路上任何一环断了,事务都不会生效。比如代理对象没生成(类被 final 修饰、方法是 private)、拦截器没匹配上(注解写在了错误的位置)、事务管理器配置不对(多数据源场景下用错了管理器),都会导致链路中断。
提示:排查事务问题时,第一步永远是确认“方法调用是否真的经过了代理”。可以在方法里打印
this.getClass(),如果输出的是原始类名而不是带$Proxy或$$EnhancerBySpringCGLIB的代理类名,那基本就能定位方向了。
1.3 三类坑的划分逻辑
我把常见的失效场景归成三类,划分依据是“问题出在哪个环节”:
| 类别 | 问题环节 | 典型现象 |
|---|---|---|
| 第一类:调用链路问题 | 代理未被经过 | 自调用、内部方法调用失效 |
| 第二类:注解与配置问题 | 拦截器未匹配或配置错误 | 注解位置错误、传播行为配置不当 |
| 第三类:异常与回滚问题 | 回滚条件未满足 | 异常被吞、回滚规则不匹配 |
这个划分不是学术分类,而是按排查顺序来的——先确认调用链路,再确认注解配置,最后确认异常处理。实际排查时按这个顺序走,能少绕很多弯路。
2. 第一类坑:自调用把代理绕过去了
自调用导致事务失效,是出现频率最高、也最容易被忽视的一类问题。它的隐蔽性在于:代码看起来完全正常,注解也写了,编译不报错,运行时也不报错,就是事务不生效。
2.1 一个真实的对账问题复盘
早些年我参与过一个订单结算模块,里面有个方法负责“扣减库存 + 写流水”。当时为了代码复用,把扣库存的逻辑抽成了一个带@Transactional的独立方法,然后在主流程里直接调用。测试环境一切正常,因为测试时都是单条数据操作,即使事务没生效,单条 insert 本身也是原子的,看不出问题。
上线后遇到批量结算场景,主流程循环调用扣减方法,中间某一条因为库存不足抛了异常。按预期整个批次应该回滚,结果发现前面已经扣掉的库存没有恢复,流水却写进去了,直接导致库存和流水对不上。排查了大半天,最后定位到就是自调用——主流程调扣减方法时走的是this,事务注解形同虚设。
这个问题的教训是:单条操作测不出事务问题,必须用“多条操作 + 中途异常”的场景去验证。后来我把这条写进了团队的测试规范里。
2.2 为什么自调用一定失效
回到代理机制。Spring 在启动时会给带事务注解的 Bean 生成代理对象,容器里实际存放的是代理对象。但当你在类内部用this.saveOrder()调用时,this是原始对象,不是代理对象。原始对象上的方法调用不会触发任何拦截逻辑,事务拦截器根本没机会介入。
用生活化的类比:代理对象是公司的前台,外部访客(其他 Bean)找你要先经过前台登记;但你自己在工位上喊同事帮忙,不需要经过前台。事务拦截器就是那个前台,自调用相当于跳过了前台。
这里有个容易混淆的点:即使saveOrder是public方法,自调用依然失效。很多人以为只有private方法才会失效,其实public方法自调用同样不行,因为问题不在方法可见性,而在调用是否经过代理。
2.3 三种可落地的修复方案
针对自调用,我实践下来有三种修复方式,各有适用场景:
方案一:把方法拆到另一个 Bean 里。这是最干净的做法。把需要事务的方法抽到独立的 Service 中,通过依赖注入调用。跨 Bean 调用一定经过代理,事务自然生效。
@Service public class OrderService { @Autowired private StockService stockService; public void createOrder(Order order) { // 跨 Bean 调用,经过代理,事务生效 stockService.deductStock(order); } } @Service public class StockService { @Transactional public void deductStock(Order order) { // 事务逻辑 } }方案二:注入自身代理。如果不想拆类,可以注入自己的代理对象来调用。注意要用@Lazy避免循环依赖问题。
@Service public class OrderService { @Autowired @Lazy private OrderService self; public void createOrder(Order order) { self.saveOrder(order); // 通过代理调用 } @Transactional public void saveOrder(Order order) { // 事务逻辑 } }方案三:用 AopContext 获取当前代理。需要开启@EnableAspectJAutoProxy(exposeProxy = true),然后通过AopContext.currentProxy()拿到代理对象。这种方式侵入性较强,我一般只在无法拆类且不想注入自身时用。
三种方案怎么选?我的经验是:优先拆类,职责清晰、无额外配置;拆类成本高时用注入自身;前两种都不合适才考虑AopContext。拆类虽然要多写一个类,但长期维护成本最低,也最符合单一职责原则。
2.4 自调用的隐蔽变体
自调用不只有“直接调本类方法”这一种形态,还有几个变体容易被忽略:
- 通过
this.显式调用:和直接调用本质一样,都走原始对象。 - 在 lambda 或匿名内部类里调用本类方法:如果 lambda 捕获的是
this,同样绕过代理。 - 父类方法调用子类事务方法:如果父类方法里调用了子类的
@Transactional方法,且调用发生在父类实例上,也可能绕过代理。
排查这类问题时,我习惯在事务方法入口打一行日志,输出this.getClass().getName()。如果看到的是原始类名,就说明调用没经过代理,直接往自调用方向查。
3. 第二类坑:注解写对了地方,但拦截器没匹配上
第二类坑的特点是:调用链路没问题,确实经过了代理,但事务拦截器就是没生效。问题出在注解本身或配置上。
3.1 注解位置错误:写在接口上还是实现类上
@Transactional可以写在接口方法上,也可以写在实现类方法上,但两者的行为在不同代理方式下不一样。
用 JDK 动态代理时,注解写在接口上能生效;但用 CGLIB 代理时,注解写在接口上可能不生效,因为 CGLIB 是基于实现类生成子类的,它看的是实现类上的注解。Spring Boot 2.x 之后默认用 CGLIB 代理,所以注解写在接口上失效的情况变得很常见。
我的建议很明确:注解一律写在实现类的方法上。这样无论代理方式怎么变都不会出问题。接口上写注解虽然在某些配置下能用,但属于给自己埋雷。
3.2 方法可见性:private 和 final 方法注定失效
事务拦截器要能拦截方法,方法必须是可以被代理覆盖的。private方法无法被代理覆盖,final方法也不行,static方法同样不行。这三种修饰符下的@Transactional都是无效的。
@Service public class PaymentService { @Transactional private void doPay() { // private 方法,事务不生效 } @Transactional public final void doRefund() { // final 方法,事务不生效 } }这类问题编译期不报错,运行期也不报错,只能靠代码审查或排查时发现。我一般会在 Code Review 时专门扫一遍@Transactional修饰的方法,确认都是public且非final。
3.3 传播行为配置不当引发的“假失效”
有时候事务其实生效了,但因为传播行为配置不对,表现得像没生效。最典型的是Propagation.NOT_SUPPORTED和Propagation.NEVER。
NOT_SUPPORTED表示以非事务方式执行,如果当前存在事务就挂起。如果你在一个事务方法里调用了标注NOT_SUPPORTED的方法,被调用的方法确实不在事务里,看起来就像“事务没生效”。
还有一种情况是REQUIRES_NEW用错了地方。REQUIRES_NEW会开启一个全新的事务,如果外层事务回滚,内层REQUIRES_NEW的事务不会跟着回滚。有人误以为内层也会回滚,结果发现数据没清理干净,误判成事务失效。
| 传播行为 | 行为 | 容易误判的场景 |
|---|---|---|
| REQUIRED | 有事务则加入,无则新建 | 默认值,一般不会误判 |
| REQUIRES_NEW | 总是新建独立事务 | 外层回滚时内层不回滚 |
| NOT_SUPPORTED | 挂起当前事务,非事务执行 | 看起来像事务没生效 |
| NEVER | 非事务执行,有事务则抛异常 | 抛异常被误认为配置错误 |
排查传播行为问题时,我会在方法里打印TransactionSynchronizationManager.isActualTransactionActive(),直接确认当前是否真的处于事务中,比猜配置靠谱得多。
3.4 多数据源下用错事务管理器
多数据源场景是事务失效的重灾区。每个数据源需要独立的事务管理器,如果@Transactional没有指定transactionManager,默认用的是主事务管理器。操作从库时如果没指定对应的事务管理器,事务管的是主库连接,从库操作根本不在事务里。
@Service public class ReportService { // 操作从库,但没指定事务管理器,事务管的是主库 @Transactional public void saveReport(Report report) { reportMapper.insert(report); // 走从库,事务不生效 } // 正确写法:指定从库的事务管理器 @Transactional(transactionManager = "secondaryTransactionManager") public void saveReportCorrect(Report report) { reportMapper.insert(report); } }这类问题的排查方法是:确认数据源配置、确认事务管理器 Bean 名称、确认注解里指定的名称和 Bean 名称一致。三个都对上,事务才会管到正确的连接。
4. 第三类坑:异常被吞了,回滚条件没满足
第三类坑最隐蔽,因为事务其实开启了,代理也经过了,注解也写对了,但最后数据还是没回滚。问题出在异常处理上。
4.1 默认只回滚 RuntimeException
Spring 声明式事务默认只在遇到RuntimeException和Error时回滚,遇到受检异常(Exception及其非运行时子类)不回滚。这是很多人踩过的坑:方法里抛了个IOException,事务正常提交了,数据留在了库里。
@Transactional public void importData(File file) throws IOException { dataMapper.insert(parse(file)); // 如果 parse 抛出 IOException,事务不会回滚 }修复方式有两种:一是显式指定rollbackFor,二是把受检异常包装成运行时异常抛出。我倾向于第一种,配置清晰:
@Transactional(rollbackFor = Exception.class) public void importData(File file) throws IOException { // 现在任何异常都会触发回滚 }注意:
rollbackFor = Exception.class虽然省事,但会连一些本不该回滚的异常也回滚掉。更严谨的做法是按业务需要指定具体的异常类型,比如rollbackFor = BusinessException.class。
4.2 try-catch 把异常吞掉了
比受检异常更常见的是:方法里自己 try-catch 了异常,catch 块里没重新抛出,事务拦截器根本感知不到异常,自然不回滚。
@Transactional public void processOrder(Order order) { try { orderMapper.insert(order); inventoryService.deduct(order); } catch (Exception e) { log.error("处理订单失败", e); // 异常被吞,事务照常提交 } }这种写法在“记录日志后继续执行”的场景里很常见,但如果 catch 块里没有重新抛出异常,事务就不会回滚。修复方式是在 catch 块里重新抛出,或者手动调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()标记回滚。
@Transactional public void processOrder(Order order) { try { orderMapper.insert(order); inventoryService.deduct(order); } catch (Exception e) { log.error("处理订单失败", e); TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); } }手动标记回滚的方式适合“捕获异常后还要做补偿逻辑”的场景,但要注意:标记回滚后,方法正常返回,事务仍会回滚,补偿逻辑里的数据库操作也会一起回滚。如果补偿逻辑需要独立提交,得用REQUIRES_NEW单独开事务。
4.3 嵌套调用中的异常传播
嵌套事务场景下,异常传播也容易出问题。外层方法调用内层事务方法,内层抛异常被外层 catch 了,外层没重新抛出,内层事务的回滚标记可能丢失。
这里的关键是理解REQUIRED传播行为下内外层共用同一个事务。内层抛异常,事务被标记为 rollback-only;外层 catch 后如果继续执行并正常返回,提交时会抛UnexpectedRollbackException,因为事务已经被标记回滚了。
排查这类问题,我会在 catch 块里加日志打印TransactionSynchronizationManager.isCurrentTransactionReadOnly()和事务状态,确认回滚标记是否还在。
4.4 回滚规则与业务异常的匹配
还有一种情况是自定义了回滚规则,但规则没匹配上实际抛出的异常。比如配置了rollbackFor = OrderException.class,但实际抛出的是OrderException的子类,且子类没被规则覆盖(某些配置方式下会这样)。或者用了noRollbackFor排除了某个异常,结果那个异常恰好是需要回滚的。
我的经验是:回滚规则尽量简单明确,不要配太复杂的rollbackFor和noRollbackFor组合。业务异常统一继承一个基类,回滚规则指定这个基类,既清晰又不容易漏。
5. 一套可复用的排查流程
讲完三类坑,最后分享一套我常用的排查流程。这套流程按“从外到内”的顺序走,能覆盖绝大多数失效场景。
5.1 第一步:确认调用是否经过代理
在事务方法入口打印this.getClass().getName(),看是否是代理类。不是代理类,直接查自调用。这一步能解决第一类坑的绝大部分问题。
5.2 第二步:确认事务是否真的开启
打印TransactionSynchronizationManager.isActualTransactionActive(),返回false说明事务没开启。结合第一步的结果,判断是代理问题还是注解配置问题。
5.3 第三步:确认异常是否触发了回滚
在 catch 块或全局异常处理器里打印异常类型,对照rollbackFor配置,确认异常类型是否在回滚范围内。同时检查是否有 try-catch 吞异常的情况。
5.4 第四步:确认事务管理器是否匹配
多数据源场景下,打印当前事务管理器绑定的数据源,确认和实际操作的数据库一致。这一步能解决多数据源下的事务失效问题。
| 排查步骤 | 检查内容 | 对应坑类型 |
|---|---|---|
| 第一步 | 是否经过代理 | 第一类:自调用 |
| 第二步 | 事务是否开启 | 第二类:注解配置 |
| 第三步 | 异常是否触发回滚 | 第三类:异常处理 |
| 第四步 | 事务管理器是否匹配 | 第二类:多数据源 |
这套流程我用了很多次,基本能在十分钟内定位到问题方向。实际排查时不用死板按顺序走,根据现象先怀疑最可能的类型,能更快。
5.5 几个容易忽略的细节
最后补充几个排查时容易忽略的点:
- 测试方法本身没有事务:单元测试里如果测试方法没加
@Transactional,被测方法的事务行为可能和预期不同。测试类上加@Transactional会让测试方法整体在一个事务里,测完自动回滚,这时候被测方法的事务行为会被“合并”,测不出真实效果。 - 异步方法里的事务:
@Async方法运行在独立线程,事务上下文不会传递过去,方法里的事务注解需要独立生效,不能依赖调用方的事务。 - 事务超时配置:
@Transactional(timeout = 5)配置了超时,方法执行超过 5 秒会抛异常并回滚。如果业务方法本身耗时较长,可能被误判为“事务失效”,实际是超时回滚了。
这些细节单独看都是小问题,但组合起来排查时容易互相干扰。我的建议是排查时一次只改一个变量,改完验证,避免多个因素混在一起导致判断失误。
实际项目里,事务失效往往不是单一原因,而是多个因素叠加。比如自调用 + 异常被吞,两个问题同时存在,只修一个还是不正常。所以排查时要有耐心,按流程一步步确认,每确认一步就排除一类可能,最后剩下的就是根因。这套方法我在多个项目里验证过,比凭经验瞎猜效率高得多。