朕喜技术(ZhenxiJS)
2026.09.17
1 分钟阅读

稀疏文件(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 扩展头(typeflag S / 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 的同类问题

工具朴素用法(会实体化)保稀疏用法
cpcp -a src dstcp -a --sparse=always src dst
rsyncrsync -a src dstrsync -aS(--sparse)
tartar -cf a.tar dirtar --sparse -cf a.tar dir
dddd if=img of=outdd 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=272 KiB,rc=0
恢复后物理占用5.5 GiB3.6 MiB
备份放大倍数(大屏)6x ~ 200x1.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   ← 守卫正确拦下,不再静默放大 / 写满备份盘

六、经验与避坑

  1. du 和 du --apparent-size 必须一起看。 只看表观会以为「磁盘要满了」;只看物理会以为「备份没问题」。 两个数差几十万倍,说明是稀疏文件,备份链路要专门处理。
  2. 归档体积小 ≠ 稀疏性保住了。 gzip 压 0 的压缩率极高,5.6 GiB 能压到 5.5 MiB。 判断稀疏性要看 ① 未压缩 tar 流体积(gzip -dc a.tgz | wc -c) ② 解包后 st_blocks / du。本题的 E1 断言在问题态也是 PASS,就是这个陷阱。
  3. 压缩路径「不报错」比非压缩路径「报错」更危险。 非压缩是响亮的 ENOSPC,压缩是静默的假性成功 —— 后者可能潜伏几个月, 直到某次真的要恢复才发现恢复盘装不下。
  4. 修复要在打包侧。恢复侧加 --sparse 是防御性写法(防转存链路丢 SF 头), 但救不了打包时已经写实的归档。
  5. 不要让「能力探测」依赖参数默认值写法。 用 ${VAR:-默认} 时,VAR=""(显式空)会被当成未设置 → 拿到默认值, 于是「故意关掉 --sparse」的降级/测试场景失效,守卫测不出问题。 要区分「未设置」与「显式为空」就用 ${VAR-默认}(单短横)。
  6. 打包脚本要有「保护性断言」,而不是只校验可解压。 原脚本只做 tar -tzf(能不能列目录),这在不丢数据的前提下必然通过。 要新增的是「稀疏性断言」:解出来物理占用是否仍远小于表观。
  7. 文件系统/挂载点不一定支持 SEEK_HOLE。 overlayfs、某些网络文件系统、老内核可能返回 ENXIO 或不支持。 代码要优雅退化,并允许用 TAR_SPARSE_OPT 之类的开关降级 + 告警。
  8. tmpfs 当备份盘时 down 必须带 -v。 否则 named volume / 残留标记会让现场没重建,出现「我明明清过怎么还有旧数据」的错觉。
  9. 这条坑在真实场景里很常见:qcow2/raw 虚拟磁盘镜像、 qemu-img create / fallocate 预分配的大文件、PostgreSQL 预分配表空间、 跳着写的日志、容器镜像层 —— 都容易出现「表观巨大 / 物理极小」。 备份、迁移、归档链路都要显式开启稀疏保持。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-09-17-0800-tar-sparse

分享这篇文章