inode 与硬链接:删了文件,空间为什么没降

inode 与硬链接:删了文件,空间为什么没降
隔壁老康难度 ★★☆☆☆ · 前置:stat 那十几个字段分别在说什么 | 一句话结论:文件名只是目录项,inode 才是文件本体。只要还有进程开着它,
unlink之后空间就不还——df和du报的不是一回事,根源在这里。
有台机器的磁盘报警,我上去看,df -h / 显示 98G 用了 93G,第一反应是盘该扩了,但运维流程要走单子,得先写清楚是谁占的,于是按常规套路找大目录,du 挨个扫过去,数出来一共 41G,93 减 41 还差 52G,这 52G 既不在日志里也不在家目录里,就像一个不存在的租客占着半个房子,我盯着那两个数字看了很久,开始怀疑自己是不是算错了,又重算了一遍,还是 52G。
先说结论,省得你也绕一个小时,那 52G 在一个被删掉的文件上,只是删它的时候还有个进程开着。
按常规套路找大目录,
1 | du -sh /var/log /var/lib /home |
1 | 2.1G /var/log |
加起来 41G。93 减去 41,还差 52G 不知道在哪。
我当时的排查顺序挺有代表性的,先怀疑系统保留区(tune2fs -m 那种),再怀疑 ext4 的元数据,接着怀疑是不是有挂载点重叠导致 du 跳过了什么东西,最后甚至开始怀疑 df 的数字是不是在骗我,这四条假设我挨个去验证,每一条都要敲命令、看输出、再推翻,折腾了快一个小时,期间我一直默认占空间的必然是一个看得见名字的文件,这个前提错得很安静,因为它从来没被写下来过,也就没人去质疑它。
折腾了快一个小时才想起来还有一条命令,
1 | sudo lsof +L1 2>/dev/null | head |
1 | COMMAND PID USER FD TYPE DEVICE SIZE/OFF NLINK NODE NAME |
一行,45.2G,名字后面挂着 (deleted)。
文件名被删了,inode 没释放,因为 nginx 还攥着它的 fd,这一篇讲的就是这件事为什么必然发生,以及它连带出来的一整套东西。这件事有意思的地方在于它不是故障,内核的每一步都做对了,unlink 该删的记录删了,st_nlink 该减一减了,数据块该留也留了,三件事都挑不出毛病,可合起来的结果就是运维看着 93G 的占用水位线找不到那 52G 在哪,任何人第一次撞见都得懵一下,因为脑子里那个模型一直把文件和文件名当成一件事,一件事只需要处理一次,删了就没了,而真实结构里这是两件事,删掉名字和丢掉数据是两个独立动作,它们在绝大多数时候同时发生,所以你从来没注意过它们能分开。(这条命令是同事路过时顺口提的,我之前完全不知道 lsof 的那个 +L1 是干什么的,参数见过很多次,一直当成某个无关的过滤开关)
文件名不是文件
这句话听着像在抠字眼,但它是整个文件系统的地基。
一个文件在磁盘上的完整结构是两层,
| 层 | 存的是什么 | 属于谁 |
|---|---|---|
| 目录项(dentry) | 名字 → inode 号的映射 | 目录 |
| inode | 大小、权限、属主、时间、数据块位置、链接数 | 文件 |
目录本身也是一个文件,它的内容就是一张名字到 inode 号的表,所以严格讲,新建文件不是创建了一个文件,而是在目录里加了一条记录,另外分配了一个 inode,这两件事是分开的,分开这件事本身没什么存在感,可一旦你要问这个文件到底是谁,答案就只能落在 inode 上,因为名字属于目录,换一个目录就是另一个名字,而 inode 从头到尾只有一个,这也顺带解释了为什么同一个文件可以有多个名字但只有一份内容,目录那张表里多写一行就够了,数据块一个都不用复制,代价低到几乎为零,所以硬链接这个功能在内核里几乎没有成本,它只是给 inode 的计数加了个一。
这张表能直接看,ls -i /tmp 会给每个名字前面加上 inode 号,那一列数字比文件名更能代表身份。
1 | ls -i /tmp |
1 | 2490934 tiny.txt 2491033 xonly 2491034 share |
-i 就是 inode 号,而硬链接不过是另一条指向同一个 inode 的记录,它和原文件完全平级,没有主次之分,两条记录都指着同一个 inode,谁都不是谁的副本,所以删掉其中任何一条,另一条照样能用,内容一点不动,我第一次理解这个模型的时候画了半页纸的箭头,画完才意识到自己想复杂了,其实就一句话,名字是贴在 inode 上的便利贴,可以贴很多张,贴几张都不影响底下那件东西本身,麻烦的地方在于你没法从 inode 反查出它一共有几个名字,内核根本不存这条反向信息,想数只能遍历目录去比对 inode 号,这也是 find / -inum 262144 慢得让人想放弃的原因。
graph LR D1["目录 /home/a"] -->|"report.txt"| I["inode 262144<br/>nlink=2<br/>数据块"] D2["目录 /home/b"] -->|"r.txt"| I I --> B["磁盘上的数据块"] P["进程 nginx<br/>fd=12"] -.->|"持有打开的文件"| I
删一个文件,内核做了三件事
rm 底下调的是 unlink,逻辑就三步,
- 在目录里删掉那条名字 → inode 的记录;
st_nlink减一;- 如果
st_nlink归零,并且没有任何进程打开它,才真正释放 inode 和数据块。
第 3 步那两个条件是与,不是或,所有现象都从这一个与字里长出来。
| 状态 | st_nlink |
有进程打开? | 空间释放? |
|---|---|---|---|
| 普通删除 | 变 0 | 没有 | 立即释放 |
| 删了但有人开着 | 变 0 | 有 | 不释放 |
| 只删了一个硬链接 | 还大于 0 | 无所谓 | 不释放 |
| 有进程打开但没删 | 还大于 0 | 有 | 不释放 |
内核里没有一张叫 unlinked 的链表专门收这类文件,真正撑住它的是 inode 自己的引用计数,只要还有打开的 fd 持着这个 inode,计数就不归零,iput 就不会走到最后那一步(iput_final → generic_drop_inode),inode 和数据块都留着,等最后一个 close 才真正清理,文件系统那一侧另有一条链,叫 orphan list,ext4 在 nlink 掉到 0 时把它挂上去(ext4_orphan_add),掉电重启后 e2fsck 靠这条链把块收回来,所以你看到的那 52G 并没有悬空,它一直有一个内核里的账本记着。(orphan list 这个名字我查了半天,因为它和 unlinked 链表传说混在一起,很多老文章还在讲后面那个)
所以删了文件空间不降不是 bug,是设计,数据还在被使用,只是没名字了。
st_nlink 就是 struct stat 里那个字段,stat -c %h 能看,建一个硬链接两边都变 2,删掉一个变回 1,内容一点没动。
这也就解释了 ls -l 第二位那个数字到底是什么,它就是 st_nlink,普通文件是 1,每加一条硬链接加一,目录的那个数字更大,因为它至少有 . 和父目录里的那条记录,我在讲 stat 那篇里专门绕了一圈才明白这一点,这里就不再重复。倒是有一个副作用值得记一下,st_nlink 大于 1 的普通文件意味着它还有别的名字,但你没办从这一个 inode 查出那些名字在哪,find / -inum 得把整个根遍历一遍,盘大了要跑很久,所以清理遗留文件的时候,靠 nlink 只能判断该不该删,找不到该删哪一个。
为什么改了名它还在写
这是生产上最经典的一幕。
1 | cd /var/log/nginx # nginx.pid 在别处的话就用全路径 |
如果第二步没做,或者信号没送到主进程,nginx 会继续往那个已经改名的文件里写,原因很简单,
mv在同一文件系统内只改目录项里的名字,inode 没变;- nginx 手里握的是 fd,fd 指向 inode,跟名字没有关系;
- 所以它写进去的还是同一份数据,只是这份数据现在叫
access.log.1了。
然后 du 看着 access.log.1 越来越大,df 也越来越满,你以为 logrotate 在工作,实际上什么都没发生,这种状态能持续很久,因为两边都觉得自己是对的,du 看见的确实是一个在长的文件,df 看见的确实是在被占的块,没人报错,也没人报警,唯一的症状就是盘慢慢满,而磁盘一般都是在最不合适的时候才满的,比如周五晚上,比如大促前一小时,我遇到的那次是后者,当时所有力气都花在找是谁写的日志上,没人想到去查这个文件还有没有名字,因为报警里显示的路径明明还好好的在那,ls 也能看见它,能看见的东西默认等于活着的,这个默认太强了,强到需要一条专门的命令才能反驳它。(我第一次遇到的时候排查方向完全错了,我在看 logrotate 的配置,怀疑它加了 delaycompress 之后没压缩,其实压缩根本不是问题所在)
清空的正确姿势不是 rm,是截断,/var/log 一般归 root,所以要 sudo,这是对生产日志的写操作,先想清楚再敲,
1 | sudo sh -c ': > /var/log/nginx/access.log' |
或者
1 | truncate -s 0 /var/log/nginx/access.log |
truncate 把 inode 的 size 设成 0 并释放数据块,文件名不变,fd 也不变,写日志的进程毫发无伤,继续往同一个 inode 里写,所以它不需要重启任何一个服务,也不需要通知谁,日志就从零开始重新长。
一句话,rm 对正在被写的日志文件是无效的,要用 truncate,这条我记了很久,因为第一次遇到的时候,我 rm 完之后发现磁盘没降,还以为 df 缓存了,后来在另一个项目上又遇到一次同样的症状,才把这条彻底刻进去,那次是 php-fpm 的慢日志,运维同事删了文件之后还一脸笃定地说应该等一会儿才降,因为文件系统有延迟,我们俩就这么等了两小时,水位线纹丝不动,最后还是 lsof 一查就破了案,两小时换来一个不该忘的结论,代价还算便宜。
df 和 du 问的不是同一个东西
| 命令 | 怎么算 | 从哪拿数据 |
|---|---|---|
df |
文件系统层面:总块数减空闲块数 | statfs 系统调用,问的是块设备 |
du |
目录树层面:把所有文件的 st_blocks 加起来 |
遍历目录,问的是每个 inode |
一个问设备,一个问 inode,能对上才怪,这两个命令的名字里都带着 disk 和 usage 的意思,谁都会觉得它们在回答同一个问题,实际上它们连数据来源都不在一层,df 问的是文件系统自己还剩多少空闲块,du 问的是这些目录底下的文件登记了多少块,一个从上面往下看,一个从下面往上加,中间差着所有没有名字的东西,我一开始以为这种差异只出现在特别边缘的场景里,直到发现差 52G,才意识到 52G 已经不算边缘了,它占了总量的一半还多,而两个命令都没报错,都在很自信地给数字。
差异至少四类,第一类就是上面那个,被删但还被打开的文件,du 找不到名字,df 知道块被占着。
稀疏文件其实两类都对得上,因为你比较的如果不是 ls -l 的 st_size 而是实际块数,会误导人的通常是 ls -l,它显示的是逻辑长度,不是占用。
文件系统自身开销也是一类,ext4 的日志、inode 表、元数据都占块,du 不计入,小文件特别多的时候这一项很显眼,一个全是几 KB 小文件的目录能吃掉比预期多得多的盘。
挂载边界也会制造差异,du / 默认不跨文件系统,而 df / 只报一个,反过来,du 跑到没权限的目录会静默跳过,结果偏小,这个偏小还特别难发现,因为 du 不报错,它只是少算了几块。
第 1 类有个特别好用的自查法,前面已经出现过了,df 满而 du 加起来不多,先去翻 deleted 文件。
1 | lsof +L1 |
+L1 的意思是链接数小于 1,也就是已经被删掉的,输出里 NLINK 那列是 0 的就是,这个参数长得像某种过滤语法,其实就一个条件,我第一次知道的时候还愣了下,因为它跟 -i、-p 那些参数完全不是一个路子。
没装 lsof 也能找,
1 | sudo find /proc/*/fd -lname '*(deleted)' 2>/dev/null | head |
扫的是每个进程的 fd 里,符号链接指向带 (deleted) 的那些,find 的 -lname 就是拿符号链接的目标去匹配,而 /proc/PID/fd 下面那些恰好都是符号链接。
两种链接,六条差别
硬链接不是万能的,它有三条硬限制。
不能跨文件系统,因为 inode 号只在单个文件系统内唯一,两个设备各自从 1 开始编号,撞号了也说不清指的是谁,所以 ln /tmp/a /dev/shm/a 这种写法会直接报错。
不能给目录建,这条是无条件的,vfs_link() 里撞到目录直接 return -EPERM,root 也不行,因为放开了会造出环,fsck 和目录遍历都会挂进去。
第三条不算限制,但更实际,多数时候你其实该用软链接。
软链接是另一个东西,它是个独立文件,内容就是目标路径那串字符串,所以差别很成体系,
| 对比 | 硬链接 | 软链接 |
|---|---|---|
| inode | 共享同一个 | 自己独立 |
| 跨文件系统 | 不行 | 可以 |
| 指向目录 | 不行 | 可以 |
| 目标删除后 | 还能访问 | 变成悬空链接 |
| 大小 | 和原文件一样 | 目标路径的字节数 |
| 权限 | 和原文件一样 | 恒为 777,没意义 |
软链接的权限位永远显示 lrwxrwxrwx,chmod 它也不生效,它的权限根本没用,访问时看的是目标文件的权限,这一条我在面试里被问过一次,答对了,但当时是因为背过,不是因为想通了,真正想通是有一次我给一个软链接 chmod 700,然后以另一个用户身份访问它指向的文件,照样能读,才明白那七位只是摆设。
顺带说一条反直觉的经验,软链接用相对路径比绝对路径好,绝对路径的软链接在目录整体搬移之后会断,相对路径的还能用,打包软件的时候这条特别省事,因为你不知道用户会把它解到哪个父目录下。
文件系统不同,这些数字会飘
上面这些结论在 ext4、xfs、btrfs 上基本成立,细节有差。
xfs 的 inode 号是 64 位,数字非常大,第一次看到十位数的时候我还以为自己的 find 写错了,overlayfs 和容器里 st_nlink 的行为不太一样,偶尔能看到 0 却仍然读得到,tmpfs 上的文件不占块设备,df 看到的是内存用量,NFS 上 df 和 du 的差异可能很大,因为客户端并不知道服务端真实的占用。
排查前先确认挂载类型,findmnt -T /var/log 会一路往上找到那个目录属于哪个挂载点,这一行的信息比 df -h 更直接。
1 | findmnt -T /var/log |
让 inode 被复用一次
理解名字和 inode 不是一回事,最快的办法是让一个 inode 被复用一次。
1 | cd /tmp && rm -rf ino && mkdir ino && cd ino |
1 |
|
删掉再建同名文件,
1 | rm a.txt |
a.txt 的 inode 变了,也不一定,可能被复用,内核会把刚释放的 inode 塞进缓存优先复用,这一点本身就说明同名文件不等于同一个文件,所以拿 inode 号做判断也不完全可靠,短时间内的复用会让两次不同的创建看起来像同一个文件。(这个坑我在比对两个备份的时候踩过,inode 一样,我以为是同一份,其实是旧的被删了、新的恰好用了同一个号)
再验删了但空间没降,这个实验要盯着 df 的 Used 列看,
1 | cd /tmp/ino |
df 的 Used 不动,lsof 里那个文件带着 (deleted),等 30 秒 Python 退出、fd 关掉,空间立刻回来。
然后对比截断,这个实验最直观的地方是你能亲眼看到文件从 0 开始重新长,
1 | echo "一些日志" > /tmp/ino/service.log |
截断之后进程接着写,文件从 0 开始长,这就是 logrotate 本该有的行为。
最后看两种链接,
1 | cd /tmp/ino |
1 |
|
看第一列,hard.txt 和 orig.txt inode 相同、链接数都是 2,soft.txt 是自己的 inode,大小 8 是 orig.txt 那串字符的字节数。
删掉 orig.txt,
1 | rm orig.txt |
第一个正常,第二个 No such file or directory,因为软链接自己没死,它指向的那个名字没了。
硬链接和软链接,混着问的最多
删了文件空间就该降吗,有进程还开着就不降,lsof +L1 一秒钟能查出来。
mv 会搬数据吗,同一文件系统内只改目录项,不搬,跨文件系统才真复制,那时候等价于 cp 加 rm,所以一个几百 G 的目录 mv 过去慢得像在拷,是正常的。
rm 能腾出日志占的空间吗,对正在被写的日志不管用,用 : > file 或 truncate -s 0。
硬链接是两个文件吗,同一个 inode 的两条记录,没有主次。
符号链接的权限有用吗,没用,永远 777,访问控制看目标。
ln -s 用相对路径不安全吗,反了,相对路径在目录搬移之后还活着,绝对路径会断。
名字、inode、引用计数
文件名是目录里的一条记录,inode 才是文件,ls -i 能看到 inode 号。
unlink 三步,删记录、nlink 减一、两个条件都满足才释放。
删了空间不降等于还有进程开着那个 inode,lsof +L1 直接定位。
日志文件要截断,不要 rm。
df 问块设备,du 问 inode,对不上是常态。
硬链接不能跨文件系统、不能指目录,软链接能,但会悬空。
到这里,一个文件在系统里的身份,它叫几个名字、归谁、能干什么、被谁占着,都讲完了,接下来该换主角了,进程。第一件事是个几乎所有人都答错的问题,ps 里的 D 是什么意思,为什么这种进程连 kill -9 都杀不掉。











