文件删了磁盘空间却没释放:一次 Linux inode 与文件删除机制排查复盘

磁盘 df 显示 100% 满,找到 30G 大日志 rm 掉,df 却纹丝不动还是 100%。排查梳理:df 与 du 看的根本不是一个东西、rm 删的是文件名不是数据、inode 要硬链接数和打开计数双双归零才回收、lsof 揪出已删除却被进程占用的幽灵文件、inode 耗尽是另一种磁盘满、硬链接与软链接的本质区别,以及一套磁盘空间排查纪律。

2024 年的一个深夜,磁盘告警把我从床上拽了起来:一台日志服务器的根分区 df 显示 100% 满了,服务已经开始报"写入失败"。我睡眼惺忪地登上机器,du 一通找,很快锁定了一个 30G 的巨型日志文件,心里一松——找到元凶了。我干净利落地 rm 掉它,准备回去睡觉,顺手又敲了一下 df 确认。结果屏幕上那个数字,纹丝不动,还是 100%。我以为自己看错了,又删了几个文件,df 依然铁板一块。一个 30G 的文件,我亲眼看着 rm 命令执行成功、ls 里它也确实消失了,可磁盘空间就是一点没还回来。文件到底删没删掉?空间到底去哪了?这件事逼着我把 Linux 的文件系统、inode、硬链接、文件删除机制这一整套彻底理清了。本文复盘这次实战。

问题背景

环境:CentOS 7,一台日志服务器,根分区 50G
事故现象:
- df 显示根分区 100% 满,服务写入失败
- 找到一个 30G 大日志文件,rm 删掉了
- df 依然显示 100%,空间没有任何释放

现场排查:
# 1. 确认磁盘确实满了
$ df -h
Filesystem      Size  Used Avail Use% Mounted on
/dev/vda1        50G   50G     0 100%  /        # ★ 满了

# 2. du 找大文件,锁定一个 30G 的日志
$ du -sh /var/log/app/*.log | sort -rh | head
30G  /var/log/app/huge.log
$ rm /var/log/app/huge.log                      # 删掉它
$ ls /var/log/app/huge.log
ls: 无法访问: 没有那个文件或目录          # ★ 文件确实没了

# 3. ★ 可是 df 一点没变
$ df -h /
/dev/vda1        50G   50G     0 100%  /        # ★ 还是 100%!

# 4. ★ 关键命令:找"已删除但仍被进程打开"的文件
$ lsof | grep deleted
java  8123  root  5w  REG  253,1  32G  /var/log/app/huge.log (deleted)
#     ^^^^             ^^^^                                  ^^^^^^^^^
#     进程还开着它      文件类型 REG    它已经被标记为 deleted

根因(后来想清楚的):
1. ★ Linux 删文件,rm 删的是"目录项"(文件名),不是数据本身。
2. 一个文件的数据(inode + 数据块)能否被回收,看它的
   【两个引用计数】:硬链接数,和【打开它的进程数】。
3. huge.log 还被 java 进程开着写日志 -> 打开计数 > 0。
4. ★ 于是 rm 只是把文件名删了,inode 和那 30G 数据块
   【没有被回收】 -> df 看到的空间一点没还。
5. 文件会一直占着空间,直到那个 java 进程关闭这个 fd
   (或进程退出)。删名字不等于删数据。

修复 1:df 与 du 为什么对不上

# === ★ 排查磁盘,第一件事:搞懂 df 和 du 看的不是一个东西 ===

# === df:站在【文件系统】角度,看块设备用了多少 ===
$ df -h /
Filesystem      Size  Used Avail Use%
/dev/vda1        50G   50G     0 100%
# df 问的是文件系统:"你这个分区,一共多少块、用了多少块"
# ★ 它统计的是【块的占用】,跟"文件还有没有名字"无关。

# === du:站在【目录树】角度,把文件一个个加起来 ===
$ du -sh /
20G   /
# du 问的是:"从这个目录往下走,所有【还能看到的文件】加起来多大"
# ★ 它只能数到【还有名字、能被遍历到】的文件。

# === ★ 于是两者对不上,通常就两种情况 ===
# 情况 A:du 算出来【远小于】df 的 Used
#   -> 有文件被删了名字、但还被进程占着(这次就是)
#   -> 那些数据 df 算得到、du 算不到。
# 情况 B:du 算出来【远大于】df,或某目录 du 特别大却找不到文件
#   -> 多半是【挂载点遮盖】:某目录原本有文件,
#      后来在它上面挂载了别的设备,老文件被"盖住"了。

# === 一个排查动作:从根开始,逐层 du 收窄范围 ===
$ du -h --max-depth=1 / 2>/dev/null | sort -rh | head
# --max-depth=1 只看第一层,哪个目录大就进哪个,逐层往下挖。
# 2>/dev/null 把 /proc 之类的报错丢掉,清爽。

# === ★ df 和 du 差很多时,别反复 du,直接去查"删了没释放" ===
$ lsof | grep deleted
# 这一条,往往一秒就能解释清楚那笔"对不上的账"。

修复 2:删了文件空间没释放——这次的根因

# === ★ 核心认知:Linux 的 rm,删的不是"文件",是"文件名" ===
# 一个文件在磁盘上,其实是三样东西:
#   1. 文件名(目录项)—— 记在它所在目录里,指向一个 inode
#   2. inode      —— 文件的"身份证":权限、大小、数据块位置…
#   3. 数据块      —— 真正存内容的地方
# rm 干的事,只是【把目录项删掉】—— 把那个"名字"抹了。

# === ★ inode 和数据块,什么时候才真正被回收 ===
# 内核给每个 inode 维护【两个计数】,两个都归零才回收:
#   - i_nlink :硬链接数 —— 有多少个"文件名"指向这个 inode
#   - 打开计数 :有多少个进程,此刻正【打开着】这个文件
# ★ rm 让 i_nlink 减 1。但如果还有进程开着它,
#   打开计数 > 0 -> 数据块【不回收】,空间【不释放】。

# === ★ 找出"已删除但仍被占用"的文件(本节主命令)===
$ lsof | grep deleted
java  8123  root  5w  REG  253,1  32G  /var/log/app/huge.log (deleted)
# 解读这一行:
#   java 8123  -> 进程名 java、PID 8123,是它开着这个文件
#   5w         -> 文件描述符 5,以写方式(w)打开
#   32G        -> ★ 这个"幽灵文件"还实打实占着 32G
#   (deleted)  -> 文件名已删,但数据还在

# === ★ 解法 1:重启 / 优雅重启那个进程 ===
# 进程一退出,它持有的所有 fd 关闭 -> 打开计数归零
# -> 内核立刻回收 inode 和数据块 -> df 空间瞬间回来。
$ systemctl restart myapp        # 最干净,但要能接受重启

# === ★ 解法 2:不重启进程,直接清空那个 fd(救急神技)===
# 通过 /proc 找到那个 fd,把它截断成 0 字节:
$ ls -l /proc/8123/fd | grep deleted
l-wx------ 1 root root 64 ... 5 -> /var/log/app/huge.log (deleted)
$ : > /proc/8123/fd/5            # ★ 把 fd 5 指向的文件清空
# : > 文件  是"清空文件"的写法。这样不用动进程,
# 空间立刻释放,进程还继续往这个(已空的)文件写。

# === ★ 经验:磁盘满,先 lsof grep deleted 再说 ===
# 很多时候根本不用满世界找大文件 ——
# 一个被删了名字、却被进程死死攥着的大文件,就是答案。

修复 3:inode 用满了——另一种"磁盘满"

# === ★ 还有一种"磁盘满":块没满,但 inode 用光了 ===
# 文件系统在格式化时,inode 的数量就【固定】了。
# 每创建一个文件,就吃掉一个 inode —— 哪怕这文件是 0 字节。
# ★ 海量小文件 -> inode 先于磁盘块被耗尽。

# === 现象:df -h 看着还有空间,却"No space left on device" ===
$ df -h /
/dev/vda1   50G  20G  30G  40%  /         # ★ 块明明还剩 30G
$ touch /tmp/test
touch: 无法创建 '/tmp/test': 设备上没有空间   # ★ 却创建不了文件

# === ★ 真相要用 df -i 看(i = inode)===
$ df -i /
Filesystem     Inodes   IUsed  IFree  IUse%  Mounted on
/dev/vda1     3276800  3276800     0   100%  /
#             ^^^^^^^ 总数      ^^^^^^^ 全用光了  ^^^^ 100%
# ★ 块还剩 40%,inode 却 100% —— 这就是 inode 耗尽。

# === ★ 找出是哪个目录,塞了海量小文件 ===
$ for d in /var/* /tmp /home/*; do
    echo "$(find $d 2>/dev/null | wc -l)  $d"
  done | sort -rn | head
2900000  /var/spool/clientmqueue     # ★ 罪魁:近 300 万个文件
# 逐个目录数文件数,数量最大的就是元凶。

# === 常见的 inode 黑洞 ===
# - /var/spool/clientmqueue :cron 任务有输出又没人收的邮件,
#   日积月累堆出几百万个小文件(经典坑)
# - session 文件、缓存碎片、没清理的临时文件
# - 某个程序疯狂创建小文件又不删

# === 清理(确认无用后)===
$ find /var/spool/clientmqueue -type f -delete
# 文件太多时 rm * 会"参数列表过长",用 find -delete 更稳。

# === ★ 结论:磁盘满,df -h 和 df -i 都要看 ===
# 块满 vs inode 满,是两种病,解法完全不同。

修复 4:硬链接与软链接——删文件到底删了什么

# === ★ 想真正理解"删文件",必须搞懂硬链接和软链接 ===

# === 看一个文件的 inode 号 和 硬链接数 ===
$ ls -li file.txt
1314520 -rw-r--r-- 2 root root 100 ... file.txt
#^^^^^^^             ^
# inode 号           ★ 这个 2,就是硬链接数

# === 硬链接:给同一个 inode,再起一个名字 ===
$ ln file.txt hard.txt           # 创建硬链接
$ ls -li file.txt hard.txt
1314520 ... 2 ... file.txt        # ★ inode 号【完全一样】
1314520 ... 2 ... hard.txt        # ★ 链接数都是 2
# 硬链接的本质:两个文件名,指向【同一个 inode、同一份数据】。
# ★ 删掉 file.txt,数据【不会丢】 —— hard.txt 还指着那个 inode,
#   链接数从 2 减到 1,数据照样在。要链接数归 0 才真删。

# === 软链接(符号链接):一个独立的小文件,内容是"一个路径" ===
$ ln -s file.txt soft.txt        # 创建软链接,注意 -s
$ ls -li file.txt soft.txt
1314520 ... file.txt
2200100 ... soft.txt -> file.txt  # ★ inode 不同!它是独立文件
# 软链接自己有独立的 inode,它存的内容就是字符串 "file.txt"。
# ★ 删掉 file.txt,soft.txt 立刻变成【死链接】——
#   它指向的路径已经不存在了,访问就报错。

# === ★ 硬链接 vs 软链接,关键差异一张表记牢 ===
# 硬链接:同 inode | 不能跨文件系统 | 不能链接目录 | 原文件删了数据还在
# 软链接:独立 inode| 能跨文件系统  | 能链接目录   | 原文件删了就成死链

# === 找出死掉的软链接 ===
$ find /path -xtype l             # xtype l = 指向不存在目标的软链接

# === ★ 回到这次事故:为什么 rm 没释放空间,现在彻底清楚了 ===
# rm 做的是"硬链接数减 1"。huge.log 原本链接数是 1,
# rm 之后链接数变 0 —— 但因为 java 进程还开着它,
# "打开计数"不为 0,内核就【先不回收】。
# ★ 真正的删除条件是:硬链接数 0 且 打开计数 0,缺一不可。

修复 5:找出到底谁占了空间

# === ★ 一套"磁盘满了"的标准查法,从粗到细 ===

# === 第一步:确认是块满还是 inode 满 ===
$ df -h && df -i
# 块满 -> 往下找大文件;inode 满 -> 找海量小文件目录(修复 3)。

# === 第二步:从根逐层 du,锁定大目录 ===
$ du -h --max-depth=1 / 2>/dev/null | sort -rh | head
# 哪个大进哪个,一层层挖,直到找到具体的大文件 / 大目录。

# === 第三步:直接找出全盘最大的几个文件 ===
$ find / -xdev -type f -size +500M -exec ls -lh {} \; 2>/dev/null
# -xdev :★ 不跨文件系统,只在当前分区找(否则会跑到别的盘)
# -size +500M :只找大于 500M 的文件
# 排查根分区满,加 -xdev 极重要,不然 du/find 会乱跑。

# === ★ 第四步:别忘了"删了没释放"的幽灵文件 ===
$ lsof -nP +L1
# +L1 :★ 专门列出"硬链接数 < 1"的文件 —— 即已删除还被占用的。
# 比 lsof | grep deleted 更精准,这是查这类问题的利器。

# === ★ 第五步:警惕"挂载点遮盖"——最隐蔽的一种 ===
# 现象:du 某目录很小,df 却显示分区满,怎么都找不到大文件。
# 原因:比如 /data 原本写了一堆文件,后来某次把一块新盘
#   挂到了 /data 上 —— 老文件还在,但被挂载点"盖住"了,
#   ls /data 看到的是新盘,看不到底下被压着的老文件。
# ★ 排查:把那个设备临时挂到别处,看"底下"藏了什么
$ mkdir /mnt/check
$ mount --bind / /mnt/check       # 把根"原样"再挂一份
$ du -sh /mnt/check/data          # ★ 这里能看到被遮盖的真实占用
$ umount /mnt/check               # 看完卸载

# === 第六步:大日志文件,优先想"截断"而非"删除" ===
$ : > /var/log/app/big.log        # 清空但保留文件(进程继续写)
# ★ 对正被进程写的日志,千万别直接 rm ——
#   rm 了会变成幽灵文件,空间还是不释放(就是这次的坑)。
#   用 : > 清空,既释放空间,文件还在,进程继续写,皆大欢喜。

修复 6:磁盘空间排查纪律

# === 这次事故暴露的认知盲区,定几条纪律 ===

# === 1. ★ rm 一个文件,不等于释放了它的空间 ===
# 只要还有进程开着它,空间就一直占着。
$ lsof -nP +L1                  # 先查有没有这种幽灵文件

# === 2. ★ df 和 du 对不上,九成是"删了没释放" ===
# df 算块占用,du 算可见文件 —— 幽灵文件只有 df 算得到。
$ lsof | grep deleted

# === 3. 正被写的日志,要清空不要删 ===
$ : > /var/log/xxx.log          # 截断,空间立刻还、文件还在
# rm 它 -> 幽灵文件,空间不释放,还得去重启进程。

# === 4. ★ 磁盘满,df -h 和 df -i 都要看 ===
# 块满和 inode 满是两种病,后者 df -h 还显示有空间。

# === 5. 找大文件加 -xdev,别跨文件系统乱跑 ===
$ find / -xdev -type f -size +500M 2>/dev/null

# === 6. du 某目录小、df 却满,想到挂载点遮盖 ===
# 文件被新挂载的设备盖住了,mount --bind 出来才看得见。

# === 7. 救急清空幽灵文件:: > /proc/PID/fd/N ===
# 不重启进程也能把空间要回来。

# === 8. 排查磁盘满的命令链 ===
$ df -h                         # ① 哪个分区满了
$ df -i                         # ② 是块满还是 inode 满
$ du -h --max-depth=1 /xxx      # ③ 逐层锁定大目录
$ find /xxx -xdev -size +500M   # ④ 揪出具体大文件
$ lsof -nP +L1                  # ⑤ ★ 查删了没释放的幽灵文件
# 按这个顺序,磁盘空间问题基本能定位。

命令速查

需求                        命令
=============================================================
看各分区块使用率            df -h
看各分区 inode 使用率       df -i
逐层找大目录                du -h --max-depth=1 /xxx | sort -rh
找全盘大文件(不跨盘)        find / -xdev -type f -size +500M
查已删除但被占用的文件      lsof -nP +L1
查 deleted 文件(简版)      lsof | grep deleted
清空文件保留(给正写的日志) : > /var/log/xxx.log
不重启进程清空幽灵文件      : > /proc/PID/fd/N
看文件 inode 号和硬链接数   ls -li file
找死掉的软链接              find /path -xtype l

口诀:rm 删的是名字不是数据 -> df 不降先查 lsof deleted
      df -h 和 df -i 都要看 -> 正写的日志要截断不要删

避坑清单

  1. rm 删的是目录项(文件名),不是数据,有进程开着空间就不释放
  2. df 看块占用,du 看可见文件,两者对不上多半是删了没释放的幽灵文件
  3. inode 和数据块要硬链接数为 0 且打开计数为 0 才回收,缺一不可
  4. 用 lsof +L1 或 lsof | grep deleted 找已删除但仍被进程占用的文件
  5. 正被进程写的日志要用 : > 清空,直接 rm 会变幽灵文件空间不释放
  6. 磁盘满 df -h 和 df -i 都要看,inode 耗尽时 df -h 还显示有空间
  7. 海量小文件会先耗尽 inode,/var/spool/clientmqueue 是经典黑洞
  8. 硬链接共享同一个 inode,软链接是独立文件存的是目标路径字符串
  9. find 找大文件加 -xdev,否则会跨文件系统跑到别的盘上
  10. du 某目录小但 df 满,要想到挂载点遮盖,文件被新挂的设备盖住了

总结

这次"文件删了、空间却没还"的事故,纠正了我一个用了很多年、却从来没真正想透的概念——"删除文件"。在这次之前,我心里的删除模型粗暴而直接:一个文件,就是磁盘上的一坨数据;rm 这个命令,就是一把铲子,我对着这坨数据铲下去,它就没了,它占的空间也就当场还给了系统。在这个模型里,"rm 执行成功"和"空间被释放",是同一件事的两种说法,中间不该有任何缝隙。所以当我亲眼看着 rm 干掉那个 30G 的日志文件、ls 里它也确实消失了,df 却纹丝不动地停在 100% 时,我的整个删除模型,就在那个数字面前碎了一地。复盘到根上,我才终于看清,Linux 里一个所谓的"文件",根本不是一坨浑然一体的东西,它是被拆成三层的:最上面是【文件名】,也就是记在某个目录里的一个"目录项",它的作用仅仅是"指向"——指向中间那一层;中间那一层是 inode,它是这个文件真正的"身份证",记着权限、大小、属主、以及最关键的——数据到底存在磁盘的哪些块上;最下面才是真正存放内容的【数据块】。想清楚这个三层结构,rm 到底干了什么,就一目了然了:rm 这把铲子,铲掉的从来不是最底下的数据,它铲掉的,只是最上面那个【文件名】。它把目录里那条"指向 inode"的记录抹掉了,如此而已。那么 inode 和它底下那 30G 数据块,究竟什么时候才会被真正回收?答案是,内核给每个 inode 默默维护着【两个】计数器,而且必须等这两个计数器【同时归零】,才肯把空间还回去:一个是"硬链接数",也就是有多少个文件名还指着这个 inode;另一个,是我以前完全不知道的——"打开计数",也就是此刻有多少个进程,正开着这个文件的文件描述符。我那个 huge.log 的真实处境是:它的硬链接数本来是 1,我一 rm,这个数减到了 0;但是,那个 java 进程,此时此刻还死死地开着它、还在往里头写日志——它的"打开计数"是 1,不为 0。于是内核的判断是:名字虽然没了,可还有人开着它呢,数据块,先不回收。那 30G,就这样变成了一个没有名字、却实实在在占着磁盘的"幽灵文件"——它从所有 lsdu 的视野里消失了,因为它们只能遍历"还有名字"的文件;但它逃不过 df 的眼睛,因为 df 数的是文件系统层面"块的占用",根本不在乎这块数据还有没有名字。这下,dfdu 那笔怎么也对不上的账,终于讲通了:差额,正是这些幽灵文件。找到它们的钥匙,是 lsof——lsof | grep deleted 或者更精准的 lsof +L1,一敲下去,那个被标着 (deleted)、却还占着 32G 的家伙就原形毕露;而要把空间要回来,要么重启那个攥着它的进程,要么用 : > /proc/PID/fd/N 这招,绕过文件名、直接把那个文件描述符背后的内容清空。这次事故还顺带教会了我一条极其实用的纪律:一个正在被进程写入的日志文件,永远不要直接 rm——rm 它,只会原样制造出一个幽灵文件,空间一点不还;正确的做法是用 : > 把它"截断"成 0 字节,文件名还在、inode 还在、进程的文件描述符也还有效,进程毫无察觉地继续往这个被清空的文件里写,而那几十 G 的空间,当场就回来了。这次从一个"删了却没消失"的诡异数字出发,我最大的收获,是把脑子里"rm = 抹掉数据 = 释放空间"那个三位一体的等式,拆成了它本来的样子:rm 只是删掉一个名字;数据和空间的命运,取决于"还有没有名字指着它"和"还有没有进程开着它"这两件事——只有当一个文件,既没有任何名字、也没有任何进程惦记着它的时候,它才算真正地、连同它占的每一个字节,从这台机器上消失。

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

端口在监听外面却连不上:一次 Linux 防火墙 iptables 与 firewalld 排查复盘

2026-5-20 19:10:49

Linux教程

公钥明明在服务端却死活不认:一次 SSH 连接问题排查复盘

2026-5-20 19:17:29

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