bigywater.tech
分布式事务 · 21

全局锁与回滚日志:AT 模式的原理(自己实现一个最小版本)

AT 模式让业务只写普通的 SQL,由框架保存前后镜像、用全局锁保护数据,必要时按前镜像回滚。它具体是怎么做到的?全局锁解决什么?哪些情况仍然会脏写、脏读?代价有多大?这篇自己实现一个最小版本,用真实的 MySQL 逐个量出来(它不是 Seata,但思路一致)。

825 ms
有全局锁时,T2 想改被 T1 全局锁住的同一行,在全局锁上等到 T1 全局提交的时间
98%
全局事务回滚前,另一个线程读到那个最终被回滚、从未生效的值(900)的比例
357 → 1991 µs
普通本地更新与一次 AT 分支更新的单次延迟中位数

分支与镜像

实测 · 本机执行输出
  两个分支都已经本地提交,全局事务还没决定:
   账户 RM 的 undo_log: [('xid-1', 'account', '1', {'id': 1, 'balance': 1000}, {'id': 1, 'balance': 900})]
   库存 RM 的 undo_log: [('xid-1', 'stock', '1', {'id': 1, 'qty': 50}, {'id': 1, 'qty': 47})]
   tc_db.global_lock: [('acct.account', '1', 'xid-1'), ('stock.stock', '1', 'xid-1')]
   别的会话此刻读到:账户 900、库存 47 —— 分支已经本地提交,新值对所有人可见(全局事务还没提交)
  全局提交之后:undo_log 行数 0 + 0,global_lock 行数 0;账户 900、库存 47

分支在本地事务里读前镜像、改、读后镜像、写 undo_log、申请全局锁再提交;提交后新值对所有人可见。

按前镜像回滚

实测 · 本机执行输出
  分支 1(账户扣 100)已本地提交:账户 900
  分支 2(库存)失败 → 全局回滚
  回滚动作:[('account', '1', {'id': 1, 'balance': 1000})];回滚之后:账户 1000、库存 50,undo_log 行数 0,global_lock 行数 0

全局回滚时先校验当前值等于后镜像,再按前镜像恢复,业务不用写补偿代码。

全局锁

实测 · 本机执行输出
  有全局锁,T1 提交:T2 分支成功,等全局锁 825 ms(T2 在 T1 的全局提交之后才拿到锁);T1 ok;最终账户 890(1000 - 100 - 10)
  有全局锁、T2 只愿意等 0.3 s:T2 分支失败:acct.account:1 的全局锁被 T1 持有,等了 0.3s;T1 ok;最终账户 900
  没有全局锁、T1 决定回滚:T2 立刻成功(分支成功,等全局锁 0 ms);T1 回滚失败:account#1 当前值 {'id': 1, 'balance': 890} 不等于这个分支提交后的值 {'id': 1, 'balance': 900}:有人在全局事务之外改过...;账户现在是 890(T1 想把它恢复成 1000,但 T2 已经又扣了 10:不能直接恢复)

本地行锁在分支提交时释放,全局锁接力保护这一行直到全局事务结束;没有它,回滚会因后镜像对不上而失败。

绕过全局锁的写入

实测 · 本机执行输出
  普通写入不看全局锁:它立刻写入(等了 6 ms);T1 全局回滚:回滚失败(脏写):当前值 {'id': 1, 'balance': 905} 不等于这个分支提交后的值 {'id': 1, 'balanc...;账户 905
  普通写入先看全局锁:它等了 542 ms(T1 回滚完成之后才写);T1 全局回滚:回滚成功;账户 1005(1000 恢复后再 +5)

全局事务之外的普通写入不知道全局锁,会造成脏写;让所有写入先检查全局锁可以补上。

读隔离

实测 · 本机执行输出
  读线程共读了 89438 次,读到的余额值分布 {900: 87586, 1000: 1852};其中读到 900(一个最终被回滚、从未真正生效的值)的有 87586 次(98%)
  需要读到“已经确定”的值时,读之前要先看全局锁(或用 SELECT FOR UPDATE 并让它等全局锁),相当于把读也串到全局事务后面

默认读到的是已本地提交、未全局确定的值,可能读到最终被回滚的值;要读确定值得看全局锁。

代价

实测 · 本机执行输出
  写法                                   | 单次延迟中位数 | 8 线程吞吐(次/秒,3 轮中位数)
  -------------------------------------+---------+-------------------
  普通本地更新(1 条 UPDATE + 提交)              | 357 µs  | 20739             
  AT 分支(前镜像、改、后镜像、undo、全局锁、提交)+ 全局提交清理 | 1991 µs | 3047              
  上一项 + TC 里记录全局事务的开始与状态               | 2365 µs | 2282              
  AT 路径上 8 个线程各改自己的行(互不冲突),3 轮共遇到 3 次 InnoDB 死锁(提示 1213;具体死在哪两条语句上我没有追查),重试后成功;普通本地更新没有出现
  一次 AT 分支更新比普通更新多 4 条语句(前镜像、后镜像、undo_log、全局锁)和一次全局提交时的清理;换来的是业务代码里只有普通的 SQL

每个分支多 4 条语句和全局提交时的清理,延迟约 5.6 倍,吞吐大幅下降。

分步演示

AT 模式
本步的对象

    本文用到的工具与边界

    • 本机 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,数字每次运行会有小幅抖动。本篇是我自己写的最小 AT(tools/articles/at.py,Python + MySQL 26.7.0,账户库、库存库、TC 库三个 schema),不是 Seata;undo_log 用 JSON 存前后镜像,只实现了单行主键更新;全局锁表用主键唯一约束;“等待”用轮询;脏读观察用一个线程不停读
    • 没有验证:Seata 的 AT 实现(行锁的粒度、锁冲突的重试策略、分支注册方式);多行 / 范围更新、INSERT 与 DELETE 的镜像;全局锁在 TC 集群下的存储;undo_log 的清理与大小;回滚时碰到 DDL 变更;AT 用的 SQL 解析(这里的 SQL 是 Python 里拼的,没有拦截 JDBC);8 线程基准里的 3 次死锁没有追查具体语句
    京ICP备2026067502号-1京公网安备11010502063487号