如果你在面试里被问到“分布式事务”,下意识想回答“在方法上加上@Transactional不就行了”,那这场面试大概率会走不远。这个场景不是凭空想象,我在很多团队的实际代码里都见过:订单服务创建订单,又去调用库存服务扣库存,整个方法用 Spring 的 @Transactional 包住,本地测试一切正常,上线后却出现库存扣了但订单不存在、订单存在但库存没扣的问题。更常见的是面试时被追问一句“你这个事务能保证跨服务一致吗”,然后气氛突然安静。
跨服务场景下,@Transactional 只是本地事务,它的作用域是单个数据源、单个事务管理器。真正的分布式事务,需要的是二阶段提交、TCC、消息最终一致、SAGA 这类专门方案,而不是一个注解。选择哪一种,取决于你的业务能接受多长的一致性窗口,也取决于你能付出多少一致性成本。这不是一个技术选型问题,本质上是一个架构和业务取舍问题。
1. 先承认一件不舒服的事:本地事务注解撑不起跨服务一致性
1.1 @Transactional 到底做了什么
Spring 的 @Transactional 看起来很神奇,毕竟开发者只需要在方法上写一个注解,方法里所有数据库操作就仿佛获得了“要么全成功、要么全失败”的保障。但它的实现机制决定了适用范围:Spring 通过 AOP 在方法执行前开启事务,获取一个数据库连接,绑定到当前线程,然后在方法执行结束后根据是否抛异常决定 commit 或 rollback。
这个机制的关键点有两个:
- 事务管理的是同一个数据源连接。
- 事务边界是当前 Spring 管理的方法。
也就是说,@Transactional 能把同一条数据库连接上的多个 SQL 操作包在一个数据库本地事务里。如果方法里全部操作都落在同一个数据库上,它可以保证本地事务的原子性。比如在一个用户服务里,同时更新用户表和用户日志表,只要两个表在一个库中,用 @Transactional 是合适的。
但注意,这里没有“跨服务”三个字。一旦涉及另一个服务、另一个数据库、另一个连接,Spring AOP 管不了远程调用那边的资源。
1.2 跨服务时,Spring 的“手”够不到另一个数据库
订单服务和库存服务是两个独立进程,通常还各自拥有独立数据库。订单服务方法里调用库存服务接口时,Spring 的事务代理只能拦截订单服务自身的数据库连接;库存服务的数据库连接在库存服务进程里,由库存服务自己的事务管理器控制。
两者之间没有共享事务上下文,也没有全局协调者。因此,订单服务回滚时,无法把库存服务已经提交的数据“拉回来”;库存服务回滚时,订单服务也不一定知道。
一个非常典型的失败时序是:订单服务调用库存服务扣减库存,库存服务已经扣减成功,但由于网络超时,订单服务收到的是异常。订单服务回滚本地事务,订单没有生成,库存却被扣掉了。另一个典型时序是:订单服务先扣库存,后写订单,结果订单写入失败触发回滚,库存照样多了或少了。无论哪种情况,@Transactional 都无能为力,因为这些操作分散在两个独立事务里。
1.3 嵌套事务不是分布式事务
有人说:“我在服务 A 方法里调用服务 B 方法,两个方法都加了 @Transactional,传播行为用 REQUIRED,那不就能嵌套事务了吗?”
这种理解只适用于同一个进程中、同一个数据源上的事务嵌套。Spring 的传播行为确实支持多个方法加入同一个物理事务,但实现时靠的是同一个数据库连接。如果服务 B 是独立服务,走的是 HTTP 或 RPC,它的数据库连接不在当前线程里,REQUIRED 根本不可能让两个服务共享一个事务。
真正意义上的嵌套事务,在单库场景靠 savepoint 实现,也只能在同一事务里回滚到某个保存点。跨服务时,没有这个保存点机制。所以,嵌套事务不能成为分布式事务的替代品。
注意:判断一个事务方案是否解决跨服务问题,先问一句“它协调了哪些资源管理器”。如果只有一个数据库连接在参与事务,那它只能是本地事务。
2. 为什么订单与库存这种场景,最容易踩进“假事务”的坑
2.1 先用一条最常见的链路复现问题
假设有一个下单接口,核心逻辑长这样:
@Transactional public void createOrder(CreateOrderRequest req) { orderMapper.insert(convert(req)); stockService.deduct(req.getSkuId(), req.getCount()); }stockService.deduct() 底层是一个 HTTP 调用,库存服务里再开一个本地事务,执行库存扣减 update 语句。这段代码在联调环境跑得很顺,因为库存充足、网络不抖动、也没有并发。
一旦到了生产环境,问题开始出现。比如库存服务响应变慢,订单服务的 HTTP 客户端超时,但库存服务其实已经完成了扣减。订单服务抛异常回滚,订单没生成,库存却减少了。又比如库存服务扣减成功后返回成功,但订单服务接下来某个本地操作失败回滚,导致只有库存被扣,没有订单。每一个异常分支都在破坏“订单与库存一致”的目标。
2.2 你以为的原子性,在四个异常点碎了一地
跨服务调用的失败场景远比想象中复杂。最常见的是这四个:
- 库存扣减成功,订单本地事务提交失败。结果是库存变少,订单不存在。
- 订单提交成功,库存调用超时但实际扣减成功。结果是订单存在,库存也变少,但用户可能因下单失败选择重试,导致重复下单和库存重复扣减。
- 库存扣减成功,但调用方重试。超时后调用方不确认结果,自动重试,可能造成重复扣减。
- 两个服务都成功,但中间状态对外可见。比如订单状态是“已创建”,库存已经预扣,用户取消订单后,库存没有及时恢复。
这四个异常点说明:分布式事务的一致性不只是“都成功或都失败”,还包括状态不确定、重复请求、中间态可见等更细粒度的问题。使用本地事务注解根本无法覆盖这些分支。
2.3 真正的变量不是 SQL,而是“什么时候提交,什么时候补偿”
很多团队在排查这类问题时,第一反应是看 SQL 有没有写错。但真正的问题往往不在 SQL,而在于数据库事务的提交时机和失败后的补偿策略。
单库事务里,所有操作在同一个事务中,最后一起 commit;跨服务场景里,订单服务的提交动作和库存服务的提交动作是分开的。如果库存服务先提交,订单服务后提交,那么两个提交点之间就存在一个一致性窗口。在这个窗口内,任何一次失败,都必须有业务层面的补偿动作:要么取消订单,要么加回库存,要么进入待人工处理状态。
所以,不是“扣库存 SQL 写得不对”,而是“当库存已经提交成功,但订单服务无法提交时,谁来把库存加回去”。这个问题需要独立的事务协调方案来回答。
3. 把分布式事务的几种解法看透,才知道怎么选
3.1 2PC/XA:教科书式的强一致,为什么在微服务里不常用
二阶段提交(2PC)是最经典的分布式原子性协议。它引入一个协调者,先让所有参与者执行 prepare,确认各自能提交后,再由协调者广播 commit。如果某个参与者 prepare 失败,所有人都 abor。
2PC 的优点是能提供强一致,听起来很完美;但缺点的代价也很大:
- 整个协议是同步阻塞的,prepare 后连接会被长期占用。
- 协调者单点,一旦协调者故障,参与者会一直等待,造成资源卡死。
- 参与者如果 prepare 成功后网络中断,协调者无法知道它最终是否提交,于是就需要额外决策规则,甚至引入 3PC。
XA 是 2PC 在数据库层面的实现。它要求每个参与资源都支持 XA 协议,数据库、消息队列都可以,但很多场景中的外部服务、缓存、HTTP 接口并不具备这种能力。
在微服务架构里,跨服务调用通常不是“多个数据库参与一个事务”,而是“多个业务服务参与一个流程”。数据库 XA 只能覆盖同一种资源类型,管不了业务服务内部的复杂状态。因此,2PC/XA 更适合参与方很少、资源类型统一、并发不高的内部系统,不适合互联网高并发下单链路。
3.2 TCC:用业务补偿换可控性
TCC 是业务层面的两阶段事务模型,三个核心操作分别是:
- Try:预留资源。比如扣库存时,先冻结数量,不直接扣减。
- Confirm:确认执行。比如冻结成功后,真正扣减库存。
- Cancel:取消补偿。比如冻结失败或业务取消时,释放冻结数量。
TCC 不依赖数据库的分布式事务协议,可以把服务接口纳入事务过程。它适合需要明确预占资源的场景,比如库存扣减、账户冻结、积分占位。
但 TCC 的代价是业务侵入性非常高。每个参与方都要实现 Try、Confirm、Cancel 三个操作,还要处理空回滚、幂等、悬挂等异常情况。比如 Cancel 可能在 Try 还没执行时就被触发,这时候需要识别并做空回滚。这些细节如果没处理好,反而会引入更多一致性问题。
所以,TCC 不是银弹。它适合高价值、需要对资源做显式控制的业务,但只有在团队有能力承担大量业务补偿代码时,才能发挥价值。
3.3 本地消息表与事务消息:用最终一致换性能
在很多互联网业务里,我们并不需要“扣库存”和“创建订单”在同一个瞬间同时成功。我们可以接受一个短暂的时间窗口:订单创建后,库存稍后异步扣减成功,最终两边一致。
本地消息表的核心思路是:把一条“待发送消息”和业务数据放在同一个本地数据库事务里。比如订单表插入一条订单,消息表插入一条“扣减库存”消息,然后由定时任务轮询消息表,把消息发送给 MQ 或直接调用库存服务。只要消息没有被成功投递,就不断重试,直到消费方确认。
事务消息则把这个过程下沉到消息中间件。以 RocketMQ 的事务消息为例,发送方先发一条半消息,然后执行本地事务;本地事务执行成功后 commit,消费者才能看到消息;如果本地事务一直没 commit,消息中间件会反查本地事务状态。这样就把“本地业务提交”和“消息可见”绑定在一起。
这个方案最大的优势是性能好、不阻塞主链路,适合订单、库存、积分、通知等场景。但它只保证最终一致,不保证强一致。消费方可能需要幂等处理,消息也可能延迟,业务上要做对账兜底。
3.4 SAGA:把长事务拆成可补偿的短事务
SAGA 是一种面向长流程的分布式事务方案。它把一个完整业务流程拆成多个本地事务,依次执行:先扣库存,再创建订单,再支付。每一步独立提交,如果某一步失败,就逆向执行之前每一步的补偿操作。比如扣库存的补偿是加回库存,创建订单的补偿是关闭订单。
SAGA 有两种常见实现方式:事件编排和状态机编排。事件编排里,每个服务完成本地事务后发布事件,下一个服务订阅事件继续执行;状态机编排则通过一个流程引擎维护整体状态,按状态定义正向操作和逆向操作。
SAGA 的优点是适合长时间运行的多阶段流程,不长时间占用资源。但它的中间状态对外是可见的,而且补偿操作本身也需要可靠执行,否则会出现部分成功、部分失败但无法恢复的情况。
3.5 选型对比:没有万金油,只有取舍
| 方案 | 一致性 | 性能/资源占用 | 侵入性 | 典型场景 |
|---|---|---|---|---|
| 2PC/XA | 强一致 | 低,阻塞且协议开销大 | 低,依赖资源支持 XA | 参与方少、低并发、资源类型统一 |
| TCC | 业务级一致 | 中 | 高,需要实现三套操作 | 高价值业务、资源预占、跨服务 |
| 本地消息表/事务消息 | 最终一致 | 高 | 中,需要额外表或 MQ 配置 | 异步链路、削峰、非实时场景 |
| SAGA | 最终一致(中间态可见) | 中高 | 中高,需要定义补偿流程 | 长流程、多阶段、允许中间态 |
实际项目中,不一定只选一种。比如主链路用 TCC 保证库存和订单,通知、积分等非关键链路边用消息最终一致。选择方案的核心指标不是“哪个更先进”,而是“业务到底能接受哪种一致性窗口,团队能不能维护对应的复杂度”。
4. 面试官真正想听的不是定义,而是你的决策过程
4.1 先问“这个场景真的需要分布式事务吗”
分布式事务方案听起来越高级,越容易让人忘记一个根本问题:这个业务是不是真的处于分布式事务边界。
如果你有两个服务,但它们访问的是同一个数据库,只是接口拆开了,那么本地事务可能已经够用。甚至有些服务拆分之后反而把数据依赖变复杂了,这时候不是引入更强的事务框架,而是重新考虑服务边界。
如果多个服务之间没有强一致需求,比如下单后发短信、发优惠券,完全可以用消息队列异步处理。硬把它们纳入一个强一致事务,只会压低吞吐、增加故障面。
面试时先问清“一致性要求是什么”,会让回答比直接背方案高出好几个层次。工程上也是一样,先判断问题边界,再决定要不要用重型方案。
4.2 回答框架:调用链、一致性要求、失败分支、补偿设计
如果面试官追问“具体怎么设计”,可以按下面这个四步框架回答:
- 画出调用链:哪些服务参与了同一个业务变更,每个服务负责哪些数据变更,调用顺序是什么。
- 明确一致性要求:这个业务是强一致还是最终一致?允许的窗口期是多少?
- 拆解失败分支:每个调用步骤失败时,哪些服务的数据已经提交了?哪些还处于未提交状态?
- 设计补偿与对账:已经提交的服务如何补偿?重试和幂等怎么做?定时对账如何兜底?
这个框架的好处是,它把“分布式事务”从抽象概念拉回到具体业务场景。面试官听到的不仅是对方案的了解,还有处理实际问题的能力。如果能把某个具体业务套进这个框架里分析,效果更好。
4.3 一个可复用的“消息+本地事务”最小示例
以订单创建和库存扣减为例,用事务消息来设计最终一致方案。订单服务在本地事务里写订单表,并发送一条事务消息,由消息中间件保证订单业务数据和消息发送一致。
// 订单服务 @Transactional public void createOrder(CreateOrderRequest req) { orderMapper.insert(convert(req)); // 事务消息:本地事务提交后,消息才会对消费者可见 rocketMQTemplate.sendMessageInTransaction( "stock-deduct-topic", buildStockMessage(req), req ); }库存服务监听这个主题,消费到消息后执行库存扣减。扣减成功则返回 ACK;扣减失败则进入重试;一直失败则进入死信队列,触发告警和人工处理。
为了让消费方能够安全重试,每条消息里最好带上业务唯一键,比如订单号或幂等号。消费方在扣减前先查询流水表,如果已经处理过就直接 ACK,避免重复扣减。
这段代码只是示例结构,不是为了让你抄到项目里直接用。真正落地时还要做事务消息的回查监听、消费者幂等、死信处理、对账任务,以及确认你的 MQ 版本确实支持事务消息。这也是一个很重要的经验:任何分布式事务方案都要在真实环境里做小流量验证,而不是写完代码就上线。
4.4 落成代码后,怎么排查现场
分布式事务问题最麻烦的地方,是问题往往不固定复现。排查时可以按下面的顺序走:
- 先看现象:是超卖、少库存、订单状态卡住,还是数据长期不一致?
- 看日志和调用链:确认每个服务的本地事务在哪个节点提交或回滚,调用到底成功没有。
- 查超时重试:是不是 HTTP/RPC 超时导致调用方不知道真实结果,于是重试了?
- 看消息链路:消息是否投递成功,消费方是否消费,是否重复消费?
- 检查幂等:每个写操作是否有唯一业务键,能不能安全重试。
- 走对账:用定时任务扫描订单和库存流水,找到不一致记录,先隔离再修复。
这里要特别提醒,很多“分布式事务异常”其实不是分布式事务框架的问题,而是出在超时设置、重试策略和幂等等基础能力上。先查基础,再怀疑框架,排查顺序不要反过来。
注意:遇到不一致问题,不要急着改代码。先把当时的调用链日志、消息投递记录、数据库流水对齐,确认是哪一环断了,再决定修哪里。
4.5 哪些场景,千万别硬上分布式事务
分布式事务不是越强越好。以下几种场景,我会建议不要硬上:
- 高并发热点扣减场景:比如库存数量极少、抢购压力极大。强行加分布式事务框架会增加协调开销和锁等待,不如把库存扣减设计成独立服务,配合预扣、超时释放和异步通知。
- 外部系统不接受事务控制:比如第三方支付或物流,只有接口给对方,对方不参与你的事务协议。这时候只能靠回调、消息和对账保证最终一致。
- 多个服务可以用一个服务承接:如果为了微服务而微服务,导致同一个库被多个服务分别写入,事务边界反而更复杂。这时候重新合并服务或调整库结构,比引入分布式事务更合理。
- 业务一致性窗口无法接受:比如资金转账、账户提现,这类业务通常要求强一致或准实时一致。不能简单用异步消息糊弄,必须设计流水、冻结、确认、冲正等机制。
在这些场景里,真正的解法往往是调整业务模型,而不是把重型事务框架塞进来。这比任何具体方案都重要。
5. 事务观比事务方案更重要
5.1 从一个注解开始,建立流程思维
回到最初的 @Transactional。它并没有错,错的是它被当作跨服务一致性的万能解。在单服务、单数据源范围里,它非常可靠;一出这个边界,就不再适用。
观察一个开发者是否真正理解事务,不是看他能否背出 TCC 的英文全称,而是看他能不能准确说出“这个事务边界覆盖了哪些资源,如果其中一个资源已经提交,如何恢复”。这需要一个流程思维,而不是注解思维。
5.2 一致性是设计出来的,不是补出来的
数据一致性不能完全靠事后补偿解决,更应该在设计阶段就定义清楚。比如下单链路里,哪些操作可以异步化,哪些必须同步强校验;失败时是重试、补偿,还是直接进入人工处理;怎么用唯一订单号做幂等;状态机里正反向操作分别是什么。
这些设计做在前面,分布式事务才能真正稳定。如果只靠加一个框架或者在方法上堆注解,后面大概率会一直被对账和事故电话追着跑。
5.3 真正高级的能力:提前判断边界
面试和实战都在反复验证一件事:高手不一定能解决所有一致性问题,但能提前判断哪些地方需要强一致,哪些地方可以最终一致,哪些地方根本不需要事务。
当你能说出“这个场景不需要分布式事务,用本地事务加幂等就能解决”,或者“这里需要 TCC,因为要预留库存”时,你对分布式事务的理解已经不再是名词清单,而是一套能落地的取舍逻辑。
所以,下次再有人问分布式事务时,别急着回答“加 @Transactional”。先把这个问题的边界拆清楚,也许答案会简单很多,也会可靠很多。