[转] 文件名只是个标签,文件真正的身份是 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-

posted @ 2026-10-12 15:02  Zhentiw  阅读(3)  评论(0)    收藏  举报