[转] 文件名只是个标签,文件真正的身份是 inode
渡一原创
作者:谢杰
你有没有遇到过这种情况?
一个日志文件已经被 rm 删掉了,但正在运行的服务居然还在往里面写。你去目录里 ls,文件名明明没了;可磁盘空间也没有立刻回来,进程更没有报错。
再比如,你用 pnpm 装依赖。100 个项目都装了 lodash,每个项目的 node_modules 里看起来都有一套完整文件,磁盘却没有跟着膨胀 100 倍。
这两件事表面上毫无关系。一个是删除文件,一个是装包。
但它们都在提醒你一件事:文件名并不是文件本身。文件名只是一个入口,真正被文件系统管理的对象,是 inode。
一旦把这层关系看清,硬链接、软链接、pnpm 的省空间,以及“文件删了为什么还能用”这些看似零碎的问题,就会突然连成一条线。
1. 你以为在操作文件名,其实是在查地址
平时我们总是说“打开文件”“删除文件”。但在 Linux 常见的文件系统里,系统做的事比这多一层。
它大致维护着两类东西:
- inode:记录文件类型、权限、大小、链接计数,以及文件数据放在哪。
- 目录:记录“这个名字对应哪个 inode”。
可以把 inode 想成一个人的档案,文件名则像通讯录里的名字。
目录 /home/user/projects/
├── "README.md" → inode 131078
├── "package.json" → inode 131079
├── "src" → inode 131080
└── "dist" → inode 131081
inode 131078
├── 文件类型:普通文件
├── 权限:644
├── 大小:1024 bytes
├── 链接计数:1
└── 数据位置 → [磁盘块 2048, 2049, 2050]
这里有一个关键点:inode 里没有文件名。
文件名在目录里,inode 只关心“我是谁、谁能访问我、我的数据在哪”。所以你看到的:
文件名 → inode 编号 → 文件数据
才是一次文件访问真正走过的路径。
比如执行:
$ cat hello.txt
系统不会拿着 hello.txt 这个字符串,直接去磁盘里找内容。它会先在当前目录里找到:
hello.txt → inode 1001
再读取 inode 1001,知道这个文件大小是 20 KB、权限是什么、数据在哪几个磁盘块;最后才去读取那些数据块,输出 Hello World。
所以,文件名更像门牌号。它能帮你找到房子,但门牌号本身不是房子。
2. rm 删掉的,首先只是这个入口
理解了这一点,再看 rm 就没那么神秘了。
$ rm hello.txt
很多人会下意识地觉得:Linux 现在应该把 20 KB 数据从磁盘上擦干净了。
其实它首先做的是删除目录里的这一条映射:
hello.txt → inode 1001
同时把 inode 的链接计数减 1。
如果这是最后一个名字,并且没有进程还打开着这个文件,文件系统才会把 inode 和数据块标记为可回收。它不需要立刻把每个字节清零,所以 rm 往往很快。
这也解释了文章开头的现象。
服务在 rm 之前已经打开了日志文件。即使文件名被删掉,服务手里的文件描述符仍然有效,它还是能继续读写那份数据。直到最后一个名字消失、最后一个进程也关闭文件,空间才真正有机会被回收。
删掉一个文件名,不等于立刻消灭那份文件数据。
3. inode 不是抽象概念,终端里就能看到
在终端执行:
$ stat README.md
你会看到类似输出:
File: README.md
Size: 2341 Blocks: 8 IO Block: 4096 regular file
Device: 801h/2049d Inode: 131078 Links: 1
Access: (0644/-rw-r--r--) Uid: ( 1000/ user) Gid: ( 1000/ user)
Access: 2025-06-20 14:22:00.000000000 +0800
Modify: 2025-06-18 09:15:00.000000000 +0800
Change: 2025-06-18 09:15:00.000000000 +0800
Birth: 2025-01-15 10:30:00.000000000 +0800
其中的 Inode: 131078,就是这个文件在当前文件系统里的 inode 编号;Links: 1 表示现在只有一个目录项指向它。
再执行:
$ ls -i
131078 README.md 131079 package.json 131080 src 131081 dist
文件名前面的数字,就是 inode 编号。
现在问题来了:如果两个文件名指向同一个 inode,会发生什么?
答案就是硬链接。
4. 硬链接:同一份文件内容,可以有多个名字
执行下面这条命令:
$ ln README.md README-backup.md
再看一眼:
$ ls -li
131078 -rw-r--r-- 2 user user 2341 Jun 18 09:15 README.md
131078 -rw-r--r-- 2 user user 2341 Jun 18 09:15 README-backup.md
131079 -rw-r--r-- 1 user user 890 Jun 10 16:40 package.json
你会看到两个变化:
README.md和README-backup.md前面的 inode 都是131078。- 权限后面的数字从
1变成了2,也就是链接计数变成了 2。
磁盘上的关系是这样的:
目录 /home/user/projects/
├── "README.md" → inode 131078
├── "README-backup.md" → inode 131078
└── "package.json" → inode 131079
inode 131078
├── 链接计数:2
└── 数据位置 → [同一份文件数据]
说白了,硬链接不是复制文件,而是给同一份文件内容再取一个名字。
所以你修改 README.md,README-backup.md 看到的也会是修改后的内容;你删掉 README.md,README-backup.md 依然可用,因为 inode 的链接计数只是从 2 变成了 1。
这也是硬链接最反直觉的地方:不存在谁是“原文件”、谁是“备份”。 两个名字地位完全相同,删掉任何一个都不影响另一个。
5. 为什么硬链接不能跨文件系统,也不能链接目录
硬链接很省空间,但它不是万能的。
第一,它只能在同一个文件系统里创建。
inode 编号只在自己的文件系统内部有意义。/home 和 /mnt/external 如果来自不同分区,它们各自有自己的 inode 表。你不能让 /mnt/external 里的一个目录项,直接指向 /home 文件系统里的 inode。
$ ln /home/user/file.txt /mnt/external/link.txt
ln: failed to create hard link '/mnt/external/link.txt' => '/home/user/file.txt': Invalid cross-device link
第二,用户不能通过 ln 给目录创建硬链接。
原因也很好理解:目录里还能包含目录。要是允许任意给目录再取一个硬链接名字,目录树就可能出现环。你从 A 走到 B,再从 B 走回 A,遍历文件系统的程序就很容易迷路。
这两个限制决定了:硬链接适合共享文件内容,但不适合表达灵活的“路径指向关系”。
这正是软链接要解决的问题。
6. 软链接:它保存的不是 inode,而是下一跳路径
创建软链接:
$ ln -s README.md README-soft.md
再看输出:
$ ls -li
131078 -rw-r--r-- 2 user user 2341 Jun 18 09:15 README.md
131078 -rw-r--r-- 2 user user 2341 Jun 18 09:15 README-backup.md
131083 lrwxrwxrwx 1 user user 9 Jun 20 15:00 README-soft.md -> README.md
这次,README-soft.md 的 inode 是全新的 131083。它不是在给 inode 131078 增加一个名字,而是在创建一个新文件。
这个新文件的内容只有 9 个字节:README.md。
目录 /home/user/projects/
├── "README.md" → inode 131078 → 文件数据
└── "README-soft.md" → inode 131083 → "README.md"
当你读取 README-soft.md 时,系统会先找到 inode 131083,发现它是软链接;然后读出里面保存的路径 README.md,再按这条路径重新找到 inode 131078。
软链接说白了就是一个“指向另一个名字的文件”。因此它能做到硬链接做不到的三件事:
- 可以跨文件系统,因为它存的是路径,不是别的文件系统里的 inode。
- 可以指向目录,因为它只是让系统继续解析一段路径。
- 可以先指向一个还不存在的目标;目标不存在时,它就是悬空链接。
它也有一个直接的代价:软链接依赖路径。
$ rm README.md
$ cat README-soft.md
cat: README-soft.md: No such file or directory
README-soft.md 本身还在,但它保存的路径已经找不到目标了。相比之下,硬链接不依赖“原来的那个名字”,只要还有一项指向 inode,文件内容就还在。
7. pnpm:为什么它既用硬链接,又用软链接
现在回到最开始那个问题:pnpm 为什么能让一堆项目看起来都有完整的 node_modules,却不把磁盘塞满?
因为 pnpm 把“文件内容”和“目录关系”拆开处理了。
先看文件内容。
pnpm store 中保存着 lodash@4.17.21 的文件。项目 A 和项目 B 安装它时,pnpm 可以在各自的 .pnpm 目录下,把这些文件硬链接到 store 中的同一个 inode:
pnpm store/
└── lodash@4.17.21/
├── index.js ← inode 500001
├── package.json ← inode 500002
└── fp/
└── add.js ← inode 500003
project-a/node_modules/.pnpm/lodash@4.17.21/node_modules/lodash/
├── index.js ← 硬链接,inode 500001
├── package.json ← 硬链接,inode 500002
└── fp/ ← 普通目录
└── add.js ← 硬链接,inode 500003
你可能立刻会问:store 里的包明明也是一个目录,硬链接又不能指向目录,.pnpm 里的目录是怎么来的?
答案是:pnpm 不会硬链接整个包目录。 它会先在 .pnpm 中新建一套普通目录,再把里面的每个文件——包括子目录里的 add.js——逐个硬链接到 store。
目录就像新搭出来的书架,真正占空间的书才是共享的。这样,项目拥有完整的目录结构,文件内容却不需要复制一遍。
但文件共享只是第一步。Node.js 还需要按照 node_modules/<包名> 的路径来找依赖,还要让每个包找到它自己的依赖。这种“目录应该指向哪里”的关系,硬链接做不了,因为它不能链接目录。
于是软链接登场:
project-a/node_modules/
├── some-package -> .pnpm/some-package@1.0.0/node_modules/some-package
└── .pnpm/
├── some-package@1.0.0/
│ └── node_modules/
│ ├── some-package/
│ └── some-dependency -> ../../some-dependency@2.0.0/node_modules/some-dependency
└── some-dependency@2.0.0/
└── node_modules/
└── some-dependency/
这就是 pnpm 的分工:
- 硬链接让相同文件内容只存一份。
- 软链接把这些目录组织成 Node.js 能识别的依赖关系。
你看到的是一棵完整的 node_modules 树,pnpm 实际上维护的却是一套“目录各自存在、文件内容共享、依赖关系靠指向连接”的结构。
8. 实际工作中,该选哪一种链接
理解这些并不意味着你以后要手写一堆 ln 命令。更有用的是,你知道什么时候该用哪种思路。
适合硬链接的,是“同一份文件内容要在多个位置出现”的场景:
- 希望节省磁盘空间。
- 文件都在同一个文件系统里。
- 不希望删掉某个名字后,剩下的名字立刻失效。
适合软链接的,是“我希望这个路径通向另一个位置”的场景:
- 把配置目录链接到版本控制之外的位置。
- 把外部磁盘里的大文件挂到项目里。
- 给工具链提供它期待的目录结构。
比如在 Monorepo 里,应用希望通过 node_modules/@myapp/utils 使用公共工具包。比起复制一份代码,更合理的做法通常是让这个路径软链接到真正的 packages/utils:
$ ln -s ../../../packages/utils node_modules/@myapp/utils
改动发生在 packages/utils,所有指向它的地方都会看到最新结果。你要共享的是“这个路径应该去哪”,软链接正好合适。
写在最后
硬链接和软链接真正难的地方,不是记住两条 ln 命令,而是换一个角度看文件。
文件名不是文件本身,它只是目录里的一次指向;inode 才是文件内容和元信息真正落脚的地方。硬链接是在给同一个 inode 增加名字,软链接是在保存一条通往另一个名字的路径。
所以以后再遇到“删了文件为什么空间没释放”“pnpm 为什么不复制依赖”“这个链接为什么突然失效”,你不必靠猜。先问一句:现在消失的,是一个名字,还是最后一个指向数据的引用?
这个问题,往往就是答案的开始。
-EOF-

浙公网安备 33010602011771号