分布式事务方案对比

一个业务操作跨越两个服务、两个数据库时,“要么都成功、要么都回滚”变得很难:单库事务靠数据库的 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最终一致中高(要写补偿)长流程编排
最大努力通知弱(尽力而为)通知、回调类

选型建议

  1. 优先想办法变成单库事务:把相关的表放到同一个库,能省掉一整类问题。
  2. 单库不行就退到最终一致:本地消息表或事务消息是性价比最高的起点。
  3. 涉及资金、库存等核心资源,且要求实时一致,再上 TCC。
  4. 长流程(旅游预订、审批流)选 Saga,但一定要准备好补偿与人工兜底入口。
  5. 无论选哪种方案,消费端都必须幂等,并配一个对账任务定期修正长尾不一致。具体的框架实现(如 Seata 的 AT、TCC、Saga、XA 模式)请以官方文档为准。

小结:分布式事务没有全能方案。能单库就单库,不能单库就选“本地消息表 / 事务消息”这类最终一致方案;只有核心资源的实时一致性需求才值得引入 TCC,长流程用 Saga,通知类场景用最大努力通知。所有方案都建立在幂等与对账这两个地基上。

笔记加载中…