news 2026/10/2 23:45:15

一个定时任务把 Seata 全局锁等超时拖了 40 分钟:AT 模式的脏写防线,很多团队根本没配对

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
一个定时任务把 Seata 全局锁等超时拖了 40 分钟:AT 模式的脏写防线,很多团队根本没配对

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 模式被误用在批量更新场景?评论区聊聊你的思路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 23:42:27

Agent 缓存命中率提升与 Token 成本控制:从架构到工程落地

1. 为什么 Token 成本是 Agent 系统的第一瓶颈 2025 年之后,业界对 Agent 的讨论重心已经从「能不能跑通」转移到了「能不能规模化、能不能赚钱」。一个简单的客服 Agent demo 可能每次对话消耗几千 token,但一旦铺开到日均百万次调用,token 账单会直接吃掉毛利。 Agent 与…

作者头像 李华
网站建设 2026/10/2 23:42:10

石家庄材料复试检测服务商客户口碑力荐,正规资质实力参考

打铁工社(北京)供应链管理有限公司&#xff0c;是一家专注于建设工程报建报验与全过程项目管理的咨询服务平台。企业成立于2021年&#xff0c;2024年7月正式完成工商注册登记&#xff0c;法定代表人陆玥含&#xff0c;经营范围涵盖工程管理服务、工程造价咨询、招投标代理、商务…

作者头像 李华
网站建设 2026/10/2 23:33:35

Zabbix 7.0 LTS数据库分区实战:从部署到性能优化

简介&#xff1a;Zabbix 7.0 LTS部署后&#xff0c;历史与趋势数据表容易快速膨胀&#xff0c;甚至出现Zabbix housekeeper进程繁忙告警&#xff0c;而数据库分区是缓解这类问题的有效手段。这份PDF操作记录基于MySQL或MariaDB环境&#xff0c;围绕zbx_db_partitiong.sql分区脚…

作者头像 李华