本节摘要:操作系统在裸硬件和应用之间垫了一层抽象,核心是四样东西:文件、页缓存、mmap 和 fsync。文件系统用统一的文件接口盖住硬件差异,页缓存让内存充当磁盘的高速缓存,mmap 把文件映射进地址空间,fsync 负责把"写完"这件事说清楚。这层抽象让程序员不必关心扇区和寻道,但也埋下不少语义陷阱——写到页缓存不算落盘,mmap 的快是有代价的,fsync 的"同步"其实分好几层。本节沿着一次读写走过的 I/O 栈,把这几个抽象逐个拆开。
阅读完本节,你应当能够:
如果让程序员直接面对一块磁盘,生活会非常痛苦。他要记住数据写在哪个扇区、哪个磁道,要处理不同品牌硬盘之间的差异,还要在断电、坏块这些意外面前自己兜底。没有谁会真的这么干。操作系统做了一件关键的事:把"存储"这个词从物理概念翻译成了"文件"这个逻辑概念。
这一节要拆的,就是这次翻译。它分四层来看:文件是怎么统一起来的(VFS),读写在内存和磁盘之间是怎么流动的(页缓存),把文件映射进内存又意味着什么(mmap),以及"数据到底算不算写完了"这件事靠谁说了算(fsync)。四层串起来,就是一次读写请求从应用走到物理介质的完整路径。
Linux 里同时住着好几种文件系统:ext4、XFS、Btrfs、还有网络文件系统。它们的磁盘布局天差地别——有的用位图管空间,有的用 B+ 树索引文件元数据。可应用只认得 open、read、write 这几个调用,从不关心下面是谁在干活。这个奇迹靠的是一层叫 VFS 的抽象:虚拟文件系统。
VFS 不是某个具体文件系统的实现,而是一套接口规范和数据结构骨架。你可以把它想成一个标准化的火车站:不管哪家公司的列车,都得按统一的轨道宽度和站台编号来运行。VFS 定义了四个核心对象,它们各司其职又互相咬合:
一次 open 调用,会先从根目录逐级往下解析路径,每走一步都先查目录项缓存,没命中才去问具体文件系统。走到头,内核分配一个文件对象,把它的操作函数指针指向这套文件系统自己的实现。以后每次 read、write,都通过这张函数指针表派发下去。这就是"策略与机制分离":VFS 定好"要做什么",具体文件系统只负责"怎么做"。
这个设计的价值在于,新文件系统可以无缝接入,应用一行代码都不用改。FUSE 更把这种能力放到了用户态——开发者能在普通进程里写一个文件系统,通过网络、加密、云存储这些形态把数据"假装"成文件。抽象到这里,已经不仅是内核的独白,而是一套开放的协议。
文件接口解决的是"怎么表示",页缓存解决的是"怎么变快"。磁盘太慢,内存快但小,那就让内存当磁盘的缓存——这正是上一节讲的存储分层在操作系统内部的又一次重演。
页缓存的基本单位是页,通常 4KB。当你读一个文件,内核先把对应的页从磁盘读进页缓存,再把数据拷给你;第二次读同一页,就直接从内存命中,磁盘根本不用动。写则反过来:write 调用先把数据写进页缓存,标记成"脏页",然后立刻返回,真正写回磁盘的动作被推迟到后面。这个策略叫写回缓存。
写回的好处是吞吐高——一堆小写可以攒成一大块顺序写下去,还允许后面的读覆盖掉前面的写,让中间那几次写彻底作废。代价是风险:脏页还在内存里,掉电就没了。于是内核需要一套回写机制,在脏页太多、太老,或者内存吃紧的时候把它们刷下去。回写本身要占带宽,如果你正跑着一个对延迟敏感的业务,突然来一波大规模回写,会明显感觉到系统卡了一下。这就是"写回"用延迟换吞吐的典型代价。
还有一条旁路叫直接 IO(O_DIRECT)。它跳过页缓存,把数据直接从用户缓冲区送到设备,省掉一次拷贝,代价是没了缓存带来的合并和预读。数据库往往用它来管自己的日志和数据文件,因为数据库自己就有一层缓冲池,不想让内核再缓存一份——双重缓存不仅浪费内存,还让"数据到底落没落盘"这件事更难判断。
页缓存之上还有一个更诱人的抽象:mmap。它把文件直接映射进进程的虚拟地址空间,让你像访问内存数组一样访问文件。读一个字节,就是读一个内存地址;缺页时内核自动把对应的文件页拉进页缓存,再填进你的地址空间。
mmap 的魅力在于"少一次拷贝"。普通 read 要把数据从页缓存拷到用户缓冲区,mmap 则让进程直接摸到页缓存里的那页,省掉了中间这道搬运。对反复访问同一块大文件、或者需要随机跳读的场景,mmap 很划算。但它不是银弹,坑也不少:
所以我的判断是:mmap 适合"读多、顺序扫、共享映射"这类场景,不适合"频繁随机小写、需要强持久化"的日志场景。数据库的 WAL 宁可老老实实走 write 加 fsync,也不会用 mmap 去碰它。
| 维度 | read 与 write | mmap |
|---|---|---|
| 数据流动 | 磁盘到页缓存,再拷贝到用户区 | 进程直接访问页缓存,少一次拷贝 |
| 写回控制 | 由 write 加 fsync 精确控制 | 由内核按页惰性回写 |
| 随机访问成本 | 每次 read 是一次系统调用 | 缺页时才付出成本 |
| 适合场景 | 强持久化、顺序追加、日志 | 大文件随机读、共享只读映射 |
前面一路讲下来,有个词反复出现却一直悬着:到底怎样才算"写完"了?
答案是分层的。write 返回,只说明数据进了页缓存,还在内存里。要让数据安全,得调 fsync,它会把指定文件的脏页刷到设备。但即便 fsync 返回了,数据也可能只到了设备的易失写缓存,还没真正写进闪存颗粒或磁盘盘片——极端掉电下依然可能丢。要彻底,还得看设备支不支持、以及内核有没有发出冲刷设备缓存的命令。所以"持久化"从来不是二元的,而是一层层往下推进的:进页缓存、进设备缓存、进持久介质,每一层之间都隔着一次掉电风险。
数据库对此最敏感。事务提交时那句"已提交",背后要求的是数据真的到了持久介质,而不是躺在某层缓存里假装安全。于是日志的每次写入都要配套 fsync,甚至要关掉或显式冲刷设备写缓存。这也是为什么数据库常说自己"把 fsync 的语义用到了极致"——它要的确定性,恰恰是操作系统抽象里最贵、最容易被人忽略的那一环。
为了省掉一次次 fsync 的开销,数据库还演化出"组提交"的做法:把多个并发事务的日志攒成一批,用一次 fsync 一起刷下去。这样每个事务摊到的刷盘成本就小了很多,吞吐能抬上去,代价是单个事务要稍微多等一会儿——等待的这批时间,换来的是一起落盘的确定性。这也是"用延迟换吞吐"在持久化路径上的一次翻版。
⚠️ 常见坑:以为 fsync 之后万无一失。fsync 只保证"到设备",不保证"到介质"。设备上如果还有一层写缓存没被冲刷,掉电照样丢。数据库高可用方案里,往往还要靠带掉电保护的电容、或者靠多副本,才敢说真正落盘。
💡 关键直觉:页缓存是"用延迟换吞吐",fsync 是"用吞吐换安全"。什么时候该省这口气、什么时候该吐这口气,取决于你能承受丢多少数据。
最后把四层拼成一条完整的路径,看一次写请求到底走了多远。
应用发出 write,先进 VFS,落到页缓存,把页标脏;到了一定时机,脏页被回写,进入块层;块层会把相邻的请求合并、按磁盘的物理顺序排序,再交给设备驱动;驱动把请求翻译成设备能懂的协议命令,投进队列,最后由控制器搬运到闪存颗粒或盘片。反过来读,就是这条路径倒着走一遍,中间夹着页缓存的命中和预读。
读路径上还有两个常被忽略的帮手:命中与预读。命中意味着页缓存里已经有这页,磁盘一次都不用动;预读则是内核看到你在顺序读,就提前把后面几页也拉进来,让磁盘的机械或闪存并行能力被充分利用。这两个机制合起来,解释了为什么"顺序扫全表"的实际吞吐往往远超随机点查——不是硬件变了,是缓存和预读帮顺序读把路铺平了。
这条栈的好处是每一层都只管自己的事,坏处是层层有开销——锁、拷贝、上下文切换,一个不少。所以追求极致性能的人会想绕开它:要么用直接 IO 跳过页缓存,要么干脆把驱动搬到用户态直接跟硬件对话(SPDK 那一路)。但绕开不是免费的,你省下了开销,也就放弃了内核帮你做的合并、缓存和故障兜底。抽象的价值,恰恰在于这些"看不见的兜底"。
硬件给了物理约束,操作系统给了接口,接下来就该看数据本身怎么被组织起来了。
下一章,我们将进入存储引擎与索引:看 B+ 树和 LSM 树如何利用顺序写、页缓存和预读这些特性,把数据摆放得既省空间又查得快。