Linux 文件很大却不占空间?用 Python 检查稀疏文件与磁盘分配量
ls -l 显示文件有 8 MiB,du 却只算出几 KiB:文件损坏了,还是磁盘统计不准?对包含“空洞”的稀疏文件,两者可能都正确,只是计算口径不同。
本文面向初级到中级读者,先创建一个小型稀疏文件,再用 Python 输出逻辑字节数、已分配字节数和硬链接身份。检查器只读元数据,不打开文件内容、不删除文件、不修改系统设置。
1. 方法:先统一比较口径
ls -l展示普通文件的逻辑长度;GNUdu -B1用字节单位展示分配量,du --apparent-size -B1展示逻辑量。特别注意:du -b等价于后者,不是“实际占用的字节数”。选项定义见 GNU du 手册。- Python 的
st_size是逻辑字节数;Linux 上st_blocks * 512是文件报告的已分配字节数。st_blksize是建议 I/O 大小,不能拿来乘st_blocks。字段定义见 Python stat 结果文档。 - 两个路径若具有相同的
(st_dev, st_ino),可能只是同一个文件的硬链接;报告保留身份,不按路径相加。Linux 的 stat 手册说明了链接与元数据行为。
分配量小于逻辑量只是线索,不是可回收空间估算。 压缩、共享数据块、预分配和文件系统实现都会影响解释;本工具不定位空洞,也不判断文件能否删除。df 与目录总量不一致时,应继续看磁盘空间与已删除文件排查。
2. 准备:使用独立练习目录
需要 Linux、已有的 Python 3.10+ 和 GNU coreutils;不需要 pip 包。本例在 Python 3.11.4、GNU coreutils 9.7 上测试,shell 命令适用于 bash 或 zsh。
- 先确认工具,再新建目录。重复实验时换一个目录名,不覆盖旧文件:
python3 --version
du --version
mkdir sparse-usage-demo
cd sparse-usage-demo
- 在这个目录执行下面的命令。它只在首尾写入 4 个字节,中间的逻辑区间没有逐字节写入;
xb会拒绝已有文件:
python3 - <<'PYCODE'
import os
size = 8 * 1024 * 1024
with open("sparse.bin", "xb") as stream:
stream.write(b"HEAD")
stream.seek(size - 4)
stream.write(b"TAIL")
stream.flush()
os.fsync(stream.fileno())
with open("empty.bin", "xb"):
pass
print("created sparse.bin and empty.bin")
PYCODE
在支持稀疏文件的文件系统中,这种跨越写入可以形成空洞;空洞读取为零,行为说明见 Linux lseek 手册。实际分配量由当前文件系统决定,不保证所有挂载类型都得到相同节省。
不要对现有数据库、虚拟磁盘或日志运行 truncate 来“修复大小”。 缩短文件会丢失数据。本文没有这类操作。
3. 实现:只读文件用量检查器
把下面代码保存为 file_usage.py:
"""Read Linux regular-file size metadata. Python 3.10+."""
import argparse
import json
import os
import stat
import sys
def inspect_file(path):
info = os.lstat(path)
if not stat.S_ISREG(info.st_mode):
raise ValueError(f"{path!r}: not a regular file; no final symlink following")
blocks = getattr(info, "st_blocks", None)
if blocks is None:
raise ValueError("st_blocks is unavailable on this platform")
allocated = blocks * 512
return {
"path": path,
"logical_bytes": info.st_size,
"allocated_bytes": allocated,
"allocated_less_than_logical": allocated < info.st_size,
"hard_links": info.st_nlink,
"device": info.st_dev,
"inode": info.st_ino,
}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("paths", nargs="+", help="regular-file paths; no recursion")
args = parser.parse_args()
try:
rows = [inspect_file(path) for path in args.paths]
except (OSError, ValueError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
print(json.dumps(rows, indent=2, ensure_ascii=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())
os.lstat()读取最后一个路径项本身;stat.S_ISREG()限制输入为普通文件,所以目录、FIFO 和最后一级符号链接会被拒绝。父目录中的链接仍会被正常解析;这不是对不可信目录的路径隔离工具。
成功返回 0,stdout 是一个 JSON 数组,包含 logical_bytes、allocated_bytes、allocated_less_than_logical、hard_links、device 与 inode。任一路径失败返回 2,说明写到 stderr,不输出部分 JSON;参数错误同样返回 2。脚本不递归,且不会把多个文件当作同一个时刻的快照。
4. 运行:对照 JSON 与 du
依次执行:
python3 file_usage.py --help
python3 file_usage.py sparse.bin empty.bin
ls -l -- sparse.bin
du -B1 -- sparse.bin
du --apparent-size -B1 -- sparse.bin
du -b -- sparse.bin
本次测试中,sparse.bin 的逻辑长度为 8388608 字节,分配量为 8192 字节,布尔字段为 true。du -B1 显示 8192,另外两条 du 命令显示 8388608。这些是测试环境观察值,不能把分配量写死为通用断言;空文件的布尔字段为 false,没有除以零的问题。
想把同样的检查用于实际文件时,传入确定属于你的普通文件路径即可;路径以 - 开头时在参数前加 --。要先寻找候选大文件,可用按逻辑大小排名的工具,再逐个检查分配量,而不是直接相加排名结果。
5. 验证:覆盖硬链接和失败输入
把下面保存为 check_usage.py,与 file_usage.py 放在一起。测试只在当前目录下创建自己的临时子目录,结束时自动清理;不会改动前面的演示文件。
"""Run next to file_usage.py; all fixtures live in a temporary directory."""
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
script = Path("file_usage.py").resolve()
def run(paths, expected):
result = subprocess.run(
[sys.executable, str(script), *map(str, paths)],
text=True, capture_output=True, check=False,
)
assert result.returncode == expected, result.stderr
if expected == 0:
assert result.stderr == "", result.stderr
return json.loads(result.stdout)
assert result.stdout == "", result.stdout
assert result.stderr.startswith("ERROR:"), result.stderr
with tempfile.TemporaryDirectory(prefix="usage-check-", dir=".") as tmp:
directory = Path(tmp)
sparse = directory / "sparse.bin"
size = 8 * 1024 * 1024
with sparse.open("xb") as stream:
stream.write(b"HEAD")
stream.seek(size - 4)
stream.write(b"TAIL")
stream.flush()
os.fsync(stream.fileno())
empty = directory / "empty.bin"
empty.touch(exist_ok=False)
tiny = directory / "tiny.bin"
tiny.write_bytes(b"x")
before = sparse.stat()
rows = run([sparse, empty, tiny], 0)
assert [row["logical_bytes"] for row in rows] == [size, 0, 1]
for path, row in zip([sparse, empty, tiny], rows):
info = path.lstat()
assert row["allocated_bytes"] == info.st_blocks * 512
assert row["allocated_less_than_logical"] == (
row["allocated_bytes"] < row["logical_bytes"]
)
assert sparse.stat().st_mtime_ns == before.st_mtime_ns
with sparse.open("rb") as stream:
assert stream.read(4) == b"HEAD"
stream.seek(size // 2)
assert stream.read(4) == b"\0" * 4
stream.seek(size - 4)
assert stream.read() == b"TAIL"
alias = directory / "alias.bin"
os.link(sparse, alias)
linked = run([sparse, alias], 0)
assert linked[0]["hard_links"] == linked[1]["hard_links"] == 2
assert (linked[0]["device"], linked[0]["inode"]) == (
linked[1]["device"], linked[1]["inode"]
)
symlink = directory / "link.bin"
symlink.symlink_to("sparse.bin")
run([symlink], 2)
run([directory], 2)
run([directory / "missing.bin"], 2)
run([sparse, directory / "missing.bin"], 2)
print("PASS: sizes, blocks, content, hard links, and failure exits")
运行:
python3 check_usage.py
成功时输出:
PASS: sizes, blocks, content, hard links, and failure exits
这里断言的是计量公式、内容与退出行为,不是某种文件系统必须节省多少空间。测试还核对最后一级符号链接、目录、缺失文件和混合输入失败时没有部分 stdout。硬链接的相同身份只说明别名关系;不要把它当成两份独立备份。需要查找独立内容副本时,参考分块 SHA-256 重复文件检查。
6. 常见问题与边界
| 现象 | 原因与具体处理 |
|---|---|
allocated_bytes 大于 logical_bytes |
小文件也可能按整块分配;先比较字节单位,不要据此判定损坏。 |
| 演示文件没有明显节省 | 检查当前挂载类型和文件系统能力,在支持稀疏文件的练习目录重试;不要调整脚本让它虚报节省。 |
not a regular file |
输入是目录、FIFO 或最后一级符号链接;明确选择真正的普通文件路径。 |
No such file / Permission denied |
核对路径和父目录的搜索权限;不要把失败算成零占用,也不要默认以 root 重跑。 |
| 多个硬链接都报告同一分配量 | 用设备号与 inode 识别同一个文件;如需汇总,先按身份去重,且仍不代表删除后释放量。 |
| 复制后目标占用变大 | 复制工具、文件系统与空洞保留策略可能不同;先在小样本上复制并复查两种大小,再做容量预算。 |
元数据查询成功不代表内容可读、备份完整或文件此后不再变化。不要在活动数据库上用一次 stat 结果估算精确备份空间;本工具也不统计目录自身、快照或文件系统全局元数据。
7. 小结
先区分逻辑长度与文件报告的分配量,再解释 ls 和 du 的差异。用小型稀疏文件与硬链接测试验证公式和退出码,把只读 JSON 报告接入巡检。把结果当成进一步排查的线索,而不是删除建议或可回收空间承诺。
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/linux-sparse-file-disk-usage.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。