Linux 基础体系 · 第 5/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

Linux 文件、Inode 与链接:描述符、硬链接、符号链接和删除语义

在 Linux 中,“文件”不是一个单一对象。一个路径名、目录项、inode、打开文件描述和文件描述符,分别属于不同层次:

进程
 └─ 文件描述符 fd
     └─ 打开文件描述(open file description)
         ├─ 当前文件偏移
         ├─ 文件状态标志
         └─ inode
             ├─ 文件类型与权限
             ├─ 所有者、大小、时间
             └─ 数据块或目录内容

目录
 └─ 目录项(directory entry)
     ├─ 名字
     └─ inode 号

理解这些对象之间的关系,才能解释以下看似反直觉的现象:

  • 两个不同路径可以指向同一个文件;
  • 删除文件名后,已经打开的文件仍然可以读写;
  • cp、硬链接和符号链接的行为完全不同;
  • ls -l 看到的是路径解析后的结果,而不是所有内核对象;
  • 文件名消失并不必然意味着数据立即释放;
  • rename() 可以替换一个已存在的路径,但已经打开该路径的进程仍可能继续访问旧内容。

本文以本地 Linux 文件系统为主要范围。具体文件系统对磁盘布局、日志和崩溃恢复的实现不同,但下面关于路径、链接、文件描述符和删除的基本语义,是 Linux VFS 与常见本地文件系统共同提供的抽象。


一、路径名不是文件本身

1. 路径解析的基本过程

/var/log/app.log 首先是一个字符串。内核执行 open("/var/log/app.log", ...) 时,并不是直接从字符串中取出“文件对象”,而是从根目录开始逐级查找:

  1. 找到根目录 /
  2. 在根目录中查找名字 var
  3. 得到 var 对应的目录 inode;
  4. 在该目录中查找名字 log
  5. 得到 log 对应的目录 inode;
  6. log 中查找名字 app.log
  7. 最终得到目标 inode;
  8. 按照打开方式检查权限并建立打开状态;
  9. 将一个整数文件描述符返回给进程。

目录本身也是一种特殊文件。它的数据内容逻辑上是一组“名字到 inode”的映射:

directory entry=(name,inode number)\text{directory entry} = (\text{name}, \text{inode number})

例如:

目录 /tmp 的内容:

"a.txt"  ───────> inode 12001
"b.txt"  ───────> inode 12001
"link"   ───────> inode 12050(符号链接自身)

这里 a.txtb.txt 是两个目录项,但它们可以指向同一个 inode。link 则通常指向一个专门表示符号链接的 inode。

目录项通常被称为 dentry。dentry 是 VFS 用于表示路径组件的内核对象,并且会被缓存;它不是用户可以直接读取的独立“文件内容”。磁盘上的目录格式由 ext4、XFS 等文件系统分别实现,VFS 在其上提供统一的路径解析语义。

2. 相对路径和符号链接中的相对目标

相对路径默认相对于进程当前工作目录:

cat ./config/app.conf

但符号链接中的相对目标不是相对于执行命令时的当前目录,而是相对于符号链接所在的目录

mkdir -p /tmp/link-demo/real /tmp/link-demo/dir
printf 'ok\n' > /tmp/link-demo/real/app.conf
ln -s ../real/app.conf /tmp/link-demo/dir/app.conf

cat /tmp/link-demo/dir/app.conf
# ok

这里符号链接保存的目标字符串是 ../real/app.conf。解析它时,内核以 /tmp/link-demo/dir 作为起点,因此得到 /tmp/link-demo/real/app.conf

如果错误地把它理解为相对于当前工作目录,就会在部署目录、归档解包和软件安装中生成大量失效链接。


二、inode:文件元数据和数据定位的核心对象

1. inode 保存什么

在 Linux 文件系统中,inode 通常保存一个文件对象的元数据,例如:

  • 文件类型:普通文件、目录、符号链接、设备文件、套接字等;
  • 权限位;
  • 用户 ID 和组 ID;
  • 文件大小;
  • inode 的时间戳;
  • 硬链接计数;
  • 文件数据所在位置或间接索引信息;
  • 扩展属性、ACL 或其他文件系统特有信息。

inode 通常不保存文件名。文件名属于目录项。正因为文件名和 inode 分离,多个文件名才能指向同一个 inode。

文件系统还可能拥有 inode 号,但 inode 号的有效范围通常局限于该文件系统。两个不同挂载文件系统中的 inode 号相同,并不表示它们是同一个文件对象。

可以用 stat 观察路径对应的 inode:

mkdir -p /tmp/inode-demo
printf 'hello\n' > /tmp/inode-demo/a
ln /tmp/inode-demo/a /tmp/inode-demo/b
ln -s a /tmp/inode-demo/c

stat -c 'name=%n inode=%i type=%F links=%h size=%s' \
  /tmp/inode-demo/a \
  /tmp/inode-demo/b \
  /tmp/inode-demo/c

典型输出类似:

name=/tmp/inode-demo/a inode=123456 type=regular file links=2 size=6
name=/tmp/inode-demo/b inode=123456 type=regular file links=2 size=6
name=/tmp/inode-demo/c inode=123457 type=symbolic link links=1 size=1

其中:

  • ab 的 inode 号相同;
  • 普通文件 inode 的硬链接计数为 2;
  • c 的 inode 号不同,它是符号链接自身;
  • 符号链接的大小通常是目标字符串长度,这里目标 a 长度为 1。

输出中的 stat 默认会跟随符号链接;若要查看符号链接自身,应使用:

stat -c 'name=%n inode=%i type=%F links=%h size=%s' -L /tmp/inode-demo/c

不同 stat 实现和选项组合容易造成混淆。GNU/Linux 上更直接的区分方式是:

stat /tmp/inode-demo/c       # 默认跟随链接
stat -L /tmp/inode-demo/c    # -L 也表示跟随链接

若希望明确查看链接自身,可使用 lstat 系统调用,或使用 ls -l 查看其 -> 目标;在脚本中应确认所用工具的选项语义,而不要仅凭命令名称猜测。

2. inode 不等于磁盘上的全部内容

inode 主要描述对象和数据位置。目录内容由目录 inode 引用的数据块保存;普通文件的实际内容也由 inode 所描述的数据块保存;符号链接则保存一个目标路径字符串。

短符号链接在一些文件系统中可能直接存放在 inode 内部,而不占用单独数据块。这是实现优化,不是应用程序可以依赖的通用接口。


三、文件描述符、打开文件描述和 inode

1. 文件描述符是进程内的整数句柄

文件描述符(file descriptor,简称 fd)是一个进程文件描述符表中的整数。例如:

0  标准输入
1  标准输出
2  标准错误
3  程序后来打开的某个文件

open() 返回的是 fd,而不是 inode 号,也不是路径:

int fd = open("/tmp/data", O_RDONLY);

同一个进程中的 fd 只是访问内核打开状态的入口。可以通过 /proc 观察:

printf 'data\n' > /tmp/fd-demo
exec 3</tmp/fd-demo

readlink /proc/$$/fd/3
# /tmp/fd-demo

ls -l /proc/$$/fd/3
# ... 3 -> /tmp/fd-demo

/proc/$$/fd/3 是一个 procfs 中的特殊符号链接,展示该 fd 当前引用的打开对象。它不是普通文件系统中用户创建的硬链接,也不能据此推断路径仍然存在。

2. 打开文件描述保存“打开状态”

Linux 内核中的 打开文件描述(open file description)通常保存:

  • 所引用的文件对象;
  • 当前文件偏移;
  • 文件状态标志,如 O_APPENDO_NONBLOCK
  • 与该次打开相关的其他状态。

文件描述符表中的一个 fd 指向一个打开文件描述:

进程 A 的 fd 3 ─┐
                ├─> 打开文件描述 X ─> inode I
进程 A 的 fd 4 ─┘

执行:

int fd2 = dup(fd);

得到的两个 fd 指向同一个打开文件描述,因此共享当前偏移:

char a[4], b[4];

read(fd,  a, 3);  // 假设读到 "abc",偏移从 0 变为 3
read(fd2, b, 3);  // 从偏移 3 继续读取,而不是重新从 0 开始

fork() 后,父子进程继承的 fd 也通常指向相同的打开文件描述,因此它们会共享偏移和部分打开状态。

但是,以下两次独立的 open() 通常建立两个不同的打开文件描述:

int a = open("data", O_RDONLY);
int b = open("data", O_RDONLY);

它们虽然可以引用同一个 inode,却拥有各自独立的文件偏移。于是:

fd a ─> open description A ─> inode I
fd b ─> open description B ─> inode I

这一区分可以解释并发读写时的差异:共享同一打开文件描述的线程可能共享偏移;分别调用 open() 的线程则通常不共享偏移。使用 pread()pwrite() 可以按显式偏移读写,避免依赖共享的当前偏移。

3. fd 的生命周期

一个典型的打开流程如下:

open()
  │
  ├─ 路径解析
  ├─ 权限检查
  ├─ 创建打开文件描述
  └─ 返回进程内 fd

dup()/fork()
  └─ 增加对已有打开状态的引用

close()
  └─ 删除一个 fd;若不再有引用,打开状态可释放

close(fd) 只关闭当前进程的这个 fd。它不等于删除路径,也不一定立即释放 inode。

程序必须检查 open()read()write()close() 的返回值。特别是在网络文件系统或某些存储错误场景下,写入错误可能延迟到 write()fsync()close() 阶段才暴露;close() 返回错误时不能简单忽略。


四、硬链接:多个目录项指向同一个 inode

1. 硬链接的定义

硬链接是一个新的目录项,它直接指向已有 inode:

printf 'version 1\n' > /tmp/inode-demo/original
ln /tmp/inode-demo/original /tmp/inode-demo/alias

ls -li /tmp/inode-demo/original /tmp/inode-demo/alias

典型结果:

123500 -rw-r--r-- 2 user user 10 ... alias
123500 -rw-r--r-- 2 user user 10 ... original

-i 显示 inode 号。两个路径的 inode 号相同,所以它们是同一个文件对象的两个名字。

通过任一名字写入,另一名字都能看到修改:

printf 'changed\n' > /tmp/inode-demo/alias
cat /tmp/inode-demo/original
# changed

这不是文件内容复制。若使用:

cp /tmp/inode-demo/original /tmp/inode-demo/copy

copy 默认拥有新的 inode;以后修改 copy 不会修改 original

2. 硬链接的形式化条件

对已有非目录 inode II,创建硬链接相当于在目录 DD 中增加:

(D,N)I(D, N) \mapsto I

并使 inode 的硬链接计数增加:

nlink(I)nlink(I)+1n_{\text{link}}(I) \leftarrow n_{\text{link}}(I) + 1

其中 N 是新名字。硬链接操作不复制数据块,也不创建新的普通文件 inode。

普通用户通常不能对目录创建硬链接,原因是任意创建目录硬链接会破坏目录树结构,可能造成目录环和 .. 关系不一致。mkdir 创建的目录还会自动产生:

父目录中的名字
目录内的 "."
父目录中的子目录关系对应的 ".."

目录硬链接的管理由内核和文件系统共同约束。应用程序不应把目录硬链接当作普通文件链接使用。

3. 跨文件系统限制

硬链接目标和新名字必须位于同一个文件系统中。否则:

ln /home/user/file /mnt/otherfs/file
# ln: ... Invalid cross-device link

原因是硬链接只保存 inode 号,而 inode 号只在对应文件系统内有意义。跨文件系统无法让一个目录项直接引用另一个文件系统的 inode。

符号链接没有这个限制,因为它保存的是路径字符串;但目标路径在目标环境中必须可解析。

现代 Linux 还可能因为权限和安全策略拒绝硬链接,例如受 fs.protected_hardlinks 等内核策略影响。即使用户对目录有写权限,也不应假定一定能成功创建任意硬链接。


五、符号链接:保存目标路径的独立文件对象

1. 符号链接的结构

创建符号链接:

ln -s /etc/hosts /tmp/inode-demo/hosts-link
ls -l /tmp/inode-demo/hosts-link
# ... /tmp/inode-demo/hosts-link -> /etc/hosts

符号链接自身有一个 inode,其内容是目标路径 /etc/hosts。当路径解析遇到符号链接时,内核把目标字符串重新加入解析过程:

/tmp/inode-demo/hosts-link
        │
        └─> /etc/hosts

因此:

  • 符号链接自身可以被删除;
  • 符号链接目标可以不存在;
  • 符号链接可以跨文件系统;
  • 符号链接可以指向目录;
  • 符号链接可以形成循环。

例如:

ln -s does-not-exist /tmp/inode-demo/broken
ls -l /tmp/inode-demo/broken
# ... -> does-not-exist

cat /tmp/inode-demo/broken
# cat: ... No such file or directory

“链接文件存在”与“链接目标存在”是两个不同问题。

2. statlstatfstat

同一个路径涉及两种观察对象:

  • stat(path):通常跟随符号链接,查看目标;
  • lstat(path):查看符号链接自身;
  • fstat(fd):通过已打开的 fd 查看 fd 当前引用的对象。

在 C 中:

#include <sys/stat.h>
#include <unistd.h>
#include <stdio.h>

int main(void) {
    struct stat st;

    if (lstat("link", &st) == -1) {
        perror("lstat");
        return 1;
    }

    if (S_ISLNK(st.st_mode)) {
        puts("link is a symbolic link");
    }

    return 0;
}

程序处理不可信路径时,不能只依赖字符串拼接。符号链接可能把访问引向预期目录之外。Linux 提供 openat()、目录 fd,以及较新的 openat2() 约束路径解析行为;具体使用时应根据内核和 libc 版本确认可用的标志与语义,并检查所有错误返回。


六、删除:删除名字,不一定立即删除数据

1. unlink() 的真实含义

对普通文件执行:

rm /tmp/inode-demo/original

通常对应 unlink() 系统调用。它的主要语义是从父目录中删除名为 original 的目录项,并减少目标 inode 的硬链接计数:

nlink(I)nlink(I)1n_{\text{link}}(I) \leftarrow n_{\text{link}}(I) - 1

如果该 inode 仍有其他目录项:

alias ─> inode I
original ─> inode I

删除 original 后:

alias ─> inode I

数据仍然可以通过 alias 访问。

如果删除后硬链接计数变为 0,是否可以真正回收数据,还要看是否存在内核引用,例如打开文件描述、内存映射等。可以近似表示为:

可回收    nlink(I)=0ropen(I)=0rmap(I)=0无其他文件系统或内核引用\text{可回收} \iff n_{\text{link}}(I)=0 \land r_{\text{open}}(I)=0 \land r_{\text{map}}(I)=0 \land \text{无其他文件系统或内核引用}

这里的 r_open 表示仍有打开状态引用,r_map 表示仍有映射等引用。实际内核对象生命周期还涉及 inode、页缓存和文件系统内部引用计数,公式是帮助理解语义的抽象,而不是文件系统内部唯一计数器。

2. 现场验证:删除后仍能读写

下面的 shell 示例使用一个额外 fd 保持文件打开:

set -eu

f=$(mktemp /tmp/unlink-demo.XXXXXX)
printf 'before\n' > "$f"

exec 3<>"$f"
rm "$f"

printf 'path exists after rm: '
if [ -e "$f" ]; then
    echo yes
else
    echo no
fi

printf 'read through fd: '
IFS= read -r line <&3
printf '%s\n' "$line"

readlink "/proc/$$/fd/3"

典型结果:

path exists after rm: no
read through fd: before
/tmp/unlink-demo.xxxxx (deleted)

路径已经不存在,但 fd 仍然引用原来的打开对象。(deleted)/proc 对该状态的展示,不是一个可直接访问的普通路径后缀。

如果打开方式允许写入,即使路径被删除,也可以继续写入:

f=$(mktemp /tmp/unlink-write.XXXXXX)
exec 3<>"$f"
rm "$f"

printf 'still reachable\n' >&3

当最后一个引用关闭后,若没有其他目录项,文件数据才会成为文件系统回收对象。磁盘空间因此可能被“已删除但仍打开”的进程占用。诊断这类问题常用:

lsof +L1

它通常列出硬链接计数小于 1 但仍被进程打开的文件。命令输出依赖 procfs 和 lsof 权限;容器、不同 mount namespace 或权限限制可能导致观察不完整。

3. 删除符号链接删除的是链接自身

执行:

ln -s /etc/hosts /tmp/inode-demo/hosts-link
rm /tmp/inode-demo/hosts-link

删除的是 /tmp/inode-demo/hosts-link 这个符号链接 inode,不是 /etc/hosts

rm 通常不会跟随命令行中最后一个符号链接去删除其目标;但递归删除、脚本拼接路径、通配符展开和特殊工具的选项可能改变风险。对不可信目录树执行递归删除时尤其不能把“符号链接不会被跟随”扩展成“整个脚本绝对不会越界”:脚本后续若对链接目标执行 cdcpfind -exec 或自行拼接路径,仍可能出现路径穿越。


七、打开、删除和重命名之间的状态关系

可以把关键对象和操作表示为以下关系:

flowchart LR
    P[进程 fd] --> O[打开文件描述]
    O --> I[inode]
    D[目录项: 名字] --> I

    U[unlink 名字] --> D
    R[rename 名字] --> D
    C[close fd] --> O

    D -.硬链接计数为零.-> X{仍有打开或映射引用?}
    O -.最后一个引用关闭.-> X
    X -->|否| F[文件数据可回收]
    X -->|是| K[继续保留,路径可能已不存在]

关键路径如下:

  1. open() 通过路径找到 inode,并创建打开文件描述;
  2. unlink() 只修改目录项和硬链接计数;
  3. 已有 fd 不依赖目录项继续存在;
  4. close() 减少对打开状态的引用;
  5. 目录项和打开引用都消失后,文件系统才可能回收空间。

rename() 的特殊性

rename(old, new) 主要修改目录项关系。对同一文件系统中的普通文件,替换已有 new 通常是一个原子的命名空间操作:

操作前:
old ─> inode A
new ─> inode B

rename(old, new) 后:
new ─> inode A
inode B 的目录项引用减少

已经打开 old 的进程仍引用 inode A;已经打开原 new 的进程仍可能引用 inode B。打开路径的进程不会因为路径后来被替换而自动切换到新 inode。

这就是常见安全更新模式的基础:

printf 'new content\n' > /tmp/app.conf.tmp
mv /tmp/app.conf.tmp /tmp/app.conf

在同一文件系统内,mv 通常使用 rename(),读者看到的路径要么指向旧文件,要么指向新文件,而不会看到“半个新文件”。但这只说明命名空间替换具有原子性,不自动保证:

  • 新文件内容已经持久化到介质;
  • 目录项更新已经持久化;
  • 断电后一定保留更新;
  • 跨文件系统移动仍是原子操作。

跨文件系统时,rename() 会失败并返回 EXDEV,用户空间工具通常退化为复制后删除。这一过程可能暴露部分文件、丢失元数据或留下源文件。


八、写入、缓存和持久化:可见不等于已落盘

Linux 普通文件写入通常经过页缓存。成功返回 write() 表示内核已经接受了数据,但不等于数据已经稳定写入持久介质。

若应用需要较强的崩溃持久性,通常需要考虑:

write(fd, data, len);
fsync(fd);

如果还创建、删除或重命名了目录项,往往还需要对父目录 fd 执行 fsync(),以持久化目录结构变化:

int dirfd = open("/tmp", O_RDONLY | O_DIRECTORY);
fsync(dirfd);

完整的持久化协议依赖更新方式、文件系统和存储设备。日志文件系统可以在崩溃后恢复元数据一致性,但“恢复后一定存在最新业务内容”仍取决于应用是否正确执行同步操作以及硬件是否遵守写入保证。

因此:

rename() 的原子可见性
≠
数据已持久化

这是文件替换程序中经常被忽略的边界。


九、匿名临时文件与“先打开、再删除”

一种常见临时文件模式是:

  1. 创建文件;
  2. 立即 unlink()
  3. 通过 fd 使用它;
  4. 关闭 fd 后自动释放。
int fd = open("/tmp/work", O_RDWR | O_CREAT | O_EXCL, 0600);
if (fd == -1) {
    perror("open");
    return 1;
}

if (unlink("/tmp/work") == -1) {
    perror("unlink");
    close(fd);
    return 1;
}

/* 继续通过 fd 读写 */

这样其他进程无法通过路径重新打开该文件,进程退出或关闭 fd 后文件也会自然消失。

现代 Linux 和部分文件系统还支持 O_TMPFILE

int fd = open("/tmp", O_RDWR | O_TMPFILE, 0600);

它创建没有目录项的临时 inode。该能力依赖内核、libc 和底层文件系统支持,失败时可能返回 EOPNOTSUPP 或其他错误。若需要将其最终公开为路径,可使用 linkat() 将打开的匿名文件链接到目录;这属于需要严格检查权限、目录和错误处理的底层接口,不应把 O_TMPFILE 当作所有挂载点都支持的通用能力。


十、常见误解与反例

误解一:文件名就是文件

反例:

printf x > /tmp/a
ln /tmp/a /tmp/b
rm /tmp/a
cat /tmp/b
# x

删除 a 只删除了一个目录项;inode 仍由 b 引用。

误解二:inode 号相同就代表可以跨目录或跨系统任意访问

inode 号只能在对应文件系统语境下解释。不同挂载点中即使显示相同数字,也可能是完全不同的 inode。判断两个路径是否是同一对象,应结合设备号和 inode 号,例如:

stat -c '%d:%i %n' /path/one /path/two

设备号和 inode 号组合通常用于识别同一文件系统中的对象,但网络文件系统、容器命名空间和特殊文件系统下仍应结合具体语义判断。

误解三:删除打开文件会立即释放空间

反例是前面的 rm 后继续通过 fd 读写。日志轮转中经常出现:

路径上的日志已删除
进程仍持有旧日志 fd
磁盘空间仍未释放

诊断时应查找已删除但仍打开的文件,而不是只看目录中的文件名。

误解四:符号链接和硬链接只是两种写法

它们的对象关系不同:

硬链接:
name-a ─────┐
             └─> inode I

符号链接:
name-b ─> inode S ──保存字符串──> "name-a"

硬链接直接引用目标 inode;符号链接引用的是另一个 inode,而该 inode 的内容是路径字符串。因此目标被替换、移动或删除时,两者表现不同:

printf old > /tmp/target
ln /tmp/target /tmp/hard
ln -s /tmp/target /tmp/sym

printf new > /tmp/target

cat /tmp/hard   # old
cat /tmp/sym    # new

这里 printf new > /tmp/target 是通过截断原 inode 写入,因此硬链接仍看到 old 被替换为 new 才是预期?注意上例实际结果是 hard 也会看到 new,因为重定向 > 打开原路径并截断同一个 inode。为了展示差异,必须使用 rename 替换:

printf old > /tmp/target
ln /tmp/target /tmp/hard
ln -s /tmp/target /tmp/sym

printf new > /tmp/new-target
mv /tmp/new-target /tmp/target

cat /tmp/hard   # old
cat /tmp/sym    # new

mv 在同一文件系统内通常执行原子重命名,使路径 /tmp/target 改为指向新 inode;硬链接 hard 仍指向旧 inode,符号链接 sym 重新按路径解析,因此跟随到新 inode。这个例子也说明“修改文件内容”和“替换路径指向的 inode”不是一回事。

误解五:ls -l 显示的链接数是引用次数的全部含义

普通文件的链接数通常对应硬链接目录项数量,但目录的链接数还受 ... 和子目录关系影响;特殊文件系统可能有不同实现。链接数适合诊断常见情况,但不能作为跨所有文件系统推断内部生命周期的唯一证据。


十一、权限、竞态和生产风险

访问路径至少涉及两类权限:

  1. 对路径中每个父目录需要搜索(执行)权限;
  2. 对最终对象执行读、写等操作时需要相应权限。

删除文件主要需要父目录的写权限和搜索权限,而不是目标普通文件本身的写权限。目录的粘滞位(如 /tmp 常见的 1777)会进一步限制用户删除他人文件。

路径检查和使用之间存在竞态:

检查 /tmp/job/output 确认安全
        ↓
攻击者替换某个目录组件或符号链接
        ↓
程序真正 open() 到另一个对象

因此,特权程序不应依赖“先 stat() 检查,再 open() 使用”的非原子流程。更可靠的做法包括:

  • 使用目录 fd 和 openat() 限定解析起点;
  • 对不应跟随的组件使用相应的 O_NOFOLLOWopenat2() 解析约束;
  • 使用 fstat() 检查已经打开的对象;
  • 对临时文件使用排他创建,如 O_CREAT | O_EXCL
  • 不把用户可控路径直接拼接到高权限删除或覆盖命令中。

符号链接保护还受到内核配置、挂载选项、用户命名空间和程序自身逻辑影响。realpath() 可以把路径规范化为绝对路径,但它不能单独消除并发替换竞态,也不应被当作安全检查与实际打开之间的锁。


十二、诊断时应分别问四个问题

遇到“文件消失”“磁盘空间未释放”“链接内容不一致”等问题,可以按对象层次定位:

路径是否存在?

test -e /path/to/file
test -L /path/to/file

-e 通常检查目标是否存在;对悬空符号链接,-e 可能为假,而 -L 可确认链接自身存在。

路径最终解析到哪个 inode?

stat /path/to/file

必要时分别比较:

stat -c '%d:%i %F %n' path1 path2

是否有其他硬链接?

find /some/mount -xdev -samefile /path/to/file -print

该操作可能遍历大量目录,生产环境使用时要评估开销,并注意权限和挂载边界。

是否仍被进程打开?

lsof +L1
ls -l /proc/<pid>/fd

在容器环境中,宿主机和容器可能拥有不同的进程、挂载和 PID 命名空间;从容器内部观察到的信息不一定覆盖宿主机全部引用。


十三、把几个层次合并起来看

对路径 /srv/app/current/config,一次访问可能经历:

路径字符串
  ↓
目录项 current
  ↓
符号链接目标字符串,或直接得到 inode
  ↓
目标 inode
  ↓
权限检查与打开
  ↓
进程 fd
  ↓
打开文件描述:偏移、状态标志、引用
  ↓
数据块、页缓存和文件系统持久化机制

而删除操作只从其中一部分开始:

unlink("/srv/app/current/config")
  ↓
删除父目录中的名字 config
  ↓
减少 inode 硬链接计数
  ↓
若仍有 fd 或映射,则数据继续存在
  ↓
最后引用消失后,文件系统才可回收空间

硬链接改变的是“有多少目录项直接指向 inode”;符号链接增加的是一个保存路径字符串的独立 inode;文件描述符保持的是一次打开状态;rename() 改变的是目录项到 inode 的映射。它们都可能被口语化为“文件”,但其生命周期和并发行为完全不同。

掌握这种分层后,以下结论可以直接从机制推出:

  • 想共享同一份内容并让两个名字始终指向同一 inode,可以使用硬链接,但不能跨文件系统;
  • 想让一个名字跟随目标路径的替换,可以使用符号链接;
  • 想让已打开内容不受路径删除或替换影响,应依赖 fd,而不是反复按路径重新打开;
  • 想原子替换一个文件名,应在同一文件系统内准备新文件后执行 rename()
  • 想确认磁盘空间为什么没有释放,应检查已删除但仍打开的对象;
  • 想保证断电后更新仍存在,必须把命名空间原子性和数据持久化分别设计,不能只依赖 rename()

系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。