Spring事务底层原理与失效场景排查:从代理机制到分布式事务

📅 发布时间:2026/10/11 6:35:27
Spring事务底层原理与失效场景排查:从代理机制到分布式事务
每个做过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_READOracle默认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和分支事务注册把多个服务纳入一个全局事务里事务边界明确但全局事务期间的锁定和协调开销都不小。它的适用场景是核心交易链路、强一致性要求非常高、并发量又还在可控范围内的业务。TCCTry-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”相关的回调信息这些内容配合线程栈比盲目打断点高效得多。事务不是把注解写上就结束的它是系统健壮性的地基之一。希望这篇文章能帮你在下一次面对“事务又不生效了”的时候少走一点弯路。