← cd ../posts

硬链接、软链接、inode:为什么 rm 不一定释放磁盘

2026-06-25

这是 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.txt 12 字节)

软链接更像 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

nvmpyenvupdate-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 还在。