Spring事务原理与失效场景全解析:从@Transactional到代理机制
1. 先别急着写注解搞清楚Spring事务到底在干嘛很多人在项目里用Transactional无非就是方法上加个注解然后跑起来发现要么没回滚、要么事务没生效、要么死锁各种莫名其妙的问题一股脑全冒出来。说实话用了好几年的Spring真正能把事务讲清楚的人真不多大部分人对Transactional的理解停留在“加了注解就有事务”的层面。今天我用一整篇的篇幅把Spring事务这点事从头到尾捋一遍包括它的底层原理、传播机制、隔离级别、失效场景以及我个人在项目里踩坑之后总结出来的排查思路。内容偏实战建议你找个安静的时间从头看到尾。先立个基础概念。事务Transaction的本质是一组操作要么全部成功、要么全部失败它要满足ACID四个性质原子性、一致性、隔离性、持久性。Spring本身并没有实现事务它做的是把不同的事务管理器比如JDBC的DataSourceTransactionManager、JPA的JpaTransactionManager抽象成统一的PlatformTransactionManager接口然后通过Transactional注解帮我们在目标方法前后自动完成开启事务、提交、回滚这些操作。听起来很美好对吧但问题恰恰出在这层“自动”上。你只是写了一个注解真正干活的是Spring容器创建的代理对象。方法的调用会被拦截器处理拦截器决定事务怎么开、什么时候提交、什么时候回滚。如果你不理解这层机制后面所有的“为什么我加了注解事务却没生效”你都找不到答案。很多人以为Transactional是Spring Boot时代才有的东西实际上它是Spring Framework从2.x版本就开始提供的核心能力。从早期的XML配置tx:annotation-driven/到后来的EnableTransactionManagement再到Spring Boot的自动装配底层的原理一直没有变过基于AOP的动态代理。搞懂了代理你就搞懂了Transactional的一大半。这里先记住一个结论Transactional是由代理对象来触发事务逻辑的而不是你写的那个目标对象本身。这句话我会在后面反复提到因为几乎所有事务失效的案例都跟它有关。2. 传播行为与隔离级别七个传播行为背后的真实应用场景2.1 七种传播行为逐个拆解Transactional注解最容易被忽略的两个属性一个是propagation传播行为一个是isolation隔离级别。先说传播行为。Spring定义了7种传播行为很多新手一看到表格就头大其实工作中用得到的翻来覆去就那么两三种。REQUIRED是默认值。它的意思是如果当前已经存在事务就直接加入到这个事务里如果当前没有事务就新建一个事务。这是使用频率最高的传播行为适合绝大部分场景。举个例子你的服务层调了userService.createUser()里面又调orderService.createOrder()两个方法都是REQUIRED那它们就在同一个事务里任何一个方法抛出异常整个事务回滚谁的数据都不会落库。REQUIRES_NEW是我在项目中第二常用的。它的逻辑是不管当前有没有事务都强制开启一个新事务并且把当前事务挂起。为什么要这么干最常见的场景是记录日志。假设你的业务方法要更新一批数据如果失败了要回滚但你又想把“这次操作失败了”这个日志记录下来这时候日志写入就不能放在业务事务里否则业务回滚的时候日志也跟着没了。你需要一个独立的新事务去把日志写进去REQUIRES_NEW就是干这个的。NESTED跟REQUIRES_NEW有本质区别。NESTED基于数据库的保存点Savepoint机制它并不是真正独立的事务而是当前事务里的一个嵌套子事务。子事务回滚了只回滚到保存点位置父事务可以继续提交但是父事务回滚了整个子事务也会全部回滚。这里要注意NESTED能否生效取决于事务管理器是否支持保存点比如JDBC的DataSourceTransactionManager是支持的但JPA的JpaTransactionManager就不支持。剩下的SUPPORTS、NOT_SUPPORTED、MANDATORY、NEVER工作中见得很少。SUPPORTS是有事务就加入、没事务就以非事务方式运行NOT_SUPPORTED是如果有事务就先挂起以非事务方式执行MANDATORY是必须在一个已有事务中执行否则直接抛异常NEVER是必须在非事务环境下执行否则抛异常。这四种我实际项目里基本没用过但面试的时候可能会被问到你能说出它们的区别就行。2.2 隔离级别到底怎么选隔离级别解决的是并发事务下数据一致性的问题。SQL标准定义了四个级别从低到高分别是READ_UNCOMMITTED、READ_COMMITTED、REPEATABLE_READ、SERIALIZABLE。它们对应的副作用依次是脏读、不可重复读、幻读。在Spring里你通过Transactional(isolation Isolation.XXX)来指定但有一个很重要的点你必须清楚事务隔离级别本质上是由底层数据库支持的Spring只是帮你把请求发给数据库数据库不支持的话Spring再指定也没用。比如MySQL默认是REPEATABLE_READOracle默认是READ_COMMITTED各有各的设计考量。脏读指的是一个事务读到了另一个事务未提交的数据。这个最危险因为那个事务后面可能回滚你读到的话就相当于读到了一份“不存在”的数据。READ_UNCOMMITTED允许脏读所以生产环境基本没人用。不可重复读是在同一个事务里两次读同一条记录发现值不一样。原因是另一个事务对这行数据做了修改并提交了。READ_COMMITTED可以避免脏读但避免不了不可重复读——Oracle默认就是这一档所以在Oracle下你写代码要特别注意如果业务要求事务内多次查询结果必须一致你得手动加锁或者换隔离级别。幻读指同一个事务里两次执行相同的范围查询第二次却多出了一些行。这通常是另一个事务“插入”了新数据导致的。REPEATABLE_READ在MySQL下通过MVCC和间隙锁能一定程度上避免幻读但不是在所有数据库上都能做到。SERIALIZABLE把并发度降到最低读读都要排队性能最差不到万不得已别用。我在实际项目中的经验是绝大概率你不需要自己指定隔离级别直接用数据库默认值就是最合适的。随意调高隔离级别会显著降低并发能力你可能没踩到脏读的坑反而先踩到性能瓶颈的坑。隔离级别脏读不可重复读幻读MySQL默认READ_UNCOMMITTED可能可能可能否READ_COMMITTED不可能可能可能否REPEATABLE_READ不可能不可能可能InnoDB基本避免是SERIALIZABLE不可能不可能不可能否2.3 超时设置一个容易被忽视的坑Transactional(timeout 5)表示事务允许的最长执行时间默认单位是秒。如果超过了这个时间事务会被强制回滚。这个属性在长事务、大批量数据处理的场景下很关键可以用来防止某个慢SQL拖垮整个数据库连接池。但是这里有个坑超时时间的计算起点是事务开启的时间而不是你调用某个方法的时间。如果你的方法在进入事务之前已经执行了一些操作比如做复杂的参数校验再去调用一个带Transactional的方法那超时计算是从后续事务一开始就计时的前后容易产生误解。另外不同的事务管理器对超时的支持程度不一样DataSourceTransactionManager是支持的但某些情况下如果你用的是ORM框架它底层的超时处理不一定能精准控制到你预期的那条SQL上。3. 事务失效的八大经典场景与排查实录3.1 自调用最经典也最容易踩的坑这是我在代码评审里看到次数最多的问题。一个类里面的方法A调用了方法B两个都加了Transactional结果B的事务压根就没生效。原因就是我开头说的代理机制外部调用bean.methodA()时实际调用的是Spring生成的代理对象代理会开启事务但methodA内部调用methodB时是在当前对象this上直接调用的根本没有经过代理所以methodB上的Transactional形同虚设。要解决这个问题常规手段有三种。第一种把methodB拆到另一个Service类里通过注入的方式调用这样可以保证走代理。第二种在类内部通过ApplicationContext.getBean(类名.class)拿到代理对象再调用methodB。第三种在类里注入自己Autowired private UserService self;然后self.methodB()。我个人最推荐第一种职责边界清晰也便于后续扩展。注意在Spring Boot 2.6.x之后如果你的工程开启了spring.main.allow-circular-referencesfalse默认就是false在类里注入自己这种方式要小心循环依赖的问题。虽然自己注入自己不构成循环依赖但为了保险起见优先考虑拆类方案。3.2 方法修饰符问题private和protected挂了Transactional加在private方法上会失效加在protected方法上很多情况下也会失效。原因还是代理。Spring的默认代理方式是CGLIB它生成的是目标类的子类因此只能对public方法做增强。虽然更高的Spring版本对非public方法在某些条件下也能处理但我不建议你依赖这种特殊情况。统一把事务方法设计成public这是最稳妥的习惯。3.3 异常被catch了还能回滚吗这是一个高频问题。你写了事务方法Transactional public void createOrder() { try { orderDao.insert(); throw new RuntimeException(库存不足); } catch (Exception e) { log.error(订单创建失败, e); } }你以为它会回滚但实际上订单照样插进去。为什么因为Spring判定是否回滚看的不是你有没有抛异常而是异常有没有从目标方法传播到事务拦截器那一层。你把异常catch在自己的方法里拦截器根本感知不到异常它以为方法正常执行完了自然就提交了。正确的做法有两种要么把异常重新抛出去要么手动调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()来标记当前事务需要回滚。第二种方式适合你确实需要捕获异常做一些逻辑处理比如记录日志但又希望后续事务回滚的场景。我个人更倾向于“把事务控制在正确的方法边界”的做法——异常该抛就抛让外层统一处理。3.4 非RuntimeException的默认行为Transactional默认只对RuntimeException和Error触发回滚如果你抛的是一个Exception子类的自定义异常比如业务异常默认情况下事务是不会回滚的。这大概是所有失效场景里最反直觉的一个。很多人写代码Transactional public void updateConfig() throws Exception { throw new Exception(配置更新失败); }然后发现配置照样改了一脸懵。解决方案就很直接在注解里加上rollbackFor明确指定要回滚的异常类型。我项目里一般会定义一个全局的业务异常基类然后在Transactional(rollbackFor Exception.class)上做统一配置这样无论是受检异常还是运行时异常都能正确触发回滚。Transactional(rollbackFor Exception.class) public void updateConfig() throws Exception { // 业务逻辑 }rollbackFor同样可以指定多个异常类型rollbackFor {BizException.class, IllegalStateException.class}。如果你有特殊的“某些异常不回滚”的需求还可以用noRollbackFor来指定。3.5 代理方式不对JDK动态代理和CGLIB的区别Spring的事务默认优先使用JDK动态代理但它有个限制只能代理接口。如果你的Service类没有实现任何接口那Spring就会用CGLIB去生成代理子类。多数情况下框架会自动选择合适的方案但如果你依赖了接口注入或者某个类恰好实现了接口、事务方法声明在实现类上这里就容易出幺蛾子。一个很典型的场景UserService接口定义了方法UserServiceImpl实现了它Transactional写在实现类上外部调用方是通过UserService接口注入的。这种情况下JDK动态代理是正常的因为调用会走到代理对象。但如果内部有一个方法没有在接口里定义而是实现类独有并且这个方法上加了Transactional从外部用接口调用时这个事务就失效了——因为代理对象根本不知道有这个接口方法外的增强方法。解决方法是简单粗暴的针对事务方法所在的类统一用CGLIB代理或者确保你的事务方法都在接口方法覆盖范围内。Spring Boot下你可以设置spring.aop.proxy-target-classtrue来强制使用CGLIB。3.6 多线程环境下的“伪事务”这是一个隐蔽的坑。你在一个Transactional方法里开了个新线程去执行数据操作Transactional public void batchProcess() { new Thread(() - { dataDao.update(...); // 希望跟主线程在同一个事务里 }).start(); }结果显然是不行的。Spring的事务边界是以“线程”为维度的事务连接是绑定在当前线程上的新开的线程拿不到你主线程里的那个数据库连接也不会加入当前事务。你在新线程里执行的SQL要么走自己的独立事务很可能还是自动提交的要么压根没有事务。这个问题一旦出现在生产环境排查起来非常费劲因为日志里完全没有报错看起来一切正常。我的建议是事务方法里不要开多线程去操作数据库。如果确实需要并行处理把并行拆成独立的服务调用各自管理自己的事务然后在主流程里做最终一致性对账。3.7 类没有被Spring容器管理这就更基础了。Transactional能起作用的前提是该类必须被Spring容器管理也就是你得加上Service、Component之类的注解或者通过XML/JavaConfig显式注册。如果你只是new了一个对象出来那Spring根本不知道这个对象的存在注解自然完全不生效。这类问题在单体应用里出现得少但如果你写一些工具类、封装类的时候容易犯这个毛病。3.8 数据库引擎不支持事务最后一个是环境层面的问题。比如MySQL的MyISAM引擎不支持事务表建出来后加了Transactional也回滚不了。我这个场景遇到得不多因为现代项目大多用InnoDB但如果你接手老系统发现某些表是MyISAM而业务代码里又写了完整的事务管理逻辑那就会出现“事务完全不生效但代码毫无问题”的奇怪现象。排查方法很简单SHOW TABLE STATUS WHERE Name your_table;查看Engine字段即可。4. 常见问题与排查技巧实录4.1 一次真实的回滚失效排查过程有一年我排查过一个线上问题。场景是这样的用户批量导入数据导入过程中有一条数据校验失败需要整个批次回滚但实际表现是部分成功部分失败数据不一致。刚开始代码走查没发现问题——事务注解、异常抛出、传播行为配置看着都对。后来我在关键位置加了日志发现事务拦截器根本没进入回滚分支。进一步排查才发现代码里用了一个封装过的DBUtils工具类去执行SQL而这个工具类内部自己建立了新的数据库连接绕过了Spring管理的事务连接。也就是说你的Transactional管的是Spring数据源里拿出来的那个连接但你的工具类用的连接并不是同一个。这个问题本质上属于“事务边界外操作数据库”但伪装得极其隐蔽因为日志里看不到任何明显的报错。那次过后我在项目规范里加了一条不允许在事务方法中直接使用自己创建的数据库连接所有数据访问必须走Spring管理的Bean。那次排查的另一个收获是我们引入了一个简单的自定义注解用AOP切面把所有Transactional方法统一记录“是否进入了事务代理”的日志方便后续排查类似问题。4.2 排查事务问题的四步方法论如果你遇到一个问题还没法定位我建议你按下面的顺序来排查能省下大量时间。第一步确认执行事务方法的对象是不是Spring代理对象。最简单的办法是在方法里注入一个AopContext.currentProxy()看看或者加日志打印当前对象类型或者直接打断点观察方法的进入堆栈里有没有CglibAopProxy相关的调用。第二步确认你抛的异常是不是被内部catch掉了。去代码里全局搜一下try {和catch(Exception e)重点看事务方法内部有没有吞异常的逻辑。第三步检查异常类型是否满足回滚条件看是RuntimeException还是检查异常确认有没有配rollbackFor。第四步确认事务管理器和数据源是不是同一个。在Spring Boot里用多数据源的时候这是最高频的问题——你的事务注解标在A数据源服务上但实际操作的Mapper连的是B数据源那事务管的就是A连接B上的操作自然不受控制。4.3 一个高频问题速查表症状大概率原因快速验证方式方法抛异常但没有回滚异常被catch吞掉在catch块里加日志确认异常是否被捕获控制台没有事务日志方法没有被代理查看方法调用堆栈是否经过拦截器外抛受检异常不回滚默认只回滚RuntimeException检查注解上有没有rollbackFor事务方法内部调用方法失效自调用绕过了代理拆类或注入代理对象多线程操作不同步回滚事务绑定在单个线程上避免在事务方法内开线程查询出现脏数据隔离级别太低查看数据库默认隔离级别事务貌似生效但偶尔失败多数据源导致事务管理器不匹配检查事务管理器绑定的数据源5. 实操中的配置细节与优化心得5.1 事务管理器选型别忽略多数据源场景Spring Boot集成事务大部分情况下是零配置的只需要在启动类或配置类上确保没有禁用事务管理自动配置就行。但如果你项目里有多个数据源情况就变得复杂了。Spring Boot默认会创建一个DataSourceTransactionManager绑定的是主数据源。如果你的业务代码里有多个数据源而事务管理器没有指定具体绑定哪个数据源很可能出现事务管了A却管不了B的割裂状态。我自己处理过多数据源项目一般做法是分数据源配置各自的事务管理器然后在service层用Transactional(transactionManager xxxTransactionManager)来明确指定。这里容易踩的坑Spring Boot的自动配置只创建了唯一的事务管理器如果你手动声明了第二个主事务管理器就不一定是你期望的那个了。需要你把其中一个标记为Primary避免注入冲突。5.2 大事务拆解事务不是越大越好Transactional用起来很爽但很多团队把事务弄得过大一个方法里执行几百上千条SQL导致长事务锁表时间过长并发量直接被拖垮。我在优化过一个慢接口之后把事务方法的代码从200行精简到40行性能提升立竿见影。优化的思路大致有三条。第一把不需要事务的纯查询操作移出事务方法减少锁持有时间。第二把数据库批量操作用批量接口一次性提交少让事务管理器做无谓的停留。第三如果业务允许异步化非核心链路用消息队列或者定时任务去最终一致性补偿。这里必须提醒事务拆小了之后原来“一个事务裹住所有操作”的原子性就没了。你要想清楚业务能够接受的中间态是什么引入确认机制来兜底不然拆完事务可能带来更头疼的数据不一致问题。5.3 嵌套事务和保存点别乱用REQUIRES_NEW很多团队一遇到“内部方法要回滚但不影响外部事务”的需求第一反应就是REQUIRES_NEW。但REQUIRES_NEW会把当前事务整个挂起再开一个新事务这是有代价的——它释放了当前事务持有的数据库锁吗不会全部释放而且会额外占用一个数据库连接。在高并发场景下大量的REQUIRES_NEW会导致数据库连接池被快速耗尽。如果你的需求是“内部方法失败只回滚内部的部分”大多数情况下应该考虑NESTED而不是REQUIRES_NEW。NESTED用保存点实现只在当前事务里打一个标记内部回滚后外部事务还能继续。代价更低。但是别忘了NESTED依赖数据库连接是否支持保存点在JPA环境下不可用这点在选型时要提前确认。5.4 回滚策略的最佳实践关于回滚策略我总结出一个相对稳妥的模板代码结构大概是这样的Service public class TradeServiceImpl implements TradeService { Transactional(rollbackFor Exception.class, timeout 10) Override public void createTrade(TradeDTO dto) { // 1. 参数校验校验失败直接抛业务异常 // 2. 核心写操作 // 3. 记录操作日志如有需要则用独立事务 } }几个细节说一下。rollbackFor Exception.class是一种兜底手段不要指望靠它来掩盖异常设计不合理的问题。timeout要根据业务耗时设置合理阈值太大没意义太小会导致正常业务也被误杀。还有一点Transactional建议放在接口实现类上或者实现类的方法上而不是接口的方法上。因为使用JDK动态代理时注解在接口上虽然也能被识别但有些框架的AOP解析对接口注解支持不够完善放在实现类上最保险。5.5 一个跟JPA相关的特殊提醒如果你用的是JPA/Hibernate而不是MyBatis事务回滚的粒度问题会更复杂一点。JPA的持久化上下文Persistence Context会在事务中维护一套实体的状态当你修改了一个托管实体后事务提交前Hibernate会做脏检查自动把变更flush到数据库。这种机制下事务边界内实体的状态变更非常容易被隐式持久化哪怕你没有显式调用save方法。很多人用JPA时发现事务回滚了但某些字段还是变了问题就出在flush时间点上。我遇到过项目里因为JPA实体在事务中被修改然后方法抛了异常事务回滚了但实体状态仍然被保留到下一次请求导致缓冲区脏数据。解决方式是尽量在事务方法里避免修改托管实体或者用DTO交互不让实体跨事务边界传递。这个细节很多人不关注但一旦发生数据错乱排查成本极高。6. 写在最后的一些实际体会从我多年的开发和踩坑经历来看Transactional确实是一个“入门容易精通难”的注解。它给你提供了极大的便利但同时也隐藏了非常多的实现细节。年轻人刚进项目时容易犯的毛病是——只要方法里写了数据库操作就无脑加Transactional结果事务粒度失控、数据库连接被长时间占用、并发能力下降。而真正有经验的开发者会把事务当作一个需要精心设计的资源来管理明确它的边界、明确它的传播方式、明确它是否和数据库的隔离级别匹配。我再分享一个务实的小建议如果你所在的项目对数据一致性要求很高可以考虑把Transactional的管理规范写到团队的开发文档里包括哪些场景必须用、哪些场景禁止用、事务方法边界怎么划分、嵌套事务的代价是什么。团队里只要有人在这些细节上踩过一次坑就值得把这个案例固化下来。我个人的体会是理解Transactional的关键始终是那一条主线——Spring事务是基于代理实现的事务的开启、提交、回滚全部由代理对象控制。你只要在脑子里面把这根弦绷住大部分问题你都能自己推出来答案。剩下的就是多动手、多复现、多测边界场景经验到位之后事务问题就不再是玄学了。