异常复现的 7 种根因
- DDL 在线建索引期间的并发窗口(最常见)
在 ADD INDEX 的 WriteReorganization 阶段:
SnapshotVer 时刻的快照 → Backfill 扫描全表建索引
│
并发 DML 在 StateWriteOnly 下双写数据和索引
pkg/ddl/index.go:2585 的注释明确承认:
// For the normal pessimistic transaction, it’s ok.
// But if async commit is used, it may lead to inconsistent data and index.
异步提交事务中,如果 TiKV 的锁被 GC 提前清理了,并发写入的索引条目可能丢失。这种情况下:
- 行数据写入了
- 索引条目丢失了
- ADMIN RECOVER INDEX 补上了
- 但同样的场景还会再次发生,只要有并发 DDL + 异步提交
- SkipWriteUntouchedIndices 优化导致的不一致留存
pkg/executor/write.go:299-308:
if sessVars.InTxn() || … {
opts = append(opts, …)
} else {
opts = append(opts, table.SkipWriteUntouchedIndices) // 自动提交时启用
}
这个优化的逻辑是:UPDATE 时,如果索引列的值没有变化(“untouched”),就跳过写索引条目,因为它应该已经存在。
问题链条:
-
某条数据因为某些原因丢失了索引条目(比如 DDL backfill 遗漏)
-
ADMIN RECOVER INDEX 补上了
-
后续有一条 UPDATE,改了其他列,索引列没变
-
SkipWriteUntouchedIndices 生效 → 不再写索引(认为它已存在)
-
但此时如果旧索引条目因为某些原因又丢了(见第 3、4 点)
-
这条数据的索引再次缺失
-
GC UnsafeDestroyRange — 物理删除索引 KV
pkg/store/gcworker/gc_worker.go 中,GC Worker 在执行 doUnsafeDestroyRange 时,直接从 TiKV 物理层面删除 KV 范围,不经过 MVCC。
哪些 DDL 会注册删除范围:
┌────────────────┬────────────────────────────┐
│ DDL 操作 │ 注册的删除范围 │
├────────────────┼────────────────────────────┤
│ ADD INDEX 回滚 │ 索引和临时索引的 key range │
├────────────────┼────────────────────────────┤
│ DROP INDEX │ 索引 key range │
├────────────────┼────────────────────────────┤
│ DROP TABLE │ 整个表(含数据和索引) │
├────────────────┼────────────────────────────┤
│ TRUNCATE TABLE │ 旧表数据范围 │
├────────────────┼────────────────────────────┤
│ DROP PARTITION │ 分区 + 全局索引范围 │
└────────────────┴────────────────────────────┘
如果 GC UnsafeDestroyRange 的 key range 计算有偏差(比如 redoDeleteRanges 重试逻辑),或者删除了仍被引用的范围,索引数据会物理消失。ADMIN RECOVER INDEX 补回来后,下一次 GC 如果再次误删,又会出现不一致。
- 分区表 — Mutation Checker 完全关闭
pkg/table/tables/mutation_checker.go:90-93:
if t.Meta().GetPartitionInfo() != nil {
// TODO: Support check for partitions as well
return nil // 完全跳过检查
}
分区表在写入时的数据一致性检查完全被跳过。而且 tidb_enable_mutation_checker 默认就是 OFF(pkg/sessionctx/variable/tidb_vars.go:1465):
DefTiDBEnableMutationChecker = false
即使 ADMIN RECOVER/CLEANUP 修复了分区表的不一致,后续的任何 DML 写入都不会校验索引和数据是否一致,异常会反复累积。
- Temp Index Merge 3 阶段建索引的竞争
pkg/ddl/index_merge_tmp.go 的三阶段合并:
- Running: Backfill 写临时索引,并发 DML 双写真实索引+临时索引
- ReadyToMerge: 等待 all TiDB 节点感知 schema 版本
- Merging: 将临时索引重放到真实索引
合并过程的竞争:在 mergeIndexWorker.BackfillData 中,从临时索引读取条目后,需要检查对应数据行是否还存在。但此时并发 DML 可能同时修改同一行,导致:
- 临时索引的条目被重放到真实索引
- 但对应数据行已经被并发事务删除了
- 产生了多余的索引条目
ADMIN CLEANUP INDEX 可以删掉,但只要建索引操作(ADD INDEX)重跑或新节点加入触发 rebuild,同样的竞争还会发生。
- 索引值正确但 Handle 指向错误行(最隐蔽)
ADMIN RECOVER INDEX 的 batchMarkDup(pkg/executor/admin.go:489):
// recover index: the constraint of unique index is broken,
// handle in index is not equal to handle in table
// 只打 WARNING 日志,不修复!
如果索引键存在但 handle 指向了另一行,代码只是记录一条 warning 日志,不做任何修复。这意味着:
- 唯一索引的约束实际上被破坏了
- 查询通过该索引可能返回错误行
- 后续使用该索引的 UPDATE/DELETE 会操作错数据行
- 错误的数据修改会进一步扩散不一致
- 不完整事务 — 数据写入成功但索引写入失败
TiDB 内部写入顺序(pkg/table/tables/tables.go):
INSERT: 写行数据 → 写索引 → 提交
UPDATE: 删旧索引 → 写新行数据 → 写新索引 → 提交
DELETE: 删行数据 → 删索引 → 提交
如果在 “写索引” 这一步遇到临时错误(region split、leader 切换等),但之前的数据写入已经进了 staging buffer:
- 乐观事务 → 提交时整体失败,回滚
- 悲观事务 → 写入行数据不会被回滚,但索引没写进去
这个错误可能间歇性复现(比如集群正在做 region 调度时),所以 ADMIN RECOVER INDEX 修复后,同样的错误还会发生。