bigywater.tech
分布式事务 · 6

TCC:Try、Confirm、Cancel 的设计要点

TCC 把一个分布式事务拆成 Try、Confirm、Cancel 三个业务动作,每个动作在本服务内各自提交。这样的好处是什么、Try 该怎么写才不会超卖、总量守不守恒、管理器崩溃会留下什么?这篇用真实的 MySQL 把设计要点逐个量出来。

423.3 ms vs 2.5 ms
订单 A 在中间停 500 ms 时,同一行上订单 B 的耗时:XA 对 TCC
+15 件
检查和扣减写成两条语句时,库存 100 被 16 个线程抢出的超卖量
195
2000 个随机结局的订单里,管理器在 Try 之后崩溃、留下预留的订单数(采样 222 次,守恒检查 0 次违反)

三个动作各改什么

实测 · 本机执行输出
  初始: 账户 (balance, frozen, spent) = (1000, 0, 0),库存 (avail, locked, sold) = (10, 0, 0)
    Try   账户:冻结 80
    Try   库存:预留 1
    Confirm 账户、库存
  → CONFIRMED。 账户 (balance, frozen, spent) = (920, 0, 80),库存 (avail, locked, sold) = (9, 0, 1)
    Try   账户:冻结 80
    Try   库存:预留 1
    Cancel 账户、库存
  → CANCELLED。 账户 (balance, frozen, spent) = (920, 0, 80),库存 (avail, locked, sold) = (9, 0, 1)
    Try 失败:账户 1 余额不足,对已 Try 的 [] 发 Cancel
  → REJECTED(余额 50 买不起 80 的商品)。 账户 (balance, frozen, spent) = (50, 0, 80),库存 (avail, locked, sold) = (9, 0, 1)

Try 把资源从可用挪到冻结(余额、库存),Confirm 把冻结的挪到已用或已售,Cancel 把冻结的退回可用。每个动作是本服务的一次本地提交,不跨实例。

业务层预留代替数据库锁

实测 · 本机执行输出
  订单 A 在中间停多久 | 订单 B(TCC)耗时 | 订单 B(XA)耗时
  ------------+-------------+-----------
  0 ms        | 0.6 ms      | 0.7 ms    
  200 ms      | 1.3 ms      | 179.5 ms  
  500 ms      | 2.5 ms      | 423.3 ms  
  hold=0 一行是 50 次的中位数,其余是单次测量。TCC 里订单 A 的 Try 在各自的本地事务里已经提交,行锁早就放了;XA 里订单 A 的行锁从 PREPARE 一直握到 COMMIT

Try 提交之后行锁就释放了,别的订单可以继续处理同一行;XA 里行锁从 PREPARE 握到 COMMIT。预留的资源被记在业务列里。

Try 要一条语句检查并预留

实测 · 本机执行输出
  条件 UPDATE(avail >= n 写在 WHERE 里,正确的 Try):成功 100、被拒 50;库存 avail=0 locked=100 sold=0,avail + locked + sold = 100
  先 SELECT 再 UPDATE(检查和扣减是两条语句):成功 115、被拒 35;库存 avail=-15 locked=115 sold=0 —— 多卖了 15 件,avail 变成 -15

条件写进 UPDATE 的 WHERE,检查和修改是同一条语句。先 SELECT 再 UPDATE 在并发下会超卖。

总量守恒

实测 · 本机执行输出
  结局:{'CONFIRMED': 1175, 'CANCELLED': 630, 'ABANDONED': 195, 'REJECTED': 0};采样 222 次,违反不变量 0 次
  最终:账户 balance=9890400 frozen=15600 spent=94000(frozen = 195 笔崩溃订单 × 80 = 15600);库存 avail=3630 locked=195 sold=1175

只要每个动作都是原子的单语句,balance + frozen + spent 和 avail + locked + sold 始终等于初始值,并发采样没有违反。

崩溃留下的预留

实测 · 本机执行输出
  结局:{'CONFIRMED': 80, 'ABANDONED': 20, 'REJECTED': 30};库存 avail=0 locked=20 sold=80
  30 个订单因“库存不足”被拒,但库里其实只卖出了 80 件,有 20 件被崩溃订单的预留占着(没有人会再来 Confirm 或 Cancel)
  朴素版本里 stock 表只有一个总数 locked,没有“这 20 件是哪个订单的”:想释放只能整体清零。试一下:
  (a) 表上有 CHECK(locked >= 0) 兜底:
  清理程序把 locked 整体释放: (avail, locked, sold) = (10, 0, 0) (此时订单 X 的预留也被释放了)
  订单 X 的 Confirm 随后到达:OperationalError 3819 Check constraint 'c2' is violated.
  (b) 表上没有这个 CHECK:
  清理程序把 locked 整体释放: (avail, locked, sold) = (10, 0, 0) (此时订单 X 的预留也被释放了)
  订单 X 的 Confirm 随后到达,执行成功: (avail, locked, sold) = (10, -1, 1)

管理器在 Try 之后崩溃,预留一直占着库存,别人买不到;没有 xid 级别的记录就不知道该释放谁,整体清零会连在途订单的预留一起释放。

代价

实测 · 本机执行输出
  写法         | 提交动作                     | 中位数    | p99    | 相对本地事务
  -----------+--------------------------+--------+--------+-------
  本地事务(一个实例) | 1 次提交                    | 0.2 ms | 0.5 ms | 1.0x  
  TCC(两个服务)  | 4 次服务调用,4 次提交            | 0.6 ms | 1.1 ms | 2.9x  
  XA(两个实例)   | 2 次 PREPARE + 2 次 COMMIT | 0.7 ms | 3.8 ms | 3.5x  
  TCC 没有协调者写决定日志、没有 prepare 状态,每个动作都是普通的单语句提交,所以比 XA 便宜;代价是业务代码要为每个服务写 Try / Confirm / Cancel 三套逻辑,并且自己负责防重复、防悬挂(下一篇)

TCC 的每个动作都是普通提交,尾延迟比 XA 低;但每个服务要写三套业务逻辑,防重复、防悬挂都靠业务自己。

分步演示

TCC
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / MySQL 26.7.0 与 PostgreSQL 16.15(Homebrew,每个实验自己 initdb、自己起实例,端口 9150 以上,只监听 127.0.0.1)/ Python 3.14.8 + PyMySQL 1.2.3 + psycopg 3.3.6 / Redis 8.10.2 / Kafka 4.3.1 / RabbitMQ 4.3.6 / etcd 3.7.2(按各篇注明用到哪些);不连任何外部服务,不碰本机已有的数据库实例;故障用 kill -9 进程、让连接断开或在代码里的指定位置抛异常来制造;计时用 time.perf_counter,数字每次运行会有小幅抖动。本篇用两个独立的 MySQL 26.7.0 实例(账户服务、库存服务)和一个单实例对照;TCC 是我自己写的 Python 实现(tools/articles/tcc.py),故意不带 xid 记录和防重复保护;XA 对照用的是 MySQL 自带的 XA 语句;“管理器崩溃”是在代码里直接不再发后续调用,不是 kill 进程
    • 没有验证:Seata TCC / Hmily 等框架的实际行为;服务之间的调用是网络请求时的超时、重试与乱序(只用了进程内的函数调用,这些是下一篇的内容);多商品、多账户订单的死锁风险;Confirm / Cancel 失败后的重试策略;CHECK 约束在其它数据库里的行为
    京ICP备2026067502号-1京公网安备11010502063487号