资讯动态

Spring声明式事务失效全解析:从代理链到回滚规则

发布时间:2026/10/18 16:58:17 来源:尧图企业网站定制
做过Java后端的人一定都遇到过这种场景一个方法上标了Transactional异常也抛出来了可数据就是没回滚。问一圈同事有的说异常你没抛出去有的说事务压根没生效折腾半天最后发现是自调用或者异常被catch吞了。这个问题我入行到现在见过无数次几乎每个团队都会在某个上线节点后踩一遍。spring-tx声明式事务是Spring框架里用得最多、但理解最容易浮于表面的模块之一。很多人觉得会加个注解就算会事务了实际上声明式事务背后是AOP代理、TransactionInterceptor、事务同步管理器这一整套机制在协同工作。这篇文章不想复述官方文档我想从一个实际踩过坑的人的角度把spring-tx的底层逻辑、传播行为、隔离级别、回滚规则以及那些让人头疼的事务不生效问题一次讲透。如果你是刚开始接触Spring的初学者这篇能帮你建立对声明式事务的完整认知如果你已经写了两三年业务代码文中关于自调用、传播行为、排查链路的经验应该能帮你少踩几个坑。1. 一个注解背后的整条代理链声明式事务究竟是怎么生效的1.1 你注入的Bean不是你以为的那个Bean先说结论Transactional本身不干活它是给Spring容器看的标记。真正干活的是容器在启动阶段创建的代理对象。当你在类或者方法上标注Transactional时Spring会在扫描阶段识别这些注解然后对目标Bean做代理增强。这种增强分两条路线如果目标类实现了接口Spring默认走JDK动态代理代理类和目标类实现同一个接口如果目标类没有实现任何接口Spring就用CGLIB生成一个子类来覆盖目标方法。不管哪条路线最终效果都一样——你注入到其他类里的那个Bean其实已经不是你自己写的那个实例而是代理实例。调用过程是这样的外部代码调用bean.method()时先进入代理对象代理对象判断这个方法的配置里有没有事务。如果配置了就交给TransactionInterceptor事务拦截器。TransactionInterceptor内部通过PlatformTransactionManager获取一个事务再反射调用目标方法最后根据返回结果或抛出的异常决定commit还是rollback。这套机制就是AOP事务只是AOP最典型的应用场景。理解这一点是理解后面所有事务失效问题的基础因为绝大多数失效问题的根源都出在代理这一层。1.2 从Transactional到commit/rollback的完整调用路径实际运行时的调用链可以整理成下面这样外部调用 - 代理对象 - 事务拦截器: TransactionInterceptor.invoke() - 解析事务属性传播行为、隔离级别、超时时间、回滚规则 - 调用事务管理器: DataSourceTransactionManager.getTransaction() - 从连接池获取Connection - 关闭autoCommit - 反射调用目标方法: method.invoke(target, args) - 正常返回 - 事务提交 commit() - 抛出异常 - 按回滚规则决定 rollback() 还是 commit() - 清理资源: 恢复autoCommit、关闭Connection这条调用链里有几个细节经常被忽略。第一事务本质上是绑定在数据库连接上的。Spring做的事情就是帮你把Connection的autoCommit关掉在方法结束的时候帮你提交或回滚。DataSourceTransactionManager在处理过程中会把这个Connection关联到TransactionSynchronizationManager这个类内部用ThreadLocal保存当前线程的事务资源。这一点解释了为什么Spring事务默认只对单线程有效你在事务方法里新开一个线程去执行另一个带Transactional的方法那个新线程里没有继承当前线程的事务上下文事务也就各自独立了。第二commit不是直接调用connection.commit()就完事。Spring在提交前会触发事务同步TransactionSynchronization的一串回调比如beforeCommit、afterCommit、afterCompletion。如果你用过某些ORM框架或者消息队列的事务消息通常就是借助这些回调做收尾工作的。理解这条链后面讲自调用失效、private方法失效、多线程事务不生效的时候都能顺理成章推导出来。2. 传播行为七个枚举值里真正左右数据一致性的只有三个2.1 REQUIRED90%场景的默认答案Spring的Propagation枚举定义了七个传播级别初次接触的人很容易被REQUIRES_NEW、NESTED、SUPPORTS、MANDATORY、NOT_SUPPORTED、NEVER这些名字吓到。真正写业务代码时绝大多数情况只需要搞明白三个REQUIRED、REQUIRES_NEW、NESTED。REQUIRED是默认值规则是当前调用链上已经存在事务就直接加入不存在就新建一个。白话讲就是能蹭就蹭没有就自己开。这个默认选择是合理的因为在一个对外提供的业务方法里我们通常希望整个操作是一个原子整体要么全部成功要么全部失败。如果每个方法都强制开新事务外层方法异常时内层已经提交的数据就无法跟着回滚一致性直接崩掉。举个例子一个订单创建方法依次调用扣库存方法和写日志方法三个方法都标了Transactional(propagation Propagation.REQUIRED)。库存扣减成功、写日志失败外层抛异常整个事务回滚库存扣减也一起回滚。这是REQUIRED带来的最基本保证。2.2 REQUIRES_NEW旁路操作的代价REQUIRES_NEW的规则是不管当前有没有事务都挂起当前事务新开一个独立事务方法结束后立即提交与外层事务没有关联。这个模式最典型的应用场景是失败了不能影响主流程的旁路操作。比如核心业务处理完后要记录审计日志日志记录失败不能让主业务回滚这时候日志方法标REQUIRES_NEW就很合适。再比如发送通知消息这种外部调用主流程失败时消息也没法跟着回滚不如让它独立事务。但REQUIRES_NEW不是免费的。它意味着方法内部要重新从连接池拿一个连接。如果并发量大、外层事务又持有连接较久连接池很容易被打满。我遇到过一个批量导入场景外层开了一个事务循环内部对每一行调用了REQUIRES_NEW的入库方法1000行就是1000个独立事务每个都要拿新连接导入一跑连接池直接告警。这种场景的正确解法是外层不要开事务或者批量聚合后在边界处提交而不是依赖REQUIRES_NEW硬撑。2.3 NESTED与选择事务传播的一条经验法则NESTED是一个容易被忽略的选项。它的规则是基于当前事务保存一个Savepoint保存点内层方法在保存点之上执行内层异常只回滚到保存点外层可以决定继续或回滚外层回滚时内层一起回滚。这个语义比REQUIRES_NEW温和因为它没有新开连接只是利用了数据库的保存点机制。NESTED适合整体要事务但其中某一步失败了可以单独跳过的场景。比如批量导入10条数据其中一条校验失败时不想整个批次回滚而是跳过这一条继续后续数据最后统一提交。不过要注意NESTED要求底层事务管理器支持保存点MySQL的InnoDB支持但支持程度在不同数据库上有差异使用前要确认。我自己在选传播行为时有个习惯默认全部REQUIRED只有明确分析过数据一致性风险后才考虑REQUIRES_NEW或NESTED。判断依据就一个问题内层事务失败后外层到底该不该跟着失败应该跟着失败、整体一致 - REQUIRED内层失败时外层不受影响且内层提交的数据即使外层回滚也可以接受 - REQUIRES_NEW内层失败可跳过但外层失败时内层必须一起回滚 - NESTED如果内层方法需要回滚但外层想捕获异常后继续REQUIRED下做不到。因为异常一旦传到事务边界整个事务会被标记为rollback-only后续提交会抛出UnexpectedRollbackException这个坑后面专门讲。3. 隔离级别与回滚规则事务边界靠什么守住一致性3.1 隔离级别的取舍隔离级别解决的是并发事务之间的可见性问题。Transactional的isolation属性对应JDBC的Connection常量常见五个级别DEFAULT、READ_UNCOMMITTED、READ_COMMITTED、REPEATABLE_READ、SERIALIZABLE。生产环境里Oracle默认READ_COMMITTEDMySQL InnoDB默认REPEATABLE_READ。大多数业务系统用默认级别就够了更高的隔离级别意味着更低的并发和更高的锁代价。经常有人分不清REPEATABLE_READ和SERIALIZABLE。简单理解REPEATABLE_READ通过MVCC快照保证同一个事务里多次读取同一范围数据结果一致但没法彻底解决幻读SERIALIZABLE则让事务之间几乎没有并发冲突代价自然最大。还有个MySQL特有的坑REPEATABLE_READ级别下同一事务里先select后update前面的select是快照读update是当前读。如果并发事务插入了匹配条件的新记录update影响的行数和select看到的不一致。做库存、对账类业务时很容易踩到。这种场景一般需要显式使用select ... for update来加锁而不是靠调整隔离级别。3.2 rollbackFor不写等于埋雷这是我做代码评审时最常提的一点Spring的Transactional默认回滚规则是只有RuntimeException和Error才触发回滚受检异常checked exception默认不回滚。Spring这么设计的官方理由是受检异常往往表示业务规则上的可预期错误比如余额不足、数据不存在这类情况希望事务继续让调用方决定怎么处理。但现实是很多业务系统里自定义了BusinessException继承自Exception而不是RuntimeException事务方法里抛出这个异常方法结束数据照样提交。程序不报错数据错了这种问题最难排查。正确姿势只有一个原则触发回滚的异常必须通过rollbackFor显式声明。Transactional(rollbackFor Exception.class) public void transfer(String from, String to, BigDecimal amount) { // 扣款逻辑 // 入账逻辑 }把rollbackFor设为Exception.class意味着无论受检异常、运行时异常还是自订异常只要从方法里抛出全部触发回滚。这是绝大多数业务系统应该采用的默认值。顺带提醒如果代码内部catch了异常且没有重新抛出事务管理器感知不到异常自然也不会回滚。这是异常被吞导致事务不生效最常见的分支。3.3 readOnly除了SQL优化还有一层含义Transactional还有一个常被误解的属性readOnly。很多人以为它只是一个查询优化标记实际上它的语义是当前事务只读不允许执行写操作。Spring在实现上会把连接的readOnly标志设为true在MySQL驱动上对应执行SET TRANSACTION READ ONLY这类操作并且在提交时不执行正常的事务提交逻辑。Hibernate这类ORM框架还会在readOnly环境下禁用脏检查节省快照对比的开销。注意readOnly的真正收益通常体现在ORM层面纯JDBC场景下影响很小甚至会在连接切换时产生额外开销。我的建议是查询接口可以加readOnlytrue它首先是一种语义声明帮助阅读代码的人快速知道这个方法不写数据但别指望它带来大幅性能提升。4. 自调用陷阱与代理失效生产事故的高发区4.1 this.method()为什么拦不住这是声明式事务失效最经典的场景。同一个类里一个方法调用另一个方法Service public class OrderService { public void createOrder(Order order) { // 业务处理 this.logOrder(order); // 自调用 } Transactional public void logOrder(Order order) { orderRepository.insertLog(order); } }createOrder调用logOrder时logOrder上的Transactional不会生效。原因回到前面讲的代理机制外部注入OrderService时注入的是代理对象但this指向的是当前对象本身不是代理对象。this.logOrder()直接调用原始对象的方法事务拦截器根本没机会介入。一句话总结事务拦截的是通过代理对象发起的调用而自调用绕过了代理。4.2 绕法排序与最终建议项目里常见的绕法按推荐程度排个序第一种拆到另一个Bean里。把logOrder放到独立的组件比如OrderLogService中由OrderService注入并调用。这是最干净、最符合Spring设计思路的解法。第二种通过AopContext拿到当前代理对象再调用。比如AopContext.currentProxy()强转后调用要求启动时配置exposeProxytrue代码可读性差但确实能解决问题。第三种自己注入自己。构造器注入OrderService自身通过注入的代理来调用内部方法。方式可行但容易在代理初始化顺序上绕晕不推荐新手用。第四种放弃注解改成编程式事务TransactionTemplate。如果方法内部确实有自调用需求干脆不用Transactional改用TransactionTemplate在方法体代码里显式开启和提交事务语义反而更清晰。从我的经验看出现自调用需求往往意味着类职责划分有问题事务方法和普通方法挤在同一个类里互相调用时就容易踩到代理边界。优先应该做的是重新拆分职责而不是去写绕代理的魔法代码。4.3 家族成员private方法、static方法、新线程与反射调用自调用只是代理失效的一种下面这些场景同样会让Transactional悄悄失效private方法CGLIB通过生成子类覆盖方法实现代理private方法无法被覆盖Spring会给出警告但很多人没看日志。static方法静态方法不属于实例调用代理拦截不到。final方法CGLIB不能覆盖final方法同样失效。通过反射拿到原始Class的方法对象再调用而不是代理Class的方法拦截器同样不会生效。还有一个常见的假失效方法内部新开一个线程去执行带事务的代码。前面说过事务资源通过ThreadLocal绑定在当前线程新线程里没有事务上下文事务完全不生效。这在异步消息发送、异步任务处理场景里反复出现处理原则是事务边界和异步边界不要重叠要么用事务同步回调要么用事务消息解耦。怎么最快确认一个Transactional是否生效在方法里打断点看调用栈里有没有TransactionInterceptor。调用栈里找不到这个类基本可以断定是自调用或反射绕过了代理。5. 事务不生效时的完整排查链路从现象到根因5.1 一次线上问题排查的三步走我用一次真实的排查经历来演示完整链路。某服务的批量结算接口线上频繁出现部分数据已经入账但接口报错提示失败。代码里明确写了Transactional(rollbackFor Exception.class)按理说不会出现提交一半的情况。第一步确认注解是否真的作用在代理方法上。检查接口实现类时发现注解写在接口方法上实现类方法没标。这里有个细节Spring的Transactional如果同时出现在接口和实现方法上以实现类为准但如果只有接口方法标了注解而目标类走的是CGLIB代理类没有实现接口或者强制配置了CGLIB注解根本不会被读取。检查结果是这个Service没有实现接口走CGLIB接口上的注解被完全忽略了。这是第一个看上去生效其实没生效的坑。第二步确认异常是否真的从方法里抛出。看日志发现服务里有一个catch块捕获异常后记日志然后返回一个失败结果对象没有重新抛出。事务管理器只认异常是否从代理方法边界抛出catch后正常返回事务自然正常提交。这是又一个以为在回滚、实际在提交的典型。第三步确认有没有事务边界之外的显式提交。老项目里经常有历史代码手动调用sqlSession.commit()或connection.commit()一旦出现这种调用Spring在方法结束时再提交就会出现不可控行为。检查后确实发现一处历史遗留的手动flush。三步走完根因清晰CGLIB代理没代理到接口上的注解异常被catch吞掉还存在手动commit。修复方案是注解全部放到实现类方法上异常处理改成catch后重新抛出删掉手动提交。5.2 一张可以直接用的排查清单把上面的经验收拢成一张清单遇到事务问题按顺序逐条核对类是否被Spring管理有没有Service/Component注解方法是不是public是不是final/static/private方法注解是标注在真正被代理的类的方法上还是只写在接口方法上调用方注入的是不是代理对象有没有this.自调用方法有没有被同类方法或反射绕过代理调用异常是不是真的抛到了事务边界有没有被catch吞掉rollbackFor是否覆盖了异常类型事务方法有没有运行在另一个线程里有没有手动提交/回滚代码干扰Spring的事务控制传播行为是否符合预期有没有内层REQUIRES_NEW导致外层无法整体回滚这张清单我在团队里转过一轮基本覆盖了这几年遇到过的90%事务问题。5.3 UnexpectedRollbackException被忽略的事务级回滚标记再单独说一个值得记忆的异常UnexpectedRollbackException。现象是外层方法明明catch住了内层异常日志里却冒出这个异常事务还是回滚了。原因就是前面传播行为里提到的机制。REQUIRED传播下内层方法执行时使用同一个事务内层抛出的异常一旦在事务边界被处理会把整个事务标记为rollback-only。外层代码catch住异常继续往下走但rollback-only标记已经打在这个事务上catch根本清除不掉。最终事务边界尝试提交时事务管理器发现rollback-only标记抛出UnexpectedRollbackException并执行回滚。根本原因一句话回滚标记是事务级别的不是方法级别的。REQUIRED传播的内层一旦失败整个事务在原子性语义下已经不可能提交了。解决方式是把内层方法改成REQUIRES_NEW或者内层不要执行会触发回滚的操作或者重新评估这段逻辑——内层失败时外层本来就不应该继续。6. 验证与测试怎么证明你的事务配置真的有效6.1 日志先行让Spring自己把事务过程说出来很多项目里事务相关测试覆盖很低原因只有一个事务行为很难在当前测试方法里直观看到。我的做法是先开日志让Spring自己把过程说出来。在启动配置里把org.springframework.transaction的日志级别调到DEBUGSpring会把事务的begin、commit、rollback过程全部打印出来。日志里出现Getting transaction for ...说明事务已经开启出现Initiating transaction commit或Initiating transaction rollback说明走到了对应分支。对着日志判断事务是否生效比对着代码猜要快得多。这个方法在开发阶段尤其有用。我给团队的建议是凡是新建事务相关代码第一步先在本地DEBUG日志下跑一遍增删改查确认begin和commit/rollback都出现再谈业务验证。6.2 冒烟测试的写法要确认一个Transactional是否真的生效最有效的测试方式是故意让它失败然后断言数据没有被修改。Test void testCreateOrderRollback() { Order invalidOrder buildInvalidOrder(); assertThatThrownBy(() - orderService.createOrder(invalidOrder)) .isInstanceOf(BusinessException.class); Long count orderRepository.count(eventLogTable); assertThat(count).isEqualTo(0L); }运行后去数据库确认没有残留数据说明回滚有效。如果数据被写了说明事务配置有问题。做这类测试时注意两个事一是测试数据要能被正确清理别污染共享的开发库二是断言要查到真实落库的数据别查询命中缓存。另一层是用Spring测试框架的事务回滚特性。测试类上加Transactional让每个测试方法跑在事务里结束后自动回滚适合验证业务逻辑而非代理行为。这两层验证各有侧重业务逻辑正确性靠单元测试事务边界是否生效靠冒烟测试。6.3 线上怎么看事务是否在正常工作线上验证事务是否生效最直接的是看慢查询日志和锁等待。一个标了Transactional的长方法执行期间多个SQL操作之间一直占用同一个连接如果SQL之间连接频繁切换事务可能被某个配置问题悄悄关闭了。监控系统里可以看数据库线程状态。提交量和回滚量的比值很有信息量如果回滚比例异常高说明业务里经常走异常路径需要审视是不是有大量预期外的回滚如果回滚比例极低但业务确实频繁出现失败请求那多半有异常被吞掉的情况。我在团队里定过一个评审原则凡是在事务方法里执行不只一条写入SQL的操作代码评审时必须回答三个问题——事务边界在哪异常会不会触发回滚有没有自调用绕过代理任何一个回答不了上线前就把这个类拉出来再过一遍。最后再分享一个我自己的习惯不要只在方法上写Transactional就完事而是把传播行为、隔离级别、回滚规则这些配置在代码评审时讲清楚。一个项目维护到第三年出问题的不再是刚写的代码而是某次重构动过的老事务方法——有人往里加了个异步调用有人把异常catch住了有人把方法改成了final。声明式事务最省力的是不用写事务代码最需要警惕的恰恰也是它你在不知不觉中越过了代理边界系统却不给你任何提示。把事务当成一个有边界的组件来维护比记住一大堆注解参数重要得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑