2024 年的一个下午,监控报警:一台服务器的根分区使用率冲到了 100%。我赶紧上去看,df -h 一跑,/ 分区果然写着 100% Used,一个字节都不剩了。我心想这好办,无非是哪个日志或者临时文件撑爆了,找出那个大文件删掉就行。我熟练地用 du 去找:du -sh /* 一层层往下钻,把整个根分区从上到下扫了个遍,把每个目录的体积加起来——结果加出来总共也就用了二十几个 G,可这块盘明明有 50 个 G,df 说它满了。du 把所有文件都算上,只数出来一半;另一半,凭空消失了。我把 du 又跑了好几遍,换着参数跑、加上 -a 跑、连隐藏文件都数上,结果始终对不上:df 说用了 50G,du 说只有 25G。盘上有 25G 的空间,被某种 du 根本看不见的东西占着。一个"看不见的文件"占着一半的盘——这个现象逼着我去搞清楚一件我从来没细想过的事:在 Linux 里,一个文件被 rm 删掉之后,它占的磁盘空间,到底是不是立刻就还回来了?这件事逼着我把文件、目录项、inode、文件描述符这一整套关系彻底理清了。本文复盘这次实战。
问题背景
环境:CentOS 7,一台服务器,根分区 / 报警 100%
事故现象:
- df -h 显示 / 分区 100% Used,空间耗尽
- ★ 用 du 把整个 / 从上到下扫一遍,加起来只有约一半
- df 说用了 ~50G,du 只数出 ~25G —— 差了 25G 对不上
现场排查:
# 1. df 看:根分区确实满了
$ df -h /
Filesystem Size Used Avail Use% Mounted on
/dev/vda1 50G 50G 20M 100% / # ★ 满了
# 2. ★ du 一层层找大文件,却怎么也加不到 50G
$ du -sh /* 2>/dev/null
...
$ du -sh / 2>/dev/null
25G / # ★ du 只数出 25G
# 3. ★ df 和 du 差了一半,空间被某种 du 看不见的东西占着
# —— 关键一招:找"被删除了、但还被进程占着"的文件
$ lsof -nP | grep '(deleted)'
java 9527 app 5w REG 253,1 26843545600 ... /www/logs/app.log (deleted)
# ^^^^^^^^^^^ ^^^^^^^^^
# ★ 这一个文件就 25G ★ 已被删除
# ★ 真相:一个 25G 的日志文件被 rm 删了,但 java 进程
# 还开着它 —— 文件的空间一点没还回来。
根因(后来想清楚的):
1. ★ 在 Linux 里,rm 一个文件,删的不是文件本身,
只是删掉了"目录里那个指向它的名字"。文件占的
磁盘空间,要等到【没有任何进程还开着它】,才真正
被释放。
2. 之前有人嫌 /www/logs/app.log 太大,直接 rm 把它
删了。但那个 java 进程一直在运行,它手里还攥着
这个文件的【文件描述符】,还在往里写日志。
3. ★ 对这个 java 进程来说,文件还活着、还能写;对
du 来说,文件的【名字】已经没了,du 顺着目录树
找文件名,根本找不到它 —— 所以 du 数不到这 25G。
4. 但对内核来说,这个文件还被一个进程开着,它占的
25G 磁盘块就【不能释放】—— 所以 df 还在算这 25G。
5. ★ df 和 du 对不上的差额,几乎就是这种"已删除但仍
被占用"的文件。靠 rm 是删不掉的,文件名早没了。
df 看的是"空间用了多少",du 看的是"文件名还能找到多少"。
修复 1:df 和 du 为什么会对不上
# === ★ 纠正核心误区:df 和 du 数的不是同一个东西 ===
# === 我以为的 vs 真实的 ===
# 我以为:df 和 du 都是"算磁盘用了多少",应该永远
# 一致。对不上,一定是其中一个数错了。
# ★ 真相:它俩看的是两个【不同】的东西 ——
# - df:问【文件系统】,这块盘的数据块用掉了多少。
# 它统计的是磁盘块层面的占用,和"文件叫什么名字、
# 在不在目录里"无关。
# - du:从某个目录出发,【顺着目录树走】,把它能
# "看见"的文件,一个个量大小再加起来。
# ★ 核心区别:du 必须能【通过文件名找到】一个文件,
# 才能把它算进去。一个文件如果【没有名字了】,
# du 就把它彻底漏掉 —— 但 df 照样在算它的空间。
# === ★ 关键概念:文件、文件名、inode 是三样东西 ===
# 在 Linux 里,一个"文件"其实由几部分组成:
# - inode:文件的【本体】—— 它记录文件的大小、权限、
# 数据块在磁盘上的位置。inode 没有名字。
# - 目录项(名字):目录里的一条记录,把一个【文件名】
# 指向一个 inode。这就是"文件名"。
# - 一个 inode 可以被【多个名字】指向(硬链接)。
# ★ 你 ls 看到的"文件",其实是"目录项 -> inode"。
# 文件的【数据和空间】属于 inode,不属于名字。
# === ★ rm 到底删了什么 ===
$ rm /www/logs/app.log
# rm 这个命令,真名其实叫 unlink(取消链接)。它做的是:
# ★ 把"app.log 这个名字 -> inode"的【这条目录项】删掉。
# 它【没有】直接去删 inode、去释放数据块。
# 内核释放一个 inode 和它的磁盘空间,要同时满足:
# 1. 指向它的【文件名】全部没了(链接数归零),且
# 2. 【没有任何进程】还开着它(打开数归零)。
# ★ 只要还有一个进程开着它,哪怕名字全删了,
# 这个文件的空间就【一点都不会还】。
# === 认知 ===
# ★ df > du,且差得多 —— 经典信号:有"已被删除、
# 但还被进程开着"的文件,占着空间却没有名字。
# du 顺着名字找不到它们,df 却老老实实在算。
修复 2:揪出"已删除却还占着空间"的文件
# === ★ 用 lsof 找出那些"没有名字、却还活着"的文件 ===
# === lsof:列出"谁打开了什么" ===
# lsof = list open files。它能列出系统里每个进程
# 当前打开着的所有文件。
$ lsof -nP
# -n :不把 IP 解析成域名(快)
# -P :不把端口号解析成服务名(快)
# ★ 排查时一律加 -nP,否则会很慢。
# === ★ 关键:筛出标着 (deleted) 的行 ===
# 一个文件如果"已经被删名字、但进程还开着",lsof
# 会在它后面明明白白标一个 (deleted):
$ lsof -nP | grep '(deleted)'
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
java 9527 app 5w REG 253,1 26843545600 ... /www/logs/app.log (deleted)
nginx 3310 www 8w REG 253,1 314572800 ... /tmp/x.tmp (deleted)
# ★ 逐列解读这一行:
# - COMMAND/PID :★ 是哪个进程开着它(java,PID 9527)
# - FD :5w —— 5 号文件描述符,w 表示以写打开
# - SIZE/OFF :★ 26843545600 字节 ≈ 25G —— 元凶就是它
# - NAME :原来的路径,后面跟着 ★ (deleted)
# === ★ 更准的一招:lsof +L1 ===
# +L1 = 只列出"链接数 < 1"的文件,也就是名字已经
# 没了、但还开着的文件。比 grep deleted 更精确:
$ lsof -nP +L1
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NLINK NODE NAME
java 9527 app 5w REG 253,1 26843545600 0 ... /www/logs/app.log (deleted)
# ★ NLINK 那列是 0 —— 没有任何名字指向它了。
# === ★ 把"被删文件占的空间"按进程汇总,看谁是大头 ===
$ lsof -nP +L1 | awk '{s[$1"/"$2]+=$8} END{for(k in s) print s[k], k}' | sort -rn | head
26843545600 java/9527
314572800 nginx/3310
# ★ 一眼看出:java 进程 9527,一个人就占了 25G。
# === 认知 ===
# ★ df 比 du 多出来的空间,答案就在 lsof | grep deleted
# 里。rm 删不掉它们(名字早没了),
# 要"动它背后那个还开着它的进程"才行。
修复 3:怎么把这部分空间真正收回来
# === ★ 收回空间:让进程"松手",不是再去 rm ===
# === 为什么不能再 rm ===
# 这个文件的【名字】早就没了(就是当初那次 rm 删掉的)。
# 你现在 ls 根本看不到它,自然也无从 rm。
# ★ 要释放它的空间,只有一条路:让那个还开着它的
# 进程,把对应的【文件描述符】关掉(或者进程退出)。
# === ★ 解法 1(最干净):重启 / 优雅重载那个进程 ===
# 进程一退出,它打开的所有文件描述符全部关闭,内核
# 立刻释放那个"已删除文件"的空间。
$ systemctl restart 那个服务 # 或优雅 reload
# ★ 我这次:重启那个 java 服务,df 里的 25G 当场回来。
# 对支持 reload 的服务(如 nginx),reload 也能让它
# 重新打开日志文件、放掉旧的那个。
# === ★ 解法 2(不重启进程):清空文件描述符指向的内容 ===
# 不能重启进程怎么办?可以【不删文件、直接把内容清空】。
# 先从 lsof 拿到 PID 和 FD 号(比如 PID 9527、FD 5):
$ lsof -nP +L1 | grep java
java 9527 app 5w REG ... /www/logs/app.log (deleted)
# ^^^ FD 是 5
# 进程打开的每个 fd,在 /proc 下都有对应的入口:
$ ls -l /proc/9527/fd/5
lrwx------ ... /proc/9527/fd/5 -> /www/logs/app.log (deleted)
# ★ 往这个 fd 写一个空,就把那 25G 的内容清掉了:
$ : > /proc/9527/fd/5 # 或 truncate -s 0 /proc/9527/fd/5
# ★ 进程的 fd 还在、还能继续写,但文件内容被清零,
# 25G 空间立刻释放。这是"不能停服务"时的救命招。
# === ★ 一个重要提醒:别用 rm 删正在被写的活动日志 ===
# 这次事故的病根,就是有人对一个正在被写的日志文件
# 直接 rm。结果名字没了、空间没还、进程还在闷头写。
# ★ 要清空一个还在被进程写的日志,正确做法是【清空
# 内容】,而不是删掉文件:
$ : > /www/logs/app.log # ★ 清空,文件还在,inode 不变
# 而不是:
$ rm /www/logs/app.log # ✗ 名字没了,空间也收不回
# === 验证 ===
$ df -h / # 空间回来了
$ lsof -nP +L1 # 不再有大块的 (deleted) 文件
# ★ df 和 du 重新对得上(或差距很小),才算真正收干净。
修复 4:磁盘满的其他常见原因
# === ★ "已删除文件占用"是经典原因,但不是唯一 ===
# === ★ 原因 1:inode 用尽 —— 空间还在,却写不进去 ===
# 一个文件系统,数据块有上限,inode 数量【也有上限】。
# 海量小文件会把 inode 耗光 —— 这时 df -h 看着还有
# 空间,但创建任何新文件都报 "No space left"。
$ df -i # ★ -i 看的是 inode 用量
Filesystem Inodes IUsed IFree IUse% Mounted on
/dev/vda1 3276800 3276800 0 100% / # ★ inode 满了
# ★ df -h 看空间,df -i 看 inode,两个都要看。
# 常见元凶:某个目录堆了几百万个小文件(session、
# 缓存、邮件队列)。找出来:
$ for d in /var/* /tmp; do echo "$(find $d 2>/dev/null|wc -l) $d"; done | sort -rn
# === 原因 2:大文件还在、有名字 —— 这种 du 找得到 ===
# 最普通的一种:就是有个大日志/大文件,名字好端端的。
$ du -ah / 2>/dev/null | sort -rh | head -20 # ★ 列出最大的 20 个
$ find / -xdev -type f -size +1G 2>/dev/null # 找 1G 以上的文件
# ★ -xdev:只在当前文件系统找,不跨挂载点。
# === ★ 原因 3:文件被"挂载点"盖住了 ===
# 一个目录(比如 /data)本来有文件,后来往 /data 上
# 挂了一块新盘。原来 /data 里的文件还在根分区上,
# 但被挂载点【盖住】了,du 进 /data 看到的是新盘。
$ du -sh /data # 看到的是挂载后的内容
# ★ 临时把挂载点解开,看下面藏了多少:
$ umount /data && du -sh /data # 看被盖住的旧文件
# 或者用 mount --bind 看原始目录,不打扰现有挂载。
# === 原因 4:systemd journal 日志撑大 ===
# systemd 的二进制日志,默认会一直长。
$ journalctl --disk-usage # 看 journal 占了多少
$ journalctl --vacuum-size=200M # ★ 只保留 200M
# === 原因 5:/tmp、/var/log 没人清 ===
$ du -sh /tmp /var/log /var/cache
# ★ 老服务器上,这几个目录是磁盘满的常客。
# === ★ 通用排查顺序 ===
$ df -h # ① 看哪个分区满了
$ df -i # ② 看是不是 inode 满了
$ du -ah 那个分区 | sort -rh | head # ③ 有名字的大文件
$ lsof -nP +L1 # ④ 已删除却被占用的文件
# 四步走完,磁盘满的原因基本跑不掉。
修复 5:正确解法——救急、根治、防复发
# === ★ 解法分三层:先救急,再根治,最后防复发 ===
# === ★ 解法 1:救急 —— 先腾出一点点空间 ===
# 磁盘 100% 时,很多操作会失败,系统会很不稳。先弄
# 出一点空间让自己能喘口气:
$ journalctl --vacuum-size=100M # 砍 journal
$ yum clean all # 清包缓存(Ubuntu: apt clean)
$ find /tmp -type f -mtime +7 -delete # 清 7 天前的临时文件
# ★ 不要急着大刀阔斧删东西 —— 先腾够"能正常排查"的
# 空间就行,真正的大头要靠下面定位。
# === ★ 解法 2:根治 —— 按 df/du 差额找已删除文件 ===
# 这次的 25G 黑洞,根治就一步:
$ lsof -nP +L1 # 找到那个 (deleted) 大文件
$ systemctl restart 那个服务 # 让进程松手,空间立刻回来
# 不能重启就用修复 3 的 : > /proc/PID/fd/N 清空。
# === ★ 解法 3:防复发 —— 别再 rm 活动日志,交给 logrotate ===
# 病根是"有人手动 rm 了正在写的日志"。正确做法是
# 把日志切割交给 logrotate,它知道怎么和进程配合。
$ cat /etc/logrotate.d/myapp
/www/logs/*.log {
daily # 每天切
rotate 7 # 留 7 份
size 500M # 超过 500M 也切
compress # 旧的压缩
missingok
notifempty
copytruncate # ★ 关键:见下
}
# ★ copytruncate:先把日志【复制】成一份归档,再把
# 原文件【清空(truncate)】。原文件的 inode 不变,
# 进程的 fd 一直有效,继续往同一个文件写 —— 全程
# 不需要进程配合、不需要重启。专为"程序不会主动
# 重开日志文件"的情况设计。
# ★ 如果程序支持收到信号后重开日志(nginx 那样),
# 就用 postrotate 发信号,不必 copytruncate。
# === ★ 解法 4:加监控,别等 100% 才发现 ===
# 磁盘到 100% 才报警,已经晚了 —— 那时系统都快写不动。
# 阈值设在 80% / 90%,留出处理的时间。
$ df -h | awk 'NR>1 && int($5)>85 {print "WARN 磁盘超 85%: "$0}'
# 把这种检查挂进定时任务 + 告警。
# === 验证清单 ===
$ df -h / # 使用率降下来了
$ df -i / # inode 也没满
$ lsof -nP +L1 # 没有大块已删除文件
$ du -sh / ; # 和 df 的 Used 对得上 # 两者重新一致
# ★ 四项都正常,这次磁盘满才算真正根治、且防住复发。
修复 6:磁盘空间排查纪律
# === 这次事故暴露的认知盲区,定几条纪律 ===
# === 1. ★ df 和 du 数的不是一回事,对不上是有意义的信号 ===
# df 看磁盘块占用,du 顺着文件名加总。差额 = 没名字的文件。
# === 2. ★ rm 删的是"文件名",不是"文件" ===
# 只要还有进程开着它,空间一点都不会还。
# === 3. ★ df 远大于 du,头号嫌疑:已删除但被占用的文件 ===
$ lsof -nP +L1 # 或 lsof | grep '(deleted)'
# === 4. 收回这种空间靠"让进程松手":重启 或 : > /proc/PID/fd/N ===
# === 5. ★ 永远别 rm 正在被写的活动日志,要清空就 : > 文件 ===
# === 6. 磁盘满别只看 df -h,inode 也会满,df -i 一起看 ===
# === 7. 文件可能被挂载点盖住,du 进不去,必要时 umount 看 ===
# === 8. ★ 救急先腾一点空间,但大头要靠 lsof / du 定位,别乱删 ===
# === 9. 日志切割交给 logrotate,copytruncate 适合不会重开日志的程序 ===
# === 10. 排查"磁盘满"的步骤链 ===
$ df -h # ① 哪个分区满
$ df -i # ② 是不是 inode 满
$ du -ah 分区 | sort -rh | head # ③ 有名字的大文件
$ lsof -nP +L1 # ④ 已删除却被占用的文件
$ 重启进程 / : > /proc/PID/fd/N # ⑤ 对症释放
# 按这个顺序,磁盘满基本能定位、能根治。
命令速查
需求 命令
=============================================================
看各分区空间使用 df -h
看各分区 inode 使用 df -i
看某目录下各项体积 du -sh /*
列出最大的文件 du -ah / | sort -rh | head -20
找大于 1G 的文件 find / -xdev -type f -size +1G
找已删除但被占用的文件 lsof -nP +L1
找已删除文件(另一种写法) lsof -nP | grep '(deleted)'
看某进程的某个 fd ls -l /proc/PID/fd/FD号
清空一个 fd 的内容 : > /proc/PID/fd/FD号
清空一个日志文件 : > /path/to/app.log
看 journal 日志占用 journalctl --disk-usage
压缩 journal 到指定大小 journalctl --vacuum-size=200M
口诀:df 看磁盘块占用,du 顺着文件名加总,df 远大于 du 就是已删除文件还被进程占着
rm 删的是名字不是文件,进程不松手空间不还,lsof +L1 揪它,别 rm 活动日志
避坑清单
- df 和 du 数的不是同一个东西,df 看文件系统磁盘块占用,du 顺着目录树按文件名加总
- du 必须能通过文件名找到一个文件才能算进去,没有名字的文件 du 完全看不见但 df 照算
- rm 删的是目录里那个指向文件的名字,不是文件本身,真名其实是 unlink 取消链接
- 内核释放文件空间要同时满足指向它的名字全没了且没有任何进程还开着它,缺一不可
- df 远大于 du 且差得多,头号嫌疑是有已被删除但仍被进程开着的文件占着空间
- 用 lsof -nP +L1 或 lsof grep deleted 揪出这种文件,看 SIZE 列和是哪个进程开着它
- 这种空间靠 rm 收不回名字早没了,要让进程松手,重启进程或 : > /proc/PID/fd/N 清空
- 永远别用 rm 删正在被进程写的活动日志,要清空就用 : > 文件,文件还在 inode 不变
- 磁盘满别只看 df -h,inode 也会被海量小文件耗尽,df -i 要一起看,两者都可能 100%
- 日志切割交给 logrotate,程序不会主动重开日志文件时用 copytruncate 边复制边清空
总结
这次"磁盘满了、却有一半空间凭空消失"的事故,纠正了我一个关于"删除"的、几乎刻在本能里的理解。在我的脑子里,"删除一个文件"这件事,一直是一个干净、彻底、即时完成的动作:我敲下 rm,那个文件就从这个世界上消失了,它占的空间,在那一瞬间,原封不动地、立刻地,还给了磁盘。删除就是删除,它是一个点,不是一段过程;rm 一回车,事情就结束了。正因为这个理解在我心里太天经地义,所以当 df 和 du 对不上的时候,我的第一反应根本不是"是不是有文件没被真正删掉",而是"这两个命令里,一定有一个算错了"。我反复地、固执地重跑 du,加各种参数,把隐藏文件也数上——我做的所有努力,都建立在一个我从未质疑的前提上:那 25G 的差额,是 du 漏数了,只要我数得足够仔细,这两个数字就该严丝合缝地对上。我压根没有想过,问题不在于"哪个数字错了",而在于,这两个数字本来就在数两样不同的东西;我更没有想过,那 25G 空间,是被一个我以为早已"删掉了"的文件,稳稳地占着。复盘到根上,我才明白,我把"文件"这个东西,理解得太囫囵、太一体了。在我朴素的认知里,一个文件就是一个文件:它有个名字,名字底下就是内容,名字和内容是焊死在一起、不可分割的一个整体——删掉名字,就等于删掉内容。可 Linux 里的真实结构,是把这一个"文件"拆成了好几层:真正存着数据、占着磁盘空间的,是一个叫 inode 的本体,而它自己是没有名字的;我们平时所说的"文件名",其实是另一样东西——是某个目录里的一条记录,这条记录像一根指针,把一个名字指向那个 inode。名字和本体,是分开的两样东西,中间靠"指"这个动作连着。而 rm 这个命令,我一直以为它在"删文件",可它真正的名字其实叫 unlink——取消链接。它做的,仅仅是把"名字指向 inode"的那根指针剪断,把目录里那一条记录划掉。至于 inode 本身、至于那 25G 实实在在的数据,它根本没碰。内核要真正回收一个文件的空间,有它自己的、严格的条件:必须指向这个 inode 的名字全部消失了,并且,没有任何一个进程还开着它——这两个条件,缺一个都不行。而我的事故,恰恰就卡在第二个条件上:有人 rm 掉了那个日志的名字,第一个条件满足了;可那个 java 进程从头到尾都在运行,它手里一直攥着这个文件的描述符,在闷头往里写。于是这个文件就进入了一种我从未想象过的、悬浮的状态:它没有名字了,所以 ls 找不到它、du 顺着目录树也走不到它,它在"文件名的世界"里已经死了;可它又被一个进程牢牢开着,所以内核绝不会动它的空间,它在"磁盘的世界"里活得好好的,稳稳占着 25G。df 和 du 那个对不上的差额,丈量的正是这种"半死不活"的文件——它们没有名字、却占着空间。这次最大的收获,是我意识到,当两个本该一致的数字对不上时,最廉价、也最容易让人偷懒的解释是"其中一个错了",而这个解释,往往会把你引向一整天的徒劳。真正有价值的追问是反过来的:它们俩,是不是从一开始就在测量两样不同的东西?df 站在磁盘的角度数"块被占用了多少",du 站在目录树的角度数"顺着名字能找到多少"——平时它俩的结果碰巧一致,只是因为绝大多数文件既有名字、又占着空间;而它们一旦分道扬镳,那个差额本身,就是一条信息量极大的线索,它在清清楚楚地告诉你:这台机器上,存在着"有空间、却没有名字"的文件。所以下一次,当两个我以为该相等的数字摆在面前却不相等时,我不会再急着去判定谁对谁错了。我会先静下来问一句:这两个数字,各自到底是怎么数出来的?它们站的位置一样吗?它们看的,真的是同一个东西吗?——答案,常常就藏在这个被我习惯性跳过的问题里。
—— 别看了 · 2026