硬链接、软链接、inode:为什么 rm 不一定释放磁盘
这是 Linux 系列的第 12 篇。上一篇讲权限——这一篇拆"文件"这个抽象本身的底层结构。
0. 一个让所有新手抓狂的真实故事
$ df -h /
Filesystem Size Used Avail Use% Mounted on
/dev/sda1 50G 28G 22G 56% /
$ tail -f /var/log/app.log
(程序运行中,日志一直涨)
$ sudo rm /var/log/app.log
$ df -h /
Filesystem Size Used Avail Use% Mounted on
/dev/sda1 50G 28G 22G 56% /
↑ 没变?!
文件明明删掉了,磁盘怎么没释放?!
答案在这一篇。
1. 一个文件,两部分
Linux 的核心抽象:
┌────────────────────────┐
名字(文件名) ─────► │ inode (元数据 + 指针) │ ─────► 实际数据块
"app.log" │ - 大小 │ (data blocks)
│ - 时间戳 │
│ - 权限 │
│ - 属主 │
│ - 链接数 │
│ - 指向 data blocks │
└────────────────────────┘
- 文件名只是个标签——存在某个目录里
- inode 是文件本体——所有"关于文件的事实"都在 inode
- data blocks 是真正的内容——硬盘上的字节
一个 inode 可以被多个名字指——这就是硬链接。
$ ls -i README.md
1245678 README.md
↑ 这就是 inode 号
2. 硬链接(hard link):同一个 inode,多个名字
$ echo "hello" > original.txt
$ ln original.txt copy.txt # 注意:没有 -s
$ ls -li
1245678 -rw-r--r-- 2 me staff 6 Apr 22 10:00 copy.txt
1245678 -rw-r--r-- 2 me staff 6 Apr 22 10:00 original.txt
↑ ↑
inode 一样 链接数 2
观察:
- 两个名字,同一个 inode
- 链接数从 1 变成 2
- 大小一样(因为是同一份数据,不占双倍磁盘)
改一个就是改两个(因为本来就是同一份):
$ echo "world" >> copy.txt
$ cat original.txt
hello
world
删一个不影响另一个:
$ rm copy.txt
$ cat original.txt
hello
world
$ ls -li original.txt
1245678 -rw-r--r-- 1 me staff 12 ... original.txt
↑ 链接数回到 1
硬链接的限制
- 不能跨文件系统(inode 是文件系统内部的编号,跨盘没意义)
- 不能链接目录(防止形成环——
/的硬链接指向/foo,再链/foo/bar指向/,无限循环)
硬链接什么时候有用
实际工作里用得不多,但有几个场景:
- 去重备份(time machine、rsync
--link-dest):每次"快照"对没变化的文件只建硬链接,不复制数据。100 次快照只占 1 份空间 - 多个调用名(busybox:一个二进制 + 硬链接成
ls / cat / grep / vi等几十个名字)
3. 软链接 / 符号链接(symlink):一个新文件,指向另一个名字
$ ln -s original.txt link.txt
$ ls -li
1245678 -rw-r--r-- 1 me staff 12 ... original.txt
1245901 lrwxrwxrwx 1 me staff 12 ... link.txt -> original.txt
↑ ↑
不同 l = symlink
观察:
- 软链接是一个新文件(新 inode)
- 它的内容就是目标的路径字符串
- 大小 = 目标路径的字符数(这里
original.txt12 字节)
软链接更像 Windows 的"快捷方式"。
软链接的特点
# 跨文件系统、可链接目录
$ ln -s /mnt/data /home/me/data
$ ln -s ~/projects/myapp/.env /etc/myapp/.env
# 删目标后,软链接变"悬空"
$ rm original.txt
$ ls -l link.txt
lrwxrwxrwx 1 me staff 12 ... link.txt -> original.txt # 还在
$ cat link.txt
cat: link.txt: No such file or directory # 但读不出来
硬 vs 软对比
| 硬链接 | 软链接 | |
|---|---|---|
| 是新文件吗 | 否(同 inode) | 是 |
| 跨文件系统 | ❌ | ✅ |
| 链接目录 | ❌ | ✅ |
| 删目标后还能用 | ✅ 文件还在 | ❌ 变悬空 |
| 占磁盘 | 仅多一个目录 entry | 一个新 inode + 目标路径字符串 |
90% 场景用软链接(ln -s)。硬链接是底层机制,日常很少手动建。
4. 链接数和 rm 的真相
rm 这个名字也是误导——它实际不是"删文件",是"unlink"——把一个目录 entry(名字)解开。
rm file
↓ 内核
unlink(file)
↓
inode.链接数 -= 1
↓
if 链接数 == 0:
if 还有进程打开它:
等所有进程关掉 fd 再回收磁盘
else:
立刻回收磁盘
关键:链接数减到 0 时,还要等所有打开 fd 的进程关掉它,才真正释放磁盘。
这就解释了开头那个故事——
$ tail -f /var/log/app.log ← 这个进程持有 fd
$ sudo rm /var/log/app.log ← unlink 成功,但 inode 还活着
$ df -h ← 磁盘没变,因为 inode 还在
验证 + 救场
# 找到还在用"已删除"文件的进程
$ sudo lsof | grep deleted
nginx ... /var/log/nginx/access.log (deleted)
java ... /tmp/heap.bin (deleted)
# 救场方法 1:把进程重启(关闭 fd)
$ sudo systemctl restart nginx
# 救场方法 2:直接清空文件,进程不重启(更优雅)
# 但这要求文件还有名字。已经 rm 的就只能从 /proc/<pid>/fd/N 抢救
$ sudo lsof | grep '(deleted)'
$ sudo cp /proc/12345/fd/5 /tmp/recovered.log # 通过 /proc 还能读到内容
$ sudo : > /proc/12345/fd/5 # 把 fd 指向的内容清空(释放 disk)
最后一招(: > /proc/<pid>/fd/N)是没法重启服务时的应急——把进程持有的 fd 内容清零,磁盘当场释放。
教训:日志文件不要简单
rm删——要用logrotate(下面后面的章节会讲),或者直接: > file.log(清空,不 unlink,进程继续用)。
5. inode 也会"用完"
文件系统在格式化时预分配固定数量的 inode(ext4 默认每 16KB 一个)。如果你有 大量小文件——inode 可能比磁盘更早耗尽:
$ df -h
/dev/sda1 50G 22G 28G 44% /
$ df -i
/dev/sda1 3.2M 3.2M 0 100% /
↑
inode 100% 满了!
症状:"明明 28G 空闲但写不进文件"。
# 找哪个目录吃了最多 inode
$ sudo find / -xdev -printf '%h\n' 2>/dev/null | sort | uniq -c | sort -rn | head -10
1234567 /var/spool/postfix/maildrop ← 罪魁祸首
89012 /var/cache/apt/...
...
常见元凶:
- 没轮转的 mail queue(postfix)
- 大量 npm
node_modules(一个node_modules轻松 10 万文件) - 没清的 git pack 文件
- session 文件(PHP 之类)
解法:删掉那堆小文件,或者新装机器时给"小文件多"的系统选不同的 inode 比率(mkfs.ext4 -i 4096)。
6. 软链接的几个实战玩法
① 系统级"当前版本"指针
$ ls /opt
node-v18.0.0/
node-v20.0.0/
node-v22.0.0/
node -> node-v22.0.0/ ← symlink 指向"当前用"的版本
# 切换版本只需要重指
$ sudo ln -snf node-v20.0.0 /opt/node
nvm、pyenv、update-alternatives 全是这个原理。
② 配置集中管理 + symlink 散到家
# 你的 dotfiles 都在 ~/dotfiles
$ ln -s ~/dotfiles/.zshrc ~/.zshrc
$ ln -s ~/dotfiles/.gitconfig ~/.gitconfig
$ ln -s ~/dotfiles/.tmux.conf ~/.tmux.conf
# 改 ~/.zshrc 实际改的是 ~/dotfiles/.zshrc,可以 git commit
③ 把数据目录搬到大盘但不让应用感知
$ sudo systemctl stop docker
$ sudo mv /var/lib/docker /mnt/big-disk/docker
$ sudo ln -s /mnt/big-disk/docker /var/lib/docker
$ sudo systemctl start docker
Docker 看到的还是 /var/lib/docker,实际数据在大盘上。
④ readlink 看 symlink 指向哪
$ ls -l /usr/bin/python3
lrwxrwxrwx 1 root root 9 ... /usr/bin/python3 -> python3.10
$ readlink /usr/bin/python3
python3.10
$ readlink -f /usr/bin/python3 # 一直追到最终
/usr/bin/python3.10
7. cp / mv / tar 跟链接的几种坑
# cp 默认会"跟着" symlink 复制目标内容
$ cp link.txt new.txt
# new.txt 是 original.txt 的副本,不是 symlink
# 要保留 symlink 关系
$ cp -P link.txt new.txt # P = preserve link
$ cp -a src/ dst/ # -a 包含 -P 等(推荐用于归档)
# mv 跨文件系统会复制再删——硬链接计数会归 1
# mv 同文件系统就是改名(inode 不变)
# tar 默认保留 symlink
$ tar czf backup.tar.gz dir/
# 解压后 symlink 还在
cp -a 是"几乎不丢任何信息的复制"——保 symlink、保权限、保属主、保时间戳。做备份首选。
8. 现在做一件事
# 1. 看你 home 下的 symlink
$ find ~ -maxdepth 3 -type l -ls 2>/dev/null | head
# 2. 看 /usr/bin 里有多少是 symlink
$ ls -l /usr/bin | grep -c ^l
# 通常几十个,python3 / pip / vi 等都是 symlink 到具体版本
# 3. 看你磁盘 inode 用量
$ df -ih
# 4. 找当前还被进程持有的"已删除"文件
$ sudo lsof 2>/dev/null | grep deleted | head -5
# 如果有 — 你已经知道为什么 df 没释放了
理解 inode 这一层抽象,你就再也不会被"删了为啥没空间""明明有空间为啥写不了"这种问题困扰。
下一篇:mount-and-fs——ext4 / btrfs / zfs / tmpfs 怎么挑、
mount命令背后发生了什么、/etc/fstab怎么写、为什么云上服务器经常 LVM 还在。