楼层: 首页/ 软件技术/ 数据库三剑客/ 分布式事务:2PC / TCC / Saga / 本地消息表
08

分布式事务:2PC / TCC / Saga / 本地消息表

Distributed Transactions · 2PC / TCC / Saga / Local Message Table

前面所有事务的例子,都建立在一个没说出口的前提上:数据在同一个库、同一个连接里。一旦订单、库存、账户被拆到三个库,BEGIN ... COMMIT 就管不到隔壁了——订单库提交成功,库存库的扣减却可能超时、可能失败、甚至请求根本没到。这一章讲的就是:拆开之后,"要么全成要么全撤"这件事怎么重新成立。先给个定心丸:2PC、TCC、Saga、本地消息表不是四个竞品,而是从"强一致但慢"到"最终一致但快"的一根光谱。选型本质上只回答一句话——你的业务能不能接受几百毫秒到几秒的短暂不一致。

8.1 先划清边界:本地事务只管一个库

论本地事务的原子性到底是谁在保证

不是你的代码,是数据库自己。InnoDB 靠 undo log(回滚)+ redo log(持久化)+ 行锁,在自己的存储引擎内部实现 ACID。它能管住的边界,就是"这一个实例、这一个连接提交的这一批写"。

跨库时,订单库的事务提交了,库存库根本不知道有这回事——没有任何一方能同时管住两边。这时候你要么引入一个"外部协调者"来管(2PC/TCC/Saga),要么干脆放弃同时管、改成"先落一半,另一半靠重试追上来"(本地消息表/事务消息)。

同一个"下单"动作,拆库前 vs 拆库后
环节单库(一个事务搞定)拆成三个服务三个库
写订单INSERT orders,在事务里调订单服务,HTTP/RPC 一次
扣库存UPDATE stock,同一个事务、同一把锁调库存服务,第二个独立的本地事务
减余额UPDATE account,同一个事务调账户服务,第三个独立的本地事务
失败怎么撤ROLLBACK 一句话,数据库保证没人能一句话撤:前两个已经各自提交进库了,你得自己写补偿逻辑把它们撤回来
别用 try/catch 冒充分布式事务

最常见的错误写法:try { 下单(); 扣库存(); 减余额(); } catch (Exception e) { 回滚下单(); }。它有三处致命伤:

① "失败"不等于"没生效"。扣库存调用超时抛异常,但库存服务那边其实已经成功提交了——你一回滚订单,就出现"订单没了、库存减了"。

② 补偿动作自己也会失败。回滚下单() 同样是一次网络调用,它也可能超时;失败之后谁来重试?

③ 进程可能直接没了。服务在两步之间被 kill、机器断电、容器被驱逐,catch 块根本不执行,数据就永久停在中间态。

记住一句话:跨库场景下,"撤销"不是一个动作,而是一段需要幂等 + 重试 + 对账的流程。

8.2 2PC / XA:让数据库自己两阶段提交

2PC(Two-Phase Commit,两阶段提交)是最经典的做法:引入一个协调者(Coordinator),先让所有参与者"准备好但别提交",全部 OK 再统一下令提交。XA 就是数据库实现 2PC 的标准接口(MySQL、Oracle、PG 都支持)。

论两阶段到底在等什么

阶段一 Prepare(准备):协调者让每个参与者执行事务、写好日志、锁住资源,但不提交,然后回答"能提交吗"。这一步一旦回答 yes,参与者就失去了单方面反悔的权利,只能等你。

阶段二 Commit/Rollback(提交或回滚):全部 yes → 广播 Commit;有任何一个 no → 广播 Rollback。

它把"同时成功"变成了一件确定的事,代价是——参与者从 Prepare 到收到最终指令的这段时间里,资源一直被锁着。

MySQL 里的 XA 命令(注意:真的能在同一个连接里跨库)

-- 在协调者侧发起一个全局事务,xid 全局唯一 XA START 'xid-order-20260920-0001'; -- 第一个参与者:订单库 UPDATE orders SET status = 'CREATED' WHERE id = 1001; XA END 'xid-order-20260920-0001'; XA PREPARE 'xid-order-20260920-0001'; -- 阶段一:写 redo,锁不放 -- 第二、第三个参与者用同一个 xid 在各自实例上重复上面三步 -- 全部 PREPARE 成功之后,协调者才下令: XA COMMIT 'xid-order-20260920-0001'; -- 阶段二:真提交 -- 任何一个失败(或超时),改成 XA ROLLBACK 'xid-...'
2PC 的三个硬伤(面试必问)

① 同步阻塞。Prepare 之后资源被锁住,直到阶段二才释放。一个参与者慢,全局都慢;参与者越多,事务时间越长,锁冲突越严重——这是它吞吐上不去的根因。

② 协调者单点。参与者 Prepare 完却在等指令时协调者挂了,参与者会一直锁着资源(业界叫"悬挂事务")。MySQL 里能看到 XA RECOVER 里那一堆 xid,但要不要提交只能人工判断。

③ 可能出现数据不一致。Commit 阶段广播时网络抖一下,有的参与者收到 commit 提交了,有的没收到还锁着——数据就分叉了。所以它其实是"强一致(有前提)"而不是"永远一致"。

结论:2PC/XA 适合"参与者少(2~3 个)、事务短、并发不高、但对一致性要求极高"的场景(比如同一个机房内的两个数据库做一次对账落库)。高并发互联网业务基本不用它。

8.3 TCC:把"锁"搬到业务层

论TCC 为什么能避开数据库长事务

TCC = Try / Confirm / Cancel,业务层面的两阶段。核心技巧是把"锁资源"换成"预留资源":

Try:不改最终数据,只做预留。比如库存表上有 stock 和 frozen 两列,Try 只把 5 个库存从 stock 挪到 frozen(可售变冻结),本地事务立刻提交,不持有数据库锁。

Confirm:全局成功,把 frozen 真正扣掉。

Cancel:全局失败,把 frozen 还回 stock。

它没有"阶段一锁到阶段二"这个问题,所以性能远好于 XA;代价是三个接口得你自己写、自己保证幂等。

TCC 三接口骨架(Spring 生态里可由 Seata TCC 模式调度)

@Service public class StockTccAction { // ---- Try:预留。WHERE 条件保证"可售足够",天然防超卖 ---- public boolean tryDeduct(String bizId, long skuId, int count) { int n = jdbc.update( "UPDATE stock SET frozen = frozen + ? " + "WHERE sku_id = ? AND stock - frozen >= ?", count, skuId, count); if (n == 0) return false; logDao.insert(bizId, "STOCK_TRY"); // 记账:Cancel 靠它判断"Try 到底来没来" return true; } // ---- Confirm:真正扣减。必须幂等 ---- public void confirmDeduct(String bizId, long skuId, int count) { if (logDao.exists(bizId, "STOCK_CONFIRM")) return; // 重复调用直接返回 jdbc.update("UPDATE stock SET stock = stock - ?, frozen = frozen - ? WHERE sku_id = ?", count, count, skuId); logDao.insert(bizId, "STOCK_CONFIRM"); } // ---- Cancel:解冻。空回滚保护:Try 没来过就什么都不做 ---- public void cancelDeduct(String bizId, long skuId, int count) { if (!logDao.exists(bizId, "STOCK_TRY")) { // ← 空回滚 logDao.insert(bizId, "STOCK_CANCEL_EMPTY"); // 留痕,防止 Try 迟到后又被执行(悬挂) return; } if (logDao.exists(bizId, "STOCK_CANCEL")) return; // 幂等 jdbc.update("UPDATE stock SET stock = stock + ?, frozen = frozen - ? WHERE sku_id = ?", count, count, skuId); logDao.insert(bizId, "STOCK_CANCEL"); } }
TCC 的三个经典坑(面试几乎必问,代码里必须处理)
问题怎么发生的怎么防
空回滚Try 请求因为网络问题没到服务,但全局事务判定失败,Cancel 先到了。此时什么都没冻结,你却去"解冻",等于凭空加了库存Cancel 里先查事务日志:没有 Try 记录就直接返回,并写一条空回滚记录
幂等Confirm/Cancel 因为超时重试被调用多次,重复扣减或重复解冻每次执行前查日志表(bizId + 动作 唯一键),执行过就跳过
悬挂Try 请求在网络里"卡了很久",Cancel 已经执行完了,这个迟到的 Try 才到达并成功预留资源——资源永远冻着没人解Try 执行前检查是否已有 Cancel/空回滚记录,有就拒绝执行
TCC 只适合"能预留"的资源

库存能冻结、余额能冻结,所以适合 TCC。但有些操作没法预留:发短信、发优惠券、调第三方支付接口。短信发出去是撤不回的——这类步骤只能靠"正向重试 + 对账补偿",别硬套 TCC。

另外提醒:TCC 的复杂度全在那三个问题 + 日志表上,没有日志表(或没用框架自带的)的 TCC 实现,几乎一定会在生产中出错。

8.4 Saga:长流程的正向执行 + 逆向补偿

如果流程很长(下单 → 锁库存 → 支付 → 开票 → 发货 → 积分),用 2PC 会把锁持有到天荒地老,用 TCC 要写十几对接口累死人。Saga 的思路朴素得多:一路往前跑,每一步都是本地事务、立刻提交;跑挂了就倒着走,对每个已经跑完的步骤调用一个补偿动作。

论Saga 不是"回滚",是"反向业务"

数据库的 ROLLBACK 是物理撤销,数据回到从未发生的样子。Saga 的补偿是"再做一个业务动作把它对冲掉":退款不等于没扣过钱,它是一条新的流水;释放库存不等于没锁过,库存流水里多了一条记录。所以 Saga 的业务表通常有"正向流水 + 补偿流水"两套痕迹——这是特性不是 bug,金融场景反而需要这种可追溯性。

Saga 有两种组织方式:协同式(Choreography)——每个服务干完就发事件,下一个服务听事件自己动起来,简单但流程藏在各处,链路一长就看不清;编排式(Orchestration)——有一个中心编排器按状态机驱动每一步,链路清晰、便于排查,是生产推荐的做法。

编排式 Saga 的核心结构(状态机 + 补偿栈)

enum Step { CREATE_ORDER, LOCK_STOCK, PAY, CREATE_INVOICE, SHIP, ADD_POINTS } // 编排器:走一步压一栈,失败就反向弹栈补偿 public void run(SagaContext ctx) { Deque<Step> done = new ArrayDeque<>(); try { for (Step s : Step.values()) { stepHandler.execute(s, ctx); // 每步=本地事务+立刻提交,且必须幂等 done.push(s); sagaLog.mark(ctx.sagaId(), s, "DONE"); // 持久化进度,进程挂了能续跑 } } catch (Exception e) { while (!done.isEmpty()) { Step s = done.pop(); retryUntilOk(() -> stepHandler.compensate(s, ctx)); // 逆序补偿,失败也要重试 } throw new SagaFailedException(e); } } // 补偿动作示例:撤单不是删记录,而是把订单状态改成 CANCELLED public void compensate(Step s, SagaContext ctx) { switch (s) { case CREATE_ORDER -> orderDao.updateStatus(ctx.orderId(), "CANCELLED"); case LOCK_STOCK -> stockClient.release(ctx.orderId()); case PAY -> payClient.refund(ctx.payId()); case CREATE_INVOICE-> invoiceDao.voidInvoice(ctx.invoiceId()); default -> { } // SHIP / ADD_POINTS 之后再失败,通常改为人工介入 } }
Saga 的补偿有三个"必须"

① 补偿必须幂等。补偿自己也会超时重试,重复退款是事故。做法同 TCC:用 sagaId + step 唯一键记账。

② 补偿必须最终成功。Saga 允许中间态存在一段时间,但补偿不能"试一次就算了"。要么无限重试 + 告警,要么落到人工工单——绝不能静默失败。

③ 不是每步都能补偿。货已发出、发票已开出,业务上撤不回来。所以 Saga 要设计"不可逆点"(point of no return),过了这个点就只允许"继续往前"或人工处理,不要设计出一套"撤回已发货"的假补偿。

还有个常被忽略的点:补偿期间数据对用户可见吗?Saga 不支持隔离性,中间态是能被查到的。要么在前端展示"处理中",要么用状态字段把中间态对查询屏蔽掉。

8.5 本地消息表与事务消息:最实用的最终一致

如果业务其实不需要立刻一致——比如下单后 1 秒内给用户加积分、发通知、更新搜索索引——那最省事也最稳的方案是本地消息表:把"业务写"和"待发消息"塞进同一个本地事务,然后把发送交给后台任务重试。它把"跨库的原子性"这一个难题,转化成了"本地事务原子性(数据库保证)+ 消息重试(你自己保证)",复杂度一下子降了一个数量级。

落地第一步:消息表 DDL(关键在唯一键和重试字段)

CREATE TABLE local_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_id VARCHAR(64) NOT NULL, -- 业务唯一键(如订单号),消费端靠它去重 topic VARCHAR(64) NOT NULL, payload JSON NOT NULL, status TINYINT NOT NULL DEFAULT 0, -- 0待发 1已发 2已确认 3失败 retry_count INT NOT NULL DEFAULT 0, next_retry DATETIME NOT NULL, -- 指数退避的下次重试时间 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_biz_topic (biz_id, topic), -- 防重复落表 KEY idx_pending (status, next_retry) -- 后台扫描走这个索引 );

落地第二步:业务写和消息落表,同一个本地事务

@Transactional(rollbackFor = Exception.class) public void createOrder(Order order) { orderMapper.insert(order); // 业务表 localMessageMapper.insert(LocalMessage.of( order.getId(), "order.created", order)); // 消息表,同一个事务里 } // 提交成功 = 业务和消息都落库;抛异常 = 两个都不落。原子性由本地数据库保证 // 落地第三步:后台任务扫表投递(失败就指数退避重试,一直重试到确认) @Scheduled(fixedDelay = 1000) public void deliver() { for (LocalMessage m : mapper.lockPending(100)) { // SELECT ... FOR UPDATE SKIP LOCKED try { producer.send(m.getTopic(), m.getBizId(), m.getPayload()); // key=bizId,保证同单进同一分区 mapper.markSent(m.getId()); // status = 1 } catch (Exception e) { mapper.backoff(m.getId(), expBackoff(m.getRetryCount())); // 1s/2s/4s/... 上限 5 分钟 } } }

论事务消息:把上面的消息表搬进 MQ

RocketMQ 的事务消息就是"本地消息表"的框架化版本,省掉了你自己建表和扫表。它的机制是三步:

① 发半消息:消息先发到 Broker,但对消费者不可见。

② 执行本地事务:半消息发成功后,才执行你的本地业务(写订单)。

③ 提交或回滚半消息:本地事务成功 → 让 Broker 把消息变为可见;失败 → 让 Broker 丢掉半消息。如果 Broker 迟迟等不到你的答复(比如你的进程挂了),它会反过来回调你的"事务状态反查接口"——这就是为什么你要提供一个"这笔订单到底存不存在"的查询方法。

注意:事务消息只保证"本地事务成功 ⇔ 消息一定投递至少一次",不保证消费端只收到一次。所以消费端幂等这一步,永远躲不掉。

本地消息表的三个必踩点

① 消息表不在同一个事务里 = 全白干。常见错误是"先 insert order 提交,再 insert message"——中间崩了消息就丢了,业务却以为会有人去处理。必须在同一个 @Transactional 里,或同一个数据库实例。

② 只写"已发送"不写"已确认"。MQ 只是收下了消息,不代表下游消费成功。要留 status=2 已确认(由下游回执),否则你永远不知道有哪些消息需要补偿。

③ 扫表任务没加"跳过已锁记录"。多个实例同时跑定时任务会重复投递。要么用 FOR UPDATE SKIP LOCKED,要么用分布式锁,要么按 id 分片处理。重复投递本身不可怕(幂等兜住),但会让下游压力翻倍。

8.6 工程上真正难的部分:幂等、对账、可观测

所有最终一致方案的最后一道防线都是幂等——因为"至少一次"投递是常态,"恰好一次"是幻想。幂等不是加个 if 就完事,它有明确的三种做法,各有适用场景。

三种幂等实现对比
方式做法适合注意
唯一键去重给业务表加 UNIQUE(biz_id),或建一张 idempotent_record(request_id PRIMARY KEY) 表,插入冲突就说明处理过了创建型操作:下单、支付、发券最简单最可靠,优先考虑;要注意冲突时返回什么(应返回首次结果而非报错)
状态机UPDATE ... SET status='PAID' WHERE id=? AND status='UNPAID',用受影响行数判断本次是否生效状态流转:支付、发货、退款条件必须带上"期望的旧状态",只写 WHERE id=? 就没有幂等性
去重表 / 幂等号上游生成全局唯一 requestId,下游处理前先 INSERT 去重表,成功才继续跨服务调用、第三方回调去重表要有 TTL 清理,否则无限膨胀

用受影响行数实现状态机幂等(最常用的一招)

-- 关键:WHERE 里带上"期望的旧状态",让数据库帮你做原子判断 UPDATE orders SET status = 'PAID', paid_at = NOW() WHERE id = 1001 AND status = 'UNPAID'; -- 返回受影响行数 1=本次生效,0=已经处理过
int affected = orderMapper.markPaid(orderId); if (affected == 0) { // 要么已经支付过(正常重复投递),要么状态不对(异常)——查一下再决定 Order o = orderMapper.findById(orderId); if ("PAID".equals(o.getStatus())) return; // 幂等返回,别抛异常 throw new IllegalStateException("订单状态异常: " + o.getStatus()); }

论对账:最终一致的"最后一道保险"

再好的重试也可能漏。生产上一定要有一个定时对账任务,用"业务侧的真相"去校验"下游的状态":

① 增量对账:每 5 分钟取最近 10 分钟内的业务单,逐笔查下游状态,不一致就补发/补偿。窗口重叠一点没关系,因为幂等。

② 全量对账:每天凌晨对指定日期做一次全量比对,输出差异报告,人工兜底重大差异。

③ 关键指标要能看见:待发送消息积压量、重试次数分布、补偿成功/失败数、对账差异数——这些曲线忽然抬头,就是事故的前兆。没有监控的最终一致方案等于没有最终一致。

关于分布式事务的四个"反常识"

① 强一致往往不是业务需求。用户下单后积分晚 2 秒到账,没人会发现;为了这 2 秒上一套 XA,换来的却是吞吐下降和运维复杂度,这买卖很亏。

② 不要在一个业务里混用多种方案。有的链路用 Seata AT、有的用消息表、有的用 TCC,出问题时你连"这笔单到底走到哪了"都说不清。一个系统定一种主方案。

③ 分布式事务只能保证"数据最终对上",不保证"你不用写补偿代码"。所有方案的复杂度,最后都沉淀在业务代码里——这是本质,不是某个框架不够好。

④ 能不分布式就别分布式。把强关联的表放在同一个库、用同一个本地事务,是最可靠的"分布式事务方案"。很多所谓的分布式事务需求,最后是被"合并库表"这种最土的方案解决的。

8.7 怎么选:一张表 + 三个判断

四种跨库一致性方案全对比(背下这张表,面试够用)
方案一致性性能复杂度什么时候用它
2PC / XA强一致(有前提)差(同步阻塞)低(框架封装好)参与者少(2~3)、事务短、并发低、且必须强一致的场景,例如同机房两个库做落库。高并发业务慎用。
TCC最终一致好高(手写三接口 + 日志表)资源可预留、对资金/库存准确性要求极高的核心链路。写之前先确认"能不能预留"。
Saga最终一致好中(编排 + 补偿)跨 4 个以上服务的长流程,如订单履约、审批流、出行行程。需要设计"不可逆点"。
本地消息表最终一致好(异步)低(一张表 + 定时任务)性价比最高的默认选择:通知、加积分、刷新缓存/索引、发券等异步副作用。
事务消息
(RocketMQ)
最终一致好(异步)低(不用自己建表)同上,但团队已经用了 RocketMQ 且不愿维护消息表。注意仍需消费端幂等 + 状态反查接口。

选三个问题定方案

问题一:失败时,业务能"撤"吗?能撤(库存、余额)→ TCC 或 Saga;撤不了(已发短信、已发货)→ 只能本地消息表 + 人工兜底,别幻想强一致。

问题二:用户能接受"晚一点对上"吗?能(绝大多数场景)→ 直接上本地消息表,最省事;不能(比如支付扣款与账务流水必须同时可见)→ 考虑 TCC 或 2PC。

问题三:这段逻辑有几个参与方、多长?2~3 个、短 → 2PC 可接受;5 个以上、含人工环节 → Saga 编排式;只有一个"发消息"的副作用 → 本地消息表。

记
本章小结

① 本地事务管不到跨库,跨库的一致性必须由你(或框架)显式设计。"try/catch 里手动回滚"不是方案。

② 2PC/XA 强一致但有同步阻塞、协调者单点、可能不一致三硬伤;TCC 用"预留"换性能,但要处理空回滚/幂等/悬挂;Saga 用"正向 + 逆向补偿"适合长流程;本地消息表用"同一本地事务 + 重试"换来了最低的复杂度。

③ 幂等是所有人的底座:唯一键去重、状态机(带上期望旧状态)、去重表三选一。没有幂等,任何最终一致方案都会在重试面前崩掉。

④ 对账和监控是最后一道保险,别省略。对不上要能发现,而不是等用户投诉。

⑤ 最后一句话:能用本地事务解决的,绝不引入分布式事务。