Seata(二):AT 模式原理、undo_log 与全局锁
Seata(二):AT 模式原理、undo_log 与全局锁
导语:AT 模式是 Seata 的门面,也是面试问得最细的地方。核心就三样东西——前后镜像(undo_log)、全局锁、二阶段反向补偿。本篇把一阶段的 8 个步骤、二阶段提交与回滚的完整细节、undo_log 表结构、全局锁的加解锁与重试、写隔离/读隔离、脏写与脏读的边界一次讲透,共 14 题。
一、AT 模式一阶段
1. AT 模式的前提与整体机制是什么?
答:
两个前提(必须记住):
- 基于支持本地 ACID 事务的关系型数据库(MySQL、Oracle、PG 等);
- Java 应用通过 JDBC 访问数据库(Seata 通过代理 JDBC 层来拦截与增强)。
整体机制 = 「两阶段提交」的柔性改造:
| 阶段 | 内容 | 关键点 |
|---|---|---|
| 一阶段 | 业务 SQL 与回滚日志(undo_log)在同一个本地事务里提交,同时释放本地锁与连接资源 | 已经提交了本地事务(与 XA 的根本区别) |
| 二阶段·提交 | 异步、批量地删除 undo_log,释放全局锁 | 极快、不阻塞业务 |
| 二阶段·回滚 | 用一阶段写入的 undo_log(前镜像)生成反向 SQL 做补偿 | 需要先校验后镜像,再反向更新 |
一句话本质:AT 把「回滚」从「数据库的 undo 机制」搬到了「应用层的 undo_log 表」,因此可以在本地事务提交之后再做补偿。
2. AT 模式一阶段的完整流程(8 步)是什么?
答: 以 update product set name = 'GTS' where name = 'TXC' 为例:
四个必须讲清的细节:
| 细节 | 说明 |
|---|---|
| 为什么要有"前后镜像"? | 前镜像(before image)是回滚的凭据(回滚时用它恢复原值);后镜像(after image)是回滚时校验的凭据(确认数据没被别人改过) |
为什么用 beforeImage 的 where 条件去查,却用主键回查 afterImage? | 因为业务 SQL 执行后原 where 条件可能不再匹配(如把 name 改成了 GTS,就 where name='TXC' 查不到了),所以必须用主键定位 |
| 查询的都是哪些字段? | 主键 + 被 update 的字段(还要包含"被 where 命中的字段"的原始值,用于回滚定位) |
| 全局锁在"本地提交前"申请 | 这是写隔离的关键:先锁后提交,保证"我提交时这行没被别的全局事务改过" |
undo_log 里存了什么(rollback_info 的结构):
{
"branchId": 641789253,
"xid": "xid:xxx:123456",
"context": "serializeContext",
"undoItems": [
{
"undoType": 1,
"tableName": "product",
"sqlType": "UPDATE",
"beforeImage": {
"rows": [{ "id": 1, "name": "TXC", "since": 2014 }],
"tableName": "product",
"sqlType": "UPDATE"
},
"afterImage": {
"rows": [{ "id": 1, "name": "GTS", "since": 2014 }],
"tableName": "product",
"sqlType": "UPDATE"
}
}
]
}3. undo_log 表结构是怎样的?为什么每个业务库都要建?
答:
CREATE TABLE `undo_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`branch_id` bigint(20) NOT NULL,
`xid` varchar(100) NOT NULL,
`context` varchar(128) NOT NULL, -- 0.7.0+ 新增:序列化上下文
`rollback_info` longblob NOT NULL, -- ★ 前后镜像 + 业务 SQL 信息
`log_status` int(11) NOT NULL, -- 日志状态
`log_created` datetime NOT NULL,
`log_modified` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`) -- ★ 幂等的关键
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;| 字段 | 作用 |
|---|---|
xid | 全局事务 ID;回滚时按它定位「哪些日志属于这个全局事务」 |
branch_id | 分支事务 ID;精确到「哪个分支」 |
rollback_info | 前后镜像 + sqlType + tableName(回滚的完整依据) |
log_status | 日志状态(是否已回滚等) |
ux_undo_log (xid, branch_id) 唯一索引 | 防止同一分支重复写入——如果重复插入会失败,保证补偿操作用的日志唯一、可靠 |
「每个业务库都要建」的两个原因:
- undo_log 与业务数据在同一个本地事务里提交——不同库的交易发生在各自的库里,所以每个库都得有这张表,才能保证「本地提交和写日志」的原子性;
- 二阶段回滚是在各自的业务库上执行的(由该库对应的 RM 执行),所以它得能读到自己写的日志。
常见面试追问:「undo_log 和 TC 的 lock_table 是一回事吗?」 → 不是。
undo_log在业务库(RM 侧,用于回滚数据);lock_table在 TC 侧存储(用于全局锁)。二者位置、用途、生命周期都不同。
4. AT 模式支持哪些 SQL?哪些不支持?
答:
| 支持情况 | 说明 |
|---|---|
| 支持 | INSERT / UPDATE / DELETE(基于主键或唯一索引定位行);SELECT ... FOR UPDATE(会作为"全局读已提交"的手段,申请全局锁) |
| 有限支持 | UPDATE 不带主键/唯一索引时会全表扫描生成前镜像(可能非常慢,且可能不精确) |
| 不支持 / 不建议 | 多表关联的复杂 update/delete、INSERT ... SELECT、批量 INSERT 中的部分自增回填、DDL、存储过程/触发器中的 SQL、跨库 SQL、使用了函数/表达式的更新导致无法还原 |
为什么有限制:AT 要能精确地"再定位到受影响的行"并"反向还原",所以必须能唯一确定行(主键/唯一索引)且字段值可逆向。
几个具体坑:
UPDATE ... SET x = x + 1:前镜像是x=10,回滚时执行set x = 10(用前镜像的值覆盖),不是x = x - 1——这正是"镜像补偿"比"逆向 SQL"更可靠的原因;- 更新
id/ 主键的场景:镜像定位会失效,尽量避免; - 自增主键的
INSERT回滚:二阶段回滚就是DELETE该行(用后镜像的主键值); ON DUPLICATE KEY UPDATE:语义复杂(可能插入也可能更新),Seata 支持有限,生产慎用。
二、AT 模式二阶段
5. 二阶段提交做了什么?为什么说它"几乎不消耗性能"?
答:
为什么快(三点):
- 没有真正的业务操作:一阶段已经把业务数据提交了,二阶段提交只删日志;
- 异步化:
asyncCommitBufferLimit控制异步提交的缓冲队列,不阻塞业务线程; - 批量删除:多个分支的日志合并成批量 SQL 删除,减少交互次数。
由此产生的重要现象(面试加分):
- 「全局事务已提交,但 undo_log 还残留」是正常的——异步删除还没执行完;TC 挂了/重启时,也会留下残留记录,需要通过补偿任务/定时清理处理;
- 但残留不影响正确性:因为二阶段提交不依赖 undo_log(不删只是占空间)。
6. 二阶段回滚的完整流程是什么?为什么需要"校验"?
答:
「数据校验」的意义(高频追问):
校验是为了避免「回滚把别人的修改覆盖掉」。
举例:全局事务 A 把 name 从 TXC 改成 GTS 并提交了;此后另一个全局事务 B(或非事务操作)把它改成了 ABC。此时 A 要回滚:
- 若不校验:A 直接把
name改回TXC→ 覆盖了 B 的修改(数据丢失); - 校验后:发现当前值
ABC≠ afterImage 的GTS→ 说明数据"被动过",不能盲目回滚,进入异常处理策略(默认记录告警/抛异常,人工介入)。
校验失败的处理策略(client.undo.data.validation):
| 策略 | 行为 |
|---|---|
默认(true 校验开启) | 校验不一致 → 抛异常/记录,事务进入需要人工处理的状态 |
false(关闭校验) | 不校验,直接按 beforeImage 覆盖回滚(有覆盖他人数据的风险) |
面试结论:AT 模式不是"绝对安全"的——如果业务里存在「绕过 Seata 直接改库」的操作(如运维手工改数、其他框架写同一个库),校验会失败,回滚会失效或需要人工介入。这也是「参与 Seata 的表要"独占写入"」这条实践约定的由来。
7. 回滚的幂等性怎么保证?失败会重试吗?
答:
| 问题 | 答案 |
|---|---|
| 为什么需要幂等? | 二阶段回滚指令可能因为网络超时被 TC 重发,RM 可能多次收到同一条回滚请求 |
| 怎么幂等? | ①回滚成功后删除 undo_log → 再次收到请求时查不到日志,直接返回成功(这是最主要的幂等机制);②ux_undo_log(xid, branch_id) 唯一索引保证日志唯一;③回滚本身是"用前镜像覆盖",天然幂等(同一份前镜像执行多次结果相同) |
| 失败会重试吗? | 会。TC 对回滚/提交指令有重试机制(client.tm.commitRetryCount / rollbackRetryCount,默认 1? 实际为 1 与 1,可按需调大),超过重试次数后进入告警/人工介入;同时 TC 侧有定时扫描未完成的事务进行补偿 |
| 失败的常见原因 | 业务库不可用、undo_log 被误删、镜像校验失败(数据被外部修改)、SQL 不兼容(复杂 SQL 无法生成反向语句) |
8. AT 模式的一阶段/二阶段与 XA 的本质差异总结?
答:
| 维度 | XA | Seata AT |
|---|---|---|
| 一阶段 | prepare:执行但不提交,持锁 | 提交本地事务 + 写 undo_log,释放本地锁 |
| 二阶段 | commit / rollback(数据库原生) | 删 undo_log / 按镜像反向补偿 |
| 锁持有时间 | 从 prepare 到全局提交(长) | 只到本地提交(短) |
| 回滚机制 | 数据库的 undo 日志 | 应用层的 undo_log 表 |
| 隔离性 | 数据库级隔离(强) | 默认全局读未提交(弱,靠全局锁防脏写) |
| 一致性 | 强一致(CP) | 最终一致(AP 取向) |
| 性能 | 低(同步阻塞) | 高(异步二阶段) |
| 依赖 | 数据库支持 XA | JDBC + undo_log 表 + 全局锁 |
| SQL 兼容性 | 好(数据库自己处理) | 有限制(需能定位行与反向还原) |
一句话:XA 把回滚交给数据库,AT 把回滚交给应用;代价是 AT 需要全局锁 + undo_log,收益是一阶段释放本地锁、性能大幅提升。
三、全局锁与隔离性
9. 全局锁是什么?加锁维度是什么?
答: 全局锁是 TC 维护的一把"分布式行锁",作用是「防止不同全局事务脏写同一行记录」。
锁的维度(LockKey):resourceId + tableName + pk
· resourceId:数据源/库的标识
· tableName:表名
· pk:**主键值**(如果 SQL 的 where 条件包含唯一索引,也可能用唯一索引值)
存储位置:TC 侧的 lock_table(db 模式)
CREATE TABLE lock_table (
xid, transaction_id, branch_id,
resource_id, table_name, pk, row_key, gmt_create, gmt_modified
)
row_key = resourceId + tableName + pk申请与释放:
四个关键点(面试常问):
- 全局锁不是数据库锁:它由 TC 集中管理,是"逻辑锁",不阻塞其他非 Seata 事务(比如直接连库的手工 SQL 完全不受影响);
- 粒度是"行级"(主键):所以 SQL 必须能定位到主键/唯一键,否则退化为表级/无法精确加锁;
- 它是 AT 的性能瓶颈:热点行会让不同全局事务串行排队(每次申请都是一次 RPC);
- 重试参数:
client.rm.lock.retryInterval(默认 10ms)、client.rm.lock.retryTimes(默认 30 次)——调大能提高成功率但会增加 RT;调小则高并发下更容易失败。
注意:AT 模式下申请全局锁发生在本地事务提交之前(第 2 题的步骤⑥)——顺序不能反,否则就失去防脏写的意义。
10. 「写隔离」是怎么实现的?为什么能防止脏写?
答: 写隔离的三条规则(官方原文的提炼):
经典示例(表 a 的字段 m,初值 1000):
结论:因为全局锁在 tx1 的整个生命周期内一直由 tx1 持有,所以不会发生脏写。
为什么"必须锁定在整个全局事务期间",而不是只锁到本地提交?
如果只在本地提交时短暂持锁,那么 tx1 提交后释放锁 → tx2 修改 → tx1 全局回滚时无法还原(因为 tx2 的修改是基于 tx1 的结果做的,回滚会覆盖或语义错乱)。全局锁持有到全局事务结束,才能保证"我在回滚之前,这行没被人改过"。
11. 「读隔离」是怎么实现的?AT 默认的隔离级别是什么?
答:
| 项目 | 说明 |
|---|---|
| 数据库本地事务隔离级别要求 | 读已提交(Read Committed)或以上 |
| Seata AT 默认的全局隔离级别 | 读未提交(Read Uncommitted) |
| 如何升级到"全局读已提交" | 通过 SELECT ... FOR UPDATE 的代理实现 |
为什么默认是"读未提交"(关键推理):
SELECT FOR UPDATE 的代理机制(读已提交的实现):
两个重要补充(面试高频):
- Seata 并未代理所有
SELECT——只代理了FOR UPDATE的查询,因为代理所有查询性能代价太大; - 因此普通
SELECT在 AT 模式下仍然是"读未提交"语义:业务上要避免「读到中间态后做出不可逆决策」(如根据中间态判断"已经扣款成功"就去发货)。
12. AT 模式下会不会出现脏读、脏写、不可重复读?
答: 逐一分析(这是最能体现深度的一题):
| 现象 | 是否可能 | 原因 |
|---|---|---|
| 脏写(Dirty Write) | 不会 | 全局锁保证:同一行在全局事务结束前不会允许另一个全局事务写入(见第 10 题) |
| 脏读(Dirty Read) | 会 | 一阶段已提交本地事务,其他事务(尤其非 Seata 事务)可读到之后会被回滚的数据 → 默认读未提交 |
| 不可重复读 / 幻读 | 可能 | AT 默认全局读未提交,重复读可能看到变化;只有 SELECT FOR UPDATE 才升级为读已提交 |
| 写偏斜(Write Skew) | 可能 | 全局锁是行级的,不同行的交叉约束(如"账户 A + B 总额不能为负")无法靠行锁保证 |
| 丢失更新(Lost Update) | 不会(同全局事务体系内) | 全局锁串行化了同一行的写 |
实践结论(务必记住):
- AT 只保证"全局写不冲突"(防脏写),不保证读隔离;
- 业务若对"读到中间态"敏感 → 用
SELECT FOR UPDATE或 对读也走全局事务(代价大),或用业务状态机规避(如订单先落"处理中"状态,读到中间态时按状态机拒绝); - 绕过 Seata 直接写库(手工 SQL、其他框架)会破坏全局锁的约束,导致脏写与回滚校验失败——这是 AT 最大的实践约束。
13. 全局锁与「undo_log 校验」是怎么配合防脏写的?
答: 它们是两道防线,面向不同层面:
这句话可以直接背下来:
「全局锁负责预防,后镜像校验负责兜底;前者是协作约束,后者是最后一道保险。」
14. 综合实战:AT 模式下的热点行竞争怎么优化?
答: 场景:秒杀扣库存,同一个 SKU 的库存行被高频扣减,全局锁排队严重 → Global lock wait timeout / 吞吐掉底。
第一步:确认瓶颈
第二步:按「成本从低到高」依次尝试
| 方案 | 做法 | 效果 | 代价 |
|---|---|---|---|
| ① 缩短事务持有时长 | 全局事务里不要做耗时操作(不要调第三方、不要查大表、不要发 MQ);把非核心逻辑移出事务 | 直接缩短锁持有时间 | 需要重构业务边界 |
| ② 调锁重试参数 | client.rm.lock.retryInterval / retryTimes 调大 | 降低失败率 | RT 变长,只是缓解 |
| ③ 库存分桶(行拆分) | 把「1 行库存 1000」拆成「10 行各 100」,扣减时随机/轮询选一行,where num >= 1 | 把行级锁竞争打散 10 倍 | 需要改造库存模型(统计要汇总、超卖控制更细) |
| ④ 改用 TCC | Try 阶段预占库存(Redis/DB 冻结),Confirm 落账 | 避开长事务全局锁,性能最好 | 需要写三个方法(开发成本高) |
| ⑤ 异步化 + 最终一致 | 库存扣减改为「Redis 预扣 + MQ 异步落库 + 对账补偿」 | 吞吐最高 | 一致性从"强"变"最终",需要幂等与对账 |
| ⑥ 局部限流/排队 | 热点 SKU 单行排队(如用 Redis 分布式锁收口) | 保护数据库 | 引入新组件、复杂度上升 |
第三步:给出"分层方案"(面试最佳答案)
第四步:说明风险与取舍(加分)
| 取舍 | 说明 |
|---|---|
| 分桶让"总量控制"变复杂 | 需要汇总各桶余量,且可能出现"某个桶空了但总量还有"→ 需要逐桶尝试或统一调度 |
| TCC 有"空回滚/悬挂/幂等"三个坑 | 必须实现(见《Seata(三)》) |
| 最终一致意味着中间态可见 | 需要业务侧(前端/状态机)容忍「扣减中」的状态 |
| 无论如何,参与 Seata 的表要"独占写入" | 否则后镜像校验会失败,回滚不可靠 |
下一篇:《Seata(三)》讲清另外三种模式——TCC 的 Try/Confirm/Cancel 与空回滚、悬挂、幂等三大难题;Saga 的状态机与补偿;XA 的强一致原理;再做四种模式的横向选型对比,并给出 TCC 实战代码与常见问题排查清单。
