☰
fallocate深度测评:文件系统预分配的快慢、虚实与坑
2026/10/21 0:11:54 网站建设 项目流程

1. 为什么值得给预分配做一次细粒度测试

fallocate 看起来实在太简单了:一个命令、一个路径、一个大小参数,文件的大小就“变”了。在本地测试机上跑 10G 的预分配,几毫秒弹回来,你很容易忽略它背后真正做的那些事——物理块是从哪里找出来的、读回来为什么是零、第一次写同一个位置时会不会更慢、快满盘时失败了会留下什么。这些恰恰是“文件系统预分配”里最值得较真的地方。我把 fallocate 放到不同文件系统、不同模式、不同写入条件下拆开测了一遍,这篇就是那份细粒度测试的完整记录。

先说清楚这个测试解决什么问题。生产环境里,我们经常用预分配来“抢地盘”:下载任务预留空间、虚拟磁盘一次性铺满大小、数据库表空间初始化时先占住容量。最常见的替代方案是 truncate 或者直接写全零文件。truncate 虽然快,但空间并没有真实落到磁盘上,首次写入会触发大量实时分配;直接写全零文件呢,速度慢到让人怀疑人生,但胜在空间是真实落地且内容是确定的。fallocate 号称兼具两边的优点,可它到底快在哪、占用是不是真的“预分配”、读零语义靠什么保证,这些如果不实测,心里始终没底。

这篇文章适合存储运维、后端开发、虚拟化平台维护者,也适合任何被“预分配后性能反而变差”这种问题折磨过的人。我会从测试坐标系的建立开始,一步步把时间、空间、内容、边界行为四个维度拆开,最后给出一份能直接复跑的脚本和常见坑排查表。

2. 测试环境、工具链与基线脚本

2.1 测试环境选择与挂载方式

细粒度测试最关键的不是命令本身,而是环境是否可控。我的测试机上准备了两块物理存储:一块普通 SATA SSD,一块 7200 转机械盘,都分成独立分区后格式化为 ext4 和 xfs 各一份。为什么同时覆盖两种文件系统?因为 fallocate 的具体实现是文件系统相关的。ext4 依赖 extent 机制配合 unwritten extent 标记,xfs 同样有自己的延迟分配与预分配逻辑,两者在失败回滚、碎片分布、写时转换上的表现并不一致。只在一个文件系统上得出结论,很容易被环境局限带偏。

挂载参数全部走默认,不开 nobarrier、不调 allocsize,因为这些参数本身会影响结果。如果你要复现,建议至少准备一个 ext4 和一个 xfs 分区,并记录内核版本和文件系统版本。命令如下:

mkfs.ext4 -F /dev/vdb1 mkfs.xfs -f /dev/vdc1 mount /dev/vdb1 /mnt/ext4 mount /dev/vdc1 /mnt/xfs

文件系统版本差异可能导致同一模式的不同行为,尤其是 KEEP_SIZE 和 PUNCH_HOLE 这类组合标志。测试前先确认支持情况:

fallocate --help | grep -i keep fallocate --help | grep -i punch

2.2 观测工具:从系统调用到块分布

基准测试不能只看一个命令的耗时,还要能回答“耗在哪”。我准备了四组观测工具。

第一组是时间观测。/usr/bin/time -v可以拿到 wall clock 和最大驻留内存,strace -T能精确打印每个系统调用的耗时,这对判断 fallocate 内部到底是元数据操作还是数据写盘非常有帮助。

第二组是空间观测。ls -l告诉我们逻辑大小,du -B1告诉我们实际物理块占用,stat -c '%b %B %s'从 inode 层面直接输出块数和块大小。三者的差异能立刻区分“真预分配”和“假预分配”。

第三组是块分布观测。filefrag -v输出文件的物理 extent 列表和碎片率;debugfs -R "stat <inode>" <设备>能直接看到 ext4 的 extent 树结构,xfs 则用xfs_db。这部分能看到 fallocate 分配出来的是一个连续大块还是散布多处的小块。

第四组是IO层观测。fio 用来测预分配文件与未预分配文件在随机写下的延迟分布,这个数据能暴露 unwritten extent 转换对业务 IO 的真实影响。

一套命令打下来,基本能把 fallocate 从“用户态调用”到“磁盘块分布”的完整路径摸清。

2.3 可直接复跑的测试脚本

我写了个半自动脚本,按三种方式各建一个 10G 文件,比较逻辑大小、物理占用和耗时:

#!/usr/bin/env bash set -euo pipefail TARGET=/mnt/ext4 SIZE=10G COUNT=$((10 * 1024)) echo "=== fallocate ===" rm -f "$TARGET/falloc.img" /usr/bin/time -v fallocate -l "$SIZE" "$TARGET/falloc.img" 2>&1 | grep "wall clock" stat -c "logical=%s blocks=%b block_size=%B" "$TARGET/falloc.img" echo "=== dd write ===" rm -f "$TARGET/dd.img" /usr/bin/time -v dd if=/dev/zero of="$TARGET/dd.img" bs=1M count=$COUNT oflag=direct status=none 2>&1 | grep "wall clock" stat -c "logical=%s blocks=%b block_size=%B" "$TARGET/dd.img" echo "=== truncate ===" rm -f "$TARGET/trunc.img" /usr/bin/time -v truncate -s "$SIZE" "$TARGET/trunc.img" 2>&1 | grep "wall clock" stat -c "logical=%s blocks=%b block_size=%B" "$TARGET/trunc.img"

脚本里的oflag=direct是刻意的,为了绕开页缓存干扰,拿到真实写盘耗时。truncate 那行也保留下来,作为“逻辑上也算预分配”的对照组。对着结果看,后面每一条都能讲出故事来。

3. 时间基准:fallocate 为什么是“瞬移”

3.1 10G 文件的三种分配方式耗时对比

我在同一块 SATA SSD 的 ext4 分区上连续跑了五次,取中间值,量级大致如下:

方式逻辑大小物理块占用耗时量级是否真实占盘
truncate -s 10G10G0微秒级否
fallocate -l 10G10G约 10G毫秒级是
dd 写入 10G 全零10G约 10G数十秒级是

第一次看到这个结果的人通常会惊讶:truncate 快可以理解,因为只改了 inode 里的文件大小字段,磁盘一个数据块都没分配;dd 慢也可以理解,因为要把 10G 的零实打实写一遍;fallocate 居然也是毫秒级,和 truncate 几乎同量级,可它却能把物理块全部占住,怎么做到的?

核心答案是:fallocate 走的是“元数据分配 + 未初始化标记”的路径,而不是“数据写盘”的路径。用户态一个系统调用进去,文件系统在空闲块里找到足够的物理块范围,更新 inode 的 extent 树,把这段范围标记为 unwritten extent,然后就返回了。整个过程不碰数据扇区,省掉了最耗时的写盘环节,自然快。

3.2 unwritten extent:快背后的机制与代价

unwritten extent 这个概念值得好好展开。它本质上是文件系统在 extent 树上做的一个状态标记:物理块已经分配出去了,也有明确的逻辑范围对应,但这些块在逻辑上还没有被真正写入数据。用户读取这块范围时,文件系统不跑盘,直接填零返回;用户写入这块范围时,文件系统先把状态转换为普通 written extent,再把数据落盘。

生活化一点类比:fallocate 相当于你提前在停车场租下了一整片区域,工作人员在系统里把这片区域标记为“已预订”,但停车位地面的划线、指示牌都还没做。租用记录已经生效,账面容量扣掉了。第一次有车开进来时,才真正完成划线、铺设地面等物理动作,这就是 unwritten extent 的“转换”。

明白这个机制后,预分配文件“首次写入可能比预想中慢”就说得通了。写入一个未初始化的 extent 时,内核要把对应范围的页缓存先清零、再填上你的业务数据、最后执行 extent 状态切换并提交日志。一次 4KB 随机写入的耗时通常还在微秒到毫秒级,影响不明显;但如果业务上来就是大范围的随机写,每次写入都触发成片的 extent 转换,IO 延迟的尾巴会被明显拉长。这一点在 3.3 的实验里能直接看到。

3.3 一个小实验:预分配后的首次写入开销

我用 fio 做了一组对照。同一个 8G 文件,一份是用 fallocate 预分配好的,一份是普通按需分配的文件,两个都跑纯随机 4K 写,direct=1,队列深度 1,覆盖整个文件范围:

fio --name=randwrite-falloc --filename=falloc.img \ --ioengine=libaio --rw=randwrite --bs=4k --size=8G \ --direct=1 --iodepth=1 --runtime=60 --time_based \ --output-format=normal fio --name=randwrite-normal --filename=normal.img \ --ioengine=libaio --rw=randwrite --bs=4k --size=8G \ --direct=1 --iodepth=1 --runtime=60 --time_based

普通文件在前一段的随机写过程中,每写到一个新块都得先实时找空闲块、更新位图、再写数据,IO 延迟的分布比较散。预分配文件省掉了“找块”这一大步,常规写延迟更平稳。但仔细观察 clat 分布的长尾部分,预分配文件的 P99 在某些文件系统上反而有尖刺。尖刺来源就是 unwritten extent 转换:原本一整段连续未初始化块,被随机写打得七零八落,extent 树需要不断进行分裂和合并操作,每一次分裂加日志提交都是延迟尖峰。

这个实验给我最大的提醒是:预分配解决的是“分配路径上的延迟”,它不会帮你消灭“状态转换路径上的延迟”。如果你的业务是一次性顺序写满文件,预分配收益最大;如果是长时间随机小写,初始化完一波之后反而要留意 extent 树碎片化带来的深度增长。

4. 空间与内容层面的细粒度观测

4.1 du、ls、stat 三个视角的差异

同一个 test.img,用三个命令看会有三种观感。ls -l显示逻辑大小,经过 fallocate 后是 10G;du -B1统计文件占用的物理块字节数,fallocate 后一般也接近 10G;stat -c '%b %B %s'则把物理块数和块大小直接拆开给你看。这三者一旦出现明显差异,说明“预分配”并不彻底。

truncate 出来的文件就是典型反例:ls -l显示 10G,du -B1却是 0,因为文件系统把所有范围都当成“未分配空洞”,物理块一个都没占用。fallocate 和它最大的区别就在这里:逻辑大小和物理占用同时发生变化,这才能叫占盘。

仔细想想,这个“占盘”对上层业务意味着什么?意味着文件系统在后续分配时,会把这些块排除在空闲空间之外;意味着不管写不写数据,这块盘的空间确实扣掉了;意味着 fstrim 之类精简动作也不能随随便便把这些块回收。预分配的本质就是“预先承担存储成本,换取后续写入路径的稳定性”。

4.2 在 ext4 的 extent 树里到底发生了什么

想看文件系统内部怎么记录这些块,debugfs 是最好用的工具。先拿到 inode 号,再 dump 元数据:

INODE=$(stat -c '%i' /mnt/ext4/falloc.img) debugfs -R "stat <$INODE>" /dev/vdb1

输出里能看到 extent 树的起始信息,但直接输出对新手不太友好。实际观察中更直观的还是 filefrag:

filefrag -v /mnt/ext4/falloc.img

对于一个布局良好的 10G 预分配文件,ext4 通常会分配几个连续的物理区间,显示成几个大 extent。如果文件系统碎片化严重,区间会被拆成几十段甚至上百段,逻辑上还是 10G,物理上分散得很。碎片化会直接影响后续顺序读性能,尤其是机械盘场景。

这里我要强调一个容易被忽略的点:fallocate 本身不承诺分配“物理连续”的大块,它只承诺分配“足够大的总量”。文件系统当前空闲空间的连续性决定了一切。所以生产环境里预分配之前,最好先做一次空间整理或者至少检查一下文件系统碎片率;否则你预分配出来的可能是一盘散沙。

4.3 读取预分配区域:为什么“看”到的是零

对 fallocate 生成的文件做读取测试,很多人会疑惑:明明磁盘上没有写过数据,为什么读出来全是零?这就要回到 unwritten extent 的语义。逻辑块与物理块已经有映射,但映射状态是“未初始化”。读路径遇到未初始化的页,不会发起真实的物理块读取,而是直接在页缓存里生成一个全零页面返回给用户空间。

所以预分配文件的“读零”不是磁盘扇区本来就是零,而是文件系统在逻辑层给你保证一个确定的零视图。这个保证也带来一个副作用:预分配之后你立即对整个文件做一次顺序读,实际上不会产生对应的物理 IO,速度反而比读真实数据文件快得多。有人把这种现象当成“预分配了但又没完全预分配”,其实是误解。空间是真实占住的,只是读路径被文件系统优化掉了。

如果就是要物理盘上也确定全零,可以配合 ZERO_RANGE 这类标志,或者干脆用写零的方式来初始化。读零语义和物理零是两回事,这一点在做合规审计或安全擦除时要格外注意。

5. 边界场景与模式组合的实际表现

5.1 磁盘空间不足时的部分失败行为

细粒度测试不能只看顺利路径,还得看失败路径。我用一个小 loop 文件模拟接近满盘的分区:创建一个 2G 的镜像文件,格式化后挂载,写入大量小文件把剩余空间压到低于 5%,然后尝试预分配一个 300M 的文件。

dd if=/dev/zero of=/tmp/small.img bs=1M count=2048 mkfs.ext4 -F /tmp/small.img mount -o loop /tmp/small.img /mnt/small # 填充到剩余空间不足 dd if=/dev/urandom of=/mnt/small/fill bs=1M count=80 fallocate -l 300M /mnt/small/big.img echo $?

失败路径下,fallocate 会返回 ENOSPC,但文件的最终状态取决于文件系统实现和具体版本。有些情况是在确认没有足够连续空闲块后直接拒绝,不留下任何可见分配;有些情况可能已经改写了文件大小或者留下了部分已分配的范围。我的建议很明确:不要把 fallocate 当成无副作用的原子操作。空间紧张时,先检查 df,再在业务代码里捕获失败并做文件清理;最稳妥的方式是先 fallocate 到临时路径,再 rename 到目标路径,避免业务入口处看到半个预分配文件。

5.2 KEEP_SIZE:容易被误用的“塔防式预分配”

KEEP_SIZE 是 fallocate 的隐藏玩法。默认模式下,预分配会把文件逻辑大小扩展到 offset+len;加上-k之后,文件大小保持不变,纯粹“占住”物理空间。我用一个小文件演示过:

fallocate -l 1G -k /mnt/ext4/hidden.img ls -l /mnt/ext4/hidden.img # 仍然显示 0 du -B1 /mnt/ext4/hidden.img # 显示约 1G

这个组合很适合一个场景:你想让某个文件“以后能快速释放出预分配空间”,但逻辑大小必须保持为 0,方便其他逻辑判断“文件还没有数据”。但也很容易踩坑:团队里有人只看了ls -l判断文件大小,没有意识到物理空间已经被扣掉,于是在一个满盘系统里拼命创建这类文件,等 df 报警时已经晚了。

排查这类问题,最直接的命令就是du加df对照。遇到“ls 显示很小、df 空间却少了”的怪现象,优先怀疑 KEEP_SIZE 或 PUNCH_HOLE 打洞操作。

5.3 PUNCH_HOLE:从预分配到空间回收

如果 KEEP_SIZE 是“只占空间不改大小”,PUNCH_HOLE 就是反向操作:释放空间,同样不改逻辑大小。在已预分配的文件中间打洞:

fallocate -p -o 0 -l 5G /mnt/ext4/falloc.img du -B1 /mnt/ext4/falloc.img

打完洞之后,文件前 5G 变成“未分配空洞”,读取返回零,但ls -l的大小还是 10G。这个能力对数据库分区归档、日志文件裁剪非常有用:不用重命名文件、不用重新 seek,直接在文件内部按范围释放空间。

但打洞也有代价,它会破坏原有 extent 的连续性,进一步增加文件碎片。在某些存储场景下,PUNCH_HOLE 还会对精简分配的快照产生影响,因为打洞本质上是在告诉存储“这块空间我不需要了”,如果底层是精简供给的卷,这个动作可能反过来放大碎片。业务上需要打洞时,建议先评估文件碎片率变化,再决定是否值得。

5.4 碎片与 extent 退化的测量

碎片化对预分配文件的影响是可量化的。我做了一个压力测试:在一个文件上反复增加预分配区域,然后打洞掉中间一小部分,接着再追加预分配,循环几十轮。每轮之后用 filefrag 观察 extent 数量和碎片率。

for i in $(seq 1 50); do fallocate -l 200M /mnt/ext4/frag.img fallocate -p -o $((i * 100))M -l 10M /mnt/ext4/frag.img filefrag -v /mnt/ext4/frag.img | tail -1 done

结果非常典型:extent 数量从个位数慢慢涨到几十甚至上百。ext4 的 extent 树会为了维护这么多零散区间变深,文件系统读写路径在做范围查找时的 CPU 开销也随之上升,机械盘上的表现更明显。这个测试让我意识到,预分配虽然能避免实时分配,但它并不能代替存储层面的空间整理;对大文件做周期性碎片检查,依然是必要的维护动作。

6. 常见坑与排查速查实录

6.1 几个让人直挠头的现场

现场一:某脚本调用 fallocate 创建了一个 100G 的文件,返回成功,但 df 剩余空间几乎没变化。排查时看了一眼命令,发现用了-k。去掉 KEEP_SIZE 后,空间立即被真实占用。教训是:读任何预分配相关脚本,先确认有没有 KEEP_SIZE。

现场二:预分配文件读起来极快,业务方怀疑是不是没有真正写盘、会不会丢数据。解释清楚 unwritten extent 的读零语义后,对方仍不放心,最后用 fio 在文件上跑一轮写覆盖加校验,确认数据完整。这种情况不是故障,是语义理解问题。

现场三:某存储服务用 fallocate 给新块设备预分配了 10T 空间,刚创建完,监控发现底层物理存储使用率飙升。原因是这套存储本身就是精简分配,上层 fallocate 直接把空间“实占”了,精简效果完全被破坏。后来方案改成按需增长,不再在薄卷上做全量预分配。这个案例对云原生环境下使用预分配有很强的警示作用。

6.2 快速排查命令速查表

现象可能原因排查命令处理建议
fallocate 成功但 df 空间未减少使用了 KEEP_SIZE 模式du -B1 <file>确认是否真的需要 keep 语义
文件很大但占用很小truncate 或空洞文件filefrag -v <file>需要真实占盘时换 fallocate
预分配后首次写入延迟高unwritten extent 转换fio 测 clat 分布评估是否分批预分配或预热
预分配后读全文件极快未初始化 extent 返回零页strace -e read观察 IO物理全零需额外做写零
filefrag 显示大量碎片空间不连续或打洞过多filefrag -v <file>整理空间或考虑文件重写
fallocate 返回 EOPNOTSUPP文件系统不支持该模式fallocate -l 1M /tmp/t改用 dd 或考虑换文件系统
薄卷使用率瞬间暴涨预分配破坏精简语义检查存储层分配状态按需增长,避免全量预分配

6.3 什么时候该用、什么时候不该用

给一段基于实测的个人判断。需要保证首次写入延迟稳定的大文件场景,比如虚拟机磁盘镜像、数据库数据文件、下载任务占位文件,fallocate 是利器。它会提前把“找块”这步做完,业务写入路径上少一层不确定性。

反过来,场景是频繁覆盖写的临时文件、追求精简空间的缓存目录、底层已经是精简供给的存储卷,我建议慎用甚至不用 fallocate。预分配把空间成本提前支付了,如果文件生命周期很短,收益完全覆盖不了副作用。

文件很小时,也不值得用。为几十 KB 的文件预分配,分配路径上的耗时差距可以忽略,但空间碎片和管理复杂度是实打实多出来的。小文件走普通写路径就好。

7. 实操心得与扩展建议

在我自己环境上反复跑过这些测试之后,一个很深的体会是:fallocate 是个好工具,但它不是“无脑提速器”,它是存储空间管理策略里的一环。理解 unwritten extent 是理解整个工具的关键,理解了它,你就能解释为什么预分配能几毫秒建出 10G 文件、为什么读预分配区域比读真实数据快、为什么首次写入可能遇到延迟尖刺。这些现象背后是同一条机制,摆在一起看就完全自洽。

最后分享一个小技巧,尤其适合做磁盘镜像或者数据库表空间初始化时使用。一次性把整个文件 fallocate 到底,不如分批次做:先 fallocate 一个 1G 的文件,写完业务数据后,再在原文件上追加 1G,循环执行。这样既享受预分配的快路径,又避免把海量 unwritten extent 一次性暴露给后续随机写,减少转换时段的延迟抖动。配合 fio 验证一下,效果会很明显。

如果你也想在自己的环境里复现这套测试,建议把脚本里的 SIZE 改小一点先跑通,再把 10G 换成你的业务真实大小。不同文件系统、不同存储介质、不同内核版本,得出的数据都会有差异,但现象背后的机制是一致的。跑完一轮,你再看到 fallocate,就不会只把它当一个“占位命令”了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询