钱扣了,库存没扣,分布式事务怎么兜底

4 分钟阅读

跨了两个服务的操作,中间任何一步失败都会留下不一致。这篇讲清 TCC、本地消息表和 MQ 事务消息三种兜底方式,以及它们各自会漏在哪。

设想一个下单接口。订单服务先在自己的库里插入一条订单,然后调库存服务扣库存。这个调用超时了。订单服务不知道对面到底扣没扣,它只看到一个超时。重试,可能扣两次。不重试,可能一次没扣,可订单已经落库了。

本地事务能管住的只有自己这个库。两张表在同一个库,一个 BEGIN ... COMMIT 就能包住。换成两个服务、两个库,没有一个现成的事务能把两边一起包住。

下面几节的方案大多接受短暂的不一致,靠重试和补偿把它拉回来。这种目标叫最终一致。先把问题摆出来。

sequenceDiagram
    participant O as 订单服务
    participant S as 库存服务
    O->>O: 本地事务插入订单
    O->>S: 调用扣库存
    S--xO: 超时
    Note over O,S: 订单已落库,库存没扣

最直接的想法是照搬数据库的两阶段提交。协调者先让所有参与方 prepare,全点头了再 commit,有一个不点头就整体 rollback。数据库层的 XA 就是这套协议。它的麻烦出在 prepare 之后,数据行还被锁着,直到最终 commit 或 rollback 才放。协调者这时候宕机,参与方就一直举着手等。这条路的代价是一个横跨网络的长事务,高并发下很难受。

把一个大事务拆成三个业务动作(TCC)

TCC 换个角度,不指望数据库替它锁资源,让业务自己预留。一个事务拆成 Try、Confirm、Cancel 三段。

Try 阶段检查和预留。转账 100 块,Try 只把 A 账户里的 100 冻结,余额暂时不动。冻结成功,说明这笔钱后面的动作基本能做成。

Confirm 阶段确认。把冻结的 100 真正扣掉,事务结束。

Cancel 阶段取消。把冻结的 100 解冻,回到余额。

拿下单冻结库存举例。Try 把可售库存减 1、冻结库存加 1。Confirm 把冻结库存减 1,货就当出去了。Cancel 把冻结库存减 1、可售库存加 1,货回到货架。

sequenceDiagram
    participant TC as 事务协调器
    participant Order as 订单服务
    participant Stock as 库存服务

    TC->>Order: Try 预留
    TC->>Stock: Try 预留
    Order-->>TC: 成功
    Stock-->>TC: 成功
    alt 全部成功
        TC->>Order: Confirm 确认
        TC->>Stock: Confirm 确认
    else 有一步失败
        TC->>Order: Cancel 取消
        TC->>Stock: Cancel 取消
    end

预留这一步把「能不能做」提前定了下来,后面 Confirm 几乎不会失败。占住资源的是业务字段,数据库的行锁在这一层已经不参与。这套做法对业务的侵入也在这里,每个参与者都得实现 Try、Confirm、Cancel 三个接口。

三个坑要单独讲,它们比接口本身更容易出事。

坑什么情况怎么处理
重复调用Confirm 或 Cancel 因重试来了两次用事务 ID 去重,同一个 ID 只处理一次
空回滚Try 超时没到,协调者先发了 CancelCancel 发现没有对应的 Try,记录状态后直接返回成功,不能报错
悬挂Cancel 先到并成功,Try 之后才到Cancel 时留下标记,Try 看到已取消就拒绝执行

悬挂是最阴的一种。Cancel 已经处理完了,晚到的 Try 才把资源冻上。这一冻,没人再发 Confirm 或 Cancel,资源就永远挂在那里。

把消息和业务写进同一个本地事务(本地消息表)

另一种做法更朴素。它先把订单做完,把「还欠库存一个通知」这件事记在本地账上,之后慢慢还。

第一步,订单服务的本地事务里做两件事,插入订单,和往一张本地消息表插一条待发送的消息。两次写入在同一个库、同一个事务里,要么一起成功,要么一起失败。

第二步,一个独立的轮询任务读这张表里没发出的消息,逐条发给下游或者投到 MQ。

第三步,下游处理成功后,把这行消息标记成已发送。

重试由轮询兜底。发失败就下次再发,直到成功。下游因此可能收到重复消息。消息里带一个业务唯一 ID,下游用唯一索引或者去重表挡掉第二次。

sequenceDiagram
    participant Biz as 订单服务
    participant DB as 本地数据库
    participant Job as 轮询任务
    participant MQ as 消息队列
    participant Down as 下游服务

    Biz->>DB: 同一个事务里插入订单 + 待发送消息
    DB-->>Biz: 提交
    Job->>DB: 读取未发送消息
    Job->>MQ: 投递
    Job->>DB: 标记已发送
    MQ->>Down: 投递
    Down-->>MQ: 处理成功(幂等)

好处是不依赖任何特殊中间件,普通数据库就能做,重试逻辑自己完全掌握。代价有三处。消息表和业务表在同一个库,两个功能绑在一起。轮询有间隔,下游通常晚几百毫秒到几秒才收到。消息表会随业务量变大,需要定期清理已发送的历史。

让 MQ 来保管这条消息(MQ 事务消息)

本地消息表那套自己写有点啰嗦。RocketMQ 这类 MQ 把同一件事做进了协议里,叫事务消息。

生产者先给 broker 发一条半消息(half message)。broker 把它存下来,消费者看不到。

broker 确认收到之后,生产者才执行本地事务,比如插订单。

本地事务成功,生产者给 broker 发 commit,半消息对消费者可见。本地事务失败,发 rollback,消息被丢弃。

broker 迟迟没等到 commit 或 rollback,它会反过来回查生产者,问那条本地事务到底成没成,再决定提交还是丢弃。这一步是给生产者宕机准备的。

sequenceDiagram
    participant P as 生产者
    participant MQ as Broker
    participant DB as 本地数据库
    participant C as 消费者

    P->>MQ: 发送半消息
    MQ-->>P: 已存储,消费者不可见
    P->>DB: 执行本地事务
    alt 本地事务成功
        P->>MQ: commit
        MQ->>C: 消息可见,投递
    else 本地事务失败
        P->>MQ: rollback
        MQ->>MQ: 丢弃消息
    end
    Note over MQ,P: 生产者宕机,broker 回查本地事务
    MQ->>P: 回查事务状态
    P-->>MQ: 成功或失败

半消息对消费者不可见,加上回查,这两件事合起来,等于把「本地事务」和「消息发出」绑成了一个整体。业务代码不再需要自己建消息表和轮询。

它绑具体的 MQ。RocketMQ 支持这套语义,Kafka 的事务是另一回事,主要解决 Kafka 内部的读写原子和多分区提交,跨外部数据库的那段还是得自己兜。回查接口也得自己实现。下游照样要幂等。

怎么选

场景更合适的选择
两边都是数据库、事务短、并发低、能接受锁XA / 2PC,生产里用得不多
高并发、资金、业务能改造成预留式TCC
已有普通数据库、想少引中间件、能接受秒级延迟本地消息表
本来就用 RocketMQ、想少写代码MQ 事务消息

这三种做法都躲不开幂等。TCC 的 Confirm 和 Cancel 会被重复调用,消息方案里的消费者会收到重复消息。幂等没做,协调层越完整,重复和错乱只会来得越整齐。

评论