file - system
05. 文件系统
0. 本章先解决什么问题
程序看到的是:
打开文件
读取内容
写入内容
关闭文件
但底层设备只会按块读写。文件系统要解决:
- 文件名如何找到磁盘上的数据?
- 目录如何组织?
- 文件元数据存在哪里?
- 读写为什么有缓存?
- 写成功是否代表数据已经落盘?
- 崩溃时如何保持文件系统不坏?
文件系统是 OS 把块设备包装成文件和目录抽象的机制。
这张图怎么读
这张图回答一个很容易被忽略的问题:write 成功只是数据进入了某条写入路径,不等于数据已经安全落到持久介质。文件系统、页缓存、设备队列和崩溃窗口,是理解数据安全的关键。
1. 文件抽象
文件是有名字、有内容、有元数据的字节序列。
常见元数据:
| 元数据 | 含义 |
|---|---|
| 文件大小 | 字节数 |
| 权限 | 谁能读写执行 |
| 所有者 | 属于哪个用户或组 |
| 时间戳 | 创建、修改、访问时间 |
| 数据位置 | 内容存在哪些块 |
| 类型 | 普通文件、目录、设备、链接等 |
文件内容本身只是字节。文本、图片、可执行文件的意义来自解释规则。
2. 目录和路径
目录是一种特殊文件,保存名字到文件对象的映射。
路径解析:
/home/user/a.txt
大致是:
从根目录 /
-> 找 home
-> 在 home 中找 user
-> 在 user 中找 a.txt
每一级都可能涉及权限检查和元数据读取。
深路径、大目录、频繁路径解析都会有成本。
3. inode 的直觉
很多文件系统使用类似 inode 的结构保存文件元数据。
可以理解为:
文件名 -> inode
inode -> 文件元数据 + 数据块位置
目录保存的是名字到 inode 的映射。
这解释了硬链接:
多个文件名可以指向同一个 inode
删除文件名不一定立刻删除数据,只有链接计数和打开引用都结束后,空间才可回收。
4. 文件描述符和打开文件表
应用 open 一个文件后,得到文件描述符。
fd = open(path)
fd 指向内核里的打开文件对象,其中可能保存:
- 当前读写偏移。
- 打开模式。
- 文件对象引用。
- 状态标志。
多个 fd 可能指向同一个文件,也可能共享或不共享偏移,取决于打开和复制方式。
所以并发写文件时要小心偏移和原子性。
边界条件:文件名、fd 和 inode 不是同一个东西
文件系统里有三个概念很容易混在一起:
| 概念 | 它是什么 | 常见误解 |
|---|---|---|
| 路径/文件名 | 目录项里的名字 | 以为名字就是文件本体 |
| inode/文件对象 | 文件元数据和数据块索引 | 以为删名字就立刻删数据 |
| fd/打开文件对象 | 进程访问文件的句柄和状态 | 以为 fd 一定独占文件 |
一个典型场景:
进程 A 打开 log.txt,得到 fd=3
进程 B 删除 log.txt 这个目录项
进程 A 继续向 fd=3 写入
在很多文件系统语义下,A 仍然能写,因为它持有打开文件引用。目录里看不到这个名字,不代表底层文件对象已经立刻消失。只有当目录链接和打开引用都释放后,空间才可能回收。
这个边界能解释很多现象:
| 现象 | 解释方向 |
|---|---|
| 删除大文件后空间没立刻回来 | 仍有进程打开该文件 |
| 日志轮转后旧文件还在增长 | 进程仍写旧 fd,没有重新打开 |
| 两个 fd 写入互相影响 | 它们可能共享打开文件偏移 |
| rename 后读者仍看到旧内容 | 读者可能持有旧文件对象 |
文件系统排障时,不能只看路径是否存在,还要看打开引用、链接计数、文件偏移和进程持有的 fd。
5. 块设备和文件块
磁盘或 SSD 通常按块读写。
文件系统要把文件字节映射到设备块:
文件偏移 0-4095 -> 块 A
文件偏移 4096-8191 -> 块 B
随机小 IO 会频繁访问不同块。顺序 IO 更容易预读和合并。
这解释了:
顺序读写通常比随机读写快。
6. 页缓存
OS 会把文件内容缓存到内存中。
读文件:
应用 read
-> 查页缓存
-> 命中: 从内存复制给应用
-> 未命中: 从磁盘读入页缓存
写文件:
应用 write
-> 写入页缓存
-> 标记 dirty
-> 之后刷盘
这带来性能,也带来持久性问题。
write 返回
不一定代表已经写入持久介质
7. 刷盘和崩溃一致性
系统崩溃或断电时,页缓存里的脏数据可能还没落盘。
文件系统要考虑:
- 数据块写入了吗?
- 元数据写入了吗?
- 目录项写入了吗?
- 文件大小更新了吗?
如果顺序不当,可能出现:
目录里有文件名,但 inode 不完整
文件大小变了,但数据块没写完
空间分配表和真实数据不一致
日志文件系统通过先记录意图或元数据日志,降低崩溃后结构损坏风险。
8. fsync 的直觉
同步刷盘用于要求数据持久化的场景。
简化:
write:
数据进入内核缓存
fsync:
要求相关数据和元数据刷到持久介质
fsync 成本可能很高,因为它等待设备完成写入。
数据库、配置文件更新、关键日志、事务系统会非常关心刷盘语义。
9. mmap 文件
mmap 可以把文件映射到进程虚拟地址空间。
文件内容 <-> 虚拟内存区域
程序像访问内存一样访问文件内容。
优点:
- 减少显式 read/write。
- 可能减少复制。
- 适合随机访问文件片段。
注意:
- 缺页时仍可能触发磁盘 IO。
- 写回时机和持久性要理解。
- 文件变化和映射一致性要小心。
10. 权限和链接
文件系统还管理权限。
常见权限:
- 读。
- 写。
- 执行。
- 所有者。
- 用户组。
链接:
| 类型 | 直觉 |
|---|---|
| 硬链接 | 多个名字指向同一文件对象 |
| 符号链接 | 一个文件保存到另一路径的引用 |
路径解析、权限检查和链接处理都是文件系统的工作。
11. 联系实际:文件写入后为什么会丢
如果程序:
write data
返回成功
立刻断电
数据可能还在页缓存或设备缓存里,没有持久化。
可靠写入通常要考虑:
- 写入临时文件。
- 刷新临时文件内容。
- 原子重命名到目标路径。
- 刷新目录元数据。
不同系统语义不同,但核心问题一样:
写入内容和更新名字/元数据都要考虑崩溃点。
排障卡:文件写入成功不等于已经安全落盘
文件写入链路至少有四个位置:
| 位置 | 说明 | 崩溃后风险 |
|---|---|---|
| 应用缓冲 | 程序自己暂存,还没交给内核 | 数据完全丢失 |
| 内核页缓存 | write 返回后常见位置 | 断电可能未落盘 |
| 设备缓存 | 存储设备内部缓存 | 取决于设备和刷盘策略 |
| 持久介质 | 真正写入稳定存储 | 风险最低 |
练习:设计一个“写配置文件”的安全流程:写临时文件、刷新内容、刷新目录项、原子替换。你不需要记住所有系统细节,但要理解为什么直接覆盖原文件在崩溃时可能留下半截内容或旧目录状态。
失败指纹表:文件系统症状到持久化边界
| 现象 | 更可能的边界 | 关键证据 | 修正方向 |
|---|---|---|---|
| 程序返回成功但重启后数据丢失 | 数据停在应用缓冲或页缓存 | 是否 flush/fsync、崩溃时间点、写入日志 | 明确持久化边界,必要时 fsync 文件和目录 |
| 文件变成半截内容 | 原地覆盖期间崩溃 | 文件大小、写入顺序、临时文件策略 | 使用临时文件 + fsync + rename |
| 文件存在但内容是旧的 | rename/目录项或缓存状态未同步 | 目录 fsync、mtime、写入和替换日志 | 刷新目录项,确认原子替换流程 |
| 大量小文件写入很慢 | 元数据和同步成本过高 | inode/目录操作数、fsync 次数、IO 请求大小 | 批量写入、合并、降低同步频率 |
| mmap 修改后行为不一致 | 映射页和刷盘边界混淆 | msync/munmap、页脏状态、崩溃点 | 明确 mmap 写回策略和一致性要求 |
| 权限看似正确但无法访问 | 路径上级目录或链接/身份问题 | 每级目录权限、链接目标、进程身份 | 检查整条路径、软硬链接和实际用户 |
12. 学完本章你能解决什么问题
学完这一章,你应该能解决或开始分析这些问题:
- 文件系统如何把块设备包装成文件和目录?
- 文件名、目录、inode 的关系是什么?
- 文件描述符为什么是应用访问文件的句柄?
- 页缓存为什么能让第二次读文件更快?
- write 返回为什么不等于数据已经持久化?
- fsync 为什么昂贵但重要?
- mmap 文件为什么像内存访问但仍可能触发 IO?
- 崩溃一致性为什么是文件系统必须考虑的问题?
文件系统的核心是:把慢而块状的存储设备,抽象成有名字、有权限、可缓存、可恢复的文件空间。