
事务注解的正确姿势一次把Spring事务回滚机制聊透在Java后端开发里事务处理大概是面试被问得最频繁、实际开发中踩坑也最多的知识点之一。很多同学写代码时随手在Service方法上加一个Transactional以为万事大吉结果线上出了数据不一致的问题翻日志找了半天也定位不到原因。还有更常见的同一个类里一个方法调用另一个方法注解明明写着事务却不生效数据该写进去照样写进去。这类问题我见过太多次了今天就从实际使用的角度把Transactional的正确用法和回滚机制的触发逻辑彻底理一遍。这篇文章适合正在做Java后端开发、或者准备面试的工程师阅读。我会尽量把原理讲得直白一些同时把我在实际项目中踩过、填过的坑都交代清楚保证你看完能直接照着排查自己的代码。1. 设计思路拆解Spring的事务到底是怎么“管理”起来的要理解Transactional不能只把它当成一个“加了就能回滚”的开关。它本质上是一个基于**AOP面向切面编程**的声明式事务方案。Spring容器在启动时会为标注了事务注解的Bean生成代理对象。每次调用这些Bean的事务方法实际进入的是代理对象——代理先开启事务再执行你的业务逻辑最后根据执行结果决定提交还是回滚。这里有一个非常关键的概念需要先搞清楚Transactional是类级别还是方法级别的生效规则。如果注解标注在类上表示该类的所有public方法默认启用事务如果标注在方法上则只对这一个方法生效。但这里有个优先级陷阱——如果类级别和方法级别同时存在方法级别的注解会覆盖类级别的配置。这一点看似简单实际编码中却经常被忽略尤其是当团队中有人习惯在类上统一配置又有人喜欢在具体方法上单独调参时。再说说代理模式。Spring默认使用的是JDK动态代理它要求目标类必须实现接口代理对象和业务对象实现同一个接口在调用时由代理拦截。如果你的类没有实现任何接口Spring会退回到CGLIB代理通过继承目标类来生成子类代理。这两种模式都会影响事务注解的生效范围比如JDK动态代理下只有接口里声明的方法被调用才会经过代理而CGLIB代理下protected方法也被纳入增强范围但private方法依然不行。这个细节后面谈事务失效时会用到。Spring的事务管理还涉及一个重要组件事务管理器。在单体应用中最常见的是DataSourceTransactionManager它内部持有一个DataSource通过DataSourceUtils获取当前线程绑定的数据库连接从而控制连接的提交、回滚和释放。换句话说Transactional不直接操作数据库而是协调底层的事务管理器完成工作。因此如果你的项目里没有正确配置事务管理器或者配置了多个数据源而没有指定走哪个管理器注解同样可能“静默失效”。2. 回滚机制详解默认行为往往就是埋坑的地方关于回滚Spring的官方文档只写了一句话当方法抛出RuntimeException或Error时事务才会回滚。也就是说默认情况下受检异常Checked Exception不会触发回滚。这个设计初看有些反直觉但结合Java的异常体系仔细一想Spring团队确实有他们的考量受检异常通常表示业务可预期的状态比如“库存不足”“余额不够”这类异常往往需要业务层自己处理或转为特定返回结果而不是让底层数据全部撤销非受检异常则代表程序性错误或不可预期的状态如空指针、数组越界、SQL异常这类情况数据已经处于不安全状态必须回滚。实际项目里默认行为造成的坑远不止“受检异常不回滚”这么简单。我见过一个真实案例同事在事务方法里用try-catch捕获了所有异常然后在catch块里打印日志并重新抛出一个自定义的受检异常BizException方法上没有指定rollbackFor结果数据库写入操作全部生效了。排查了很久最后看了一下注解配置才发现问题出在rollbackFor没有设置。这个案例很有代表性——异常被捕获又重新包装后Spring的代理根本感知不到底层的异常类型自然谈不上回滚。这引申出一个非常重要的实操准则事务方法内的异常处理要么不捕获直接抛出去要么捕获后手动标记回滚比如调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。后一种做法适合某些需要吞掉异常但仍然回滚的场景比如做数据补偿时你不想让异常中断主流程又必须保证前面的写操作不落库。关于rollbackFor的配置策略我个人踩过几次坑之后形成了这样的习惯凡是方法有数据库写操作且预期可能会抛出自定义业务异常就一定显式声明Transactional(rollbackFor Exception.class)。这样无论受检异常还是非受检异常统统回滚简单粗暴不会漏。如果不希望某些异常触发回滚比如某个第三方接口校验失败但数据插入不影响那么可以在catch中吞掉异常并返回失败标记而不是依赖noRollbackFor这种偏门配置——后者维护成本高团队其他人看着也容易懵。这里还要提一句noRollbackFor的使用场景。它确实存在比如你希望“余额不足”这种业务异常不影响整个事务但“数据库连接断开”这种系统级异常依然回滚。不过绝大多数业务场景我觉得用rollbackFor Exception.class再加合理的异常处理就够了特殊规则反而会让代码的可读性下降。3. 传播行为选择七种传播级别背后的取舍传播行为Propagation是Spring事务设计中最值得深挖的一个维度。它解决的问题是当一个事务方法调用另一个事务方法时这两个方法的事务边界如何合并。默认的是REQUIRED含义是如果当前存在事务则加入不存在则新建。这是使用频率最高的配置大多数业务场景都适用比如下单接口里的库存扣减和订单创建就应该在同一个事务里完成。但实际业务经常出现需要拆分的场景。我举几个典型例子第一个是监控日志记录。业务主流程的事务方法A中可能需要调用一个方法B去记录操作日志。如果B也采用REQUIRED那么B一旦被A的事务裹挟A回滚B的结果也会被撤销——日志丢了排查问题的时候全靠它呢。这种情况就应该给B配置REQUIRES_NEW让B挂起当前事务另起一个独立事务执行提交不受A的回滚影响。第二个是异步任务、消息发送、外部接口调用。这类操作往往要求“尽最大努力通知”而不是与主事务强一致。如果它们被放进主事务里主事务长时间不提交连接一直被占用外部系统感知不到数据变化还可能因为事务隔离级别导致读到的是旧数据。用REQUIRES_NEW或者NOT_SUPPORTED把它们的连接解除业务上更合理。第三个是NESTED嵌套事务。它和REQUIRES_NEW的关键区别在于嵌套事务相当于在事务里设置一个保存点Savepoint内层回滚只会回滚到保存点位置不影响外层已经执行的操作而REQUIRES_NEW完全挂起外层事务内层是一个独立的新事务。这个差异非常实用。比如一个批量导入功能外层事务遍历多条数据每条数据处理失败时就回滚当前一条但不影响前面已经处理成功的数据。用REQUIRES_NEW做不到这个因为内层事务一旦提交外层事务回滚不到它用NESTED则可以灵活控制。不过要注意NESTED在部分数据库和部分连接池配置下表现不稳定如果项目数据库是Oracle或者SQL Server行为可能与MySQL有细微差别。我在项目里用NESTED时还专门写了单元测试验证回滚边界因为这个特性太依赖于底层数据库的保存点支持了。另外几个传播级别相对少用但也简单说下MANDATORY要求当前必须存在事务否则报错适合某些只能作为子事务执行的内部逻辑SUPPORTS表示有无事务都可以NOT_SUPPORTED表示以非事务方式执行且挂起已有事务NEVER则表示必须以非事务方式执行若存在事务直接抛异常。这些都看具体业务用得好是利器用错了就是事故现场。这里有必要提醒大家一个经典的坑REQUIRES_NEW不是万能的它会导致两个事务并发操作同一数据时出现隔离性问题。比如主事务先更新了订单状态新事务再去查这个订单如果隔离级别是默认的READ_COMMITTED新事务看不到主事务尚未提交的修改可能读到旧状态。这时候需要仔细设计事务边界甚至考虑引入分布式事务或消息队列来解耦而不是把传播级别当成事务乱局的万能药。4. 实战核心事务失效与误回滚的高频场景排查下面这部分我把实际开发中频率最高的几个事务问题集中整理一下每一个都是我真实遇到并排查过的给出的排查思路也尽量具体到步骤。先看一张框架性的速查表方便你在报错或出现数据异常时快速定位。异常现象可能原因排查重点方法内异常了但数据已经写入异常被捕获后吞掉或异常类型不在rollback范围内检查catch块逻辑检查rollbackFor配置同一类内this调用事务不生效自调用绕过了代理对象改用注入自身代理或拆分到不同Bean数据库操作全部生效完全不回滚事务注解加在了private或protected方法上检查方法访问修饰符改为public抛出UnexpectedRollbackException内层事务标记rollbackOnly外层事务仍尝试提交检查嵌套事务的传播级别和异常处理多数据源下事务失效未指定事务管理器或管理器连接了错误数据源检查Transactional(transactionManager ...)配置4.1 自调用导致的事务失效这是面试必问但实际开发中最容易犯的低级错误。OrderService类里有一个方法createOrder它调用了同类里的另一个方法updateStock。两个方法都加了Transactional按道理都该有事务保护。但实际运行时会发现updateStock的注解完全不生效里面的操作如果抛了异常前面的订单数据照样被回滚——等等这里我说反了更准确的情况是createOrder的事务起了作用但updateStock的事务被忽略了它只是以普通代码方式嵌入到createOrder的事务里。如果updateStock自己捕获了异常并返回那createOrder完全感知不到数据就不会回滚了。问题根源在于Spring AOP的代理机制。外部调用createOrder时进入的是代理对象事务开始但createOrder内部调用updateStock时使用的是this关键字也就是原始业务对象而不是代理对象因此代理无法拦截这次调用updateStock上的事务配置自然被绕过。解决方案有三种。第一把updateStock拆到另一个Service类里通过注入的Bean来调用。第二在当前Service里注入ApplicationContext用context.getBean(OrderService.class)获取代理对象后再调用。第三使用AopContext.currentProxy()获取当前代理但需要配置aopConfig.exposeProxy true才生效。我在项目里的习惯是第一种最优先因为拆类本身也是职责清晰的好设计如果方法确实内聚在同一个类里就用注入自身的方式解决这种方式在Spring Boot 2.x之后自动支持非常方便。4.2 异常被捕获后的误回滚/不回滚很多同事不理解为什么事务方法里不要随便写try-catch。这里的关键在于Spring的AOP拦截逻辑是在代理方法里执行invoke业务方法抛出异常后代理会判断异常类型来决定是否回滚。如果你在业务方法内部捕获了异常异常根本不会传到代理层代理只会看到一个正常的返回结果自然会提交事务。所以有两种选择。第一别捕获让异常一路向上抛给上层统一异常处理器。第二像前面说的捕获后手动setRollbackOnly()。但这里还有一个微妙的坑即使你调用了setRollbackOnly()事务也只在当前方法提交阶段检查标记如果方法正常返回代理会尝试提交提交时发现标记为回滚就会抛出UnexpectedRollbackException。这个异常默认也是一个RuntimeException如果没有统一处理会传给调用方造成调用链上莫名其妙的报错。所以手动标记回滚的同时最好在catch块里也抛出一个明确的业务异常或者直接返回失败结果并让上层感知。4.3 UnexpectedRollbackException的成因与解决热词里出现了unexpectedrollbackexception: transaction rolled back because it has been mar说明很多人在实际中遇到过这个异常。它的全名是UnexpectedRollbackException翻译过来就是“意外的回滚异常”。这个异常的触发逻辑很有意思假设外层方法A使用REQUIRED内层方法B也用REQUIRED那么B加入A的事务。现在B内部抛出了异常并且这个异常没有被传递到A比如B里自己catch了但是B的事务参与方会被标记为rollback-only因为Spring会在底层将当前事务标记为只能回滚。此时A方法继续执行业务逻辑正常结束没有抛出任何异常于是代理尝试提交A的事务——结果发现事务已经被标记为rollback-only代理无法提交只能抛出UnexpectedRollbackException来通知调用方事务本该回滚却被提交了这不符合状态机。解决这个问题的核心是要么让内层异常正常抛出来让外层感知并处理要么内层使用REQUIRES_NEW开启独立事务这样它的回滚标记不会污染外层事务。另外还有一个办法内层方法异常后外层通过编程式事务或者边界处理把事务状态重置但比较麻烦。从实际代码维护角度看我优先建议调整异常传播路径让异常对内可见而不是让事务管理器在背后做“危险的标记”。4.4 事务方法非public导致不生效这是另一个高频低级错误。Transactional只能作用在public方法上如果你把它加在package-private或protected方法上Spring代理无法拦截注解被静默忽略。这个问题的危险之处在于它不会报错没有异常程序正常跑数据就是不回滚。排查时如果发现事务方法怎么调都正常优先检查方法的访问修饰符。4.5 多线程与事务不能忽略的一个魔鬼细节Transactional采用的是ThreadLocal绑定数据库连接确保一个事务内的多次数据库操作使用同一个连接。这意味着事务天然与当前线程绑定一旦你开启多个线程并行处理数据库操作每个线程拿到的连接是独立的各自有各自的事务边界。父线程的事务不会包住子线程的操作。如果子线程中抛了异常父线程的事务也不会感知到。我遇到过一个订单批量处理项目为了提升性能用了多线程并发扣减库存结果部分线程成功部分线程失败订单状态出现了不一致。后来改成了单线程顺序处理并利用批量SQL优化性能瓶颈才保证了强一致性。如果需要多线程并发且要全局一致那就得认真考虑分布式事务方案或者改造为最终一致性模型而不是寄希望于一个事务注解搞定一切。4.6 事务与连接池耗尽还有一个运维层面的坑值得单独提一下长事务。一个Transactional方法内如果包含远程调用外部HTTP请求、RPC调用、耗时IO操作会长时间占用数据库连接。在高并发下连接池很快被耗尽后续请求全部排队等待表现为接口响应极慢甚至超时。我处理过一个真实事故一个核心交易接口里调用了一个外部风控服务风控服务响应超时5秒导致数据库连接被占用5秒。并发上来后连接池40个连接全部占满所有新请求直接阻塞系统接口大面积超时。这类问题的解决思路是事务方法里不要做远程调用。远程调用尽量放在事务开启之前或事务提交之后或者通过异步消息队列解耦。如果确实需要在事务中进行那就要为第三方调用设置合理的超时时间并且考虑是否可以用独立事务配合REQUIRES_NEW把耗时操作剔除出去。5. 隔离级别与锁的概念事务边界外的另一层约束聊完传播行为和回滚机制不能回避隔离级别Isolation。这一块在面试题里出现的频率极高尤其是结合Transactional的isolation属性一起考察。数据库定义了四种标准隔离级别MySQL默认是REPEATABLE_READOracle默认是READ_COMMITTEDSpring的事务隔离级别是参考JDBC驱程序号定义的默认是DEFAULT表示使用底层数据库的默认隔离级别。这里我要强调一个经常被误解的知识点Transactional注解只控制事务启动时的隔离级别设置并不会在真正意义上改变数据库的全局配置。因为隔离级别最终是执行SET TRANSACTION ISOLATION LEVEL语句实现的所以一旦注解配置的级别与业务对一致性的要求不匹配就会出现脏读、不可重复读、幻读等问题。实际项目里如果只在一个数据库实例上运行通常在数据库层统一设置默认隔离级别Transactional里很少显式去配避免每次都要写一堆参数。但面试时可以讲清楚各个级别的含义和适用场景。刚提到一个很核心的点事务和数据库连接——Transactional在开启时从连接池拿一个连接整个事务期间所有SQL都走这个连接。所以在设计长事务之前务必想清楚这个连接占用多少时间、并发有多少、连接池是否扛得住。事实上很多线上事故的根因并非事务回滚逻辑错误而是长事务把连接拖垮导致整体可用性下降。从这个角度说事务注解不仅仅是业务正确性的工具也是系统稳定性的潜在风险点这在我前面的经验里已经有验证。关于readOnlytrue这个配置也值得多说两句。很多人以为设置了只读事务数据库就能自动拒绝写操作。其实它更像一个性能优化提示底层会调整连接的状态比如MySQL驱动可能设置session的tx_read_only连接池也可能走只读路由去从库但数据库并不保证一定禁止写入。所以不要把readOnly当成安全检查它只是告诉Spring和底层连接“这个事务没有写操作你可以做相关优化。”如果写操作混进来结果可能是在主库上正常写入没有任何报错但代码意图完全被弄混了。6. 分布式事务与最终一致性单体事务之外的延伸热词里有“分布式事务”“订单与库存分布式事务”“分布式事务一致性”等关键词说明很多读者已经在处理多服务的数据一致性问题了。这个话题很大这篇文章里我不展开所有内容只说一下从单体Transactional走向分布式时核心思路会发生什么变化。单体应用中一个数据库的连接就是事务的锚点Spring的声明式事务足够了。一旦服务拆分成多个模块订单服务管订单库库存服务管库存库两个库之间无法用同一个事务管理器控制Transactional就失效了。于是出现了一批分布式事务方案从经典的2PC两阶段提交到TCCTry-Confirm-Cancel再到基于消息队列的最终一致性模型各有各的适用场景。我个人的实践体会是在绝大多数业务场景最终一致性比强一致性更实用。比如下单后扣减库存如果库存服务和订单服务分离与其让两个库同时提交强一致不如在订单创建成功后发送一条可靠消息库存服务消费消息后扣减库存。如果库存不够通过定时对账或者事务消息的“回查”机制做补偿。这种方案避免了2PC中协调者单点、资源锁定时间长、性能损耗大的问题也更符合微服务架构的设计理念。当然也有必须强一致的场景比如账户余额转账。这个时候可以考虑TCC或者使用Seata AT模式的全局事务。但无论是哪种方案都建立在单体事务基础之上先把Transactional用明白再谈分布式方案进阶路的顺序才合理。7. 异常处理与事务的最终闭环关于异常框架的选择我见过很多团队直接用默认的RuntimeException来标记所有失败导致代码可读性很差。更合理的做法是定义统一的业务异常体系。比如项目里常用BizException继承RuntimeException构造时可以携带错误码和错误信息。然后在Transactional(rollbackFor Exception.class)的保护下所有BizException及其他RuntimeException都能回滚。这里有个代码细节值得拿出来讲如果事务方法里既有数据库写操作又有外部系统调用外部调用失败会导致数据库回滚但外部系统里可能已经产生了副作用也就是著名的“分布式事务的本地事务与远程事务的边界问题”。比如你先调了短信服务并成功发送然后数据库操作失败回滚用户收到一条短信但业务数据没有生成这就是数据与通知的不一致。从这个角度出发事务方法内的外部调用顺序必须在设计时想清楚通常建议把返回值作为事务提交成功后才能执行的通知逻辑转移出事务边界就是前面说的用TransactionSynchronizationManager.registerSynchronization注册事务提交后的回调或者在事务外重新查询状态后再发送。TransactionSynchronizationManager这个类一定要知道。它可以在事务提交后、回滚后等不同阶段执行回调逻辑。比如你在事务里更新了订单状态希望事务成功提交后再发送WebSocket通知给用户就可以注册一个afterCommit回调这样即使通知发送失败也不会污染主事务的正确性。这个工具的灵活度比把一切逻辑塞进事务方法里要高得多。8. 排查事务问题时我用的三板斧最后分享我排查事务问题的固定套路。遇到“数据没回滚”或者“莫名其妙回滚了一大片”的情况我一般按这个顺序排查第一步确认注解配置是否完整。检查方法是不是public类上有没有其他AOP切面干扰注解是否被自定义的重复注解覆盖事务管理器是否指向了正确的数据源。第二步确认异常传播路径。在事务方法入口和出口打日志方法级日志包含UUID追踪号重点观察异常抛出时是否有catch把异常吞掉异常是不是在转换成BizException时脱离了rollbackFor的控制范围。第三步确认数据库真实性能与连接状况。用SELECT * FROM information_schema.INNODB_TRX\G查看当前活跃事务结合Druid或HikariCP的连接池监控观察连接占用情况。若发现长事务配合方法耗时分析定位到具体业务逻辑。这个方法看起来简单但效率极高。很多同事查一天查不出来的问题我往往通过这三步就能定位到七七八八。尤其在自调用、异常被捕获这两个“静默失效”场景里前两步几乎能直接命中问题。最后说点实际体会这几年见过太多项目在事务处理上栽跟头有一大半其实不是技术难而是对Transactional的边界理解不到位。它只是一个声明式工具真正控制数据一致性的依然是方法设计、异常传播路径和事务边界划分这三件事的组合。在我自己写代码时我会刻意让事务方法保持短小精简只包含必要的数据库操作外部交互一律拖到事务外如果有人提交的代码里事务方法超过几十行甚至包含远程调用我通常都会在code review时多问一句“这个事务真的需要覆盖这么多操作吗”。如果你现在正准备面试我建议把本文提到的几个场景——自调用失效、异常捕获导致的不可回滚、UnexpectedRollbackException的成因、传播级别与隔离级别的组合——都亲手写一遍测试代码验证。纸上谈兵永远体会不到实际运行结果的微妙之处。把这些坑真正踩过一遍你对Spring事务的理解就会上一个台阶。