40TB 裸金属迁移验收全绿、切业务 3 小时就回滚——应用把 mtime 改回去了,全公司只有 ctime 抓得住
去年底给一家做日志归档分析的客户搬数据,源端是一台服役 4 年的老双路,目的端是轻云互联的裸金属(25G 双口直连、本地 NVMe、IPMI 和 BIOS 全套权限在手)。40TB,两百多万个文件,rsync 两轮同步,dry-run 校验输出为空,凌晨 2 点切流量。
3 小时后,业务侧的统计任务报了 1.1 万条主键冲突——目的端的文件比源端"旧"。注意不是丢了,是内容对不上,但文件名、大小、甚至 mtime 全部一模一样。这才是这次事故最恶心的地方。
一、为什么"校验通过"是个伪命题
我们的验收标准当时是这么定的:
rsync -aHn --delete /data/ /mnt/new/data/
# 输出为空 → 认为一致 → 切
这个命令在 99% 的迁移里都是对的。但它比较的是什么?是 size 和 mtime(秒级)。只要这两样相同,rsync 就认为文件没变过,直接跳过。
出事的目录是归档落盘区。写入进程的逻辑是:
open(path, O_WRONLY|O_CREAT);
write(buf, len); // 定长记录,长度恒定
futimens(fd, ts_biz); // 把 mtime 手动设回业务时间戳
close(fd);
也就是说:文件在 10 月被改写了,但 mtime 被强行写回了 8 月。这是归档/日志类系统很常见的做法(保持文件的逻辑时间),但它同时把 rsync 的 quick check 彻底废掉了。再加上定长记录,文件 size 也没变——两个判据全部失效。
抓现行只需要一条命令:
stat --printf='%n\n size=%s\n mtime=%.9Y\n ctime=%.9Z\n' /data/archive/2026/08/15/a.log
输出里 mtime 停在 8 月,ctime 却是迁移当晚的时间。ctime(inode change time)是内核维护的,用户态没有任何接口能直接写它。 任何 write、truncate、rename、chmod、chown、link 都会刷一次 ctime。它没法被应用"伪装"。
二、正确的增量判定:改用 ctime 圈定变更集
rsync 本身没有 --ctime,但可以自己算变更集再喂给 --files-from。完整的双轮流程长这样:
# 第一轮全量,务必记下开始的时刻
T0=$(date '+%Y-%m-%d %H:%M:%S')
echo "$T0" | tee /root/migrate_T0
# -H 保留硬链接,--numeric-ids 避免 uid/gid 名字映射错乱
rsync -aH --numeric-ids --info=progress2 \
/data/ /mnt/new/data/
# 第二轮:只同步 ctime 在 T0 之后变化的文件
cd /data
find . -xdev -newerct "$T0" -type f -print0 \
| tee /root/changed.lst | tr -cd '\0' | wc -c
rsync -aH --numeric-ids --from0 \
--files-from=/root/changed.lst \
/data/ /mnt/new/data/
几个必须注意的点:
find必须在源端跑。目的端的 ctime 全是迁移写入时刻,比了没意义。-print0+--from0是为了扛住文件名里的空格和换行,别用默认的换行分隔。-xdev防止 find 掉进挂载的 NFS 或 loop 设备里爬一整天。- 生成列表前
cd /data,让路径是./xxx相对形式,否则--files-from找不到。
ctime 是"保守超集"——它会把 chmod 过的、被 rename 过的无关文件也带进来,宁多勿漏,这个特性在迁移场景里是优点。
三、切换窗口内的最终验收,只有一条命令可信
应用停了、写入冻结之后,做最后一轮。别再自己写对比脚本,直接用 rsync 自己的算法,但把快速判断换成 checksum:
rsync -rlptgoDHAXn --numeric-ids --checksum --delete \
/data/ /mnt/new/data/ | tee /root/verify.log
[ -s /root/verify.log ] && echo "FAIL: 还有差异" || echo "OK: 双端一致"
-n 是 dry-run,只报告不动手;-c 强制读两端文件算校验和,绕过 size/mtime 判断。40TB 全量读一遍,在 25G 直连 + NVMe 的组合下大约 4~5 小时,必须留够窗口,别想着"抽几个文件看看"糊弄过去。
另外 -a 拆开写是有原因的:-a = -rlptgoD,不含 -H(硬链接)、-A(ACL)、-X(xattr)。归档系统里这三样基本都有。漏掉 -H 的后果是:源端一千个文件共享一个 inode,目的端变成一千份独立数据,du 直接从 2TB 涨到 20TB,盘写满时你还在纳闷"我明明只搬了 40TB"。
迁完先自查一眼:
find /data -type f -links +1 | wc -l
du -sh --apparent-size /data # 逻辑大小
du -sh /data # 实际占用,两者差距大说明硬链接多
四、被忽略的两个配角坑
1. 25G 直连别用 ssh 承载 rsync
第一轮我们用的 -e ssh,单流卡在 1.4GB/s 上不去,top 里 sshd 单核跑满 100%。ssh 是单线程加密,AES-NI 再快也就这个量级。要么指定 -c aes128-gcm@openssh.com,要么干脆用 rsync daemon 走内网直连(rsync://),把加密开销整体去掉。前者是保守选择,后者快 40% 以上,但记得只在受控内网干。
2. RAID 卡写缓存让"迁移完成"变成假的
目的端的 RAID 卡如果开着 write-back 且电池/超级电容状态异常,卡会静默降级成 write-through,写入速度掉到 1/5,同时 rsync 报告的进度是"完成"——数据在卡缓存里。这次因为目的端是轻云互联的裸金属,IPMI 能直接进 BIOS 和阵列卡管理界面看 BBU 状态,才发现电容健康度已经是 degraded。云主机上你连这一层都摸不到,只能看着 IO 曲线干着急——这也是我们做大容量迁移坚持用物理机的原因之一。
五、把这套流程固化成清单
- 迁移开始前记录
T0,并落盘到文件,别留在内存里。 - 源端先跑一遍
find -newerct,确认变更集规模合理(如果一次就是几百万文件,说明该停机了)。 - 全量轮用
-aH --numeric-ids,不要加--delete,避免源端误删把目的端也带走。 - 增量轮用 ctime 变更集 +
--files-from,比全量 checksum 快一到两个数量级。 - 冻结窗口内跑
--checksum --delete -n,输出为空才允许切。 - 切换后保留源端只读至少 7 天,别急着格式化。
- mtime 是应用可控的,任何依赖 mtime 做变更判断的同步方案,在归档类业务面前都不可靠。
说句难听的:这次不是 rsync 的问题,是我们把"文件没变"的定义交给了应用去决定。ctime 之所以可靠,是因为它是内核的账本,不是应用的账本。迁移这种一次性、不可回滚的操作,永远只信内核。