sn: 18
batch: 4
round: 9
topic: 分布式事务 Seata AT 模式原理
我们有个库存服务,平时走 Seata AT 一切正常。直到有天运营配了个批量调价任务,凌晨跑批更新 20 万行库存记录,同一线上的秒杀扣库存接口全部报Global lock acquire failed,锁等待持续了 40 多分钟,期间所有扣库存请求被阻塞重试,流量雪崩到相邻服务。事故复盘时发现,跑批任务用的不是 AT 模式的托管数据源,等于绕过了全局锁,跟正常事务撞在一起互相脏写。这个案例让我彻底搞明白了 AT 模式的防线设计——不是加个注解就完事,两阶段提交、undo_log、全局锁三件套,缺一个都是事故温床。
版本背景:Seata 1.7.x,MySQL 8.0,AT 模式,undo_log 表在业务库。
AT 模式的本质:二阶段 + 数据镜像补偿
先建立直觉。AT 模式的核心思想是:把每个参与者本地事务的"前后镜像"记下来,提交时记录前镜像 + 后镜像到 undo_log 表,全局回滚时拿前镜像反向补偿,全局提交时直接删掉 undo_log 异步清理。
一阶段的执行流程值得仔细拆。业务 SQL 执行时代理数据源会干这些事:
// 代理数据源执行 update 时的核心逻辑(DataSourceProxy 的语义还原) StatementProxy proxy = getStatementProxy(sql); // 1. 先查前镜像:按 where 条件 select 出将要被修改的行 TableRecords beforeImage = queryBeforeImage(sql); // 2. 执行业务 SQL 本身 int affected = proxy.execute(); if (affected == 0) { return 0; } // 3. 再查后镜像:按主键把修改后的行查出来 TableRecords afterImage = queryAfterImage(beforeImage.pkValues()); // 4. 前后镜像打包成 undo_log,与业务 SQL 在同一个本地事务里写入 insertUndoLog(xid, beforeImage, afterImage); // 5. 本地事务提交,并向 TC 注册分支、申请该表该行的全局锁 connectionProxy.commit();逐行说:第 1 步和第 3 步是 AT 的灵魂——前镜像用于回滚,后镜像用于校验;第 4 步"与业务 SQL 同一本地事务"这个细节至关重要,保证 undo_log 和业务数据的一致性:本地事务提交成功,镜像就在;本地事务回滚,镜像也不存在,不存在"业务提交了但镜像丢了"的中间态;第 5 步向 TC(事务协调器)注册分支事务并拿全局锁,锁的粒度是"表名 + 主键值"。
全局锁:防脏写的唯一防线,也是性能瓶颈
二阶段回滚时有个关键校验:拿前镜像的数据跟当前数据库里的数据比对,如果对不上,说明这行数据在全局事务提交后又被别的事务改过(脏写),默认直接报BranchTransactionExpiredException,需要人工介入。
而全局锁就是阻止这种情况发生的机制。TC 维护一张全局锁表,粒度是"资源ID + 表名 + 主键"。写冲突的两条全局事务,后到的那个必须等先到的提交或回滚。我们那次事故里,跑批任务没走代理数据源(用了原生 JDBC 连接池直接执行),所以它不申请全局锁,AT 事务提交后它又改了同一批行,回滚校验必然失败。
官方给的解法是@GlobalLock + SELECT ... FOR UPDATE组合,我用一个抢券扣库存的例子说明:
@GlobalLock @Transactional(rollbackFor = Exception.class) public void deductStock(long skuId, int count) { // 1. 关键:必须用 for update 查询,显式申请本地行锁 + 注册全局锁意图 Stock stock = stockMapper.selectForUpdate(skuId); if (stock.getCount() < count) { throw new BizException("库存不足"); } // 2. 正常更新 stockMapper.deduct(skuId, count); }逐行拆:@GlobalLock告诉 Seata 这个方法虽然不发起全局事务,但要在 TC 上登记全局锁;第 1 行的for update是触发条件——Seata 拦截到SELECT FOR UPDATE语句时,先查本地行锁,再向 TC 申请全局锁。这样上面说的跑批任务只要也用这个模式(哪怕它不参与任何全局事务),就跟 AT 事务之间建立了互斥。这是个很多团队不知道的暗知识:非 AT 参与方也要修改热点数据时,必须用 @GlobalLock + for update 才能防脏写。
隔离级别的真相:AT 默认读未提交,别拿它当 TCC 用
AT 模式一阶段本地事务就提交了,全局提交是异步的。这意味着:全局事务还没提交时,它的修改对其他连接已经可见。这就是"读未提交"隔离级别。写隔离靠全局锁兜住,但读没有任何保护。
我们真踩过这个坑:订单服务在全局事务里扣了库存(还没提交),页面查询接口立即读到了"已扣减"的库存数,前端展示了错误状态 2 秒后又被回滚抹掉。用户看到库存从 5 变成 3 又变回 5。这种抖动在低频场景无感,在库存这类展示敏感场景就是客诉。
解法有两类,我的取舍是:
| 方案 | 做法 | 适用 |
|---|---|---|
| 业务容忍 | 展示接口读从库或加短 TTL 缓存 | 展示类查询,2 秒内不一致可接受 |
| 读隔离 | 关键读也注册到全局事务(读已提交) | 资金、库存核对等强一致读 |
| 换模式 | 热点行改 TCC 或 Saga | 秒杀扣减等超高并发热点 |
第二类的实现是把查询也包进@GlobalTransactional并对查询语句用 for update,代价是查询也拿全局锁,吞吐下降明显。我的判断是:AT 模式适合的场景是"中低并发、跨 2-5 个服务、追求开发效率"的业务流,比如下单扣库存减优惠券这种主链路。秒杀级别的热点行,AT 的全局锁串行化就是灾难,直接上 TCC 或者 Redis 预扣更合理。
回滚失败兜底:undo_log 校验不过时的人工通道
最后说回滚失败怎么办。AT 的反向补偿是把前镜像 update 回去,但执行前有个 where 校验:当前行数据的值必须跟后镜像一致才补偿(严格模式下),不一致就抛异常等人工处理。生产上我们会配三道防线:监控 undo_log 表中log_status = 0且超过 5 分钟未清理的记录(说明全局事务悬挂或回滚失败);对账任务定时比对事务参与方的数据一致性;运维台提供"按 xid 查询分支状态 + 手动重试补偿"的工具入口。那次 40 分钟的锁等待之后,我们把全局锁超时从默认的 30 秒调成 10 秒快速失败,配合业务侧重试 + 告警,宁可快速报错也不要长时间阻塞——这是个我认为值得推广的参数取舍:AT 模式下锁等待超时宁短勿长,快速失败配合重试的可用性,好过长时间持有连接拖垮连接池。
全局事务发起方:XID 是怎么传播的
理解了分支侧,再看发起侧。@GlobalTransactional的拦截器做了三件事:开启全局事务拿到 XID、把 XID 绑到当前线程上下文、清理收尾。XID 沿着 RPC 链路传播,每个服务的 agent/SDK 解出 XID 后向 TC 注册分支:
// GlobalTransactional 拦截器的核心逻辑(TransactionalTemplate 的语义还原) public Object globalExecute(GlobalTransactionalInterceptor interceptor, BusinessMethod method) { // 1. 向 TC 申请开启全局事务,拿到全局唯一的 XID GlobalTransaction tx = tm.beginTransaction(timeout, name); try { // 2. XID 存入 RootContext(ThreadLocal),RPC 框架扩展点会把它塞进请求头 RootContext.bind(tx.getXid()); // 3. 执行业务方法,内部的所有 RPC 调用都会携带 XID Object result = method.invoke(); // 4. 业务成功,通知 TC 全局提交 tm.commit(tx); return result; } catch (Exception e) { // 5. 任意分支失败,通知 TC 全局回滚,TC 逐个通知各分支用前镜像补偿 tm.rollback(tx); throw e; } finally { // 6. 清理线程上下文,防止线程复用导致的 XID 串染 RootContext.unbind(); } }逐行看:第 2 行的 ThreadLocal 绑定是传播机制的源头,第 6 行的 unbind 是防串染的关键——和 traceId 跨线程传递一样,XID 串到别的请求会造成全局回滚误伤无关事务。第 4 行的全局提交在 AT 模式下是异步的:TC 只标记状态,各分支自己异步删除 undo_log,这就是为什么 AT 的一阶段提交能保持高性能。
XID 传播在 RPC 框架层的实现是个容易被忽视的排查点。我们用 Dubbo 时,Seata 的 filter 需要同时在 provider 和 consumer 侧配置,漏配一侧的表现很迷惑:本地事务正常、undo_log 正常生成,但 TC 上看不到分支注册,全局回滚时那个分支的数据纹丝不动。这种"半个参与者"的状态比彻底没接入更危险,因为监控上看起来一切正常。
生产配置清单:事故换来的 7 个参数
那次 40 分钟锁等待后,我们固化了一份 AT 模式参数清单:default-global-transaction-timeout从默认 60 秒压到 30 秒(防止悬挂事务长期占资源);分支事务重试次数tm.default-retry-times保持 5;lock.retryTimes全局锁等待次数从默认 30 调到 10、间隔 10ms——宁可快速失败进重试队列;undo_log 表加上log_status和create_time的监控项;TC 数据源用独立 DB,跟业务库隔离;跑批任务强制走 @GlobalLock 或干脆挪出 AT 链路;最后是演练,每季度模拟一次"TC 宕机 + 分支悬挂"的故障注入。参数清单的本质是把架构约束变成机器可执行的配置,靠人记约束,事故只是时间问题。
思考题
前镜像 + 后镜像方案在 update 语句修改了 100 万行的场景下会怎样?undo_log 的体积和查询镜像的成本会怎么变化?如果让你设计,你会怎么防止 AT 模式被误用在批量更新场景?评论区聊聊你的思路。