恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
@Transactional管不到跨服务:分布式事务方案选型与幂等设计
首页
资讯中心
/
@Transactional管不到跨服务:分布式事务方案选型与幂等设计
@Transactional管不到跨服务:分布式事务方案选型与幂等设计
发布时间:2026/9/1 2:50:03
这道面试题的陷阱不是“你不会分布式事务”而是“你用Transactional去扛跨服务场景”。最近在讲业务一致性方案时几乎每次都会遇到这种回答一提分布式事务就说“用Transactional失败就回滚”。如果是单库、单服务、单连接这个理解没问题一旦链路变成“订单服务调库存服务”Transactional能保证的就只有本地那一段管不到另一个服务的行为。这篇文章会把问题拆开先讲Transactional的真实边界再用订单和库存的案例看数据怎么不一致然后从可靠消息、SAGA、TCC到XA对比一轮最后给出一套面试和落地时都能用的判断框架。1. 先把这个问题的边界说清楚Transactional 到底管了什么1.1 本地事务和 Spring 事务的规则Transactional本质上是对数据库事务的封装。Spring 通过 AOP 拦截方法在方法执行前开启事务在方法正常结束时提交在抛出异常时回滚。它依赖的核心对象是PlatformTransactionManager无论是 JDBC 的DataSourceTransactionManager还是 JPA 的JpaTransactionManager背后都对应同一个数据库连接。因此它真正能管理的内容有三个特征同一条数据库连接。同一个本地事务管理器。同一份数据库资源。符合这三个条件时Transactional可以保证 ACID原子性、一致性、隔离性、持久性。比如一个用户修改自己的昵称同时插入一条操作日志对应同一个数据库中间任何一个 SQL 报错整个事务回滚数据不会出现“昵称改了但日志没写”的情况。这也是为什么很多人习惯性地认为“事务就是万能回滚”。在单体应用、单一数据源的前提下这个认知基本正确。可一旦服务被拆分数据库被拆分或者调用链从同步 HTTP 变成异步消息事情就变了。1.2 跨服务之后“管不到”的部分才是重点微服务架构下订单服务和库存服务通常各有自己的数据库。假设订单服务里有一个方法Transactional public void createOrderAndDeductStock(OrderDTO dto) { orderMapper.insert(dto); // 订单库 inventoryService.deduct(dto.getSkuId()); // 远程调用库存服务 }这段代码表面上像是一个“大事务”但实际执行时远程调用的数据库操作发生在库存服务的数据库里。订单服务的事务管理器无法感知库存服务在做什么也不能强制库存服务的连接回滚。更准确地说这里存在着两个独立的事务订单服务本地事务。库存服务本地事务。两个事务之间没有共享的连接也没有全局协调者。Transactional只能保证orderMapper.insert(dto)失败时订单服务本地回滚。它无法保证库存服务扣减成功与否。如果inventoryService.deduct()调用库存服务成功库存扣减已提交紧接着订单服务后续代码抛异常Transactional只能回滚订单库里的 insert库存服务里的扣减已经生效无法被自动撤销。于是出现了典型的跨服务数据不一致。1.3 为什么会出现“订单生成成功但库存没扣”这种假象还有另一种更隐蔽的场景。远程调用发生在本地事务提交之前但远程调用是成功的库存最终扣减了。本地订单插入也在远程调用之后正常返回。看起来两个服务都成功了可如果数据库在提交时连接断开、事务超时或者唯一索引冲突本地事务回滚订单没了但库存已经扣了。反过来也一样本地订单先插入并提交成功库存扣减接口网络超时。前端重试也好用户重新下单也好最终可能出现订单存在但库存未扣减或扣减了多次的情况。这种“部分成功”不是异常处理能完全兜住的。因为每个服务都认为自己本地事务成功了只有站在全局视角才能发现数据不匹配。而Transactional的视角从来都是局部不是一个全局协调者。所以在面试和项目设计里第一步不是选框架而是认清边界Transactional是本地事务注解解决不了跨服务全局一致性。真正要解决的问题是如何在多个本地事务之间达成可接受的最终一致并保证失败后可补偿、可恢复。2. 订单与库存的经典场景拆到每一步看数据流2.1 把目标场景固定下来下单、扣库存、更新订单状态为了讨论不跑偏我们固定一个最常见的业务链路用户创建订单订单服务写入order表状态为“待支付”或“已创建”。 订单服务调用库存服务库存服务扣减 sku 库存数量。 扣减成功后订单服务把订单状态更新为“库存已锁定”或“处理中”。这个场景里的三个动作分布在两个服务里涉及两个独立数据库。只要有一个环节失败整体数据就可能不一致。这里需要先明确一点如果库存服务和订单服务真的在同一个数据库里那完全可以通过本地事务完成甚至不需要拆服务。很多团队为了追求微服务而强行拆分最后反而引入了不必要的分布式事务问题。技术上能拆不等于事务边界也应该跟着拆。2.2 伪代码演示三个本地事务拼接出的“全局失败”用伪代码描述最直接// 订单服务 Transactional public Long createOrder(OrderInput input) { Order order Order.build(input); orderDao.insert(order); // 本地事务1订单库 InventoryOpResp resp inventoryClient.deduct(input); // 远程调用 if (!resp.isSuccess()) { throw new BusinessException(库存扣减失败); } orderDao.updateStatus(order.getId(), LOCKED); // 本地事务1的一部分 return order.getId(); }这串伪代码里真正被Transactional包裹的只有orderDao.insert和orderDao.updateStatus。远程调用inventoryClient.deduct的执行结果不属于事务的一部分它只作为条件判断。当库存扣减接口返回成功时库存数据库已经提交。随后如果orderDao.updateStatus因为order表状态字段不合法、乐观锁版本冲突等原因抛异常Transactional会回滚订单服务本地事务但库存服务已经执行的扣减无法自动回滚。当订单服务本地事务先提交客户单成功inventoryClient.deduct因为网络超时报错时订单状态仍然可能是“已创建”或“待锁定”库存没有扣减。用户看到订单生成了但仓库里还有库存超卖风险随之而来。2.3 三种最常见的失败时间线我通常会把失败情况归纳成三类方便新人建立判断模型第一类远程调用之前本地失败。订单插入本身就报错事务回滚库存服务根本没有被调用。这类问题最简单不需要分布式事务只要保证本地事务正确即可。第二类远程调用失败本地事务回滚。inventoryClient.deduct抛异常订单服务本地事务回滚订单没有生成扣减也没有发生。表面看一致实际上要确认库存服务是否真的没有扣减。如果库存服务收到请求后自己处理失败并返回失败那没问题如果请求发出后迟迟没响应客户端超时库存服务实际已经扣减成功那么订单和库存依然不一致。第三类远程调用成功后续本地操作失败。这是最危险的情况。库存服务扣减成功并提交订单服务后续更新失败本地事务回滚。最终库存少了订单也没生成或者订单状态还停留在错误状态。很多同学在面试时说“我只要在 catch 里调用库存冲正接口就行”但这个设计需要回答三个问题冲正接口超时怎么办冲正接口重复执行会不会重复加库存冲正失败时如何保证最终能恢复所以一个 catch 不能解决分布式事务它只能解决“单次调用失败”的临时补救。2.4 真正的难点不是失败而是“部分成功”无法自动回滚本地事务的回滚靠的是数据库的 undo 能力数据库能精确地恢复到事务开始之前的状态。跨服务没有共享 undo log每个服务只知道自己改了什么不知道别的服务改了什么。拿订单和库存来讲最终要恢复一致至少需要知道库存扣减流水发生了什么订单当前处于什么状态以及由谁负责发起补偿。这些信息单靠本地事务无法自动呈现必须由业务建立一套状态记录和补偿机制。这也是为什么所有分布式事务方案本质上都不是“让失败像本地事务一样自动回滚”而是“让失败的局部操作可以被识别、被重试、被补偿最终达到一致”。3. 分布式事务的几条路消息、SAGA、TCC怎么选3.1 可靠消息 本地消息表最常见的最终一致性起点本地消息表方案理解起来不复杂它把“发消息”变成数据表里的一条记录和业务操作放在同一个本地事务里。以订单创建为例本地事务 A 1. 写入 order 表 2. 写入消息表 message_outbox内容为“扣减库存” 3. 提交本地事务。事务提交后有一个异步任务轮询消息表把未发送的消息投递到 MQ或者直接调用库存服务。库存服务处理成功后回调更新消息状态。这样做的核心价值是业务操作和消息创建要么一起成功要么一起失败。不会出现“订单创建了但消息没写进去”的情况。即使异步任务挂了重启后仍然可以扫描message_outbox表把未发送的消息继续投递。需要强调的是消息表方案只能保证“至少一次发送”。库存服务可能收到重复消息所以业务消费方必须做幂等处理。比如扣库存操作在库存扣减流水表里加唯一索引重复消息会被忽略。这个方案的最大缺点是消息表与业务表耦合发送延迟取决于轮询频率消息量的增长也会对数据库产生一定压力。它不复杂很适合团队刚引入最终一致性时的第一步也经常作为面试中“不要只知道 Seata能说清楚本地消息表”的加分点。3.2 事务消息把“发消息”和“业务提交”放进同一套约束RocketMQ 提供事务消息很多团队在订单同步类场景使用。它和本地消息表相似只是把“本地消息表”迁到了 MQ 侧由 broker 进行半消息和状态检查。流程大概是这样订单服务先发送一条半消息到 MQ消息对下游不可见。执行本地事务写入订单数据并提交。本地事务成功后通知 MQ 提交半消息下游库存服务可以消费。如果本地事务执行期间进程宕机MQ 会回查订单服务的本地事务状态再决定是否提交消息。使用事务消息时消息的可见性和本地事务结果强绑定。这里同样要处理消费端幂等因为经过回查、重投消息依然可能重复消费。引入事务消息时要注意MQ 中间件的回查机制需要业务实现一个检查方法通常要求本地事务状态可查询。比如订单表里有state字段回查时读取订单状态判断是否应该提交消息。它比本地消息表更解耦但依赖 MQ 组件。如果团队本来没有 MQ不建议仅为了分布式事务引入一套中间件。先评估团队运维能力和已有基础设施。3.3 SAGA用业务流程拆解和补偿代替全局锁SAGA 的核心思路是一长串本地事务串联成全局流程每个本地事务都有对应的补偿动作。第 N 个本地事务失败时依次执行前面所有已提交事务的补偿。订单和库存用 SAGA 可以这样设计主流程创建订单 - 锁定库存 - 支付成功。补偿流程如果支付失败撤销订单状态释放库存。补偿动作本身是一个业务方法必须幂等。SAGA 适合长事务、业务流程步骤明确、各参与方边界清晰的场景。它追求的是最终一致性而不是实时一致性。中间某个时刻别的用户可能看到库存短暂扣减但订单未支付最终通过超时取消释放库存。SAGA 有两种实现方式编排式有一个中央状态机控制每一步比如订单服务作为协调者协同式各服务监听事件自行触发下一步和补偿回调。对面试或实际项目来说先讲清楚“正向流程 反向补偿 状态机”三个概念再落到自己出现的业务链路里会比背框架 API 更有说服力。如果已有状态机框架可以把 SAGA 变成一组状态节点和异常处理节点没有框架也不用手动重复造可以先从一张表维护流程状态开始。3.4 TCC适合前置资源预留和强控制场景TCC 是 Try-Confirm-Cancel 的缩写。它把每个参与方的事务操作拆成三个方法Try尝试执行业务预留资源。比如库存服务先冻结库存不直接扣减。Confirm确认执行业务。Try 成功且全局事务确认时把冻结库存转成实际扣减。Cancel取消执行业务。Try 成功但全局事务回滚时释放冻结库存。TCC 的价值在于 Try 阶段就能发现资源不足避免极端并发下的超卖。库存服务可以先检查并冻结库存冻结成功后才进入下一步。它是强业务侵入型的方案。每个参与方都必须实现 Try、Confirm、Cancel 三个接口。代码量明显增加而且 Confirm 和 Cancel 也要满足幂等。对于“订单 库存 账户余额”这类对一致性要求较高、短期操作可以接受资源预占的场景TCC 通常比纯消息方案更可控。但真实的账户或库存系统往往已经有自己的流水表想要快速改造成 TCC需要确认资源预留的语义不会破坏原有业务。面试时如果提到 TCC至少要说清楚“为什么用 Try 阶段预留资源而不是直接扣减”以及“Confirm 失败时的兜底”。没做过完整实现就把方案逻辑讲清楚不要装作跑过很多并发压测。3.5 为什么不推荐把 XA 和两阶段提交当默认选项XA/2PC 是最经典的强一致方案。它通过事务管理器统一调度多个资源执行 prepare、commit 或 rollback。它的优点是全局一致性强故障恢复机制理论完善。但缺点是协调者容易变成单点瓶颈。参与者资源在整个事务期间持有锁事务时间越长锁竞争越严重。prepare 阶段占用数据库资源高并发场景吞吐明显下降。网络分区、协调者宕机时两阶段提交可能长时间阻塞。很多分布式事务框架会内置 XA 模式的接口但生产环境的默认选项通常是 AT、TCC 或消息、SAGA。真正需要强一致的业务如果吞吐量不高数据量不大也可以考虑不拆服务直接用同一个数据库和本地事务如果已经拆了服务又非要用 XA就要非常谨慎地评估全局事务耗时和资源占用。3.6 解决方案横向对比与选型判断下面把这个链路里的常用方案放一张表格实际选型时重点看“一致性要求、吞吐量、业务耦合度、现有基础设施”这四列。方案一致性吞吐影响业务侵入依赖组件适用场景本地消息表最终一致中消息表写入影响库负载低数据库简单异步任务、通知、初始化类操作事务消息最终一致中依赖 MQ 性能低RocketMQ订单创建后推送消息解耦较干净的链路SAGA最终一致中无全局锁中状态机/MQ长流程、业务步骤多、适合补偿TCC最终一致但接近强一致低锁范围缩小到预占资源高业务实现三个接口库存、资金、资源预占类操作XA/2PC强一致低整体资源占用高低事务管理器与数据库支持少用适合低并发且必须强一致场景没有竞选的“最好方案”只有“当前业务最适合的方案”。订单和库存场景如果目标是防止超卖TCC 或 SAGA 比单纯消息更适合如果只是创建订单后通知库存准备拣货认为最终一致可接受可靠消息和事务消息就够用。4. 想清楚这两件事再选方案一致性等级和业务容忍度4.1 先判断业务是真的需要强一致还是最终一致就能满足很多团队一讨论分布式事务就默认“必须强一致”。但真实业务往往不需要。下单后通知积分系统、创建订单后发短信、商品上架后同步索引这类链路即使延迟几秒甚至几分钟用户也感知不到。用强一致方案反而会拖垮吞吐。判断标准可以看三点用户是否能容忍短暂的业务状态不一致不一致如果被其他用户观察到是否会造成重复扣款、库存超卖、资金平账问题系统是否具备对账、补偿、状态纠正能力如果资金和库存严格核算建议至少做到“资源预占或小粒度锁”如果只是异步通知类就选消息方案不要盲目上 TCC 或 XA。4.2 幂等设计是第一前提分布式事务方案只是补后手无论选哪一套方案幂等都是绕不开的前置条件。库存扣减要做到重复请求不重复扣减订单状态更新要避免从“已创建”又被更新成“已创建”补偿动作要避免释放两次库存。常见的幂等设计手段有幂等键每次调用生成唯一 requestId服务端记录处理过的 requestId。唯一索引在业务流水表上建唯一约束重复插入直接报错或忽略。状态机校验只有符合前置状态时才允许状态流转比如“已取消”的订单不能再次支付。没有幂等设计的分布式事务就像没有安全带的缓冲装置遇到异常时会反复产生脏数据。4.3 查询补偿、定期对账和状态机兜底分布式事务方案做不到百分百自动恢复。任何方案都会有消息丢失、回调失败、状态机卡死的可能。最终兜底的是三件事可查询每个服务能提供当前业务状态查询接口比如订单状态、库存扣减流水、消息表中的投递状态。可对账定时任务拉取两个服务的数据比对差异自动或人工修正。可补偿对账发现的异常任务对应有明确的补偿操作比如重新扣减、取消订单、返还库存。这套兜底能力甚至比选型更重要。面试时能主动讲“我会在基础设施加一张事务链路表用来记录每个节点完成了没有哪个节点需要重试”会明显拉开和背概念的人的差距。4.4 用一张决策清单辅助选型具体到订单和库存可以这样走明确跨了几个服务几个数据库。明确失败后用户可接受的恢复时间。判断资源是否可异步补偿比如库存可以冻结后释放就不是纯不可逆场景。如果库存是核心资源且并发高优先考虑 TCC 或“创建订单事件 - 库存冻结 - 状态推进”的事件流方案。如果核心诉求只是“下单后处理库存”接受最终一致事务消息或本地消息表成本最低。无论选哪个都必须补幂等、重试、状态表和定时对账。不要把决策变成“员工会用哪个框架就用哪个”。框架只是实现手段业务一致性和可维护性才是目标。5. 面试时怎么讲才不像背八股5.1 一个能复用的话术框架面试官问“分布式事务怎么处理”不建议上来就说“用 Seata”或“用 TCC”。我见过比较稳的回答套路是先把问题拆开确认当前场景是跨服务、跨库。说明Transactional只解决本地事务跨服务需要额外的方案。根据业务一致性要求给出选型。讲方案的同时落到“幂等、重试、补偿、对账”这四个可执行细节。话术可以这样组织“我们订单服务和库存服务是独立部署的各自有数据库。最开始我也想过直接加Transactional但很快就发现它只能控制当前服务本地事务远程调用失败后没法回滚库存服务。后来我们根据业务容忍度把链路改成最终一致性本地业务表收到好消息后异步调库存或者用 TCC 做库存冻结具体会看这个库存能不能冻结、需不需要实时锁定。不管选哪种我都会先保证消费端幂等再增加状态表和定时对账。”这段表达里没有堆砌名词但把技术边界、业务判断和运维兜底都说到了。5.2 面试官最常追问的几个点面试官如果继续追问通常会问三个方向追问一远程调用超时了你怎么知道库存扣没扣不能只回答“继续重试”。要说明“重试查库存流水”和“查订单状态”两个维度。先通过库存流水表确认是否已扣减再决定要不要回滚或补偿。追问二补偿和重试怎么保证不重复要主动讲“幂等键 去重表 状态流转”。比如库存补偿流水表里存 operationId唯一索引重复进来的补偿直接跳过。追问三如果 MQ 挂了你的最终一致方案还成立吗不要嘴硬说“MQ 不会挂”。可以说“本地消息表或事务消息都依赖 MQMQ 不可用时会先让业务进入降级状态比如保留一条待定时任务扫描的本地任务表等 MQ 恢复后继续投递。”5.3 常见减分回答和更稳的回答容易减分的回答包括“用 Transactional失败会自动回滚。” —— 没有边界感跨服务场景说不通。“直接用 SeataSeata 很成熟。” —— 没有结合业务也没有考虑 Seata 部署、运维和全局锁的影响。“用分布式锁把两个服务锁住。” —— 分布式锁只能控制互斥不能提供事务的原子性和一致性。“用消息队列消息发了就一定保证成功。” —— 没考虑消息丢失、重复消费和本地事务与发消息的原子性。更稳的回答是先把边界说清楚再讲方案取舍。结合当前业务的实时性和吞出要求。承认任何方案都有异常但强调有对账、补偿和状态检查兜底。5.4 在线面试模拟一次完整的问与答面试官订单服务调库存服务一个成功了另一个失败你怎么办候选人我先确认这两个服务是不是同一个数据库。如果是同一个库直接一个本地事务就结束了。面试官不是同一个库。候选人那我就不会只用Transactional因为它管不到库存服务那边。我会先看业务一致性要求。假设是下单后必须实时锁库存防止超卖我会考虑在库存服务做 TCCTry 阶段先把库存冻结订单提交时再 Confirm订单回滚时 Cancel。同时给库存操作加 requestId冻结、确认、取消都做幂等。面试官那 Cancel 失败了呢候选人那就需要定时对账扫描超时未终态的冻结单把超过业务容忍时间的冻结库存释放。另外订单侧也会维护一个订单状态表对账任务会比对订单状态和库存冻结状态发现不一致就触发补偿。正常情况下消息方案或 TCC 只能处理绝大多数异常剩下少量异常靠对账兜底。这套回答里没有停留在概念层而是把方案和兜底串成了一条闭环。6. 落地时的工程红线哪些地方不能依赖 Transactional6.1 事务内不能包裹远程调用远程调用的耗时往往在几十毫秒到几秒之间把它放在Transactional里数据库连接和事务都会被拉长。连接池一旦被占满后续请求排队接口超时最终拖垮整个服务。同时远程调用会增加事务的不确定因素比如网络闪断、对端超时、对端假死。本地数据库事务却不能在远程结果未知时无限等待。如果远程调用了很久数据库事务默认超时时间一到本地事务回滚对端却可能已经提交。实践上建议事务方法只处理本地数据库读写。远程调用放到事务提交之后通过事件、消息或独立编排步骤执行。如果确实需要在远程调用后修改本地状态拆分状态机分阶段更新。6.2 事务里不要直接发 MQ 消息一个常见错误是在Transactional方法里调用 MQ producer 发送消息。如果消息发送成功但本地事务回滚消息已经进入消息队列下游会消费到一条无效业务请求。例如订单创建后发送“扣库存”消息Transactional public void createOrder(...) { orderDao.insert(order); mqTemplate.send(stock-deduct, order); // 消息在事务提交前发出 }如果mqTemplate.send执行后orderDao.insert正常返回但事务提交时发生异常订单库中没有订单消息却已经被消费方收到库存被扣减了就会造成孤儿扣减。解决思路有几种将“写业务表”和“写消息表”放到同一个本地事务然后由后台任务提交消息。使用支持事务消息的 MQ。使用TransactionSynchronizationManager.registerSynchronization在事务提交后发送消息但也要注意提交后发送失败的情况仍需要状态检查和重试。不管选哪种核心原则都是“消息发送必须在事务提交后才能对外可见”。6.3 数据源、连接池、事务超时都要单独设置跨服务事务改造时很多人只关注方案忽略基础配置。分布式场景里事务时间过长最容易触发数据库锁和连接池排队。至少关注这几项连接池大小核心服务连接池不要设置得过小否则并发增加时容易阻塞。事务超时Transactional(timeout ...)只对当前事务管理器有效不能跨服务设置。数据库锁等待时间确认innodb_lock_wait_timeout等参数避免长事务阻塞其他业务。网络超时和重试远程调用要设置明确的超时比如 1 秒或 3 秒超时后进入重试或补偿流程而不是无限等待。这些参数之间是有联动关系的。连接池越小事务耗时越久越容易出现资源枯竭。排查问题时不要只看应用日志还要看连接池状态、数据库活跃会话和慢 SQL。6.4 架构层面能避免分布式事务就尽量避免最省力的分布式事务就是不产生分布式事务。常见思路把订单和库存数据放到同一个库甚至同一个 schema用本地事务完成。把需要强一致的资源操作放到同一个服务避免服务间拆分。使用“状态机和事件”替代“同步调用”把强一致改成最终一致。用“应用层幂等”把重复调用变成安全操作。微服务拆分时先问一句这两个数据真的必须分库吗如果只是服务边界明确但数据库可以共享很多时候可以直接用本地事务或用多条 SQL 配合锁完成。为了微服务而分库会引入大量不必要的复杂性问题。6.5 如果线上已经踩了跨服务事务的坑怎么逐步改造已经上线的系统可能大量存在“事务里调远程”的代码不能一次性全部重构。我建议按四步走。第一步先盘点所有Transactional方法把包含远程调用、消息发送、长时间 IO 的方法标记出来。第二步根据业务影响分类影响资金和库存的最高优先改造不涉及核心数据可以放后。第三步把最高优的场景拆成“本地事务 MQ 事件”或“TCC/SAGA”模型先加状态表和幂等去重再迁移调用链路。第四步增加对账任务持续观察改造前后数据差异。改造过程中最重要的一点是保留一条“老链路”的兼容期先让新链路在影子流量或灰度环境跑一段时间对比订单和库存流水是否一致确认没有问题后再切断老链路。不要一次性地把事务方法全改掉否则出问题时无法快速定位是数据库锁、消息丢失还是补偿逻辑写错。最终你会发现分布式事务不是一个框架的事。它是业务建模、服务拆分、消息可靠投递、消费幂等、状态机和对账体系共同作用的结果。Transactional该用的时候继续用但它只负责本地事务。跨服务场景真正靠的是把每一步都设计成可重试、可补偿、最终一致的工程动作而不是指望一个注解把事情全部扛下来。