☰
Spring事务底层原理与失效场景排查:从代理机制到分布式事务
2026/10/11 6:35:21 网站建设 项目流程

每个做过Java后端的人,大概都经历过这样的场景:代码里加了一个@Transactional,以为数据就能妥妥地提交或回滚,结果一上线,数据乱了、消息重复了、连接池满了,最后查下来全是事务在“捣鬼”。Spring事务这东西,面试题里翻来覆去考,工作中也天天用,但真到了排查问题的时候,能一次说清楚的没几个人。这篇文章我从实际踩坑的角度,把Spring事务从底层原理到分布式场景的常见问题串一遍,尽量用大白话讲明白“为什么会出现这个问题,以及下次遇到该怎么处理”。

文章不只适合刚入行的同学看,也适合正在维护订单、库存、支付这类强一致性系统的朋友——你对事务的理解深度,往往决定了线上出问题之后的排查效率。

1. 先从底层看透Spring事务:它不是魔法,是代理

1.1 事务最原始的痛:连接、提交与回滚

要理解Spring事务,先得回到最原始的状态。数据库事务本质上就是对一组SQL操作的绑定,要么全部成功,要么全部回滚。在没有框架之前,我们写JDBC的时候是什么样?

Connection conn = dataSource.getConnection(); try { conn.setAutoCommit(false); // 执行多条SQL conn.commit(); } catch (Exception e) { conn.rollback(); throw e; } finally { conn.close(); }

这套代码看着简单,但一旦业务复杂起来,就全是问题:事务边界散落在业务代码里,耦合严重;一个方法里嵌套另一个方法,事务归属说不清;异常捕获之后忘记回滚,数据就残留了一半。所以Spring也好,其他框架也好,解决的核心痛点其实只有一个:把事务的开启、提交、回滚从业务代码里拆出去,让开发者只关心业务规则。

这就引出了Spring事务的基本架构。Spring本身不实现数据库事务,它做的事是统一管理事务的边界,底层还是交给数据库事务和具体的事务管理器。比如我们最常用的DataSourceTransactionManager,它内部做的事,其实就是帮我们拿到了Connection,设置autoCommit为false,在方法正常结束时提交事务,在抛出异常时执行回滚。

1.2 @Transactional本质上是AOP代理

很多人对@Transactional的误解,是从“注解标了就该生效”开始的。这个注解本身什么都不干,它能起作用,是因为Spring在启动时扫描到了它,然后为对应的Bean生成了一个代理对象。

这个过程可以这么理解:Spring容器里放着的,表面上是你写的Service对象,实际上一开始放进去的就是一个代理。这个代理像门卫一样,在你调用方法之前先检查一下:这个方法有没有事务需求?有的话先开启事务。方法执行后,再根据有没有异常决定提交还是回滚。

这个门卫就是Spring AOP里的事务拦截器,底层也就是TransactionInterceptor。它和目标方法的关系,决定了你对Spring事务的很多认知都得调整。比如,为什么同类内部调用事务会失效?因为内部调用是“this.method()”,this是原始对象,不是容器里的代理对象,门卫根本没拦住,事务自然就没了。

提示:判断一个Spring事务有没有生效,最直接的办法就是在方法里断点看当前对象是原始类还是代理类。如果看到的是类似于com.example.Service$$EnhancerBySpringCGLIB这类类名,说明代理已经生效了。

1.3 事务同步管理与MyBatis、JPA的微妙关系

另一个容易忽略的点,是事务同步。Spring的事务管理器不是简单地把事务挂到当前线程上,而是通过ThreadLocal把事务信息存到了当前线程里,具体就是TransactionSynchronizationManager这个类。MyBatis、JPA这些ORM框架在整合Spring之后,都会去检查当前线程有没有活跃事务,有的话就直接复用这个连接,这样SQL才能和事务处于同一个连接里。

这个机制用得好,能解决很多隐藏问题。比如,一个事务方法里如果通过多线程去操作数据库,子线程拿到的一定是新的连接,因为ThreadLocal不跨线程传递,父线程里的事务状态子线程感知不到。这就是很多人“Spring事务在多线程下失效”的根本原因。不是说事务失效了,是子线程本身就不在事务管理范围内。

理解了这个,再看那些“为什么@Transactional方法里异步处理不生效”“为什么事务里拿到的Connection和Mapper的不是同一个”之类的坑,基本都能自己推出来了。Spring事务本质上是把“连接管理”和“业务方法”绑在了同一个线程的生命周期上,一旦线程变了,连接变了,事务也就断了。

2. 事务传播行为和隔离级别:理解透了才能少踩坑

2.1 传播行为如何影响嵌套调用

事务传播行为是Spring事务面试里绕不开的话题,也是很多同学觉得“记住了定义但用不好”的部分。传播行为本质上解决的是:当一个事务方法调用另一个事务方法时,这两个方法的事务要怎么处理?是共用同一个事务,还是各开各的,或者直接以非事务方式执行?

Spring一共定义了7种传播行为,但实际开发中,我认为真正值得你重点理解的就三种:

第一种是REQUIRED,这也是默认值。含义是如果当前有事务,就直接加入当前事务;如果当前没有事务,就新建一个。这个模式最符合常规的业务直觉:一个大的服务方法里调了多个内部方法,大家同一个事务,一荣俱荣、一损俱损。绝大多数单服务内的数据库操作,都应该用它。

第二种是REQUIRES_NEW,含义是无论当前有没有事务,都挂起当前事务并新建一个独立事务。这个用得不多,但有一个典型场景:你需要记录操作日志,而日志的落库不能因为主业务失败就一起回滚。这种场景下,日志方法的传播行为设为REQUIRES_NEW,就能保证即便主事务回滚,日志也还是保留着的。

第三种是NESTED,它是一个带保存点(savepoint)的嵌套事务。如果当前有事务,NESTED并不是加入当前事务,而是在当前事务中创建一个嵌套子事务。内部方法抛出异常时,可以只回滚到嵌套事务之前的保存点,不影响外部事务继续提交。这个行为听着美好,但要注意它对底层数据库的支持有一定要求,MySQL下依赖SAVEPOINT,如果外层事务最终也回滚了,嵌套部分依然跟着回滚。

2.2 隔离级别:从脏读到幻读

隔离级别解决的是多个并发事务同时读写同一条数据时,彼此能看到什么的问题。标准SQL定义了四种隔离级别,从宽松到严格分别是:读未提交、读已提交、可重复读、串行化。每一种隔离级别都对应着一类并发问题是否可能出现。

脏读是指一个事务能读到别的事务还没有提交的数据。如果对方回滚了,你读到的数据就是脏的。读已提交等级下,这个问题就被解决掉了。

不可重复读是指同一事务里,两次读取同一条记录,得到的结果不一样。原因是别的并发事务在这期间修改并提交了这条记录。可重复读解决的就是这个问题,MySQL默认的REPEATABLE_READ能保证事务内多次读取相同记录,结果是一致的。

幻读比不可重复读更隐蔽一点。说的是同一事务里两次查询相同条件的结果集数量不一样,因为别的并发事务插入了几条新数据。MySQL在可重复读级别下,通过间隙锁等手段,在很大程度上避免了幻读,但严格来说,幻读这个概念的彻底解决需要串行化隔离级别。

Spring里面对隔离级别的配置,是通过事务注解的isolation属性来控制。比如:

@Transactional(isolation = Isolation.READ_COMMITTED) public void updateOrderStatus(Long orderId) { // 业务逻辑 }

这里给一个很直观的选型建议:绝大多数业务系统使用数据库的默认隔离级别就够了。MySQL默认REPEATABLE_READ,Oracle默认READ_COMMITTED,都是经过长时间验证的成熟选择。盲目调高隔离级别到串行化,会让数据库的并发能力断崖式下降,因为本质上它变成了串行执行。而调成读未提交,又会带来脏读风险,业务上基本没有场景需要这种激进级别。

先敲定概念,之后我会在日常运维里再谈事务日志、锁等待和慢SQL的关联。

2.3 隔离级别和事务日志的关联

热搜词里有一条消息让我印象很深:“数据库 'ais20221123194008' 的事务日志已满”。如果对事务底层机制不熟,看到这个错误第一反应往往是磁盘满了。实际上,事务日志满有两种常见原因:一是磁盘空间真的不足;二是数据库设置的是自动增长模式,但增长上限被卡死;三是日志的备份策略没做好,事务日志长期不清理,导致文件无限膨胀。

以SQL Server为例,事务日志记录了每个事务的所有修改细节,如果数据库处于简单恢复模式,日志在事务提交后会被清理或截断;如果处于完整恢复模式,就必须依赖定期备份日志来截断。日志文件一旦撑满,所有的新事务都无法写入,报错信息就是“事务日志已满”,但这并不等于所有老的日志文件都没用了,而是没有空间继续记录新操作。

排查思路很清晰:先看磁盘剩余空间,再查日志文件的当前大小、最大大小和自动增长配置。如果磁盘没问题,确认是不是日志无法自动增长导致的。同时也要检查是否有长事务长期保持着日志不释放——有时候一个“开启事务后长时间不提交”的操作,会让日志文件一直处于活跃状态,无法及时清理。

.Spring的事务开发里,这类问题也经常伴随着连接池配置一起出现。下面我讲连接池的时候会专门提到remove-abandoned这个参数的坑。

3. 事务失效场景排查手册与连接池实战

3.1 这些场景,@Transactional就是不生效

我几乎每年都能碰到一批“明明标了事务,却没有回滚”的代码。这里整理一下最常见的情况,你可以直接拿来做排查清单。

第一,同类内部方法调用。前面说过,这是代理机制带来的坑。内部调用走的是this指针,不是代理对象,事务拦截器压根没有登场机会。解决办法有几种:拆分成两个不同的Bean;注入自己(即通过容器获取代理对象);或者用AopContext.currentProxy()拿到当前代理。我比较推荐拆分Bean的方式,因为这样结构更清晰,也不引入Spring内部API的依赖。

第二,方法不是public的。@Transactional如果加在private、protected或者包可见方法上,Spring的AOP默认是不生效的。原因在于Spring AOP基于代理,而代理只能拦截被公开暴露的方法调用。这里建议的开发习惯是:事务注解不要再私有方法上用,哪怕你觉得“只有我自己调用,能越界到哪里去”也不行。

第三,业务异常被捕获了。很多事务回滚失败的代码长这样:

@Transactional public void createOrder(OrderDTO dto) { try { // 库存扣减 // 订单保存 } catch (Exception e) { log.error("订单创建失败", e); // 这里没有把异常继续抛出去 } }

事务拦截器要靠异常来决定是否回滚,你把异常吞掉了,它看到的信号就是“方法正常执行完”,于是提交事务。所以事务方法里要么捕获异常后重新抛出,要么干脆不捕获,让异常直接往上抛。这里有个面试题也常考的一个点:默认情况下,运行时异常和Error会触发回滚,受检异常不会触发回滚。这也就意味着你抛出一个IOException之类的受检异常,默认是不会导致事务回滚的,除非你在@Transactional里主动声明rollbackFor = Exception.class。

第四,数据库引擎不支持事务。MySQL下如果使用了MyISAM引擎,事务方法再合理,也是不会回滚的。排查的时候记得确认一遍表引擎,InnoDB才是支持事务的。

第五,多线程下的事务方法调用。Spring事务和线程绑定,子线程里调用的方法,即使加了@Transactional,也不会使用主线程的事务上下文。这个前面讲事务同步时提过了,真正需要并发一致性的时候,通常要考虑分布式锁和最终一致性方案,而不是指望事务跟着子线程走。

3.2 排查事务问题的三板斧

真到排查问题的时候,最忌讳瞎猜。我的流程一般是三步:先确认有没有代理,再确认异常有没有被吞,最后看事务同步管理器里当前线程有没有绑定事务。

第一步,确认代理。在事务方法的入口,把this.getClass()打出来,看看类名里有没有CGLIB或者JdkProxy之类的字样。没有的话,事务代理根本没生成,再往下查就没有意义了。

第二步,确认异常链路。事务方法内部有哪些地方catch了异常,catch之后有没有继续抛出。这里要特别小心一些框架底层的行为,比如Feign调用失败后,某些配置下异常类型可能被包装成别的受检异常,如果不指定rollbackFor,回滚就不会发生。

第三步,确认当前线程是否有事务上下文。可以通过TransactionSynchronizationManager.isActualTransactionActive()判断当前是否存在真实事务。这个方法在排查“我以为自己在事务里,其实没有”的场景时非常管用。

我建议把这些步骤沉淀成一个小工具方法,统一封装成日志输出,方便在测试环境直接验证。

3.3 Druid连接池与超时连接清理

Spring Boot项目里,Druid是很多人都会选择的连接池,但连接池配置里的细节很值得说道说道。热搜词里提到了remove-abandoned这个配置:

spring: datasource: druid: remove-abandoned: true # 设置连接超时回收时间,单位秒 remove-abandoned-timeout: 180 # 打开日志记录回收连接时的调用栈 log-abandoned: true

这个功能的设计初衷,是为了回收那些被业务代码遗留而没有正常归还的连接,防止连接池被耗尽。听起来挺好,但这里有几个使用上的坑。

第一,remove-abandoned是兜底方案,不是常规方案。如果业务代码里大量存在连接没关闭的问题,正确地做法是修复资源的生命周期管理,而不是靠连接池强行回收线程。因为连接池回收连接时,底层是在硬切一个正在使用的连接,如果那个连接正在执行SQL,很可能导致事务中断或数据异常。

第二,remove-abandoned-timeout的值不要设得太短。你又不知道什么时候业务代码就卡了一下,比如一次慢SQL执行了5分钟,如果超时回收时间设置为180秒,那么这条慢SQL会被连接池强制中断,结果往往是业务侧报错,数据库侧还得处理被中断的事务。

第三,log-abandoned建议打开。它可以打印出连接分配时的调用栈,对定位“这个连接到底是谁拿走了没归还”非常有价值。线上排查时配合慢SQL和线程栈,基本能锁定问题代码。

注意:别把remove-abandoned当成灵丹妙药。一个设计良好的系统,连接池的空闲等待时长、最小空闲连接数、最大连接数,都应该结合压测结果来配置,而不是全部交给“自动回收”。

我在实际项目里见过不止一次这样的场景:并发一上来,连接池被打满,业务报“waiting for connection”超时。很多人第一反应就是调大maxActive,结果数据库连接数量上去了,数据库压力又上来,最后进入了无限循环调参的怪圈。正确的做法是先把慢SQL、长事务、连接泄漏这些根因找出来,连接池参数只是配合手段。

4. 从单机事务到分布式事务:Spring Cloud与微服务场景

4.1 订单与库存的一致性问题

单机时代,一个订单事务里直接扣库存,改状态,全部都在同一个数据库事务里完成,操作简单,一致性强。但在微服务架构下,订单服务、库存服务、支付服务各自独立部署,还往往各自拥有独立数据库,原来的数据库本地事务就彻底覆盖不了跨服务的数据变更了。

举一个最经典的例子:用户下单成功后,订单服务在自己的数据库里写了一条订单数据,库存服务在另外的数据库里扣减库存。如果订单写入成功,但库存扣减失败,数据就处于不一致状态——用户以为下单成功了,但库存根本没扣。反过来,如果库存先扣了,订单没创建成功,库存又白白丢失了。

这就是分布式事务要解决的问题。要注意,它已经不是单个事务管理器能管的范畴了,因为你面对的至少是两个独立的数据源,甚至可能是不同的数据库类型。Spring本地事务管理到了这一步,能做的只是管理好“单个服务内的数据库事务”,跨服务的一致性需要引入额外的协调机制。

4.2 常见方案:Seata、TCC、事务消息怎么选

分布式事务的经典方案主要有几种:强一致类的,典型是两阶段提交(2PC/3PC)和基于其改造的TCC;最终一致类的,典型是本地消息表加分派、事务消息。Seata流行的原因,就是它把多种模式集成到了一个框架里,同时和Spring Cloud Alibaba生态深度融合,配置成本相对低。

我个人的观点是:不要把“分布式事务”这个词当作银弹。每引入一个分布式事务方案,都要付出性能、复杂度、运维三方面的代价。举个例子,Seata的AT模式通过全局事务ID和分支事务注册,把多个服务纳入一个全局事务里,事务边界明确,但全局事务期间的锁定和协调开销都不小。它的适用场景是核心交易链路、强一致性要求非常高、并发量又还在可控范围内的业务。

TCC(Try-Confirm-Cancel)是另一种更偏业务侵入式的方案。它要求你把每一个操作都拆成预留资源、确认操作、补偿操作三个阶段。优点是控制能力强,缺点是开发量非常大。我做过一次TCC改造,简单说一个下单操作,除了主流程要写,还要额外维护一套可补偿的接口,联调和测试成本直接翻倍。

事务消息走的是另一个思路,核心思想是“本地事务先行,再通过消息异步定最终结果”。典型做法就是RocketMQ里的事务消息机制:先发送一条半事务消息,消息在服务器端暂存;业务方执行本地事务,根据结果提交或回滚事务消息;消息消费者只有在事务消息被确认提交后,才能消费到它。

这个方案的经济性很强:它不需要额外的协调者容器,不需要维护TCC那套复杂状态机,业务方只需要把“本地操作”和“消息发送”绑定在同一个本地事务里。但代价是最终一致性和时效性之间存在窗口期。对于下单之后“过几分钟才真正扣库存”这种业务,是完全可以接受的。

4.3 选型判断:什么时候用强一致,什么时候用最终一致

我自己判断一个业务用不用分布式事务、用哪种分布式事务,通常会问三个问题:这个操作失败之后用户能感知到吗?感知之后能接受补偿吗?系统并发量大概在哪个量级?

比如,库存扣减这种失败后必须严格回滚的场景,优先考虑强一致方案。转支付、余额扣减同理。而用户下单成功后的短信通知、积分累计、订单状态异步更新这类操作,用事务消息配合消息重试就够了,强行引入强一致分布式事务只是给系统平添复杂度。

另外,不管选哪种方案,幂等性都是底线。分布式环境下,网络超时、消息重投、调用重试都是常态,业务处理端必须对同一个操作保持幂等。这里最常用的手段就是唯一业务单号和状态机判断:同一个订单号只允许成功入账一次。

最后,还有一个常被忽略的点:虽然服务拆了,但如果你能通过合理的表结构设计,把强一致要求高的业务放在同一个库里,用Spring本地事务处理,那仍然是性价比最高的方案。分布式事务不是设计目标,而是在不得不拆的情况下,用来兜住一致性底线的工具。

5. Spring AI与事务:新场景下最容易忽略的两个坑

5.1 别用事务包住远程调用

最近Spring AI相关的话题热度非常高,很多团队开始把大模型能力接入业务系统,比如智能客服、内容生成、代码评审。新框架带来了新便利,也带来了新问题。最常见的一个毛病就是:在事务方法里调用大模型接口,然后把AI的响应结果落库。

要理解这里面的风险,首先要明白远程调用的耗时特点。一个普通的数据库事务,哪怕业务复杂一点,也就是几十毫秒到几百毫秒的量级。但大模型接口的响应时间经常以秒计,调用超时甚至可能到几十秒。如果这样一个远程调用被包裹在Spring事务里,等于意味着一个数据库事务要存活几十秒,数据库连接会被长时间占用,事务锁也会长时间不被释放。

后果是连锁的:连接池不够用,后边的请求排队等待连接。数据库端的锁等待增多,其他事务被阻塞。如果调用AI接口再触发一次重试,那简直是一场小型生产事故。

正确做法非常朴素:远程调用和AI调用不能和数据库事务放在同一个方法边界里。先完成必要的本地事务操作,再在事务提交之后去调用远程服务,拿到结果之后如果需要更新数据库,再开启新的数据库事务来处理。

5.2 WebSocket、SSE推送与事务的边界

Spring Boot集成WebSocket过程里同样有事务的坑。常见场景是这样的:业务系统通过WebSocket给客户端实时推送状态,开发者在业务处理的事务过程中,直接调用了WebSocket的消息推送方法。表面上看一切正常,但一旦事务回滚,推送出去的消息就收不回来了,客户端看到的和数据库里的最终状态不一致。

这类问题的本质还是事务边界和外部操作边界没有分开。正确做法是:先完成数据库事务,提交成功后,再发送WebSocket消息。如果一定要保证“事务失败则消息不发”,可以把消息发送动作放到事务提交后的回调里——Spring提供了TransactionSynchronizationManager.registerSynchronization,可以在事务提交后执行一段逻辑。

我见过一个更隐蔽的变种:事务方法里往消息队列里发送一条消息,结果事务回滚了,消息却已经发出去了,消费者端处理的是不存在的数据。这种问题的标准解法,就是用前面提到的“事务消息”,让消息的发送和本地事务握手,而不是在事务方法里直接裸发MQ。

5.3 一点前沿观察:Spring AI Agent的接入思路

Spring AI相关的工具链出来以后,很多人开始讨论Agent类应用要不要纳入事务管理。我的建议是,不要为了“省事”把Agent的思考过程塞进事务。Agent通常有多次推理循环、工具调用、上下文维护,事务在这种场景下帮不了什么忙,反而会因为长耗时而拖垮整个请求链路。

更合理的模式是,把Agent调用当作一个个独立的外部服务,每次调用完毕之后再通过本地事务保存结果。如果Agent某个环节失败,靠重试和补偿来达成最终一致性,而不是天真地认为“线程不动、事务一直开着,就能保证Everything一致”。

未来这类场景只会越来越多。我建议开发者在接Spring AI的时候,就把“事务边界”和“外部调用边界”两条线画清楚,事务只管数据库状态变更,外部调用一律放到事务落定之后再发起。这个习惯越早建立,踩坑越少。

写在最后一段,关于Spring事务的个人体会

如果你问我这些年最大的感悟是什么,我会说:Spring事务的核心看似是注解和配置,实际上考验的从来都是你对“边界”的把控能力。事务边界划在哪里、方法边界和代理边界重不重合、外部操作边界和数据库事务边界有没有纠缠,这些问题捋清楚了,事务相关的坑基本就告别了。

我个人在带队做代码审查的时候,有个习惯动作:看到新写的Service方法,第一眼先数这个方法里有多少次远程调用或RPC,如果超过零,而且这个方法还加了@Transactional,我基本就会打个问号。再往下看,如果远程调用在事务中间、且没有补偿机制,那这单就是必须打回去改的。

最后再分享一个小技能:排查事务问题时,日志里搜“Transaction synchronization”“Participating transaction”“Rolling back”,基本能快速定位Spring底层到底在处理哪个阶段的动作。事务提交阶段出现异常时,日志里往往还会带出“afterCompletion”相关的回调信息,这些内容配合线程栈,比盲目打断点高效得多。

事务不是把注解写上就结束的,它是系统健壮性的地基之一。希望这篇文章能帮你在下一次面对“事务又不生效了”的时候,少走一点弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询