TCC 把一个分布式事务拆成 Try、Confirm、Cancel 三个业务动作,每个动作在本服务内各自提交。这样的好处是什么、Try 该怎么写才不会超卖、总量守不守恒、管理器崩溃会留下什么?这篇用真实的 MySQL 把设计要点逐个量出来。
初始: 账户 (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。预留的资源被记在业务列里。
条件 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 低;但每个服务要写三套业务逻辑,防重复、防悬挂都靠业务自己。