2024 年的一个深夜,监控把我叫醒:一台核心服务器的根分区报 100% 占满,服务开始大面积写入失败。我登上去,df -h 一看,/ 分区确实红得发亮——用了 100%。我心想这好办,无非是哪个目录堆了垃圾,du 一把找出来删掉就行。可当我老老实实用 du 把根下面每个目录的体积都加起来,却撞见了一件彻底违背常识的事:所有目录加在一起,只用了不到一半的容量。磁盘明明写着满了,可那些占着空间的文件,我用 du 怎么找都找不到——它们像是凭空蒸发了,只留下一个被占满的磁盘和一脸懵的我。一个文件,既能实实在在地占着磁盘空间,又能让 du 完全看不见它,这件事本身就说明,我对"删除一个文件"到底意味着什么,理解得太浅了。我顺着这个矛盾一路挖下去,最后挖到的真相,把 Linux 里"文件""inode""文件句柄"这几个概念彻底给我串了起来。这件事逼着我把 Linux 的磁盘空间、df 与 du、删除文件的底层机制这一整套彻底理清了。本文复盘这次实战。
问题背景
环境:CentOS 7,一台核心服务器,根分区 50G
事故现象:
- df -h 显示 / 分区 use% = 100%,服务写入失败
- ★ du -sh 把所有目录加起来,只有 20 多 G
- 磁盘明明满了,可那 20 多 G 的空间,du 怎么都找不到
现场排查:
# 1. df 确认:根分区确实满了
$ df -h /
Filesystem Size Used Avail Use% Mounted on
/dev/vda1 50G 50G 20K 100% / # ★ 满了
# 2. ★ du 统计:全部加起来对不上
$ du -sh /* 2>/dev/null
... (各目录加总)约 23G # ★ 只有 23G!
# 磁盘说用了 50G,du 说只有 23G —— 27G 凭空消失
# 3. ★ 关键一招:找"被删除、但还被进程占着"的文件
$ lsof | grep -i deleted
java 9001 app 5w REG 253,1 28911062528 /opt/app/logs/app.log (deleted)
# ^^^^^^^^^^^ 约 27G ^^^^^^^^^ ★ (deleted)
# ★ 真相大白:一个 27G 的日志文件,被 rm 删掉了,
# 但 java 进程(PID 9001)还开着它 —— 空间根本没释放!
根因(后来想清楚的):
1. ★ 之前有人发现 app.log 太大,直接 rm 把它删了。
2. ★ 但 rm 删的只是【目录里那个文件名】,不是数据本身。
3. 当时 java 进程还【开着】这个文件在持续写日志。
4. ★ 文件名没了,所以 du(按目录名遍历)看不见它;
但只要进程还开着它,数据和空间就【一直占着】,
所以 df(按文件系统块统计)看得见这 27G。
5. du 看不见、df 看得见 —— 矛盾的根源就是这个
"已删除但未释放"的文件。
6. ★ 真正的释放:要么重启/通知进程关掉这个文件,
要么 truncate 把它清空 —— rm 在这种场景下没用。
df 与 du 对不上,先查"被删除却没释放"的文件。
修复 1:df 与 du 到底在量什么——为什么会对不上
# === ★ df 和 du 都说"用了多少空间",但它们量的【不是一个东西】===
# === df:站在【文件系统】的角度数"块" ===
$ df -h /
Filesystem Size Used Avail Use%
/dev/vda1 50G 50G 20K 100%
# df 问的是文件系统:"你一共有多少数据块?用掉了多少?"
# ★ 只要一个文件的数据【还占着块】,df 就把它算进 Used,
# 它【根本不关心】这个文件还有没有名字。
# === du:站在【目录树】的角度,一个个文件名累加 ===
$ du -sh /opt
# du 干的事是:从某个目录出发,【遍历目录项】,
# 把每个【还有名字的文件】的大小加起来。
# ★ 它是顺着"文件名"去找文件的 —— 一个文件如果
# 【没有名字了】,du 这条路就【永远走不到它】。
# === ★ 于是矛盾产生了 ===
# 一个文件被 rm 删了名字,但进程还开着它:
# - 数据块还占着 -> df 算得到(df 数块)
# - 文件名没了 -> du 找不到(du 顺着名字找)
# 这就是 df 满、du 对不上的【根本原因】。
# === 还有一个小差异:du 默认按"占用的块"算 ===
$ du -h --apparent-size file # 看文件【逻辑大小】
$ du -h file # 看文件【实际占块】(默认)
# 稀疏文件、小文件,这两个值会不一样,但通常差不大。
# === ★ 一句话记住 ===
# df 数的是"文件系统里被占的块"(看得见无名文件);
# du 数的是"目录树里还有名字的文件"(看不见无名文件)。
# 两者对不上,差额几乎总是"已删除但未释放"的文件。
修复 2:被删除却没释放的文件——这次的真凶
# === ★ 核心问题:在 Linux 里,rm 一个文件到底删掉了什么 ===
# === rm 删的是"目录项(文件名)",不是"数据" ===
# 一个文件在磁盘上,其实是两部分:
# - 一个 inode + 数据块 —— 真正的内容
# - 一个目录项 —— 把"文件名"指向那个 inode
# ★ rm 干的事,只是删掉【目录项】,并把 inode 的
# "链接数"减 1。
# === ★ 数据什么时候才真的被释放 ===
# 文件系统释放数据块,要同时满足【两个】条件:
# 1. 没有任何目录项指向它了(链接数 = 0)
# 2. ★ 没有任何进程还开着它(打开的文件句柄数 = 0)
# 只要还有一个进程开着它,哪怕名字早删了,
# 数据和空间【一直保留】—— 这就是这次 27G 的去向。
# === ★ 找出这种"被删除却没释放"的文件 ===
$ lsof | grep -i deleted
java 9001 app 5w REG 253,1 28911062528 /opt/app/logs/app.log (deleted)
# ^^^^ ^^^ ^^^^^^^^^^^ ^^^^^^^^^
# PID fd(第5号,w=写) 占用字节数 ★ 已删除标记
# ★ (deleted) 就是铁证:文件名没了,但进程还开着它。
# === 更聚焦的查法:只看占空间大的 ===
$ lsof +L1
# +L1 = 只列出"链接数 < 1"的文件(即已被删除却仍打开的)
# 比 grep deleted 更准,专门干这个。
# === ★ 怎么把空间真正要回来 ===
# 办法 A:重启持有它的进程(最干净)
$ systemctl restart myapp # 进程一关,句柄释放,空间立即回来
# 办法 B:不能重启?用 /proc 把那个文件【就地清空】
$ ls -l /proc/9001/fd/5 # 9001=PID,5=lsof 里看到的 fd
lrwx... /proc/9001/fd/5 -> /opt/app/logs/app.log (deleted)
$ : > /proc/9001/fd/5 # ★ 把这个已删文件的内容清空
# 进程还开着句柄,但文件内容被清掉,空间立刻释放,
# 而且进程不用重启、继续往这个(空的)句柄写。
修复 3:inode 也会满——另一种"明明没满却写不进"
# === ★ 磁盘"满"有两种,空间满只是其一,inode 满是另一种 ===
# === 现象:df -h 还有空间,却报 "No space left on device" ===
# 这种诡异情况,八成是 inode 用尽了。
# === inode 是什么 ===
# 每一个文件(哪怕是 0 字节的空文件),都要占用【一个 inode】。
# 文件系统在格式化时,inode 的【总数就定死了】。
# ★ 如果你有海量的小文件 —— 比如几百万个 session 缓存、
# 几百万个邮件碎片 —— 空间没用完,inode 先用光了。
# === ★ 用 df -i 看 inode 使用情况 ===
$ df -i /
Filesystem Inodes IUsed IFree IUse% Mounted on
/dev/vda1 3276800 3276800 0 100% / # ★ inode 满了!
# IUse% 100% = 一个新文件都创建不了,即使 df -h 显示有空间。
# === ★ 找出"哪个目录文件数最多" ===
$ for d in /*; do echo "$(find $d 2>/dev/null | wc -l) $d"; done | sort -rn
2891022 /opt/app/cache # ★ 这个目录,289 万个文件!
5102 /var
...
# 顺着这个目录进去,你会发现海量的小碎文件。
# === 也可以直接统计某目录下的文件数 ===
$ find /opt/app/cache -type f | wc -l
2891022
# === 解决:清掉海量小文件;治本要改程序 ===
$ find /opt/app/cache -type f -mtime +7 -delete # 删 7 天前的
# ★ 治本:让程序别再产生海量碎文件 —— 比如缓存换成
# Redis、小文件合并存储等。
# ★ 排查"磁盘满"养成习惯:df -h 和 df -i 【两个都看】。
修复 4:找出真正的大文件、大目录——du 的正确姿势
# === ★ 确认是"空间"满了之后,要快速定位是谁占的 ===
# === 第一步:看根下面哪个一级目录最大 ===
$ du -sh /* 2>/dev/null | sort -rh | head
23G /opt
8.1G /var
2.3G /usr
...
# -s 只看汇总 -h 人类可读 2>/dev/null 屏蔽没权限的报错
# sort -rh 按人类可读大小【倒序】,head 取最大的几个。
# === ★ 第二步:钻进最大的目录,逐层往下挖 ===
$ du -sh /opt/* | sort -rh | head
20G /opt/app
3G /opt/data
...
$ du -sh /opt/app/* | sort -rh | head
19G /opt/app/logs # ★ 一层层挖,锁定到 logs
# 这种"看汇总 -> 进最大的 -> 再看汇总"的钻取,
# 几下就能定位到真正的大头。
# === 只挖到某个深度,不被刷屏 ===
$ du -h --max-depth=1 /opt | sort -rh
# --max-depth=1 = 只统计到下一层,不把所有子文件都列出来。
# === ★ 直接找出全盘最大的几个【文件】 ===
$ find / -xdev -type f -size +500M -exec ls -lh {} \; 2>/dev/null
# -xdev ★ 不跨文件系统(不然会跑去 /proc /sys 乱统计)
# -size +500M 只找大于 500M 的文件
# 一条命令把"巨型文件"直接揪出来。
# === ★ 一个高频疏忽:du 看不见"被删未释放"的文件 ===
# 如果你 du 半天找不到大头,而 df 又确实满 ——
# ★ 立刻回到【修复 2】,查 lsof | grep deleted。
# 这次事故的教训就是:别在 du 这条路上死磕,
# du 找不到的空间,要去 lsof 里找。
修复 5:日志文件的正确清理方式——别再 rm 了
# === ★ 这次事故的直接导火索:有人用 rm 删了一个【在用的】日志 ===
# === 错误示范:对正在被写的日志 rm ===
$ rm /opt/app/logs/app.log
# ★ 后果:程序还开着这个文件的句柄在写,
# - 空间不释放(链接数 0 但句柄还在)
# - 程序继续往那个"已删除"的句柄里写,数据进了黑洞
# - df 满、du 找不到 —— 就是这次的局面。
# === ★ 正确做法 1:用 truncate 把文件【清空】 ===
$ truncate -s 0 /opt/app/logs/app.log
# 或者等价的:
$ : > /opt/app/logs/app.log
$ cat /dev/null > /opt/app/logs/app.log
# ★ 这是把文件【内容】清掉,但【文件名/inode 还在】。
# 程序的句柄还指向同一个文件,继续写没问题,
# 空间【立刻】释放。这才是清在用日志的正解。
# === ★ 正确做法 2:交给 logrotate 管,根本不手动删 ===
$ cat /etc/logrotate.d/myapp
/opt/app/logs/*.log {
daily # 每天切割
rotate 7 # 保留 7 份
compress # 旧的压缩
missingok
notifempty
copytruncate # ★ 关键:复制一份后,truncate 原文件
}
# ★ copytruncate:先把日志复制走,再 truncate 原文件 ——
# 程序的句柄不受影响,不用重启,空间正常回收。
# 没有 copytruncate 时,rotate 后要用 postrotate 通知程序重开文件。
# === 验证 logrotate 配置对不对 ===
$ logrotate -d /etc/logrotate.d/myapp # -d 只调试不执行
$ logrotate -f /etc/logrotate.d/myapp # -f 强制立即切一次
# === ★ 一句话纪律 ===
# 清理"正在被程序写的"日志:用 truncate / : > ,绝不用 rm。
# rm 只适合清理【没有任何进程在用】的文件。
修复 6:磁盘空间排查纪律
# === 这次事故暴露的认知盲区,定几条纪律 ===
# === 1. ★ 磁盘满,df -h 和 df -i 两个都要看 ===
$ df -h / # 看空间满没满
$ df -i / # 看 inode 满没满(海量小文件会先满这个)
# === 2. ★ df 满但 du 对不上 -> 查"被删除未释放"的文件 ===
$ lsof | grep -i deleted
$ lsof +L1
# 这是 df/du 矛盾的【头号原因】,务必第一时间想到。
# === 3. ★ 清在用的日志,用 truncate,绝不用 rm ===
$ truncate -s 0 大日志 # 对 —— 空间立即释放
$ rm 大日志 # ★ 错 —— 进程开着就不释放
# === 4. 已经 rm 了、空间没回来,两条路 ===
$ systemctl restart 进程 # A:重启进程(最干净)
$ : > /proc/PID/fd/N # B:就地清空那个已删文件句柄
# === 5. 定位大头:du 钻取 + find 找巨型文件 ===
$ du -sh /* | sort -rh | head
$ find / -xdev -type f -size +500M 2>/dev/null
# === 6. 日志交给 logrotate,配 copytruncate ===
# 别等磁盘满了手动救火,从一开始就让日志自动切割。
# === 7. 排查磁盘问题的命令链 ===
$ df -h # ① 哪个分区空间满
$ df -i # ② 是不是 inode 满
$ du -sh /* | sort -rh | head # ③ 哪个目录最大
$ lsof | grep deleted # ④ ★ 有没有被删未释放的
$ find / -xdev -size +500M # ⑤ 全盘巨型文件
# 按这个顺序,磁盘空间问题基本能定位。
命令速查
需求 命令
=============================================================
看分区空间使用 df -h
看 inode 使用 df -i
看目录占用并排序 du -sh /* | sort -rh | head
只统计到下一层 du -h --max-depth=1 目录
找被删除却没释放的文件 lsof | grep deleted / lsof +L1
就地清空已删文件句柄 : > /proc/PID/fd/N
清空正在写的日志 truncate -s 0 文件 / : > 文件
全盘找巨型文件 find / -xdev -type f -size +500M
统计某目录文件数(查inode) find 目录 -type f | wc -l
调试 logrotate 配置 logrotate -d /etc/logrotate.d/xxx
口诀:df 满 du 找不到 -> lsof grep deleted 查被删未释放的文件
清在用的日志只能 truncate,rm 会让空间永远要不回来
避坑清单
- 磁盘满先 df -h 看空间、df -i 看 inode,海量小文件会先把 inode 占满
- df 显示满但 du 加总对不上,第一时间查 lsof | grep deleted
- rm 删的只是目录项(文件名),不是数据本身,数据释放还需要没进程开着它
- 文件被 rm 后进程仍持有句柄,空间一直不释放,du 也看不见这个无名文件
- 清理正在被程序写入的日志必须用 truncate 或 : >,绝不能用 rm
- 已经 rm 了空间没回来,重启进程或用 : > /proc/PID/fd/N 就地清空
- du 默认数有名字的文件,df 数文件系统的块,两者本就不是一回事
- du 找大文件时加 -xdev 不跨文件系统,否则会跑去统计 /proc /sys
- df -h 有空间却报 No space left,八成是 inode 用尽,用 df -i 确认
- 日志交给 logrotate 自动切割,配 copytruncate 避免重启进程
总结
这次"磁盘明明满了、du 却怎么都找不到那些空间"的事故,纠正了我一个埋藏得很深的、关于"删除一个文件到底发生了什么"的错误认知。在这次之前,我心里对"删文件"的理解,朴素得近乎天真:一个文件,就是磁盘上的一坨数据;我执行 rm,就是把这坨数据抹掉;数据没了,它占的空间自然就回来了。在这个理解里,"文件名"和"文件数据"是浑然一体、不分彼此的同一个东西,rm 这个动作,干净利落,一步到位。正是这个假设,让我面对那个矛盾的现场时彻底卡住了:df 斩钉截铁地说磁盘用了 50G,du 把每个目录都翻遍了却只数出 23G,中间那 27G,既不在任何一个目录里,又确确实实地占着磁盘——一个文件怎么可能"既存在又不存在"?复盘到根上,我才真正看清,在 Linux 里,我以为浑然一体的那个"文件",其实是被拆成了两个彼此独立的东西。一个,是 inode 加上它名下的数据块,这是文件真正的"实体"——内容本身;另一个,是目录里的一个"目录项",它仅仅是一个"名字",一根指向那个 inode 的指针。我执行 rm 的时候,我以为我抹掉了实体,可我实际抹掉的,只是那个"名字"——那根指针。Linux 把这件事看得很清楚:一个文件的数据,要到什么时候才真正被释放?它要同时满足两个条件——没有任何"名字"再指向它了,并且,没有任何"进程"还开着它。rm 只完成了前一个条件,而这次,那个 27G 的日志文件,后一个条件死死地没满足:就在它被 rm 删掉名字的那一刻,有一个 Java 进程正开着它、正一刻不停地往里写日志。于是这个文件就进入了一种我从未想象过的中间状态:它没有名字了,所以顺着目录树、顺着文件名去遍历的 du,再也找不到它——对 du 而言它已经"死了";可它的数据块还被一个活着的进程牢牢攥在手里,文件系统一个块都不敢回收——对数着磁盘块的 df 而言它"活得好好的"。du 看不见、df 看得见,这个把我搞懵的矛盾,恰恰就是这个"已删除但未释放"的幽灵文件存在的铁证。揭穿它的,是 lsof | grep deleted 这一行命令——那个带着 (deleted) 标记、却依然占着 27G 的文件,清清楚楚地躺在输出里。这次从一个"空间凭空消失"的谜题出发,我最大的收获,是终于把"文件名"和"文件数据"在脑子里彻底分了家。rm 从来不是我想象中那个一步到位的"抹除",它只是"解除一个名字的绑定";数据真正的生死,还攥在"有没有进程开着它"这只看不见的手里。也正因如此,清理一个正在被程序写入的日志,rm 是彻头彻尾的错误——它删掉名字,却既要不回空间,又让程序继续往一个没有名字的黑洞里倾倒数据;正确的做法,是用 truncate 把文件的"内容"清空,而把那个"名字"原封不动地留给还开着它的进程。一个文件,远不只是一坨数据那么简单;而"删除",也远不是抹掉那么干脆。当 df 和 du 给出两个对不上的数字时,它们没有一个在撒谎——它们只是在从两个不同的角度,共同向你描述一个你过去从不知道其存在的、悬在名字与数据裂缝之间的幽灵。
—— 别看了 · 2026