2023 年,我处理过一个让我对着屏幕发了半天呆的故障。一个后台服务,要往磁盘上写一个文件,突然写不动了,报错是 No space left on device——"设备上没有空间了"。这句话再直白不过,我心想:磁盘满了呗,清一清就好。我 SSH 上去,df -h 一看,愣住了:那块盘,用了 58%,清清楚楚还剩 40% 多没用——好几十个 G 的空闲空间,白纸黑字摆在那里。我以为是我看错了盘,把每一块挂载点都翻了一遍,没有一块是满的。可服务那边,No space left on device 还在一遍一遍地刷。我甚至自己手动 touch 了一个空文件——一个字节都不占的空文件——它也报同样的错。一个明明还剩几十个 G 的磁盘,却连一个 0 字节的空文件都创建不了。"没有空间"和"还剩 40%",这两件事就这么荒唐地、同时成立着。我盯着 df -h 那个让人安心的 58%,第一次开始怀疑:是不是"磁盘满没满",根本不是 df -h 这一个数字能回答的?这件事逼着我把磁盘的 inode、df -i、文件系统的两种容量这一整套彻底理清了。本文复盘这次实战。
问题背景
环境:CentOS 7,一台应用服务器,数据盘挂在 /data
事故现象:
- 服务写文件失败,报 No space left on device
- ★ df -h 看 /data 只用了 58%,还剩几十 G
- ★ 手动 touch 一个 0 字节空文件,也报同样的错
现场排查:
# 1. ★ 报错说没空间,可 df -h 看着空间很足
$ df -h /data
Filesystem Size Used Avail Use% Mounted on
/dev/vdb1 200G 116G 84G 58% /data
# ^^^ ★ 才用了 58%!
# 2. ★ 手动验证:连空文件都创建不了
$ touch /data/test.txt
touch: cannot touch '/data/test.txt': No space left on device
# ★ 0 字节的空文件都建不了 —— 问题【和"大小"无关】
# 3. ★ 换个角度:df -i 看 inode(文件数量)
$ df -i /data
Filesystem Inodes IUsed IFree IUse% Mounted on
/dev/vdb1 13107200 13107200 0 100% /data
# ^^^^^^^^ ^^^^^^^^ ^ ^^^^
# ★ 真相!IUse% 是 100% —— inode 用光了!
# 4. ★ 找出是哪个目录,占了海量的文件(inode)
$ for d in /data/*; do
echo "$(find "$d" 2>/dev/null | wc -l) $d"
done | sort -rn | head -3
12980000 /data/sessions # ★ 一个目录 1298 万个文件!
8800 /data/logs
1200 /data/uploads
# 5. 看看这个目录里都是些什么
$ ls /data/sessions | head -3
sess_0000a1b2c3d4... # ★ 一堆会话临时文件
$ ls -l /data/sessions/sess_0000a1b2c3d4...
-rw------- 1 app app 64 ... # ★ 每个才几十字节,但都占一个 inode
根因(后来想清楚的):
1. ★ 一块磁盘,其实有【两种】独立的容量,会各自
单独耗尽:
- 空间(block):能装下多少【字节】的数据。
- inode :能装下多少【个】文件。
2. ★ 每创建一个文件,不管它多小、哪怕是 0 字节,
都会【消耗一个 inode】。inode 记录文件的元信息
(权限、大小、数据块位置...),一个文件一个。
3. /data/sessions 这个目录,程序往里写会话临时
文件,只生不灭、从不清理,攒了 1298 万个 ——
每个文件几十字节,【总字节数没多少】,但
【消耗的 inode 数】把 inode 池榨干了。
4. ★ 于是:空间(block)还剩 42%,但 inode 是
100%。再建任何文件 —— 哪怕 0 字节 —— 都没有
inode 可分配,报 No space left on device。
5. ★ df -h 只看空间这一种容量,看不到 inode。
要 df -i 才看得见另一种容量。
不是没空间了,是没"文件数"的额度了 —— inode 耗尽。
修复 1:No space left on device——先别急着清空间
# === ★ 这个报错,有两种完全不同的原因 ===
# === No space left on device 的两种可能 ===
# 1. ★ 真的没空间了:数据块(block)用满,
# df -h 显示 Use% 100%。-> 清理大文件。
# 2. ★ inode 用光了(本文这次):文件【数量】到顶,
# df -h 可能还很空,但 df -i 显示 IUse% 100%。
# -> 清理的是"小文件的数量",不是大文件。
# ★ 两种的现象一样,处置完全不同。先别埋头清空间。
# === ★ 一招快速分清:touch 一个空文件 ===
$ touch /data/test_$$
# ★ 判断:
# - 报 No space left,但这是个 0 字节文件 ——
# 它【不占空间】,却还失败 -> 不是空间问题,
# 是 inode 问题。
# - 能成功 -> 空间问题(或那一刻刚好有空间)。
# 我这次:0 字节文件都 touch 失败 —— 立刻锁定是
# inode,而不是空间。
# === ★ 两条命令都要看 ===
$ df -h /data # 看【空间】用了多少
$ df -i /data # ★ 看【inode】用了多少
# ★ 哪个的 Use%/IUse% 到了 100%,就是哪个耗尽了。
# 只看 df -h,你会漏掉 inode 这一整种可能。
# === ★ 为什么"空文件也失败"这么关键 ===
# 一个 0 字节的文件,需要的"空间"是 0。如果磁盘
# 还有空间,创建 0 字节文件【没有任何理由失败】。
# ★ 它失败了,说明缺的不是"空间",是别的东西 ——
# 是 inode。这个推理,是这次破案的转折点。
# === 认知 ===
# ★ 看到 No space left on device,第一反应不该是
# "清空间",而该是"df -h 和 df -i 都看一眼,
# 先分清到底是哪一种'满了'"。
修复 2:一块磁盘,其实有两种独立的容量
# === ★ 重新认识"磁盘满了"这件事 ===
# === ★ 文件系统的两个独立"池子" ===
# 一个文件系统(格式化好的分区),管着两样东西,
# 是【两个独立的、会各自单独耗尽的池子】:
#
# 池子 A:数据块(block)—— 存文件【内容】的地方。
# 容量单位是字节/GB。df -h 看的就是它。
#
# 池子 B:inode —— 存文件【元信息】的地方。
# 容量单位是"个"。df -i 看的就是它。
#
# ★ 关键:这两个池子【互不相通】。block 还很空,
# inode 可以已经满了;反过来也一样。
# === ★ 什么是 inode ===
# 每个文件,在磁盘上其实是【两部分】:
# - 文件的【内容】 -> 放在 block 里。
# - 文件的【元信息】-> 放在一个 inode 里。
# inode 里存:文件大小、权限、属主、时间戳、以及
# ★ 内容到底放在哪些 block 上 的指针。
# ★ 注意:inode 里【不存文件名】。文件名存在目录里,
# 目录把"名字"映射到"inode 号"。
# === ★ 一个文件 = 至少一个 inode ===
# 创建任何一个文件 —— 普通文件、目录、空文件 ——
# 都要【分配一个 inode】给它。
# ★ 所以:文件【数量】消耗 inode;文件【大小】
# 消耗 block。一个 0 字节文件,占 0 block,
# 但实实在在占【一个 inode】。
# === ★ 看一个文件的 inode 号 ===
$ ls -i /etc/hostname
264590 /etc/hostname
# ^^^^^^ 这就是它的 inode 号
$ stat /etc/hostname # 看 inode 里的全部元信息
Size: 12 Blocks: 8 IO Block: 4096 regular file
Inode: 264590 Links: 1
Access: (0644/-rw-r--r--) Uid: (0/root) Gid: (0/root)
# === ★ 两种"满"的画面 ===
# 画面一(常规):一堆大文件 -> block 满,inode 还空。
# df -h 100%,df -i 没事。
# 画面二(本文):海量小文件 -> inode 满,block 还空。
# ★ df -i 100%,df -h 看着没事。
# ★ "磁盘满了"从来不是一个数字。它是两个数字。
# === 认知 ===
# ★ 磁盘的容量是【二维】的:能装多少字节(block)、
# 能装多少个文件(inode)。任何一维耗尽,都会让你
# "写不进去"。df -h 只量了其中一维。
修复 3:inode 用光了,揪出是谁吃掉的
# === ★ 确认 inode 耗尽后,找出"文件大户" ===
# === 第一步:df -i 确认是哪个挂载点 inode 满了 ===
$ df -i
Filesystem Inodes IUsed IFree IUse% Mounted on
/dev/vda1 6553600 280000 6273600 5% /
/dev/vdb1 13107200 13107200 0 100% /data # ★ 就是它
# ★ IUse% 100% 的那个挂载点,就是案发现场。
# === ★ 第二步:在那个挂载点里,找文件最多的目录 ===
# inode 是被"文件数量"吃掉的,所以要找的是
# "哪个目录下,文件个数最多" —— 不是"哪个目录最大"。
# ★ 注意:这里要数【文件个数】,不能用 du(它算大小)。
# 方法 A:逐个顶层目录数文件数
$ for d in /data/*/; do
printf "%10d %s\n" "$(find "$d" 2>/dev/null | wc -l)" "$d"
done | sort -rn | head
12980000 /data/sessions/ # ★ 元凶:1298 万个文件
8800 /data/logs/
1200 /data/uploads/
# 方法 B:统计每个目录【直属】的文件数
$ find /data -xdev -type d -exec sh -c \
'echo "$(ls -1A "$1" 2>/dev/null | wc -l) $1"' _ {} \; \
| sort -rn | head
# === ★ 第三步:确认这些文件是什么、能不能删 ===
$ ls /data/sessions | head -5
sess_a1b2c3... # ★ 一看就是会话/缓存临时文件
$ ls -l /data/sessions | head -3 # 看大小、时间
-rw------- 1 app app 64 Mar 01 10:22 sess_a1b2...
# ★ 几十字节、很久以前的、典型的"只生不灭"临时文件。
# === ★ 一个大坑:目录文件太多,ls 和 rm 都会很慢 ===
# 1298 万个文件挤在一个目录里,ls 会卡很久(它要排序),
# rm * 可能直接报 "Argument list too long"。
# ★ 删海量小文件,用 find 配 -delete,别用 rm *:
$ find /data/sessions -type f -name 'sess_*' -mtime +7 -delete
# -mtime +7:只删 7 天前的,保住近期还在用的。
# === ★ 边删边看 inode 在回收 ===
$ watch -n5 'df -i /data | tail -1'
# ★ IUse% 应该开始往下掉。
# === 认知 ===
# ★ inode 耗尽,要找的是"文件【个数】最多的目录",
# 不是"占空间最大的目录"。这俩是不同的搜法 ——
# 用 find ... | wc -l 数个数,不是 du 比大小。
修复 4:inode 为什么会用光,以及它的总量从哪来
# === ★ 一个反直觉的事实:inode 总量是【格式化时定死的】 ===
# === inode 总数,在 mkfs 那一刻就固定了 ===
# 格式化一个文件系统(mkfs)时,系统会根据分区大小,
# 按一个【默认比例】,预先创建好一批 inode。
# ★ 这个数量,从格式化那一刻起就【固定不变】。
# 之后无论你怎么用,inode 总数不会自己增长。
$ df -i /data | tail -1
/dev/vdb1 13107200 ... # Inodes 这一列就是总数,定死的
# === ★ 默认比例:大约每 16KB 空间配 1 个 inode ===
# ext4 默认:每 16384 字节(16KB)的空间,分配 1 个 inode。
# ★ 这个比例的潜台词是:文件系统【假设】你存的文件
# 平均大小在 16KB 上下。
# ★ 危险就在这:如果你的实际文件【平均远小于 16KB】
# (比如一堆几十字节的会话文件),那么 ——
# ★ inode 会比 block 先用光!空间还剩一大半,
# 文件个数的额度先没了。这正是本文这次。
# === ★ 反过来的情况 ===
# 如果你存的全是大文件(视频、镜像、大日志),
# 平均几百 MB,那 block 会先满,inode 多得用不完。
# ★ 所以"哪个池子先满",取决于你的【文件平均大小】
# 和文件系统【假设的平均大小(16KB)】谁大谁小。
# === 看一个文件系统的 inode 细节 ===
$ tune2fs -l /dev/vdb1 | grep -i inode
Inode count: 13107200 # inode 总数
Free inodes: 0 # ★ 一个都不剩了
Inodes per group: 8192
Inode size: 256
# === ★ 想要更多 inode?只能重新格式化 ===
# inode 总数不能在线调整。如果你明确知道一个分区
# 将来要存【海量小文件】,得在【格式化时】就指定
# 更高的 inode 密度:
$ mkfs.ext4 -i 4096 /dev/vdb1 # 每 4KB 一个 inode(密度更高)
# 或用 -N 直接指定 inode 总数:
$ mkfs.ext4 -N 50000000 /dev/vdb1
# ★ 注意:这会清空分区数据,且 inode 越多越占空间。
# 只在【确实要存海量小文件】时才这么调。
# === ★ 提一句:XFS 不一样 ===
# XFS 文件系统的 inode 是【动态分配】的,用多少分多少,
# 一般不会出现 inode 提前耗尽。如果业务天生海量
# 小文件,选 XFS 能省掉这个坑。
# === 认知 ===
# ★ inode 会用光,根子是:文件系统在格式化时,
# 按"文件平均 16KB"这个【假设】分配了固定数量的
# inode。你存的小文件远小于这个假设,inode 就会
# 先于空间耗尽。
修复 5:正确解法——清掉小文件,并堵住再堆积
# === ★ 解法:先回收 inode,再根治"只生不灭" ===
# === ★ 解法 1:安全地批量删除海量小文件 ===
# 确认了是哪个目录的临时/过期小文件后:
$ find /data/sessions -type f -mtime +7 -delete
# ★ 要点:
# - 用 find -delete,不要 rm *(海量文件会
# Argument list too long)。
# - 用 -mtime/-atime 只删【过期的】,别误删近期
# 还在用的。
# - 删之前先 find ... | wc -l 看看会删多少,心里有数。
# === ★ 解法 2:根治"只生不灭"——给临时文件配清理 ===
# inode 耗尽的根因,几乎都是"某类临时/缓存文件
# 只创建、不清理"。要从源头堵:
# - 程序自己:写临时文件的逻辑,要配【过期删除】。
# - 会话文件:配会话过期回收(如 PHP 的 session.gc)。
# - ★ 系统层面:用 tmpfiles / 定时任务 自动清理:
$ cat /etc/cron.daily/clean-sessions
#!/bin/bash
find /data/sessions -type f -mtime +3 -delete
# ★ 让"清理"成为一件自动、定期发生的事,而不是
# 等 inode 满了才人工救火。
# === ★ 解法 3:海量小文件,改变存储策略 ===
# 如果业务本质上就会产生海量小文件,治本是换思路:
# - 把大量小文件【合并】成少量大文件(如打包归档)。
# - 小数据存进【数据库/KV/对象存储】,而不是
# 一个文件一条 —— 数据库里一行不占文件系统 inode。
# - 真要用文件系统,格式化时选 XFS,或 ext4 调高
# inode 密度(见修复 4)。
# === ★ 解法 4:把 inode 也纳入磁盘监控 ===
# 这次的根本教训:监控只盯了 df -h(空间),漏了
# df -i(inode)。★ 磁盘监控必须【两个都监控】:
$ df -h /data | awk 'NR==2{print "space:"$5}'
$ df -i /data | awk 'NR==2{print "inode:"$5}'
# ★ 空间使用率、inode 使用率,任意一个超过比如 85%
# 都要告警。只监控空间,就会像我这次一样,被
# inode 从背后偷袭。
# === ★ 解法 5:一个目录别塞太多文件 ===
# 就算 inode 总量够,单个目录塞几百万文件,ls、
# rm、甚至程序 open 都会变慢(目录项查找慢)。
# ★ 把文件按规则【分散到多级子目录】(如按 hash
# 前两位分桶:/data/sessions/a1/, /a2/...),
# 每个目录文件数控制在合理范围。
# === 验证 ===
$ df -i /data # ★ IUse% 降下来了
$ df -h /data # 空间一如既往充足
$ touch /data/ok && rm /data/ok && echo "可以创建文件了"
# ★ inode 有富余、能正常创建文件,才算真的修好。
口诀放进脑子:写不进去先 df -i,空间和 inode 是两本账。
修复 6:磁盘容量排查纪律
# === 这次事故暴露的认知盲区,定几条纪律 ===
# === 1. ★ 磁盘有两种容量:空间(block)和文件数(inode),会各自单独耗尽 ===
# === 2. ★ No space left on device 先 df -h 和 df -i 都看,分清哪种满了 ===
$ df -h 挂载点 ; df -i 挂载点
# === 3. ★ touch 一个 0 字节空文件还失败,就是 inode 问题不是空间问题 ===
# === 4. 每个文件不管多小都占一个 inode,文件数量消耗 inode,文件大小消耗 block ===
# === 5. ★ inode 耗尽,要找"文件个数最多的目录",用 find|wc -l,不是 du 比大小 ===
$ for d in 路径/*/; do echo "$(find "$d"|wc -l) $d"; done | sort -rn
# === 6. 删海量小文件用 find -delete,别用 rm *(会 Argument list too long) ===
# === 7. ★ inode 总数在格式化时按"每 16KB 一个"定死,存小文件会让 inode 先于空间耗尽 ===
# === 8. 根治在源头:给临时/缓存文件配自动过期清理,别等满了人工救火 ===
# === 9. ★ 磁盘监控必须同时监控空间使用率和 inode 使用率,只监控空间会被偷袭 ===
# === 10. 排查"写不进文件/No space left"的步骤链 ===
$ df -h 挂载点 # ① 空间满了吗
$ df -i 挂载点 # ② ★ inode 满了吗
$ touch 空文件 # ③ 空文件也失败 -> 锁定 inode
$ find 各目录 | wc -l 找文件大户 # ④ 揪出谁吃光了 inode
$ find -delete 清理 + 配自动清理 # ⑤ 回收 + 根治
# 按这个顺序,"磁盘写不进去"基本能分清真假、能根治。
命令速查
需求 命令
=============================================================
看磁盘空间使用 df -h
看磁盘 inode 使用 df -i
看某挂载点空间 df -h /data
看某挂载点 inode df -i /data
验证是否 inode 问题 touch /data/test (0 字节也失败=inode)
看一个文件的 inode 号 ls -i 文件
看 inode 里的元信息 stat 文件
数某目录下文件总数 find /data/xxx | wc -l
找文件个数最多的目录 for d in /data/*/; do echo "$(find "$d"|wc -l) $d"; done|sort -rn
安全批量删海量小文件 find /data/xxx -type f -mtime +7 -delete
看文件系统 inode 细节 tune2fs -l /dev/vdb1 | grep -i inode
格式化时调高 inode 密度 mkfs.ext4 -i 4096 /dev/vdb1
口诀:No space left 先 df -h 再 df -i,空间和 inode 是两本独立的账
0 字节空文件都建不了就是 inode 耗尽,海量小文件把文件数额度榨干了
避坑清单
- 一块磁盘有两种独立的容量,空间 block 装多少字节和 inode 装多少个文件,会各自单独耗尽
- 报 No space left on device 先 df -h 和 df -i 都看一眼,分清到底是空间满了还是 inode 满了
- touch 一个 0 字节空文件还报 No space left,它不占空间却失败,就是 inode 问题不是空间问题
- 每创建一个文件不管多小哪怕 0 字节都消耗一个 inode,文件数量耗 inode,文件大小耗 block
- inode 耗尽要找文件个数最多的目录,用 find 路径 wc -l 数个数,不是用 du 去比哪个目录大
- 删海量小文件用 find -delete 配 -mtime 只删过期的,别用 rm 星号会报 Argument list too long
- inode 总数在格式化时按每 16KB 一个 inode 定死,存大量小文件会让 inode 先于空间耗尽
- inode 总数不能在线扩,要更多只能重新格式化时用 -i 或 -N 指定,或改用 inode 动态分配的 XFS
- 磁盘监控必须同时监控空间使用率和 inode 使用率,只监控空间会被 inode 耗尽从背后偷袭
- 根治在源头给临时缓存会话文件配自动过期清理,海量小文件考虑合并归档或存进数据库对象存储
总结
这次"磁盘还剩四成、却连一个空文件都写不进去"的事故,纠正了我一个关于"容量"的、朴素到我从未审视过的观念。在我的脑子里,一块磁盘的"满没满",从来就是一个【一维】的问题。磁盘就像一个杯子,数据就像水,杯子有一个固定的容积,水倒进去占掉一部分,剩下的就是空的——"满"或"不满",取决于水位线到了哪里,如此而已。这个画面太直观、太顺理成章了,以至于 df -h 给我的那个百分比,在我心里就等同于"杯子里的水位"。它显示 58%,我就彻底地、不加怀疑地相信:这个杯子还空着 42%,还能再装很多东西。所以当服务报"没有空间"时,我和 df -h 之间产生了一个我无法理解的矛盾——一个还空着 42% 的杯子,怎么会装不下哪怕一滴水?我把每一块盘的"水位"都查了一遍,确认它们都没满,然后我就卡住了,因为在我那个一维的世界里,这件事在逻辑上根本不可能发生。复盘到根上,我才明白,磁盘从来就不是一个一维的杯子。一个文件系统,它同时在管理【两种】完全不同、互不相通的东西。一种,是我一直以为的"空间"——存放文件【内容】的那些数据块,它的容量单位是字节。但还有另一种,是我从来不知道、也从来没去量过的——inode,存放文件【身份信息】的那些小格子,它的容量单位是"个"。每创建一个文件,哪怕它是个 0 字节、什么内容都没有的空文件,系统都必须先给它一个 inode,把它的名字、权限、归属登记在册。这意味着,一块磁盘的"满",其实有两条路:你可以用大文件把"空间"那条路堵死,也可以用海量的小文件,把"inode"那条路堵死。我的磁盘,正是后者——一千两百多万个几十字节的会话文件,它们的总字节数,确实没占多少"空间";可它们一个一个地领走了 inode,直到 inode 这个池子里,一个空格子都不剩。这时候,"空间"那条路明明还宽敞得很,可"登记文件身份"这条路已经彻底堵死了——没有 inode,新文件就拿不到身份,拿不到身份的文件,系统根本不允许它存在,哪怕它是空的。我那个一维的杯子模型,只画出了"空间"这一条路;另一条同样会堵死的路,它从来就没在我的地图上出现过。这次最大的收获,是我意识到,我之所以会卡死在那个"不可能的矛盾"里,不是因为我的观察有错——df -h 的 58% 没有撒谎——而是因为我用来理解这个观察的【模型,维度不够】。当现实是二维的,而我的模型只有一维时,现实里那个属于第二维度的真相,在我的模型里就找不到任何一个能安放它的位置;于是它在我眼里,就只能表现为一个"荒唐的、不可能的矛盾"。矛盾感本身,其实是一个珍贵的信号——它不是在说"世界出错了",它是在说"你描述世界的那张图,缺了一块"。所以下一次,当我遇到一个"现象和我的认知激烈冲突、怎么想都想不通"的情况时,我不会再一遍遍地去重新核对那几个我已经查过的数字了。我会停下来,问一个朝向我自己的问题:我现在用来理解这件事的这个模型,它会不会本身就太简陋了?这件事情,会不会其实有一个我压根没意识到的维度,而真正的答案,就静静地躺在那个我从来没有抬头看过的方向上?——很多想不通的"矛盾",并不是世界真的出了矛盾,而只是我们手里那张地图,画得还不够维度。
—— 别看了 · 2026