零侵入的魔法从哪来
AT 模式最迷惑人的地方:业务代码一行不改,回滚全自动——补偿逻辑从哪冒出来的?答案藏在代理数据源里:Seata 把你的 DataSource 包了一层,所有 SQL 先经过它。它做的事一句话概括:执行前拍张照(前镜像)、执行后拍张照(后镜像)、把两张照片存进 undo_log 表。回滚就是看照片逆操作。拆开一阶段二阶段细看。
一阶段:本地事务提前提交
拦截 SQL: update account set balance=90 where id=1
1 查前镜像: select balance from account where id=1 → 100
2 执行 SQL: balance 100 → 90
3 查后镜像: select balance from account where id=1 → 90
4 组装 undo_log(前后镜像 + SQL 信息)写入本库
5 本地事务提交:业务 SQL + undo_log 原子落库
6 向 TC 注册分支并汇报状态注意第 5 步——一阶段结束时,本地事务已经提交了,数据库行锁释放了。这是 AT 与 2PC 的根本区别:2PC 在 prepare 后锁着资源等全局指令;AT 把「等」的时间压缩到了极限,各分支的本地事务自顾自提交,全局的成与败交给二阶段处理。这就是 AT 吞吐高的根源。
二阶段:提交很快,回滚要验
全局提交:数据已经落库,什么都不用做,TC 通知各分支异步批量删除 undo_log 即可——提交路径近乎零成本。
全局回滚:RM 收到回滚通知后分两步走:先校验——把 undo_log 里的后镜像和当前表数据比对,一致说明这一行没人动过,安全;再回滚——根据前镜像自动生成反向 SQL(update 回原来的值、delete 掉 insert 的行)执行,最后删 undo_log。若比对不一致,说明有别人在全局事务外改了这行——脏写发生,反向 SQL 会覆盖别人的修改,Seata 拒绝执行并告警转人工。防脏写是 AT 的生命线。
全局锁:写在 TC 上的轻量锁
一阶段就提交了本地事务,两个全局事务并发改同一行怎么办?A 事务改了 balance 还没走到二阶段,B 事务也来改——A 回滚时发现数据被 B 改了,脏写。Seata 的答案是全局锁:分支事务在提交本地事务之前,必须先去 TC 拿到这行的全局锁(表名 + 主键维度);拿不到就自旋重试,超时则一阶段整体回滚。全局锁管住「全局事务之间」的写冲突,数据库行锁管住「本地并发」,两层各司其职。
锁从数据库行锁挪到 TC 内存,持有时长从「整个事务」缩短为「一阶段的瞬间」——这是 AT 快的另一半原因。但反过来看,锁压到 TC 也意味着热点行是 AT 的天敌:一万笔事务同时抢某账户这一行的全局锁,自旋重试会把吞吐打穿,这正是下一篇的主题。
隔离性的诚实交底
AT 的隔离级别要交底清楚:写隔离靠全局锁保证;读隔离默认没有——一阶段本地事务已提交,别的事务立刻能读到「全局上还没决定」的数据,相当于全局读未提交。Seata 提供加 @GlobalLock 或 select for update 的全局读方案,但都会拉低性能。所以业务设计要接受「中间态可见」,或者把强读一致的部分拆回本地事务——这与第 2 篇 BASE 的精神一脉相承。
| 维度 | AT 表现 |
|---|---|
| 业务侵入 | 零(数据源代理 + 注解) |
| 补偿方式 | 前后镜像自动生成反向 SQL |
| 一致性 | 最终一致,默认读未提交 |
| 额外要求 | 每个业务库建 undo_log 表,只支持关系库 |
| 软肋 | 热点行全局锁竞争、脏写只能人工 |
小结
AT 一句话:代理数据源记前后镜像,一阶段本地事务提前提交换吞吐,二阶段按镜像反向回滚;全局锁压在 TC 防脏写,读未提交是默认,热点行是软肋。原理懂了,下一个问题接踵而至:账户余额这种天然的行热点,AT 抢锁抢到怀疑人生,TCC 冻结字段才是出路——下一篇:热点账户的锁冲突突围。
评论 (0)