bigywater.tech
分布式事务 · 7

TCC 的空回滚、悬挂与幂等

第 6 篇的 TCC 在理想的网络里没问题。但真实的网络会让消息迟到、乱序、重复:Cancel 比 Try 先到、Try 迟到、Confirm 发两遍。这篇把这三类问题一个个复现,再用一张 tcc_log 把它们一起防住,并穷举所有消息顺序来验证。

14 / 23
朴素版在 23 条消息到达顺序里终态不对的条数(带 tcc_log 防护的是 0 / 23)
(920, 80, 0)
悬挂:Cancel 先到、Try 迟到后,账户最终的 (balance, frozen, spent),80 块钱永久冻结
2.2 倍
防护让 Try 的延迟中位数变成的倍数(155 µs 到 344 µs)

空回滚

实测 · 本机执行输出
  朴素版,表上有 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')] —— 不改业务列,只记一条 CANCELLED

Try 没执行,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 字节。记录不能过早清理,否则迟到的消息又能造成悬挂。

分步演示

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 实例上的账户服务;三种实现是我自己写的 Python(tools/articles/tcc.py:朴素版、宽容 Cancel 版、带 tcc_log 防护的版本);“消息迟到、乱序、重复”是在测试里按指定顺序直接调用服务来模拟的,不是真实网络里的丢包;朴素版和宽容版的穷举跑在去掉 CHECK 的表上以便错误显现
    • 没有验证:真实网络里消息迟到的最大时延(所以 tcc_log 的保留期没有给出具体数值);多个服务之间的联合行为(只测了账户这一个服务);Seata、Hmily 等框架怎么实现这三个防护;tcc_log 在分库分表或多活下的行为;“其它”3 条终态不对的序列没有逐条分析
    京ICP备2026067502号-1京公网安备11010502063487号