第 6 篇的 TCC 在理想的网络里没问题。但真实的网络会让消息迟到、乱序、重复:Cancel 比 Try 先到、Try 迟到、Confirm 发两遍。这篇把这三类问题一个个复现,再用一张 tcc_log 把它们一起防住,并穷举所有消息顺序来验证。
朴素版,表上有 CHECK(balance >= 0 and frozen >= 0):
第 1 次 Cancel:OperationalError 3819 Check constraint 'c1' is violated.
第 2 次 Cancel:OperationalError 3819 Check constraint 'c1' is violated.
第 3 次 Cancel:OperationalError 3819 Check constraint 'c1' is violated.
状态: (1000, 0, 0) —— CHECK 拦住了,但 Cancel 永远失败,管理器只能一直重试
朴素版,没有 CHECK:Cancel 执行成功,状态 (balance, frozen, spent) = (1080, -80, 0) —— 凭空多出 80 块钱,frozen 变成负数
防护版:Cancel 返回 EMPTY,状态 (1000, 0, 0),tcc_log = [('x1', 'CANCELLED')] —— 不改业务列,只记一条 CANCELLEDTry 没执行,Cancel 先到。朴素版会把没冻结过的钱退回,凭空多出 80;有 CHECK 时则 Cancel 永远失败。防护版发现没有 Try 记录,只记一条 CANCELLED。
“宽容 Cancel”版:Cancel(frozen >= 80 才执行)先到,什么都没改,状态 (1000, 0, 0)
随后迟到的 Try 到达并执行成功,状态 (920, 80, 0) —— 80 块钱被冻结了,管理器已经走完流程,再也不会有人 Confirm 或 Cancel
防护版:Cancel 先到(记 CANCELLED),迟到的 Try 被拒绝:x2 已经有记录,拒绝 Try;状态 (1000, 0, 0),tcc_log = [('x2', 'CANCELLED')]Cancel 先到、Try 迟到。只修空回滚而不记录 Cancel 来过,迟到的 Try 会冻结资源,而管理器早已结束。防护版靠同一条 CANCELLED 记录,让迟到的 Try 被主键冲突拒绝。
朴素版:Try → Confirm 一次,状态 (920, 0, 80)
同一个 Confirm 再来一次:状态 (920, -80, 160) —— spent 记了 160,frozen 变成 -80
朴素版:Try → Cancel 一次,状态 (1000, 0, 0)
同一个 Cancel 再来一次:状态 (1080, -80, 0) —— balance 比初始多了 80
防护版:Try → Confirm,再重复 Confirm 两次:状态 (920, 0, 80) → (920, 0, 80),tcc_log = [('x3', 'CONFIRMED')] —— 重复的 Confirm 看到状态已经是 CONFIRMED,什么都不做
防护版:Try → Cancel,再重复 Cancel:状态 (1000, 0, 0),tcc_log = [('x4', 'CANCELLED')]确认包丢了,管理器重发。朴素版每次都再改一遍:spent 变成 160、frozen 变成 -80。防护版看到状态已是目标状态就直接返回。
共 23 条序列(取消路径 16 条、确认路径 7 条);正确的终态:取消路径 = 初始 (1000, 0, 0),确认路径 = (920, 0, 80);为了让错误能显现,朴素版和宽容版跑在没有 CHECK 的表上
朴素版:23 条序列里 14 条终态不对 {'frozen 变成负数(多退或多确认了)': 6, '预留永久卡住(frozen > 0)': 5, '其它': 3}
例:取消路径 到达顺序 C => (balance, frozen, spent) = (1080, -80, 0)
例:取消路径 到达顺序 C → C => (balance, frozen, spent) = (1160, -160, 0)
例:取消路径 到达顺序 C → C → T => (balance, frozen, spent) = (1080, -80, 0)
例:取消路径 到达顺序 C → T → C => (balance, frozen, spent) = (1080, -80, 0)
宽容 Cancel 版:23 条序列里 15 条终态不对 {'frozen 变成负数(多退或多确认了)': 1, '预留永久卡住(frozen > 0)': 11, '其它': 3}
例:取消路径 到达顺序 C → T => (balance, frozen, spent) = (920, 80, 0)
例:取消路径 到达顺序 C → C → T => (balance, frozen, spent) = (920, 80, 0)
例:取消路径 到达顺序 C → T → T => (balance, frozen, spent) = (840, 160, 0)
例:取消路径 到达顺序 T → C → T => (balance, frozen, spent) = (920, 80, 0)
防护版:23 条序列里 0 条终态不对 {'frozen 变成负数(多退或多确认了)': 0, '预留永久卡住(frozen > 0)': 0, '其它': 0}
(T=Try,K=Confirm,C=Cancel)23 条消息顺序里,朴素版 14 条、宽容 Cancel 版 15 条终态不对,带 tcc_log 的版本全部正确。三个问题要一起防,只修一个不够。
实现 | 终态不是初始状态的次数 | 另一个线程不停读,读到 frozen 为负的次数 ------------+-------------+------------------------- 朴素版 | 0 / 300 | 665 宽容 Cancel 版 | 164 / 300 | 0 防护版 | 0 / 300 | 0 线程调度决定谁先到。朴素版的 Try 和 Cancel 只是加减,先后顺序不同终态一样,所以终态是对的;但 Cancel 先到时中间状态的 frozen 是负数,另一个线程读到了 665 次;宽容版在 Cancel 先到时卡住预留;防护版两种顺序都回到初始状态
Try 和 Cancel 同时到达,防护版 0 / 300;并发安全来自 tcc_log 主键,同一个 xid 上只有一个能先成功。
动作(中位数,各 400 次) | 朴素版 | 防护版 | 防护版 / 朴素版 ----------------+--------+--------+---------- Try | 155 µs | 344 µs | 2.2x Confirm | 118 µs | 307 µs | 2.6x tcc_log 里 10 万行(xid 约 7 个字符)占 9.5 MiB,平均每行约 100 字节(含主键索引);每笔 TCC 事务每个参与的服务一行 过早清理:Cancel 记下的 CANCELLED 行被删除后,迟到的 Try 到达 —— Try 执行成功,状态 (920, 80, 0)(又悬挂了) 所以 tcc_log 的保留期必须比“一条消息在网络里最长能晃多久”还长;删之前要确认不会再有这个 xid 的迟到消息
每个动作多一次 tcc_log 读写,延迟约 2 倍,每行约 100 字节。记录不能过早清理,否则迟到的消息又能造成悬挂。