稀疏普通文件可以具有很大的逻辑长度,却只为其中部分地址范围分配存储。读取长度范围内的空洞会得到零字节,因此文件长度、字节内容和文件系统分配,是三项不同测量。为虚拟磁盘预算容量、复制备份,或者判断零区域是否已为将来写入保留空间时,都需要理解这种关系,不能用其中一个数字替代另外两个。

坏掉的样子

seek 越过文件结尾,只改变描述符偏移,不改变文件长度。后续写入或显式扩展,才可能创建中间读为零的范围。只调用 seek,就声称已经创建大型稀疏文件,是错误的;同样,逻辑大小很大,也不表示文件系统已经预留足够块,允许以后填满它。偏移位置、逻辑范围与实际容量承诺必须分开。

如果复制读取每个字节并普通写出,就可能把空洞变成分配了块的零区域。最终校验值完全相同,分配空间却大得多。传输或恢复流程若只依据源文件当前分配量做预算,即使正确保留逻辑内容,也可能耗尽目标容量。因此成功复制字节与保持稀疏表示,是需要分别验证的目标。

打洞是有意改变数据的操作。配合 KEEP_SIZE 对已分配范围打洞,会保持长度,却让对应字节读为零,并不是可以随手清理任意文件区域的无害命令。预分配解决另一类问题:它保留文件系统分配,即使未写入的已分配范围也可能读为零。内容都是零,不能证明这两种表示具有相同空间行为。

快速验证

把脚本保存为 sparse.py,在一次性 Linux 系统中使用 Python 3.11 或更新版本运行 python3 sparse.py,临时目录需位于 ext4。实测使用 Linux 6.12.76 和 Python 3.11.2。样本创建三个临时文件,使用约三十二 MiB 完整分配数据,以及少量稀疏分配:

from pathlib import Path
from tempfile import TemporaryDirectory
import ctypes,hashlib,os

size=16*1024*1024;first=4*1024*1024;second=12*1024*1024
libc=ctypes.CDLL(None,use_errno=True);libc.fallocate.argtypes=[ctypes.c_int,ctypes.c_int,ctypes.c_longlong,ctypes.c_longlong];libc.fallocate.restype=ctypes.c_int
def digest(path):
    with path.open('rb') as handle:return hashlib.file_digest(handle,'sha256').hexdigest()
with TemporaryDirectory(prefix='xbcatm-sparse-') as directory:
    root=Path(directory);path=root/'sparse';fd=os.open(path,os.O_RDWR|os.O_CREAT|os.O_EXCL,0o600)
    try:
        os.lseek(fd,size,os.SEEK_SET);assert os.fstat(fd).st_size==0
        os.ftruncate(fd,size);assert os.fstat(fd).st_size==size and os.fstat(fd).st_blocks==0
        assert os.pread(fd,16,0)==b'\0'*16
        os.pwrite(fd,b'A',first);os.pwrite(fd,b'B',second);os.fsync(fd)
        allocated=os.fstat(fd).st_blocks*512;assert allocated<size
        data_start=os.lseek(fd,0,os.SEEK_DATA);hole_start=os.lseek(fd,data_start,os.SEEK_HOLE)
        assert data_start==first and first<hole_start<=second
        print('seek alone leaves size zero; truncate creates zero-reading holes; sparse size',size,'allocated',allocated)
        print('first SEEK_DATA',data_start,'following SEEK_HOLE',hole_start)
        dense=root/'dense'
        with path.open('rb') as source,dense.open('wb') as target:
            while chunk:=source.read(65536):target.write(chunk)
            target.flush();os.fsync(target.fileno())
        assert digest(path)==digest(dense)
        assert dense.stat().st_blocks*512>=size
        print('explicitly writing all copied bytes preserves content but allocates the zero-filled ranges')
        result=libc.fallocate(fd,0x01|0x02,first,4096)  # KEEP_SIZE | PUNCH_HOLE
        assert result==0,os.strerror(ctypes.get_errno())
        os.fsync(fd)
        assert os.fstat(fd).st_size==size and os.pread(fd,1,first)==b'\0'
        assert os.pread(fd,1,second)==b'B' and os.fstat(fd).st_blocks*512<allocated
        print('punching the aligned data range releases its blocks and makes it read zero without changing file length')
        reserved=root/'reserved'
        with reserved.open('w+b') as handle:
            os.posix_fallocate(handle.fileno(),0,size);os.fsync(handle.fileno())
            info=os.fstat(handle.fileno());assert info.st_size==size and info.st_blocks*512>=size
            assert handle.read(16)==b'\0'*16
        print('preallocation reserves blocks even while the newly allocated range reads as zeros')
    finally:os.close(fd)

样本先在空文件中 seek 到十六 MiB,确认大小仍为零。随后 truncate 到这个长度,在本次 ext4 环境中得到逻辑十六 MiB、尚无已分配数据块的文件,读取开头为零。这个对照直接区分移动偏移与扩展文件,而不是依据 seek 返回了一个大数字,就推测文件内容范围已经改变。

在四 MiB 和十二 MiB 分别写入一个字节后,两个小区域获得分配。同步后,记录的逻辑长度为一千六百七十七万七千二百一十六字节,报告分配量为八千一百九十二字节。Linux 的 st_blocks 按五百一十二字节单位换算,不能误当成文件系统较大的块大小,否则计算出的占用会出现偏差。

记录中首次 SEEK_DATA 为四百一十九万四千三百零四,后续 SEEK_HOLE 为四百一十九万八千四百。这些值描述第一个写入字节附近的 ext4 区域,不是只标记那个非零字节。数据区间里也可以含零,因此区间报告不能替代查找非零内容,两种问题应使用各自合适的观察方法。

脚本通过普通写入把全部逻辑字节复制到第二个文件,摘要与稀疏源一致,分配量却覆盖整个逻辑长度。这个受控密实复制展示扩张风险,没有声称每种现代复制工具或文件系统都会用相同方式实体化空洞。评估真实迁移时,需要测试实际工具、选项和目标端,而不是泛化一次普通复制结果。

接着对齐打洞移除第一个已分配数据区域,原来的 A 变成零,远处 B 保留,文件长度不变,分配块减少。第三个文件通过预分配保留十六 MiB 空间,开头内容仍读为零。两项结果分别说明打洞会改变有效字节,以及零内容本身无法区分空洞和已经预留的分配。

为需要的表示做预算并保留它

应在真实归档、传输和恢复之后,同时检查逻辑大小与分配块。感知稀疏的选项可能保留空洞,压缩、reflink 和文件系统专用复制又可能建立其他存储关系。文件系统共享或压缩区间时,单文件报告的分配量,也不应直接解释成它独占的物理设备用量,需要结合具体存储机制理解。

需要为未来分配保留空间时,应使用目标文件系统文档规定的预分配契约。仅被 truncate 扩大的虚拟镜像,之后仍可能增加实际占用,并在共享池满时失败。容量规划要考虑预期填充规模和竞争分配,不能只看创建时一个很小、看起来很省空间的数字,就认为后续写入预算已经落实。

SEEK_DATA 与 SEEK_HOLE 提供的是文件系统报告的区间信息,实现允许保守报告空洞,例如把更大范围视为数据。依赖精确偏移前,应验证支持和行为。打洞或复制区间时,首先保留应用预期字节;只有结果数据与分配关系符合操作目的,节省空间才有实际价值,不能为了较小数字破坏内容。

来源

  • lseek(2),定位偏移、SEEK_DATA/SEEK_HOLE 与文件系统报告边界,已核验 2026-09-12。
  • truncate(2),扩展逻辑长度并使新增区域读为零,已核验 2026-09-12。
  • fallocate(2),预分配与保持逻辑大小的打洞,已核验 2026-09-12。
  • Linux 6.12 fallocate UAPI,样本使用的 KEEP_SIZE 与 PUNCH_HOLE 标志,已核验 2026-09-12。