☰
@Transactional管不到跨服务:分布式事务方案选型与幂等设计
2026/10/10 18:41:33 网站建设 项目流程

这道面试题的陷阱,不是“你不会分布式事务”,而是“你用@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 进行半消息和状态检查。

流程大概是这样:

  1. 订单服务先发送一条半消息到 MQ,消息对下游不可见。
  2. 执行本地事务,写入订单数据并提交。
  3. 本地事务成功后,通知 MQ 提交半消息,下游库存服务可以消费。
  4. 如果本地事务执行期间进程宕机,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 用一张决策清单辅助选型

具体到订单和库存,可以这样走:

  1. 明确跨了几个服务,几个数据库。
  2. 明确失败后用户可接受的恢复时间。
  3. 判断资源是否可异步补偿,比如库存可以冻结后释放,就不是纯不可逆场景。
  4. 如果库存是核心资源且并发高,优先考虑 TCC 或“创建订单事件 -> 库存冻结 -> 状态推进”的事件流方案。
  5. 如果核心诉求只是“下单后处理库存”,接受最终一致,事务消息或本地消息表成本最低。
  6. 无论选哪个,都必须补幂等、重试、状态表和定时对账。

不要把决策变成“员工会用哪个框架就用哪个”。框架只是实现手段,业务一致性和可维护性才是目标。

5. 面试时怎么讲,才不像背八股

5.1 一个能复用的话术框架

面试官问“分布式事务怎么处理”,不建议上来就说“用 Seata”或“用 TCC”。我见过比较稳的回答套路是:

  1. 先把问题拆开,确认当前场景是跨服务、跨库。
  2. 说明@Transactional只解决本地事务,跨服务需要额外的方案。
  3. 根据业务一致性要求给出选型。
  4. 讲方案的同时,落到“幂等、重试、补偿、对账”这四个可执行细节。

话术可以这样组织:

“我们订单服务和库存服务是独立部署的,各自有数据库。最开始我也想过直接加@Transactional,但很快就发现它只能控制当前服务本地事务,远程调用失败后没法回滚库存服务。后来我们根据业务容忍度,把链路改成最终一致性:本地业务表收到好消息后异步调库存,或者用 TCC 做库存冻结,具体会看这个库存能不能冻结、需不需要实时锁定。不管选哪种,我都会先保证消费端幂等,再增加状态表和定时对账。”

这段表达里没有堆砌名词,但把技术边界、业务判断和运维兜底都说到了。

5.2 面试官最常追问的几个点

面试官如果继续追问,通常会问三个方向:

追问一:远程调用超时了,你怎么知道库存扣没扣?

不能只回答“继续重试”。要说明“重试查库存流水”和“查订单状态”两个维度。先通过库存流水表确认是否已扣减,再决定要不要回滚或补偿。

追问二:补偿和重试怎么保证不重复?

要主动讲“幂等键 + 去重表 + 状态流转”。比如库存补偿流水表里存 operationId,唯一索引,重复进来的补偿直接跳过。

追问三:如果 MQ 挂了,你的最终一致方案还成立吗?

不要嘴硬说“MQ 不会挂”。可以说“本地消息表或事务消息都依赖 MQ,MQ 不可用时会先让业务进入降级状态,比如保留一条待定时任务扫描的本地任务表,等 MQ 恢复后继续投递。”

5.3 常见减分回答和更稳的回答

容易减分的回答包括:

  • “用 @Transactional,失败会自动回滚。” —— 没有边界感,跨服务场景说不通。
  • “直接用 Seata,Seata 很成熟。” —— 没有结合业务,也没有考虑 Seata 部署、运维和全局锁的影响。
  • “用分布式锁把两个服务锁住。” —— 分布式锁只能控制互斥,不能提供事务的原子性和一致性。
  • “用消息队列,消息发了就一定保证成功。” —— 没考虑消息丢失、重复消费和本地事务与发消息的原子性。

更稳的回答是:

  • 先把边界说清楚,再讲方案取舍。
  • 结合当前业务的实时性和吞出要求。
  • 承认任何方案都有异常,但强调有对账、补偿和状态检查兜底。

5.4 在线面试模拟:一次完整的问与答

面试官:订单服务调库存服务,一个成功了另一个失败,你怎么办?

候选人:我先确认这两个服务是不是同一个数据库。如果是同一个库,直接一个本地事务就结束了。

面试官:不是同一个库。

候选人:那我就不会只用@Transactional,因为它管不到库存服务那边。我会先看业务一致性要求。假设是下单后必须实时锁库存,防止超卖,我会考虑在库存服务做 TCC:Try 阶段先把库存冻结,订单提交时再 Confirm,订单回滚时 Cancel。同时给库存操作加 requestId,冻结、确认、取消都做幂等。

面试官:那 Cancel 失败了呢?

候选人:那就需要定时对账扫描超时未终态的冻结单,把超过业务容忍时间的冻结库存释放。另外订单侧也会维护一个订单状态表,对账任务会比对订单状态和库存冻结状态,发现不一致就触发补偿。正常情况下消息方案或 TCC 只能处理绝大多数异常,剩下少量异常靠对账兜底。

这套回答里没有停留在概念层,而是把方案和兜底串成了一条闭环。

6. 落地时的工程红线:哪些地方不能依赖 @Transactional

6.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该用的时候继续用,但它只负责本地事务。跨服务场景真正靠的是把每一步都设计成可重试、可补偿、最终一致的工程动作,而不是指望一个注解把事情全部扛下来。

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

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

立即咨询