1. 一次线上事故,把分布式事务逼到了台前
先讲个真实经历。几年前我还在做电商后台,某个大促前夕,订单服务调用库存服务扣减库存,结果库存服务那边数据库连接池被打满,接口超时。订单这边一看超时,直接抛异常回滚了本地事务,但库存服务那边其实已经扣成功了。结果就是用户下单失败,库存却少了,最后对账发现少了几百件货,只能人工补偿。从那以后,“分布式事务”这四个字算是刻进骨子里了。
这个场景现在几乎是Java面试里绕不开的必考题。你去翻各大厂的面试题,十有八九会问到“订单和库存的数据一致性怎么保证”“Seata的AT模式原理是什么”“2PC和TCC有什么区别”。面试官问这些,不是说让你背几个名词,而是想看你有没有真正处理过跨服务数据一致性的问题。这篇文章就把分布式事务从原理到实现、从方案选型到面试话术,完整拆一遍。
内容会包括:分布式事务为什么难、CAP和BASE理论怎么理解、2PC/TCC/本地消息表/MQ事务消息/最大努力通知这几种方案的底层逻辑和适用场景、Seata AT模式的实现原理,以及面试中高频追问的坑和话术。无论你是准备面试,还是工作中真要落地分布式事务,这篇文章都值得收藏慢慢看。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分布式事务的底层逻辑:为什么单机事务那套玩不转了
2.1 ACID在分布式环境下失效的根本原因
先把基础打牢。单机事务的ACID大家都很熟:原子性、一致性、隔离性、持久性。在单个数据库里,靠的是undo log、redo log、行锁、MVCC这些机制,由数据库自己保证。但你一旦把数据拆到多个服务、多个数据库里,问题就来了:没有全局的锁管理器,没有共享的日志,更没有统一的事务管理器。
最简单的例子:下单要扣库存。订单库在订单服务里,库存库在库存服务里。订单服务先开启本地事务,写完订单表,然后通过RPC调用库存服务去扣库存。这时候库存服务的事务是独立的,它不知道订单服务的事务会不会回滚;订单服务的事务也控制不了库存服务的事务。两边各管各的,一旦中间任何一环出问题,就会产生“部分成功、部分失败”的脏数据。
这就是分布式事务要解决的核心问题:让多个独立资源管理器上的操作,在外界看来要么全部提交、要么全部回滚。不能靠单个数据库了,必须有一个协调者,或者一种协议,把多个节点绑在一起,达成最终一致。
2.2 CAP理论:分布式事务能做的和不能做的
说到分布式事务,CAP是绕不开的。C是一致性,A是可用性,P是分区容错性。网络分区在分布式环境里是必然事件,所以P必须满足,于是你只能在C和A之间做取舍。
这个理论落到分布式事务上,含义很深刻:如果追求强一致,意味着在分布式事务执行期间,参与节点必须锁住资源,其他请求要等待,这会牺牲可用性;如果追求高可用,就不能等所有节点都确认,只能让数据在一段时间内不一致,后面再补。
所以你看市面上所有分布式事务方案,本质上都是在CAP里选边站:
- 2PC、Seata AT模式属于CP倾向,追求强一致,牺牲的是可用性窗口。
- TCC属于CA偏CP,但通过业务逻辑把锁粒度拆得更细。
- 消息事务、本地消息表属于AP倾向,追求最终一致,允许中间状态存在。
面试官问CAP,想听的其实不是概念背诵,而是“你能不能用它解释方案选型”。比如为什么订单场景不用2PC而用消息最终一致?因为下单链路对可用性要求极高,你不可能让用户在下单时因为库存服务抖动就卡死、重试、失败。这个分析思路比背概念值钱得多。
2.3 BASE理论:最终一致才是分布式系统的常态
BASE理论是对CAP里AP方向的一个落地指导:基本可用、软状态、最终一致。核心意思就是:不追求任何时刻强一致,但保证系统对外基本可用,允许中间有一段时间数据不一致,最终通过补偿、对账、重试等手段把数据纠正过来。
这里有个很容易被误解的点:最终一致不等于“随便不一致”。它只是把一致性的时间窗口放宽了,但放多宽、怎么检测不一致、怎么补偿,都需要设计。比如订单已创建、库存扣减还在途,这时候用户查订单状态,应该看到“处理中”,而不是直接看到成功或失败——这就是软状态在业务上的体现。
理解了CAP和BASE,你再看后面的具体方案,就会非常清晰:每一种方案都是在特定的业务约束下,对一致性和可用性做的一次权衡,没有银弹,只有取舍。
3. 四种主流分布式事务方案:原理、优缺点和适用场景
3.1 2PC两阶段提交:教科书级别的强一致方案
2PC是最经典的分布式事务协议,分准备阶段和提交阶段。协调者先问所有参与者“能不能提交”,大家都说OK了,再广播“提交”;只要有一个人说不行,就广播“回滚”。
听起来很完美,但实际用起来问题不少。最大的问题是阻塞:准备阶段所有参与者都锁着资源,等协调者的最终指令。如果协调者挂了,参与者只能一直等,事务卡住,资源释放不了。这就是为什么2PC在实际生产里很少直接用的原因——单点故障就能拖垮整个链路。
还有一个问题是数据不一致:如果第二阶段提交过程中,协调者给部分参与者发了提交指令后自己挂了,有的节点提交了,有的没提交,数据就分叉了。所以2PC看起来是强一致,但其实只是提交阶段的强一致,提交之后仍然有窗口期。
不过2PC也有它的位置。如果你的事务涉及节点不多、链路较短、对一致性要求极高,而且能接受一定的阻塞风险,比如银行核心账务,那2PC仍然可接受。Java里的Atomikos、Narayana就是基于XA协议实现了2PC。
3.2 TCC:业务侵入强,但灵活度最高的方案
TCC是Try-Confirm-Cancel三段式。Try阶段做资源检查和预留,Confirm阶段执行真正的业务提交,Cancel阶段做回滚补偿。它不像2PC那样锁数据库资源,而是用业务上的预占来做“软锁”。
举订单的例子:Try阶段把库存从“可用库存”移到“预占库存”,然后返回成功。如果订单和库存整体成功,就调Confirm,把预占库存真正扣掉;如果失败,就调Cancel,把预占库存释放掉。
TCC的优点是灵活,不需要数据库锁,性能比2PC好得多,而且能跨异构系统。缺点是业务侵入极强,每个参与方都要写Try、Confirm、Cancel三个方法,还要处理幂等、悬挂、空回滚这些边界问题,实现成本相当高。
面试的时候,如果面试官问TCC,你要能说出来三个坑:一是Confirm和Cancel必须幂等,因为可能被重试多次;二是Try成功但Confirm没执行时,要先执行Cancel,这叫空回滚要怎么处理;三是Try阶段如果网络超时,到底执行没执行都不知道,后续怎么保证不悬挂。能说清楚这三点,面试官基本就认可你真做过。
3.3 本地消息表:最朴素可靠、成本最低的方案
本地消息表算是国产架构里的经典方案,思路非常简单:把业务操作和消息写入放在同一个本地事务里,然后异步把消息发给下游,下游消费成功就更新消息状态,失败就重试。
比如订单服务在创建订单的本地事务里,同时往message表里插入一条“扣减库存”的消息。这两个操作一起提交或一起回滚,保证了业务和消息的一致性。然后有个定时任务扫message表,把未发送的消息推给库存服务;库存服务处理成功回调更新状态;多次失败的消息进入死信处理,人工介入。
这个方案的好处是极其简单可靠,不需要额外引入中间件,也不依赖什么分布式协调器。缺点是需要写消息表和定时任务,而且消息有延迟,实时性差一些;另外如果业务链路很长,每个环节都这么搞,消息表越来越多,维护成本也不小。
适用场景比较典型的是那种链路清晰、下游消费逻辑不复杂、允许秒级延迟的异步场景。
3.4 MQ事务消息:把本地消息表搬到MQ里
RocketMQ的事务消息,本质上是把本地消息表的逻辑内置到消息队列里。发送方先发一个half消息,消息在队列里对消费者不可见;然后执行本地事务,根据本地事务结果提交或回滚half消息;如果长时间没收到提交回滚指令,MQ会回查发送方,问本地事务到底执行得怎么样。
这个方案的好处是发送方不用自己维护消息表,回查机制也帮忙解决了事务状态不确定的问题。但有个前提:你的MQ必须支持事务消息。RocketMQ支持,Kafka原生不支持事务消息语义,RabbitMQ也是阉割版的方案。
用MQ事务消息的时候,最容易踩的坑是忽略消费者的幂等性。因为MQ的重试机制、消费端网络抖动,都可能导致同一条消息被消费多次。你的下游消费者必须自己保证幂等,否则看似事务消息解决了分布式事务,实际上数据还是会花。
3.5 方案选型对比:不要问哪个好,要问哪个适合
很多刚接触分布式事务的同学喜欢问:“哪个方案最好?”其实这个问题没有答案,因为每个方案的适用场景完全不同。
我梳理过一个选型判断逻辑,供大家参考:
- 如果事务涉及的节点少(2~3个),链路短,且能接受阻塞,用2PC或XA。
- 如果事务链路复杂、环节多,但实时性要求不高,优先考虑MQ事务消息或本地消息表。
- 如果事务是资金类、核心链路,对一致性和时效性都高,但你能接受业务侵入,用TCC。
- 如果业务能做成异步,而且天然具备对账条件,最终一致就能满足,就不要为了强一致引入重型方案。
面试的时候被问到选型,回答思路比答案本身更重要。你可以说:我会先评估参与方数量、一致性要求、可用性要求、开发成本,再来看用哪种方案,而不是一上来就套Seata。这个思路能体现出你真正理解分布式事务的本质。
4. Seata AT模式:Java圈最热门的落地实现
4.1 AT模式的执行流程:中间态是怎么被隐藏的
Seata是目前Java生态里最主流的分布式事务框架,它的AT模式在面试里被问得最多。AT模式的设计思路是用代理数据源的方式,在业务无侵入的前提下,模拟出类似2PC的效果。
流程大概是这样的:事务发起方通过@GlobalTransactional开启全局事务,Seata会注册一个全局事务ID(XID),通过Dubbo或Spring Cloud的拦截器把XID透传到参与方。每个参与方的本地事务照常执行,但Seata的DataSourceProxy会在本地事务提交前,把数据变更前后的快照记录到undo_log表里。所有参与方都执行完后,TC协调者发起全局提交或全局回滚。
这个设计最妙的地方在于:从业务代码角度来看,每个参与方只是执行了一个普通的本地事务,完全感知不到全局事务的存在。事务的开启、提交、回滚都是由框架自动完成的,这就是所谓的“无侵入”。
4.2 全局锁与undolog:AT模式的两大关键机制
AT模式能工作的核心有两个机制:全局锁和undo log。
全局锁解决的是写冲突问题。两个全局事务同时操作同一行数据时,需要保证它们不会互相覆盖。Seata在一个分支事务执行SQL前,会先获取全局锁,拿到锁之后才允许执行本地事务。没有拿到锁的事务会等待,超时则报错。这个机制保证了不同全局事务之间的隔离性。
undo log则是回滚的关键。每个分支事务提交前,Seata会记录数据变更前镜像(before image)和变更后镜像(after image)。全局回滚时,根据before image反向生成回滚SQL,把数据恢复到变更前状态。这里有个很关键的点:回滚时还会校验当前数据和after image是否一致,如果不一致说明有脏写,就不能直接回滚,需要人工处理。
这两个机制合在一起,让AT模式能提供读已提交级别以上的隔离性,同时回滚不需要写业务补偿代码,这是它远比TCC好落地的原因。
4.3 面试常问:全局锁和数据库锁有什么区别?哪些坑必须避
面试官很喜欢问一个细节:AT模式的全局锁会和数据库的本地锁冲突吗?答案是会潜在冲突,因为Seata在获取全局锁之前,业务SQL已经在执行了,数据库层面的事务还没有提交,普通查询看不到数据,但Seata的查询不受本地事务隔离性约束,这里就有并发控制的设计要考虑。
还有几个常见坑要提醒大家:
- undo_log表必须和业务表放在同一个数据库里,否则无法保证本地事务和undo log的原子性。
- 全局超时时间、重试次数要根据实际链路设置,默认值在慢接口场景下容易被触发。
- AT模式不适用于大事务,因为全局锁长时间持有会严重降低并发吞吐。
- 不要混用AT模式和TCC模式。混用的时候,AT模式分支的undo log可能覆盖TCC分支的补偿逻辑,出问题非常难排查。
Seata在实际生产里确实能解决很多问题,但它不是银弹。如果在面试中你能主动说出“AT模式适合中等并发、事务链路可控的业务,不适合超高并发和大事务”,面试官会对你另眼相看。
5. 订单与库存场景实战:从需求到方案的完整推演
5.1 业务场景描述和一致性需求分析
回到文章开头的那个场景:下单扣库存。用户发起一个订单,包含创建订单、扣减库存、有时候还有锁优惠券、增加积分。每一个操作都是独立服务。
这个问题难在哪?难在“用户侧”和“内部系统”对一致性的接受度完全不同。对用户来说,他不关心你内部怎么协调,他只关心下单成不成、库存有没有扣、最后是不是要么都成功要么都失败。
但从技术实现来说,创建订单和扣减库存之间只要隔了一个RPC,就没有办法靠单个数据库事务解决。所以设计的第一步不是选框架,而是明确一致性需求:这个场景能接受最终一致吗?能给多长时间窗口?用户什么时候能看到结果?
我的答案是:下单主链路必须尽最大努力保证即时成功,但因为网络、超时等因素,允许极短时间内订单和库存之间存在不一致,通过异步重试和补偿机制在几秒内达成最终一致。这个决策直接决定了后面用不用TCC、用不用2PC。
5.2 基于MQ事务消息的方案设计与落地方案
明确了最终一致的策略之后,我会选择RocketMQ事务消息来落地,原因是:下单链路涉及多个下游服务,而且这些服务之间的依赖关系是异步可解耦的,用事务消息最贴合。
具体设计是这样的:
订单服务收到下单请求后,开启本地事务,创建订单数据,同时向RocketMQ发送一条half消息。本地事务提交后,MQ执行commit操作,将half消息转为正常消息,库存服务订阅并消费这条消息,执行扣减库存操作。如果本地事务回滚,half消息会被rollback掉,不会投递给库存服务。如果MQ在长时间内没有收到commit/rollback指令,会主动回调订单服务的checkLocalTransaction接口,查询本地订单状态,根据状态决定最终是commit还是rollback。
这里有一个容易被忽略的点:消费者(库存服务)消费消息后的执行结果,最好通过回调通知订单服务更新订单状态。我见过不少团队只把消息发出去就完事,库存扣减失败后,订单还傻傻地挂在“已创建”状态,没有做状态更新和补偿。这其实丢掉了事务消息“事务”二字的精髓——事务不只是发消息,还需要追踪消息的最终执行结果。
5.3 方案实施中的关键配置和参数调整
在做事务消息方案的时候,有几个参数需要花时间调,不然上线后就会不停踩坑:
- 事务回查的间隔和最大次数。RocketMQ默认回查间隔是60秒,如果业务链路很慢,默认间隔可能不够,需要调大。回查次数有限制,超过次数消息会进入死信队列,这时候需要告警,人工处理。
- half消息的保存时间。如果你业务对实时性要求很高,可以适当调低消息的延迟级别,让消息尽快从half状态转为可消费状态。
- 消费端线程数和消费失败重试次数。库存服务消费失败会走重试,但如果库存接口一直不稳定,重试次数太多会积压消息。需要根据库存服务的吞吐能力合理设置。
另外,事务消息方案最适合的是“发送方本地事务稳定可靠”的场景。如果发送方本地事务本身频繁回滚,或者数据库连接经常断,half消息会被大量回查和回滚,MQ这边的压力也会很大。
5.4 这套方案的兜底设计:对账与人工补偿不能省
任何分布式事务方案都不能保证100%零故障,所以一定要有自己的兜底设计。我在实际项目里会加一层定时对账和补偿机制。
具体来说:每天凌晨跑一次对账任务,把订单表和库存扣减流水表做比对。发现订单已创建但库存没有扣减流水,而且消息状态一直没成功的,拉出来走补偿或者告警人工处理。这个对账任务在初期可以一天一跑,后面稳定了可以降低频率或者只在故障后手动触发。
很多人觉得分布式事务方案选了就万事大吉,实际上对账才是最后一道防线。无论是2PC还是TCC,都有协调者宕机、消息丢失、网络分区这些不可避免的异常。对账能兜住剩余的概率性故障,让你敢在生产环境放心使用分布式事务。
6. 面试官视角:分布式事务高频考题的回答思路
6.1 先答概念再答场景:面试题的两种出题方向
面试里分布式事务的题基本可以分成两类:一类是纯概念题,比如“讲一下2PC、TCC、消息事务的区别”;另一类是场景题,比如“用户下单库存扣减失败怎么保证一致性”。这两种题的回答策略完全不同。
概念题你要展现的是知识体系的完整性:每种方案的原理、优点、缺点、适用场景,以及它们之间在一致性和可用性取舍上的差异。可以先用表格做对比,再挑一个你最熟的方案展开细节,面试官能感受到你的知识是体系的,不是背的。
场景题你要展现的是分析能力和落地经验:不要上来就甩方案,而是先分析业务对一致性、可用性、延迟的约束,再推导出方案选型。比如订单场景,你先说“这个场景用户对可用性要求高,不能接受强一致阻塞”,再说“所以我选了最终一致方案,用MQ事务消息”,面试官就会觉得你真正理解业务。
6.2 高频追问Top5:预设口径防翻车
根据我面试别人和被面试的经验,分布式事务的高频追问大概有这几个,提前把回答口径准备好:
第一个追问:2PC为什么不能用在订单场景?答:2PC第二阶段有阻塞风险,协调者挂了所有参与者都卡住,资源不释放,下单链路会雪崩。订单场景对可用性极其敏感,不能接受这个阻塞。
第二个追问:TCC的空回滚是什么意思?答:Try因为网络超时没执行到,但Cancel却被触发了,这时候Cancel不能报错,需要识别出空回滚,直接返回成功,避免把没预留的资源释放掉。
第三个追问:Seata AT模式和TCC的区别?答:AT模式通过undo log实现无侵入回滚,TCC需要业务方手写补偿逻辑;AT模式对数据库资源有隐性锁冲突,TCC通过业务预占降低锁冲突。代价是AT模式实现简单但性能上限低,TCC复杂但更灵活。
第四个追问:消息事务里消费者要不要做幂等?答:必须做。即使事务消息保证消息不被重复发送,但消费端网络超时后重试、消费成功但提交偏移量失败,都会导致重复消费。幂等是消费端的底线义务。
第五个追问:分布式事务能不能彻底消灭?答:不能根除,只能优化。可以通过合理地拆分服务边界、尽量避免跨服务事务、把强一致需求控制在最小范围内来减少分布式事务的发生频率。但如果系统规模上来了,彻底消灭不现实。
6.3 面试答题的节奏和表达技巧
最后说点面试技巧上的东西,这部分是我自己招人时特别看重的。面试官问分布式事务,一般不是想听你从头到尾念教科书,而是想通过追问判断你是不是真的踩过坑。
我的建议是:回答可以采用“结论先行”的方式。先给出一个明确观点或方案,比如“我会选择RocketMQ事务消息,因为订单链路可以接受最终一致”,然后再展开解释为什么这么选。这样的表达方式让面试官觉得你对问题有掌控力,而不是被动地罗列知识点。
另外,碰到不会的细节不要不懂装懂。分布式事务领域很深,Seata原理、RocketMQ事务消息的底层实现细节,很多人只是用过但没研究过。如果你只会使用、讲不清原理,就说“我主要使用过,底层源码还没深入研究”。诚实面对短板,比强行编造好太多——面试官一眼就能看穿你是在编还是在聊真实经验。
7. 实践建议与踩坑心得
7.1 什么时候该引入分布式事务框架?什么时候不该引入
写到这里,我想给正在实践分布式事务的人一些建议,这些话我从不对面试的人说,但我觉得比面试技巧更重要。
第一,不是所有跨服务调用都需要分布式事务。有很多团队一遇到分布式数据不一致,就想着上Seata、上TCC,结果系统复杂度爆炸,问题没解决反而冒出更多问题。错误的做法是把所有链路都加上事务,正确的做法是把强一致需求控制在最小范围内。
第二,很多业务场景可以通过设计避免分布式事务。比如把“下单”和“扣减库存”放在同一个聚合服务里,或者库存预占设计成可重试、可补偿的流程,就根本不需要引入分布式事务框架。能用业务设计解决问题的,永远比用技术框架好。
第三,如果必须要用,建议从消息事务和Seata入手。Seata能让你快速落地AT模式,减少对业务的侵入,而消息事务能覆盖绝大多数的最终一致场景。TCC和2PC除非业务真的需要,否则不要轻易上——它们带来的复杂度远超想象。
7.2 我在生产环境踩过的几个深坑
经验这东西,不踩坑是长不出来的。分享几个我在分布式事务上踩过的实际坑,希望能帮大家少走弯路。
第一个坑:Seata全局锁超时导致接口雪崩。上线的时候没压测,结果双11流量一上来,全局锁等待超时,大量事务直接报错,下单成功率掉到70%以下。后来把超时时间调大、加了重试,又把并发量控住了,才缓过来。这个经历让我从此以后所有分布式事务方案都先压测再上线。
第二个坑:消息事务的本地事务和MQ不在一起。有的团队用了RocketMQ事务消息,但发送方本地事务数据库和MQ不在同一个机房,网络延迟大,回查超时频繁,消息经常被回滚。后来统一了部署位置才解决问题。这个点在设计方案的时候就要考虑,不要等到上线了才处理。
第三个坑:对账任务只出不补。我见过一些团队写了对账逻辑,也能扫描出不一致数据,但扫描出来后没有自动补偿,全靠人工去捞数据。这种做法在数据量小的时候还行,数据量上来之后人工根本处理不过来。对账一定要配套自动补偿任务,至少要能生成工单,不然地鼠打不完。
7.3 下一步学习和扩展建议
如果看完这篇文章,你想继续深入分布式事务,我建议按这个顺序去学习:
先读一遍《数据密集型应用系统设计》里关于分布式事务和共识算法的章节,把理论基础打牢;然后去读Seata源码,重点看DataSourceProxy和TransactionManager的实现;再用RocketMQ搭一个事务消息的demo,把half消息、回查机制跑通,加深理解。
之后可以尝试在项目里落地一个真正的分布式事务场景,哪怕只是一个内部后台的小功能。只有在真实的网络环境、真实的并发压力下跑过,你才能真正理解分布式事务为什么要这么设计,为什么有那些限制。
我在实际使用中还有一个体会:分布式事务的面试题,背得再熟也不如自己动手写过一遍。原理可以在书上学到,但“为什么要这样设计”的答案,只有在你踩过坑之后才能真正理解。希望这篇文章能帮你理清思路,在面试和实战中少走弯路。
