1GB 的文件只占 8KB:稀疏文件的洞,以及拷贝时怎么不把它填满
1GB 的文件只占 8KB:稀疏文件的洞,以及拷贝时怎么不把它填满
做镜像、日志预分配或者虚机磁盘时,会碰到一个让 ls 和 df 对不上的现象:文件明明显示 1.0G,占用却只有几 KB。这不是统计错误,而是稀疏文件(sparse file)。本文把实测过程写清楚,并给出拷贝、打包、写中间数据时保持或放弃稀疏的具体手段。以下所有数字来自本地隔离临时目录的真实执行,未连接任何生产存储;本文为原创整理,使用 AI辅助进行实验设计、命令执行与文字检查。
先分清三个"大小"
同一个文件在不同工具里会看到三个不同的量,混用就会误判:
- 表观大小(
stat的st_size、ls -l):文件的逻辑长度,稀疏文件也按这个算。 - 实际分配(
stat的st_blocks× 512):真正落盘的块数。注意单位是 512 字节,不是 4K,直接用会算错。 - 目录遍历占用(
du默认):按分配块累计;加--apparent-size才变成表观大小。
实测:truncate -s 1073741824 s.img 之后,stat 给出 size=1073741824 blocks=0,即表观 1GiB、实际分配 0。此时 du -h s.img 是 0,而 du -h --apparent-size s.img 是 1.0G;ls -l 只显示前者那个逻辑长度。
为什么 0 块?因为 truncate 只改元数据,不写入数据块,文件里全是"洞"。读取这些洞会返回零字节,但内核不需要真的去读盘。
写一点数据后,占用只增加那一小块
在偏移 100MB 和 700MB 各写 4096 字节,再查:
after truncate: size=1073741824 blocks=0 alloc=0
after 2 writes: size=1073741824 blocks=16 alloc=8192
两次 4K 写入一共 8192 字节,st_blocks 正好是 16(16 × 512 = 8192)。文件仍然是 1GiB 逻辑长度,但只占 8KB。这说明写入是按页分配的,未触碰的区域继续保持为洞。
用 SEEK_DATA / SEEK_HOLE 直接看洞的分布
不想猜的话,可以用 lseek 的两个扩展 whence 遍历数据段与洞段。Python 里 os.SEEK_DATA 和 os.SEEK_HOLE 直接可用:
import os
size=os.stat('disk.img').st_size
fd=os.open('disk.img',os.O_RDONLY); off=0; segs=[]
while off<size:
try: d=os.lseek(fd,off,os.SEEK_DATA)
except OSError: break
h=os.lseek(fd,d,os.SEEK_HOLE); segs.append((d,h-d)); off=h
os.close(fd)
# segments == [(104857600, 4096), (734003200, 4096)]
输出与写入位置一一对应:100MiB 处 4096 字节数据,700MiB 处 4096 字节数据,其余全部是洞。这个方法比解析 filefrag 的文本输出更稳,也不依赖文件系统工具版本;代价是必须用能返回 SEEK_DATA 的文件系统(ext4、xfs、btrfs、tmpfs 都支持)。
真正危险的是"普通拷贝"
洞不会自动跟着文件走。多数按字节流写的拷贝方式会把整段零也读出来再写下去,于是 1GiB 全部落盘。实测同一份文件(2 个 4K 数据段,其余为洞)用不同方式复制,哈希全部一致,但占用差了两个数量级:
disk.img apparent=1073741824 blocks=16 alloc=8192
cp (默认) apparent=1073741824 blocks=16 alloc=8192
cp --sparse=never apparent=1073741824 blocks=2097232 alloc=1073782784
rsync -aS apparent=1073741824 blocks=16 alloc=8192
rsync (无 -S) apparent=1073741824 blocks=2097208 alloc=1073770496
dd conv=sparse apparent=1073741824 blocks=4096 alloc=2097152
python shutil.copyfile apparent=1073741824 blocks=2097224 alloc=1073778688
几个可以直接抄走的结论:
- GNU
cp默认会检测全零块并保持稀疏,所以cp通常是安全的;加--sparse=never才会强制写满,这一条在把镜像交付给不支持稀疏的目标(比如某些网络存储)时才需要。 rsync的-S(--sparse)是关键开关。本地同机 rsync 默认也是整份数据过一遍,不加-S就把洞填实了。dd必须显式写conv=sparse;不加就是整份写。- Python 的
shutil.copyfile、以及手写的read(1MB)/write循环,都属于逐字节流式复制,会把洞填成实块。这一点在写备份脚本时最容易踩。
打包:tar 的默认行为差别很大
打包是第二个高频误伤点。普通 tar -cf 会把零数据真的写进归档,--sparse 则按 GNU 稀疏格式记录洞:
tar -cf plain.tar disk.img # 归档大小 1073745920(约 1GiB)
tar --sparse -cf sparse.tar disk.img # 归档大小 10240(10KiB)
tar --sparse -xf sparse.tar -C out # 解出后 blocks=16,稀疏被保留
解包时这里有个容易忽略的点:tar --sparse 解出来的文件仍然是稀疏的(blocks=16,占用 8KB),内容哈希与原件一致。归档体积从 1GiB 降到 10KiB,传输和存储成本完全不同。
什么时候该主动放弃稀疏
稀疏不是越稀疏越好,有三类场景要反着做:
- 目标不支持稀疏:部分网络文件系统、某些对象存储网关、以及经由 POSIX 层封装的同步工具,会把洞读成零再上传,流量和费用按表观大小算。这时提前
--sparse=never反而能避免中途爆量。 - 随机写密集的数据库文件:预分配的价值是避免运行期碎片和扩容抖动,
fallocate一次真实占位比 truncate 更合适。实测fallocate -l 64M alloc.img得到blocks=131088(约 64MiB 实分配),与 truncate 的 0 块形成对照。 - 需要按表观大小计费或校验容量的流程:备份系统如果按
st_size估算用量,稀疏文件会让估算严重偏高,需要改成读st_blocks。
一个可直接用的判断函数
把上面的规则收成一个只读检查,用来在拷贝前后判断是否被填实:
def footprint(path):
st = os.stat(path)
return {'apparent': st.st_size, 'allocated': st.st_blocks * 512,
'ratio': round(st.st_blocks * 512 / st.st_size, 6) if st.st_size else None}
对一个 1GiB 的稀疏镜像,正常结果应该是 ratio 极小(本例 8192/1073741824 ≈ 0.0000076)。如果拷贝后发现 ratio 接近 1,说明这一份已经是实心文件,要回头检查是用 cp、rsync -S、tar --sparse 还是流式写入。
边界说明
以上只在本地 ext4 上做过实测,没有覆盖 btrfs 的 reflink 共享计费、压缩文件系统的特殊语义,也没有覆盖跨网络传输时中间层的处理。不同实现(GNU coreutils、busybox、BSD tar、Python 各版本)对稀疏的默认策略并不一致,不能据本文外推。另外,稀疏只减少零字节的物理占用,不减少逻辑长度,也不提供任何加密或访问控制;需要保护内容仍然要靠权限与加密,不要指望"洞"能隐蔽数据。
本文所有命令与输出都可以在隔离临时目录复现,未做结果修饰。
- 点赞
- 收藏
- 关注作者
评论(0)