第 20 章

文件系统:数据的城市规划

文件系统:数据的城市规划

城市规划师要解决一个问题:把无数建筑物合理地分布在土地上,让人们能找到它们、管理它们、修建新的或拆除旧的。文件系统要解决的问题完全一样——把无数字节合理地组织在磁盘上,让操作系统和用户能找到、读写、增删它们。

磁盘是一块巨大的字节数组——没有"文件名",没有"目录",只有连续的 0 和 1。文件系统是建立在这块"原始土地"上的"城市":它发明了文件、目录、权限、时间戳……将混沌的字节变成有意义的结构。

Level 1:建立直觉

文件系统的基本概念

文件:有名字的字节序列,有元数据(大小、创建时间、权限……)

目录:特殊的文件,内容是"名字→文件"的映射表。目录可以包含目录,形成树状结构。

路径:从根目录(Unix: /,Windows: C:\)到文件的定位字符串

挂载(Mount):把一个存储设备的文件系统连接到主文件系统树的某个位置

Linux 文件系统树:
/
├── bin/    (可执行文件)
├── etc/    (配置文件)
├── home/
│   ├── alice/
│   └── bob/
├── tmp/    (临时文件)
├── var/    (变化的数据:日志、缓存)
├── proc/   (虚拟文件系统,内核状态)
└── mnt/    (挂载点)
    └── usb/   ← USB 设备挂载在这里

文件在磁盘上是怎么存的

磁盘被分成固定大小的块(Block),通常 4KB。文件系统把文件的内容分散存储在这些块里。

问题:一个文件的内容可能分散在磁盘各处,文件系统怎么知道哪些块属于同一个文件?

答案:inode(索引节点)。每个文件有一个 inode,记录:

inode 结构(ext4 简化版):
┌────────────────────┐
│ 大小: 12345 字节   │
│ 权限: rw-r--r--    │
│ 所有者: alice       │
│ 修改时间: 2024-...  │
│                    │
│ 直接块指针 [12个]  │ → 直接指向数据块(每块4KB,共48KB)
│ 一级间接指针       │ → 指向"指针块"(4KB/8字节=512个指针,共2MB)
│ 二级间接指针       │ → 指向"指针块的指针块"(共1GB)
│ 三级间接指针       │ → 理论最大文件大小 ~512GB
└────────────────────┘

文件名不在 inode 里!文件名存在目录里(目录是"名字 → inode 编号"的映射表)。

# 查看 inode 信息
stat file.txt
# File: file.txt
# Size: 12345         Inode: 67890
# Access: -rw-r--r--
# Modify: 2024-01-15 10:30:00

# 查看 inode 编号
ls -i file.txt
# 67890 file.txt

硬链接和软链接

# 硬链接:两个文件名指向同一个 inode
ln file.txt hardlink.txt   # 同一个 inode,同一份数据
ls -i file.txt hardlink.txt
# 67890 file.txt
# 67890 hardlink.txt  ← 完全相同的 inode!

# 软链接(符号链接):特殊文件,内容是目标路径
ln -s file.txt softlink.txt
ls -la softlink.txt
# softlink.txt -> file.txt

# 区别:
# 删除 file.txt 后:
# 硬链接 hardlink.txt 依然可用(inode 还存在,引用计数 > 0)
# 软链接 softlink.txt 变成"断链"(dangling symlink)

Level 2:原理剖析

ext4:Linux 默认文件系统

ext4 是 Linux 最广泛使用的文件系统,2008 年发布,最大支持 1 EB(10^18 字节)文件系统和 16 TB 单文件。

磁盘布局

磁盘布局(简化):
┌──────────────┬────────────┬────────────┬────────────┐
│  超级块      │ 块组描述符 │ inode表    │ 数据块     │
│ (FS元数据)   │ (块组信息) │ (文件元数据)│ (文件内容) │
└──────────────┴────────────┴────────────┴────────────┘

Extents(区段):ext4 不再用多级指针,改用 extent——连续块的描述符:

struct ext4_extent {
    uint32_t ee_block;     // 逻辑块号(文件内偏移)
    uint16_t ee_len;       // 连续块数量(最多 32768 块 = 128MB 单个 extent)
    uint16_t ee_start_hi;  // 物理块号高位
    uint32_t ee_start_lo;  // 物理块号低位
};

一个 extent 描述一大段连续块,而不是逐块指针。大文件只需少数几个 extent,寻址更高效。

日志(Journal):保证文件系统一致性:

写文件不是一步完成的:
1. 写数据块
2. 更新 inode(大小、时间戳、块指针)
3. 更新目录(如果是新文件)
4. 更新位图(标记块已使用)

如果在步骤2完成后断电,数据块已写但 inode 未更新
→ 数据丢失或文件系统不一致

日志解决方案:
1. 先把"将要做的操作"写入日志区(Journal)
2. 提交日志记录(commit)
3. 执行实际的写操作
4. 清除日志记录

断电恢复:重放未完成的日志记录,保证一致性

日志模式

data=writeback: 只记录元数据,数据不经过日志(最快,数据安全性低)
data=ordered:   先写数据块,再提交元数据日志(默认,平衡)
data=journal:   数据也写入日志(最安全,最慢)

目录的内部结构

目录在磁盘上是什么样子?

ext4 目录项(每个文件/子目录一条):
┌──────────┬──────────┬──────────┬──────────┐
│ inode号  │ 记录长度 │ 文件名长 │ 文件名    │
│ (4字节)  │ (2字节)  │ (1字节)  │ (变长)    │
└──────────┴──────────┴──────────┴──────────┘

目录查找:顺序扫描(小目录)
         HTree(B树变体)(大目录,dir_index特性启用后)

文件查找过程(查找 /home/alice/photo.jpg):

1. 读取根目录 inode(总是 inode #2)
2. 读取根目录数据块,找 "home" → inode #100
3. 读取 inode #100 的数据块,找 "alice" → inode #500
4. 读取 inode #500 的数据块,找 "photo.jpg" → inode #7890
5. 读取 inode #7890,获得数据块位置
6. 读取数据块

路径越深,需要的磁盘 I/O 越多。这是 /proc/1234/maps 这样的深路径访问开销不可忽视的原因。

写时复制(CoW)文件系统

传统文件系统(ext4)写数据时直接覆盖旧数据。写时复制(Copy-on-Write)文件系统(btrfs、ZFS、APFS)从不覆盖:

传统文件系统写入:
旧块: [DATA_OLD] → 直接覆盖 → [DATA_NEW]
              ↑
         断电此处:数据丢失/损坏

CoW 文件系统写入:
1. 分配新块,写入新数据: [DATA_NEW]
2. 更新 inode 指向新块
3. 释放旧块: [DATA_OLD]

断电无论在哪:要么旧数据完整,要么新数据完整

CoW 的好处:

  1. 天然的崩溃一致性:不需要单独的日志
  2. 快照(Snapshot)几乎免费:只需保留旧的 inode 树指针
  3. 数据完整性:配合校验和,可以检测数据静默损坏
# btrfs 快照:几乎零开销
btrfs subvolume snapshot / /snapshots/2024-01-15
# 瞬间完成,不复制任何数据(CoW:只有修改时才占用额外空间)

# ZFS 快照
zfs snapshot pool/dataset@2024-01-15

macOS APFS 的"文件克隆"特性:复制一个大文件几乎是瞬间完成的,因为 CoW——两份"副本"共享同一数据块,直到任一份被修改才真正复制。

文件缓存:内核的 Page Cache

文件 I/O 不直接读写磁盘——先经过页缓存(Page Cache)

读文件流程:
1. 应用调用 read(fd, buf, len)
2. 内核检查 Page Cache:该文件块是否已缓存?
   命中 → 直接从缓存复制到 buf(不访问磁盘)
   未命中 → 从磁盘读取 → 放入 Page Cache → 复制到 buf

写文件流程:
1. 应用调用 write(fd, buf, len)
2. 数据写入 Page Cache("脏页")
3. 内核后台把脏页异步写回磁盘(pdflush/writeback)
4. write() 立刻返回(不等磁盘)

fsync():强制将指定文件的所有脏页写回磁盘。数据库用它保证数据持久化:

write(fd, data, len);   // 写到 Page Cache
fsync(fd);              // 阻塞,直到数据真正落盘
// 现在数据对断电安全了

Page Cache 有多重要?查看系统实际内存使用:

free -h
#              total  used   free   shared  buff/cache  available
# Mem:          16G    4G    2G     500M      10G         12G
#                                            ↑
#                               大部分"使用的"内存是 Page Cache
# 这些内存随时可以释放(如果需要内存)

Level 3 · 规范怎么定义的(资深)

文件系统的标准与规范

文件系统的用户接口由 POSIX 标准定义。POSIX 规定了文件的基本操作语义:open() 返回文件描述符、read()/write() 的原子性保证(对于管道和 FIFO,小于 PIPE_BUF 字节的写入是原子的)、fsync() 确保数据持久化到存储设备、rename() 在同一文件系统内是原子操作等。这些语义保证是数据库和日志系统正确性的基础。

具体文件系统格式由各自的规范文档定义。ext4 的磁盘布局在 Linux 内核文档 Documentation/filesystems/ext4/ 中有详细描述:超级块(superblock)位于偏移 1024 字节处,包含块大小、inode 数量、特性标志等元数据;块组描述符表紧随其后;每个块组包含 inode 位图、数据块位图、inode 表和数据块。ext4 的日志(journal)基于 JBD2(Journaling Block Device 2),支持三种模式:journal(元数据+数据都写日志)、ordered(先写数据,再提交元数据日志,默认模式)和 writeback(只写元数据日志,数据顺序不保证)。

BtrfsZFS 采用写时复制(Copy-on-Write)设计,其数据结构基于 B-tree 的变体。ZFS 的规范来自 OpenZFS 项目的文档和源码——它没有传统意义上的"标准文档",而是以参考实现为规范。ZFS 的每个数据块都有 256 位校验和(默认 SHA-256 或 Fletcher-4),读取时自动验证,检测到损坏时从冗余副本恢复——这种"端到端数据完整性"保证是传统文件系统所不具备的。

Level 4 · 边界与陷阱(所有人)

陷阱 1:fsync() 不等于数据安全落盘

fsync(fd) 保证将文件描述符 fd 关联的数据和元数据刷到存储设备。但如果你创建了一个新文件,只 fsync() 文件本身是不够的——你还需要 fsync() 所在目录,否则目录项(文件名→inode 的映射)可能在断电时丢失,导致文件"消失"。更极端的情况:某些消费级 SSD 的固件会欺骗操作系统——声称数据已写入 NAND Flash,实际还在 SSD 内部的 DRAM 缓存中。断电时这些缓存中的数据会丢失。企业级 SSD 配备电容(power-loss protection)来解决这个问题,但消费级 SSD 通常没有。

陷阱 2:rename() 原子性的边界条件

POSIX 保证 rename() 在同一文件系统内是原子操作——要么完成,要么不发生。数据库和配置管理工具广泛利用"写临时文件 + rename"模式来实现原子更新。但这个保证有两个前提:(1)源和目标必须在同一文件系统上(跨文件系统的 rename() 会返回 EXDEV 错误);(2)rename() 本身的原子性不包括数据持久化——如果你 write() 临时文件后直接 rename() 而不先 fsync(),断电时可能看到新文件名但内容为空(因为数据还在页缓存中未落盘)。正确的安全写入序列是:write()fsync(file)rename()fsync(directory)

陷阱 3:inode 耗尽比磁盘空间耗尽更隐蔽

ext4 在创建文件系统时预分配固定数量的 inode(默认每 16KB 一个 inode)。如果你的应用创建了大量小文件(如邮件服务器、缓存目录),可能在磁盘空间尚有剩余时就耗尽了 inode——df 显示空间充足,但 df -i 显示 inode 使用率 100%,任何新文件创建都会失败(ENOSPC)。Btrfs 和 ZFS 则动态分配 inode,不存在这个问题。如果你遇到"磁盘满"错误但 df 显示还有空间,记得检查 df -i

本章评分
4.8  / 5  (9 评分)

💬 留言讨论