1. 先搞清楚声明式事务到底在替我们做什么Transactional这个注解几乎是每个 Java 后端开发者每天都在用的东西。加在方法上方法里的多条数据库操作就自动变成一个原子单元抛异常就回滚正常结束就提交。看起来简单到不需要思考但恰恰是这种“看起来简单”的东西坑最多。我见过太多项目里注解加得满满当当代码跑起来也没报错但数据该回滚的时候没回滚该一致的时候不一致。排查半天最后发现是事务压根没生效——注解在那儿摆着但代理没拦住或者拦住了但异常被吞了或者数据源根本就不是同一个。这类问题有个共同特点不报错、不提示、静默失效等你发现数据脏了往往已经过去好几天。这篇内容就是把我这些年踩过的、帮别人排查过的声明式事务失效场景做个系统梳理。核心聚焦三类最高频的坑代理机制导致的失效、异常处理导致的失效、事务传播与数据源配置导致的失效。每一类我都会讲清楚底层原理、给出可复现的代码片段、说明排查方法最后整理成速查表。适合已经用过Transactional但被它坑过的开发者也适合想彻底搞明白 Spring 事务代理机制的同学。读完之后你至少能做到看到一段加了事务注解的代码能快速判断它到底会不会生效。先说一个基本认知这是后面所有内容的地基Spring 的声明式事务本质上是AOP 代理。你写的 Service 方法加了TransactionalSpring 在启动时会给这个 Bean 生成一个代理对象调用方拿到的其实是代理不是原始对象。代理在方法执行前后插入事务的开启、提交、回滚逻辑。所以事务生效的前提是调用必须经过代理。任何绕过代理的调用注解就是一行注释。这个认知记住了后面很多坑你甚至能自己预判。2. 第一类坑代理没拦住注解成了摆设这是最经典、最高频的一类。注解明明加了日志里也看不到任何事务相关的输出方法里的多条 SQL 各自独立提交。根因只有一个调用没有经过代理对象。2.1 同类内部方法调用最隐蔽的自我欺骗先看一段几乎每个项目都出现过的代码Service public class OrderService { Autowired private OrderMapper orderMapper; public void createOrder(Order order) { // 一些前置校验 validate(order); // 调用本类的另一个方法期望它有事务 saveOrderAndItems(order); } Transactional(rollbackFor Exception.class) public void saveOrderAndItems(Order order) { orderMapper.insert(order); // 插入订单明细如果这里抛异常期望订单也回滚 orderMapper.insertItems(order.getItems()); } }这段代码里saveOrderAndItems加了事务注解但通过createOrder调用它时事务不会生效。原因很直接createOrder内部调用saveOrderAndItems用的是this.saveOrderAndItems(...)this是原始对象不是代理对象。代理根本没参与这次调用事务拦截器自然也不会执行。这个坑的隐蔽性在于代码看起来完全合理注解也在编译不报错运行不报错只有数据出问题时才会暴露。而且很多人第一次遇到时会怀疑是注解参数写错了、是数据库引擎不支持事务排查方向完全跑偏。解决办法有几种各有取舍把方法拆到另一个 Bean 里。这是最干净的做法。saveOrderAndItems挪到一个独立的OrderTxService中通过注入调用。跨 Bean 调用走的是代理事务正常生效。缺点是类会变多但对于核心事务逻辑这点拆分成本完全值得。注入自身代理。通过Autowired注入自己需要配置允许循环依赖或者用Lazy或者用AopContext.currentProxy()拿到当前代理再调用。后者需要开启EnableAspectJAutoProxy(exposeProxy true)。这种做法能用但代码可读性差团队里新人看到会懵我不太推荐在业务代码里大量使用。用TransactionTemplate编程式事务。如果只是个别方法需要直接注入TransactionTemplate在方法里显式执行。牺牲了一点声明式的优雅但意图极其明确不会有“到底生没生效”的疑虑。注意同类调用失效这个坑和 Spring 版本无关和 Boot 版本也无关它是代理机制的本质决定的。任何声称“升级版本就好了”的说法都是误导。2.2 private 方法和 final 方法代理根本没法覆盖第二类代理失效出在方法的可见性和修饰符上。Spring 的 AOP 代理分两种JDK 动态代理和 CGLIB 代理。Boot 2.x 之后默认用 CGLIB。CGLIB 的原理是生成一个子类覆盖父类的方法在覆盖的方法里织入增强逻辑。那么问题来了private 方法不能被继承final 方法不能被覆盖。这两种方法上的Transactional代理无能为力。Service public class PaymentService { Transactional(rollbackFor Exception.class) private void doPay(Payment payment) { // 这里的注解完全无效 paymentMapper.insert(payment); accountMapper.deduct(payment.getAmount()); } }private方法加事务注解编译能过启动不报错但事务百分之百不生效。final方法同理CGLIB 生成的子类无法覆盖它。还有一种情况是static方法同样不行。排查这类问题有个简单办法看方法签名。只要不是public或者至少是包级可见且非 final就要警惕。我个人的习惯是事务方法一律public这是硬规矩团队 code review 时也会专门盯这一点。另外提一句接口上的Transactional和实现类上的注解在 JDK 代理和 CGLIB 代理下行为有差异。Boot 默认 CGLIB 时注解写在实现类方法上即可如果项目强制用了 JDK 代理比如spring.aop.proxy-target-classfalse那注解最好写在接口方法上否则可能读不到。这个细节在混合代理配置的老项目里经常出问题。2.3 对象不是 Spring 管的new 出来的没有代理第三类代理失效更基础你操作的对象压根不是 Spring 容器里的 Bean。public class ReportService { Transactional(rollbackFor Exception.class) public void generateReport(Long id) { // 多条写操作 } } // 在某个地方 ReportService reportService new ReportService(); reportService.generateReport(1L); // 事务不生效new出来的对象没有经过 Spring 的 Bean 生命周期没有代理注解就是装饰。这种情况在工具类、静态工厂、反射创建对象的场景里偶有出现。还有一种变体通过Bean方法手动new了一个对象返回但方法内部又直接用了这个new出来的引用去调用事务方法同样失效。判断标准很简单这个对象是不是从容器里拿的。Autowired注入的、getBean拿的是代理new的、反序列化出来的、反射构造的不是。写代码时如果对某个对象的来源不确定宁可多看一眼注入链路。3. 第二类坑异常被吞了回滚条件没触发代理拦住了事务也开启了但最后数据还是没回滚。这类问题的根因在异常处理上。Spring 声明式事务的默认回滚规则是遇到RuntimeException及其子类、Error时回滚遇到受检异常Checked Exception时不回滚。围绕这条规则衍生出一堆坑。3.1 受检异常默认不回滚最容易踩的默认值先看代码Transactional public void transfer(Long from, Long to, BigDecimal amount) throws Exception { accountMapper.deduct(from, amount); if (amount.compareTo(BigDecimal.ZERO) 0) { throw new Exception(金额非法); // 受检异常 } accountMapper.add(to, amount); }金额非法时抛了Exception按直觉应该回滚扣款不该生效。但实际结果是扣款提交了加款没执行。因为Exception是受检异常不在默认回滚范围内。这个默认设计有它的历史原因但对业务代码来说几乎总是反直觉的。解决办法是显式指定rollbackForTransactional(rollbackFor Exception.class)我现在的习惯是所有事务注解一律带上rollbackFor Exception.class不依赖默认值。多写几个字符省掉未来可能的排查时间这笔账怎么算都划算。团队规范里也应该把这条写死。3.2 异常被 catch 住没抛出事务感知不到第二类异常坑更常见方法内部把异常吞了。Transactional(rollbackFor Exception.class) public void batchImport(ListItem items) { for (Item item : items) { try { itemMapper.insert(item); } catch (Exception e) { log.error(导入失败: {}, item.getId(), e); // 异常被吞循环继续 } } }这段代码的意图可能是“单条失败不影响其他条”但结果是整个方法正常结束事务提交前面插入成功的记录全部落库。如果业务期望是“有任何一条失败就整体回滚”那这个 catch 就是致命的。这里要区分两种业务诉求整体原子任何一条失败都要全部回滚。那就不能在循环里 catch或者 catch 之后必须重新抛出throw new RuntimeException(e)让异常传播到代理层。单条独立每条独立事务失败的不影响成功的。那就不该用一个外层大事务而应该让每条走独立事务配合REQUIRES_NEW或者拆成独立方法调用并且明确接受“部分成功”的语义。最怕的是代码写着大事务心里想的是单条独立或者反过来。这种语义错位比技术 bug 更难排查因为代码“运行正常”只是结果和预期不符。还有一种变体catch 之后没有重新抛而是返回了一个失败标记。事务代理只看有没有异常抛出你返回什么它不关心。所以“返回失败对象”这种模式在事务方法里要格外小心它不会触发回滚。3.3 异常被包装后类型变了回滚规则对不上第三类异常坑稍微绕一点异常抛了但类型变了。Transactional(rollbackFor RuntimeException.class) public void process(Data data) { try { doProcess(data); } catch (IOException e) { throw new BizException(处理失败, e); // 自定义异常 } }如果BizException继承的是Exception而不是RuntimeException那它就不在rollbackFor RuntimeException.class的范围内事务不回滚。很多人写rollbackFor时随手写了个RuntimeException但业务里抛的自定义异常偏偏是受检的两边对不上。我的建议还是那条rollbackFor Exception.class一劳永逸。如果团队有自定义异常体系确保所有业务异常都继承RuntimeException这样即使不写rollbackFor也能兜住。两条路选一条别混着来。提示noRollbackFor也要留意。有些代码为了“某些异常不回滚”配了noRollbackFor BizException.class结果后来业务异常体系调整这个配置变成了隐形炸弹。配置越少越安全。4. 第三类坑传播行为和数据源事务边界错乱前两类坑集中在“事务有没有开启”和“异常有没有触发回滚”。第三类坑更隐蔽事务开启了也回滚了但回滚的范围不对或者操作的根本不是同一个数据源。4.1 传播行为用错REQUIRES_NEW 和 NESTED 的语义差异先看一个典型场景主流程里记录操作日志希望日志独立提交不受主流程回滚影响。Transactional(rollbackFor Exception.class) public void mainProcess(Order order) { orderMapper.insert(order); logService.saveLog(order.getId()); // 期望日志独立提交 if (someCondition) { throw new RuntimeException(业务失败); } } // LogService Transactional(propagation Propagation.REQUIRES_NEW, rollbackFor Exception.class) public void saveLog(Long orderId) { logMapper.insert(orderId); }REQUIRES_NEW的语义是挂起当前事务开启一个全新的事务新事务独立提交或回滚。上面的代码在saveLog正常返回后日志已经提交了主流程后续抛异常回滚日志不受影响。这个行为符合预期。但很多人会把REQUIRES_NEW和NESTED搞混。NESTED是嵌套事务基于保存点savepoint实现外层回滚时内层也会回滚。如果上面的代码把REQUIRES_NEW换成NESTED主流程抛异常时日志会跟着回滚和“独立提交”的预期完全相反。两者的关键差异整理如下传播行为底层机制外层回滚时内层内层回滚时外层适用场景REQUIRES_NEW挂起外层开新事务不受影响不受影响日志、审计等独立提交NESTED保存点一起回滚可单独回滚子步骤失败不影响主流程其他部分还有一个高频错误REQUIRES_NEW用在同类调用里。因为同类调用不走代理传播行为配置根本不生效实际执行的是外层事务的传播行为。这个坑和 2.1 节叠加排查时容易只看到一层。另外REQUIRES_NEW会占用额外的数据库连接。如果外层事务持有连接内层又开新事务连接池瞬间需要两个连接。高并发下如果连接池配置偏小可能出现等待超时。这个副作用在压测时才会暴露平时开发环境很难发现。4.2 多数据源下事务管理器不匹配项目里配了多个数据源事务注解加了但操作的数据源和事务管理器对不上回滚自然失效。Transactional(rollbackFor Exception.class) // 默认事务管理器 public void crossDataSourceOp() { primaryMapper.insert(a); // 数据源 A secondaryMapper.insert(b); // 数据源 B }如果primaryMapper和secondaryMapper绑定的是不同数据源而Transactional没有指定transactionManager那默认事务管理器只管其中一个数据源。另一个数据源的操作是独立提交的回滚时管不到。这类问题的排查思路先确认每个 Mapper 绑定的数据源再确认事务管理器绑定的数据源两边必须一致。跨数据源的事务单靠Transactional是解决不了的需要分布式事务方案如两阶段提交、补偿事务或者从业务设计上拆开避免强一致要求。还有一种更隐蔽的情况动态数据源切换。通过 AOP 在方法执行前切换数据源但事务管理器在切换之前就已经绑定了连接。这时候事务里的操作可能落在错误的数据源上。这类问题需要确保数据源切换发生在事务开启之前通常的做法是把切换逻辑放在事务注解的外层或者用编程式方式显式控制顺序。4.3 只读事务里做写操作不报错但不生效Transactional(readOnly true)本意是优化查询性能提示数据库这是只读操作。但有人在这个方法里顺手做了写操作Transactional(readOnly true) public void refreshCache(Long id) { Data data dataMapper.selectById(id); cache.put(id, data); statMapper.updateAccessTime(id); // 写操作 }不同数据库和驱动对只读事务的处理不一样。有的会直接抛异常有的会静默忽略写操作有的则正常执行。行为不确定就意味着不可依赖。只读事务就老老实实只读写操作拆到独立方法里这是底线。5. 排查套路事务不生效时按这个顺序查上面三类坑讲完给一套实操排查流程。遇到“事务好像没生效”的问题按这个顺序走基本能定位到根因。5.1 第一步确认代理是否生效最直接的办法是看日志。开启 Spring 的logging.level.org.springframework.transactionDEBUG方法执行时如果能看到Creating new transaction、Participating in existing transaction这类日志说明代理生效了。如果完全没有事务相关日志那就是代理没拦住回到第 2 节排查同类调用private/final对象不是 Bean也可以在启动日志里找Bean xxxService is not eligible for getting processed by all BeanPostProcessors这类提示或者用AopUtils.isAopProxy(bean)在代码里断言。我习惯在单元测试里加一个断言确认关键 Service 是代理对象防止重构时不小心破坏了代理条件。5.2 第二步确认异常是否触发了回滚代理生效但没回滚看异常。在事务方法里临时加日志确认异常确实抛到了方法外层。如果方法内部 catch 了检查是否重新抛出。如果抛了检查异常类型是否在rollbackFor范围内。这一步用 DEBUG 日志配合断点很快能确认。有个小技巧在Transactional方法上临时把rollbackFor改成Throwable.class如果这样能回滚说明就是异常类型不匹配的问题。定位后再改回合理配置。5.3 第三步确认事务边界和数据源前两步都正常但回滚范围不对查传播行为和数据源。确认方法调用链上每一层的事务传播配置确认每个数据库操作绑定的数据源和事务管理器是否一致。多数据源项目里这一步尤其重要。常见问题速查表现象可能原因排查动作完全无事务日志代理未生效查同类调用、方法修饰符、对象来源有事务日志但不回滚异常被吞或类型不匹配查 catch 块、rollbackFor 配置部分操作回滚部分不回滚传播行为或数据源不一致查传播配置、事务管理器绑定日志跟着主流程回滚了REQUIRES_NEW 写成了 NESTED核对传播行为枚举值高并发下事务超时REQUIRES_NEW 占用额外连接查连接池大小和事务嵌套层数6. 几条写事务代码的硬规矩踩了这么多坑我现在写事务代码有几条不商量的规矩分享出来供参考。规矩一事务方法一律 public一律带rollbackFor Exception.class。不依赖默认值不给自己留模糊空间。团队规范里写死code review 时作为检查项。规矩二事务方法不 catch 异常除非明确要吞并且知道后果。如果必须 catch要么重新抛出要么在 catch 里显式调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()标记回滚。后者是编程式补救能用但不如直接抛异常清晰。规矩三同类调用不指望事务。需要事务的子逻辑拆到独立 Bean。拆不动的时候用TransactionTemplate显式控制别赌代理会不会生效。规矩四传播行为只在真正需要时配置。默认的REQUIRED能覆盖 90% 的场景。用REQUIRES_NEW之前先想清楚连接池够不够、外层事务挂起的代价能不能接受。规矩五多数据源项目事务注解必须显式指定transactionManager。不依赖默认避免数据源调整后事务悄悄失效。最后分享一个我实际排查中用过的小手段在测试环境给事务方法加一个环绕切面记录方法进入时的事务状态TransactionSynchronizationManager.isActualTransactionActive()和退出时的回滚标记。跑一轮集成测试哪些方法“以为有事务实际没有”一目了然。这个切面只在测试环境启用成本很低但能把大部分静默失效提前暴露出来。事务这东西最怕的就是它不说话你得主动去问它。
阅读完成 · 觉得有帮助?