AT 模式让业务只写普通的 SQL,由框架保存前后镜像、用全局锁保护数据,必要时按前镜像回滚。它具体是怎么做到的?全局锁解决什么?哪些情况仍然会脏写、脏读?代价有多大?这篇自己实现一个最小版本,用真实的 MySQL 逐个量出来(它不是 Seata,但思路一致)。
两个分支都已经本地提交,全局事务还没决定:
账户 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 倍,吞吐大幅下降。