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,那么一阶段里时间线的顺序是:
- 业务 SQL 执行,
update_time被数据库改成 T1; - Seata 紧接着按主键查后镜像,afterImage 里是 T1;
- 本地事务提交,数据库在提交时又更新了一次时间戳,落库变成 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、全局锁、分布式事务回滚