文件系统核心概念 — inode、journal、COW / Filesystem Concepts: Inodes, Journaling, and Copy-on-Write
📅 创建时间:2026-06-17 🏷️ 标签:#文件系统 #inode #Journal #COW #超级块 📚 前置知识:[[03-partition-table]]
📋 本章目标
- 理解文件系统的核心职责——从"文件名"到"磁盘块"
- 掌握 inode 的概念——为什么文件名和数据是分开存储的
- 理解目录的本质——一张"文件名→inode号"的映射表
- 掌握超级块——文件系统的元数据之锚
- 理解 Journal 日志——为什么突然断电不会损坏文件系统
- 理解 COW 写时复制——btrfs/ZFS 的核心设计哲学
- 理解硬链接 vs 符号链接的 inode 视角区别
- 理解 UGO/rwx 权限的存储方式
第1部分:文件系统到底做什么?
1.1 一个看似简单的操作背后
当你执行:
cat /home/Jaten/hello.txt发生了一系列事情:
┌─────────────────────────────────────────────────────────────┐
│ cat hello.txt 的完整路径 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 找到 `/` 的 inode │
│ └→ 根目录的 inode 号是固定的 (ext4 上是 2) │
│ │
│ 2. 在 `/` 的目录项中查找 `home` │
│ └→ 目录本质上是一张表: {文件名 → inode号} │
│ │
│ 3. 找到 `home` 的 inode 号 │
│ └→ 读取 inode → 知道"这是一个目录" + "它的数据在哪些块" │
│ │
│ 4. 在 `home` 的目录项中查找 `Jaten` │
│ └→ 同上,逐级索引 │
│ │
│ 5. 在 `Jaten` 的目录项中查找 `hello.txt` │
│ └→ 找到 inode 号 │
│ │
│ 6. 读取 `hello.txt` 的 inode │
│ └→ 知道: 文件大小、权限、数据块位置 │
│ │
│ 7. 从数据块读取文件内容 │
│ └→ inode 里存储了数据块的地址,据此去读磁盘 │
│ │
│ 关键认知: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ "文件名" 不在 inode 里! │ │
│ │ 文件名存在目录的数据块中 │ │
│ │ inode 只存元数据(大小/权限/数据位置),不存名字 │ │
│ │ 这就是为什么 rename/mv 只在目录里改一行,极快 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
第2部分:inode — 文件的"身份证"
2.1 inode 里存了什么
┌─────────────────────────────────────────────────────────────┐
│ ext4 inode 结构(简化) │
├─────────────────────────────────────────────────────────────┤
│ │
│ inode 编号: 42 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 文件类型: 普通文件 (-) │ │
│ │ 权限: rw-r--r-- (0644) │ │
│ │ 所有者: uid=1000 (Jaten) │ │
│ │ 所属组: gid=1000 (Jaten) │ │
│ │ 文件大小: 1234 字节 │ │
│ │ 时间戳: │ │
│ │ atime: 最后访问时间 │ │
│ │ mtime: 最后修改时间 │ │
│ │ ctime: 最后元数据变更时间 │ │
│ │ 链接计数: 1 (指向这个 inode 的目录项数量) │ │
│ │ 数据块指针: │ │
│ │ 12 个直接指针 → 指向前 12 个数据块 │ │
│ │ 1 个间接指针 → 指向一个"存满指针"的块 │ │
│ │ 1 个二级间接 → 两重指针 │ │
│ │ 1 个三级间接 → 三重指针 │ │
│ │ ext4 额外: extent tree 根 (取代间接指针) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 注意 inode 里没有的东西: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ❌ 文件名 — 存在目录项里 │ │
│ │ ❌ 文件内容 — 存在数据块里,inode 只有指针 │ │
│ │ ❌ 父目录 — 文件不需要知道自己在哪个目录 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
2.2 目录的本质
┌─────────────────────────────────────────────────────────────┐
│ 目录的数据块内容(概念图) │
├─────────────────────────────────────────────────────────────┤
│ │
│ /home/ 目录的数据块(简化): │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ inode号 | 条目长度 | 类型 | 名称 │ │
│ │ ──────────┼───────────┼───────┼────────────────── │ │
│ │ 2 | 12 | DIR | . │ │
│ │ 16777217 | 12 | DIR | .. │ │
│ │ 16809985 | 24 | DIR | Jaten │ │
│ │ 16809986 | 20 | DIR | root │ │
│ │ 16809987 | 28 | REG | hello.txt │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ . = 指向当前目录自己(inode 2 是 / 的 inode) │
│ .. = 指向父目录 (inode 16777217) │
│ │
│ mv 操作的本质: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 1. 在目标目录加一行 "hello.txt → inode 16809987" │ │
│ │ 2. 从源目录删掉那一行 │ │
│ │ │ │
│ │ inode 完全没动。数据块完全没动。 │ │
│ │ 这就是为什么 mv 一个 10GB 的文件在一瞬间完成 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
2.3 硬链接和符号链接 — inode 视角
┌─────────────────────────────────────────────────────────────┐
│ 硬链接 vs 符号链接 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 硬链接 (Hard Link): │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 目录 /a/: "file" → inode 42 ← 原始目录项 │ │
│ │ 目录 /b/: "link" → inode 42 ← 硬链接! │ │
│ │ │ │
│ │ inode 42: │ │
│ │ 链接计数 = 2 │ │
│ │ 数据块指针 → [真正的文件数据] │ │
│ │ │ │
│ │ "file" 和 "link" 是平等的,都是这个 inode 的入口 │ │
│ │ 删除 "file" → 链接计数减为 1 → 数据还在 │ │
│ │ 删除 "link" → 链接计数减为 0 → inode 和数据被回收 │ │
│ │ │ │
│ │ 限制: 硬链接不能跨文件系统 (inode 号只在同一个 FS 内) │ │
│ │ 目录通常不允许硬链接 (防止循环) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 符号链接 (Symbolic Link / Symlink): │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 目录 /a/: "link" → inode 99 │ │
│ │ │ │
│ │ inode 99: │ │
│ │ 类型 = 符号链接 (l) │ │
│ │ 数据块存: "/b/file" ← 就是一段文本,存储路径! │ │
│ │ │ │
│ │ 访问 "link" → 读 inode 99 → 看到路径 "/b/file" │ │
│ │ → 用这个路径重新查找 → 找到真正的文件 │ │
│ │ │ │
│ │ 符号链接可以跨文件系统 │ │
│ │ 如果目标被删了 → 悬空链接 (dangling symlink) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
第3部分:超级块 — 文件系统的"身份证"
3.1 超级块存了什么
┌─────────────────────────────────────────────────────────────┐
│ 超级块 (Superblock) 的关键字段 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 魔数 (Magic Number): │ │
│ │ ext4: 0xEF53 │ │
│ │ btrfs: "_BHRfS_M" │ │
│ │ NTFS: "NTFS " │ │
│ │ │ │
│ │ 用途: mount 时,内核读超级块 → 看魔数 │ │
│ │ 识别格式 → 调用对应驱动 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ext4 超级块的其他关键字段: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 块大小 1024/2048/4096 字节 │ │
│ │ inode 总数 文件系统创建时决定 │ │
│ │ 块总数 整个文件系统可用的块数 │ │
│ │ 空闲块数 还剩多少块可用 │ │
│ │ 空闲 inode 数 还剩多少 inode 可用 │ │
│ │ 挂载计数/最大挂载 触发 fsck 的阈值 │ │
│ │ 最后挂载时间 用于检测不干净卸载 │ │
│ │ 文件系统 UUID 全局唯一标识 (用在 fstab 里) │ │
│ │ 卷名 给人类看的名字 (如 "root") │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 使用 dumpe2fs 可以看到超级块: │
│ # dumpe2fs -h /dev/nvme0n1p3 | head -20 │
│ │
└─────────────────────────────────────────────────────────────┘2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
第4部分:Journal — 为什么断电不丢数据
4.1 没有 Journal 的可怕场景
┌─────────────────────────────────────────────────────────────┐
│ 没有 Journal: 创建文件的崩溃时刻 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 操作: 在 /home/ 下创建一个 100MB 的文件 │
│ │
│ 需要的步骤: │
│ 1. 分配一个新的 inode (改 inode 位图) │
│ 2. 写 inode (填大小/权限/时间戳) │
│ 3. 在 /home 目录项中添加一行 (文件名 + inode 号) │
│ 4. 分配数据块 (改块位图) │
│ 5. 在 inode 中写数据块指针 │
│ 6. 写 100MB 数据 (100MB / 4KB = 25600 个数据块) │
│ 7. 更新超级块 (空闲块数、空闲 inode 数) │
│ │
│ 如果在第 4 步后断电: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 位图标记了块已分配,但 inode 没有指向它们 │ │
│ │ → 这些块永久"丢失" (空间泄漏) │ │
│ │ │ │
│ │ 需要 fsck 扫描整个磁盘来修复 │ │
│ │ 在 TB 级磁盘上,fsck 可能需要数小时 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
4.2 Journal 的工作原理
┌─────────────────────────────────────────────────────────────┐
│ Journal —— 先写日记,再做正事 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 带 Journal 的创建文件流程: │
│ │
│ 1. 在 Journal 区域写: "我打算: 分配 inode #99, │
│ 分配块 1000-35600, 在 /home 目录加 'bigfile → 99'" │
│ → 这个叫 Journal 事务 (Transaction) │
│ │
│ 2. 标记这个事务为"正在提交" │
│ │
│ 3. 实际执行操作: │
│ - 写 inode #99 │
│ - 分配并写数据块 │
│ - 更新目录项 │
│ - 更新超级块 │
│ │
│ 4. 标记这个事务为"已完成" │
│ │
│ 如果在步骤 3 中间断电: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 重启后,文件系统检查 Journal: │ │
│ │ "有个事务标记了'正在提交'但没'已完成'" │ │
│ │ → 重放 (Replay) 这个事务 → 把该写的写完 │ │
│ │ │ │
│ │ 不会丢数据,不需要全盘 fsck │ │
│ │ 恢复只需要几秒 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ext4 的三种 Journal 模式: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ journal (data=journal): │ │
│ │ 文件数据和元数据都写 Journal → 最安全, 最慢, 很少用│ │
│ │ │ │
│ │ ordered (data=ordered, 默认): │ │
│ │ 元数据写 Journal, 数据先于元数据写入 → 中等安全 │ │
│ │ 文件数据不会丢失, 但可能不完整 (文件大小可能是旧的) │ │
│ │ │ │
│ │ writeback (data=writeback): │ │
│ │ 只元数据写 Journal, 数据写入顺序不限 → 最快 │ │
│ │ 断电后新文件可能包含文件系统原有的旧数据 → 安全风险 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
第5部分:COW — btrfs/ZFS 的核心哲学
5.1 COW = Copy-On-Write
┌─────────────────────────────────────────────────────────────┐
│ COW vs 传统覆盖写入 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 传统文件系统 (ext4): 修改一个块 = 直接在原位置覆盖写入 │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 修改前: │ │
│ │ 块 A: [旧数据] │ │
│ │ 块 B: [元数据, 指向 A] │ │
│ │ │ │
│ │ 修改: │ │
│ │ 块 A: [旧数据] → 直接覆盖 → [新数据] │ │
│ │ │ │
│ │ 如果在覆盖过程中断电: │ │
│ │ 块 A 可能新旧数据混合 (torn write) │ │
│ │ 这就是为什么需要 Journal │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ COW 文件系统 (btrfs/ZFS): 修改一个块 = 写到新位置 │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 修改前: │ │
│ │ 块 A: [旧数据] │ │
│ │ 元数据树: ...→ 块 A │ │
│ │ │ │
│ │ 修改: │ │
│ │ 1. 分配新块 C │ │
│ │ 2. 把修改后的内容写到 C │ │
│ │ 3. 更新元数据树: ...→ 块 C │ │
│ │ 4. 只有元数据树更新完成的那一刻,才"提交"修改 │ │
│ │ │ │
│ │ 块 A 原封不动 —— 旧版本还在! │ │
│ │ │ │
│ │ 如果在步骤 3 断电: │ │
│ │ 元数据树没有更新 → 仍然指向块 A → 旧版本完好 │ │
│ │ 新写的块 C 是孤立的,会被 GC 回收 │ │
│ │ 永远不会有"半旧半新"的状态 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ COW 的两个直接好处: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 1. 天然的快照 (Snapshot) │ │
│ │ 旧数据块没有被覆盖 → 它们就是天然的快照 │ │
│ │ 创建一个快照几乎不占额外空间 │ │
│ │ │ │
│ │ 2. 不需要 Journal │ │
│ │ COW 本身保证了原子性 → 写操作要么完整完成 │ │
│ │ 要么完全不生效 → 不需要单独的 Journal 区域 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ COW 的代价: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 1. 碎片化 —— 频繁修改的文件数据散落在各处 │ │
│ │ 2. 写放大 —— 更新一棵 B-tree 的叶子需要复制整条路径 │ │
│ │ 3. 不适合 VM 镜像/数据库 —— 频繁随机写导致大量碎片 │ │
│ │ (btrfs 对此有 nocow 选项) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
第6部分:权限模型 — UGO/rwx
6.1 权限的存储
┌─────────────────────────────────────────────────────────────┐
│ inode 中的权限字段 │
├─────────────────────────────────────────────────────────────┤
│ │
│ inode 里的权限用一个 16 位整数存储: │
│ │
│ 类型(4bit) suid sgid sticky rwx(owner) rwx(group) rwx(other) │
│ ───────────────────────────────────────────────────────────── |
│ 1000 - - - 111 101 101 │
│ │
│ 解读: │
│ 1000 = 普通文件 │
│ 111 = rwx (读+写+执行) ← owner │
│ 101 = r-x (读+执行) ← group │
│ 101 = r-x (读+执行) ← other │
│ │
│ 显示为: -rwxr-xr-x │
│ ↑ │
│ '-' = 普通文件 │
│ 'd' = 目录 │
│ 'l' = 符号链接 │
│ │
│ 权限的 inode 视角: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 目录的 'x' (执行权限) = 可以进入这个目录 │ │
│ │ 目录的 'r' (读权限) = 可以列出目录内容 (ls) │ │
│ │ 目录的 'w' (写权限) = 可以增删目录中的文件 │ │
│ │ │ │
│ │ 删除一个文件不需要文件本身的 'w' 权限 │ │
│ │ 只需要 文件所在目录 的 'w' 权限! │ │
│ │ (因为删除只是从目录中移除一项) │ │
│ │ │ │
│ │ 这就是 sticky bit 的作用 (/tmp 的 1777): │ │
│ │ 任何人都能在 /tmp 创建文件, │ │
│ │ 但只有文件所有者和 root 能删除 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
核心总结
总结1:文件系统的核心数据结构
inode: 文件的元数据(大小/权限/时间戳/数据块指针)— 没有文件名
目录: 一张表 {文件名 → inode 号} — 文件名存在这里
超级块: 文件系统的元数据(块大小/inode 数/UUID/魔数)2
3
总结2:Journal 的哲学
先写日记 (Journal), 再做正事 (实际写入)
断电后: 看日记 → 重放 → 恢复完成
这就是为什么现代文件系统断电不坏2
3
总结3:COW 的利弊
利: 天然快照、不需要 Journal、写操作原子性
弊: 碎片化、写放大、不适合频繁随机写的场景2
章节测试
测试1:inode 不含文件名
为什么 mv 一个 10GB 的文件瞬间完成?
测试2:硬链接和符号链接
硬链接不能跨文件系统的根本原因是什么?
测试3:Journal
突然断电时,文件系统如何利用 Journal 恢复?
测试4:COW
为什么 COW 文件系统不需要单独的 Journal?
测试5:目录权限
/tmp 是 drwxrwxrwt。所有人都能读写 /tmp。一个普通用户能否删除 /tmp 下另一个用户创建的文件?为什么?
参考答案
测试1答案
mv 只修改目录项:在目标目录中加一行 {oldname → inode X},从源目录中删除 {oldname → inode X}。inode 和数据块完全不变。文件不管多大,这个操作是 O(1) 的。
测试2答案
硬链接的本质是两个目录项指向同一个 inode 号。inode 号只在同一个文件系统内部有意义——它是文件系统内部的编号。跨文件系统时,inode 号在另一个文件系统中有完全不同的含义。符号链接不受此限制,因为符号链接存储的是路径字符串(跨文件系统通用)。
测试3答案
重启后,文件系统检查 Journal 区域:未完成的事务(标记了"正在提交"但没有"已完成")会被重放——按照事务记录的操作重新执行,直到完成。已完成的事务可以直接跳过。这个过程就是 Journal Replay。
测试4答案
因为 COW 本身提供了原子性保护。修改操作永远不覆盖原始数据,而是写到一个新位置。只有当元数据树更新完成、指向新位置的那一刻,修改才算"生效"。如果在更新过程中断电,元数据树仍然指向旧位置,旧数据完整。不存在"半旧半新"的中间状态需要 Journal 来修复。
测试5答案
不能。虽然目录 w 权限允许增删文件,但 /tmp 的最后一个 t(sticky bit)改变了这个规则:只有文件所有者、目录所有者和 root 才能删除文件。即使别人能写 /tmp,也无法删除你的临时文件。
相关笔记
- [[03-partition-table]] - 分区划分了区域,文件系统在里面建 inode
- [[05-filesystem-formats]] - ext4/btrfs/NTFS 分别如何实现这些概念
- [[12-ext4-deep-dive]] - ext4 的 inode 和 journal 的源码级细节
下一步学习
- [ ] 阅读 05 - 常见文件系统格式详解
学习状态:🟡 开始学习