删了大文件磁盘空间却没回来:一次 Linux 已删除文件占用排查复盘

服务器磁盘快满,找到一个 28G 的日志文件 rm 删掉,df 一看占用却纹丝不动。排查梳理:rm 删的是文件名也就是目录项不是数据本身,数据被回收要硬链接计数和打开计数都归零;删了被进程打开的文件名字立刻消失但数据保留到最后一个进程关闭它;du 按文件名统计、df 看真实块占用,两者对不上常是有已删除但被打开的文件;lsof grep deleted 或 lsof +L1 定位它和占用进程,重启进程或冒号重定向清空 /proc/PID/fd 释放空间;正在被写的文件要清空用截断别用 rm,日志切割交给 logrotate 配 copytruncate,以及一套磁盘空间排查纪律。

2024 年的一个深夜,我被磁盘告警叫醒——一台线上服务器根分区用到了 96%,再涨下去服务就要写不进数据了。我登上去 du 了一圈,很快揪出元凶:一个服务的日志文件 app.log,一个人就胖到了 28G。我没多想,rm 一下,把它干掉了。删除命令一声不吭就执行完了,我心满意足地 df -h 看了一眼——磁盘占用,还是 96%。我以为是显示缓存,等了一会儿再看,纹丝不动,还是 96%。我又确认了一遍那个 28G 的文件,ls 已经找不到它了,它确确实实被我删掉了。这就彻底说不通了:我明明删掉了一个 28G 的庞然大物,ls 都证实它没了,这 28G 的空间,怎么就是不肯回来?一个被 rm 删掉、连 ls 都看不见的文件,凭什么还占着磁盘?我盯着这个矛盾愣了很久,最后才明白,我对 rm 这个用了无数次的命令,理解从根上就是错的——我一直以为 rm 是"把文件抹掉",可它做的,根本不是那件事。这件事逼着我把 Linux 的文件删除机制、硬链接计数、已删除但被打开的文件这一整套彻底理清了。本文复盘这次实战。

问题背景

环境:CentOS 7,一台线上服务器,跑着一个一直在写日志的服务
事故现象:
- 根分区磁盘用到 96%,触发告警
- 找到一个 28G 的日志文件,rm 删掉了
- ★ 删完 df 一看,磁盘占用没掉,还是 96%

现场排查:
# 1. 磁盘确实快满
$ df -h /
/dev/vda1   100G  96G  4.0G  96%  /

# 2. 删掉那个 28G 的日志
$ rm /opt/app/logs/app.log
$ ls /opt/app/logs/app.log
ls: 无法访问: 没有那个文件或目录          # ★ ls 确认:文件没了

# 3. ★ 可是 df 占用一点没掉
$ df -h /
/dev/vda1   100G  96G  4.0G  96%  /        # ★ 还是 96G!28G 没回来

# 4. ★ du 和 df 对不上 —— 经典症状
$ du -sh /opt/app/logs                     # du:目录里没那么大的东西了
$ df -h /                                  # df:磁盘还是占着
# du 说"没了",df 说"还占着" —— 两者打架。

# 5. ★ 用 lsof 找"已删除但还被打开"的文件
$ lsof | grep deleted
java  9300  appuser  3w  REG  ... 30064771072 .../app.log (deleted)
#                                ^^^^^^^^^^^ ★ 28G,标着 (deleted)
# ★ 进程 9300 还开着这个"已删除"的文件!

根因(后来想清楚的):
1. ★ rm 删除的,根本不是文件的"数据本身",
   它删的只是一个【目录项】—— 一个"文件名 -> 数据"
   的指向关系。rm 之后,这个名字没了,仅此而已。
2. 文件的真实数据(占着那 28G 的部分),要被真正
   释放,需要满足【两个条件同时成立】:
   - 指向它的目录项(硬链接)数,归零(rm 做到了)
   - ★ 没有任何进程还【打开着】它(rm 做不到这个)
3. ★ 我 rm 的时候,那个服务进程(9300)正开着
   app.log 在往里写日志 —— 它手里还攥着这个文件。
4. 于是:名字没了(ls 看不见),但进程还开着它,
   ★ 数据就一直【挂在那个进程名下】,28G 空间不释放。
5. 这种文件,就是"已删除但仍被打开"的文件,
   lsof 里标着 (deleted)。它正是 du/df 对不上的元凶。
rm 删的是名字;进程还开着,数据和空间就不会走。

修复 1:rm 删的是名字,不是数据本身

# === ★ 先纠正最核心的误解:rm 到底删了什么 ===

# === 一个文件,其实是两部分 ===
# 在 Linux 里,你以为的"一个文件",拆开看是两样东西:
#  1. ★【数据本体】+ 一个叫 inode 的元数据结构 ——
#     真正占磁盘空间的,是这部分。
#  2. ★【目录项】—— 一条"文件名 指向 哪个 inode"的记录,
#     它存在于目录里。这就是你看到的"文件名"。
# 文件名和数据本体,是【分开】的,中间靠目录项连起来。

# === ★ rm 干的事:删目录项,不是删数据 ===
# 你执行 rm app.log,内核做的是:
#  - 把"app.log -> inode"这条目录项删掉
#  - 给那个 inode 的【链接计数】减 1
# ★ 它【没有】、也【不直接】去抹掉数据本体。
#   rm 这个名字,其实更准确的叫法是 unlink —— "解除链接"。

# === 验证:文件可以有多个名字(硬链接)===
$ echo hello > a.txt
$ ln a.txt b.txt                  # 给同一份数据,再起一个名字 b.txt
$ ls -l a.txt b.txt
-rw-r--r-- 2 ... a.txt            # ★ 第 2 列那个 "2" = 链接计数
-rw-r--r-- 2 ... b.txt            #   两个名字,指向【同一份数据】
$ rm a.txt                        # 删掉 a.txt 这个名字
$ cat b.txt
hello                             # ★ 数据还在!b.txt 还能读到
# ★ 这证明:rm 删的是名字。只要数据还有别的名字指着,
#   数据就好好的 —— rm 一个名字,删不掉数据。

# === ★ 那数据什么时候才真的被删 ===
# 一个 inode 的数据本体被真正释放、空间被回收,
#   要等它的【链接计数归零】—— 也就是指向它的
#   所有名字都被 rm 掉。
# 上面 b.txt 还在,链接计数是 1,数据就不释放;
#   再 rm b.txt,计数归 0,数据这才被回收。

# === 看一个文件的链接计数 ===
$ ls -l 文件                       # 第 2 列就是链接计数
$ stat 文件 | grep Links           # stat 看得更清楚
Links: 1

修复 2:文件被真正释放,要满足两个条件

# === ★ 关键:rm 之后,还有第二个条件卡着空间不放 ===

# === 数据真正被释放,需要【两个计数】都归零 ===
# 内核要回收一个文件的数据和空间,必须【同时】满足:
#  ① 硬链接计数 == 0(指向它的文件名,一个都不剩)
#  ② ★ 打开计数 == 0(没有任何进程还开着这个文件)
# ★ 两个条件【都】成立,数据才被抹、空间才回收。
#   差任何一个,数据就赖着不走。

# === ★ 我这次卡在第 ② 个条件上 ===
# 我 rm app.log,条件 ① 满足了:文件名没了,
#   硬链接计数归零,ls 也确实找不到它了。
# 但那个服务进程,在我 rm 的时候,★【正开着】app.log!
#   它手里那个 fd,就是一个"打开引用" —— 打开计数 > 0。
# ★ 条件 ② 不满足 -> 内核不回收 -> 28G 空间纹丝不动。

# === 为什么 Linux 要这么设计 ===
# 想象一下:进程 A 正在读写一个文件,进程 B 把它 rm 了。
# 如果 rm 立刻抹掉数据,进程 A 下一次读写就会读到垃圾、
#   甚至崩溃。
# ★ 所以 Linux 的规矩很合理:你可以 rm 一个被打开的文件,
#   "名字"立刻消失,但"数据"会【保留到最后一个打开它的
#   进程关闭它为止】—— 让正在用它的进程安全地用完。
# 这种文件,就是"已删除但仍被打开"(deleted but open)。

# === ★ 于是 du 和 df 会"打架" ===
# - du 是【遍历目录、按文件名】统计大小的。
#   名字没了,du 自然就【数不到】这个 28G 了。
# - df 是【看文件系统真实的块占用】的。
#   数据还没释放,df 就【照样把这 28G 算在占用里】。
# ★ "du 显示的远小于 df 显示的占用" —— 这个对不上,
#   几乎就是"有已删除但被打开的大文件"的典型信号。

# === 快速感受这个对不上 ===
$ du -sh /* 2>/dev/null | sort -rh | head   # 按目录统计实际文件
$ df -h /                                    # 看文件系统真实占用
# ★ 两者差出一大截 -> 八成有 deleted 文件在偷偷占空间。

修复 3:找出已删除但还占空间的文件

# === ★ 核心工具:lsof —— 它能看见"没有名字"的文件 ===

# === 列出所有"已删除但仍被打开"的文件 ===
$ lsof | grep deleted
java  9300  appuser  3w  REG  253,1  30064771072  131  .../app.log (deleted)
# 逐列看懂这一行:
#  - java / 9300       :哪个进程、PID 是多少,在占着它
#  - 3w                :该进程用 3 号 fd 打开它,w=可写
#  - 30064771072       :★ 文件大小 —— 这里是 28G
#  - (deleted)         :★ 关键标记 = 名字已删,数据还在
# ★ 这一行就是全部真相:进程 9300 攥着一个 28G 的死文件。

# === 只看占空间大的已删除文件,按大小排 ===
$ lsof -nP 2>/dev/null | grep deleted \
    | awk '{print $7, $1, $2, $NF}' | sort -rn | head
30064771072 java 9300 .../app.log
# ★ 第 1 列(大小)排序,最大的那个就是要处理的目标。

# === ★ 从 /proc 这个角度也能看见它 ===
$ ls -l /proc/9300/fd | grep deleted
3 -> /opt/app/logs/app.log (deleted)
# ★ /proc/PID/fd 里,这个 fd 的软链接后面跟着 (deleted) ——
#   和 lsof 看到的是同一件事,换个角度印证。

# === 直接定位"是哪个进程占着已删除的大文件" ===
$ lsof -nP +L1
# ★ +L1 = 只列出"链接计数 < 1"的文件,也就是名字已删的。
#   它比 grep deleted 更精准,专门干这件事。
# 输出里 NLINK 列为 0 的,就是已删除还被打开的文件。

# === 确认一下这个进程是什么、能不能动 ===
$ ps -p 9300 -o pid,comm,args
9300  java  /opt/app/bin/run ...
# ★ 搞清楚占着文件的是什么服务,才能决定下一步怎么处理。

修复 4:怎么把空间要回来

# === ★ 既然空间被"进程还开着"卡住,思路就是解除这个占用 ===

# === 思路 1:最干脆 —— 重启 / 重载占着文件的进程 ===
# 进程一退出 / 重启,它手里所有的 fd 都会被关闭。
# ★ 那个已删除文件的"打开计数"随之归零 ->
#   两个条件都满足 -> 内核立刻回收,28G 瞬间归还。
$ systemctl restart myapp        # 占文件的是服务,重启它
# 验证:
$ df -h /
/dev/vda1   100G  68G  32G  68%  /   # ★ 28G 回来了!

# === ★ 思路 2:不能重启服务时 —— 把那个 fd 清空 ===
# 有些核心服务不方便重启。可以【不删文件、不关进程】,
#   而是直接把那个 fd 指向的内容【截断成 0】:
$ : > /proc/9300/fd/3
# 解释:/proc/PID/fd/3 就是进程 9300 那个 3 号 fd ——
#   它仍然指着那份"已删除"的数据。
# ★ ": >" 把它的内容清空(截断为 0 字节)。
#   数据没了,空间立刻释放,而进程毫发无伤、继续运行。
# 注意:这适用于日志这类"内容可丢弃"的文件;
#   ★ 别对数据库文件、重要数据文件这么干。

# === ★ 思路 3:正确的预防 —— 别再用 rm 删活动日志 ===
# 这次的根本错误,是对一个【正在被写入】的日志文件用了 rm。
# 对活动日志,正确做法是【截断】而不是【删除】:
$ : > /opt/app/logs/app.log       # 清空内容,但文件名/inode 还在
# 或:
$ truncate -s 0 /opt/app/logs/app.log
# ★ 截断不删名字、不换 inode,进程的 fd 一直有效,
#   它继续往同一个文件写 —— 空间当场释放,且不出 deleted 文件。

# === 处理后再核对 du 和 df ===
$ du -sh / 2>/dev/null ; df -h /
# ★ 两者重新对得上(差距回到合理范围)= 问题真解决了。

修复 5:从根上避免——日志切割的正确姿势

# === ★ 这类事故的高发区:日志,所以日志要交给专门的工具 ===

# === 为什么手动 rm 日志总出事 ===
# 服务进程通过一个 fd,持续往日志文件写。
# 你 rm 它 -> 文件名没了,但进程的 fd 还指着旧数据 ->
#   ★ 空间不释放,而且进程还在【对着一个没有名字的文件
#   继续写】—— 写进去的日志,你哪儿都看不到了。
# 手动 rm 活动日志,几乎必踩坑。

# === ★ 正解:用 logrotate 管理日志切割 ===
$ cat /etc/logrotate.d/myapp
/opt/app/logs/*.log {
    daily                  # 每天切一次
    rotate 7               # 保留最近 7 份
    compress               # 旧日志压缩
    missingok
    notifempty
    copytruncate           # ★ 关键选项,见下
}

# === ★ copytruncate:专门解决"进程开着日志"的难题 ===
# copytruncate 的做法是:
#  1. 先把当前 app.log 【拷贝】一份出来当归档
#  2. 然后把原 app.log 【截断为 0】(不是删!)
# ★ 因为是截断不是删,文件名和 inode 没变,
#   进程的 fd 始终有效,它继续往同一个文件写 ——
#   既切割了日志,又不产生 deleted 文件、不丢日志。

# === 另一种思路:让程序自己重开日志文件 ===
# logrotate 不加 copytruncate 时,默认是
#   把日志改名,然后靠 postrotate 通知程序"重开日志":
# postrotate
#     systemctl reload myapp      # 程序收到信号,重新打开日志文件
# endscript
# ★ 程序重开 -> 旧 fd 关闭 -> 旧文件空间释放。
#   这要求程序支持"收到信号后重开日志"(如 nginx)。

# === 手动测试 logrotate 配置对不对 ===
$ logrotate -d /etc/logrotate.d/myapp     # -d 只演练,不真执行
$ logrotate -f /etc/logrotate.d/myapp     # -f 强制执行一次

# === ★ 一句话原则 ===
# 正在被进程写入的文件,要"清空"就用截断(: > 或 truncate),
#   ★ 永远不要用 rm。日志切割这件事,交给 logrotate。

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

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

# === 1. ★ rm 删的是文件名(目录项),不是数据本身 ===
# 数据被回收,要硬链接计数 和 打开计数【都归零】。

# === 2. ★ 删了大文件 df 不降 -> 文件还被进程开着 ===
$ lsof | grep deleted          # 第一时间就查这个

# === 3. ★ du 远小于 df 占用 = 有已删除但被打开的文件 ===
$ du -sh / 2>/dev/null ; df -h /   # 两者对不上就是信号

# === 4. 用 lsof +L1 精准定位已删除还占空间的文件 ===
$ lsof -nP +L1

# === 5. 要空间回来:重启占文件的进程,或清空它的 fd ===
$ systemctl restart 服务         # 或  : > /proc/PID/fd/N

# === 6. ★ 正在被写的文件别用 rm,要清空就截断 ===
$ : > 文件     # 或 truncate -s 0 文件

# === 7. 日志切割交给 logrotate,配 copytruncate ===

# === 8. 排查"删了文件空间没回来"的命令链 ===
$ df -h /                       # ① 确认磁盘占用
$ du -sh /* 2>/dev/null|sort -rh # ② 按目录找大头
$ lsof | grep deleted           # ③ 找已删除但被打开的文件
$ lsof -nP +L1                  # ④ 精准列出并看大小、占用进程
$ systemctl restart 占用进程     # ⑤ 重启进程,空间归还
# 按这个顺序,空间问题基本能定位。

命令速查

需求                        命令
=============================================================
看磁盘占用                  df -h /
按目录找占空间大头          du -sh /* 2>/dev/null | sort -rh
找已删除但仍被打开的文件    lsof | grep deleted
精准列出已删除占空间文件    lsof -nP +L1
从 proc 角度看 deleted fd   ls -l /proc/PID/fd | grep deleted
看文件硬链接计数            stat 文件 | grep Links
重启进程释放空间            systemctl restart 服务名
不重启清空某个 fd           : > /proc/PID/fd/N
清空活动文件(别用 rm)     : > 文件   或  truncate -s 0 文件
演练 logrotate 配置         logrotate -d /etc/logrotate.d/xxx

口诀:rm 删的是名字不是数据,文件被进程开着空间就不释放
      du 比 df 小就查 lsof grep deleted,活动日志要截断别 rm,切割交给 logrotate

避坑清单

  1. rm 删除的是文件名也就是目录项,不是数据本身,准确说 rm 做的是解除链接
  2. 文件数据被真正回收要两个条件:硬链接计数归零,且没有进程还打开着它
  3. 删了被进程打开的文件,文件名立刻消失但数据保留到最后一个进程关闭它
  4. du 按文件名遍历统计,df 看真实块占用,两者对不上常是有已删除被打开文件
  5. lsof grep deleted 能列出已删除但仍被进程打开的文件,带 deleted 标记
  6. lsof +L1 专门列出链接计数小于 1 的文件,精准定位已删除还占空间的文件
  7. 重启占用文件的进程会关闭它所有 fd,已删除文件的空间随之被立刻回收
  8. 不能重启进程时可以用冒号重定向清空 /proc/PID/fd/N,但别对数据文件这么做
  9. 正在被进程写入的文件要清空请用截断,truncate 或冒号重定向,永远别用 rm
  10. 日志切割交给 logrotate 并配 copytruncate,它截断而非删除,不产生 deleted 文件

总结

这次"删掉 28G 大文件、磁盘空间却一寸没回来"的事故,纠正了我一个用了无数次、却从未真正看清过的命令——rm。在我的脑子里,rm 的含义朴素得不能再朴素:删除。我敲下 rm 一个文件,在我的想象里,就等于这个文件连同它占的空间,被从磁盘上干净利落地抹掉了——名字没了,数据没了,空间空出来了,这三件事是同一个动作的、瞬间完成的、不可分割的结果。正是这个想象,让我在删完那个 28G 的日志后,带着十足的把握去看 df,然后被一个雷打不动的"96%"砸得晕头转向。ls 明明已经找不到那个文件了,它在我的认知里"应该已经彻底不存在了",可它占的空间却倔强地、清清楚楚地还赖在那里。这个矛盾之所以让我卡死,是因为在我那个"rm = 瞬间抹掉一切"的模型里,它根本不可能发生。复盘到根上,我才终于把"一个文件"这件事,拆成了它本来的样子。我一直以为浑然一体的"文件",其实是两样东西:一样是真正占着磁盘空间的数据本体,另一样,是一个挂在目录里的、写着"这个名字对应那份数据"的小牌子。而 rm 这个命令,它一辈子只干一件事——把那块小牌子摘掉。它从不直接去碰数据本体。数据本体什么时候才真正被抹去、空间什么时候才真正被释放?这要等两件事同时成立:一是指向它的牌子,一块都不剩了;二是,没有任何一个进程,手里还攥着它。我那次失败,恰恰就败在第二个条件上——我摘牌子的时候,那个服务进程正死死开着这个文件在往里写日志。牌子是被我摘掉了(所以 ls 看不见了),可进程那只手还紧紧攥着它,于是内核遵守着它那条体贴的规矩:只要还有进程在用,这份数据就得给它留着,一个字节都不能动。那 28G,就这样变成了一个没有名字、却依然占着磁盘的幽灵。我对着 df 的困惑,本质上是我以为我执行了"删除",而其实我只执行了"改名为无名"。这次最大的收获,是我对那些自己用得太熟、熟到不再去想的命令,重新生出了一点敬畏。rmlscp——这些命令我每天敲几十遍,正因为太熟,我早已不再追问它们"到底做了什么",而是直接用一个想当然的、简化的印象代替了它们真实的机制。可偏偏就是这种"想当然",在某个边界情况下,会把我引向一个我完全无法理解的死胡同。一个命令的名字,是它意图的概括,不是它机制的说明书。rm 叫"remove",但它的真身是"unlink"。下一次,当一个我无比熟悉的命令,给出一个我无法理解的结果时,我不会再去怀疑系统出了错——我会回过头,老老实实地重新问一遍:这个命令,它真正做的,到底是什么?

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

服务报 Too many open files:一次 Linux 文件描述符耗尽排查复盘

2026-5-20 20:35:30

Linux教程

磁盘还有空间却报 No space left:一次 Linux inode 耗尽排查复盘

2026-5-20 20:44:03

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