ls -l 报 10,737,418,240 字节。du -h 报 16KiB。df 说磁盘一点没少。三个命令查的是同一个文件,三个答案都对。
文件占多少磁盘,不是文件自己的属性,是一笔引用账:几个名字指向它、几个进程还开着它、它的块被几份数据共享。
【实测】三条结论先摆出来。环境是 macOS 27.2(APFS,/System/Volumes/Data,测试时可用 16GiB),每组都是本次单次测量:
cp -c克隆 1GiB:0.002 秒,df三次读数都是 16,280,000 KiB,没变;普通cp:0.485 秒,少 1,040,000 KiB。- 10GiB 稀疏文件:
ls10,737,418,240 字节,du -k16 KiB,df不变。 - 1GiB 文件
unlink后df不变;关掉占着它的进程,Available 回来 1,040,000 KiB。
四个工具,四种体积
同一批文件,四个命令给出四套数字。stat 分配指 st_blocks × 512,也就是文件系统实际分给它的字节。
| 场景 | ls -l | du -k | stat 分配 | df 变化 |
|---|---|---|---|---|
| 写入 1GiB | 1,073,741,824 B | 1,057,260 KiB | 1,082,634,240 B | −1,040,000 KiB |
| 普通 cp 1GiB | 1,073,741,824 B | 1,048,576 KiB | 1,073,741,824 B | −1,040,000 KiB |
| cp -c 克隆 | 1,073,741,824 B | 1,048,576 KiB | 1,073,741,824 B | 0 |
| 克隆上改 1MiB | 1,073,741,824 B | 1,048,576 KiB | 没变 | 0 |
| 稀疏 10GiB | 10,737,418,240 B | 16 KiB | 16,384 B | 0 |
| unlink 但 fd 未关 | 没有名字了 | — | — | 0 |
第一行有两个偏差。mkfile 写出的文件逻辑 1GiB,du 却多 8,684 KiB,块比字节多(这一截我没拆);df 只少 1,040,000 KiB,比文件实际分配的 1,057,260 KiB 少 17,260 KiB——它按 40,000 KiB 一档记账,1GiB 记成 26 档。
克隆:两块盘只花一块的钱
【实测】cp -c 复制 1GiB 用了 0.002 秒,df 三次读数没动。cmp 内容一致,ls -i 显示 inode 不同,du 各自报 1,048,576 KiB。真复制 cp 用了 0.485 秒,df 掉了 1,040,000 KiB。
cp -c 背后的系统调用是 clonefile(2),手册第一句:
The cloned file dst shares its data blocks with the src file but has
its own copy of attributes and extended attributes.
数据块共享,属性各存一份。克隆是独立 inode、独立文件,改名改权限不影响原件;只有写入时,APFS 才复制被写的那一段。
【实测】整份覆写 512MiB 的克隆,df 掉 520,000 KiB;删原件回来 520,000 KiB;再删克隆又回来 520,000 KiB。原件和克隆共享同一份块,写时才分裂。
du 在这里会成倍地骗人。三个 256MiB 目录,du 各报 262,144 KiB,合计 786,432 KiB;df 只少 240,000 KiB,因为第三份是克隆。du 统计的是「文件认为自己占的块」,不区分共享。 拿 du 相加估磁盘,遇到克隆就翻倍。
克隆不是备份:它共享物理块,一边写不污染另一边,但块坏了、文件系统坏了,两边一起坏。
还有个不响的坑,man cp 写了:跨卷时 cp -c 不报错,直接退回真复制。你以为在做廉价快照,其实在搬 1GiB。这次没有第二个卷,我没测回退,只核对了手册。
稀疏文件:长度是承诺,块才是事实
【实测】Python seek 到 10GiB 写 1 个字节:ls 报 10,737,418,240 字节,du 报 16 KiB,st_blocks 是 32 个 512B 扇区,即 16KiB,df 没动。
读没有数据的位置会得到零,磁盘上没有对应的块,这叫洞。ls 报逻辑长度,du 报真分配的块,两个都对。
【实测】2GiB 的稀疏文件,普通 cp 花 0.007 秒,复制品 st_blocks 还是 32,df 没动;cp -c 0.0065 秒、同样 32 个扇区。macOS 的 cp 认洞,不会把零写成真块。
Linux 上 cp、tar、rsync 是否保留洞取决于工具和参数,这次一个都没测,不比较。稀疏文件在真实系统里不罕见:虚拟机磁盘、数据库预分配文件、容器镜像层、dd seek 出来的测试盘。备份工具不认洞,一个「看起来 2GiB」的镜像会被写成真 2GiB 再传一遍。
删掉不等于释放
【实测】打开 1GiB 文件,unlink 掉路径,df 一动不动;进程还活着的这一秒里,文件没有名字,块一直在。关掉 fd 再 sync,Available 立刻回来 1,040,000 KiB。
unlink 删的只是目录项。inode 上还有链接计数,每个打开它的 fd 各算一个引用,计数归零块才还给文件系统。所以「rm 完磁盘没空」通常不是 rm 的锅。
lsof 能看到这些没有名字的文件,NLINK 为 0、NAME 还在就是它:
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NLINK NODE NAME
Python 59436 <user> 3r REG 1,18 268435456 0 54233458 /private/tmp/.../held2.bin
这里我第一遍写错了:lsof +L1 -p <pid> 的两个条件是「或」,输出会把全系统所有进程的已删文件都列出来。要加 -a 才是「且」。脚本里拿它做判断,这个错很难发现。
常见肇事者:被 rm 后还在写的日志、被删的数据库临时文件、还开着的虚拟机磁盘镜像。办法只有让进程重开文件,或者重启。
df 自己也有两个坑
第一个坑是粒度。
【实测】同卷依次写 1、4、5、10、20、39、40、80 MiB(累计 199MiB),Available 从 18,360,000 KiB 变成 18,360,000、18,360,000、18,360,000、18,360,000、18,320,000、18,280,000、18,240,000、18,160,000。八个点都落在 40,000 KiB 的整数倍上:累计 40MiB 记 1 档,79MiB 记 2 档,119MiB 记 3 档,199MiB 记 5 档。
所以一次写 10MiB 以下,df 显示「没变」;克隆省的块也可能看不见。「df 没动」不能单独证明「没占空间」——这次克隆的结论靠的是删原件只回来 40,000 KiB、删克隆回来 1,040,000 KiB 的对照。
第二个坑是三列不能相加。同一次 df -k,APFS 容器里每个卷都报同一个 Size(239,362,496 KiB)和 Available(17,080,000 KiB),Used 各算各的:系统 13,400,000、VM 9,400,000、Preboot 9,440,000、Update 78,000、Recovery 1,468,000、Data 188,000,000。Used 加起来 221,786,000 KiB,和 Size − Available = 222,282,496 KiB 只差 496,496 KiB。
Size 和 Available 是容器级的,Used 是卷级的。 拿 Data 卷的 Used 去减 Size,会凭空多出几十 GiB 的「幽灵空间」,那是同一容器里别的卷在用。
40,000 KiB 的步长怎么来的,我只能确认现象。合理推测是 APFS 按容器批量记账,内核实现没看,不把推测写成结论。
什么时候该信谁
| 想知道什么 | 用哪个 | 注意 |
|---|---|---|
| 还能不能装下 5GB | df -k . | 只看 GB 级变化,单次小于约 20MB 的变化会被舍掉 |
| 哪个目录最占地方 | du -x -d 1 | 克隆和硬链接会重复计数 |
| 单个文件分配了多少块 | stat -f '%z %b %B' | 共享块看不出来,克隆会报满额 |
| 谁占着已删文件 | lsof -a +L1 | 看别人的进程要 sudo |
| 复制工作区给沙箱 | cp -cR | 只在同卷有效,跨卷静默退回真复制 |
| 做备份 | 真复制到另一块盘 | 克隆共享块,不是备份 |
对做 Agent 的人来说,最后两行是重点。
【实测】256MiB 目录,cp -R 0.149 秒、df 少 240,000 KiB;cp -cR 0.007 秒、df 没动。三个副本 inode 各不相同,内容一致。
所以「每个任务复制一份工作区」的成本,取决于复制方式:普通复制是 1 份数据 × N;克隆是 1 份数据 + 每份改过的那几块。十个 1GiB 副本,前者 10GiB,后者理论接近 1GiB 加各自写入量。这是按机制推的,没跑十副本实测。
别把它当免费午餐:克隆只省「没改的部分」,每个 Agent 都要改文件,改动量该多少还是多少;物理块的风险也共享。
我没测什么
- 只测 macOS 27.2 的 APFS;Linux 的 XFS/Btrfs reflink、overlayfs、ZFS 都没测,不比较。
- 每组基本单次测量,时间是单次观测;0.485 秒 / 0.002 秒只说明这一次差两个数量级。
- 没测
cp -c跨卷回退(没有第二个可写卷),也没测tar、rsync、Time Machine 是否保留洞。 - 没看 APFS 内核实现;40,000 KiB 步长是八个观测点推出来的现象,成因是推测。
- 没碰已有数据,也没做快照实验;测试文件都在
/private/tmp下自建、自删。
下次 df 和 du 打架,先问三件事:这个文件有几个名字、几个进程还开着它、它的块是不是和别人共享。数字都对,只是账没算全。