分布式事务方案对比
一个业务操作跨越两个服务、两个数据库时,“要么都成功、要么都回滚”变得很难:单库事务靠数据库的 ACID 保证,跨库就只能靠应用层协调。本章把主流分布式事务方案摆在一起对比,并给出务实的选型思路。
难点在哪
- 网络不可靠:本地事务提交成功,返回响应可能丢失,协调方不知道该提交还是回滚。
- 资源被多个参与者持有:任何一个参与者卡住,整体就无法结束。
- 分布式环境下 CAP 取舍不可避免,多数业务最终选择“最终一致”而不是“强一致”。
2PC / XA
两阶段提交:协调者先让所有参与者做 prepare,全部成功后再统一 commit,任一失败则全部 rollback。
-- MySQL 的 XA 事务语法示意
XA START 'tx-1001';
UPDATE account SET balance = balance - 100 WHERE id = 1;
XA END 'tx-1001';
XA PREPARE 'tx-1001'; -- 阶段一:准备,事务进入 prepared 状态
XA COMMIT 'tx-1001'; -- 阶段二:全部参与者都准备好后才提交
| 优点 | 问题 |
|---|---|
| 强一致,语义接近本地事务 | 同步阻塞:prepare 后资源一直被占用 |
| 标准协议,数据库原生支持 | 协调者单点故障时参与者长期挂起 |
| 实现门槛低 | 性能差,不适合高并发写场景 |
注意:prepared 状态的事务如果协调者崩溃,会一直占着锁,必要时得用 XA RECOVER 人工干预。因此 XA 更适合低频、跨库但强一致的场景(如账务对账批处理),不适合高频交易主链路。
TCC(Try-Confirm-Cancel)
TCC 把一次业务操作拆成三个由业务自己实现的阶段:
| 阶段 | 做什么 | 失败怎么办 |
|---|---|---|
| Try | 预留资源(冻结额度、锁定库存) | 预留失败直接返回失败 |
| Confirm | 确认使用预留资源,真正扣减 | 必须成功,失败要重试 |
| Cancel | 释放预留资源 | 必须成功,失败要重试 |
// 以下片段为接口定义示意,实现类需放进对应工程
public interface AccountTcc {
boolean tryFreeze(String txId, long userId, long amount); // 冻结
boolean confirm(String txId, long userId, long amount); // 确认扣减
boolean cancel(String txId, long userId, long amount); // 解冻
}
TCC 有三个经典坑,必须提前处理:
- 空回滚:Try 还没执行,Cancel 先到了,此时不能报错,要记录“已回滚”状态。
- 悬挂:Cancel 先执行、Try 后到达,Try 必须检查该事务是否已回滚过,若是则不再冻结。
- 幂等:Confirm / Cancel 都可能被重复调用,必须用
txId去重。
本地消息表
最朴素也最实用的最终一致方案:把“要发的消息”和业务数据写在同一个本地事务里,再由后台任务投递。
-- 消息表:与业务表在同一个库,可放在同一个事务中
CREATE TABLE local_message (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
biz_id VARCHAR(64) NOT NULL, -- 业务唯一键,用于幂等
topic VARCHAR(64) NOT NULL,
payload TEXT NOT NULL,
status TINYINT NOT NULL DEFAULT 0, -- 0 待发送 1 已发送 2 已确认
retry_count INT NOT NULL DEFAULT 0,
next_time DATETIME NOT NULL, -- 下次重试时间
UNIQUE KEY uk_biz (biz_id)
);
流程:本地事务 → 插入业务数据 + 消息记录 → 后台任务扫描 status=0 的记录投递给 MQ → 消费方处理并回执 → 更新 status=2。投递失败就按退避策略重试,消费方靠 biz_id 幂等。
事务消息(RocketMQ 思路)
把上面的“本地消息表”搬到 MQ 内部:
1. 生产者发送半消息(half message),消息对消费者不可见
2. Broker 存下半消息并返回成功
3. 生产者执行本地事务
4. 本地事务成功 → 提交消息(消费者可见);失败 → 回滚消息
5. 若第 4 步响应丢失,Broker 定时回查生产者,由生产者按本地事务状态给出结论
关键点是回查接口:生产者必须实现按事务 ID 查询本地事务状态的逻辑,且该查询要幂等。相比本地消息表,它省掉了消息表,但把可靠性交给了 Broker 的回查机制,适合已经使用 RocketMQ 的系统。
Saga(长事务)
把一个长流程拆成若干本地事务,每个步骤配一个补偿动作,失败时反向依次补偿:
下单:创建订单 → 扣库存 → 扣余额 → 发券
失败:发券补偿 ← 余额补偿 ← 库存补偿 ← 订单取消
- 优点:无长时间资源锁定,适合流程长、参与方多的业务。
- 缺点:没有隔离性,中间状态对外可见,需要业务能容忍;补偿逻辑有时不可逆(如已发出的短信),要换成“正向差值补偿”。
最大努力通知
用于“结果不那么关键、通知方尽力而为”的场景,例如支付结果通知商户:
- 上游完成后主动通知下游,失败则按递增间隔多次重试(如 5s、30s、5m、30m)。
- 同时提供主动查询接口,下游也可以定时对账拉取。
- 不保证一定送达,只保证“尽力 + 可查 + 可对账”。
方案对比
| 方案 | 一致性 | 性能 | 业务侵入 | 适用场景 |
|---|---|---|---|---|
| 2PC / XA | 强一致 | 低 | 低 | 低频跨库、批处理 |
| TCC | 最终一致(准实时) | 中高 | 高(三套接口) | 资金、库存等核心链路 |
| 本地消息表 | 最终一致 | 高 | 中 | 已有 MQ、想少依赖组件 |
| 事务消息 | 最终一致 | 高 | 中 | 已使用 RocketMQ |
| Saga | 最终一致 | 高 | 中高(要写补偿) | 长流程编排 |
| 最大努力通知 | 弱(尽力而为) | 高 | 低 | 通知、回调类 |
选型建议
- 优先想办法变成单库事务:把相关的表放到同一个库,能省掉一整类问题。
- 单库不行就退到最终一致:本地消息表或事务消息是性价比最高的起点。
- 涉及资金、库存等核心资源,且要求实时一致,再上 TCC。
- 长流程(旅游预订、审批流)选 Saga,但一定要准备好补偿与人工兜底入口。
- 无论选哪种方案,消费端都必须幂等,并配一个对账任务定期修正长尾不一致。具体的框架实现(如 Seata 的 AT、TCC、Saga、XA 模式)请以官方文档为准。
小结:分布式事务没有全能方案。能单库就单库,不能单库就选“本地消息表 / 事务消息”这类最终一致方案;只有核心资源的实时一致性需求才值得引入 TCC,长流程用 Saga,通知类场景用最大努力通知。所有方案都建立在幂等与对账这两个地基上。