迁移清单写着 77 行,实际 96 行:行数校验的三个陷阱

盘点清单写 77 行,COUNT(*) 是 96 行,业务方说两百多。三个数字对应三种取数口径。行数对不上为什么不能按主键补差,以及停写重导的正确顺序。

割接前的最后一次盘点,我拿着一份表行数清单去找业务方确认。对方指着一行问:这张表线上明明有两百多条,你这里写 77 条,是漏了还是删了?

我看了一眼清单的生成方式:information_schema.tables 里的 table_rows。这份清单是前一天晚上跑的,那时库刚从备份恢复,还没有业务连接。我当时的判断是"备份比线上旧一点,正常"。但对方那句话让我不放心,于是直接连上去数了一遍。

SELECT COUNT(*) 的结果是 96 行,不是 77,也不是两百多。三个数字,三个来源,说明我连"这张表有多少行"都没搞清楚——而这是迁移验收里最基础的判据。

一、三个数字为什么不一样

  • 线上两百多:那是业务方在管理后台看到的列表条数,包含回收站/软删除的记录,取数口径不同。
  • 清单里的 77:来自 information_schema.tables.table_rows,InnoDB 的估算值,不是精确值。
  • COUNT(*) 的 96:真实行数。

table_rows 是优化器用的统计信息,InnoDB 按采样页估算,误差可以很大;表刚恢复、ANALYZE TABLE 没跑过、或者大批量写入之后统计没刷新,误差会更离谱。它适合用来估算容量,绝对不能用来做迁移验收。

取数方式 准确性 代价 适用场景
information_schema.tables.table_rows 估算,可能差 30% 以上 几乎为零 容量评估、监控趋势
SELECT COUNT(*) 精确(同事务快照内) 全表扫描,大表分钟级 迁移验收、对账
CHECKSUM TABLE 能发现内容差异 全表读,锁风险 抽样比对,不适合全库

CHECKSUM TABLE 看着最理想,但它对行顺序、页内布局、字符集都有依赖,两张逻辑一致但插入顺序不同的表会给出不同结果。我们只在个别小表上用它抽样,主体还是 COUNT(*)。

二、为什么不能"按主键补差"

意识到清单不准之后,我冒出的第二个念头是取巧:既然只差十几行,找出源端有、目标端没有的主键,补进去不就完了。

这个念头很快被否掉了,原因有三条,任何一条都足以否掉它:

第一,源端没有开 binlog,也没有 GTID。没有位点就没有"增量"这个概念,我无法知道那十几行是新增的,还是目标端曾经有、后来被删的。方向搞反会把已删除的数据补回去。

第二,主键不连续。业务表用了自增主键,但中间有物理删除,id 有大量空洞。用 id > max(id) 判断"新数据"会漏掉所有回填的历史行。

第三,也是最重要的一条:补差只处理"行数差",处理不了"同一主键内容不同"。迁移期间源端有更新,目标端是旧值,行数完全一致,数据却是错的。行数对上只是必要条件。

所以最终做法只有一个:停写,全量重导,精确比对。这比补差慢,但结论是确定的。

三、停写窗口里到底要做什么

重导不复杂,复杂的是顺序。我的做法是四步,每步都有可验证的输出:

# 1) 源端置只读(应用层开关 + 数据库兜底)
mysql -h $SRC -u$USER -p -e "SET GLOBAL super_read_only=ON; SELECT @@read_only, @@super_read_only;"

# 2) 记录源端每张表的精确行数,作为基线
mysql -h $SRC -u$USER -p $DB -N -B -e "
SELECT table_name FROM information_schema.tables
WHERE table_schema='$DB' AND table_type='BASE TABLE' ORDER BY table_name;" > /tmp/tables.txt

while read t; do
  n=$(mysql -h $SRC -u$USER -p $DB -N -B -e "SELECT COUNT(*) FROM \`$t\`;")
  echo -e "$t\t$n"
done < /tmp/tables.txt > /tmp/src-counts.tsv

# 3) 目标端重导(单事务,保证一致性快照)
mysqldump -h $SRC -u$USER -p --single-transaction --set-gtid-purged=OFF \
  --default-character-set=utf8mb4 $DB > /tmp/dump.sql
mysql -h $DST -u$USER -p $DB < /tmp/dump.sql

# 4) 目标端取同样的行数,逐表比对
while read t; do
  n=$(mysql -h $DST -u$USER -p $DB -N -B -e "SELECT COUNT(*) FROM \`$t\`;")
  echo -e "$t\t$n"
done < /tmp/tables.txt > /tmp/dst-counts.tsv

diff /tmp/src-counts.tsv /tmp/dst-counts.tsv && echo "行数一致"

--single-transaction 这一项不能省。它让 dump 落在一个一致性快照里,否则 dump 过程中其他会话的写入会让前后表处于不同时间点,行数看起来对、关联关系却是断的。

四、比对脚本要比"总数"更细

第一次比对,我只比了总行数,结果一致,就准备收工。后来发现有一张表少了 3 行、另一张表多了 3 行,总数刚好抵消。这种巧合概率不高,但一旦发生,验收就是假的。

改成逐表比对、并且带退出码,才算可用:

fail=0
join -t $'\t' -j 1 <(sort /tmp/src-counts.tsv) <(sort /tmp/dst-counts.tsv) \
| while IFS=$'\t' read -r t src dst; do
    [ "$src" = "$dst" ] || { echo "MISMATCH $t src=$src dst=$dst"; fail=1; }
  done

管道里的 while 在子 shell 里跑,fail 出了管道就丢,这是个经典坑。要么用 set -o pipefail 配合 grep -q MISMATCH,要么把结果写文件再判断:

join -t $'\t' -j 1 <(sort /tmp/src-counts.tsv) <(sort /tmp/dst-counts.tsv) \
  | awk -F'\t' '$2!=$3{print}' > /tmp/mismatch.tsv
[ -s /tmp/mismatch.tsv ] && { cat /tmp/mismatch.tsv; exit 1; }
echo "逐表行数全部一致"

五、两处差点漏掉的偏差

一是持续写入的表。会话表和事件表在"停写"之后仍有写入,因为写入方不止应用一个:还有一个统计脚本每十分钟跑一次,直连数据库。它的存在是我在比对时发现数字一直在变才注意到的。处置是把这类表的比对放在最后,并且在脚本里记录比对时刻,两次取数间隔不超过 30 秒。

二是空表。COUNT(*) 为 0 的表在 information_schema 里可能根本不出现(取决于权限和统计状态),如果表清单来自 table_rows,这些表会被整表漏掉。表清单必须来自 information_schema.tables 且过滤条件只写 table_type='BASE TABLE',不要带行数条件。

六、把结论写进报告的方式

最后那份验收报告我没有写"数据一致",而是列了三样东西:表数量(29)、逐表行数对照表、比对脚本的退出码与执行时刻。这样即便半年后有人质疑,也能重跑一遍脚本得到同样的结论。

顺带把 table_rows 从那套盘点脚本里彻底删掉了。它没有错,只是用错了地方——估算值出现在验收清单里,会让一次本该被发现的差异变成一次"数字对不上但没人深究"的悬案。

小结

  • information_schema.table_rows 是采样估算,只能用于容量评估,不能进验收清单。
  • 行数不一致时不要按主键补差:没有 binlog/GTID 就没有增量位点,且补差处理不了"行相同、内容不同"。
  • 正确路径是停写、--single-transaction 全量重导、逐表 COUNT(*) 比对。
  • 比对要逐表而不是比总数,否则一多一少会互相抵消;脚本用文件判断而不是管道内变量。
  • 表清单从 information_schema.tables 取,别用带行数过滤的条件,否则空表整表漏掉。
查看「数据库与缓存」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。

迁移清单写着 77 行,实际 96 行:行数校验的三个陷阱 - 起航社区