ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

一个定时任务把 Seata 全局锁等超时拖了 40 分钟:AT 模式的脏写防线,很多团队根本没配对 sn: 18batch: 4round: 9topic: 分布式事务 Seata AT 模式原理我们有个库存服务平时走 Seata AT 一切正常。直到有天运营配了个批量调价任务凌晨跑批更新 20 万行库存记录同一线上的秒杀扣库存接口全部报Global lock acquire failed锁等待持续了 40 多分钟期间所有扣库存请求被阻塞重试流量雪崩到相邻服务。事故复盘时发现跑批任务用的不是 AT 模式的托管数据源等于绕过了全局锁跟正常事务撞在一起互相脏写。这个案例让我彻底搞明白了 AT 模式的防线设计——不是加个注解就完事两阶段提交、undo_log、全局锁三件套缺一个都是事故温床。版本背景Seata 1.7.xMySQL 8.0AT 模式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 存入 RootContextThreadLocalRPC 框架扩展点会把它塞进请求头 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保持 5lock.retryTimes全局锁等待次数从默认 30 调到 10、间隔 10ms——宁可快速失败进重试队列undo_log 表加上log_status和create_time的监控项TC 数据源用独立 DB跟业务库隔离跑批任务强制走 GlobalLock 或干脆挪出 AT 链路最后是演练每季度模拟一次TC 宕机 分支悬挂的故障注入。参数清单的本质是把架构约束变成机器可执行的配置靠人记约束事故只是时间问题。思考题前镜像 后镜像方案在 update 语句修改了 100 万行的场景下会怎样undo_log 的体积和查询镜像的成本会怎么变化如果让你设计你会怎么防止 AT 模式被误用在批量更新场景评论区聊聊你的思路。
返回列表