编程 Seata AT 回滚报 Has dirty records when undo:镜像校验、全局锁与回滚顺序的排查记录

2026-11-04 00:04:09

Seata AT 回滚报 Has dirty records when undo:镜像校验、全局锁与回滚顺序的排查记录

项目地址:apache/incubator-seata 文档:seata.apache.org

现象

一笔 @GlobalTransactional 业务,二阶段回滚时 RM 抛 SQLUndoDirtyException: Has dirty records when undo.,本地数据既没补偿回去,业务侧还留了半截脏数据。全局事务用的 AT 模式,数据源已用 DataSourceProxy 包过,undo_log 表存在且一阶段写入正常。

AT 一阶段做的事是固定的:解析 SQL → 查前镜像 beforeImage → 执行业务 SQL → 按主键查后镜像 afterImage → 写 undo_log → 向 TC 注册分支申请全局锁 → 业务数据与 undo_log 在同一本地事务里提交。二阶段回滚则按 xid + branchId 取回 undo_log,先做数据校验,再用前镜像生成反向 SQL 补偿。

报错来自“先做数据校验”这一步,所以先把判据摊开看。

判据:dataValidationAndGoOn 的四个分支

AbstractUndoExecutor#executeOn 会先读 client.undo.dataValidation(默认 true),关闭后就直接构造反向 SQL、不做校验。开着的时候走 dataValidationAndGoOn:

// 简化后的判定顺序
if (DataCompareUtils.isRecordsEquals(beforeImage, afterImage)) {
return false;                       // ① 前镜像等于后镜像:数据本来没变,跳过
}
List currentRecords = selectForUpdateByPK(beforeImage);  // 按主键 for update 取当前快照
if (DataCompareUtils.isRecordsEquals(afterImage, currentRecords)) {
return true;                        // ② 后镜像等于当前值:可以回滚,执行反向 SQL
}
if (DataCompareUtils.isRecordsEquals(beforeImage, currentRecords)) {
return false;                       // ③ 后镜像对不上但前镜像对得上:已经回到前镜像状态,跳过
}
throw new SQLUndoDirtyException("Has dirty records when undo.");  // ④ 都不等:判脏数据

关键在比较方式:DataCompareUtils.isRecordsEquals 比的是记录整体字段相等,不是只比业务关心的那几列。任何一个字段在“业务提交 → Seata 记录后镜像”之后又被改过,第 ② 步就过不去,落到 ④。

这就直接解释了第一个高频原因。

嫌疑一:on update CURRENT_TIMESTAMP

表上如果有 update_time datetime on update CURRENT_TIMESTAMP,那么一阶段里时间线的顺序是:

  1. 业务 SQL 执行,update_time 被数据库改成 T1;
  2. Seata 紧接着按主键查后镜像,afterImage 里是 T1;
  3. 本地事务提交,数据库在提交时又更新了一次时间戳,落库变成 T2。

回滚时 select ... for update 取到的当前值带 T2,与 afterImage 的 T1 不等,第 ② 步失败;beforeImage 里的时间戳更早,第 ③ 步也失败,于是抛脏数据。这在官方 FAQ 里对应“数据库开启自动更新时间戳导致脏数据无法回滚”那一条。

处置有两条路,取舍也很清楚:

  • 关掉 DB 的自动更新时间戳,时间字段由代码维护(MyBatis-Plus 的自动填充是常见做法),这是根治;
  • 或让 update 语句不要把未更新字段写进 SET,减少这类隐式改写。

不建议直接关 client.undo.dataValidation:那等于放弃护栏,脏数据会被无声地反向覆盖。

嫌疑二:全局锁锁的到底是什么

排查中很容易怀疑“锁粒度太粗导致并发冲突”,但 AT 的全局锁实际是行级。

prepareUndoLog() 里 DELETE 用 beforeImage 生成 lockKey,其余用 afterImage;buildLockKey 拼出来的字符串长这样:

wallet_tbl:1             单行单主键
wallet_tbl:1,2,3         多行,逗号分隔
tableName:1_A,2_B,3_C    复合主键,主键值之间用下划线连接

ConnectionProxy#register() 把这串 lockKeys 随分支注册发给 TC;TC 侧 BranchSession.lock() 只对 AT 分支取锁,LockDO.rowKey = resourceId + tableName + pk,也就是一行记录对应一把锁。所以锁粒度是主键集合,不是“整张表”。注册分支时一把全成功或全失败,不会部分成功。

拿不到全局锁时,重试预算由两个参数决定:client.rm.lock.retryInterval 默认 10ms、client.rm.lock.retryTimes 默认 30,合起来约 300ms。超出预算就放弃、回滚本地事务释放本地锁,日志是 Global lock acquire failed,和脏数据异常是两回事——排查时先看日志是哪一类,能省掉一半弯路。

读隔离这块也有个易混点:全局默认隔离级别是读未提交,只有 SELECT ... FOR UPDATE 会被代理并申请全局锁;@GlobalLock 不开启全局事务,只在本地事务提交前查全局锁是否存在,成本低,但防脏写仍然必须用 @GlobalTransactional。

嫌疑三:Seata 2.0 的回滚顺序 bug(#6120)

如果不是时间戳,也不是锁冲突,就该看版本了。

复现路径很短:同一个方法里先 orderMapper.save(order),最后 orderMapper.update(..., 0),末尾用 1/0 触发回滚。2.0 上回滚失败,1.7 上正常。

原因在服务端 doGlobalRollback:1.7 取的是 globalSession.getReverseSortedBranches()(倒序),2.0 改成了 getSortedBranches()(正序)。正序时先回滚 save 那条 undo,它的 afterImage 是插入时的原始行,但当前数据已经被后续 update 改过,镜像校验第 ② 步不过、第 ③ 步也不过,判脏数据抛异常。倒序先回滚 update,afterImage 与当前值一致,能成功补偿;等轮到 save 时数据已经回到插入态,校验自然通过。

维护者 funky-eyes 确认这是 bug,PR #6121(bugfix: the branch transaction order error when rolling back)修复。同样一个方法里对同一行先 insert 再 update,在 2.0 未打补丁的版本上是稳定踩中的。升级或回退前,先确认手上的版本是否包含这个修复,比在业务代码里绕要划算。

防悬挂和两条口径不一致的配置

AbstractUndoLogManager#undo 的逻辑是:select undo_log where xid = ? and branch_id = ? for update;多条 sqlUndoLog 时先 Collections.reverse 再逆序执行。

  • 查到记录 → 删除 undo_log,与回滚一起提交;
  • 查不到(RM 一阶段超时未提交、TC 已发起回滚)→ insertUndoLogWithGlobalFinished 插一条 GlobalFinished 状态记录再提交。

这条防悬挂记录靠 ux_undo_log (xid, branch_id) 唯一索引生效:如果一阶段随后才提交成功,会撞唯一约束自动失败;如果插入时冲突,说明一阶段已提交,进入 catch (SQLIntegrityConstraintViolationException) 重试回滚。

注意口径问题:官方 FAQ 把这条描述成 log_status = 1,源码里对应的是 State.GlobalFinished 枚举。查表核对时按自己用的版本确认,不要照抄。

CREATE TABLE `undo_log` (
`id`            BIGINT(20)   NOT NULL AUTO_INCREMENT,
`branch_id`     BIGINT(20)   NOT NULL,
`xid`           VARCHAR(100) NOT NULL,
`context`       VARCHAR(128) NOT NULL,
`rollback_info` LONGBLOB     NOT NULL,
`log_status`    INT(11)      NOT NULL,
`log_created`   DATETIME     NOT NULL,
`log_modified`  DATETIME     NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

还有几个配置项的口径值得记一笔:

seata:
client:
undo:
dataValidation: true        # 默认 true,二阶段回滚镜像校验
logTable: undo_log
logSerialization: jackson
rm:
reportSuccessEnable: true   # 默认 false;true 保留 branch_table 生命周期记录
lock:
retryInterval: 10         # ms
retryTimes: 30
retryPolicyBranchRollbackOnConflict: true
log:
exceptionRate: 100          # 默认 100,但注释写的是"百分之一的概率输出"

client.rm.reportSuccessEnable 从 1.1.0 起默认 false,为了性能不上报成功分支;代价是 branch_table 里缺记录,排查时看不到完整的分支生命周期。定位阶段先把它打开,问题收敛后再按性能需要决定是否关回去。

client.log.exceptionRate 默认值是 100,说明文字却写着“目前用于 undo 回滚失败时异常堆栈输出,百分之一的概率输出”,默认值与注释自相矛盾。真要靠异常堆栈定位时,按版本实测一下这个值到底怎么生效,别默认“堆栈没打就是没出错”。

另外两个容易忽略的边界:全局事务默认超时 60000ms,超时后 TC 发起全局回滚,此时业务方法若继续执行 DB 操作,会在校验全局事务状态时被发现;TC 集群节点时间不一致也会造成类似的误判。

这次的处置

定位顺序按代价从低到高排:先看异常是 Has dirty records 还是 Global lock acquire failed,区分“数据被改过”和“没抢到锁”;是前者再逐字段比对 beforeImage / afterImage / 当前值,重点查有没有 on update CURRENT_TIMESTAMP 这类隐式改写;都排除后核对 Seata 版本是否在 #6120 的影响范围内。

最终落地的是三件事:时间戳改为代码填充、reportSuccessEnable 在排障环境打开、版本升到含 PR #6121 的构建。client.undo.dataValidation 保持默认 true——它是唯一能在补偿前拦住脏数据的东西,关掉只是把问题推到更晚。


关键词:Seata、AT 模式、undo_log、SQLUndoDirtyException、全局锁、分布式事务回滚

复制全文 生成海报 Seata 分布式事务 AT模式 undo_log MySQL Java

推荐文章

程序员茄子在线接单