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
避坑清单
- rm 删除的是文件名也就是目录项,不是数据本身,准确说 rm 做的是解除链接
- 文件数据被真正回收要两个条件:硬链接计数归零,且没有进程还打开着它
- 删了被进程打开的文件,文件名立刻消失但数据保留到最后一个进程关闭它
- du 按文件名遍历统计,df 看真实块占用,两者对不上常是有已删除被打开文件
- lsof grep deleted 能列出已删除但仍被进程打开的文件,带 deleted 标记
- lsof +L1 专门列出链接计数小于 1 的文件,精准定位已删除还占空间的文件
- 重启占用文件的进程会关闭它所有 fd,已删除文件的空间随之被立刻回收
- 不能重启进程时可以用冒号重定向清空 /proc/PID/fd/N,但别对数据文件这么做
- 正在被进程写入的文件要清空请用截断,truncate 或冒号重定向,永远别用 rm
- 日志切割交给 logrotate 并配 copytruncate,它截断而非删除,不产生 deleted 文件
总结
这次"删掉 28G 大文件、磁盘空间却一寸没回来"的事故,纠正了我一个用了无数次、却从未真正看清过的命令——rm。在我的脑子里,rm 的含义朴素得不能再朴素:删除。我敲下 rm 一个文件,在我的想象里,就等于这个文件连同它占的空间,被从磁盘上干净利落地抹掉了——名字没了,数据没了,空间空出来了,这三件事是同一个动作的、瞬间完成的、不可分割的结果。正是这个想象,让我在删完那个 28G 的日志后,带着十足的把握去看 df,然后被一个雷打不动的"96%"砸得晕头转向。ls 明明已经找不到那个文件了,它在我的认知里"应该已经彻底不存在了",可它占的空间却倔强地、清清楚楚地还赖在那里。这个矛盾之所以让我卡死,是因为在我那个"rm = 瞬间抹掉一切"的模型里,它根本不可能发生。复盘到根上,我才终于把"一个文件"这件事,拆成了它本来的样子。我一直以为浑然一体的"文件",其实是两样东西:一样是真正占着磁盘空间的数据本体,另一样,是一个挂在目录里的、写着"这个名字对应那份数据"的小牌子。而 rm 这个命令,它一辈子只干一件事——把那块小牌子摘掉。它从不直接去碰数据本体。数据本体什么时候才真正被抹去、空间什么时候才真正被释放?这要等两件事同时成立:一是指向它的牌子,一块都不剩了;二是,没有任何一个进程,手里还攥着它。我那次失败,恰恰就败在第二个条件上——我摘牌子的时候,那个服务进程正死死开着这个文件在往里写日志。牌子是被我摘掉了(所以 ls 看不见了),可进程那只手还紧紧攥着它,于是内核遵守着它那条体贴的规矩:只要还有进程在用,这份数据就得给它留着,一个字节都不能动。那 28G,就这样变成了一个没有名字、却依然占着磁盘的幽灵。我对着 df 的困惑,本质上是我以为我执行了"删除",而其实我只执行了"改名为无名"。这次最大的收获,是我对那些自己用得太熟、熟到不再去想的命令,重新生出了一点敬畏。rm、ls、cp——这些命令我每天敲几十遍,正因为太熟,我早已不再追问它们"到底做了什么",而是直接用一个想当然的、简化的印象代替了它们真实的机制。可偏偏就是这种"想当然",在某个边界情况下,会把我引向一个我完全无法理解的死胡同。一个命令的名字,是它意图的概括,不是它机制的说明书。rm 叫"remove",但它的真身是"unlink"。下一次,当一个我无比熟悉的命令,给出一个我无法理解的结果时,我不会再去怀疑系统出了错——我会回过头,老老实实地重新问一遍:这个命令,它真正做的,到底是什么?
—— 别看了 · 2026