
1. 从一次线上事故说起为什么加了注解事务还是没生效去年帮一个团队排查线上问题场景很典型用户下单后扣了库存结果订单表写入失败库存却没回滚。代码里明明在 Service 方法上加了Transactional日志里也看不到任何报错但数据就是不一致。这种注解加了等于没加的情况几乎每个用 Spring 的团队都遇到过至少一次。Transactional是 Spring 声明式事务的核心注解它的设计初衷是让开发者不用手写begin、commit、rollback这些模板代码只靠一个注解就能把事务边界声明清楚。但它的生效依赖一整套代理机制和运行时条件任何一个环节不满足事务就会静默失效——注意是静默不报错、不警告这才是最坑的地方。这篇文章面向的是已经用过 Spring 事务、但被失效问题坑过的后端开发者。我会把实际项目中最常见的七种失效原因逐一拆开每一种都讲清楚为什么会失效、怎么快速判断是不是这个原因、怎么改。文中所有案例都做了脱敏处理代码是通用写法你可以直接对照自己的项目排查。读完之后你应该能建立起一套看到事务不生效按什么顺序去查的排查直觉而不是靠猜。先说一个贯穿全文的底层认知Spring 的Transactional本质上是基于代理的 AOP。你调用的是一个被代理包装过的对象代理在方法执行前后织入事务的开启与提交逻辑。所以凡是绕过了代理或者代理织入的条件不满足的情况事务都会失效。后面七种原因本质上都是这个底层机制在不同场景下的具体表现。2. 七种失效原因逐条拆解2.1 同类内部方法调用最常见的自调用陷阱这是出现频率最高的一种。看下面这段代码Service public class OrderService { public void createOrder(Order order) { // 直接调用本类的方法 this.saveOrder(order); } Transactional public void saveOrder(Order order) { // 写订单 扣库存 } }createOrder调用saveOrder走的是this.saveOrder()也就是当前对象的引用而不是代理对象。事务逻辑织在代理对象上this指向的是原始目标对象代理根本没机会介入所以saveOrder上的Transactional完全不起作用。判断方法很简单如果失效的方法是被同一个类里的另一个方法调用的基本可以锁定是这个原因。修复方式有三种按推荐程度排序把方法拆到另一个 Bean 里。这是最干净的做法让调用方注入目标 Bean通过代理调用。事务边界本来就应该跨类划分硬塞在一个类里往往是职责没拆干净。注入自己。通过Autowired或构造器把当前 Bean 注入进来用注入的引用去调用方法。注意 Spring 默认允许循环依赖的时代已经过去构造器注入自己会报循环依赖得用Lazy或者 setter 注入绕开。用AopContext.currentProxy()。需要开启EnableAspectJAutoProxy(exposeProxy true)然后((OrderService) AopContext.currentProxy()).saveOrder(order)。这个方式能用但不够优雅属于应急手段。提示同类自调用失效是静默的典型代表方法照常执行只是没有事务。排查时优先怀疑它。2.2 方法不是 public代理根本织不进去Spring 的声明式事务默认只对public 方法生效。如果你把Transactional加在private、protected或者包级私有的方法上代理不会拦截事务自然不生效。Transactional private void doSomething() { // 事务不会生效 }这里要理解一个细节Spring 的 AOP 代理有两种实现JDK 动态代理基于接口CGLIB 基于子类继承。无论哪种都无法代理非 public 方法——JDK 代理只能代理接口里声明的方法都是 publicCGLIB 虽然能通过子类覆盖但对 private 方法无能为力对 protected 和包级方法在跨包场景下也有问题。所以 Spring 干脆统一规定非 public 方法上的Transactional直接忽略。有意思的是如果你用的是 AspectJ 的编译期/加载期织入而不是 Spring 的代理模式非 public 方法是可以支持事务的。但绝大多数项目用的都是默认的代理模式所以这条规则要记牢。判断方法检查失效方法是不是 public。修复就是改成 public或者把事务逻辑挪到一个 public 方法里。这里有个容易忽略的点——接口方法默认是 public但如果你在实现类里把方法声明成了非 public编译都过不了所以这种情况更多出现在没有接口的类里。2.3 异常类型不匹配默认只回滚运行时异常这是第二高频的坑。看这段Transactional public void updateStatus() throws Exception { // 业务逻辑 throw new Exception(检查异常); }默认情况下Spring 事务只在遇到RuntimeException和Error时回滚遇到受检异常Checked Exception不回滚。也就是说上面这段代码抛了Exception事务会正常提交数据已经写进去了。为什么这么设计因为 Spring 遵循了 EJB 时代的事务约定受检异常代表可预期的业务情况调用方应该处理它不一定要回滚运行时异常代表程序出错了应该回滚。这个约定在今天看来有点反直觉但它是默认行为必须记住。修复方式是在注解上显式指定回滚的异常类型Transactional(rollbackFor Exception.class) public void updateStatus() throws Exception { // ... }反过来如果你想让某个运行时异常不回滚可以用noRollbackFor。实际项目里我建议统一加上rollbackFor Exception.class除非你有明确的理由不回滚某些异常。这样能避免以为会回滚结果没回滚的意外。还有一个更隐蔽的变体异常被 catch 了没往外抛。Transactional public void doBusiness() { try { // 业务逻辑可能抛异常 } catch (Exception e) { log.error(出错了, e); // 没有重新抛出 } }异常在方法内部被吞掉代理感知不到任何异常自然认为方法正常结束于是提交事务。这种情况排查起来最费劲因为日志里明明有错误记录但数据就是没回滚。修复要么把异常重新抛出要么在 catch 里手动调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()标记回滚。2.4 事务传播行为配置错误REQUIRES_NEW 与嵌套的坑传播行为Propagation决定了当前方法遇到已有事务时该怎么办。默认是REQUIRED意思是有事务就加入没有就新建。大部分场景用默认值没问题但有两种配置容易出问题。第一种是REQUIRES_NEW。它表示总是新建一个事务挂起当前事务。看这个场景Transactional public void outerMethod() { // 写主表 innerService.innerMethod(); // 内部是 REQUIRES_NEW // 后续逻辑抛异常 throw new RuntimeException(外层失败); } Transactional(propagation Propagation.REQUIRES_NEW) public void innerMethod() { // 写日志表 }外层抛异常回滚但innerMethod因为开了新事务它写的数据不会跟着回滚。如果你期望的是要么全成功要么全失败这个配置就错了。REQUIRES_NEW适合那种无论主流程成败都要记录的场景比如操作日志、审计记录。第二种是NESTED。它表示在当前事务里开一个保存点savepoint内层失败回滚到保存点外层可以选择继续或整体回滚。注意NESTED依赖数据库对保存点的支持而且它和REQUIRES_NEW的语义完全不同——NESTED是同一个物理事务REQUIRES_NEW是两个独立事务。很多人把这两个搞混导致回滚范围不符合预期。判断方法检查失效或行为异常的方法上有没有配propagation以及配置的语义是否符合业务预期。修复就是根据实际需求选对传播行为拿不准就用默认的REQUIRED。2.5 数据库引擎不支持事务MyISAM 的隐形陷阱这一条属于代码全对但就是不生效的类型。MySQL 早期默认的存储引擎是 MyISAM它不支持事务。如果你建表时没指定引擎或者从老项目迁移过来表可能是 MyISAM 的。这种情况下无论你的 Spring 配置多正确事务都不会生效因为底层数据库根本不支持。判断方法SHOW TABLE STATUS LIKE your_table_name;看Engine字段是不是InnoDB。如果是MyISAM改成InnoDBALTER TABLE your_table_name ENGINE InnoDB;除了 MySQL其他数据库也有类似情况。比如某些嵌入式数据库、或者配置成了非事务性的存储方案。另外如果你用的是分库分表中间件要确认中间件对事务的支持情况——有些中间件在跨库场景下只支持弱事务甚至不支持事务。这一条之所以要单独列出来是因为它跳出了 Java 代码层面排查时容易被忽略。我见过团队花两天查代码最后发现是建表时引擎选错了。2.6 多数据源下事务管理器没配对项目里配了多个数据源时每个数据源需要各自的事务管理器。如果你在操作数据源 A 的方法上加了Transactional但没指定用哪个事务管理器Spring 会用默认的那个结果就是事务管理器管的是 B 库你操作的是 A 库事务自然不生效。Transactional(transactionManager orderTransactionManager) public void saveOrder() { // 操作订单库 }判断方法确认项目里是不是有多个DataSource和多个PlatformTransactionManager。如果有检查Transactional有没有通过transactionManager属性指定正确的事务管理器。修复就是显式指定或者通过Primary标注一个默认的。这里有个进阶坑跨数据源的事务。Spring 的声明式事务默认只能管一个数据源如果你在一个方法里同时操作两个库想让它原子性回滚需要引入分布式事务方案如 XA、TCC、消息最终一致性等。普通的Transactional做不到跨库原子性这一点要心里有数。2.7 事务方法被 final 或 static 修饰CGLIB 代理是通过继承目标类并覆盖方法来实现的。如果方法是final的子类无法覆盖代理就织不进去如果是static的它属于类而不属于对象代理机制同样无能为力。Transactional public final void finalMethod() { // 事务不生效 }判断方法检查失效方法有没有final或static修饰符。修复就是去掉这些修饰符。这种情况在实际项目里不算高频但在一些用了代码生成工具、或者继承了某些框架基类的场景下会出现——基类把方法定义成了 final子类想加事务就加不上。顺带提一个相关的如果类本身被final修饰CGLIB 无法继承这个类代理创建会直接失败启动时报错这个反而容易发现不像方法级 final 那样静默。3. 一张表理清七种原因的排查优先级排查事务失效最忌讳东一榔头西一棒子。我习惯按从代码到配置再到环境的顺序过一遍下面这张表是我自己用的排查清单按命中概率从高到低排列序号失效原因典型特征快速验证方式修复方向1同类自调用方法被本类其他方法调用看调用方是不是this.xxx()拆 Bean / 注入自己 / AopContext2异常类型不匹配抛了受检异常或异常被吞看异常类型和 catch 块加rollbackFor/ 重新抛出3方法非 public方法修饰符是 private/protected看方法签名改 public 或挪位置4传播行为配错用了 REQUIRES_NEW/NESTED看注解的 propagation按业务语义重选5多数据源没配对项目有多个数据源看有几个事务管理器指定 transactionManager6引擎不支持代码全对但不回滚SHOW TABLE STATUS改 InnoDB7final/static 方法方法有 final/static看修饰符去掉修饰符这张表的用法是从第 1 行往下查命中哪条就处理哪条。实际经验里前三条能覆盖八成以上的失效场景。第 6、7 条虽然概率低但一旦遇到特别费时间所以放在清单里备查。注意排查时不要只盯着Transactional那一行代码看要顺着调用链和代理链两个维度一起查。调用链回答方法是怎么被调到的代理链回答代理有没有机会介入。4. 三个容易被忽略的进阶细节4.1 事务与异步、缓存的叠加效应Async和Transactional同时出现在一个方法上时顺序很关键。如果Async先生效方法会被丢到另一个线程执行而事务的ThreadLocal绑定在当前线程上新线程里根本没有事务上下文Transactional就失效了。这两个注解的叠加需要特别小心通常建议拆成两个方法一个负责异步调度一个负责事务逻辑。缓存注解Cacheable和事务叠加也有类似问题。Cacheable在方法执行前查缓存如果命中就直接返回方法体根本不执行事务代理虽然开了事务但没有任何数据库操作等于空转。更麻烦的是如果缓存写入和数据库写入不在同一个事务里可能出现缓存更新了但数据库回滚了的不一致。这类问题不在本文七种原因之内但排查事务相关 bug 时值得一并考虑。4.2 只读事务不是性能开关Transactional(readOnly true)经常被当成性能优化手段到处加。它的真实作用是给底层一个提示这个事务里不会有写操作。数据库驱动和连接池可以据此做一些优化比如不开启写相关的资源。但它不会阻止你写数据——如果你在只读事务里执行了 update某些数据库会直接报错某些则默默执行。所以别把它当保险用它只是一个提示。另外只读事务在配合主从复制时有用可以路由到从库。但如果你的框架没做读写分离路由加readOnly true基本没有实际收益反而增加了理解成本。4.3 事务超时与连接池的联动Transactional(timeout 5)设置的是事务超时时间单位秒。超时后事务会被标记回滚。但要注意这个超时是从事务开启到提交的总时长如果方法里有远程调用、文件 IO 等耗时操作很容易触发超时。而且超时后的回滚行为依赖底层数据库和驱动的实现不是所有场景都能干净回滚。更隐蔽的是连接池配置。如果连接池的最大连接数太小高并发下事务方法拿不到连接会阻塞阻塞时间算不算进事务超时、会不会导致连接泄漏这些都要结合具体连接池如 HikariCP、Druid的参数一起看。事务问题和连接池问题经常纠缠在一起排查时要一起看日志。5. 我自己的排查习惯与几条硬经验排查事务问题这么多年我养成了几个固定习惯分享出来供参考。第一先看调用链再看注解。很多人一上来就盯着Transactional的参数看其实大部分问题出在方法压根没走代理上。用 IDE 的调用层级功能Call Hierarchy把调用链拉出来看看是不是同类自调用这一步能省掉大量时间。第二打开 Spring 的事务日志。把org.springframework.transaction的日志级别调到DEBUG你能看到事务的开启、提交、回滚全过程。如果日志里根本没有Creating new transaction之类的输出说明事务压根没启动问题在代理层如果有开启但没回滚问题在异常处理或传播行为。这一招比读代码快得多。第三写单元测试验证回滚。别靠肉眼看代码判断事务会不会生效写个集成测试故意在事务方法里抛异常然后断言数据库里没有数据。这种测试跑一次只要几秒但能挡住绝大多数回归问题。我习惯给每个涉及多表写入的 Service 方法都配一个回滚测试。第四统一规范降低踩坑概率。团队里约定几条硬规则事务方法一律 public一律加rollbackFor Exception.class禁止同类自调用事务方法多数据源必须显式指定事务管理器。这些规则写进代码规范配合静态检查工具如自定义的 ArchUnit 规则或 Checkstyle 规则自动拦截比事后排查划算得多。最后说一个心态上的经验事务失效之所以让人头疼是因为它不报错。所以排查时不要假设代码写了就一定生效而要假设默认不生效我需要证明它生效了。带着这个心态去查你会更快找到问题。数据库层面也可以用SELECT autocommit确认自动提交是否关闭用SHOW ENGINE INNODB STATUS看最近的事务情况这些底层手段在关键时刻能提供代码层面看不到的信息。