基于 TCC 思想的分布式库存一致性方案
外卖系统从下单到出餐涉及订单、库存、营销等多个服务。在大流量下,"用户成功下单,商家却发现菜品售罄"是最尴尬的事故之一。库存系统需要承担三件事:
- 预扣环节必须严格不超卖,并发 10 万级 QPS 也不能漏放;
- 订单创建失败时,库存必须在秒级退还,避免长时间占库;
- 订单创建成功后,预扣库存要准实时 Confirm,最终准确无误、不多扣也不少扣。
本文以 TCC 的三阶段业务语义为基础,结合 Reservation 状态机、定时对账和事件驱动,拆解这套方案如何落地。
一、目标与约束
业务对库存事务有四个硬性要求:
| 维度 | 要求 |
|---|---|
| 一致性 | 订单与库存数据最终一致,不能出现多卖少卖 |
| 实时性 | 订单创建成功后,预扣要准实时可感知(秒级) |
| 防超卖 | 高并发下扣减不能穿透,库存到 0 时必须失败 |
| 失败补偿 | 订单创建失败时,预扣库存要快速释放 |
这四点直接决定了方案必须在 “准实时 + 强一致 + 防超卖” 的三角中寻找平衡。
二、业界方案对比
库存是"高并发 + 不能超卖 + 要准实时"的场景,常见的分布式一致性方案往这三条硬约束上一套,很快就会被筛掉一大半。先看总表,再逐个说清为什么。
| 方案 | 一致性 | 实时性 / 性能 | 隔离性 | 适合场景 |
|---|---|---|---|---|
| 2PC | 强(正常时)/ 故障时可能不一致 | 差(同步阻塞 + 持锁) | 强(靠数据库锁) | 单库多资源、低并发 |
| 3PC | 强(正常时)/ 分区时仍可能不一致 | 较差(多一轮交互) | 强 | 几乎无人用于生产 |
| Saga | 最终一致 | 好 | 差(无预留,有可见中间态) | 长流程、松耦合 |
| 本地消息表 / MQ 事务 / 最大努力通知 | 最终一致 | 依赖 MQ | 好 | 对实时性要求宽松 |
| TCC(Try 预留) | 最终一致 | 好 | 好(业务层预留) | 库存扣减、资金类操作 |
2.1 2PC:高并发下的致命伤是"同步阻塞 + 锁定资源"
2PC(两阶段提交)的流程:协调器先让所有参与者 Prepare(各自执行本地事务、持有锁、写好 redo/undo,但不提交、只投票),全部投赞成票后再统一 Commit。
它的问题在库存这种场景是致命的:
- 同步阻塞、长时间锁资源:从 Prepare 到 Commit 的整个窗口里,参与者一直持有数据库行锁,被锁住的库存行对其他请求完全不可用。10 万 QPS 抢同一批菜,等于把并发全部串行化——性能直接被打垮,这就是常说的"2PC 会锁事务、高并发不行"。
- 协调器单点:协调器在 Prepare 之后宕机,所有参与者握着锁进退不得,只能干等它恢复,锁一直被占着。
- 故障时仍可能不一致:Commit 阶段协调器或网络出问题,可能出现"一部分参与者已提交、另一部分没收到指令",数据照样不一致。
一句话:2PC 是拿"可用性和并发"换"强一致",而库存要的恰恰是"高并发 + 准实时",方向正好相反。
2.2 3PC:想救 2PC,但代价更大、也没救彻底
3PC 在 2PC 基础上加了一个 CanCommit 预询问阶段,并引入超时机制:参与者等不到协调器指令时,可以自行超时中止(或提交),从而缓解"协调器一宕机就一直阻塞"。
但它并没有真正解决问题:
- 网络往返更多(三个阶段),延迟更高;
- 状态机更复杂,实现和运维都更难;
- 网络分区下依旧会不一致:分区两侧可能一侧提交、一侧中止,脑裂问题 3PC 同样搞不定。
所以生产环境几乎没人上 3PC——它更像教科书里"如何缓解 2PC 阻塞"的思路,而不是一个真用的方案。
2.3 Saga:和本方案最像,但输在"没有预留"
Saga 把一个长事务拆成一串各自独立提交的本地事务 T1→T2→…→Tn,每一步都立即真实提交,并配一个补偿事务 Ci 用于事后反向抵消;某步失败,就逆序跑补偿。
它和本方案的"事件驱动 + 补偿"听起来很像,但有一个本质区别:
Saga 没有"预留 / 冻结"这一步,每个本地事务都是立即提交、立即对外可见。
那 Saga 到底什么时候会超卖? 看一个最极端的例子——库存只剩 1 份,两个用户同时下单(两个并发 Saga,各自 T1 创建订单、T2 扣库存):
| 时刻 | Saga A(用户甲) | Saga B(用户乙) | 可售库存 |
|---|---|---|---|
| t1 | T1 创建订单甲,立即提交 | — | 1 |
| t2 | — | T1 创建订单乙,立即提交 | 1 |
| t3 | T2 扣库存 1→0,提交 | — | 0 |
| t4 | — | T2 扣库存:发现已是 0,失败 | 0 |
| t5 | — | 逆序补偿 C1:取消订单乙 | 0 |
关键在 t2 这个窗口:乙创建订单时,甲扣库存那一步还没跑,乙读到的是"还没被扣"的旧库存,于是乙也以为自己抢到了。因为 Saga 各步独立提交、互不隔离,"看到还有货"和"下单成功"不是原子的,这个窗口在并发下必然存在。于是乙经历的是"先下单成功、后被退单"——如果乙已付款、商家已接单出餐,补偿 C1 根本没法干净收场。
所以 Saga 的超卖,不一定是把库存扣成负数,而是让"超过库存数量的订单"在一段时间内同时成立,只能事后靠补偿把多余订单退掉。这带来两个硬后果:
- 隔离性差、会超卖:如上面的例子——订单已提交、库存还没扣的中间态对外是可见的,并发请求同时抢到同一批库存。本场景恰恰把"不能超卖"列为硬约束,Saga 这点是一票否决。
- 补偿是"事后补救",不一定救得回来:补偿是语义上的反向操作,如果中间态已经被下游消费(比如超卖的菜已经出餐),补偿也无力回天。
而 TCC 的 Try 是"先预留、再确认":Try 阶段就把库存"冻结"(扣可售、加冻结),在 Confirm / Cancel 之前,这部分库存对其他请求是锁定的、拿不走的。回到上面的例子:换成 TCC,甲在 Try 时就把那 1 份冻结(可售 1→0、冻结 0→1),乙 Try 时发现可售已是 0,当场失败——乙的订单根本不会创建成功,自然也没有"先成功后退单"这一说。对比下来:
| 维度 | Saga | TCC(本方案借用的语义) |
|---|---|---|
| 资源是否预留 | 否,每步立即提交 | 是,Try 先冻结 |
| 隔离性 | 差,有可见中间态 | 好,预留在窗口期锁定资源 |
| 超卖风险 | 有 | 无(预留兜底) |
| 失败处理 | 事后补偿(逆向抵消) | Cancel 释放预留(本来就没真正扣) |
| 适合 | 长流程、松耦合、对隔离性要求低 | 库存、资金这类"先占位"的短事务 |
2.4 为什么是 TCC
把三条硬约束代进去,只有 TCC 全中:
- 不能超卖 → 需要 Try 阶段的资源预留(2PC 靠锁但太慢,Saga 压根没有预留);
- 准实时 → Try 用业务层的
update where冻结,不持有数据库行锁、不傻等协调器,性能高(2PC 因此出局); - 失败快速释放 → Cancel 只是把预留放掉,本来就没真正扣,秒级回退。
因此我们选择 TCC 的 Try / Confirm / Cancel 语义作为基础思路——但正如后文会讲的,我们只借它"预留 / 确认 / 释放"的语义骨架,并不照搬那套全局协调器(第八节会专门讲为什么)。
三、TCC 核心思路
TCC 将一个分布式事务拆成三个阶段,由事务发起方协调事务参与方完成:
| 阶段 | 含义 | 操作 |
|---|---|---|
| Try | 资源检查和预留 | 冻结资源,但不真正扣减 |
| Confirm | 确认资源扣减 | 基于 Try 的预留,真正提交 |
| Cancel | 释放预留资源 | 释放 Try 阶段冻结的资源 |
和 2PC 的区别在于:TCC 的 Try 不依赖数据库的行锁等待,而是把资源冻结操作下沉到业务层(通过 update where 条件),把分布式协调变成业务表达。
四、标准 TCC 是什么
在介绍本方案之前,先明确标准 TCC 的协议。它由一个全局事务协调器(Coordinator)驱动,参与角色包括:
- 事务发起方:发起全局事务,决定何时 Confirm 或 Cancel;
- 事务协调器:持久化全局事务状态和分支事务状态;
- 事务参与者:订单、库存、营销等资源服务,对外暴露 Try/Confirm/Cancel 三个接口;
- 业务资源:参与者各自管理的数据库和冻结资源。
标准 TCC 流程如下:
sequenceDiagram
autonumber
participant B as 事务发起方
participant C as TCC 协调器
participant O as 订单参与者
participant I as 库存参与者
participant P as 营销参与者
B->>C: 开启全局事务(XID)
C->>O: Try(XID, branch_id)
O-->>C: Try 成功
C->>I: Try(XID, branch_id)
I-->>C: Try 成功
C->>P: Try(XID, branch_id)
alt 所有 Try 成功
P-->>C: Try 成功
C->>C: 持久化 COMMIT 决议
C->>O: Confirm
C->>I: Confirm
C->>P: Confirm
Note over C,P: Confirm 失败持续重试
else 任一 Try 失败
P-->>C: Try 失败
C->>C: 持久化 ROLLBACK 决议
C->>O: Cancel
C->>I: Cancel
C->>P: Cancel
Note over C,P: Cancel 失败持续重试
end
标准 TCC 的硬性约束:
- Try 在一个短时间窗口内全部完成;
- Confirm / Cancel 由协调器统一决策,全局事务超时即触发 Cancel;
- 每个参与者使用
XID + branch_id标识分支事务; - 协调器持久化全局决议,进程重启后继续推进。
只实现三个同名接口,并不等同于实现了 TCC。
五、本方案设计
5.1 事务角色
| 角色 | 服务 | 选择理由 |
|---|---|---|
| 事务发起方 | 订单服务 | 已有下单主链路,作为事务中心最自然 |
| 事务参与方 1 | 订单服务 | 自身就是订单事务的发起方,订单落库即本地事务 |
| 事务参与方 2 | 库存服务 | 库存预扣是 Try 的核心 |
为什么不让 BuyerAPI 网关做发起方?网关承担了太多职责(路由、限流、鉴权),再加上事务协调会很难维护。新建独立协调服务又成本过高。订单服务天然就是下单主链路的承担者,由它做发起方最简单。
5.2 Try / Confirm / Cancel 映射
| 服务 | Try | Confirm | Cancel |
|---|---|---|---|
| 订单服务 | 订单落库(待确认) | 提交本地事务 | 回滚本地事务 |
| 库存服务 | 库存预扣 + 写 Created 状态记录 |
确认库存扣减,状态置为 Completed |
释放预扣库存,状态置为 Canceled |
Created / Completed / Canceled 是库存使用记录表的状态机字段,每次操作对应一次状态变更。
5.3 流程全景
下图展示库存事务的完整时序,与标准 TCC 不同,本方案不依赖协调器,而是由订单事件驱动 Confirm,由定时任务兜底补偿:
sequenceDiagram
autonumber
participant ODB as 订单服务DB
participant OS as 订单服务
participant IS as 库存服务
participant IDB as 库存服务DB
participant MQ as MQ
participant CR as 定时任务
Note over OS,IS: ① Try 阶段:预扣库存
OS->>IS: Try: 预扣库存
IS->>IDB: 事务:
1. 库存表:扣减可售库存
2. 库存使用记录表:插入状态=Created
IDB-->>IS: OK
IS-->>OS: 预扣成功
Note over OS,ODB: ② 订单落库
OS->>ODB: 订单表:创建订单
Note over OS,MQ: ③ 发布订单成功事件
OS->>MQ: 订单创建成功事件
Note over IS,MQ: ④ Confirm 阶段:事件驱动
MQ->>IS: 推送订单创建成功事件
IS->>IDB: Confirm:库存使用记录 -> Completed
IDB-->>IS: OK
Note over CR,ODB: ⑤ 定时补偿扫描
CR->>IDB: 查询状态=Created 的库存使用记录
IDB-->>CR: 记录列表
CR->>ODB: 查询对应订单是否存在
ODB-->>CR: 查询结果
alt 订单不存在
CR->>IDB: Cancel:
1. 库存表:退回预扣库存
2. 库存使用记录表 -> Canceled
else 订单存在
CR->>IDB: Confirm:库存使用记录 -> Completed
end
六、补偿机制
6.1 为什么需要补偿
正常路径下,订单创建成功后会通过 MQ 触发库存 Confirm。但以下场景会留下"孤儿":
- Try 预扣库存成功,但接口超时返回失败(库存实际已扣);
- 订单落库失败,但库存已预扣;
- 订单创建成功事件推送失败 / 消费失败;
- 库存服务的 Confirm 处理失败。
这些场景必须靠补偿收敛,而不是靠重试整个流程。补偿的本质是:定期扫描"应该被处理但还没被处理"的记录,主动推进。
6.2 补偿设计
| 维度 | 决策 |
|---|---|
| 补偿对象 | 库存 DB 中状态为 Created 的库存使用记录 |
| 补偿周期 | 由定时任务(Cron)按固定频率扫描 |
| Confirm 补偿条件 | 对应订单已经创建成功(订单 DB 中能查到) |
| Cancel 补偿条件 | 对应订单未创建(订单 DB 中查不到) |
补偿不是由库存服务单方面决定,而是通过查询订单 DB 的事实。这避免了库存域误判订单状态造成错补。
6.3 补偿流程
flowchart TD
A[Cron 触发扫描] --> B[查询状态=Created 的库存使用记录]
B --> C{记录存在?}
C -->|否| Z[结束]
C -->|是| D[根据 order_id 查询订单 DB]
D --> E{订单是否存在?}
E -->|是| F[Confirm:
记录状态 -> Completed]
E -->|否| G[Cancel:
1. 库存表:退回库存
2. 记录状态 -> Canceled]
F --> H[更新记录 updated_at]
G --> H
H --> I[处理下一条记录]
I --> C
注意:补偿流程只处理 Created 状态的记录。Completed 和 Canceled 都是终态,不会被再次推进。
七、TCC 的三大经典问题:本方案只需正面解决一个
幂等、空回滚、悬挂,是任何带 Try/Confirm/Cancel 异步竞态的方案都绕不开的三个经典坑。标准 TCC 需要一个统一框架来同时防住这三个。
但在本方案里,情况不太一样:这三个里有两个(空回滚、悬挂)被「事件驱动 + 预扣模型」从结构上消除了,真正需要主动处理的只有幂等。 这也是第八节敢说"不用上标准 TCC 框架"的底气之一。
7.1 幂等:唯一必须主动处理的
场景:上游可能重复调用 Try / Confirm / Cancel——接口超时重试、MQ 消息重复投递、定时补偿和事件驱动并发撞车。
- Try 重复:订单服务调用 Try 超时后重试,如果不防,同一盘菜可能被预扣两次 → 直接超卖;
- Confirm / Cancel 重复:
OrderCreated/OrderCreatedFailed重复消费,或事件驱动和定时补偿同时命中同一条Created记录。
解决方案:每个事务操作绑定唯一 operation_id(如 op_try_{order_item_id}_{dish_id}),执行前先查是否已执行过;MySQL 层面用 status + version 做 CAS,重复操作命中终态(Completed / Canceled)就直接返回成功,不再改库存。这是本方案唯一必须靠机制硬防的一个。
7.2 空回滚:本方案不存在
什么是空回滚(标准 TCC 的经典坑):Try 还没真正落库(或压根失败),Cancel 先到了;如果 Cancel 盲目执行"退回库存",就会凭空把库存加上去。
为什么本方案不会发生:本方案里 Cancel 只在两种情况下被触发,而这两种情况都以"Try 已经成功、Created 记录确实存在"为前提——
- 消费
OrderCreatedFailed:订单创建失败。但订单是在"库存预扣成功"之后才开始创建的,所以此刻Created记录一定已经落库; - 定时补偿:扫描的目标就是"确实存在、状态为
Created"的记录,订单 DB 里查不到对应订单才回滚。
换句话说,Try 失败就没有记录,而没有记录就根本不会有任何东西去触发一次针对它的 Cancel。"Cancel 找不到 Try 记录"的那个窗口,在这套事件驱动的流程里根本不存在。
那
Canceled状态的 Fence 记录是干嘛的?它不是用来防空回滚的,而是幂等的载体:重复 Cancel 读到Canceled终态就直接返回成功,不会重复退库存(见 7.1)。
7.3 悬挂:本方案也不会发生
什么是悬挂(标准 TCC 的经典坑):Cancel 先于 Try 执行完毕——Cancel 已把事务标记为回滚,迟到的 Try 才到达,又把资源预扣了,形成"已回滚却又预扣"的矛盾。
为什么本方案不会发生:悬挂的前提是"Cancel 和 Try 乱序"。但本方案是预扣模型,回滚动作永远是"成功预扣"的下游:先有一次成功的 Try(预扣 + 写 Created 记录),才可能有后续的 OrderCreatedFailed 或补偿扫描去回滚它。不存在"Cancel 先于 Try 到达"的时序窗口——没有预扣成功,就没有任何后续回滚可言。
所以标准 TCC 里"Try 必须先检查是否已存在 Canceled Fence、命中即拒绝"那套防悬挂逻辑,在本方案里是用不到的。
7.4 三者在本方案里的真实状态
| 经典问题 | 标准 TCC 的应对 | 本方案 |
|---|---|---|
| 幂等 | 框架统一幂等 | 必须主动处理:operation_id 唯一键 + 状态终态判断 |
| 空回滚 | 框架统一防(Cancel 只写 Fence) | 结构性不存在:Cancel 只针对已成功的 Try |
| 悬挂 | 框架统一防(Try 查 Fence) | 结构性不存在:回滚永远是成功预扣的下游 |
一句话:标准 TCC 要靠一个框架同时防住三个问题,而本方案靠流程设计让其中两个根本不发生,只需为幂等写一处防护。 这正是"实现更简单"的又一条证据。
八、为什么不用标准 TCC
本方案借用了 TCC 的 Try/Confirm/Cancel 业务语义,但没有引入全局事务协调器。既然都讲 Try/Confirm/Cancel 了,为什么不直接上一套标准 TCC 框架?
答案可以归结成一句话:这个场景的一致性边界很小(只有订单 + 库存两个域,且库存的 Confirm 能由订单事件异步驱动),标准 TCC 那套复杂度在这里拿不到对应的收益。 而标准 TCC 的成本,几乎全在「实现和运维的复杂度」上。拆开看:
8.1 不用新增一个协调器服务
标准 TCC 的核心是一个独立的全局事务协调器:它要持久化全局事务和每个分支事务的状态、要在宕机重启后恢复推进、要集群化保证高可用。这是一个全新的、有状态的、要 7×24 运维的服务——部署、监控、扩容、故障演练,一样都少不了。
而本方案根本不需要这个角色:"决议"就是订单事实本身——订单 DB 里有没有这条订单,就是唯一的真相。库存该 Confirm 还是 Cancel,查一下订单 DB 就知道了,不需要一个协调器专门记录"全局事务到底该提交还是回滚"。少一个服务,就少一整个故障域和运维面。
8.2 不用给每个参与者维护 Try/Confirm/Cancel 三套接口
标准 TCC 要求所有参与者(订单、库存、营销……)都按统一的分支协议,对外暴露 Try / Confirm / Cancel 三套接口,并且这三套接口每一套都要各自处理好幂等、防空回滚、防悬挂。参与者越多,接口数量和一致性约束就按倍数增长,任何一套实现错了都可能踩坑。
本方案里,库存服务对外只暴露一个「预扣」接口(就是 Try 的业务动作)。至于 Confirm 和 Cancel,它们是库存域内部根据订单事件和状态机自己推进的,根本不需要再对外开 Confirm / Cancel 两个接口。接口面从「3 套 × N 个参与者」收敛到「1 个预扣接口 + 1 个内部状态机」,实现和测试的负担都小了一个量级。
而且正如第七节所说,空回滚和悬挂在本方案里结构性不存在——所以连"每套接口都要防悬挂、防空回滚"这部分最烧脑的负担,也一并省掉了。
8.3 一致性边界就在库存域内,单库本地事务就够了
仔细想,库存的 Try / Confirm / Cancel,落到存储上都只是对库存 DB 的几次本地事务:预扣是"扣减可售 + 插一条 Created 记录",Confirm 是"把记录改成 Completed",Cancel 是"退回库存 + 把记录改成 Canceled"。每一步都是一个本地事务,天然原子。
标准 TCC 的协调器要解决的,是「多个独立资源如何原子提交」——而这里真正需要原子性的只有库存 DB 这一处。既然单库本地事务就能保证,再引入一个跨库的两阶段协调,就是为一个不存在的问题买复杂度。
8.4 恢复依据是"订单事实",不是"协调器状态"
标准 TCC 的恢复,靠协调器把全局决议(commit / rollback)持久化下来,重启后照着推进。这就要求协调器的存储本身高可用,又引入一层"协调器的数据也要一致"的问题。
本方案的恢复依据要简单得多:定时任务扫一遍 Created 状态的库存记录,去订单 DB 查这条订单到底成没成——成了补 Confirm,没成补 Cancel。事实只有一个来源(订单 DB),不需要再维护一份协调器的全局事务日志,更不用为它做高可用。
8.5 确认之后的取消,标准 TCC 也管不了
标准 TCC 的 Cancel,只管 Try → Confirm 这个窗口内的失败:Try 成功了、但全局事务要回滚,于是 Cancel 释放预留。可一旦 Confirm 完成、订单进入履约,再发生取消(用户反悔、商家拒单、出餐或配送异常)——这就超出 TCC Cancel 的覆盖范围了。本方案里这对应的是 Refund(确认之后的逆向),它本来就不在 TCC 的语义里,只能靠事件 + 状态机单独做。
既然"确认之后的取消"反正要用事件驱动兜底,那前面那截很短的 Try → Confirm(下单到创建成功,秒级)又何必单独引入一个协调器?统一用一套状态机 + 事件驱动表达,反而更一致。
8.6 和标准 TCC 的对比
| 对比项 | 标准 TCC | 本方案 |
|---|---|---|
| 协调器 | 需要独立协调器集群 | 不需要,决议依据是订单事实 |
| 参与者接口 | 每个参与者实现 Try/Confirm/Cancel 三套 | 库存只暴露预扣一个接口,Confirm/Cancel 由内部状态机推进 |
| 事务标识 | XID + branch_id | reservation_id + operation_id |
| 一致性边界 | 跨多个资源的原子提交 | 库存域内单库本地事务 |
| Confirm 时机 | 全部 Try 成功后立即触发 | 订单创建成功事件触发 |
| Cancel 时机 | 全局事务失败或超时 | 订单创建失败事件 / 定时补偿触发 |
| 恢复中心 | 协调器持久化全局决议 | 定时扫描订单 DB + Reservation 状态机 |
| 一致性模型 | 协调器驱动的两阶段提交 | 状态机驱动的事件最终一致性 |
所以本方案的准确定义是:
借鉴 TCC 的预留 / 确认 / 释放语义,但没有采用全局事务协调器和标准分支事务协议;本质上是一套基于 Reservation 状态机、Transactional Outbox、幂等 Fence 和定时补偿实现的最终一致性库存方案。选它的根本原因是实现和运维都更简单:不用新增协调器服务,不用给每个参与者维护三套接口,甚至连空回滚和悬挂都不用防。
九、方案总结
这套方案的核心是把分布式事务中的每一个不确定窗口都变成可观察、可重试、可补偿的状态:
- Try 阶段:库存预扣 + 写
Created状态记录,单次本地事务完成; - Confirm 阶段:由订单成功事件驱动,库存使用记录从
Created推进到Completed; - Cancel 阶段:由订单失败事件或定时补偿触发,库存使用记录从
Created推进到Canceled,库存数量同步退回; - 三大问题:只有幂等需要主动处理(
operation_id+ 状态终态);空回滚和悬挂在事件驱动 + 预扣模型下被结构性消除,根本不会发生; - 最终一致性:定时扫描 + 状态机推进,保证中间态记录最终收敛到终态。
TCC 提供了清晰的语义骨架,但落地时不需要复刻协调器。库存作为独立领域,通过状态机、事件驱动和定时补偿的组合,就能同时拿到准实时、不能超卖和快速释放库存三项能力。这套思路可以推广到优惠券、礼品卡、座位预订等任何"预扣 → 确认或释放"的场景。