上个月有个读者跟我复盘他的Java岗面试,问题倒不偏门——“你们项目的订单和库存是怎么保证数据一致性的?”他背了半个月的分布式事务面试题,开口就说“用了Seata”。面试官接着追问:“Try阶段是干嘛的?Cancel执行失败怎么办?你们用TCC还是AT模式?”他一下就卡住了。问题不在他没背过,而是他根本不清楚自己项目里的调用链走到哪一步、哪一环需要什么级别的一致性保障。
分布式事务这话题,在社区电商、订单、支付这类业务里太常见了。订单、库存、优惠券、钱包可能分属不同微服务,任何一步失败都可能引发超卖、少扣款、积分错乱。面试官问这道题,表面考方案,实际考的是你有没有真正落过地,能不能把“数据一致性”这件事从理论拆到代码,再从代码拆到故障兜底。
这篇文章我把分布式事务从头到尾拆一遍:它难在哪、ACID到CAP发生了什么变化、五套主流方案的原理和适用边界、订单与库存场景的完整拆解,以及一套面试现场可以直接用的回答骨架。不管你是准备面试,还是想系统性补一补后端这堂课,这篇文章都值得你花二十分钟读完。
1. 面试官问“分布式事务”,真正问的是这三点认知
1.1 第一层考察:你能不能分清“强一致”和“最终一致”
很多人一听到分布式事务,第一反应是“2PC、TCC、SAGA、本地消息表”这些名词。但面试官真正想听的,是你有没有能力判断“这个业务到底需不需要强一致”。
举个例子,你在小红书刷到一篇笔记,点赞后右上角数字加了1,这背后可能涉及内容服务、用户服务、计数服务。如果计数服务短暂延迟几秒,用户根本感知不到。这类业务用最终一致性就够了,你非上TCC或者XA,成本和复杂度直接拉满。
反过来讲,订单支付成功后,库存必须扣掉,不然就会出现超卖。虽然也可以做成最终一致,但窗口期内超卖一旦发生,后续取消订单、补偿扣减的逻辑会变得极其复杂。所以设计上要先判断:哪些链路能容忍延迟,哪些链路在业务规则上就不能容忍。
能把“一致性等级”和“业务场景”对上号,比背十个方案名字有用得多。
1.2 第二层考察:你对自己项目的调用链有没有掌控
面试官常问“你们项目用的什么方案”,听着像是在考技术选型,其实在试探你对自己业务系统的掌控度。你可以没用过TCC,可以没用过SAGA,但你得清楚地知道:
- 你们系统哪些操作是跨服务的
- 跨服务调用失败后,是重试、回滚,还是先记录后补偿
- 消息队列用的是哪一个,消息丢失怎么兜底
- 有没有定时对账脚本,不一致数据靠什么收敛
“我们用的是本地消息表 + 定时任务 + 对账脚本”,这回答一点都不丢人。很多中大型互联网项目的核心链路就是靠这套“旁路设计”保证最终一致的,而不是你想象中的高深框架。
真正丢人的是:项目里明明发了个MQ消息,消息消费失败后靠人工补单,你却跟面试官说“我们用了分布式事务,所以是强一致的”。
1.3 第三层考察:你知不知道一致性也有等级
面试里容易加分的点是,主动说出“我们选的是最终一致性,可以容忍秒级窗口”。这句话背后的知识结构是:
- 强一致:写操作完成后,任何后续读操作都能读到最新值,比如单机MySQL的ACID事务、ZooKeeper的ZAB协议
- 弱一致:写操作完成后,读操作不保证能立刻读到最新值,存在一个未知窗口
- 最终一致:弱一致的一种特例,只要系统没有新写入,经过一段时间后所有副本最终会收敛到同一个值
- 会话一致性、单调读一致性:更细分的变种,面试中偶尔会出现
能顺着“最终一致”往下聊到“我们通过幂等表 + 状态机 + 对账收敛”,面试官基本就能判断出你是有实战经验的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分布式事务为什么难:ACID失灵、CAP取舍与BASE妥协
2.1 ACID的原子性在分布式环境里被网络延迟击穿了
单机事务我们很熟:BEGIN、UPDATE、COMMIT,要么全成功要么全回滚,靠的是数据库的锁、日志和回滚段。这套机制的前提是——所有数据都在同一个数据库实例里,事务管理器能直接控制这个实例。
到了微服务架构,订单数据在订单库,库存数据在库存库,积分数据在积分库。一次用户下单操作,要写三个数据库。此时ACID里的“原子性”就失灵了:你没有办法用一个本地数据库事务去同时控制三个库的提交和回滚。
这就是分布式事务要解决的核心问题:跨多个独立数据源,如何保证一组操作要么全部成功、要么全部不成功,或者至少让它们最终收敛到一致状态。
你可能会想:那我不拆分服务不就行了?说这话的人大概率没经历过订单量和团队规模同时膨胀的阶段。拆分是为了独立扩容、独立发布、故障隔离。分布式事务是拆分后要付的代价,只能靠架构设计去对冲。
2.2 CAP:P不可选,只能在A和C之间做选择
CAP理论说的是:一个分布式系统,一致性(Consistency)、可用性(Availability)、分区容错性(Partition tolerance)三者最多同时满足两个。在真实网络环境中,分区(P)一定会发生,网络断了、消息丢了、节点挂了都算分区。所以P是必选题,真正的选择只在C和A之间。
- 选CP:牺牲部分可用性,保证数据一致。典型如ZooKeeper,Leader挂了要重新选举,选举期间集群不可写。
- 选AP:保证服务可用,但数据可能短暂不一致。典型如Eureka,节点间互相注册,某个节点挂了其他节点照样能响应,但客户端看到的服务列表可能不是最新的。
做分布式事务设计时,CAP就是你的“世界观”。如果你选择强一致方案,比如XA,那你本质上选了CP,代价是吞吐量下降、故障时服务不可用。如果你选择最终一致方案,比如本地消息表 + MQ,那你选了AP,代价是业务上必须接受短暂的中间状态。
值得一提的是,CAP里的C比我们平时说的“数据一致性”要严格得多,指的是“线性一致性”。实际业务里大部分场景根本不需要线性一致,这时候用最终一致方案是更理性的选择。
2.3 BASE不是“不保证一致性”,而是要拿幂等与重试换
BASE理论是对CAP中AP方案的延伸,核心是三个词:
- Basically Available(基本可用):系统出故障时允许降级,比如双十一时把退款入口临时改成T+1
- Soft state(软状态):允许系统存在中间状态,数据副本之间可以有一段时间不一致
- Eventually consistent(最终一致):没有新写入后,数据经过一段时间最终会一致
BASE不是躺平,它的隐含前提是:不一致的窗口期要把影响控制住,并且要有机制去收敛。收敛靠的就是三件套:幂等、重试、状态机。
给一段很朴素的概括:分布式事务的世界里,没有“完美回滚”,只有“有底线的补偿”。面试官想听到的,正是你对这个“底线”的认知。
3. 五套主流方案拆开揉碎:原理、代码、适用边界与坑
3.1 XA/2PC:教科书爱讲、生产环境慎用的两阶段提交
XA是X/Open组织定义的分布式事务规范,Java里对应JTA。2PC(两阶段提交)是XA的核心协议,分两个阶段:
- 准备阶段(Prepare):协调者问所有参与者“能不能提交”,参与者写undo/redo日志、加锁资源,然后回“可以”或者“不行”。
- 提交阶段(Commit/Rollback):协调者收到所有参与者的“可以”后,发全局提交命令;只要有一个“不行”,就发全局回滚命令。
原理很优雅,但实际问题非常多:
- 同步阻塞:事务过程中参与者要一直持有数据库锁,其他事务只能等,在高并发场景下吞吐量直接崩
- 协调者单点:协调者挂了,所有参与者都在等它的最终指令,整个链路卡死
- 不确定窗口:协调者在提交阶段先给A发了提交命令、还没给B发的时候挂了,A提交了、B没提交,数据就不一致了
所以在互联网高并发业务里,原生2PC很少被直接使用。它更适合数据量可控、对一致性要求极高的内部系统,比如银行核心账务的短事务,且要配合可靠的协调者高可用方案。
3.2 TCC:Try-Confirm-Cancel,以及三个必须防住的经典坑
TCC把每个分布式操作拆成三个阶段:
- Try:尝试执行业务,完成资源检查和预留。比如扣库存时先把库存冻结,不真正扣减
- Confirm:确认执行业务,真正执行业务。比如把冻结库存变成已扣减
- Cancel:取消执行业务,释放预留资源。比如把冻结库存释放回可用库存
TCC的侵入性很强,每个业务都要实现三组方法。但好处是它不依赖数据库底层分布式事务,应用层自由度很高,性能也比2PC好。Seata的TCC模式就是干这个的。
TCC有三个面试必问的坑:
第一个是空回滚。Try阶段因为网络超时没执行成功,但Cancel已经被调用。此时Cancel要能判断出“Try没成功,所以不需要回滚”。判断方法通常是:在Try执行前先记录一条事务控制记录,Cancel里先查这条记录,不存在就直接返回成功。
第二个是悬挂。Cancel先到了,Try后到了。这会导致Cancel认为事务不需要回滚,然后Try又把资源预留了,预留的资源永远不会被释放。解决办法是:Try执行前检查事务控制记录,如果发现Cancel已经执行过,就直接拒绝本次Try。
第三个是幂等。Confirm和Cancel都可能因为网络重试被多次调用。接口必须做幂等,通常用事务ID做唯一索引,重复调用直接返回成功。
TCC的代码骨架大概长这样:
java复制public interface InventoryTccAction {
/**
* 尝试预留库存
* @param txId 全局事务ID,用于幂等和空回滚判断
* @param skuId 商品SKU
* @param quantity 冻结数量
* @return 预留结果
*/
boolean tryFreezeStock(String txId, Long skuId, Integer quantity);
/**
* 确认扣减,把冻结库存变成已扣减
*/
boolean confirmDeductStock(String txId, Long skuId, Integer quantity);
/**
* 取消,释放冻结库存
*/
boolean cancelFreezeStock(String txId, Long skuId, Integer quantity);
}
实现tryFreezeStock时,需要先往冻结流水表里插入一条txId唯一的数据,如果插入冲突,说明这个事务已经被处理过,直接返回成功。这套设计同时解决了空回滚和幂等的一部分问题。
3.3 本地消息表:又土又实用,面试里最容易被低估
本地消息表是eBay提出来的方案,思路朴素但非常可靠:
- 业务操作和写消息在同一个本地事务里完成。比如创建订单时,同时insert一条“待发送”的库存扣减消息
- 定时任务扫描消息表里“未发送”的消息,发送到MQ
- 消息发出后,将消息状态更新为“已发送”
- 接收方消费消息,执行业务,执行成功后回调更新消息状态
核心优势是:业务表和消息表在同一个库里,靠本地事务保证了“业务操作”和“消息产生”的原子性。只要业务操作成功了,消息一定在库里;只要消息在库里,定时任务早晚会把它发出去。
它的典型问题也很明显:
- 消息表跟业务表共库,增加数据库压力
- 消息量大的时候,消息表会膨胀,需要定期清理
- 对MQ的依赖变成了“半依赖”:MQ挂了不影响业务,但消息会积压,需要监控积压量
这是我个人最喜欢在简历项目里看到的方案之一,因为它的可靠性不依赖任何中间件的高级特性,只要数据库不丢数据,消息就不会丢。
建表可以长这样:
sql复制CREATE TABLE t_local_message (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
biz_id VARCHAR(64) NOT NULL COMMENT '业务主键,如订单号',
biz_type VARCHAR(32) NOT NULL COMMENT '业务类型,如ORDER_CREATED',
payload TEXT NOT NULL COMMENT '消息内容,JSON格式',
status TINYINT NOT NULL DEFAULT 0 COMMENT '0-待发送,1-已发送,2-消费成功',
retry_count INT NOT NULL DEFAULT 0,
next_retry_time DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_biz_type_biz_id (biz_type, biz_id)
) COMMENT '本地消息表';
定时任务扫描时注意加上 next_retry_time 条件,避免每次扫全表,也避免失败消息一直重试轰炸。
3.4 RocketMQ事务消息:半消息机制如何替代消息表
本地消息表方案虽然可靠,但让业务表跟消息表耦合在一起,数据库压力不好受。RocketMQ提供的事务消息机制,本质上是在MQ层面模拟了“本地消息表”的效果,核心是半消息(Half Message)机制。
整体流程是:
- 生产者发送一条half消息到RocketMQ,此时消费者不可见
- 发送成功后,生产者执行本地事务
- 本地事务执行成功后,向RocketMQ发送commit;失败则发送rollback
- 如果生产者执行本地事务的过程中宕机了,没有提交最终结果,RocketMQ会定时回查生产者的本地事务状态
- 生产者实现回查接口,告诉Broker事务结果是成功还是失败
在RocketMQ里,你要实现一个TransactionListener:
java复制public class OrderTransactionListener implements TransactionListener {
@Override
public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
// 执行本地事务,比如创建订单
boolean success = orderService.createOrder(msg);
// 返回本地事务结果
return success ? LocalTransactionState.COMMIT_MESSAGE
: LocalTransactionState.ROLLBACK_MESSAGE;
}
@Override
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
// Broker回查时,根据本地订单是否存在来判断事务状态
String orderId = msg.getKeys();
boolean orderExists = orderService.isOrderExists(orderId);
return orderExists ? LocalTransactionState.COMMIT_MESSAGE
: LocalTransactionState.ROLLBACK_MESSAGE;
}
}
面试时你如果能说出半消息、commit、rollback、回查这四个关键词,再补一句“回查是解决生产者宕机后消息状态不确定的关键”,这一part基本就稳了。
注意使用事务消息时,消费者拿到消息后依然可能消费失败,所以消费端的重试和幂等设计不能少。事务消息解决的是“生产者发消息和本地事务的一致性”,不是“消息被成功消费”的保证。
3.5 SAGA:长事务、补偿与编排模式
SAGA模式最早是数据库领域提出的长事务解决方案,核心思想:把一个长事务拆成一组本地事务,每个本地事务都有对应的补偿操作(Compensating Action)。如果第N个本地事务失败,就反向执行前N-1个事务的补偿操作。
SAGA有两条恢复策略:
- Backward Recovery:从失败点开始,逐个回滚之前已经提交的事务操作,这是最常见的做法
- Forward Recovery:从失败点开始,继续往后执行,适用于具备幂等重试能力的场景
落地时有两种编排方式:
- Choreography(协同):通过事件驱动,服务A执行完发事件,服务B订阅事件后执行,服务B失败发补偿事件,服务A订阅后补偿。实现简单,但流程分散,不好追踪
- Orchestration(编排):引入一个中央协调器,由它告诉每个服务“该做什么、失败后该补偿谁”。可观测性好,是生产环境更常用的模式
SAGA和TCC最大的区别是:TCC有Try阶段做资源预留,SAGA没有预留动作,直接执行正式操作,所以SAGA的每个操作必须支持“通过逆向操作来抵消”。比如“加积分”操作,补偿就是“扣掉这次加的积分”;“扣库存”操作,补偿就是“把库存加回来”。
SAGA的隔离性较弱,中间状态对外可见。比如扣库存成功了,但后续发券失败,此时库存已经少了,其他用户看到可售库存也少了。所以SAGA适合业务本身能容忍中间状态的场景,比如预订机票+酒店这种长链路。
3.6 面试加分项:Seata AT模式与它解决的问题
面试里提到Seata,很多人只知道它是个分布式事务框架。其实Seata支持AT、TCC、SAGA、XA四种模式,其中AT模式最值得聊。
AT模式的思想很巧妙:它用“undo_log”表来实现自动补偿。框架会解析业务SQL,在更新数据前生成before image(数据变更前镜像),更新后生成after image,把两条镜像写入undo_log。如果全局事务需要回滚,框架通过undo_log反向生成补偿SQL,把数据恢复成before image的样子。
对业务代码来说,只需要加一个@GlobalTransactional注解:
java复制@GlobalTransactional(timeoutMills = 10000)
public void createOrderWithDeductStock(Order order) {
orderDao.insert(order);
stockDao.deductStock(order.getSkuId(), order.getQuantity());
couponDao.markUsed(order.getCouponId());
}
AT模式看起来“无侵入”,但代价是全局锁和undo_log的写入开销,高并发下性能仍然有压力。面试时如果你能说出AT模式与TCC的区别在于“AT靠数据镜像自动补偿、TCC靠业务代码显式补偿”,并且能指出AT模式不适合什么场景,会比单纯背Seata特性高一个档次。
3.7 一张表汇总:什么场景选什么方案
| 方案 | 一致性 | 业务侵入 | 性能损耗 | 适用场景 |
|---|---|---|---|---|
| XA/2PC | 强一致 | 低,依赖数据库 | 高,锁资源 | 银行、账务等短事务、低并发强一致场景 |
| TCC | 最终一致 | 高,三组接口 | 中 | 跨服务资金操作、需要资源预留的场景 |
| 本地消息表 | 最终一致 | 中 | 低 | 无强一致要求、消息量可控的业务 |
| MQ事务消息 | 最终一致 | 中 | 低 | 有现成MQ、需要解耦的异步链路 |
| SAGA | 最终一致 | 高,每个操作要补偿 | 中 | 长事务、跨系统、可容忍中间状态 |
| Seata AT | 最终一致 | 低,注解式 | 中 | 不想手写补偿、业务相对标准化的场景 |
这张表不是让你背的,而是让你在回答问题时能快速定位:我们的业务是哪种,我们为什么不用其他方案。
4. 订单与库存场景拆解:面试官高频追问落到了哪一步
4.1 下单链路第一步:先划分“必须同步”与“可以异步”
社区电商平台一次下单,涉及订单服务、库存服务、优惠券服务、支付服务、积分服务。如果把所有写操作都放进一个大事务里,性能一定会很差,而且链路越长越容易失败。
正确做法是先按业务规则划分:
- 订单主单创建:必须成功,否则用户无法下单
- 锁定库存:必须在订单创建后立刻完成,否则超卖
- 扣减优惠券:可以异步,用户在订单详情里看到优惠券已被使用即可
- 发积分:可以异步,用户感知弱,最终加上就行
这种划分的关键是识别“业务上不能容忍丢失”的环节。库存扣减属于这一类,因为它直接影响到真实货物数量。而发积分、发送短信这类操作,丢了之后可以通过对账补偿,不丢也不会有规则问题。
面试中建议的表述是:“我们线上订单链路采用最终一致性,订单创建和库存扣减通过RocketMQ事务消息保证,积分、优惠券、短信走普通的异步消息+消费幂等+定时对账。”
4.2 库存扣减的原子SQL、幂等键与超卖防线
库存扣减最直接的要求是:不能超卖。如果扣减库存用“先查再改”,并发下很容易超卖:
sql复制-- 错误示范:先查询
SELECT stock FROM inventory WHERE sku_id = 123;
-- 判断 stock > 0 后执行
UPDATE inventory SET stock = stock - 1 WHERE sku_id = 123;
两步之间,其他请求可能已经扣到0了,你的判断就失效了。
正确做法是用一条原子SQL:
sql复制UPDATE inventory
SET stock = stock - #{quantity}
WHERE sku_id = #{skuId} AND stock >= #{quantity};
加 stock >= #{quantity} 条件,UPDATE返回受影响行数为0就说明库存不足。这一步从根上防住超卖,不需要分布式锁,也不需要select for update。
但光有原子SQL还不够。如果MQ消息重复投递,同一笔订单可能扣两次库存。这时候需要幂等键,用唯一约束挡住重复扣减:
sql复制CREATE TABLE t_stock_deduct_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tx_id VARCHAR(64) NOT NULL COMMENT '事务ID或订单号',
sku_id BIGINT NOT NULL,
quantity INT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_tx_id (tx_id)
) COMMENT '库存扣减流水表';
扣减逻辑改成两步:
- 先insert一条tx_id为订单号的流水记录。如果插入冲突,说明这笔扣减已经处理过,直接返回成功
- insert成功后再执行原子UPDATE扣减库存,两个操作放在同一个本地事务里
面试官如果追问“insert成功但update失败怎么办”,答案是在同一个本地事务里,insert和UPDATE要么都成功要么都失败,不存在只成功一半的情况。
4.3 “消息丢了怎么办”:一条完整链路把兜底讲明白
面试官问“如果消息丢了怎么办”,其实在考你知不知道“最终一致性不能只靠一条消息链路保证”。你要能把完整兜底链路讲清楚:
第一层:生产者重试。RocketMQ事务消息中,生产者发送half消息失败会重试;本地事务执行完后commit失败,Broker会通过回查机制确认状态。
第二层:消费者重试。消费失败后RocketMQ会按延迟级别重试,同时我们要保证消费逻辑幂等,重复消费不会产生副作用。
第三层:定时对账。每天凌晨跑对账任务,比如比对订单表和库存扣减流水表,找出“订单已创建但库存没扣减”或“扣减没有对应有效订单”的数据,自动补单或者告警。
第四层:状态机兜底。订单不要只存一个状态字段,至少包含“待付款、已支付、已发货、已完成、已取消”,每次状态流转都做前置校验,比如“已取消”的订单不允许再触发“扣库存”动作。
这一整套组合,比单纯说“我们用了RocketMQ事务消息,所以不会丢”要可信得多。分布式系统不存在“不会丢”的绝对保证,只存在“丢了之后能发现、能恢复”的兜底机制。
5. 面试现场的一套回答骨架:从开场到被追问都能接住
5.1 开场先定性:一句话把业务复杂度和一致性要求讲清楚
面试官问“你们的订单和库存怎么保证一致”,别上来就倒方案。先定性,再说方案。一个合格的回答结构是:
- 一句话描述业务链路:“下单会创建订单、扣减库存、扣减优惠券,三个操作分属不同服务”
- 指出一致性要求:“订单和库存的一致性要求高,我们采用最终一致性,可容忍秒级窗口”
- 点出方案:“通过RocketMQ事务消息保证订单创建和库存扣减的产生与投递一致,消费端做幂等”
- 补充兜底:“另外有定时对账任务,发现不一致数据自动补偿”
这样的回答,面试官能在十秒内建立你的思考框架。先定业务规则,再选技术方案,而不是背八股文。
5.2 被问“TCC用过吗”:诚实 + 独立分析才是加分项
被问到没实际用过的方案,最忌讳的就是硬编。你说“TCC我们项目里用过”,面试官追问一个细节,比如“Confirm失败你们怎么处理”,你就露馅了。
更靠谱的回答是:“TCC我没在生产环境用过,但我理解它的核心是预留+确认+取消,应用层侵入比较强。我们在设计库存扣减时考虑过TCC,但我们的业务可以通过事务消息+幂等+对账兜底,不需要资源冻结的强约束,所以最终选择了更轻的方案。”
这段话把三个信息点都表达了:你有知识广度、你有方案对比能力、你有落地判断力。这比假装用过十种方案更有说服力。
5.3 被问“一致性怎么测试”:故障注入、核对表与监控
面试最后常见的问题是“你们怎么验证自己的分布式事务方案没问题”。这个问题也有一个标准动作:
- 故障注入:人为制造消费者宕机、MQ延迟、数据库超时,观察链路是否按预期补偿
- 核对表:写一套对账脚本,定时比对订单、库存、积分数据,专门发现漏单、错账
- 监控告警:对消息积压量、对账失败量、补偿执行失败量设置阈值告警
- 混沌实验:在测试环境随机杀服务,验证兜底链路在故障状态下的表现
能说出“我们用混沌工程在测试环境随机杀节点”这句话,面试官对你的印象会立刻不一样。它在告诉对方,你不只写了业务代码,你还真正考虑过系统在故障下的行为。
分布式事务这话题,总结下来没有银弹。面试官心里也清楚,没有哪个方案能同时做到强一致、高性能、零侵入。他们想看到的,是你面对这种复杂性时的判断力:分得清业务的强弱一致边界,讲得清方案的适用与限制,设计得出兜底与恢复机制。
我个人的实际体会是,面试前把你自己项目的核心调用链完整画一遍,从入口到落库,标出每一环如果失败了会发生什么、由谁补偿、怎么恢复。这张图比背三十道题都管用。花一个下午把它画明白,分布式事务这道题你大概率能稳过。
