7AA7 Blog

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

AsyncLab UID:94
2026-10-04 20:08
106 3 5

下单要扣库存、创建订单、冻结优惠券,三个动作跨了三个库。单机事务保证的同生共死在这里失效。所有分布式事务方案处理的其实是同一个困境:参与者没有共享的内存和锁表,谁也无法替谁做承诺,只能靠协议协商。差别在于承诺的强度,以及维持这个强度要付多少代价。

本地事务与分布式事务的差别

本地事务依赖数据库内部的锁、回滚段和原子落盘。事务边界内的中间状态对其他事务不可见,崩溃时由数据库自己用 undo 日志回退。

分布式事务没有这种统一的控制者。跨服务时每个参与者有自己的连接、自己的锁、自己的提交时刻。由此带来三个新难点:

一是部分失败。库存扣成功、订单创建失败不是异常而是常态——网络分区时你甚至不知道对方是否执行成功。

二是可见性。本地事务未提交的中间态对外不可见,而分布式事务每个子事务提交后立即可见,中间状态会被读到。

三是时间不确定。没有全局时钟,超时只能靠本地计时,等待方无法区分对方是慢还是死了。

这三条决定了所有方案都绕不开幂等:同一操作执行多次,效果必须与执行一次相同。

2PC 的承诺与代价

两阶段提交引入一个协调者。第一阶段协调者询问所有参与者能否提交,参与者写 redo/undo 后回复 yes/no 并锁住资源;第二阶段协调者根据回复下发 commit 或 rollback。

它的吸引力在于语义清晰:应用层几乎不用改代码,靠数据库或事务中间件就能接近强一致。代价同样清晰:

阻塞。参与者回复 yes 后进入不确定状态,锁必须一直持有到收到决定。协调者此时宕机,参与者只能等待。实践中靠协调者高可用加超时兜底,超时后尝试询问其他参与者或接任的协调者;但存在一个所有参与者都回复 yes 却无人知道最终决定的窗口,无法消除。

资源占用。持锁时间从”事务执行时间”延长到”最慢参与者执行时间 + 两轮网络往返”。热点数据在这种方案下很快成为瓶颈。

不支持长时间事务。几分钟以上的业务事务不可能长时间占着数据库锁。

适用场景因此很窄:参与者少(同机房两三个库)、事务短(毫秒到百毫秒级)、对锁竞争不敏感。跨服务、跨机房、涉及外部调用的场景不适合。

TCC 的业务侵入

TCC 把提交拆成三段:Try 冻结资源,Confirm 确认扣除,Cancel 释放冻结。以账户扣款为例:

public interface AccountTccService {
    @TwoPhaseBusinessAction(name = "deduct", commitMethod = "confirm", rollbackMethod = "cancel")
    boolean try(BusinessActionContext ctx, String accountId, long amount);

    boolean confirm(BusinessActionContext ctx);
    boolean cancel(BusinessActionContext ctx);
}

try 阶段并不是真正的扣款,而是把 amount 从可用余额挪到冻结余额;confirm 才真正扣减冻结额;cancel 把冻结额退回可用余额。

最大的好处是不依赖数据库行锁——冻结是业务字段,事务可以跨越任意长的时间。代价是每条涉及的业务都要写三段代码,且都要处理幂等与以下两类异常:

幂等。try 或 confirm 可能因网络重试被执行两次,冻结额不能加两次。通常靠事务 ID 去重表解决。

空回滚。try 根本没执行(请求在网络上丢了),但协调者触发了 cancel。cancel 必须能识别出这笔事务不存在,并返回一个成功,而不是报错。

悬挂。cancel 先于 try 到达(网络乱序),try 之后又执行成功,冻结的资金就再也没人释放。try 需要先检查该事务 ID 是否已回滚,已回滚则直接放弃。

三者都要在业务代码里显式处理。TCC 的复杂度不在框架,在这些边界条件的正确实现上,适合参与者少、一致性要求高、且能改造全部参与方代码的场景。

Saga 的补偿链

Saga 把长事务拆成一串本地事务,每步对应一个补偿动作:

T1 创建订单  ->  C1 订单置为已取消
T2 扣减库存  ->  C2 库存回滚
T3 冻结券    ->  C3 券解冻
T4 发起支付  ->  C4 支付撤销(退款)

正向按序执行,任一步失败就反向执行已成功步骤的补偿。隔离性最差是它的固有特征:扣减后的库存会被别的事务读到,而这个扣减稍后可能被回滚。缓解手段有语义锁(业务表里加 frozen 标记,读到标记的事务等待或拒绝)和重排序(把最容易失败的步骤放前面)。

Saga 有两种编排方式。编排式由一个协调器决定下一步调谁,逻辑集中但协调器承担了全部流程复杂度。协同式由每个服务完成后发出事件、下一个服务订阅自触发,耦合低但流程散落各处,排查要串起整条链路。

补偿失败怎么办是 Saga 真正难的地方。补偿本身也是远程调用,也会超时。可行做法是给每个补偿配重试队列和退避策略,重试到阈值后进入人工队列并告警。这里有一条底线:补偿必须最终能成功,因此补偿逻辑里不应包含可能失败的业务校验——”取消订单”作为”创建订单”的补偿时,不能因为订单已进入某种终态就拒绝取消。

本地消息表与事务消息

只需要保证下游某个操作一定发生(最常见的是”订单创建成功一定要发出事件”)时,本地消息表是成本最低的方案。它把消息和业务数据放进同一个数据库事务:

@Transactional
public Order createOrder(CreateOrderCommand cmd) {
    Order order = orderRepository.save(Order.create(cmd));   // 业务写入
    messageRepository.save(OutboxMessage.pending(            // 消息写入,同库同事务
        "OrderCreated", order.getId(), serialize(order)));
    return order;
}

因此不可能出现”业务成功但消息没落库”,也不可能出现”消息落库了业务却回滚”。提交后由独立任务扫描未发送的消息,投递成功再标记:

@Scheduled(fixedDelay = 1000)
public void relay() {
    for (OutboxMessage msg : messageRepository.findPending(100)) {
        try {
            broker.send(msg.getTopic(), msg.getPayload());
            msg.markSent();
        } catch (Exception e) {
            msg.increaseRetry();
            log.warn("delivery failed, id={}", msg.getId(), e);
        }
    }
    messageRepository.flush();
}

失败路径同样清楚:投递失败就留着 pending 状态等待下一轮重试;投递成功但标记失败,则重复投递一次,由消费端幂等消化。CDC(读数据库变更日志)可以把轮询扫表换成读 binlog,降低延迟,代价是多一项链路依赖。

它的局限是只保证”至少发出一次”,不处理多个参与者之间的强一致,适合通知型和协作型的下游。

幂等是所有方案的共同前提

无论选哪个方案,消费端和参与者都必须幂等,而幂等键的设计决定了一切:

CREATE TABLE processed_message (
    message_key   VARCHAR(64) PRIMARY KEY,   -- 幂等键:来自业务语义
    biz_type      VARCHAR(32) NOT NULL,
    processed_at  TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP
);

幂等键必须来自业务语义而不是随机数。随机 UUID 挡不住业务层面的重复:同一订单号因重试生成两个不同的消息 ID,去重表会当成两条消息。正确的键通常是”业务类型 + 业务主键 + 操作类型”,例如 order:20261021-8891:pay,并用唯一索引兜底,让冲突在写入层暴露。

错误做法是”先查后写”:先 SELECT 判断是否处理过再决定执行,并发下两个请求会同时通过检查。让数据库的唯一约束来裁决才是可靠的:

按一致性要求选型

方案 一致性 侵入成本 性能 适用
2PC 强一致 低 差(长锁持有) 同机房、短事务、少参与者
TCC 准强一致 高 中 资金类、参与者可控
Saga 最终一致 中 好 长事务、跨服务业务流程
本地消息表 最终一致 低 好 单向通知、事件可靠投递

选型问题不是”哪个技术更先进”,而是”这块业务能否容忍中间态”。能容忍就优先用本地消息表和 Saga——成本低,失败路径清晰,也不需要协调者高可用。只有业务明确要求”要么全成要么全败、不容忍短暂中间态”时才考虑 2PC 或 TCC。

把需要强一致的一组操作收拢到同一个库和同一个服务(本质是重划边界),或者允许失败后由对账任务修正,往往比引入事务协议更划算。

🔁 5
2.5kViews
106Likes
3Comments
3Participants
11 minread time

Comments (3)

  • 韩澈 · 2026-10-04 22:34

    代码那段我复制跑了,完全一致,省了我半天。

    Reply
  • AsyncLab · 2026-10-05 07:28

    写得不错,就是例子有点理想化。

    Reply
  • 硅屿 · 2026-10-05 16:26

    有个更简单的做法:直接用现成库。不过原理还是得懂。

    Reply