Seata AT 模式不用写回滚代码:一次“脏写“让我们重读了 undo_log 的插入时机

📅 2026/7/29 15:52:45 👁️ 阅读次数 📝 编程学习
Seata AT 模式不用写回滚代码:一次“脏写“让我们重读了 undo_log 的插入时机

我们刚上 Seata 那阵,以为@GlobalTransactional一贴就万事大吉。结果有次库存服务崩了做回滚,回滚完发现账目对不上——有笔钱被"回滚成了错误的值"。查了半天才定位:本地事务抢在 Seata 写 undo_log 之前提交了一部分,导致回滚时拿不到正确的前镜像,写回了脏数据(脏写)。那次之后我重读了 AT 模式的两阶段和 undo_log 机制,才真正搞懂它"不写回滚代码"到底是靠什么撑起来的。这篇把它讲透,并附上能直接用的代码。

AT 模式的本质:自动生成前后镜像

AT 模式(Automatic Transaction)的核心思路是:拦截 SQL,在执行前后各拍一张快照(前镜像、后镜像),提交时存进 undo_log,回滚时拿前镜像反向生成补偿 SQL。你不用手写compensate(),框架替你算。它分两个阶段:

  • 一阶段:本地事务提交,同时把 undo_log 和业务数据在同一个本地事务里提交。资源直接释放,不长期持锁。
  • 二阶段:TC(事务协调器)决定提交或回滚。提交就异步删 undo_log;回滚就用 undo_log 的前镜像生成反向 SQL 恢复数据。

先上一个最直观的用法:

// 业务侧:只贴注解,不写任何回滚代码 @GlobalTransactional(name = "create-order", rollbackFor = Exception.class) public void createOrder(Order order) { orderMapper.insert(order); // 1. 订单库插入 accountClient.deduct(order.getUserId(), order.getAmount()); // 2. 远程扣余额 storageClient.deduct(order.getSkuId(), order.getCount()); // 3. 远程扣库存 }

逐行:第 1 行往本地订单库插数据;第 2、3 行是跨服务的远程调用。加了@GlobalTransactional后,Seata 会为这次调用开启一个全局事务 XID,并通过拦截器把 XID 传下去,让 account、storage 两个分支事务都挂到同一个全局事务上。你完全没写回滚逻辑,但任意一步抛异常,Seata 会驱动所有分支回滚。这看起来很省事,但省事的代价在 undo_log 的写入时机上。

数据源代理:AT 模式的隐藏前提

AT 模式能拦截 SQL、造镜像,前提是你的 DataSource 被 Seata 代理了。如果还是用原生 Druid/Hikari,Seata 根本抓不到 SQL,undo_log 不会生成,回滚时只能干瞪眼。

// 必须给数据源套一层 Seata 代理,否则 AT 完全不生效 @Bean public DataSource dataSource(DruidDataSource druid) { return new DataSourceProxy(druid); // 1. 用 DataSourceProxy 包住原生数据源 }

逐行:第 1 行DataSourceProxy是 Seata 提供的代理,所有getConnection返回的是带拦截逻辑的ConnectionProxy我们踩过的第一个坑就是:另一个微服务忘了配这个 Bean,用的原生数据源,结果那边的本地事务照常提交、却没写 undo_log,全局回滚时它不回滚,数据出现"订单一半成功一半失败"。所以上 AT 模式第一步不是贴注解,是确认每个参与服务都换上了DataSourceProxy

undo_log 表:回滚的命根子

AT 模式在每个参与服务的库里都要建一张undo_log表,存前后镜像。结构大致这样:

-- 每个业务库都要建 undo_log(Seata 官方建表语句简化) CREATE TABLE undo_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, branch_id BIGINT NOT NULL, -- 1. 分支事务 ID xid VARCHAR(100) NOT NULL, -- 2. 全局事务 XID context VARCHAR(128), -- 3. 序列化方式等上下文 rollback_info LONGBLOB NOT NULL, -- 4. 前后镜像(JSON 序列化) log_status INT, -- 5. 状态:0 正常、1 防悬挂 log_created DATETIME, log_modified DATETIME, UNIQUE KEY ux_undo_log (xid, branch_id) );

逐行:第 2 行xid把 undo_log 和全局事务关联;第 4 行rollback_info是核心,存了"执行前的数据快照(beforeImage)"和"执行后的数据快照(afterImage)",序列化后塞进 blob;第 5 行log_status的 1 代表"防悬挂"标记,用于解决"分支事务在 TC 决定回滚之后才注册"的悬挂问题。回滚时 Seata 读rollback_info,拿 beforeImage 反查当前数据,比对一致才用前镜像生成UPDATE ... SET 旧值反向写回。

两阶段提交与回滚:undo_log 的插入时机

这是关键,也是我们那次脏写的来源。undo_log 和业务数据在同一个本地事务里提交——也就是说,SQL 执行、前后镜像生成、undo_log 插入,是一起 commit 的。看一阶段的执行顺序(逻辑还原):

// 一阶段在本地事务里做的事(Seata 内部逻辑还原) ConnectionProxy conn = ...; try { beforeImage = snapshotBefore(sql); // 1. 执行前拍前镜像 affected = executeSql(sql); // 2. 真正执行业务 SQL afterImage = snapshotAfter(sql); // 3. 执行后拍后镜像 undoLog = buildUndoLog(xid, branchId, beforeImage, afterImage); insertUndoLog(undoLog); // 4. 同一事务内插入 undo_log conn.commit(); // 5. 业务数据 + undo_log 一起提交 } catch (Exception e) { conn.rollback(); // 6. 异常则业务和 undo_log 一起回滚 }

逐行:第 1 行先拍前镜像;第 2 行执行业务 SQL;第 3 行拍后镜像;第 4 行把 undo_log 插进同一个连接、同一个事务;第 5 行一次性提交。重点:前镜像是在 SQL 执行之前拍的。如果别的本地事务在你拍前镜像之后、提交之前改了这行数据,你回滚时用前镜像覆盖,就会把别人的修改覆盖掉——这就是"脏写"。我们那次事故正是两个全局事务并发改同一账户,A 的前镜像是 100,B 改成 80 并提交,A 回滚时把账户写回 100,B 的扣款凭空消失。

隔离:AT 用全局锁防脏写

Seata 用全局锁(global lock)来缓解脏写:一阶段提交前,会去 TC 申请"这批数据行的全局锁",只有拿到才提交。另一个事务想改同一行,拿不到全局锁就阻塞或失败,从而避免并发改。但这不解决全部隔离问题,所以 Seata 在回滚时有校验:

// 回滚时的数据校验(Seata 内部逻辑还原) beforeImage = undoLog.beforeImage; // 1. 取出前镜像 current = selectCurrent(row); // 2. 查当前实际数据 if (!equals(beforeImage, current)) { // 3. 和前镜像不一致 -> 说明被改过 // 4. 尝试用 afterImage 做"脏写校验",必要时重试或抛异常 handleDirtyWrite(xid, branchId); } executeReverseSql(beforeImage); // 5. 一致才用前镜像反向恢复

逐行:第 3 行回滚前先比对"当前数据"和"前镜像",如果不一致,说明在你持有锁期间数据被别人动过(脏写风险),Seata 会进入脏写处理(记录日志、按配置重试或告警)。第 5 行只有在数据没被别人改过时才用前镜像反向恢复。这层校验救了我们——后来再出现并发改同一行时,回滚不再默默覆盖,而是抛脏写异常让我们介入,账目不再对不上。

我的取舍:AT 省事,但别把它当银弹

我的观点:AT 模式适合"大多数单库 CRUD 型分布式事务",上手快、代码干净,但代价是依赖 undo_log、有全局锁开销、隔离级别弱于本地事务的 SERIALIZABLE。我们后来的实践原则:

还有个运维坑我们踩过:Seata 的 TC(事务协调器)必须独立部署且高可用,早期我们图省事把 TC 和某个业务服务放同一台机器,那台机器一次 5 秒的 GC 停顿,把十几个全局事务的回滚指令全卡住,连锁拖慢了整条下单链路。TC 是全局事务的中枢,它抖一下,所有挂了@GlobalTransactional的接口都跟着抖,务必单独部署、接注册中心(如 Nacos)、至少两节点。另外 Seata 版本升级要谨慎,1.4.x 到 1.5.x 的 undo_log 序列化格式有过变动,混版本部署会出现回滚解析失败,升级前一定先在预发环境跑一遍全链路回滚。

  1. 强一致且高并发改同一行的场景,别用 AT,改用 TCC 或 Saga 自己控补偿,AT 的脏写校验和全局锁会成为瓶颈;
  2. 每个参与库都必须建 undo_log + 配 DataSourceProxy,漏一个就出现"半成功",这是 AT 上线最高频的坑;
  3. undo_log 表要定期清理(Seata 提交后会异步删,但回滚失败的脏数据要人工兜底),否则这张表会越涨越大拖慢回滚;
  4. 别把长事务挂进 @GlobalTransactional,本地事务持锁时间越长,全局锁冲突越狠。

说实话,我不太建议一上来所有跨服务调用都套 AT。先想清楚"是不是真的要强一致"——很多场景用"本地事务 + 可靠消息(最终一致)"比全局事务更稳、性能更好。AT 是工具,不是默认答案。

思考题

如果 undo_log 和业务数据不在同一个本地事务提交(比如先提交业务、再异步写 undo_log),会发生什么?结合我们那次"脏写"事故,说说为什么 Seata 必须把两者绑在同一个事务里。

写在最后

Seata AT 模式"不写回滚代码"的魔法,来自"拦截 SQL + 前后镜像 + undo_log"三件套。但它的前提是每个库都配 DataSourceProxy、都建 undo_log,并且靠全局锁 + 回滚校验来防脏写。我们那次账目对不上,根因就是没吃透 undo_log 的插入时机和隔离边界。AT 省的是"写补偿代码"的力气,省不掉"理解它怎么回滚"的责任。