我们刚上 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/HikariSeata 根本抓不到 SQLundo_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_logSeata 官方建表语句简化 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 的前镜像是 100B 改成 80 并提交A 回滚时把账户写回 100B 的扣款凭空消失。隔离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 序列化格式有过变动混版本部署会出现回滚解析失败升级前一定先在预发环境跑一遍全链路回滚。强一致且高并发改同一行的场景别用 AT改用 TCC 或 Saga 自己控补偿AT 的脏写校验和全局锁会成为瓶颈每个参与库都必须建 undo_log 配 DataSourceProxy漏一个就出现半成功这是 AT 上线最高频的坑undo_log 表要定期清理Seata 提交后会异步删但回滚失败的脏数据要人工兜底否则这张表会越涨越大拖慢回滚别把长事务挂进 GlobalTransactional本地事务持锁时间越长全局锁冲突越狠。说实话我不太建议一上来所有跨服务调用都套 AT。先想清楚是不是真的要强一致——很多场景用本地事务 可靠消息最终一致比全局事务更稳、性能更好。AT 是工具不是默认答案。思考题如果 undo_log 和业务数据不在同一个本地事务提交比如先提交业务、再异步写 undo_log会发生什么结合我们那次脏写事故说说为什么 Seata 必须把两者绑在同一个事务里。写在最后Seata AT 模式不写回滚代码的魔法来自拦截 SQL 前后镜像 undo_log三件套。但它的前提是每个库都配 DataSourceProxy、都建 undo_log并且靠全局锁 回滚校验来防脏写。我们那次账目对不上根因就是没吃透 undo_log 的插入时机和隔离边界。AT 省的是写补偿代码的力气省不掉理解它怎么回滚的责任。