这大概是每个运维都撞过的一幕:
$ df -h /
Filesystem Size Used Avail Use% Mounted on
/dev/sda2 98G 98G 0 100% /
$ sudo du -xsh / 2>/dev/null
43G /
df 说 98G 用满了,du 把整台机器翻完只找到 43G。
剩下的 55G 去哪了?
我当时的做法很蠢:du 换了六七种参数跑了一遍又一遍,甚至开始怀疑磁盘坏了。直到有人提醒我一句:「你删过什么大文件吗?」
那一刻我才反应过来——删掉了,但空间没回来。 因为文件虽然从目录里消失了,可还有一个进程死死攥着它不放。
〇、先花两分钟:为什么「删了文件」空间还在
先建立这个认知,后面全通。
Linux 里,删除一个文件其实是两步,而且这两步是分开的:
从目录里摘掉这个文件名(rm 干的事);
释放它占用的数据块(当没有任何进程还打开着它时,才发生)。
换个比喻:你把书从图书馆的书架上撤下来了,但还有个人正捧着这本书在读——那本书还在馆里,还占着地方。管理员要等这个人放下书,才能真的把它收走。
这时候 ls、du 都看不见它了(目录里已经没有这个名字),但 df 是按文件系统实际占用统计的,它照样算这一份。
所以就会出现:
df 数字 > du 数字,差值就是「被进程攥着的已删除文件」
这也是一个特别经典的运维事故:日志把磁盘打满 → 你 rm 掉日志文件 → 磁盘还是满的。因为写日志的进程(比如 nginx、java 应用)句柄一直开着,文件根本没释放。
先把基线说清楚:上面那个「98G 满、du 只有 43G」的场面,是我为了讲清楚构造出来的典型现场。真实情况是——我机器上 lsof +L1 有输出,但一条都不占磁盘。这一点最容易被误判,先看清楚:
$ sudo lsof +L1
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NLINK NODE NAME
systemd 1 root 76u REG 0,1 0 0 11264 /memfd:systemd-udevd (deleted)
pipewire 4174 rainbow 34u REG 0,1 2312 0 7190 /memfd:pipewire-memfd:flags=0x0000000f,type=2,size=2312 (deleted)
gnome-she 4614 rainbow 32u REG 0,1 36346 0 7199 /memfd:mutter-anonymous-file-wayland-keymap (deleted)
gnome-she 4614 rainbow 78u REG 0,1 67108864 0 27 /memfd:pulseaudio (deleted)
⚠️ 第一件必须记牢的事:lsof +L1 有输出,不等于磁盘有问题。
看 NAME 列——上面每一条都以 /memfd: 开头,那是内存文件(进程在内存里造出来的匿名文件,DEVICE 列也不是真实块设备,显示成 0,1 这类)。它们rm 掉不释放磁盘,因为压根不在磁盘上。第三条那个 64 MiB 的 pulseaudio 看着挺唬人,占的是内存。
真正要找的长这样:DEVICE 是真实块设备(比如 8,2、253,1)、NAME 是真实路径、末尾带 (deleted)。下一节就是。
不装 lsof 也有办法查,同一条命令的另一种写法:
$ sudo find /proc/*/fd -lname '*deleted*' -printf '%p -> %l\\n' 2>/dev/null | head -3
/proc/1/fd/76 -> /memfd:systemd-udevd (deleted)
/proc/4174/fd/34 -> /memfd:pipewire-memfd:flags=0x0000000f,type=2,size=2312 (deleted)
/proc/4614/fd/78 -> /memfd:pulseaudio (deleted)
怎么读:/proc/<PID>/fd/<编号> = 「哪个进程的第几个文件描述符」,箭头后面是它指向的文件。两个命令一一对得上——/proc/1/fd/76 就是 lsof 里 systemd 那条 76u。
(想亲眼看看这个现象是怎么发生的,我在文末写了一段三分钟就能跑完的复现脚本,只动 /tmp,跑完自己清理。)

一、打法一:lsof +L1(最快,先跑这个)
一台真出了事的机器上,输出长这样(下面是按经典事故构造的示意,方便你看清每一列):
$ sudo lsof +L1
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NLINK NODE NAME
nginx 1042 root 2w REG 8,2 12884901888 0 524301 /var/log/nginx/access.log (deleted)
mysqld 2311 mysql 12u REG 8,2 6442450944 0 524318 /var/log/mysql/slow.log (deleted)
怎么读——这张表只要看四列:
| COMMAND / PID | 谁攥着这个文件——要释放就得动这个进程 |
| SIZE/OFF | 它占了多少空间(上面两条加起来约 18G,正好补上了缺口) |
| NLINK | 0 是关键字:链接数为 0 = 这个文件已经没有任何目录引用了 |
| NAME 末尾 | 有 (deleted) 字样 = 已删除但未释放 |
先把 SIZE/OFF 加起来,看看是不是正好等于你缺的那部分空间——对上了,案子就破了。
二、打法二:直接看 /proc(不装 lsof 也能查)
lsof 需要额外装(Ubuntu 上 sudo apt install lsof)。但 lsof 能看的东西,/proc 里本来就有。
每个进程在 /proc/进程号/fd/ 下,把自己的每个打开文件列成一个符号链接,链接指向哪里一目了然:
$ sudo ls -l /proc/1042/fd | head -5
lrwx—— 1 root root 64 Sep 27 03:00 0 -> /dev/null
lrwx—— 1 root root 64 Sep 27 03:00 1 -> /var/log/nginx/access.log (deleted)
lrwx—— 1 root root 64 Sep 27 03:00 2 -> /var/log/nginx/error.log
怎么读:
-
左边 1、2 这些数字是文件描述符编号(相当于「这个进程手上有几号牌」);
-
箭头后面是它指向的真实文件;
-
箭头后面带 (deleted) 的,就是我们要找的。
上面那份是构造的现场。这套读法你随时能在自己机器上验证——看当前终端自己的 fd:
$ ls -l /proc/self/fd
total 0
lrwx—— 1 rainbow rainbow 64 Sep 28 10:02 0 -> /dev/pts/1
l-wx—— 1 rainbow rainbow 64 Sep 28 10:02 1 -> pipe:[58522]
l-wx—— 1 rainbow rainbow 64 Sep 28 10:02 2 -> pipe:[50927]
lr-x—— 1 rainbow rainbow 64 Sep 28 10:02 3 -> /proc/6788/fd
这是我终端里的真实输出。怎么读:
| 0 | /dev/pts/1 | 标准输入——你的终端 |
| 1 / 2 | pipe:[…] | 标准输出 / 标准错误(在管道里就长这样) |
| 3 | /proc/6788/fd | ls 自己正在读的那个目录 |
看清楚了吗——它就是一张「编号 → 文件」的对照表,没有别的玄机。把 0/1/2 之外的某个编号后面看成 xxx (deleted),就是你要抓的那个洞。
批量扫全系统(不用装任何东西):
sudo find /proc/*/fd -lname '*deleted*' -printf '%p -> %l\\n' 2>/dev/null
怎么读:输出一行就是「某个进程的某个 fd 指着某个已删除的文件」。这条命令是这类问题里最通用的探针,任何 Linux 都能跑。
想看得更细,连它占多少空间一起看:
sudo ls -l /proc/1042/fd/1
# 用 stat 看这个 fd 指向的 inode 大小
sudo stat -L /proc/1042/fd/1 | grep -E "Size|Blocks"
三、打法三:只想知道"哪个服务在漏",按进程聚合
如果进程很多,直接按占用量排序,一眼看出元凶:
sudo lsof +L1 -S 2>/dev/null | awk 'NR>1 {print $1, $2, $7, $9}' | sort -k3 -rn | head
怎么读:sort -k3 -rn 是「按第三列(大小)从大到小排」,排在最上面的就是最大的那个洞。
四、怎么释放:三条路,按代价从低到高
找到之后,问题变成「怎么把空间要回来」。
路线一:重启服务(最稳,优先选)
sudo systemctl restart nginx
进程一重启,旧的句柄全部关掉,空间立刻释放。
代价是有服务中断(哪怕只有一两秒)。所以:
-
能重启就重启,这是最干净的做法;
-
心里有数:重启会让连接断开,负载均衡后面的其他节点能顶住的话,影响就是可接受的。
路线二:清空文件内容而不删文件(更平滑,但要改配置)
如果这个文件是日志,而且服务配置支持重新打开日志文件(比如 nginx 收到 USR1 信号会重开日志),那可以:
sudo truncate -s 0 /proc/1042/fd/1
怎么读:truncate -s 0 是「把这个文件截断成 0 字节」。这里操作的不是文件路径(路径早没了),而是通过 /proc 里的句柄直接操作那个文件对象——文件还开着,但里面的内容被清空了,空间当场还回来,进程完全无感知。
⚠️ 两个必须知道的限制:
这招只对「持续追加写」的文件安全(日志就是典型)。如果进程用固定偏移读写(比如数据库文件),截断会让它以为数据还在,接下来读到一片空洞——那是数据损坏;
你清的是「现在这个句柄」指向的文件。如果进程之后又按原路径创建了新文件(很多服务会),旧句柄还会留着,得配合 USR1 之类的重开日志信号。
所以:不确定文件性质时,老老实实重启服务。
路线三:查清根因,从源头掐断
释放完空间,还有一件事必须做:问清楚它为什么会涨到撑满。
-
日志没轮转?→ 配 logrotate(这是最常见的原因,很多服务默认的轮转策略形同虚设);
-
服务把 stdout 重定向到了一个从不切割的文件?→ 改成交给 journald,或者配 journald 的容量上限(/etc/systemd/journald.conf 里的 SystemMaxUse);
-
应用在写一个自己不清理的临时文件?→ 这是应用 bug,要反馈。
不查根因,下周同一时间你会再来一次。 这不是危言耸听,是这类故障的标准复现周期。
五、三个坑
坑 1:rm 之后 df 没变化,就以为删错了文件,接着删别的。
删对了。空间没释放不是因为你删错了,是因为有进程开着它。 这时候正确的动作是去查谁开着它(打法一),而不是继续删——继续删只会把现场破坏掉,甚至删掉不该删的东西。
坑 2:日志文件用 rm 而不是 truncate。
这是最标准的错误示范。日志文件的正确清理方式是清空内容(truncate -s 0 或 : > 文件),然后让服务重新打开:
sudo truncate -s 0 /var/log/nginx/access.log
原因:rm 会把文件名摘掉,服务还攥着旧句柄继续往「那个已经不存在的文件」里写,空间一直不释放;而清空内容不改变 inode,服务继续写同一个文件,一切正常。
坑 3:lsof 的数字和 /proc/PID/fd 对不上。
lsof -p PID | wc -l 会比 ls /proc/PID/fd | wc -l 多——因为 lsof 会把工作目录、root 目录、可执行文件、内存映射的库也算进去,而这些不占文件描述符。要判断「这个进程还剩多少 fd 额度」,用 /proc/PID/fd 的数字才准(这在下一篇讲 Too many open files 时会用到)。
附:三分钟亲手复现一次
光看文字不够直观。下面这段脚本你可以直接贴进终端跑——它只在 /tmp 下操作,结束时自己清理,不碰系统任何其他东西:
#!/bin/bash
# 演示「删除后空间不释放」,全程只在 /tmp 下
D=/tmp/demo-deleted
mkdir -p "$D"
echo "① 删除前的 /tmp:"; df -h /tmp | tail -1
# 造一个 100M 的文件(/tmp 小的机器把 100 改成 50)
dd if=/dev/zero of="$D/big.log" bs=1M count=100 status=none
echo "② 造好 100M 文件后:"; df -h /tmp | tail -1
# 起一个后台进程打开它(扮演"一直不放手"的服务)
tail -f "$D/big.log" > /dev/null &
HOLDER=$!
sleep 1
# 教科书式的误操作:日志用 rm 删
rm "$D/big.log"
echo "③ rm 之后(文件名没了,空间没回来):"; df -h /tmp | tail -1
echo " 谁在攥着它:"
ls -l /proc/$HOLDER/fd 2>/dev/null | grep deleted
# 释放:把攥着它的进程干掉
kill $HOLDER 2>/dev/null
sleep 1
echo "④ 进程结束后:"; df -h /tmp | tail -1
rm -rf "$D"; echo "清理完成"
我在自己机器上跑了一遍,原样贴出来(我这台的 /tmp 是 tmpfs,也就是内存盘——照样能演示,这个机制跟文件系统类型无关):
① 删除前的 /tmp:
tmpfs 3.7G 8.0K 3.7G 1% /tmp
② 造好 100M 文件后:
tmpfs 3.7G 101M 3.6G 3% /tmp
③ rm 之后(文件没了,空间没回来):
tmpfs 3.7G 101M 3.6G 3% /tmp
谁在攥着它:
lr-x—— 1 rainbow rainbow 64 Sep 28 15:18 6 -> /tmp/demo-deleted/big.log (deleted)
④ 进程结束后:
tmpfs 3.7G 8.0K 3.7G 1% /tmp
三个细节值得多看一眼:
-
③ 和 ② 一模一样(都是 101M)——rm 完毫无变化,这就是事故现场;
-
铁证那行开头的 lr-x——,第一个字符 l 说明它是个软链接(/proc 里的 fd 都是),紧跟的 r-x 说明这个句柄是只读打开的(tail -f 只读),6 -> 是文件描述符编号;
-
④ 一 kill,101M 当场掉回 8.0K——不是「慢慢回收」,是立刻释放。
这就是全部原理。 生产环境上,把你的 nginx、java、mysql 代进那个「HOLDER」的位置,就是你现在遇到的事故。
速查表(文末收藏版)
谁在占用已删除文件 → sudo lsof +L1 (先看 NAME:/memfd: 开头的是内存文件,不占磁盘)
(不装 lsof 的版本) → sudo find /proc/*/fd -lname '*deleted*' -printf '%p -> %l\\n' 2>/dev/null
按大小排序找元凶 → sudo lsof +L1 | awk 'NR>1{print $1,$2,$7,$9}' | sort -k3 -rn | head
看某个进程开了哪些文件 → sudo ls -l /proc/PID/fd
判断 df 与 du 的差值 → df -h / + sudo du -xsh / (差值 = 被攥住的已删除文件)
释放方式一(最稳) → sudo systemctl restart 服务名
释放方式二(平滑) → sudo truncate -s 0 /proc/PID/fd/N ← 仅限追加写的日志文件
清空日志的正确姿势 → sudo truncate -s 0 /var/log/xxx.log (不要用 rm)
切日志(nginx) → sudo kill -USR1 $(cat /run/nginx.pid)
根治:配日志轮转 → /etc/logrotate.d/ 下加配置
根治:限制日志总量 → /etc/systemd/journald.conf 里 SystemMaxUse=500M

