资讯动态

@DSTransactional 分布式事务实战:四个常见失效场景与排查思路

发布时间:2026/10/11 11:46:33 来源:尧图企业网站定制
DSTransactional 这玩意我第一次在代码里看到时以为它就是给方法加了个“分布式事务开关”跟平时用的本地事务注解差不多——方法一包要么全成要么全不成。后来线上出了几次事故凌晨对着日志挠头 才发现这个注解的坑比想象中多得多。它管的不只是本地数据库事务还牵扯到跨服务调用、上下文传递、线程池复用、异常回滚这些环节任何一个环节理解错了最终呈现出来的就是数据对不上账。这篇文章不打算讲理论我想把自己和团队在项目里踩过的坑、排查的思路、最后沉淀下来的做法原原本本聊一遍。适合正在用分布式事务框架、或者准备在项目里引入类似能力的人看。如果你以为“加上注解就万事大吉”那这篇文章尤其值得读完。1. 先搞清楚那个注解到底管了什么1.1 没有分布式事务时会怎样先回到问题根源。单体应用时代一个下单操作往往就是本地数据库的几个 UPDATE扣库存、写订单、记流水全部在一个数据库事务里完成一个失败全部回滚很干净。系统拆成微服务之后订单库是订单库库存库是库存库跨库操作没法再用单个数据库事务包住这时就需要一个全局的协调机制。市面上很多分布式事务框架给出的答案就是提供一个类似 DSTransactional 的注解。它的目标不是替代 Spring 的 Transactional而是在其之上做一层跨进程事务语义的扩展。你在一个入口方法上加了这个注解框架会帮你注册一个全局事务 ID然后把后续所有参与的本地事务、远程调用、消息发送都串到这个全局事务上下文里。1.2 注解的事务语义和设计定位理解这个注解要先分清“逻辑事务”和“物理事务”。物理事务是每个数据库连接上的 BEGIN/COMMIT/ROLLBACK逻辑事务是全局视角下的一组操作必须满足原子性。DSTransactional 管的是逻辑事务的编排物理事务仍然由各参与方的本地事务如 Spring 的 Transactional来执行。这就是第一个需要建立的心智模型注解本身不产生魔法它只是通过拦截器在方法入口开启全局事务在方法正常返回时尝试提交在方法抛出异常时通知所有参与者回滚。它依赖拦截器而拦截器依赖代理机制。很多使用上的坑追根溯源都是因为没理解这个“代理”的存在。提示分布式事务不是“加了注解就有”而是“加了注解之后框架才具备协调各参与方的资格”。真正的事务边界、参与方注册、提交回滚决策都需要你在代码结构上配合。2. 第一个大坑方法内部调用直接让注解失效2.1 为什么自调用不生效这是最基础也最容易踩的坑。框架的 DSTransactional 是通过切面拦截方法调用实现的切面只对“进入代理对象”的调用生效。你用this.method()在同一个类内部调用另一个方法时调用并没有通过代理对象而是直接打在目标对象上拦截器根本看不到自然也就不会开启全局事务。我在一个项目里遇到过这样的场景Service public class OrderService { DSTransactional public void createOrder(OrderDTO dto) { saveOrder(dto); deductStock(dto); } public void saveOrder(OrderDTO dto) { orderMapper.insert(dto); } public void deductStock(OrderDTO dto) { stockClient.deduct(dto.getSkuId(), dto.getCount()); } }表面看 createOrder 加了注解createOrder 内部调用 saveOrder 和 deductStock 没问题。但实际执行时框架只是在外层开启了一个全局事务如果 saveOrder 中插入失败异常会向外抛这倒也能触发回滚。真正的问题是另一个写法public void createOrder(OrderDTO dto) { // 这里没加注解 this.doCreate(dto); } DSTransactional public void doCreate(OrderDTO dto) { ... }结果 doCreate 的注解完全没被切面感知。这就是典型的“看起来包了事务实际没包”。2.2 同类内方法互相调用的表现同类内方法互相调用不仅会导致注解失效还会带来一个更隐蔽的问题全局事务上下文缺失参与方各自按本地事务处理。A 库的提交了B 库的回滚了最终数据不一致。举个具体例子。某跨平台系统的转账服务里开发同学写了一个内部方法public void transfer(TransferReq req) { checkBalance(req); doTransfer(req); } DSTransactional public void doTransfer(TransferReq req) { accountA.deduct(req.getAmount()); accountB.credit(req.getAmount()); }单元测试时因为都走本地库没发现问题上线后一压测立刻出现“扣款成功、入账失败”的投诉。排查日志时发现全局事务 ID 在 doTransfer 入口根本没有生成切面日志完全缺失。2.3 实际项目中自调用场景与规避处理方式不复杂但需要刻意遵守。我自己总结下来有几种有效规避手段将需要开启事务的方法拆到另一个 Spring Bean 中通过注入的 Bean 调用。在类中注入自身代理例如使用Autowired注入OrderService self内部调用时写self.doCreate(dto)。直接让外层方法加注解把事务边界放在入口避免“内层单独开启”的需求。实操心得我在代码评审时只要看到同一个类里出现DSTransactional方法被内部直接调用就会建议拆类。这不仅是事务问题也是可读性问题——一个类既做编排又做业务实现迟早会绕晕后面维护的人。3. 第二个大坑事务边界与传播级别把握不好3.1 默认传播级别到底带来什么分布式事务注解通常也有传播级别的概念类似 Spring 的 REQUIRED、REQUIRES_NEW。默认情况下如果在另一个 DSTransactional 方法内部继续调用带注解的方法新的调用会加入已有全局事务而不是另起一个。这看起来合理但隐含一个问题所有参与方共享一个全局事务 ID任意一个参与者出现异常整个链路都需要回滚。实际业务中一个服务里可能调用外部接口而外部接口实际上是老系统根本没有接入分布式事务框架它只会默默完成本地提交。这种情况下一旦全局回滚老系统这边已经改不回去了。3.2 REQUIRES_NEW 不是银弹有人会说那我给所有被调方法都加上 REQUIRES_NEW各自独立事务不就行了。这里要冷静一点。REQUIRES_NEW 的语义是挂起当前事务开启一个新的全局事务。如果内部事务提交成功而外部事务后续失败回滚内部事务不会跟着回滚。这在某些场景下是你想要的比如写流水但在另一些场景下会造成更大的不一致。我见过一个模拟项目把订单创建里的所有步骤都设成 REQUIRES_NEW结果一条主链路被拆成五六个独立全局事务中途任何一步失败前面已经“成功”的步骤根本退不回来。表面上代码很“分布式”实际上根本没有原子性。使用传播级别前必须先回答三个问题这个内部方法失败时外部事务应不应该跟着回滚内部方法成功提交后外部失败能不能接受这个内部方法是否必须在同一个全局事务上下文里看到外部未提交的数据答不上来就不要轻易改传播级别。3.3 事务内调远程服务和消息队列的问题还有一个边界问题特别容易被忽视事务内做远程调用、事务内发消息。如果在 DSTransactional 方法里同步调用一个外部 HTTP 接口而外部接口处理耗时较长全局事务的持有时间会被无限拉长。数据库连接会被一直占用连接池一旦被打满整个服务雪崩。更麻烦的是外部接口如果没接入同一套事务协议它执行成功之后全局事务回滚也管不到它。消息队列也一样。事务内先发“库存已扣减”的消息再执行后续操作后续操作失败触发回滚但消息已经发出去了下游已经消费了。这就是经典的“幽灵消息”。我们项目里几次数据不一致最终定位都是“消息发送太早”。注意在分布式事务中最好的远程调用策略是先完成本地数据库操作确定事务能提交再发消息或调用外部系统如果一定要在事务内发必须使用支持事务消息的方案或者把发消息动作注册到事务提交后的回调里。4. 第三个大坑异步与线程复用让上下文“丢了”4.1 异步线程为什么拿不到事务DSTransactional 的全局事务上下文通常依托 ThreadLocal 在各线程间传递。ThreadLocal 的特性是“线程隔离”A 线程写入的值B 线程读不到。于是在事务方法里新开一个线程去执行子任务子任务里就算方法上加了 DSTransactional也无法感知到父线程创建的全局事务。这个坑之所以隐蔽是因为异步任务往往不是立刻执行而是过一会儿才执行。你在主线程看日志全局事务 ID 还在异步线程的日志里要么没有 ID要么用的是自己生成的新 ID。两边数据在某个时间点就是不一致的。典型场景DSTransactional public void createOrder(OrderDTO dto) { orderMapper.insert(dto); // 异步发送消息或者异步做后续处理 asyncExecutor.execute(() - { stockService.deduct(dto.getSkuId()); }); }主线程的全局事务正常提交而异步线程里的stockService.deduct不受这个事务保护。一旦主事务后面回滚库存已经扣了。4.2 线程池复用导致上下文串扰比异步线程更隐蔽的是线程池复用。线程池中的线程是重复使用的如果框架线程和业务线程混用同一个池前一个任务的 ThreadLocal 没有清理后一个任务会“继承”一个过期的全局事务上下文。我就碰过一次诡异的问题一个完全不带事务的普通查询方法日志里竟然出现了全局事务 ID导致框架给它注册了参与者。排查后确认是线程池里上一个任务结束后没清理上下文复用的线程把残留值带给了下一个任务。这种问题偶尔出现一次复现困难特别浪费排查时间。处理方式上如果确实要在线程池中传递上下文有两点必须做使用专门的上下文传递工具在提交任务时捕获父线程的上下文在任务执行前绑定执行后无论成功失败都要清理。自定义线程池时重写beforeExecute和afterExecute保证每条任务开始前是干净的 ThreadLocal结束后被清空。4.3 异步消息发送顺序与事务提交顺序异步场景里还有一个容易忽略的细节主线程先提交了数据库事务然后异步任务才执行这是正确的顺序但很多代码写反了——先提交异步任务再提交数据库事务。由于线程调度不确定性异步任务可能比主线程的事务提交更早执行下游看到的还是旧数据。这已经不属于 DSTransactional 本身的问题而是事务与异步任务的关系设计问题。我现在的习惯是所有异步操作都放到事务提交回调里执行或者干脆在事务提交之后、接口返回之前发出。这样至少保证异步任务开始时事务结果已经可见。5. 第四类坑异常处理、回滚条件、连接与死锁5.1 try-catch 吞异常为什么要命DSTransactional 判断是否回滚依据就是方法有没有把异常抛给拦截器。你在方法内部 try-catch 把异常吞了方法正常返回拦截器认为事务应该提交于是全部参与者提交成功。数据库层可能有部分操作失败但已经被你 catch 住了全局事务什么都不知道。这个坑出现的频率高得吓人。尤其是在调用第三方接口时很多同学习惯性 catch 住异常然后记录日志为了让接口不报 500。放在普通场景可能问题不大但放在 DSTransactional 方法里等于亲手屏蔽了回滚信号。正确的做法是让异常继续向外抛由切面统一处理回滚。如果确实要对异常做包装必须包装成框架认得会触发回滚的异常类型通常是 RuntimeException 子类然后重新抛出。如果 catch 后要做补偿也要先标记当前事务需要回滚再执行补偿逻辑。5.2 checked 异常默认不回滚另一个回滚相关的问题是异常类型。分布式事务框架如果参照 Spring 事务的语义默认只对 RuntimeException 和 Error 回滚checked 异常默认不会触发回滚。如果你在方法声明里写throws Exception然后又用throw new Exception(...)抛出框架可能认为业务正常返回直接提交。解决方法是显式指定回滚规则例如DSTransactional(rollbackFor Exception.class) public void createOrder(OrderDTO dto) throws Exception { ... }如果你用的是某个自定义注解建议先去看框架源码里回滚条件的判断逻辑不要想当然认为“抛异常就回滚”。我们团队为此专门定了一个规矩业务异常统一继承一个 RuntimeException所有跨服务调用如果失败直接抛出这个业务异常。5.3 大事务、长事务、连接与死锁分布式事务天然比本地事务更重每次参与方注册、每轮提交回滚协调都要额外通信。如果你把很多操作塞进一个 DSTransactional 方法事务边界就会变得很长。长事务带来最直接的问题就是数据库连接占用时间变长以及锁持有时间变长。我们遇到过一种典型的死锁场景事务 A 里先更新订单表再调用库存服务事务 B 里先更新库存表再调用订单服务。两个事务互相等待对方释放锁最终只能靠数据库超时来解开。表面看是死锁本质是跨服务的资源访问顺序不一致叠加长事务放大了冲突窗口。规避手段事务内尽量不做远程调用远程调用放到事务提交后。尽量少更新数据行只更新必要字段。跨服务操作时给资源访问定义统一顺序避免交叉等待。设置合理的全局事务超时时间避免死等。实操心得我在订单链路里曾经把一个包含 7 个服务调用的 DSTransactional 方法拆成了三个小事务第一个管订单落库第二个管对账信息第三个管消息通知。看起来多了几个事务但整体稳定性反而上去了因为单个事务持有连接和锁的时间大幅缩短。6. 高频问题速查表与排查思路6.1 常见问题速查表下面这个表是我在排查分布式事务问题时用的高频对照表。遇到数据不一致先按表格定位方向能省很多时间。现象可能原因排查方向注解加了但全局事务 ID 没生成自调用/内部调用代理未生效检查调用方式是否 this.xxx()确认是否走了 Bean 代理事务偶尔生效、偶尔不生效线程池复用、上下文未清理检查 ThreadLocal 是否在任务结束后清理消息先发出事务后回滚事务内直接发送 MQ 消息改为事务提交后发送或使用事务消息异步任务数据不一致异步线程未继承全局事务上下文确认异步任务是否与主线程共享上下文方法抛异常但没回滚catch 吞异常 / checked 异常检查异常是否抛出切面确认 rollbackFor 配置连接池耗尽、接口大量超时事务内做远程调用或大事务查看方法耗时确认事务内是否有 RPC、是否更新大量数据两个服务互相等待最终死锁跨服务资源访问顺序不一致统一各服务加锁顺序缩小事务边界6.2 一个实际排查案例有一次线上出现“订单创建成功库存扣减失败但库存记录最终被扣了”的诡异现象。订单服务显示失败库存服务显示扣减成功两边怎么都对不上。排查过程大概是这样第一步查订单服务的异常日志发现异常发生在主流程的第 4 步但第 2 步异步调用了库存扣减。第二步看到异步执行器用的是默认线程池没有做上下文传递异步线程里的扣减操作拿到了一个新的全局事务 ID。第三步查看切面日志确认主事务进入回滚但异步线程的扣减已经提交回滚指令没有覆盖到它。最终修复方案是把异步扣减从 DSTransactional 方法中移出改为订单落库成功后再异步扣库存同时在异步任务里不再加分布式事务注解而是单独走本地事务加重试机制。这个案例让我意识到排查这类问题不能只盯着一个服务看链路里“上下文在哪个线程手里”比“哪个方法写了注解”更重要。7. 我现在的落地建议7.1 能用简单事务就不要硬上分布式事务这个建议可能有点反直觉但确实是踩坑踩出来的。分布式事务协调成本高、排查难度大如果你的业务能够通过幂等、重试、对账补偿来解决最终一致性就不要把全局事务放在主链路里。很多场景其实不需要强一致只需要最终一致加上人工补偿入口。我在项目中定过一个原则能用本地事务解决的绝对不引入全局事务必须在跨服务场景保持强一致的才考虑用 DSTransactional。而一旦决定要用就要把业务操作和事务边界设计清楚不追求“一个注解包所有”。7.2 真的需要时怎么设计边界如果确实要用我的边界设计大致是事务入口方法只做编排和协调不写太重的业务逻辑。事务内参与的服务数量控制在合理范围内超过一定数量时改用异步化加补偿。每个参与方的方法都要保证幂等因为分布式事务重试和回滚都会导致重复执行。全局事务设置超时时间宁可超时失败也不能无限期悬挂。关键日志里输出全局事务 ID方便排查时串起整条链路。事务提交后如果有后续动作发消息、通知、异步处理统一放到提交回调里。7.3 最后的个人体会回头再看 DSTransactional它本身只是一个工具真正的复杂度在于分布式环境下对事务边界、线程边界、异常边界的理解。每次线上出问题我都会先回答三个问题事务上下文现在攥在哪个线程手里它什么时候释放异常有没有真正抛到切面这三个问题答清楚了大部分坑都能绕开。另外使用这类注解之前我强烈建议先读一遍框架的源码至少把拦截器、上下文传递、回滚触发这几段逻辑看明白。不要只背用法不然出了问题你连从哪个日志开始查都不知道。最后再分享一个小技巧上线前在测试环境写一个“强制故障注入”的用例让参与方第二个服务直接抛异常验证第一个服务是否真的回滚。这个用例能挡住一半以上隐藏的问题比临时看日志高效得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑