备份 6 分钟跑完、恢复时半个文件是空洞:大带宽服务器 tar|ssh 与 rsync 的五个"伪成功"陷阱

上周帮一个客户做灾备演练,场景很典型:一台 10G 口的大带宽服务器,2.3TB 业务数据,每天凌晨 3 点跑备份脚本,日志里天天写着 backup done, exit 0,耗时 6 分 40 秒。结果真要恢复的时候,解包出来一半文件是空洞,另有一批文件大小对但内容是 0。负责人反复强调"我们有备份,而且很快"。

快,恰恰是问题的一部分。带宽从 1G 升到 10G,备份窗口从 4 小时压到 6 分钟,把所有原本能在慢速传输中"自然暴露"的坑全都藏起来了。下面这五个坑,每一个我都见过真实翻车。

一、退出码 0 是最贵的谎言:管道只回报最后一环

新手写备份第一反应就是这行:

tar -C /data -cf - . | pv | ssh backup@10.0.0.9 'cat > /backup/data.tar'
echo $?   # 0

你以为 $? 代表"备份成功"。实际上它只代表 ssh 这一环的退出码。

现在源盘某块扇区读失败,或者某个 NFS 挂载点超时,tar 会打印 Cannot read: Input/output error 然后以退出码 2 挂掉。但 tar 一挂,管道写端关闭,pv 读到 EOF 正常退出,ssh 那端的 cat 读到 EOF 也正常退出——整条管道返回 0。备份文件比预期小了几十上百 GB,而你的监控系统显示一切正常。

修法不是 "set -e",而是 pipefail + 显式区分 tar 的退出码语义:

set -uo pipefail

tar -C /data -cf - . 2>/tmp/tar.err | ssh backup@10.0.0.9 'cat > /backup/data.tar'
rc=$?
case $rc in
  0) logger -t backup "OK" ;;
  1) logger -t backup "WARN: tar exit 1, file changed while reading" ;;
  *) logger -t backup "FATAL: rc=$rc"; cat /tmp/tar.err; exit $rc ;;
esac

tar 的退出码 1 不是失败,它的含义是"读取过程中文件被修改了",在备份热点目录时几乎必然出现。如果你用 set -e 或者 [ $? -ne 0 ] && exit,那么备份脚本每天都会误报失败,运维习惯性忽略告警——这比不报警更危险。退出码 2 才是致命错误。

二、10G 带宽让 page cache 变成了骗子

这是大带宽服务器最典型的翻车方式。你在远端跑 cat > file,数据全速灌进内核 page cache,cat 一秒钟都不阻塞地退出,ssh 返回 0。此时 数据还在内存里,回写线程队列还排着。而你的脚本已经打上了"备份完成"的标记。

现场验证一下这个现象(在内核 5.x + ext4 上):

dd if=/dev/zero of=/backup/big.bin bs=1M count=204800 &
sleep 2
ls -l  /backup/big.bin              # 显示 214748364800
du -h --apparent-size /backup/big.bin   # 200G
du -h /backup/big.bin                   # 只有几 G,剩下全是"已分配未落盘"

如果这时候机器 reset、电源抖动、或者 kill -9 掉进程,你拿到的备份文件 ls -l 显示大小完全正确,内容却是半截。ext4 的 delayed allocation 让这个坑极其隐蔽。

所以远端写入必须落盘确认:

ssh backup@10.0.0.9 bash -s <<'REMOTE'
set -euo pipefail
TMP=/backup/.data.tar.part
cat > "$TMP"
sync "$TMP"          # coreutils 8.24+,对文件参数底层走 fsync(2)
mv -f "$TMP" /backup/data.tar
REMOTE

注意两点:先写 .part 再 mv,避免半截文件被误认为完整备份;用 sync <file> 而不是裸 sync,裸 sync 在老内核上返回成功不代表数据已经落到盘片,而且它会全系统刷盘,在 10G 口满速写入时把整机 IO 拖死。

然后是校验环节,这里还有第二个坑——你校验时读到的是 page cache,等于没校验。必须绕过缓存读:

# 远端用 O_DIRECT 读回算哈希,真正穿透到磁盘
ssh backup@10.0.0.9 \
  "dd if=/backup/data.tar iflag=direct bs=8M 2>/dev/null | sha256sum"

如果目标文件系统不支持 O_DIRECT(比如某些 CIFS/NFS 挂载),退回 sync && echo 3 > /proc/sys/vm/drop_caches 再校验,但要知道这个操作在业务高峰期是核弹,别在生产主机上随手执行。

三、稀疏文件把 10G 口吃满,还赖带宽不够

这个坑在虚拟机宿主、容器镜像仓库、数据库表空间目录里几乎必然遇到:

ls -lh /data/vm/disk.img      # 200G
du -h  /data/vm/disk.img      # 8.2G

200G 的文件只占 8.2G 磁盘,中间全是空洞。tar 默认不认稀疏,会把每个空洞当成真实的零字节读出来发到网线上。于是 8.2G 的数据传了 200G,10G 口跑 3 分钟,你以为"带宽真香"。等哪天备份盘满了,或者链路只有 1G,同样一份数据要传 27 分钟,你回头骂供应商带宽虚标。

# 打包端显式开稀疏
tar --sparse -C /data -cf - vm | ssh backup@10.0.0.9 'cat > /backup/vm.tar'

# rsync 侧:-S 保证接收端保持稀疏,配合 zstd 压掉零块
rsync -aS --compress-choice=zstd --compress-level=1 \
      --info=progress2 /data/vm/ backup@10.0.0.9:/backup/vm/

顺手给你一段扫描脚本,先搞清楚哪些文件是稀疏的,别盲目调优:

find /data -xdev -type f -size +1G -printf '%s\t%p\n' | while IFS=$'\t' read -r sz p; do
  real=$(du -k "$p" | cut -f1)
  app=$(( sz / 1024 ))
  if [ $(( app - real )) -gt 1048576 ]; then
    printf 'SPARSE  apparent=%sM  disk=%sM  %s\n' \
      $(( app / 1024 )) $(( real / 1024 )) "$p"
  fi
done

另外记住:cp 加上 --sparse=always,dd 加上 conv=sparse。恢复演练时如果没开稀疏,把 8G 的镜像还原成 200G 实体文件,存储池当场爆掉——这种"备份成功、恢复失败"的案例我见过不止一次。

四、rsync 的 quick check 只看 size + mtime,不看内容

rsync 默认的增量判定是 size + mtime(秒级),这在这里会漏三个场景:

  • 同一秒内被改写、且大小没变的文件。数据库数据页原地更新就是这么干的,改了 8KB 页,文件大小一点没变,mtime 精度到秒,rsync 判定"没变"。
  • 被 touch -r / cp -p / 解压工具恢复了旧 mtime 的文件。新内容配旧时间戳,增量备份永远跳过它。
  • 通过 NFS、overlayfs、容器 bind mount 写入的文件,mtime 精度在中间层被截断。

于是有人直接上 -c 全量校验。在 10G 口的环境下这一刀切得自己很疼——rsync 的 -c 是两端都读全文算哈希,单核 sha256 大概只有 400~600 MB/s,10G 口理论值 1.2GB/s,CPU 单核先打满,带宽反而闲置,备份窗口从 6 分钟涨到 2 小时。

# 单核实测,心里有数再选方案
openssl speed -evp sha256
# rsync 3.2+ 换成 xxhash,速度差一个数量级
rsync -a --checksum-choice=xxh128 /data/ backup@10.0.0.9:/backup/data/

更隐蔽的一条:带 --delete 的 rsync 在源挂载点掉线时会清空备份。曾经有台机器 /data 是独立盘,某次内核升级后 fstab 没挂上,脚本照常跑,源目录变成了空目录,--delete 老老实实把 6TB 备份删干净了。加一行保命:

mountpoint -q /data || { echo "FATAL: /data not mounted"; exit 1; }

五、备份自己的 IO 优先级,别把生产拖下水

大带宽带来的错觉是"备份不是瓶颈"。但备份的读取端在生产盘上——tar 的顺序读会把盘带跑到饱和,业务的随机写 P99 直接从个位数毫秒飙到几百毫秒,而且 10G 口吞吐越高,内存里堆积的脏页越多,回写压力越集中。

# 传统方案
ionice -c2 -n7 nice -n19 tar --sparse -C /data -cf - . | ssh ...

# cgroup v2 更靠谱,把备份的 IO 权重压到最低,内存也框住
systemd-run --scope --unit=backup-job \
  -p IOWeight=10 -p CPUWeight=20 -p MemoryMax=8G \
  -- ionice -c2 -n7 tar --sparse -C /data -cf - . | ssh ...

如果还是抢得厉害,就在发送侧限速,让备份吃不满 10G。rsync 的 --bwlimit 是用户态 sleep 实现的,多小文件场景不准,但大文件迁移够用,单位是 KiB/s:

rsync -aS --bwlimit=512000 /data/ backup@10.0.0.9:/backup/data/   # 约 500MB/s

内核级限速用 tc,但注意 tbf 对 10G 这种高速率整形精度很差,burst 给不够会把吞吐打成碎片:

tc qdisc add dev eth0 root tbf rate 4gbit burst 1mbit latency 50ms
tc -s qdisc show dev eth0    # 看 dropped / overlimits 再决定怎么调

六、一份能过恢复演练的备份骨架

把上面五条揉进一个脚本,别嫌它啰嗦——灾备演练当天你会感谢自己:

#!/usr/bin/env bash
set -uo pipefail
IFS=$'\n\t'

SRC=/data
DST_HOST=backup@10.0.0.9
TMP=/backup/.data.tar.part
FIN=/backup/data.tar

mountpoint -q "$SRC" || { echo "FATAL: $SRC not mounted"; exit 1; }

rc=0
ionice -c2 -n7 nice -n19 \
  tar --sparse --one-file-system -C "$SRC" -cf - . 2>/tmp/tar.err \
| ssh -o ServerAliveInterval=30 -o ServerAliveCountMax=6 "$DST_HOST" bash -s <<REMOTE || rc=$?
set -euo pipefail
cat > $TMP
sync $TMP
mv -f $TMP $FIN
REMOTE

case $rc in
  0) ;;
  1) echo "WARN: tar exit 1 (file changed as we read it)" ;;
  *) echo "FATAL: pipeline rc=$rc"; cat /tmp/tar.err; exit $rc ;;
esac

# 穿透 page cache 读回校验
LOCAL=$(tar --sparse -C "$SRC" -cf - . | sha256sum | cut -d' ' -f1)
REMOTE_HASH=$(ssh "$DST_HOST" \
  "dd if=$FIN iflag=direct bs=8M 2>/dev/null | sha256sum | cut -d' ' -f1")

if [ "$LOCAL" != "$REMOTE_HASH" ]; then
  echo "FATAL: hash mismatch local=$LOCAL remote=$REMOTE_HASH"
  exit 2
fi
echo "OK: $(date -Is) verified"

这套东西在轻云互联那批 10G 口大带宽机器上跑通宵备份窗口是很宽裕的,网络侧基本不会是瓶颈,真正决定"备份能不能用"的永远是落盘、校验和退出码这三件事。带宽是最容易被量化的那一环,也是唯一不会骗你的那一环——其余全靠脚本自己说实话。

最后一句忠告:备份不经过一次完整的恢复演练,就等于没有备份。而恢复演练的第一步,是先把上面这些伪成功挖出来。