声明式事务不生效?三类常见坑与排查修复指南
声明式事务是日常开发里最容易被“想当然”对待的一块内容。很多人写完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 默认只回滚 RuntimeExceptionSpring 声明式事务默认只在遇到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 秒会抛异常并回滚。如果业务方法本身耗时较长可能被误判为“事务失效”实际是超时回滚了。这些细节单独看都是小问题但组合起来排查时容易互相干扰。我的建议是排查时一次只改一个变量改完验证避免多个因素混在一起导致判断失误。实际项目里事务失效往往不是单一原因而是多个因素叠加。比如自调用 异常被吞两个问题同时存在只修一个还是不正常。所以排查时要有耐心按流程一步步确认每确认一步就排除一类可能最后剩下的就是根因。这套方法我在多个项目里验证过比凭经验瞎猜效率高得多。