☰
Spring事务传播机制实操:订单链路业务操作分离
2026/10/10 20:12:29 网站建设 项目流程

最近把一个下单链路的接口从“一个大事务包住所有业务操作”改成了按需拆分,核心就是利用Spring的事务传播机制把不该放进事务里的业务操作分离出来。这个过程花了两天时间,走了不少弯路,最后沉淀了一套比较实用的边界划分方法,也就是这次要聊的O(2)事务传播实践。文章后面会直接用订单创建和库存扣减这个场景来讲,因为在Java后台开发里,最典型的事务传播和业务操作分离问题,几乎都长在这条链路上。

这个东西适合谁看呢?如果你写过@Transactional,但不太确定REQUIRED、REQUIRES_NEW、NESTED到底该怎么选;如果你们系统里出现过“一个方法调用链特别长,被一个大事务包住,最后随便一个小操作失败全回滚”的情况;如果你正准备Java事务面试题,想搞明白事务传播级别怎么答才能体现真实理解——那这篇内容正好是冲着你来的。我不讲教科书式的定义,只讲实际问题怎么定位、怎么拆、怎么落地。

1. 项目概述:这个项目到底想解决什么问题

1.1 一切的起点:订单创建为什么把整个链路拖垮

先还原一下最原始的代码长什么样。很多团队在做订单接口的时候,习惯性在Service方法上直接加@Transactional,然后把整个业务流程都塞进去——校验地址、查询商品、扣库存、插入订单、发短信、送积分、清购物车,全都在同一个事务里跑。这段代码刚上线的时候确实没问题,因为数据量小、并发低、外部依赖响应快。但等业务量一起来,问题就集中爆发了,而且爆发方式特别难看。

第一个问题是锁持有时间过长。事务从方法进入开始,数据库连接就被占住,相关行或表的锁一直不释放,直到整个方法return。如果这个方法的平均执行时间因为某个远程接口变慢从100ms涨到2秒,那数据库层的锁等待直接翻十几倍。高峰期连接池很快就满了,后面所有请求都在等连接,系统表现为“页面卡死、接口超时”,但你去看代码又看不出哪里有大循环。

第二个问题是外部RPC不可控。下单接口里普遍要调用会员服务查地址、调用风控服务做校验,这些调用的耗时完全取决于网络和下游服务。把RPC放进事务里,等于让数据库事务去等一个不可控的第三方响应。更危险的是,如果这个RPC因为超时而抛异常,Spring默认会把整个事务标记为回滚,订单、库存扣减这些核心操作全部被连带回滚。

第三个问题是边界混乱导致补偿成本极高。很多业务操作其实不需要和主事务强一致。比如发短信通知用户,晚几秒发完全没影响;送积分失败,下次重试或者人工补发就行。但如果你把它们全包在同一个事务里,任何一个环节失败,前面已经完成的扣库存也会跟着回滚。更麻烦的是,有些操作本身有副作用——库存扣减如果回滚了,但远程仓库系统已经收到预占指令,两边的数据就对不上了,这时候你还得写一堆补偿逻辑去“找平”。

第四个问题是跨数据源无能为力。订单库和库存库可能根本不在同一个数据库实例上,本地事务通过一个Connection管理,天然无法跨库保证原子性。这时候你在Service层加多少个@Transactional都没用,报错可能就是奇怪的“事务日志已满”或者“分布式事务需要额外配置”。很多朋友第一次遇到这种问题会很懵,明明写了事务注解,为什么数据还是不一致?其实就是事务边界和业务操作边界从一开始就没划清。

1.2 分离的核心思想:事务边界不等于业务方法边界

我一直跟团队里的人说一句话:方法边界是代码组织问题,事务边界是数据一致性设计问题,两者不能想当然画等号。很多人在Service方法上加@Transactional,是因为“这段逻辑必须同时成功或同时失败”,但实际业务里大部分操作根本不需要同时成功或失败。

我举一个比较生活化的类比。你去银行办业务,柜台人员会先帮你把账户信息核对清楚,然后执行转账,转账成功后系统才打印回单、发短信提醒。你见过柜员在转账还没确认完成的时候,就先给你打回单、发短信的吗?不会。但你也不会要求柜员在转账之前就把回单打印和短信发送都“同一个流程”做完,因为这些都是转账成功之后才能做的后续动作。事务边界就应该像“转账扣款”这一步,业务操作边界则可以跨到“回单打印”“短信提醒”这些后置动作。

所以“分离业务操作的事务”这个思路,说白了就是三件事:第一,识别哪些动作必须在一个原子事务内完成;第二,识别哪些动作可以在事务提交后异步完成;第三,把第二类动作从第一类动作里摘出去,用事务传播、事务同步器或者编程式事务把这些动作放到正确的时间点。O(2)这个代号在我这边其实就是“Order Operations”的意思——订单链路上的两个核心操作组件,一个管订单落库,一个管子业务执行,两者通过事务传播来约定边界。

很多团队在这块容易走极端:一种是“能加事务就加事务,一个方法从头包到尾”;另一种是“事务没什么用,出了问题靠补偿”。这两种我都不推荐。正确的目标是“事务最小化”——让事务只覆盖真正需要原子性的核心写操作,其余业务操作全部放到事务外面或者事务提交之后执行。这既保证了核心数据的一致性,又避免了长事务和高锁竞争的老大难问题。

2. 事务传播机制选型:REQUIRED、REQUIRES_NEW和NESTED怎么选

2.1 先手把手拆明白这几个传播级别

既然要聊分离业务操作的事务,就必须把事务传播机制拿出来重新捋一遍。Spring事务传播行为,本质上回答一个问题:当前线程已经存在一个事务的时候,新进入的带@Transactional方法应该怎么办?不同的传播级别定义了完全不同的应对策略,而大部分线上问题都出在“默认的REQUIRED”上。

REQUIRED是Spring的默认传播级别。它的语义是:如果当前没有事务,就新建一个;如果当前已经有事务,就直接加入这个事务。一个事务就对应一个数据库连接,多个方法共享这个连接,最终统一提交或统一回滚。这种模式适合“多个操作必须同生共死”的场景,比如订单主表和子表一起插入,用REQUIRED就是最合理的。

REQUIRES_NEW的语义是:不管当前有没有事务,都挂起当前事务,另开一个全新的事务。新事务拥有自己的数据库连接,自己提交、自己回滚,提交之后不会受外层事务回滚影响。这个特性让它成为“分离事务里某个必须独立成功的操作”的主力工具。比如扣减库存,你希望它一旦成功就立即生效,不要因为后续订单流程失败而被回滚掉;又比如记录一条操作审计日志,你希望它无论主流程成功还是失败都能落库。

NESTED的语义有点特殊,它基于JDBC的保存点(Savepoint)机制实现。如果当前没有事务,它的行为等价于新建一个独立事务;如果当前已经有事务,它会开启一个嵌套子事务,这个子事务拥有独立的回滚点。父事务回滚时子事务一定回滚,但子事务回滚时父事务可以选择继续提交,没有“整个事务被标记为rollback-only”的连带效应。这个特性非常适合“一个大业务流程里某个环节失败后,不影响其他局部已提交状态”的场景。

除了这三个常用的,还有SUPPORTS(有事务就加入,没有事务就以非事务方式执行)、NOT_SUPPORTED(挂起当前事务,以非事务方式执行)、MANDATORY(必须有事务,否则抛异常)、NEVER(必须没有事务,否则抛异常)。这几个在一般业务代码里用得不多,但在框架底层和特殊场景下偶尔会遇到,比如你希望某段代码无论如何不要占用数据库连接,就可以用NOT_SUPPORTED。

生活化地理解这三个核心级别的区别:REQUIRED是把两笔操作记在同一张流水账上,要么全部入账,要么全部不入账;REQUIRES_NEW是另开一张独立账本,各自结算,互不绑架;NESTED是在同一张大账本里画一个可撤销的小结,小结回滚不影响大账本的其他条目,但大账本最终结果还是跟着主流程走。

2.2 关键选择:什么情况下选REQUIRES_NEW,什么情况下选NESTED

很多人看完定义还是不知道怎么选。我的判断标准不是看“这个操作重要不重要”,而是看“这个操作失败后,主流程应该怎么反应”。

如果子操作必须独立提交,比如库存扣减,选REQUIRES_NEW。库存扣减一旦成功,理论上就应该立即锁住这部分库存,避免超卖。如果把它放进主事务,主事务后面因为积分赠送失败回滚,库存扣减也被回滚,但库存服务的预占记录可能已经发出去了,两边对不上。用REQUIRES_NEW让扣库存成为一个独立事务,即使后续积分失败,库存也已经成功扣减,客户可以正常下单,积分问题异步补偿就行。

如果子操作允许局部回滚,但整体跟着主事务提交,选NESTED。举个例子,主流程里要插入多条明细,其中最后一条明细因为参数错误失败了,你希望前面的明细不白费,把这一条剔除掉,主流程继续。NESTED就能做到:子事务回滚只回滚到保存点,父事务继续提交时不带这条错误明细。

这里有一个必须强调的代价:REQUIRES_NEW会挂起外层事务的事务对象和数据库连接,等于同一线程同时持有两个连接,在高并发场景下连接池压力翻倍。一台连接池默认20个连接,你如果让一个高频接口每次调用多占一个连接,并发一高立刻打满。所以不要一遇到“子操作要独立”就无脑用REQUIRES_NEW,要先评估代码里同时活跃的并发量,如果实在有压力,可以改成“主事务只做核心写操作,库存扣减通过异步消息去执行”,这条路后面再细说。

我建议遇到具体场景时按下面这张表快速过一遍。

业务诉求推荐传播级别理由
多个表数据必须同时成功或失败REQUIRED共享同一事务,原子性最强
子操作必须独立提交,不受主事务回滚影响REQUIRES_NEW新事务独立提交,不参与外层事务回滚
子操作失败只回滚自己,父事务继续NESTED基于保存点,回滚粒度局部化
某个动作有事务就参与,没有也无所谓SUPPORTS柔性处理,不强制建事务
某个动作绝不希望占用事务/连接NOT_SUPPORTED挂起当前事务,非事务执行
某个动作必须在事务内执行,否则报错MANDATORY强制约束,常用于内部校验
某个动作必须不受事务影响NEVER明确要求非事务环境

3. 分离业务操作的三套可落地实现

3.1 方案一:REQUIRES_NEW拆掉必须立刻成功的子操作

第一个落地方案最直接,就是给子操作标记REQUIRES_NEW。拿订单创建和库存扣减来举例,我会把InventoryService单独拆出来,让它承担库存扣减的独立事务控制,绝对不要和OrderService耦合在同一个类里。原因很简单:Spring事务是基于AOP代理实现的,只有方法从外部Bean进入时,代理拦截器才会生效;同一个类里面的this调用会直接绕过代理,写再多@Transactional也没用。

核心代码基本长这样。外层OrderService负责订单创建主流程,事务保持默认REQUIRED;内层InventoryService做库存扣减,使用REQUIRES_NEW。

@Service public class OrderService { private final InventoryService inventoryService; public OrderService(InventoryService inventoryService) { this.inventoryService = inventoryService; } @Transactional public Long createOrder(CreateOrderRequest request) { // 把订单主表插入 Order order = new Order(); order.setUserId(request.getUserId()); order.setAmount(request.getAmount()); orderMapper.insert(order); // 库存扣减使用独立事务,独立提交 inventoryService.deductStock(request.getSkuId(), request.getQuantity()); return order.getId(); } }
@Service public class InventoryService { @Transactional(propagation = Propagation.REQUIRES_NEW) public void deductStock(Long skuId, Integer quantity) { Stock stock = stockMapper.selectForUpdate(skuId); if (stock.getAvailable() < quantity) { throw new InsufficientStockException("库存不足"); } stock.setAvailable(stock.getAvailable() - quantity); stockMapper.updateById(stock); } }

这段代码有几个细节要特别注意。第一个是selectForUpdate,也就是对库存行加悲观锁,保证并发扣减不会超卖。第二个是异常处理,如果deductStock抛了“库存不足”,外层事务会被标记为回滚,订单插入不会生效,数据依然是安全的。这里有个容易踩的坑:很多人在外层用try-catch把内层异常吞掉,结果内层事务已经回滚,外层却继续提交,最后订单存在但库存没扣,这就是典型的“部分成功”脏数据。所以REQUIRES_NEW子事务抛异常后,要么直接把异常抛出去让外层也回滚,要么在外层单独做补偿逻辑并明确记录补偿状态,不要盲目吞异常。

但这套方案只解决“同一个数据库里事务边界怎么切”的问题。如果订单库和库存库是不同的物理库,本地事务再独立也只是各自库内独立,跨库一致性依然保证不了。这时候不能靠REQUIRES_NEW硬撑,得引入本地消息表、事务消息或者Saga补偿这类方案。很多面试题问分布式事务一致性,本质上就是要你从“一个库的事务边界”跳出来,思考“跨库操作的最终一致”怎么做。REQUIRES_NEW在这里的真正价值是先把本地事务边界收窄,给后续异步化、消息化打下基础。

3.2 方案二:TransactionSynchronizationManager把非核心操作移到提交后

第二种方案专门解决一类特别常见的问题:主流程已经完成了核心写操作,但有一些“锦上添花”的操作必须等事务成功后才能执行。典型例子是下单成功后发短信、送积分、清除购物车。如果把这些操作放在事务提交前执行,用户支付成功但积分没到账,或者订单刚创建就收到短信但点进去订单还没查出来,体验非常差。

Spring为我们提供了事务同步器,核心入口就是TransactionSynchronizationManager。你可以在事务执行过程中注册一个TransactionSynchronization对象,然后在afterCommit回调里执行非核心操作。这个机制的作用是:把业务操作注册到事务生命周期上,让它在事务提交成功后才触发,而不是跟着事务代码同步执行。如果事务回滚了,afterCommit不会执行,从根上避免了“失败通知成功用户”的问题。

一个简洁的实现如下:

@Service public class OrderNotifier { public void sendAfterCommit(Long orderId) { TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { @Override public void afterCommit() { // 事务已提交,这里可以放心发短信、送积分 sendSms(orderId); grantPoints(orderId); } } ); } }

调用顺序上,这个sendAfterCommit要在事务提交之前被调用,也就是在事务方法内部调用。事务方法执行到最后,Spring提交事务时会遍历所有注册的同步器,依次触发afterCommit。因为afterCommit的回调已经脱离了事务范围,回调里抛出的异常不会导致事务回滚,但回调本身的异常信息会被吞掉一部分,所以回调方法内部一定要自己做try-catch和日志记录,必要时把失败数据写入重试表。

相比直接手写TransactionSynchronizationManager,我更推荐Spring 4.2之后提供的@TransactionalEventListener注解加@Async组合。它本质上也是对事务同步器的封装,写法更简洁,还自带fallbackExecution参数可以控制“没有事务时是否执行”。但从理解底层机制的角度出发,手写一次TransactionSynchronization还是很有价值的,因为面试中问到“Spring事务提交后如何执行某个操作”,你能直接说出TransactionSynchronizationManager和afterCommit这两个关键点,比泛泛而谈“用异步消息”要扎实得多。

方案二的核心收益是把“业务操作”和“事务提交点”分离了。在代码里,你关心的业务操作还是写在事务方法内,但它真正执行的时机被推迟到了提交后。这种分离方式不需要改动方法签名,也不影响主流程代码阅读顺序,非常适合下单、支付成功、注册完成这类“先落库、后通知”的场景。

3.3 方案三:TransactionTemplate精确控制哪些代码在事务里

第三种方案是用编程式事务TransactionTemplate,它的价值在于“精确到块”的事务边界控制。注解式@Transactional虽然方便,但它把整个方法体都圈进了事务范围,粒度太粗。如果你只想让某个方法里的一小段代码参与事务,其他代码不用事务,注解方式就很难优雅实现——你总不可能为了让方法里其中5行代码有事务,把另外20行代码拆到另一个类里去。

TransactionTemplate用起来很直接。先注入一个TransactionTemplate,然后在execute回调里写核心事务操作,回调之外的代码自然就在事务外执行。

@Service public class StockImportService { private final TransactionTemplate transactionTemplate; private final StockMapper stockMapper; public StockImportService(PlatformTransactionManager transactionManager, StockMapper stockMapper) { this.transactionTemplate = new TransactionTemplate(transactionManager); this.stockMapper = stockMapper; } public void importStock(List<StockItem> items) { // 这段代码在事务外执行,可以做参数校验、远程数据拉取 List<StockItem> validItems = items.stream() .filter(item -> item.getQuantity() > 0) .collect(Collectors.toList()); // 只有这段核心写入工作在事务内 transactionTemplate.execute(status -> { for (StockItem item : validItems) { stockMapper.insertOrUpdate(item); } return null; }); // 事务提交之后,再执行清理缓存、通知下游等操作 clearStockCache(validItems); notifyStockChanged(validItems); } }

编程式事务最大的好处是可以把“耗时、不可控、非核心”的操作坚决排除在事务之外。比如上面示例里,从远程拉取库存数据和最后的缓存清理都不需要放在事务里;真正需要事务保护的只有批量插入和更新那几十毫秒。这样事务时间被压缩到了极致,数据库连接被占用的时间也变短了。

编程式事务还有一个隐藏优势:你可以在同一个方法内启动多个短事务,而不是被一个长事务包住全局。比如批量导入10万条库存数据,一次性开一个大事务会导致事务日志快速膨胀,甚至出现数据库“事务日志已满”这类故障。用TransactionTemplate分批提交,每1000条一个事务,事务日志就能及时截断,锁等待和回滚代价都小得多。

当然编程式事务也有代价,就是代码侵入性比注解方式强,事务模板对象需要注入,代码可读性也相对差一些。我的建议是:核心业务链路用注解式事务保持清晰,复杂或特殊的边界场景用TransactionTemplate细粒度控制,不要所有地方都套编程式事务。

3.4 三个方案怎么选

三个方案看起来都能实现“分离业务操作”,但适用的场景差别很大,我把它整理成了一张对比表,方便你直接对照。

维度REQUIRES_NEW方案事务同步器方案TransactionTemplate方案
核心思路子事务独立提交操作延迟到事务提交后精确控制事务代码块
代码侵入性低,注解即用中,需要注册同步器中高,需要注入模板
适合场景库存扣减、外部预占等必须独立生效的操作短信、积分、缓存清理等非核心后置操作批量导入、复杂边界、局部事务控制
事务失败时表现子事务已提交,不随主事务回滚回调不执行,主事务正常回滚只有模板代码块回滚
风险点连接占用翻倍、补偿逻辑复杂回调异常易吞,需要自己做可靠性代码结构相对琐碎,可读性下降
推荐指数高频场景推荐高频场景强烈推荐特殊场景推荐

看完这张表,你对选型应该有个直觉了。我的原则是:一个订单流程里,库存用REQUIRES_NEW,通知用事务同步器,批量管理操作用TransactionTemplate,各管一段。事务传播不是银弹,但把它们组合起来,已经能覆盖绝大多数“分离业务操作的事务”需求。

4. 实操复盘:两次线上事故逼出来的边界意识

4.1 事故一:一个远程校验的RPC把订单和库存全部拖回滚

先说第一次事故。那天晚上大促期间,用户反馈下单一直失败,错误日志里出现了明显的“事务已回滚”记录。我们第一反应是库存扣减出了问题,结果排查到最后发现,是下单接口里一个会员地址校验的RPC拖了后腿。这个RPC被写在了事务方法中间,因为下游服务在高峰期响应变慢,大量请求等在网络IO上,数据库事务迟迟不提交,连接池被打满,锁等待一路飙升。

SQL Server的日志里开始出现类似“事务日志已满”的报错,数据库的活跃事务列表里挂着一大堆长事务。我们查INFORMATION_SCHEMA或对应数据库的系统视图,能看到很多事务持续时间超过了30秒,这在平时根本不可能出现。问题的根子就是事务边界和业务操作边界混在一起——那个地址校验根本不需要和订单创建同生共死,校验结果只是下单条件之一,校验通过后数据已经拿到,没必要让整个事务继续等着。

修复方式分三步走。第一步,把地址校验、风控校验这类只读RPC移到事务方法之前执行,事务方法里只拿着校验结果走核心写链路。第二步,库存扣减从主事务里拆出来,改成REQUIRES_NEW独立提交,避免订单后续操作把库存扣减连带回滚。第三步,把短信、积分这类非核心动作全部改到事务同步器afterCommit里执行。改完之后,事务持续时间从原来的几百毫秒甚至几秒,下降到了几十毫秒,数据库锁等待和连接池压力立刻缓解。

这次事故给我留下的教训非常深。很多团队喜欢在事务里做远程调用,是因为“方法够长、调完再落库”写起来顺手。但数据库事务不是在等你业务逻辑想明白,它一直在占着连接、占着锁。凡是和核心数据写入没有严格原子关系的操作,都不应该在事务里做。尤其是RPC,网络一抖动,数据库就跟着遭殃。

4.2 事故二:自调用导致REQUIRES_NEW没生效

第二次事故更隐蔽。我们把库存扣减独立事务上线之后,发现一个诡异现象:偶尔会出现“订单已创建成功,但库存扣减还没有提交”的中间状态,下游库存服务显示扣减成功了,但主库订单表里订单一直查不到。刚开始以为又是补偿逻辑问题,后来在日志里发现一个关键细节——扣库存接口的事务ID竟然和订单创建事务的事务ID是一样的。这说明REQUIRES_NEW根本没生效。

原因说出来很多人会恍然大悟:我们当时在一开始把库存扣减方法写在OrderService类内部,虽然单独抽了一个inventoryService字段,但有一次为了省事直接在OrderService内部写了deductStock方法,外层调用就用this.deductStock()。Spring事务是通过代理对象拦截方法调用实现的,this调用的是当前实例原方法,根本没有经过代理拦截器。所以你在同一类里调用带@Transactional的方法,无论注解写的是REQUIRES_NEW还是NESTED,全部失效,内层方法直接在外层事务里执行。

确认问题的方法也很简单。在方法里打印TransactionSynchronizationManager.getCurrentTransactionName(),你看看内层方法执行时返回的是外层事务名还是内层事务名。如果是同一个名字,说明事务传播没有生效。另外看Spring的事务日志,把logging.level.org.springframework.transaction.interceptor设为TRACE,每次事务开始和提交都有记录,对应着事务ID一查就清楚了。

修复方案是彻底把库存扣减抽到独立Bean里,让OrderService通过注入的InventoryService去调用。如果你实在不想拆类,也可以使用AopContext.currentProxy()拿到当前代理对象,再通过代理调用方法。但我在实践里还是强烈推荐拆Bean,因为这种方式一劳永逸,代码可读性也更好。一个独立Service代表一个独立的事务边界,这种分法才是长期可持续的。

5. 常见问题排查与避坑清单

5.1 常见问题速查表

我把这两次事故以及日常排查中遇到的高频问题整理成了一张速查表,贴出来给大家参考。这里面每一行都对应一个真实的线上坑,值得收藏。

场景/现象根因解法/建议
REQUIRES_NEW没生效,内层事务ID和外层相同同类内this调用绕过Spring代理拆分独立Bean注入调用,或使用AopContext.currentProxy()
内层事务抛异常,外层事务被标记rollback-only子事务异常未正确抛出,外层catch后继续提交子事务异常要么直接抛出去,要么做补偿后重新标记回滚
REQUIRES_NEW子事务已提交,但主流程后续失败两个事务独立,主事务回滚不会回滚子事务预先设计补偿逻辑,比如扣库存后异步回滚或退款
事务提交后回调未执行afterCommit只在提交成功时触发;没有事务时默认不执行使用@TransactionalEventListener的fallbackExecution,或在非事务方法中手动判断
高频接口调用REQUIRES_NEW后连接池打满新事务额外占用一个数据库连接,并发升高后连接不够评估并发量;必要时把独立操作改异步消息执行
数据库报“事务日志已满”长事务或大事务导致事务日志无法截断缩小事务范围、分批提交、避免事务内RPC和长循环
事务内调用远程RPC导致锁等待飙升数据库事务等待不可控网络响应把RPC移到事务外或事务前执行
缓存清理/通知操作在事务回滚后仍然执行了业务操作写在事务代码内,未放在afterCommit用事务同步器或@TransactionalEventListener延迟到提交后

这张表里最后一行值得多强调一句。很多时候大家写了一堆“业务操作代码”在事务方法里,觉得顺序对了就没问题,但顺序对不代表边界对。事务回滚之后,你前面执行过的发消息、清缓存动作不会自动撤销,这就是所谓“业务操作没有真正分离”的典型隐患。

5.2 排查时怎么快速确认事务边界

排查事务问题,最快的方式是让Spring把事务边界打印出来。第一步,在application.yml里开启事务拦截器日志:

logging: level: org.springframework.transaction.interceptor: TRACE org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG

开启之后,每次事务开始、提交、回滚都会输出对应日志,你能清楚看到每个方法走的是哪个事务管理器,事务是否被挂起,是否创建了新事务。

第二步,在代码关键位置打印当前事务信息。TransactionSynchronizationManager这个工具类非常实用,你能通过isSynchronizationActive()判断当前线程是否存在事务,通过getCurrentTransactionName()拿到当前事务方法名。我习惯写一个简单的事务信息logger,放在业务方法入口和出口,方便线上定位:

if (TransactionSynchronizationManager.isSynchronizationActive()) { String txName = TransactionSynchronizationManager.getCurrentTransactionName(); log.info("当前存在事务: {}, 事务资源: {}", txName, TransactionSynchronizationManager.getResourceMap().keySet()); }

第三步,如果怀疑是数据库层的问题,直接查活跃事务和锁等待。MySQL里查information_schema.innodb_trx和performance_schema.data_lock_waits;PostgreSQL里查pg_stat_activity和pg_locks;SQL Server这类数据库出现“事务日志已满”时,也要优先看当前活跃事务持续了多久。所有数据库排查的核心思路都一样:找长事务,看它在等什么锁,再看它执行的SQL是什么。一旦定位到某个长事务里包含RPC调用或大批量循环,问题基本就锁定到事务边界了。

这里再分享一条排查心得:遇到“Transaction marked for rollback-only”异常,不要只在报错的方法里找问题。这个异常往往不是当前方法自己设置的,而是某个嵌套子事务已经抛异常并设置了回滚标记,当前方法感知不到,继续执行到提交时才被Spring拦截。你要做的是顺着调用链往上找,看内部哪个@Transactional方法抛了异常又没被正确处理。这种问题用REQUIRES_NEW或NESTED能规避一部分,但根本解法还是把异常边界理清楚,该传播的传播,该吞的明确吞。

5.3 多说两句踩出来的体会

我现在做事务拆分时,固定的动作就是一个一个业务问题问自己:这个操作和核心数据写入是强一致关系吗?失败后能不能不打扰主流程,用异步补偿解决?它执行完能不能等事务提交后再说?问完这三个问题,事务边界基本就画清楚了。

Spring的REQUIRED和REQUIRES_NEW是静态配置,但“分离业务操作的事务”是一种设计习惯。你不需要记住所有传播级别的定义,但一定要能在实际链路里说出来:哪些操作必须在一个事务里,哪些操作需要另起炉灶,哪些操作应该挪到提交后再执行。这三种东西混在一起是灾难,分开之后,订单系统稳定度会明显上一个台阶。

还有一个小技巧,配置事务传播级别时,尽量在注解里显式写明白,而不是依赖默认值。比如@Transactional(propagation = Propagation.REQUIRES_NEW),看到代码的人一眼就知道这里是独立事务;如果什么都不写,同事看着@Transactional还要去猜你到底想要REQUIRED还是别的。代码是为团队写的,边界画得越清楚,后面维护的人越少踩坑。

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

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

立即咨询