2024 年,一台服务器的磁盘告警了:根分区用了 96%。我登上去,准备清一清,把那些占地方的大文件找出来删掉。我习惯性地从根目录开始 du -sh 一层一层往下挖,挖了半天,把所有目录加起来,算出来总共也就用了 30 多 G。可这块盘是 100G 的,df 明明白白告诉我用了 96G。30G 和 96G,中间差了整整 60 多 G——我一个目录一个目录数过来的,怎么会凭空多出 60G?我第一反应是自己漏数了某个目录,于是又从头 du 了一遍,把 /proc、/sys 这些都算上,结果还是 30 多 G。我又怀疑是不是有隐藏目录,du 加上 -a 把所有文件都列出来翻,还是对不上。我盯着 df 的 96G 和 du 的 30G 想了很久,这两个数字都来自同一块盘,却互相矛盾,这说明我对"磁盘空间是怎么被占用、又是怎么被释放"这件事的理解,根上就缺了一块。最后我才反应过来:有 60 多 G 的空间,被一些"已经不存在的文件"占着——它们被 rm 删掉了,可空间一点没还回来。这件事逼着我把 Linux 的文件删除机制、硬链接、inode、进程持有的文件句柄这一整套彻底理清了。本文复盘这次实战。
问题背景
环境:CentOS 7,一台服务器,跑着一个 Java 服务
事故现象:
- 磁盘告警:根分区用了 96%
- ★ du 把所有目录加起来只有 30 多 G,df 却说用了 96G
- 差了 60 多 G,怎么数都对不上
现场排查:
# 1. df 看分区用量
$ df -h /
Filesystem Size Used Avail Use% Mounted on
/dev/vda1 100G 96G 4.0G 96% / # ★ df:用了 96G
# 2. du 把整个根目录加起来
$ du -sh --exclude=/proc --exclude=/sys /
33G / # ★ du:只有 33G
# 3. ★ 差了 60 多 G —— 找"被删了但还占着空间"的文件
$ lsof | grep '(deleted)'
java 9400 app 5w REG 253,1 64000000000 ... /opt/app/logs/app.log (deleted)
# ^^^^^^^^^^^ ^^^^^^^^^
# ★ 这一个文件就 64G ★ 状态:已删除
# 4. ★ 看是哪个进程还攥着它
$ ls -l /proc/9400/fd | grep deleted
l-wx------ 1 app app 64 ... 5 -> /opt/app/logs/app.log (deleted)
# ★ 进程 9400(java)的 fd 5,还指着一个已删除的 64G 文件
根因(后来想清楚的):
1. ★ 在 Linux 里,rm 一个文件,删的【不是文件内容】,
删的只是【目录里指向它的那个名字】(链接)。
2. 一个文件的真实数据,什么时候才被回收?要等到两个
条件【同时】满足:① 没有任何名字再指向它;
② ★ 没有任何进程还【打开着】它。
3. 之前有人 rm 了那个 64G 的旧日志 app.log —— 名字
是没了(条件①满足),但 ★ java 进程一直开着它
在写(条件②不满足)。
4. ★ 于是:文件名消失了 -> du 顺着目录树数,数不到它
-> du 显示 33G。但数据还在、空间没还 -> df 按
分区实际占用算 -> df 显示 96G。
5. java 进程不关掉这个 fd(或不重启),这 64G 就
永远【既看不见、又占着】。
du 顺着"文件名"数,df 数的是"实际占用",删了没释放就对不上。
修复 1:rm 到底删了什么——删的是名字,不是数据
# === ★ 先纠正最核心的误解:rm 不是"删数据" ===
# === 我以为的 rm vs 真实的 rm ===
# 我以为:rm 一个文件,就是把这个文件的内容从磁盘上
# 抹掉,空间当场就还回来了。
# ★ 真相:rm 删的,只是【目录里那个指向数据的名字】。
# 文件的真实数据(以及它占的空间),不一定被回收。
# === ★ 一个文件,其实是"名字"和"数据"两部分 ===
# 在 Linux 里,一个你以为是"一个文件"的东西,实际是:
# - inode:文件的【本体】。数据存在哪、多大、权限、
# 谁拥有 …… 全在 inode 里。inode 不含文件名。
# - 目录项(名字):目录里的一行记录,把一个【名字】
# 指向一个【inode 编号】。
# ★ 你看到的"文件名",只是一个指向 inode 的【指针】。
# 数据真正的归属,是 inode,不是名字。
# === ★ rm 干的事:把名字和 inode 的连接断开 ===
# rm 这个命令,它真正的系统调用叫 unlink ——
# "取消链接"。它做的就一件事:把目录里那个
# "名字 -> inode" 的记录划掉。
# ★ 它【没有】直接去碰 inode,也没去碰数据。
# === ★ inode 里有个计数器:链接数 ===
$ ls -l app.log
-rw-r--r-- 2 app app 1024 ... app.log
# ^ ★ 这个数字 = 硬链接数 = 有几个名字指向这个 inode
# inode 里记着【有多少个名字指向我】(硬链接数)。
# - 你新建一个文件:链接数 = 1。
# - rm 一次:链接数减 1。
# - ★ 链接数减到 0,这个 inode 才【有资格】被回收。
# === ★ 但"链接数为 0"还不够 ===
# 就算名字全删光、链接数到 0 了,只要还有进程
# 【正开着这个文件】,内核就【不会】回收它的数据 ——
# 因为那个进程还在用呢,回收了它就读写到空气了。
# 内核会等到:链接数 0 且 没有进程打开它,
# 两个条件【都满足】,才真正回收数据、释放空间。
# === 一句话认知 ===
# ★ 删名字 ≠ 删数据。数据要等到"没名字 + 没人开着"
# 才被回收。这中间的空档,就是 du 和 df 对不上的根源。
修复 2:du 和 df 为什么会对不上
# === ★ 看懂 du 和 df:它俩根本是两种数法 ===
# === du 是怎么数的 ===
# du = disk usage。它的数法是:从你给的目录开始,
# ★ 顺着【目录树】一层层走,看到一个文件名,
# 就去查它的大小,然后累加。
# ★ 关键:du 是【顺着文件名】数的。
# 一个文件如果【没有名字了】(被 rm 了),
# du 走遍整棵目录树也【遇不到它】—— 数不到。
# === df 是怎么数的 ===
# df = disk free。它【不走目录树】。它直接问文件系统:
# "你这个分区,一共有多少块,已经用掉了多少块?"
# ★ 关键:df 数的是文件系统【实际占用的块】。
# 一个文件只要数据还在、空间没被回收,
# 不管它还有没有名字,df 都【算它头上】。
# === ★ 所以对不上的那一刻是这样的 ===
# 一个 64G 的文件被 rm 了,但有进程还开着它:
# - 它【没有名字】了 -> du 数不到它 -> du 少算 64G
# - 它的【数据还在、空间没还】-> df 照算 -> df 多 64G
# ★ 这就是 du 33G、df 96G,差出 60 多 G 的全部原因。
# === ★ 一个判断口诀 ===
# - du < df(du 偏小):有"被删但没释放"的文件 ——
# ★ 我这次,最常见。
# - du > df(du 偏大):多半是有【硬链接】,
# 同一份数据被多个名字指向,du 顺名字数会重复计。
# (du 默认对同一 inode 只算一次,但跨目录统计时
# 用法不对可能重复;另外稀疏文件也会让两者不一致)
# === 快速确认是不是"删了没释放" ===
$ df -h / # 记下 Used
$ du -sh --exclude=/proc --exclude=/sys / # 和 du 比
# ★ df 明显大于 du,基本就是有被删除但仍被占用的文件。
# 注意:du 要排除 /proc /sys 这种虚拟文件系统,
# 它们不是真实磁盘占用,算进去反而干扰判断。
修复 3:揪出"被删了还占着空间"的文件
# === ★ 核心命令:lsof 找出被删除但仍打开的文件 ===
# === 一条命令揪出元凶 ===
$ lsof | grep '(deleted)'
java 9400 app 5w REG 253,1 64000000000 1234 /opt/app/logs/app.log (deleted)
# ★ 逐列看清楚:
# - java :占着它的【进程名】
# - 9400 :★ 进程 PID(待会要用)
# - 5w :★ 文件描述符 5,w = 以写方式打开
# - 64000000000 :★ 这个文件多大 —— 64G!
# - .../app.log (deleted) :★ 文件路径,(deleted) = 已删除
# ★ "(deleted)" 是关键标记:文件名没了,但进程还开着。
# === ★ 按占用大小排序,先抓最大的 ===
$ lsof | awk '/\(deleted\)/{print $7, $1, $2, $NF}' | sort -rn | head
64000000000 java 9400 /opt/app/logs/app.log(deleted) # ★ 最大
2100000000 nginx 8800 /var/log/nginx/access.log(deleted)
# ★ 第 7 列是大小,排个序,占空间最多的"幽灵文件"
# 立刻浮出水面。
# === 第二条路:从 /proc 看进程的 fd ===
$ ls -l /proc/9400/fd | grep deleted
l-wx------ 1 app app 64 ... 5 -> /opt/app/logs/app.log (deleted)
# ★ /proc//fd/ 下,每个 fd 都是一个软链接,
# 指向 (deleted) 的,就是这个进程攥着的"幽灵文件"。
# === ★ 算一下某进程攥着多少"幽灵空间" ===
$ lsof -p 9400 | awk '/\(deleted\)/{s+=$7}END{print s/1024/1024/1024" GB"}'
59.6 GB
# ★ 一目了然:进程 9400 一个,就攥着 59.6G 的已删除文件。
# 这就是 df 比 du 多出来的那部分。
# === 没装 lsof 时的替代办法 ===
$ for p in /proc/[0-9]*; do
ls -l $p/fd 2>/dev/null | grep -q deleted && echo "$p 有幽灵文件"
done
# ★ 遍历所有进程的 fd,带 deleted 的就是嫌疑进程。
# === ★ 认知 ===
# 磁盘"凭空"少了空间,十有八九是这类被删除但仍被
# 进程打开的文件。lsof | grep deleted 是它的照妖镜。
修复 4:为什么会出现"删了还占着"——常见诱因
# === ★ 搞清楚:什么操作会制造出"幽灵文件" ===
# === 诱因 1:rm 了正在被写入的日志 ===
# 最经典的场景(我这次就是):
# - 一个服务一直开着 app.log 在写日志。
# - 磁盘紧张,有人手动 rm 了这个"看着很大"的 app.log。
# ★ 结果:名字删了,但服务的 fd 还开着,它【继续往
# 那个没有名字的文件里写】,文件还在涨,空间还在涨,
# 只是你 ls 已经看不见它了。
# === ★ 诱因 2:日志切割(logrotate)配置不当 ===
# logrotate 切日志,有两种做法:
# - ★ 错误做法:直接 rm/mv 老日志,但不通知进程。
# 进程的 fd 还指着老文件,新日志继续写进"幽灵"里,
# rm 出来的空间根本没释放。
# - 正确做法:切割后给进程发信号(copytruncate,
# 或让进程 reopen 日志文件)—— 见修复 5。
# === 诱因 3:程序自己创建临时文件后 unlink ===
# 有些程序会【故意】:open 一个临时文件 -> 立刻
# unlink 删掉名字 -> 但保持 fd 打开继续用。
# ★ 这其实是一种【正当技巧】:文件没名字,别人看不见、
# 动不了,进程退出时空间自动释放,很安全。
# —— 这种是"良性"的,不用管它。
# === ★ 怎么区分"良性"和"该处理" ===
# - 良性:小临时文件、进程很快就退出 —— 空间自然回收。
# - ★ 该处理:被删的是【大日志/大数据文件】,
# 而那个进程是【常驻服务,几个月不重启】——
# 它会一直攥着,空间永远回不来。
# 判断标准:文件多大 + 进程会不会很快退出。
# === 诱因 4:删了文件却没删干净的"假删除" ===
# 还有一种:文件你以为删了,其实它还有别的【硬链接】。
$ ls -li bigfile
123456 -rw-r--r-- 3 ... # ★ 链接数是 3,不是 1
# ★ 链接数 > 1,说明这个 inode 还有别的名字。
# 你 rm 了这一个名字,数据一点没少 —— 还有 2 个名字。
$ find / -inum 123456 # 按 inode 号找出它所有的名字
# ★ 要真正释放,得把指向这个 inode 的名字【全部】删掉。
修复 5:正确解法——释放空间的几种姿势
# === ★ 解法:让那个"幽灵文件"的空间真正回到分区 ===
# === ★ 解法 1(最干净):重启持有它的进程 ===
# 进程一退出,它打开的所有 fd 自动关闭,
# "没名字 + 没人开" 两个条件同时满足,
# 内核【立刻】回收那 64G,空间瞬间回来。
$ systemctl restart myapp.service
$ df -h / # ★ Used 应该当场掉下去
# ★ 这是最彻底的办法。代价:服务要重启一下。
# === ★ 解法 2(不重启):清空那个 fd 指向的文件 ===
# 不想重启服务?可以【不删文件,而是把它清空】。
# 关键:通过 /proc//fd/ 这个"还活着的入口"操作:
$ : > /proc/9400/fd/5
# ^ 把进程 9400 的 fd 5 指向的文件内容清空为 0
# ★ 文件还在、fd 还在、进程无感,但 64G 数据被清掉,
# 空间立刻释放。进程会从头继续往里写。
# ★ 注意:这适合"幽灵文件"是日志这种【可以丢弃
# 内容】的;如果是重要数据文件,别这么干。
# === ★ 解法 3:正确配置 logrotate,从源头杜绝 ===
# 日志切割要让进程"知道"文件被换了。两种正确姿势:
# (A) copytruncate:logrotate 先把日志复制一份,
# 再把【原文件清空】(truncate),而不是删除它。
# 进程的 fd 还指着原文件,继续写,空间正常。
$ cat /etc/logrotate.d/myapp
/opt/app/logs/*.log {
daily
rotate 7
copytruncate # ★ 关键:不删文件,清空它
compress
}
# (B) postrotate 发信号:切割后给进程发信号,
# 让进程【重新打开】日志文件。
/opt/app/logs/*.log {
daily
rotate 7
postrotate
kill -USR1 $(cat /var/run/myapp.pid) # ★ 通知进程 reopen
endscript
}
# ★ 应用自己负责切日志的(如 Java logback),
# 就让应用自己滚动,别再用 logrotate 去 rm 它。
# === ★ 解法 4:硬链接造成的,要删光所有名字 ===
$ ls -li bigfile # 拿到 inode 号
$ find /mountpoint -inum 123456 # 找出该 inode 的所有名字
$ rm 找到的所有名字 # ★ 全删掉,链接数才到 0
# ★ 注意 find -inum 只在同一个文件系统内有效。
# === 验证空间真的回来了 ===
$ df -h / # Used 下降
$ du -sh --exclude=/proc --exclude=/sys / # 现在该和 df 接近了
$ lsof | grep '(deleted)' | wc -l # 幽灵文件应大幅减少
# ★ df 和 du 重新对得上 = 空间真正释放了。
修复 6:磁盘空间排查纪律
# === 这次事故暴露的认知盲区,定几条纪律 ===
# === 1. ★ rm 删的是文件名,不是数据 ===
# 数据要等"没名字 + 没进程打开"两条件同时满足才回收。
# === 2. ★ du 顺文件名数,df 数实际占用,所以会对不上 ===
$ df -h / ; du -sh --exclude=/proc --exclude=/sys /
# df 明显大于 du = 有被删除但仍被占用的文件。
# === 3. ★ 揪幽灵文件:lsof | grep '(deleted)' ===
$ lsof | grep '(deleted)' | sort -k7 -rn
# === 4. 不重启就释放:: > /proc//fd/ 清空它 ===
# 仅适合日志这类可丢弃内容的文件。
# === 5. 能重启进程最干净,进程退出 fd 全关,空间立刻回收 ===
# === 6. ★ 别直接 rm 正在被写的日志,会制造幽灵文件 ===
# 日志切割用 copytruncate 或 postrotate 发信号通知进程。
# === 7. du 比 df 大,优先怀疑硬链接,find -inum 找所有名字 ===
# === 8. 排查"磁盘满了但找不到大文件"的步骤链 ===
$ df -h # ① 哪个分区满了
$ du -sh /* --exclude=/proc # ② 各目录加起来对不对
$ lsof | grep '(deleted)' # ③ 对不上就找幽灵文件
$ lsof -p PID | grep deleted # ④ 确认是哪个进程攥着
$ : > /proc/PID/fd/N 或 重启进程 # ⑤ 清空或重启,释放空间
# 按这个顺序,磁盘"凭空被占"基本能定位、能根治。
命令速查
需求 命令
=============================================================
看分区用量 df -h
看目录占用(排除虚拟fs) du -sh --exclude=/proc --exclude=/sys /
逐个目录看占用 du -sh /* 2>/dev/null
揪出被删但仍打开的文件 lsof | grep '(deleted)'
看某进程攥着的已删除文件 lsof -p PID | grep deleted
看进程的 fd 指向 ls -l /proc/PID/fd
不重启清空幽灵文件 : > /proc/PID/fd/N
看文件的硬链接数和inode ls -li 文件名
按 inode 号找出所有硬链接 find /mountpoint -inum 节点号
看 inode 是否耗尽 df -i
口诀:rm 删的是名字不是数据,du 顺名字数 df 数实际占用,对不上就是幽灵文件
lsof grep deleted 揪元凶,重启进程或清空 fd 释放,日志切割用 copytruncate
避坑清单
- rm 删除的是目录里指向数据的文件名,不是文件数据本身,空间不一定当场释放
- 文件数据要等到没有任何名字指向它且没有任何进程打开它,两个条件同时满足才被回收
- du 是顺着目录树按文件名累加,被删掉名字的文件 du 数不到,所以 du 会偏小
- df 数的是文件系统实际占用的块,只要数据还在空间没还,df 就照算,所以 df 偏大
- df 明显大于 du,基本就是有被删除但仍被进程打开的幽灵文件占着空间
- 用 lsof grep deleted 揪出幽灵文件,第七列是大小,排序就能找到占空间最多的
- 重启持有幽灵文件的进程最干净,进程退出所有 fd 自动关闭空间立刻回收
- 不能重启进程时可用冒号重定向清空 /proc/PID/fd/N,仅适合日志这类可丢内容的文件
- 别直接 rm 正在被写入的日志,日志切割要用 copytruncate 或 postrotate 发信号通知进程
- du 反过来比 df 大多半是硬链接,用 ls -li 看链接数,find -inum 找出该 inode 所有名字
总结
这次"磁盘报满、却怎么也找不到那 60 多 G 去哪了"的事故,纠正了我一个关于"删除"的、几乎是本能般的误解。在我的脑子里,rm 这个动作的含义,简单、干脆、不容置疑:删除,就是让一个文件消失,连同它占用的空间一起,当场归还。这个理解太符合直觉了——我在图形界面里把文件拖进回收站、清空,东西就没了,空间就回来了;命令行里 rm 一下,无非是同一件事更直接的版本。正因为我对"删除等于空间立刻回收"这件事如此地深信不疑,所以当 df 和 du 给出两个相差 60G 的数字时,我的全部精力,都耗在了一个错误的假设上:我假设这 60G 一定是以某个"我还能看见的文件"的形式存在的,我只是没找到它而已。于是我一遍遍地 du,我加上各种参数,我去翻隐藏目录——我做的所有努力,都是在那棵我看得见的目录树上,反复地搜寻。我从来没有想过,那 60G,根本就不在这棵树上,它属于一些"已经没有名字"的文件。复盘到根上,我才明白,rm 这个命令,它真正的名字其实叫 unlink——"取消链接"。这个名字,远比"删除"诚实。在 Linux 的世界里,一个文件被拆成了两半:一半是它的数据本体,连同一个记录着"我有多大、存在哪、还有几个名字指着我"的身份证,叫 inode;另一半,才是我们平时挂在嘴边的"文件名"——它仅仅是某个目录里的一行小小的记录,一个指向 inode 的箭头而已。rm 干的事,从来都不是去销毁那个数据本体,它只是抹掉了那一个箭头。而那份数据,什么时候才真正死去、空间才真正归还?要等到两件事同时成立:再没有任何一个箭头指向它,并且,再没有任何一个进程正开着它。我这次踩的坑,就坑在第二个条件上:那个 64G 的旧日志,它的名字确实被人 rm 掉了,可我的 Java 服务,从几个月前启动的那一刻起,就一直攥着这个文件的句柄在往里写日志。名字没了,所以顺着目录树数数的 du 再也找不到它;可数据还在、进程还开着,所以按分区实打实计账的 df,一个字节都没给它免单。这个文件就这样变成了一个"幽灵"——它对 ls 隐形,却对磁盘的剩余空间有着沉甸甸的、真实的重量。这次最大的收获,是我意识到,我对一个日常操作的"直觉",往往是这个操作在某一种环境下的"表象"被我反复强化、最后固化成的"信念",而我从来没有去追问过这个表象之下,真正发生的是什么。我以为我懂"删除",其实我只是无数次见过"删除之后东西不见了"这个结果,然后理所当然地以为,结果就等于过程。我把"文件名消失"和"数据被回收"这两件可以同时发生、也可以隔开很久才发生的事,在脑子里粗暴地焊成了一件事。一旦现实把它们重新拆开——文件名早就没了,数据却还赖着不走——我整个人就懵了,因为我的认知里根本没有"它们是两件事"这个概念。所以下一次,当我对着两个本该一致、却偏偏对不上的数字发懵时,我会提醒自己:与其怀疑某一个数字是不是错了、是不是我漏看了,不如先退一步,去搞清楚这两个数字,它们各自到底是【顺着什么、用什么方法数出来】的。du 顺的是文件名,df 数的是磁盘块——一旦我看清了它们是用两种完全不同的方式在丈量同一块盘,那个让我抓狂的 60G 缺口,非但不再是悖论,它本身就成了最响亮的那条线索:它在告诉我,这块盘上,正躺着一批有数据、却没有名字的东西。
—— 别看了 · 2026