运维手动改了一行数据,Seata 回滚时把它覆盖了:AT 模式全局锁和 undo_log 的 3 个被忽略的边界

📅 2026/8/22 16:15:03
运维手动改了一行数据,Seata 回滚时把它覆盖了:AT 模式全局锁和 undo_log 的 3 个被忽略的边界
title: 运维手动改了一行数据Seata 回滚时把它覆盖了AT 模式全局锁和 undo_log 的 3 个被忽略的边界date: 2026-08-22category: 分布式事务tags: [Java, 后端, 分布式事务, Seata, 微服务]我们订单和库存两个服务用 Seata AT 模式做分布式事务。AT 模式最吸引人的点是「不用写回滚代码」——你只管写业务 SQLSeata 在背后帮你记 undo_log、自动回滚。但正是这种「无感」让我们在某次线上事故里栽了跟头一次全局事务回滚把我们运维同事手动改掉的一行价格数据给覆盖回滚前的值了。这篇把 AT 模式的全局锁、undo_log 插入时机和隔离边界讲清楚尤其说清楚「为什么它不能保护非 Seata 改的数据」。事故现场回滚把人工修正覆盖了那天的链路是用户下单 → 订单服务AT 事务扣库存 → 调用库存服务。库存服务这边的分支事务改了stock表Seata 记了 undo_log。过程中因为发现一个商品标价错了运维直接在 DB 里UPDATE stock SET price99 WHERE id1把价格从 199 改成 99。随后订单服务那边超时全局事务发起回滚。Seata 在库存服务这边执行 undo_log 回滚把stock表里 id1 的那行整个回滚到了事务开始前的值——也就是把运维刚改的 99 又改回了 199。人工修正丢了。根因一句话Seata AT 的 undo_log 回滚是「整行还原」它不知道中间还有人用手动 SQL 改过这行。AT 一阶段本地事务 undo_log 是原子的要理解覆盖是怎么发生的得先看 AT 一阶段干了什么。Seata 通过数据源代理在你的业务 SQL 执行前后各快照一次// 业务侧只写普通 MyBatis 代码代理在底层拦截 GlobalTransactional public void createOrder(long productId, int count) { // 1. 解析 SQL生成 before image执行前快照 // 2. 执行 UPDATE stock SET count count - ? WHERE id ? // 3. 生成 after image执行后快照 // 4. 把 before/after image 连同业务SQL写入 undo_log 表同一本地事务 stockMapper.deduct(productId, count); orderMapper.insert(...); }Seata 在ExecuteTemplate里做的事简化逻辑// AbstractDMLBaseExecutor 执行流程简化 public T execute(Object... args) { // 执行业务 SQL 前先查 before image TableRecords beforeImage beforeImage(); // 执行真正的 SQL T result statementCallback.execute(statement, args); // 执行后查 after image TableRecords afterImage afterImage(beforeImage); // 把前后镜像写进 undo_log和业务 SQL 在同一个本地事务里提交 undoLogManager.flushUndoLog(connection, beforeImage, afterImage, ...); return result; }逐行说三个关键点beforeImage是在你的 SQL 真正执行前查的代表「事务开始前这行长什么样」。afterImage是执行后查的代表「事务结束后这行长什么样」。undo_log 的写入和业务 SQL绑定在同一个本地事务里提交——要么都成要么都滚。这就是为什么 AT 模式「不用写回滚代码」回滚信息已经在库里了。undo_log 表长什么样为什么必须和业务同库很多人配 Seata 时把 undo_log 表建到了一个独立的库结果回滚时找不到日志。undo_log 的结构MySQL大概是这样CREATE TABLE undo_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, branch_id BIGINT NOT NULL, -- 分支事务 ID xid VARCHAR(100) NOT NULL, -- 全局事务 ID context VARCHAR(128), rollback_info LONGBLOB NOT NULL, -- 序列化后的 before/after image log_status INT, log_created DATETIME, log_modified DATETIME, UNIQUE KEY ux_undo_log (xid, branch_id) );rollback_info里存的是 before/after image 的序列化默认 Jackson。我们早期出过一次事故undo_log 表和业务表不在同一个数据源本地事务提交成功、但 undo_log 写入因为跨库分成了两个事务undo_log 没提交成功。后来全局事务要回滚TC 下发回滚指令分支去查 undo_log 却查不到事务直接悬挂数据停留在「扣了库存没建订单」的中间态只能人工修。所以这条要刻在脑子里undo_log 表必须和业务表在同一个库、同一个本地事务。回滚时用 before image 整行还原再加全局锁防冲突二阶段回滚时Seata 取出 undo_log 里的 before image生成反向 SQL 把数据还原// 回滚执行简化用 before image 拼出 UPDATE 还原 public void undo() { // 1. 校验 after image 和当前库里数据是否一致防止脏写 // 2. 加全局锁Global Lock阻止其他 Seata 事务并发改这行 // 3. 用 beforeImage 还原数据 // 4. 删除 undo_log UndoLogManager.undo(dataSourceProxy, ...); }这里有两个被忽略的边界全局锁只对「同样走 Seata」的事务生效。它防的是「两个 Seata 全局事务并发改同一行」时的脏写。而我们运维直接连 DB 改的那行根本不走 Seata全局锁管不到它。所以回滚时 Seata 以为「这行还是我事务结束时的样子」二话不说用 before image 还原把人工改动覆盖了。after image 一致性校验救不了这种场景。Seata 回滚前会比对「当前库里的数据」和 after image如果不一致就报错脏写保护。但运维改的是price字段而我们的回滚只还原了count字段所在的那次快照——具体覆盖哪些列取决于 undo_log 记录的是整行还是部分列。Seata 默认按「SQL 影响的主键行」整行还原所以 price 也被还原了。哪个环节出了问题说清楚责任边界这不能算 Seata 的 bug而是我们误用了它的隔离语义。AT 模式承诺的是「分布式事务之间的隔离」保证的是「多个 Seata 全局事务不会互相脏写」。它从设计上就不保护「外部绕过 Seata 直接改库」的数据——也不可能保护因为它根本不知道外部改过。我在团队里定的规矩后来有三条上了 Seata AT 的库禁止任何绕过框架的直接写运维改数要走同一套 Seata 应用或先暂停相关全局事务。undo_log 表必须和业务表在同一个数据源、同一个本地事务里——这条上面已经强调过是生死线。全局事务超时tx-service-group里的timeout默认 60 秒要根据实际链路设置。我们有一次分支事务上报慢超过了全局超时TC 已经判定回滚但分支还没提交完造成了「回滚指令和分支提交撞车」的脏数据最后只能人工修数。AT 的性能代价undo_log 的写放大与回滚成本AT 模式不是免费午餐它每笔写 SQL 都要额外做「前后镜像 写 undo_log」这是实打实的写放大。我们压测过同样一个扣库存的 UPDATE开 AT 比不开单 SQL 的 RT 多了约 1.5 毫秒主要是 undo_log 的 INSERT 和镜像查询。量小无所谓量大就明显。更隐蔽的是回滚成本AT 回滚是用 before image 反向 UPDATE如果回滚发生在热点行上会和业务写抢同一行锁放大锁竞争。-- 回滚时 Seata 实际执行的反向 SQL由 before image 拼出 UPDATE stock SET count 100, price 199 WHERE id 1; -- 同时删除 undo_log DELETE FROM undo_log WHERE xid ... AND branch_id ...;所以我的经验是AT 适合「写并不极端频繁、但要求事务简单」的中后台如果是「每秒几万次扣减」的热点账户AT 的镜像开销和回滚锁竞争会成为瓶颈那种场景不如上 TCC把「扣减 冻结」做成业务层的幂等操作绕开全局事务的镜像成本。压测数据给了我们直观结论同一个热点账户走 ATTPS 从 8000 掉到 1200同样逻辑走 TCC 手动冻结还能维持在 6000 以上。镜像开销和回滚锁竞争在高频写场景是真的贵不是理论上说说。和 TCC、Saga 的取舍模式侵入性隔离保证适合AT低加个注解只隔离 Seata 事务间大部分同库/同框架的写场景TCC高写三套接口业务层强一致资金、强一致Saga中写补偿最终一致长流程、跨系统我的判断AT 模式适合「内部服务都统一用 Seata」的中后台系统图它省事一旦你的数据会被外部系统、人工、或异构应用直接修改AT 的自动回滚就会变成隐患这种场景要么上 TCC 把控制力拿回来要么在数据入口统一收口。别因为「不用写回滚」就盲目上 AT我们那次覆盖事故省下的回滚代码最后用三倍的人工修数还了回去。复盘数字那次覆盖发生在周三 16:20 左右影响 1 个商品、1 行价格数据从发现到人工修复耗时约 50 分钟要先把全局事务相关链路熔断。事后我们给所有 Seata 库加了「直接写库」的审计告警违规写操作当天就拦下了 2 次。全局事务默认超时从 60 秒调到 120 秒后分支超时导致的回滚冲突从每周 1~2 次降到零。undo_log 表统一归并到业务库后回滚失败率从 0.4% 降到了零。思考题AT 模式用 before image 整行还原意味着它其实会「顺手还原」掉事务期间别人改过的其他列。如果你的业务要求「只回滚事务自己改的列、保留别人改的列」AT 模式还能用吗你会怎么改