稀疏文件(sparse file)备份后体积暴涨、备份盘写满:tar 把空洞实体化,加 --sparse 解决
日期:2026-09-17 分支:
2026-09-17-0800环境:Debian 12 + GNU tar 1.34 + gzip,XFS + tmpfs 关键词:sparse file、稀疏文件、空洞 hole、SEEK_HOLE、SEEK_DATA、tar --sparse、 GNU sparse 扩展头 SF、st_blocks、du --apparent-size、tar: Wrote only 4096 of 10240 bytes、tar: Error is not recoverable: exiting now、No space left on device、备份放大倍数、qcow2/raw 虚拟磁盘镜像、fallocate预分配、cp --sparse=always、rsync --sparse、备份后体积变大、恢复后稀疏性丢失
一、问题背景
一台备份服务器上跑着 backup-svc,每天用 tar 把 /srv/data 打包到备份盘 /backup。
/srv/data 里放的是「虚拟磁盘镜像 + 预分配的数据库数据文件 + 跳着写的应用日志」——
都是稀疏文件(sparse file):表观大小很大,真实物理占用极小。
某天运维按「源物理占用 / 备份盘消耗」的口径看大屏,发现数据对不上:
源目录表观大小:5.6 G
源目录物理占用:64 K <-- 备份盘真正该消耗的量
备份盘实际消耗:256 M 100% <-- 撑爆了
压缩路径下更迷惑:归档只有 5.5 MiB,tar -tzf 校验通过,看起来一切正常;
可一把它解开,64 KiB 的数据变成了 5.5 GiB。
切到非压缩路径(为了提速加的那条)就直接硬故障:
tar: /backup/data-XXXXXXXX.tar: Wrote only 4096 of 10240 bytes
tar: Error is not recoverable: exiting now
[backup][ERROR] tar 失败(rc=2):备份盘写满,归档不完整!
典型特征(都符合,可据此快速判断是本题):
- 源目录
du -sh只有几十 KiB,du -sh --apparent-size却有若干 GiB; - 归档体积很小(gzip 压
0很在行),但解包后物理占用暴涨; md5一致、内容一个字节没丢,不是文件损坏;- 换成
cp -a、scp、rsync -a搬这批文件,同样会变大。
二、复现步骤
环境自带 docker compose,一键拉起(/backup 故意只有 256 MiB tmpfs):
cd scenario/2026-09-17-0800
docker compose up -d --build
docker compose exec app bash # entrypoint 已生成现场数据
① 确认「真稀疏」
du -sh --apparent-size /srv/data # 5.6G
du -sh /srv/data # 64K
stat -c '表观=%s 物理块=%b(个512B)' /srv/data/vm/vm-root.img
5.6G /srv/data
64K /srv/data
/srv/data/vm/vm-root.img 表观=2147483648 物理块=24×512 ← 2 GiB 表观 / 12 KiB 物理
st_size ≫ st_blocks × 512 就是稀疏文件的定义式指纹(这里比值约 90112:1)。
② 压缩备份:不报错,静默失败
bash /srv/ops/run_backup.sh
[backup] 源目录物理占用:64K
[backup] 归档体积:5 MiB
[backup] 校验通过 ✅(内容没丢 —— 但稀疏性已经丢了)
③ 恢复:稀疏性全丢
bash /srv/ops/run_restore.sh # 恢复后物理占用:5.5G
du -sh /restore/data # 5.5G
gzip -dc /backup/data-*.tar.gz | wc -c # 5905592320 ← 未压缩 tar 流 5.6 GiB
④ 判定脚本拿到问题态基线
bash /srv/ops/verify.sh
# 结果:PASS=5 FAIL=3
# [FAIL] B1 归档解开后物理占用 5.5G ≥ 表观总量 1/10(空洞被实体化)
# [FAIL] B2 未压缩 tar 流 5.6G ≥ 表观总量 1/10(空洞被字面展开)
# [FAIL] D1 恢复后物理占用 5.5G ≥ 表观总量 1/10(稀疏性丢失)
⚠️ 注意此时 E1(备份盘消耗)也是 PASS —— 因为 gzip 把 5.6 GiB 的
0压到了 5.5 MiB。 「压缩比好」≠「稀疏性保住了」,这是最容易误判的地方。
⑤ 非压缩备份:备份盘写满
bash /srv/ops/run_backup_raw.sh
tar: /backup/data-*.tar: Wrote only 4096 of 10240 bytes
tar: Error is not recoverable: exiting now
[backup][ERROR] tar 失败(rc=2):备份盘写满,归档不完整!
[backup][ERROR] 现场:256M / 256M used
三、根本原因
3.1 逐字节对照:--sparse 与否差 82000 倍
tar -cf /tmp/no-sparse.tar -C /srv data
tar --sparse -cf /tmp/sparse.tar -C /srv data
stat -c '%n %s bytes' /tmp/no-sparse.tar /tmp/sparse.tar
/tmp/no-sparse.tar 5905592320 bytes ← 空洞被字面展开成 5.9 GB 的 0
/tmp/sparse.tar 71680 bytes ← 空洞以 GNU sparse 扩展头(SF)表示
3.2 机制
Linux 文件系统里,稀疏文件的空洞(hole)在语义上「读出来是 0,但不占磁盘块」。
内核通过 lseek(2) 的两个 whence 告诉用户态「哪一段是洞」:
os.lseek(fd, 0, os.SEEK_DATA) # 从偏移 0 起,下一个有数据的位置
os.lseek(fd, 0, os.SEEK_HOLE) # 从偏移 0 起,下一个空洞的位置
实测(/srv 是 XFS,支持):
st_size = 2147483648
SEEK_DATA(0) = 0 # 0 处有数据(镜像头)
SEEK_HOLE(0) = 8192 # 8 KiB 后是第一个洞
而 tar 默认不走这条路径:
- 不带
--sparse:tar 只按st_size顺序read(),空洞被内核填成真实\0, tar 把它们原样写进 tar 流 → 归档里是「一长串真实 0」。 - 带
--sparse:tar 先对每个文件做SEEK_HOLE/SEEK_DATA扫描, 在流里写入 GNU sparse 扩展头(typeflagS/GNUSparseFile), 只记「数据段 + 每个洞的 offset/length」。读取端凭这些元数据重建空洞。
3.3 为什么压缩路径「静默」、非压缩路径「硬故障」
根因完全相同,只是暴露方式不同:
- 压缩路径:tar 流确实膨胀到 5.6 GiB,但管道里被 gzip 立刻压掉(
0压缩率极高), 落盘只有 5.5 MiB → 不报错 → 假性成功 → 直到恢复才爆。 - 非压缩路径:膨胀的 tar 流直接落盘 → 256 MiB 备份盘撑不住 →
ENOSPC。
3.4 关键认知:决定稀疏性的是打包侧,不是恢复侧
GNU tar 读取一个带 SF 头的归档时,解包即使不加 --sparse 也会正确还原空洞 ——
因为它读到了「哪些区间是洞」的元数据。
反过来说,恢复侧再怎么加 --sparse,也救不了「打包时已经把洞写实了」的归档。
四、解决办法
① 打包侧加 --sparse(关键)
# 压缩路径
tar --sparse -czf "$archive" -C "$(dirname "$DATA_DIR")" "$(basename "$DATA_DIR")"
# 非压缩路径
tar --sparse -cf "$archive" -C "$(dirname "$DATA_DIR")" "$(basename "$DATA_DIR")"
GNU tar ≥ 1.15 即支持。
② 打包前做「稀疏能力自检」
老 tar / 某些 busybox tar 变体可能没有 --sparse 或静默忽略它,
那样就无声退化成实体化打包。造一个真稀疏文件试打一次,用流体积反推是否生效:
tar_sparse_supported() {
probe=$(mktemp -d)/probe.bin; out=$(mktemp -d)/probe.tar
truncate -s 8M "$probe" # 表观 8 MiB / st_blocks = 0
[ "$(stat -c %b "$probe")" = 0 ] || return 0 # 文件系统不支持空洞 → 告警放行
tar $TAR_SPARSE_OPT -cf "$out" -C "$(dirname "$probe")" "$(basename "$probe")"
[ "$(stat -c %s "$out")" -lt $(( $(stat -c %s "$probe") / 10 )) ]
}
不通过就拒绝打包,而不是默默把数据放大 8 万倍。
③ 打包后校验「归档真的带 SF 头」
用「解开后物理占用」判断,不要用归档体积(后者会被 gzip 骗):
# 解到临时目录,du -sB1 物理占用 必须 < 表观总量 1/10
④ 恢复侧仍显式加 --sparse + 恢复后自检
tar --sparse -xzf "$ARCHIVE" -C "$RESTORE_DIR"
# 物理占用 ≥ 表观 1/10 就 exit 1,把静默丢失变成显式失败
显式加的意义:表达意图,并防止中间的转存链路(某些 tar 变体、归档网关) 把 SF 头丢掉后又被整读放大。
⑤ 顺带修掉非 tar 的同类问题
| 工具 | 朴素用法(会实体化) | 保稀疏用法 |
|---|---|---|
cp | cp -a src dst | cp -a --sparse=always src dst |
rsync | rsync -a src dst | rsync -aS(--sparse) |
tar | tar -cf a.tar dir | tar --sparse -cf a.tar dir |
dd | dd if=img of=out | dd conv=sparse |
virt-sparsify | — | 对 qcow2/raw 镜像做离线稀疏化 |
实测对照:
源 物理 64K
cp -a 物理 3.6M ← 被实体化
cp -a --sparse=always 物理 64K ← 保住
五、验证
# 同步修复后的文件进容器(源码目录直接改也可以)
docker compose cp app/backup_lib.sh app:/srv/app/backup_lib.sh
docker compose cp app/restore_lib.sh app:/srv/app/restore_lib.sh
docker compose exec app chmod +x /srv/app/backup_lib.sh /srv/app/restore_lib.sh
# 打包 + 恢复 + 判定
docker compose exec app bash -c 'rm -rf /backup/* /restore/*'
docker compose exec app bash /srv/ops/run_backup.sh
docker compose exec app bash /srv/ops/run_backup_raw.sh
docker compose exec app bash /srv/ops/run_restore.sh
docker compose exec app bash /srv/ops/verify.sh
判定结果:
[PASS] A1 源数据是稀疏文件(表观/物理 = 90112x ≥ 20x)
[PASS] A2 每个源文件自身都是稀疏的(物理占用 < 表观 10%)
[PASS] B1 归档解开后物理占用 3.6M < 表观总量 1/10(保持稀疏)
[PASS] B2 未压缩 tar 流 70K < 表观总量 1/10(空洞以稀疏头表示)
[PASS] C1 恢复后文件集合/表观大小与基线一致
[PASS] C2 恢复后内容 md5 与基线完全一致
[PASS] D1 恢复后物理占用 3.6M < 表观总量 1/10(稀疏性保住)
[PASS] E1 备份盘消耗 76K < 表观总量 1/10(体积没暴涨)
结果:PASS=8 FAIL=0
关键数字对比:
| 指标 | 修复前 | 修复后 |
|---|---|---|
| 压缩归档体积 | 5.5 MiB(解压后 5.6 GiB) | 623 B(tar 流 71,680 B) |
| 非压缩归档 | 写满 256 MiB,rc=2 | 72 KiB,rc=0 |
| 恢复后物理占用 | 5.5 GiB | 3.6 MiB |
| 备份放大倍数(大屏) | 6x ~ 200x | 1.19x |
| 内容 md5 | 一致 | 一致(未丢一字节) |
反向验证(故意关掉 --sparse,模拟老 tar):
TAR_SPARSE_OPT="" bash -c 'source /srv/app/backup_lib.sh; make_backup'
# [backup][ERROR] 本机 tar 不支持/无效 --sparse();
# [backup][ERROR] 继续打包会把 64K 的稀疏数据放大成表观 5.6G。
# rc=1 ← 守卫正确拦下,不再静默放大 / 写满备份盘
六、经验与避坑
du和du --apparent-size必须一起看。 只看表观会以为「磁盘要满了」;只看物理会以为「备份没问题」。 两个数差几十万倍,说明是稀疏文件,备份链路要专门处理。- 归档体积小 ≠ 稀疏性保住了。
gzip 压
0的压缩率极高,5.6 GiB 能压到 5.5 MiB。 判断稀疏性要看 ① 未压缩 tar 流体积(gzip -dc a.tgz | wc -c) ② 解包后st_blocks/du。本题的 E1 断言在问题态也是 PASS,就是这个陷阱。 - 压缩路径「不报错」比非压缩路径「报错」更危险。
非压缩是响亮的
ENOSPC,压缩是静默的假性成功 —— 后者可能潜伏几个月, 直到某次真的要恢复才发现恢复盘装不下。 - 修复要在打包侧。恢复侧加
--sparse是防御性写法(防转存链路丢 SF 头), 但救不了打包时已经写实的归档。 - 不要让「能力探测」依赖参数默认值写法。
用
${VAR:-默认}时,VAR=""(显式空)会被当成未设置 → 拿到默认值, 于是「故意关掉--sparse」的降级/测试场景失效,守卫测不出问题。 要区分「未设置」与「显式为空」就用${VAR-默认}(单短横)。 - 打包脚本要有「保护性断言」,而不是只校验可解压。
原脚本只做
tar -tzf(能不能列目录),这在不丢数据的前提下必然通过。 要新增的是「稀疏性断言」:解出来物理占用是否仍远小于表观。 - 文件系统/挂载点不一定支持
SEEK_HOLE。 overlayfs、某些网络文件系统、老内核可能返回ENXIO或不支持。 代码要优雅退化,并允许用TAR_SPARSE_OPT之类的开关降级 + 告警。 tmpfs当备份盘时down必须带-v。 否则 named volume / 残留标记会让现场没重建,出现「我明明清过怎么还有旧数据」的错觉。- 这条坑在真实场景里很常见:qcow2/raw 虚拟磁盘镜像、
qemu-img create/fallocate预分配的大文件、PostgreSQL 预分配表空间、 跳着写的日志、容器镜像层 —— 都容易出现「表观巨大 / 物理极小」。 备份、迁移、归档链路都要显式开启稀疏保持。
