跳到主内容

分布式事务选型框架:Saga、TCC、本地消息表的适用边界一张表说清 ​

「订单创建了,库存没扣」「账户扣了钱,权益没发货」——跨服务数据不一致是微服务化之后绕不开的问题。分布式事务的选型之所以困难,是因为每个方案都像在讲道理:强一致的 2PC 性能差,性能好的 Saga 会出中间态,无侵入的本地消息表只保证最终一致。选错方向的代价往往要到长尾故障时才显现,比如用 TCC 的团队在流量高峰被 Confirm 阶段的重复请求打爆,用纯 Saga 的团队在补偿逻辑里写出新的 bug。这篇文章不给标准答案,而是给一个可复用的选型框架:先问业务要什么一致性,再看团队能付出多少改造与运维成本,最后在方案矩阵里对号入座。

五类方案的横向对比 ​

先看全景矩阵,再逐个展开:

方案一致性侵入性性能典型场景
2PC/XA强一致低(依赖DB)差传统数据库间短事务
TCC准强一致高(三接口)中资金扣减等敏感操作
Saga最终一致中(补偿)好长流程跨服务编排
本地消息表最终一致低好异步解耦的主流选择
事务消息最终一致低好与 RocketMQ 深度绑定

一个常被忽略的前提:分布式事务的第一选项永远是「不要分布式事务」。能通过服务边界重划让事务回到单库,就不要引入任何框架——拆服务前先想清楚事务边界,比选型任何方案都省事。以下讨论默认跨库跨服务已不可避免。

TCC:资金场景的准强一致,三件套必须配齐 ​

TCC 把一个事务拆成 Try(资源预留)、Confirm(确认提交)、Cancel(取消释放)三个阶段。它最强的能力是「预留」:Try 阶段冻结金额而不是直接扣减,使得中间态对业务可见且可审计。但工程上必须处理三个经典异常,缺一个就是资损事故:

java
// TCC 接口的三个经典异常防护:空回滚、悬挂、幂等
@TwoPhaseBusinessAction(name = "deductBalance", commitMethod = "confirm", rollbackMethod = "cancel")
boolean tryDeduct(BusinessActionContext ctx, String accountId, BigDecimal amount);

boolean confirm(BusinessActionContext ctx);  // 必须幂等:可能被重复调用

boolean cancel(BusinessActionContext ctx) {
    // 防空回滚:Try 未执行(如超时未到达)时,Cancel 不能释放未冻结的资源
    if (!frozenRecordExists(ctx)) {
        insertEmptyRollbackRecord(ctx); // 记录空回滚,防止迟到的 Try 再执行(防悬挂)
        return true;
    }
    // 防 Cancel 重复执行:按事务 ID 条件更新,重复调用幂等返回
    return unfreezeByTxId(ctx.getXid()) >= 0;
}

空回滚、悬挂、幂等这三件套是 TCC 的及格线:空回滚是 Cancel 先于 Try 到达(Try 所在节点超时),悬挂是空回滚记录后迟到的 Try 再执行、资源被永久冻结,幂等是 Confirm/Cancel 都可能被框架重复调用。三者都用「事务日志表 + 条件更新」解决。TCC 的代价也在这里:每个参与方要写三个接口和一张日志表,改造成本与参与服务数成正比,只值得用在资金敏感、并发可控的核心操作上。

Saga:长流程的最终一致,补偿设计是灵魂 ​

Saga 把长事务拆成一串本地事务,每步失败则反向执行已完成步骤的补偿。它天然适合流程长、参与方多的场景(下单 → 扣库存 → 优惠核销 → 通知履约)。编排方式分协同式(服务间通过事件驱动)与编排式(中央协调器驱动,如 Seata Saga 模式、Temporal),生产上推荐编排式——流程可视、可控、可运维。

Saga 的设计重心全在补偿逻辑上:

java
// 补偿的三个设计约束
// 1. 补偿必须幂等:协调器重试会导致补偿多次执行
// 2. 补偿必须可空跑:对应 TCC 空回滚,前置步骤未生效时补偿不报错
// 3. 不保证隔离性:中间态对外可见,业务要接受「已扣库存但订单失败后回补」的窗口

第三点是 Saga 与 TCC 的本质差异:Saga 没有预留阶段,库存是「先扣后退」,中间态可能被其他事务读到。如果业务不能接受超卖窗口(比如秒杀库存),Saga 不合适;如果是履约流程这类「先走通、错了再撤」的场景,Saga 的低侵入和高吞吐优势明显。

本地消息表:大多数系统的最优解 ​

对于「本服务改库 + 通知下游」的异步一致性场景,本地消息表是工程性价比之王——不依赖特定中间件,只依赖本地事务的原子性:

sql
CREATE TABLE local_message (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  biz_id VARCHAR(64) NOT NULL COMMENT '业务键,用于下游幂等',
  topic VARCHAR(64) NOT NULL,
  payload JSON NOT NULL,
  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,
  UNIQUE KEY uk_biz (biz_id, topic)
) COMMENT '本地消息表:与业务同库,同事务写入';
java
@Transactional  // 业务写入与消息登记在同一本地事务,要么都成功要么都失败
public void createOrder(Order order) {
    orderMapper.insert(order);
    messageMapper.insert(LocalMessage.of(order.getId(), "order.created", toJson(order)));
}

// 独立投递任务:扫描待投递消息,发送成功才标记,失败按退避策略重试
@Scheduled(fixedDelay = 500)
public void deliverPendingMessages() {
    List<LocalMessage> pending = messageMapper.scanPending(100);
    for (LocalMessage msg : pending) {
        boolean sent = mqProducer.send(msg.getTopic(), msg.getPayload());
        messageMapper.markDeliveredOrRetry(msg.getId(), sent); // 重试超限转死信人工介入
    }
}

下游消费者必须按 biz_id 幂等(见本板块幂等设计篇),这套「本地消息表 + 消费端幂等」组合覆盖了 80% 的跨服务一致性需求。RocketMQ 的事务消息把「消息表 + 投递任务」下沉到中间件(半消息 + 回查机制),省掉建表运维,代价是与 MQ 强绑定且回查逻辑仍需自己实现,两者选其一即可。

选型推演与避坑清单 ​

以「下单扣库存」推演一遍框架:资金敏感吗?订单金额结算敏感,但库存扣减本身可用「扣减 + 回补」表达;并发量高吗?秒杀场景高并发。结论:热点扣库存用原子减(UPDATE stock SET num = num - 1 WHERE num > 0)替代事务保护,订单创建走本地消息表通知下游,只有账户冻结类操作才上 TCC。大多数团队的根本问题是把 TCC 当默认选项,用高成本方案解决低复杂度问题。

还有一类场景值得单独划出:跨系统的最大努力通知(如支付结果通知商户)。它的可靠性要求最低——通知方按退避间隔重试若干次(如 15 秒、30 秒、1 分钟、5 分钟、30 分钟),超时后放弃投递并暴露查询接口让对端主动核实。把「通知」与「一致性」区分开能省掉大量过度设计:需要结果可信的走本地消息表,只需尽力而为的用退避通知即可。两者混为一谈的常见后果,是给通知场景错误套上了事务消息的复杂度,而对账场景却漏掉了消费端幂等。

最后把避坑清单收拢如下:

  1. 先看能否不引入分布式事务:重新划分服务边界,让事务回到单库是最高优先级选项。
  2. 资金预留类操作选 TCC,且空回滚/悬挂/幂等三件套作为代码评审必查项。
  3. 长流程多参与方选 Saga(编排式),补偿的三条约束写进设计文档再动手。
  4. 异步解耦类需求默认本地消息表或事务消息,消费端幂等是这套方案的前置条件。
  5. 任何方案都要有「最终不一致」的兜底:对账任务每日跑平,差异进人工处理队列——对账不是可选项,是分布式事务的最后防线。

分布式事务选型的本质是「用多少工程成本买多强的一致性」。把业务需求的真实强度问清楚,把每个方案的实施成本摆到桌面上,答案通常并不难找。记住:架构师的水平不在于会用 TCC,而在于知道什么时候不用它。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。