2021 年,一次"df -h 明明白白告诉我磁盘还剩 40% 空间,服务却死活报 No space left on device"的事故,把我对"磁盘满了"这件事的理解,从头到尾翻新了一遍。那台服务器跑着一个消息处理服务,某天下午开始,日志里疯狂刷 java.io.IOException: No space left on device,新文件一个都写不进去。我心里咯噔一下:磁盘满了呗,这还用查?我登上去,df -h 一敲,准备看那个躺着的 100%——结果屏幕上写着,根分区用了 58%,还剩 40 多个 G 空着。我愣住了。我以为是我看错了分区,又仔细核对了挂载点,没错,就是服务在写的那个分区,实实在在空着 40 多个 G。我又怀疑是不是某个进程把空间占了又没释放(占着已删除文件的句柄),查了一圈 lsof,也不是。可服务那边,No space left on device 一个字不少,还在刷。我盯着 df -h 那个 58%,脑子里全是问号:操作系统跟我说"没空间了",我亲眼看见"明明有 40 个 G 的空间"。我们俩,看的【是同一块磁盘】吗?如果是,那它说的"空间",和我看见的"空间",到底【是不是同一个东西】?如果不是——那磁盘上,除了我以为的那个"空间",还藏着【另一种】会被耗尽的东西?这件事逼着我把磁盘的两种配额、inode 是什么、df -i、海量小文件,还有"我以为的满"和"系统说的满"的天壤之别,彻底理清了。本文复盘这次实战。
问题背景
环境:CentOS 7,根分区 ext4,一个写大量小文件的消息服务
事故现象:
- 服务疯狂刷 No space left on device,新文件写不进
- ★ df -h 一看:根分区用了 58%,还剩 40 多 G 空着
- 空间明明有,为什么报"没空间"?
现场排查:
# 1. ★ 看磁盘空间用量 —— 空间确实有
$ df -h
Filesystem Size Used Avail Use% Mounted on
/dev/vda1 100G 58G 42G 58% / # ★ 还剩 42G!
# 2. ★ 那查查是不是"已删除但句柄没放"占了空间
$ lsof | grep deleted
# (没有可疑的大文件) # ★ 不是这个原因
# 3. ★★ 关键转折:看 inode 用量(df -i)
$ df -i
Filesystem Inodes IUsed IFree IUse% Mounted on
/dev/vda1 6553600 6553600 0 100% / # ★★ inode 用满了!
# ^^^^^^^ ^^^^^^^^^^^ ★ 100%
# 4. ★ 找出是谁吃光了 inode —— 哪个目录小文件最多
$ for d in /var/* /data/* /tmp/*; do \
echo "$(find $d 2>/dev/null | wc -l) $d"; done | sort -rn | head
# 5800000 /data/msgcache # ★★ 580 万个文件!
# 5. ★ 看看那个目录里是什么
$ ls /data/msgcache | head
msg-0001.tmp msg-0002.tmp msg-0003.tmp ... # ★ 海量小临时文件
$ ls /data/msgcache | wc -l
5800000 # ★ 580 万个,每个几 KB
根因(后来想清楚的):
1. ★ 一个文件系统,有【两种】独立的"配额":
一种是"数据块"(放文件内容,就是空间 GB);
另一种是"inode"(放文件元信息,一个文件占一个)。
2. ★ 这两种配额,在格式化时就【各自定好了总数】,
彼此【独立】—— 一种用尽,另一种再空也没用。
3. ★ 那个消息服务,产生了 580 万个【几 KB 的小
文件】。小文件占的空间(数据块)极少 -> 空间
只用了 58%;但【每个文件都要吃掉一个 inode】。
4. ★ 580 万个文件 = 吃掉 580 万个 inode,正好把
文件系统的 inode 总数耗尽 -> IUse 100%。
5. ★ inode 没了,就【再也建不了任何新文件】——
哪怕只建一个 0 字节的空文件也不行,因为建文件
的第一步就是【分配一个 inode】。内核此时返回的
错误码,就是 ENOSPC -> "No space left on device"。
6. 真相:报错信息里的 "space",是个有歧义的词。
它说的不是"没有空间(GB)了",而是"没有
inode 可分配了"。我一直在用 df -h 找空间,
而真正耗尽的,是 df -i 才看得见的 inode。
不是磁盘满了,是 inode 满了 —— 它俩是
两种东西,No space 这句话,我理解错了对象。
修复 1:一个文件系统,有"两种"会被用尽的东西
# === ★ 先建立最关键的认知:磁盘有两种"配额" ===
# === ★ 把一块格式化好的磁盘,想象成一个仓库 ===
# 这个仓库,要存"文件"。但存一个文件,需要两样东西:
# ★ 东西 1:数据块(data block)—— 存文件的【内容】。
# 你那个文件里 1MB 的数据,就实实在在占 1MB 的
# 数据块。这就是我们平时说的"磁盘空间 / GB"。
# ★ 东西 2:inode —— 存文件的【元信息 + 索引】。
# 一个文件的名字归属、权限、大小、时间戳,以及
# "它的内容散落在哪些数据块上"的索引,都记在
# 它的 inode 里。★ 每个文件,占用【恰好一个】inode。
# === ★ 关键:这两样东西的总量,格式化时就定死了 ===
# ★ 你 mkfs 格式化一个文件系统时,它就按一个比例,
# 把磁盘【一次性】划分好:多少空间给数据块,
# 多少空间给 inode 表 —— inode 的【总个数】,
# 这一刻就固定了,之后【不能动态增加】。
$ df -h / # 看"数据块"这种配额的用量(就是空间)
$ df -i / # ★ 看"inode"这种配额的用量(文件个数)
# === ★ 于是,有两种完全不同的"满" ===
# ★ 满法一:数据块用尽。你存了个 100G 的大文件,
# 把空间塞满了 -> df -h 显示 100%。
# ★ 满法二:★ inode 用尽。你存了几百万个【小】文件,
# 空间没占多少,但 inode 一人一个,被领光了 ->
# df -i 显示 100%。
# ★ 这两种"满",【任意一种发生,都建不了新文件】。
# 因为建一个文件,这两样【缺一不可】。
# === ★ 本文事故,就是第二种"满" ===
# ★ 580 万个小文件,数据块没怎么用(空间 58%),
# 但 inode 被吃光(100%)。仓库的"货架空间"还
# 很空,但"登记表的格子"已经一个不剩了。
# === 认知 ===
# ★ 一个文件系统有两种独立的、会被用尽的配额:数据块
# (存文件内容,就是磁盘空间 GB)和 inode(存文件
# 元信息和索引,每个文件占恰好一个)。两者总量在
# 格式化时就定死、彼此独立。所以有两种"满":空间
# 满(df -h 100%)和 inode 满(df -i 100%)——
# 任意一种发生,都再也建不了新文件。
修复 2:inode 到底是什么,为什么它会单独耗尽
# === ★ 把 inode 这个概念,彻底讲透 ===
# === ★ inode = 文件的"身份证 + 索引卡" ===
# ★ 一个文件,在磁盘上其实是【两部分】:
# - 内容:散落在若干个数据块里;
# - inode:一张固定大小的"卡片",记着这个文件的
# 所有元信息(权限、属主、大小、三个时间戳),
# 以及【它的内容在哪几个数据块】的索引。
# ★ 注意:★ 文件名【不在 inode 里】!文件名记在
# "目录"里,目录本质是一张"文件名 -> inode 号"
# 的对照表。inode 自己只有号码,没有名字。
# === ★ 看一个文件的 inode 号 ===
$ ls -i /etc/hosts
1234567 /etc/hosts # ★ 前面那个数字就是 inode 号
$ stat /etc/hosts # ★ 看 inode 里记的全部元信息
# Inode: 1234567 Links: 1
# Access/Modify/Change: ...
# === ★ 为什么 inode 会"单独"耗尽 ===
# ★ 因为:★ 不管文件多大多小,它都【恰好占一个 inode】。
# - 一个 10GB 的大文件 -> 占 1 个 inode + 海量数据块
# - 一个 1 字节的小文件 -> 占 1 个 inode + 1 个数据块
# - 一个 0 字节的空文件 -> 占 1 个 inode + 0 个数据块!
# ★ 所以:文件【个数】决定 inode 用量,文件【大小】
# 决定数据块用量。这是两个【毫不相关】的维度。
# ★ 海量小文件 = 文件个数巨大、总大小很小
# = inode 飞快耗尽、空间几乎没动。—— 本文场景。
# === ★ 一个反直觉的事实:空文件也会耗尽 inode ===
$ for i in $(seq 1 100000); do touch /tmp/test/f$i; done
# ★ 这创建了 10 万个 0 字节的文件。df -h 看,空间
# 几乎没变;df -i 看,inode 用量【涨了 10 万】。
# ★ "空文件不占地方"是错的 —— 它不占数据块,但它
# 照样占一个 inode。
# === ★ 目录,本身也占 inode ===
# ★ 目录也是一种"文件",它也占一个 inode。所以
# "海量的小目录",同样会耗 inode。
# === ★ 谁规定了 inode 的总数 ===
# ★ 格式化时的 bytes-per-inode 比例决定。默认大约
# 每 16KB 空间分一个 inode。所以一个 100G 的盘,
# inode 总数大约是 100G / 16KB ≈ 600 多万个。
# ★ 这个比例假设了"文件平均不会太小"。一旦你的
# 场景全是几 KB 的小文件,这个假设就被打破 ->
# inode 会比空间【早得多】耗尽。
# === 认知 ===
# ★ inode 是文件的"身份证+索引卡",记权限、属主、
# 大小、时间戳和内容数据块的索引(文件名不在 inode
# 里,在目录那张"名字->inode 号"对照表里)。关键:
# 不管文件多大多小甚至 0 字节,都恰好占一个 inode
# —— 文件【个数】决定 inode 用量,文件【大小】决定
# 空间用量,两者无关。海量小文件就会让 inode 先耗尽。
修复 3:df -i——看见那个被你忽略的维度
# === ★ 排查 No space,必须同时看两个 df ===
# === ★ df -h:看数据块(空间)用量 ===
$ df -h
Filesystem Size Used Avail Use% Mounted on
/dev/vda1 100G 58G 42G 58% /
# ★ 这是大家都熟的。Use% 是【空间】用了百分之几。
# === ★ df -i:★ 看 inode 用量 —— 多数人从不看的一栏 ===
$ df -i
Filesystem Inodes IUsed IFree IUse% Mounted on
/dev/vda1 6553600 6553600 0 100% /
# ★ Inodes:这个文件系统 inode 的【总数】(格式化定的)
# ★ IUsed :已经用掉的(= 文件 + 目录的总个数)
# ★ IFree :还剩多少
# ★ IUse% :★ inode 用了百分之几 —— 这一栏 100%,
# 就是"建不了新文件"的真凶。
# === ★ 排查纪律:一报 No space,两个 df 都要敲 ===
# ★ No space left on device 这个错,有两个可能的真凶:
# - df -h 的 Use% 是 100% -> 真·空间满了
# - df -i 的 IUse% 是 100% -> ★ inode 满了
# ★ 哪个 100%,就是哪个的问题。本文的我,只敲了
# df -h,看见 58% 就懵了 —— 因为我漏看了 df -i
# 这个维度,而真凶恰恰藏在那里。
$ df -h; echo '---'; df -i # ★ 养成两个一起敲的习惯
# === ★ 单独看某个文件系统 ===
$ df -ih /data # -i 和 -h 可一起,但读数看 IUse%
$ df -i /data # 只看 /data 的 inode
# === ★ 为什么"No space" 这个英文,会误导人 ===
# ★ 内核里,无论是数据块没了、还是 inode 没了,
# 底层返回的【是同一个错误码:ENOSPC】。
# ★ 而 ENOSPC 对应的那句人话,就是 "No space left
# on device"。所以这句话,【同时承担了两种含义】,
# 它不告诉你到底是哪一种 —— 得你自己用 df -i 去分。
# === 认知 ===
# ★ df -h 只看数据块(空间)用量,df -i 才看 inode
# 用量(IUse% 那一栏)。No space left on device 这个
# 报错,底层是同一个错误码 ENOSPC,空间满和 inode
# 满【都报它】—— 它不告诉你是哪种。所以排查纪律是:
# 一报 No space,df -h 和 df -i 两个都敲,哪个 100%
# 就是哪个的问题。多数人只敲 df -h,会漏掉 inode。
修复 4:找出谁吃光了 inode
# === ★ 确认是 inode 满,下一步:揪出海量小文件在哪 ===
# === ★ 思路:inode 用量 = 文件个数,所以要找
# "文件个数最多的目录" ===
# ★ 注意:不是找"最大的目录"(那是 du 干的事),
# 是找"文件【数量】最多的目录"。这俩是两回事。
# === ★ 方法一:逐个目录数文件个数,排序 ===
$ for d in /var/* /data/* /home/* /tmp/*; do
n=$(find "$d" 2>/dev/null | wc -l)
echo "$n $d"
done | sort -rn | head
# 5800000 /data/msgcache # ★★ 元凶:580 万个文件
# 120000 /var/spool/...
# ★ find 列出目录下所有文件和子目录,wc -l 数行数,
# sort -rn 从多到少排。最上面那个,就是吃 inode 的。
# === ★ 方法二:从可疑大目录,逐层向下钻 ===
$ cd /data
$ for d in */; do echo "$(find "$d" | wc -l) $d"; done | sort -rn
# ★ 锁定 msgcache 后,再进去看它内部结构:
$ ls /data/msgcache | wc -l
5800000
# === ★ 几个最经常"偷偷攒出几百万文件"的地方 ===
# ★ 1. session 文件:PHP 的 /var/lib/php/session,
# 没配清理,会攒到几百万。
# ★ 2. 邮件队列:/var/spool/clientmqueue,程序
# 乱发邮件没人收,死信攒成山。
# ★ 3. 应用的临时 / 缓存目录:像本文的 msgcache,
# 程序只管写、不管删。
# ★ 4. 海量小日志、小图片缩略图。
# ★ 排查时,这几个地方优先看。
# === ★ 一个陷阱:目录里文件太多,ls 会卡死 ===
# ★ ls 默认要排序,几百万文件时它会吃光内存、卡很久。
$ ls -f /data/msgcache | head # ★ -f 不排序,瞬间出结果
$ ls -1 /data/msgcache | wc -l # 数个数也可能慢,要有耐心
# ★ 用 find 配合也行:
$ find /data/msgcache -maxdepth 1 -type f | wc -l
# === 认知 ===
# ★ 确认 inode 满后,要找的是"文件【个数】最多的
# 目录"(不是最大的目录)。用 for 循环对每个目录
# find | wc -l 数文件数再 sort -rn 排序,最上面就是
# 元凶。常见偷偷攒几百万文件的地方:PHP session、
# /var/spool 邮件队列、应用缓存临时目录。注意目录
# 文件极多时 ls 会卡死,要用 ls -f(不排序)。
修复 5:清理与根治——别只删一次了事
# === ★ 找到元凶后:怎么安全清掉,怎么不再犯 ===
# === ★ 第一步:确认这些小文件能不能删 ===
# ★ 先搞清楚 /data/msgcache 是干嘛的、那些 .tmp
# 是不是真的废弃临时文件。【确认无用】再删。
$ ls -f /data/msgcache | head # 看文件长啥样
$ stat /data/msgcache/msg-0001.tmp # 看时间戳,多老了
# === ★ 第二步:删海量小文件,别直接 rm * ===
# ★ rm /data/msgcache/* 会失败:参数太多,shell 报
# "Argument list too long"。正确做法用 find -delete:
$ find /data/msgcache -type f -name '*.tmp' -delete
# ★ 或者,只删 7 天前的旧文件(更安全):
$ find /data/msgcache -type f -mtime +7 -delete
# ★ -mtime +7 = 修改时间在 7 天以前。边删边看 df -i,
# IUse% 应该开始往下掉。
# === ★ 第三步:文件实在太多,删都删得慢 ===
# ★ 几百万文件,find -delete 也要跑很久。如果【整个
# 目录都能不要】,最快是把目录整个删了重建:
$ mv /data/msgcache /data/msgcache.old
$ mkdir /data/msgcache # 立刻有个干净的新目录
$ rm -rf /data/msgcache.old & # 后台慢慢删旧的
# ★ 这样服务能立刻恢复,删除在后台进行。
# === ★ 第四步:★ 根治 —— 不能只删一次 ===
# ★ 只删一次,过几个月又会满。必须根治【为什么会攒
# 出几百万文件】:
# ★ 根治 a:让产生文件的程序,自己定期清理 / 设上限。
# ★ 根治 b:加一个定时清理任务,自动删旧文件:
$ crontab -e
0 4 * * * find /data/msgcache -type f -mtime +3 -delete
# ★ 根治 c:如果是 session、邮件队列,去配置它们
# 自带的过期清理机制。
# === ★ 第五步:如果场景就是天生"海量小文件" ===
# ★ 有些业务,注定要存几千万小文件。这种,要在
# 【格式化时】就调高 inode 密度:
$ mkfs.ext4 -i 4096 /dev/vdb1 # ★ -i 4096:每 4KB 一个 inode
# ★ 默认每 16KB 一个 inode,改成 4KB,inode 总数
# 翻 4 倍。★ 但这要【重新格式化】,是建盘时的决策,
# 线上盘改不了 —— 所以建盘前就要预估好文件特征。
# ★ 或者改用对小文件更友好的文件系统 / 对象存储。
# === 认知 ===
# ★ 清理:确认小文件无用后,用 find -delete(别用
# rm * 会 Argument list too long),可配 -mtime +7
# 只删旧的;整个目录能弃就 mv 走再后台 rm -rf,
# 服务立刻恢复。★ 但只删一次没用,必须根治:让
# 程序自己清理、加 cron 定时清理。天生海量小文件的
# 场景,要在 mkfs 时用 -i 调高 inode 密度(需重新
# 格式化,是建盘决策)。
修复 6:磁盘空间与 inode 排查纪律
# === 这次事故暴露的认知盲区,定几条纪律 ===
# === 1. ★ 报 No space left on device,df -h 和 df -i 必须都敲 ===
$ df -h ; echo '---' ; df -i
# === 2. ★ 一个文件系统有两种配额:数据块(空间)和 inode(文件个数)===
# === 3. ★ 每个文件占恰好一个 inode,不论它多大,哪怕 0 字节 ===
# === 4. ★ 空间没满 inode 满了,就是海量小文件 —— 去找文件个数最多的目录 ===
$ for d in /var/* /data/*; do echo "$(find $d 2>/dev/null|wc -l) $d"; done|sort -rn|head
# === 5. 删海量文件用 find ... -delete,别用 rm *(Argument list too long)===
# === 6. ★ 目录文件极多时 ls 会卡死,用 ls -f 不排序 ===
# === 7. 整个目录能弃,就 mv 走再后台 rm -rf,服务立刻恢复 ===
# === 8. ★ 只删一次会复发,必须根治:程序自清理 + cron 定时清理 ===
# === 9. 天生海量小文件的盘,mkfs 时用 -i 调高 inode 密度(建盘决策)===
# === 10. 排查 No space left on device 的步骤链 ===
$ df -h # ① 空间满了吗
$ df -i # ② ★ inode 满了吗
$ lsof | grep deleted # ③ 已删文件句柄没放?(另一种)
$ for d in ...; do find $d|wc -l; done | sort -rn # ④ 谁吃的 inode
# df -h 满 -> 清大文件;df -i 满 -> 清海量小文件并根治。
命令速查
需求 命令
=============================================================
看磁盘空间用量 df -h
★ 看 inode 用量 df -i
看某文件的 inode 号 ls -i 文件
看某文件 inode 全部元信息 stat 文件
数目录下文件个数 find 目录 | wc -l
列超多文件的目录(不卡) ls -f 目录 | head
找文件个数最多的目录 for d in /*; do echo "$(find $d|wc -l) $d"; done|sort -rn
删海量小文件 find 目录 -type f -delete
只删 7 天前的旧文件 find 目录 -type f -mtime +7 -delete
看已删除但句柄没放的文件 lsof | grep deleted
建盘时调高 inode 密度 mkfs.ext4 -i 4096 /dev/xxx
口诀:No space 有两种 空间满看 df -h inode 满看 df -i 两个都要敲
每个文件不论大小占一个 inode 海量小文件耗的是 inode 不是空间
避坑清单
- 报 No space left on device 不要只敲 df -h,必须同时敲 df -i 看 inode 用量
- 一个文件系统有两种独立配额:数据块就是磁盘空间 GB,inode 存文件元信息每个文件占一个
- 每个文件不论多大哪怕 0 字节空文件,都恰好占用一个 inode,目录本身也占一个
- 文件个数决定 inode 用量,文件大小决定空间用量,这是两个毫不相关的维度
- 空间没满但 inode 满了,根因必是海量小文件,要去找文件个数最多而不是最大的目录
- No space left on device 底层是同一个错误码 ENOSPC,空间满和 inode 满都报它,它不区分
- 删海量小文件用 find 目录 -delete,别用 rm * 会报 Argument list too long
- 目录里文件几百万时 ls 会因排序卡死吃内存,要用 ls -f 不排序
- 只清理一次过几个月还会满,必须根治让程序自己清理并加 cron 定时清理任务
- 天生要存海量小文件的盘,要在 mkfs 格式化时用 -i 参数调高 inode 密度,这是建盘决策线上改不了
总结
这次"磁盘有空间却报没空间"的事故,纠正了我一个关于"满"的、藏得极深的错觉。在我过去的脑子里,一块磁盘的状态,是【一根单一的、连续的标尺】。这根标尺从 0% 到 100%,我用 df -h 一看,指针停在哪,磁盘就"满"到哪。"磁盘满了"这件事,在我心里是【一维】的——它只有一个量、一个百分比、一个真相。所以当服务喊"没空间"时,我笃定地认为,我只要找到那根标尺,看一眼那个逼近 100% 的数字,事情就破了。我敲下 df -h,准备和那个 100% 打个照面——结果它回了我一个 58%。那一刻我是真的懵了,因为这个 58%,在我那套"一维标尺"的世界观里,是一个【自相矛盾】的东西:标尺明明还没满,系统却说满了。我甚至开始怀疑是不是系统在撒谎、是不是 df 坏了。直到 df -i 那一行 100% 跳出来,我那根"单一标尺"才"啪"地一声断成了两根。我这才被逼着看清一件我用了这么多年 Linux 却从没正视过的事:一块磁盘的"满",从来【不是一维的】。它身上,有【两根】各自独立的标尺——一根量"空间",装文件的肉;一根量"inode",装文件的数。这两根标尺,刻度不同、用尽的节奏也不同,谁先到 100%,完全取决于你往里塞的是什么:你塞几个巨大的文件,空间那根先到顶;你塞几百万个芝麻小文件,inode 那根先到顶。而那句让我困惑了半天的 No space left on device,它最"坏"的地方,就在于它用了一个【单数】的、含混的词——"space"。它让我以为只有一种"space",于是我只去看那一根标尺。可内核说"没 space 了"时,它心里指的,可能是【这两根标尺中的任意一根】。我和系统之间,从来没有谁撒谎——我们看的是同一块磁盘;我们的分歧,只是我手里攥着一根标尺,而它说的是另一根。复盘到根上我才明白,我犯的错,是把一个【多维】的资源,在脑子里【降维】成了一维。我以为"磁盘够不够用"是一个问题,其实它是两个:"装得下这些内容的体积吗"和"登记得下这些文件的数量吗"。这次最大的收获,是我对一切"资源够不够"的判断,都生出了一种新的警觉:在我说"它还够用"之前,我必须先问一句——我衡量的,是它的【哪一个维度】?这个东西,会不会还有【我没在看的、另一个维度】,早已悄悄见底?一个数据库,我看了连接数,内存呢、磁盘 IO 呢、表锁呢?一个线程池,我看了线程数,队列长度呢、任务堆积呢?一台机器,我看了 CPU,内存、文件描述符、网络带宽呢?这些东西,没有一个是"一根标尺"能说清的。它们都像那块磁盘——是一捆【捆在一起、却各自独立到顶】的标尺,而系统报警时,往往只给你一句含混的、单数的"满了 / 不够了",绝不会贴心地告诉你是哪一根先断的。所以,以后任何一个"为什么明明没满却出问题"的困惑,我都会先停下来,不去怀疑系统,而是怀疑自己:我是不是,又只盯着那一根我最熟悉的标尺,而把真正先断掉的那一根,漏在了视野之外?把多维的东西看成一维,是最舒服、也最危险的偷懒——磁盘这件事教得最透:它从来不是一根标尺,它是一捆。
—— 别看了 · 2026