楼层: 首页/ 软件技术/ Spring Cloud 微服务/ 分布式事务深水区:从 2PC 到最终一致性
十三

分布式事务深水区:从 2PC 到最终一致性

Distributed Transactions · 2PC / TCC / Saga / Seata / Outbox

第 9 章我们认识了 Seata 和 @GlobalTransactional,但那是"会用"。这一章回答更难的问题:为什么微服务下本地事务就不够用了?强一致和最终一致到底怎么选?补偿接口为什么要写三个坑的防御?分布式事务是微服务里最容易翻车的地方——不是因为它难写,而是因为它看起来很简单:加个注解好像就完事了。下面按"问题从哪来 → 有哪些解法 → 每种解法付出什么代价 → 什么场景选哪个"这条线讲透。

为什么本地事务在微服务里"突然"失效了

先说清楚失效的根因,不然后面所有方案你都记不住。本地事务的能力边界,是"同一个数据库连接的同一个事务"。只要写操作落在同一个连接里,数据库就能替你保证要么全成功、要么全回滚。一旦业务被拆成多个服务、每个服务连自己的库,这个边界就断了——两个数据库之间没有任何机制知道对方干了什么。

论本地事务的边界到底在哪

① 单体时代:下单要写订单表、扣库存表、扣账户表,三张表在同一个库里。开一个 @Transactional,Spring 拿到一个 Connection,三张表的操作都走它,数据库用一个 undo/redo log 保证原子性。你什么都不用管。

② 拆分之后:订单表在 order_db(order-service),库存在 stock_db(stock-service),余额在 account_db(account-service)。三次写操作落在三个不同的 Connection、三个不同的数据库进程上,它们之间没有共同的事务边界。

③ 于是出现"半成功":order_db 提交了,stock_db 扣了,account_db 因为余额不足失败。数据库不会替你回滚前两个——因为从数据库视角看,那两个事务早就提交完了、天经地义地成功了。

把你的代码"翻译"成数据库视角,一眼看出问题

// ============ 单体:一个事务,数据库兜底 ============ @Transactional public void placeOrder(Long userId, Long productId, Integer count) { orderMapper.insert(...); // order_db stockMapper.deduct(...); // stock_db —— 同一连接 accountMapper.deduct(...); // account_db —— 同一连接 } // 任意一步抛异常 → 整个连接回滚 → 数据库保证原子性 // ============ 微服务:三个独立事务,没人兜底 ============ public void placeOrder(Long userId, Long productId, Integer count) { orderMapper.insert(...); // 本地事务 A,已提交 stockClient.deduct(productId, count); // 远程服务,本地事务 B,已提交 accountClient.deduct(userId, money); // 远程服务,本地事务 C,失败! // A、B 已经落库,谁来回滚它们?答案:必须你自己写补偿 }
别被注解骗了

很多人看到第 9 章 @GlobalTransactional 就以为"加个注解等于本地事务的体验"。实际上注解只是把协调逻辑自动化了,真正的一致性保证依然来自"每个参与者要写 undo_log / 补偿接口"这套额外机制。理解不了这一点,你就会在"为什么回滚了一半"的问题上反复卡住。

CAP 与 BASE:先想清楚你到底要什么

选分布式事务方案之前,必须先做一个业务判断题:这个业务,中间那几百毫秒的"不一致"能不能被用户看到、能不能被容忍?这就绕不开 CAP 和 BASE。

论CAP 的"3 选 2"是个常见误读

① CAP 是 C(一致性)、A(可用性)、P(分区容忍)。误读是"三个里挑两个"。正确的理解是:网络分区(P)在分布式系统里是必然发生的,不是你能选的选项。所以真正的取舍只在分区发生的那一刻:你选 C(拒绝服务、保证数据一致),还是选 A(继续服务、接受短暂不一致)。

② 平时不分区的时候,CA 都想要,没问题。CAP 讨论的是"出事的时候"。

③ BASE 是 CAP 的工程化答案:Basically Available(基本可用,可以降级、可以延迟)、Soft state(软状态,允许中间态存在)、Eventually consistent(最终一致)。核心思想:不消灭中间状态,而是保证中间状态最终一定会被收敛掉。

维度强一致(CP 思路)最终一致(AP + BASE 思路)
用户看到什么要么全部成功,要么全部失败,不存在中间态。可能出现"订单已创建、积分未到账"这类短暂中间态。
响应延迟要等所有参与者 prepare 完成才能返回,延迟 = 最慢的那个。本地事务提交即可返回,延迟由自己决定,通常几十毫秒。
可用性任一参与者宕机/超时,整个业务不可用。参与者宕机不影响主流程,补偿任务稍后重试。
实现复杂度中间件承担大部分复杂度(XA 驱动、Seata XA)。复杂度落到业务代码:补偿接口、幂等、状态机,必须自己写。
典型场景资金划转、对账、库存重扣减等不容错的短事务。下单、发券、加积分、通知、统计等长链路周边动作。
技术选型XA / Seata XA / Seata AT(弱化版强一致)本地消息表 / 事务消息 / Saga / TCC

看这张表请记住一句话:绝大多数互联网业务属于右边那一列。"下单送积分"延迟 3 秒到账,用户根本感知不到;而为了这 3 秒去上一个 2PC,你付出的可用性和复杂度代价是几十倍。真正需要左边那一列的场景其实很少。

2PC / XA:教科书方案,工程上的噩梦

2PC(Two-Phase Commit,两阶段提交)是所有分布式事务方案的"祖师爷"。理解它的缺陷,你才能理解后面所有方案为什么长成那样。

阶段谁在动做了什么
阶段一
Prepare
协调者(TM)→ 各参与者(RM)协调者问"你能提交吗"。参与者执行事务但不提交,把 undo/redo 信息写进日志、锁住相关资源,回复 Yes/No。此时资源已被锁住,别的请求会阻塞。
阶段二
Commit
协调者 → 各参与者如果所有人都是 Yes,协调者发 Commit,参与者提交并释放锁;只要有一个 No(或超时),发 Rollback,所有人回滚。

论2PC 的四个硬伤

① 同步阻塞:从 Prepare 到 Commit 之间,所有参与者都锁着资源。如果有人卡在阶段二之前,锁会一直不放,其他业务请求全部排队。这是 2PC 在生产上最致命的问题。

② 协调者单点:TM 宕机后,参与者会一直保持"已 Prepare 未 Commit"的悬停状态,资源被锁死,需要人工介入恢复。

③ 提交阶段的不一致窗口:部分参与者收到 Commit 并提交成功,另一些因为网络断连没收到,此时系统处于真实的不一致状态,且协调者也无法得知谁提交了。

④ 3PC 只是缓解,不是根治:三阶段(CanCommit / PreCommit / DoCommit)加入超时机制减少阻塞,但仍然顶不住分区,还会引入新的不一致窗口。工程上基本不用。

那 2PC/XA 就完全不能用吗?能用,但要挑场景:参与方少、事务短、性能要求低、且是内部系统。比如银行内部两个核心库之间的对账划转,可以接受。但把它用在"用户下单"这种高并发链路上,就是自找麻烦。

用一个能跑的例子看清楚 XA 的骨架:谁在协调,谁在等

// MySQL 原生 XA 语法,DTM/TM 就是替你依次发这些命令的人 XA START 'tx-20260920-001'; UPDATE stock SET count = count - 1 WHERE product_id = 1001; XA END 'tx-20260920-001'; XA PREPARE 'tx-20260920-001'; -- 阶段一:资源已锁,等协调者发话 XA COMMIT 'tx-20260920-001'; -- 阶段二:协调者让提交 -- 或者 XA ROLLBACK 'tx-20260920-001'; -- 出问题时的现场:查询悬而未决的事务 XA RECOVER; -- 你会看到卡在 prepare 状态的事务,资源一直锁着,只能人工决策提交还是回滚 // Java 侧:应用代码其实完全无感知,靠数据源的 XA 包装(比如 Atomikos) // 代价是:所有参与库必须是支持 XA 的同一种协议,且性能会明显下降
XA 最容易忽略的一条:数据库必须支持

XA 不是应用层能单方面实现的,它要求数据库提供 XA 接口。MySQL 的 InnoDB 支持,但一些 NoSQL、新式云原生数据库并不支持。而且挂上 XA 之后,连接池的复用会变得很麻烦——XA 连接在事务结束前不能随便还给别人用,池子容易被耗空。所以选 XA 前先确认两件事:参与库支不支持、连接池扛不扛得住。

TCC:把"预留"这件事做进业务里

TCC(Try-Confirm-Cancel)的思路换了个方向:既然数据库层面协调不了,那就在业务层面自己定义"两阶段"。不锁数据库的行锁,而是在业务表里"冻住"一份资源。

论为什么需要 Try 这个"预留"动作

先看错误做法:Try 阶段直接扣库存、扣余额,失败再"反向加回来"。这在低并发下能用,但有两个硬伤:一是中间状态对用户可见——用户查库存看到"被扣了",查余额看到"钱少了",客服电话立刻打进来;二是加回来不一定成功,如果撤销时库存已被别人买走,你就还不回去了。

再看正确做法:Try 只做"冻结"——库存表的 frozen_count 加 1,可用库存减 1,但商品并没有真正出库;余额表 frozen_amount 加上金额,可用余额减掉,但钱没有真正扣走。Confirm 才把冻结转成真正的扣减,Cancel 则是把冻结原路释放。预留的本质:先占住,但结果对外不可见。

阶段语义库存服务的具体动作
Try预留资源update stock set count = count - 1, frozen = frozen + 1 where product_id=? and count >= 1。加 count >= 1 是防超卖的关键。
Confirm确认扣减update stock set frozen = frozen - 1 where product_id=?。只减冻结量,因为可用量在 Try 阶段已经减过了。这一步必须幂等。
Cancel释放预留update stock set count = count + 1, frozen = frozen - 1 where product_id=?。把 Try 占住的还回去。必须幂等,且要能处理"Try 压根没执行"。
坑现象(怎么被触发的)防御手段
幂等网络超时后协调者重试,Confirm/Cancel 被调用两次,库存被扣两次或加两次。用 branch_id 做唯一键建事务控制表,或状态机加"仅允许从 FROZEN 流转到 CONFIRMED"的条件更新。
空回滚Try 请求因为网络原因根本没到,但协调者已经判定全局失败,发出 Cancel。Cancel 什么都没释放,却把库存加多了。Cancel 时先查事务控制表:没有 Try 记录就写入一条"空回滚"标记并直接返回,绝不做正向的资源操作。
悬挂Try 请求在网络里"迟到"了:Cancel 先到并执行完(还留了空回滚记录),之后延迟的 Try 才到达并成功冻结资源,这笔冻结永远没人来释放。Try 执行前先查事务控制表:如果已经存在 Cancel 记录,说明自己被悬挂了,直接拒绝执行 Try。

事务控制表 + 三个坑的防御骨架(这是 TCC 真正的工作量所在)

-- 事务控制表:TCC 三个坑的"账本",通常一个服务一张 CREATE TABLE tcc_transaction ( branch_id VARCHAR(64) PRIMARY KEY, -- 全局唯一:xid + 服务名,重复调用会撞主键 status VARCHAR(16) NOT NULL, -- TRIED / CONFIRMED / CANCELLED created_time DATETIME NOT NULL, updated_time DATETIME NOT NULL ); // ---------- Try:先验悬挂,再冻结 ---------- public boolean tryDeduct(String branchId, Long productId, int count) { // 悬挂防御:Cancel 已经来过,说明我这条 Try 迟到了,直接拒绝 if (tccMapper.existsCancelled(branchId)) { throw new IllegalStateException("检测到悬挂调用,拒绝执行 Try"); } try { tccMapper.insert(new TccTransaction(branchId, "TRIED")); // 主键冲突 = 重复 Try,天然幂等 } catch (DuplicateKeyException e) { return true; // 已经 Try 过了,不算失败 } return stockMapper.freeze(productId, count) > 0; } // ---------- Cancel:空回滚 + 幂等一次搞定 ---------- public boolean cancelDeduct(String branchId, Long productId, int count) { TccTransaction tx = tccMapper.selectForUpdate(branchId); if (tx == null) { // 空回滚:Try 没来过。写一条 CANCELLED 占位,既记录又挡住后面迟到的 Try tccMapper.insert(new TccTransaction(branchId, "CANCELLED")); return true; } if ("CANCELLED".equals(tx.getStatus())) { return true; // 幂等:重复 Cancel 直接返回 } stockMapper.unfreeze(productId, count); // 释放冻结 tccMapper.updateStatus(branchId, "CANCELLED"); return true; }
TCC 最大的成本不是代码,是设计

很多人以为 TCC 的难点是写那三个接口,其实真正难的是把业务拆成"预留 / 确认 / 取消"三段,并且每段都能对"重复调用、乱序到达、中途失败"免疫。这需要和产品、财务把每个中间态的语义对齐(比如"冻结的钱算不算用户的可用余额")。所以 TCC 通常只用在资金、库存这类对一致性要求极高、且团队有足够设计能力的核心链路上。普通业务用 TCC,属于用手术刀削苹果。

Saga:长流程事务的补偿链路

Saga 的思路更朴素:把一个大事务拆成一串本地事务 T1、T2、T3…,每个 Ti 都配一个补偿动作 Ci。任何一步失败,就反向依次执行 C(i-1)、C(i-2)…C1。它适合那种流程很长、参与方很多、但每一步都"可以撤销"的业务,比如旅行订票(机票 → 酒店 → 租车)。

论Saga 必须直面的一个事实:它没有隔离性

为什么:因为每个 Ti 都是立即提交的本地事务,中间状态对所有人可见。T1 扣了库存,T2 还没扣款,此时别人查询就会看到"库存少了但订单没付款"。

怎么应对:业务层面打补丁。常见三种做法——① 语义锁:加一个 status=PENDING 的中间状态,把"未完成"的记录排除在各种查询之外;② 可交换的补偿:让补偿动作尽早执行,缩短窗口;③ 业务容忍:明确告诉产品经理"这个数字会有几秒延迟",通常这就够了。

记住:Saga 换来的是可用性和吞吐,代价是把"一致性"这件事从数据库挪到了业务语义里。

对比项编排式 Orchestration协同式 Choreography
谁在指挥一个中心协调者(状态机 / 流程引擎),它依次调各服务。没有中心,每个服务完成本地事务后发事件,下一个服务监听并继续。
流程在哪里集中在状态机定义里,一眼能看清全貌。散落在各服务的事件处理器里,需要把所有服务拼起来才能看清。
耦合程度各服务只依赖协调者,服务之间零耦合。服务之间通过事件间接耦合,新增一步要改上游的发布逻辑。
可观测性好。协调者天然记录了"现在走到第几步、卡在哪"。差。出问题时要在多个服务的日志/链路里串联才能定位。
适用场景步骤多、分支多、需要人工干预或可视化编排的长流程。步骤少(3~4 步)、参与方稳定、团队习惯事件驱动。
代表实现Seata Saga(状态机 JSON 定义)、Temporal、Camunda事件总线 + 各服务自行订阅

编排式 Saga 的状态机骨架:正向走下去,失败就反向补偿

// 用状态机描述整条链路:每一步的 ServiceTask 配一个 CompensateServiceTask { "Name": "placeOrderSaga", "StartState": "CreateOrder", "States": { "CreateOrder": { "Type": "ServiceTask", "ServiceName": "orderService", "ServiceMethod": "create", "CompensateState": "CancelOrder", // 失败时回到哪个补偿态 "Next": "DeductStock" }, "DeductStock": { "Type": "ServiceTask", "ServiceName": "stockService", "ServiceMethod": "deduct", "CompensateState": "RestoreStock", // 库存补偿:加回去 "Next": "DeductBalance" }, "DeductBalance": { "Type": "ServiceTask", "ServiceName": "accountService", "ServiceMethod": "deduct", "CompensateState": "RestoreBalance", // 余额补偿:退回去 "Next": "Succeed" }, "CancelOrder": { "Type": "ServiceTask", "ServiceName": "orderService", "ServiceMethod": "cancel", "Next": "Fail" }, "RestoreStock": { "Type": "ServiceTask", "ServiceName": "stockService", "ServiceMethod": "restore", "Next": "CancelOrder" }, "RestoreBalance": { "Type": "ServiceTask", "ServiceName": "accountService", "ServiceMethod": "restore", "Next": "RestoreStock" } } } // 注意补偿链的顺序:DeductBalance 失败 → RestoreBalance → RestoreStock → CancelOrder // 每个补偿方法同样必须幂等,因为协调器会重试

本地消息表 + 定时补偿:最土、最稳、最常用

如果你只想记住一个方案,就记这个。它解决的是最常见的一类问题:"主流程写库"和"通知别人"必须一致。核心技巧只有一个——把"要发的消息"和业务数据写进同一个本地事务。这样一来,"业务提交了"和"消息被记下来了"就成为一个原子操作,不可能只发生一半。

论它到底保证了什么,没保证什么

① 保证的是:业务成功 ⟺ 消息落库。这两件事在同一个本地事务里,数据库帮你保证原子性。不存在"订单创建成功但消息丢了"。

② 没保证的是:消息一定能"投递成功"且"只投递一次"。定时任务把消息发给 MQ,可能发成功但确认超时,于是下次扫描又发了一遍。所以这是"至少一次"语义,消费端必须做幂等去重。

③ 为什么说它最稳:因为它不依赖任何特殊中间件能力,只用到了"数据库事务"和"定时任务"这两个最基础的东西。MySQL + 一个 cron 就能跑通,任何团队都能掌握,出问题也好排查。

三步搭起来:建表 + 业务事务里写消息 + 定时补偿

-- 第一步:本地消息表(和业务表在同一个库) CREATE TABLE local_message ( id BIGINT AUTO_INCREMENT PRIMARY KEY, biz_id VARCHAR(64) NOT NULL, -- 业务单据号,消费端靠它做幂等 topic VARCHAR(128) NOT NULL, payload TEXT, status TINYINT NOT NULL DEFAULT 0, -- 0=待发送 1=已发送 2=已消费 retry_count INT NOT NULL DEFAULT 0, next_retry_at DATETIME, -- 下次重试时间,退避用 created_time DATETIME NOT NULL, UNIQUE KEY uk_biz (biz_id, topic), KEY idx_scan (status, next_retry_at) ); // 第二步:业务和"消息"写在同一个 @Transactional 里 @Service public class OrderService { @Autowired OrderMapper orderMapper; @Autowired LocalMessageMapper messageMapper; @Transactional // 关键:订单和消息必须同一事务,同生共死 public void createOrder(Order order) { orderMapper.insert(order); messageMapper.insert(new LocalMessage( order.getBizId(), "ORDER_CREATED", JSON.toJSONString(order), 0)); // 提交后,消息一定在库里;即使此刻服务宕机,消息也不会丢 } } // 第三步:定时任务扫描并投递,失败就退避重试 @Scheduled(fixedDelay = 5000) public void dispatchMessages() { List<LocalMessage> list = messageMapper.selectPending(100); // 一次捞一批,避免长事务 for (LocalMessage msg : list) { try { rocketMQTemplate.syncSend(msg.getTopic(), msg.getPayload()); messageMapper.markSent(msg.getId()); // 发成功才标记 } catch (Exception e) { // 发失败:指数退避,避免打爆 MQ 也避免空转 long delay = Math.min(300, (long) Math.pow(2, msg.getRetryCount())) * 1000L; messageMapper.scheduleRetry(msg.getId(), new Date(System.currentTimeMillis() + delay)); } } }
消费端不做幂等,前面全白干

定时补偿天生是"至少一次投递",重复消息一定会出现(网络抖动、MQ 重投、服务重启)。消费端必须用 biz_id 建一张去重表(或直接用业务唯一键):插入成功才继续处理,主键冲突说明这条已经处理过,直接确认消费。少了这一步,你迟早会遇到"用户被加了两次积分"或者"发了两次货",而且对账时极难定位。RocketMQ 的事务消息本质上就是把这个本地消息表做进了 MQ 内部,思路完全一样,只是不用你自己写扫描任务。

Seata 四种模式与 AT 的 undo_log 原理

Seata 是把上面这些方案"产品化"的一个框架,它提供了四种模式,分别对应不同的思路。不要按"哪个先进"来选,要按"哪个的代价你的业务付得起"来选。

模式思路业务侵入一致性适合什么场景
AT自动补偿。代理数据源,自动记录数据行修改前后的镜像。几乎为零,加 @GlobalTransactional 即可最终一致默认首选。普通业务、团队不想写补偿逻辑、能接受全局锁带来的少量性能损耗。
TCC手动定义 Try/Confirm/Cancel 三个接口。高,三个接口都要自己写最终一致(可做到接近强一致)资金、库存等核心链路,对性能敏感且团队有设计能力。
Saga状态机驱动的长流程 + 补偿链。中,要写状态机定义和各步补偿最终一致步骤多、链路长的业务流程,如旅行预订、审批流。
XA数据库原生 XA,两阶段提交。低,但要求数据库支持 XA强一致参与方少、事务短、明确需要强一致的内部系统。高并发链路不要用。

论AT 模式的 undo_log 是怎么做到"自动回滚"的

① 拦截前:Seata 用数据源代理拦住你的 SQL。执行 update stock set count = count - 1 where id = 1001 之前,它先查一次这行数据,把结果存成 before image(前镜像)。

② 执行后:再查一次这行,存成 after image(后镜像)。然后把前后镜像和业务 SQL 一起写进 undo_log 表,和业务数据在同一个本地事务里提交。这就是它能自动回滚的全部秘密——回滚所需的信息被"顺手"持久化了。

③ 回滚时:全局失败,TC 通知各分支回滚。分支拿 undo_log 里的前镜像,生成一条反向 SQL(update stock set count = 1 where id = 1001)执行回去。

④ 防脏写校验:回滚前,它先拿 after image 去比对当前库里的数据。如果发现不一致,说明这行在全局事务之外被别的事务改过了,此时不能直接回滚(否则会覆盖别人的修改),Seata 会抛异常并需要人工处理。这个校验是 AT 模式安全性的关键,也是它"最终一致"而非"强一致"的根源。

AT 模式落地:建 undo_log 表 + 业务侧只加一个注解

-- 每个参与全局事务的库里都要建这张表(注意:是每个业务库,不是只有 TC 的库) CREATE TABLE undo_log ( branch_id BIGINT NOT NULL, xid VARCHAR(100) NOT NULL, context VARCHAR(128) NOT NULL, rollback_info LONGBLOB NOT NULL, -- 前后镜像的序列化内容,回滚全靠它 log_status INT NOT NULL, log_created DATETIME NOT NULL, log_modified DATETIME NOT NULL, UNIQUE KEY ux_undo_log (xid, branch_id) ); // business 侧:真正要写的只有下面这两行 // 1) 全局事务发起方的方法上加注解,超时时间按最慢的下游留余量 @GlobalTransactional(timeoutMills = 30000, name = "place-order") public void placeOrder(Long userId, Long productId, Integer count) { orderMapper.insert(order); stockClient.deduct(productId, count); // 各分支自己管自己的本地事务 accountClient.deduct(userId, money); } // 2) 分支服务什么都不用改,只要数据源被 Seata 代理(starter 自动配置)

选型别拍脑袋,按这三问走

# 第一问:这个业务,中间态被用户看到会出事吗? 不会 → 优先"本地消息表 + 幂等消费",成本最低、最稳 会 → 进第二问 # 第二问:参与方是不是都是同一个数据库/同一种 XA 协议,且事务足够短? 是 → 可以考虑 Seata XA,换强一致,但接受性能下降 否 → 进第三问 # 第三问:团队有能力维护"预留/确认/取消"三个幂等接口,且这是核心链路吗? 是 → TCC(资金、库存这类) 否 → Seata AT(默认答案)或 Saga(长流程) # 反面清单:以下场景不要上分布式事务 只是"发个通知/加个积分/写个报表" → 用消息异步化就够了,根本不需要事务 链路里有个别步骤天然可重试、无副作用 → 直接重试,别为了形式统一上框架 团队还在单体阶段、QPS 不到一千 → 先把业务跑通,别提前还债
关于分布式事务的两个高频误解

误解一:把它当银弹。看到跨库就上 @GlobalTransactional,结果每个请求都多两轮 TC 通信、每个分支都多一次 undo_log 写入、还可能拿全局锁。高并发下 QPS 掉一半。先问"这个操作真的需要一致性吗"——很多跨服务操作其实是"通知"性质,异步化即可,压根不需要事务。

误解二:补偿接口写得随便。补偿接口不幂等,是最常见的生产事故来源。全局事务回滚时,TC 会重试通知分支,如果 Cancel 或 restore 不是幂等的,就会出现"重复退款""库存加两次"。所有补偿接口的第一行代码,都应该是幂等校验。记住:能重试的东西,一定会被重试。

记
这一章的核心

① 本地事务的边界是"一个数据库连接",跨库就断了,所以你必须自己补上一致性。

② 先做业务判断题(中间态能不能被看到),再选技术方案;多数业务落在"最终一致"这一列。

③ 2PC 阻塞且有单点;TCC 换来性能但要写三个幂等接口;Saga 换来可用性但牺牲隔离性;本地消息表最土最稳。

④ Seata AT 靠 undo_log 的前后镜像自动回滚,但它是"最终一致",不是强一致。

⑤ 所有补偿接口必须幂等,所有消费端必须去重——这两条是底线,不是优化。

本章面试题

面试快答 · 分布式事务

Q1. CAP 里为什么说"P 是必选"?

参考答案

因为分布式系统里网络分区是客观必然(光缆断了、交换机抖动、容器重启),不是你能通过努力消除的选项。所以 CAP 的实质是:分区发生的那一刻,你在 C 和 A 之间选谁。选 C 就是宁可拒绝服务也不返回不一致数据(如 ZooKeeper 少数派不可用);选 A 就是继续服务但接受短暂不一致(如 Eureka、Nacos 默认 AP)。

Q2. TCC 的"空回滚"和"悬挂"分别是什么?为什么要同一个事务控制表解决?

参考答案

空回滚:Try 没到,Cancel 却先到了,Cancel 无事可做却可能误操作资源。悬挂:Cancel 先执行完,迟到的 Try 才到达并占了资源,从此无人释放。用同一张表的原因:两者本质都是"不知道 Try 到底有没有执行过"。用 branch_id 做唯一键把每次 Try/Cancel 都落一条状态记录,Cancel 靠"查不到记录就写空回滚标记"防误操作,Try 靠"发现已有 Cancel 记录就拒绝执行"防悬挂。一个账本同时解决两个坑。

Q3. Seata AT 模式回滚时,为什么要拿 after image 去比对当前数据?

参考答案

这是防脏写。全局事务执行期间,这行数据可能被别的、不属于本全局事务的本地事务改过。如果直接按前镜像回滚,就会把别人的修改覆盖掉,造成数据丢失。比对 after image 就是确认"从我改完到现在,这行没人动过";一旦发现不一致,就拒绝回滚并告警,交给人工处理。这也是 AT 只能做到最终一致的原因。

Q4. 本地消息表方案里,"消息落库"和"消息投递成功"分别靠什么保证?

参考答案

消息落库:靠本地事务。业务数据和消息记录在同一个 @Transactional 里提交,数据库保证原子性,不存在业务成功而消息丢失。消息投递:靠定时任务扫描 + 重试,这是至少一次语义,只保证最终会被投出去,不保证不重复。所以额外需要一个东西兜底:消费端幂等去重。三件事合起来才构成完整的方案。

Q5. 一个电商下单链路:扣库存、扣余额、发优惠券、加积分、发短信。你会怎么设计一致性?

参考答案

分层处理,不要一刀切。强一致核心:扣库存 + 扣余额是钱和货,用 Seata AT(或 TCC)保证"要么都成、要么都不成"。最终一致周边:发券、加积分、发短信属于通知性质,主事务成功后发一条 MQ 消息,各自异步消费 + 幂等,失败重试即可,不占主链路时间。要点:别把这五件事塞进一个全局事务——那会让主链路延迟、可用性都被最慢的环节拖死。

Q6. 为什么说"不是所有跨库操作都需要分布式事务"?举个反例。

参考答案

判断标准是"这一步失败会不会造成业务上不可接受的错误"。比如"下单成功后给用户发一条站内信"——即使站内信发失败,业务也是对的,用户不会因此受损,事后补发就行,这种叫通知,异步化即可,压根不需要事务。反过来"扣了钱没加积分"这种,用户会投诉,就属于需要保证的一致性范围。反例结论:把通知当事务处理,只会白白付出复杂度和性能代价。