磁盘报满 du 和 df 却差了 60G:一次 Linux 幽灵文件排查复盘

一台服务器根分区告警用了 96%,可 du 把所有目录加起来只有 33G,df 却说用了 96G,差了 60 多 G 怎么数都对不上。排查梳理:rm 删的是目录里指向数据的文件名而不是文件数据本身,文件数据要等到没有任何名字指向它且没有任何进程打开它两个条件同时满足才被回收;du 顺着目录树按文件名累加,被删掉名字的文件 du 数不到所以偏小,df 数的是文件系统实际占用的块所以偏大,df 明显大于 du 就是有被删除但仍被进程打开的幽灵文件;用 lsof grep deleted 揪出幽灵文件,第七列大小排序找占空间最多的,/proc/PID/fd 能看进程攥着哪个已删除文件;诱因多是 rm 了正在被写入的日志或 logrotate 配置不当;正确解法是重启持有它的进程让 fd 全关空间立刻回收、不能重启时用冒号重定向清空 /proc/PID/fd/N、日志切割改用 copytruncate 或 postrotate 发信号、硬链接造成的用 find -inum 找出所有名字删光,以及一套磁盘空间排查纪律。

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

避坑清单

  1. rm 删除的是目录里指向数据的文件名,不是文件数据本身,空间不一定当场释放
  2. 文件数据要等到没有任何名字指向它且没有任何进程打开它,两个条件同时满足才被回收
  3. du 是顺着目录树按文件名累加,被删掉名字的文件 du 数不到,所以 du 会偏小
  4. df 数的是文件系统实际占用的块,只要数据还在空间没还,df 就照算,所以 df 偏大
  5. df 明显大于 du,基本就是有被删除但仍被进程打开的幽灵文件占着空间
  6. 用 lsof grep deleted 揪出幽灵文件,第七列是大小,排序就能找到占空间最多的
  7. 重启持有幽灵文件的进程最干净,进程退出所有 fd 自动关闭空间立刻回收
  8. 不能重启进程时可用冒号重定向清空 /proc/PID/fd/N,仅适合日志这类可丢内容的文件
  9. 别直接 rm 正在被写入的日志,日志切割要用 copytruncate 或 postrotate 发信号通知进程
  10. du 反过来比 df 大多半是硬链接,用 ls -li 看链接数,find -inum 找出该 inode 所有名字

总结

这次"磁盘报满、却怎么也找不到那 60 多 G 去哪了"的事故,纠正了我一个关于"删除"的、几乎是本能般的误解。在我的脑子里,rm 这个动作的含义,简单、干脆、不容置疑:删除,就是让一个文件消失,连同它占用的空间一起,当场归还。这个理解太符合直觉了——我在图形界面里把文件拖进回收站、清空,东西就没了,空间就回来了;命令行里 rm 一下,无非是同一件事更直接的版本。正因为我对"删除等于空间立刻回收"这件事如此地深信不疑,所以当 dfdu 给出两个相差 60G 的数字时,我的全部精力,都耗在了一个错误的假设上:我假设这 60G 一定是以某个"我还能看见的文件"的形式存在的,我只是没找到它而已。于是我一遍遍地 du,我加上各种参数,我去翻隐藏目录——我做的所有努力,都是在那棵我看得见的目录树上,反复地搜寻。我从来没有想过,那 60G,根本就不在这棵树上,它属于一些"已经没有名字"的文件。复盘到根上,我才明白,rm 这个命令,它真正的名字其实叫 unlink——"取消链接"。这个名字,远比"删除"诚实。在 Linux 的世界里,一个文件被拆成了两半:一半是它的数据本体,连同一个记录着"我有多大、存在哪、还有几个名字指着我"的身份证,叫 inode;另一半,才是我们平时挂在嘴边的"文件名"——它仅仅是某个目录里的一行小小的记录,一个指向 inode 的箭头而已。rm 干的事,从来都不是去销毁那个数据本体,它只是抹掉了那一个箭头。而那份数据,什么时候才真正死去、空间才真正归还?要等到两件事同时成立:再没有任何一个箭头指向它,并且,再没有任何一个进程正开着它。我这次踩的坑,就坑在第二个条件上:那个 64G 的旧日志,它的名字确实被人 rm 掉了,可我的 Java 服务,从几个月前启动的那一刻起,就一直攥着这个文件的句柄在往里写日志。名字没了,所以顺着目录树数数的 du 再也找不到它;可数据还在、进程还开着,所以按分区实打实计账的 df,一个字节都没给它免单。这个文件就这样变成了一个"幽灵"——它对 ls 隐形,却对磁盘的剩余空间有着沉甸甸的、真实的重量。这次最大的收获,是我意识到,我对一个日常操作的"直觉",往往是这个操作在某一种环境下的"表象"被我反复强化、最后固化成的"信念",而我从来没有去追问过这个表象之下,真正发生的是什么。我以为我懂"删除",其实我只是无数次见过"删除之后东西不见了"这个结果,然后理所当然地以为,结果就等于过程。我把"文件名消失"和"数据被回收"这两件可以同时发生、也可以隔开很久才发生的事,在脑子里粗暴地焊成了一件事。一旦现实把它们重新拆开——文件名早就没了,数据却还赖着不走——我整个人就懵了,因为我的认知里根本没有"它们是两件事"这个概念。所以下一次,当我对着两个本该一致、却偏偏对不上的数字发懵时,我会提醒自己:与其怀疑某一个数字是不是错了、是不是我漏看了,不如先退一步,去搞清楚这两个数字,它们各自到底是【顺着什么、用什么方法数出来】的。du 顺的是文件名,df 数的是磁盘块——一旦我看清了它们是用两种完全不同的方式在丈量同一块盘,那个让我抓狂的 60G 缺口,非但不再是悖论,它本身就成了最响亮的那条线索:它在告诉我,这块盘上,正躺着一批有数据、却没有名字的东西。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
Linux教程

load 飙到 48 CPU 却闲着:一次 Linux 负载高排查复盘

2026-5-20 21:23:42

Linux教程

脚本手动跑正常 cron 里就不执行:一次 Linux 定时任务排查复盘

2026-5-20 21:33:31

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索