1.2 操作系统层面的存储抽象


1.2 操作系统层面的存储抽象

本节摘要:操作系统在裸硬件和应用之间垫了一层抽象,核心是四样东西:文件、页缓存、mmap 和 fsync。文件系统用统一的文件接口盖住硬件差异,页缓存让内存充当磁盘的高速缓存,mmap 把文件映射进地址空间,fsync 负责把"写完"这件事说清楚。这层抽象让程序员不必关心扇区和寻道,但也埋下不少语义陷阱——写到页缓存不算落盘,mmap 的快是有代价的,fsync 的"同步"其实分好几层。本节沿着一次读写走过的 I/O 栈,把这几个抽象逐个拆开。

本节目标

阅读完本节,你应当能够:

  1. 说清 VFS 如何用统一接口屏蔽不同文件系统的差异
  2. 解释页缓存为什么能加速读写,以及脏页回写为什么会有延迟尖峰
  3. 说明 mmap 的机制,以及它在什么场景划算、什么场景是坑
  4. 讲出 fsync 保证的边界,以及它与数据真正落盘之间还隔着什么
  5. 描述一次写请求从应用到物理介质经过的完整 I/O 栈

一、问题与直觉:从裸设备到文件

如果让程序员直接面对一块磁盘,生活会非常痛苦。他要记住数据写在哪个扇区、哪个磁道,要处理不同品牌硬盘之间的差异,还要在断电、坏块这些意外面前自己兜底。没有谁会真的这么干。操作系统做了一件关键的事:把"存储"这个词从物理概念翻译成了"文件"这个逻辑概念。

这一节要拆的,就是这次翻译。它分四层来看:文件是怎么统一起来的(VFS),读写在内存和磁盘之间是怎么流动的(页缓存),把文件映射进内存又意味着什么(mmap),以及"数据到底算不算写完了"这件事靠谁说了算(fsync)。四层串起来,就是一次读写请求从应用走到物理介质的完整路径。

二、VFS:一张统一的面孔

Linux 里同时住着好几种文件系统:ext4、XFS、Btrfs、还有网络文件系统。它们的磁盘布局天差地别——有的用位图管空间,有的用 B+ 树索引文件元数据。可应用只认得 open、read、write 这几个调用,从不关心下面是谁在干活。这个奇迹靠的是一层叫 VFS 的抽象:虚拟文件系统。

VFS 不是某个具体文件系统的实现,而是一套接口规范和数据结构骨架。你可以把它想成一个标准化的火车站:不管哪家公司的列车,都得按统一的轨道宽度和站台编号来运行。VFS 定义了四个核心对象,它们各司其职又互相咬合:

  • 超级块,描述整个文件系统的元信息,相当于一张身份证
  • 索引节点,代表"文件本身",装权限、时间戳、数据块指针,但不含文件名
  • 目录项,把文件名和索引节点连起来,还缓存路径解析结果
  • 文件对象,代表一个已打开的文件实例,装着读写偏移和访问模式

一次 open 调用,会先从根目录逐级往下解析路径,每走一步都先查目录项缓存,没命中才去问具体文件系统。走到头,内核分配一个文件对象,把它的操作函数指针指向这套文件系统自己的实现。以后每次 read、write,都通过这张函数指针表派发下去。这就是"策略与机制分离":VFS 定好"要做什么",具体文件系统只负责"怎么做"。

这个设计的价值在于,新文件系统可以无缝接入,应用一行代码都不用改。FUSE 更把这种能力放到了用户态——开发者能在普通进程里写一个文件系统,通过网络、加密、云存储这些形态把数据"假装"成文件。抽象到这里,已经不仅是内核的独白,而是一套开放的协议。

三、页缓存:把内存当作磁盘的缓存

文件接口解决的是"怎么表示",页缓存解决的是"怎么变快"。磁盘太慢,内存快但小,那就让内存当磁盘的缓存——这正是上一节讲的存储分层在操作系统内部的又一次重演。

页缓存的基本单位是页,通常 4KB。当你读一个文件,内核先把对应的页从磁盘读进页缓存,再把数据拷给你;第二次读同一页,就直接从内存命中,磁盘根本不用动。写则反过来:write 调用先把数据写进页缓存,标记成"脏页",然后立刻返回,真正写回磁盘的动作被推迟到后面。这个策略叫写回缓存。

写回的好处是吞吐高——一堆小写可以攒成一大块顺序写下去,还允许后面的读覆盖掉前面的写,让中间那几次写彻底作废。代价是风险:脏页还在内存里,掉电就没了。于是内核需要一套回写机制,在脏页太多、太老,或者内存吃紧的时候把它们刷下去。回写本身要占带宽,如果你正跑着一个对延迟敏感的业务,突然来一波大规模回写,会明显感觉到系统卡了一下。这就是"写回"用延迟换吞吐的典型代价。

还有一条旁路叫直接 IO(O_DIRECT)。它跳过页缓存,把数据直接从用户缓冲区送到设备,省掉一次拷贝,代价是没了缓存带来的合并和预读。数据库往往用它来管自己的日志和数据文件,因为数据库自己就有一层缓冲池,不想让内核再缓存一份——双重缓存不仅浪费内存,还让"数据到底落没落盘"这件事更难判断。

四、mmap:把文件映射进地址空间

页缓存之上还有一个更诱人的抽象:mmap。它把文件直接映射进进程的虚拟地址空间,让你像访问内存数组一样访问文件。读一个字节,就是读一个内存地址;缺页时内核自动把对应的文件页拉进页缓存,再填进你的地址空间。

mmap 的魅力在于"少一次拷贝"。普通 read 要把数据从页缓存拷到用户缓冲区,mmap 则让进程直接摸到页缓存里的那页,省掉了中间这道搬运。对反复访问同一块大文件、或者需要随机跳读的场景,mmap 很划算。但它不是银弹,坑也不少:

  • 缺页有成本。mmap 的访问是惰性的,第一次碰某个地址会触发缺页中断,从磁盘把页调进来。如果文件很大、你又真的全扫一遍,这些缺页一个都躲不掉
  • 写放大。mmap 写数据也是以页为粒度,你哪怕只改了一个字节,整页都要被标记为脏、最终整页写回。对随机小写,这比顺序追加要浪费得多
  • 一致性难管。通过 mmap 改的内存,什么时候写回磁盘由内核说了算,应用很难精确控制落盘时机

所以我的判断是:mmap 适合"读多、顺序扫、共享映射"这类场景,不适合"频繁随机小写、需要强持久化"的日志场景。数据库的 WAL 宁可老老实实走 write 加 fsync,也不会用 mmap 去碰它。

维度 read 与 write mmap
数据流动 磁盘到页缓存,再拷贝到用户区 进程直接访问页缓存,少一次拷贝
写回控制 由 write 加 fsync 精确控制 由内核按页惰性回写
随机访问成本 每次 read 是一次系统调用 缺页时才付出成本
适合场景 强持久化、顺序追加、日志 大文件随机读、共享只读映射

五、fsync:把"写完"这件事说清楚

前面一路讲下来,有个词反复出现却一直悬着:到底怎样才算"写完"了?

答案是分层的。write 返回,只说明数据进了页缓存,还在内存里。要让数据安全,得调 fsync,它会把指定文件的脏页刷到设备。但即便 fsync 返回了,数据也可能只到了设备的易失写缓存,还没真正写进闪存颗粒或磁盘盘片——极端掉电下依然可能丢。要彻底,还得看设备支不支持、以及内核有没有发出冲刷设备缓存的命令。所以"持久化"从来不是二元的,而是一层层往下推进的:进页缓存、进设备缓存、进持久介质,每一层之间都隔着一次掉电风险。

数据库对此最敏感。事务提交时那句"已提交",背后要求的是数据真的到了持久介质,而不是躺在某层缓存里假装安全。于是日志的每次写入都要配套 fsync,甚至要关掉或显式冲刷设备写缓存。这也是为什么数据库常说自己"把 fsync 的语义用到了极致"——它要的确定性,恰恰是操作系统抽象里最贵、最容易被人忽略的那一环。

为了省掉一次次 fsync 的开销,数据库还演化出"组提交"的做法:把多个并发事务的日志攒成一批,用一次 fsync 一起刷下去。这样每个事务摊到的刷盘成本就小了很多,吞吐能抬上去,代价是单个事务要稍微多等一会儿——等待的这批时间,换来的是一起落盘的确定性。这也是"用延迟换吞吐"在持久化路径上的一次翻版。

⚠️ 常见坑:以为 fsync 之后万无一失。fsync 只保证"到设备",不保证"到介质"。设备上如果还有一层写缓存没被冲刷,掉电照样丢。数据库高可用方案里,往往还要靠带掉电保护的电容、或者靠多副本,才敢说真正落盘。
💡 关键直觉:页缓存是"用延迟换吞吐",fsync 是"用吞吐换安全"。什么时候该省这口气、什么时候该吐这口气,取决于你能承受丢多少数据。

六、把整条 I/O 栈串起来

最后把四层拼成一条完整的路径,看一次写请求到底走了多远。

应用发出 write,先进 VFS,落到页缓存,把页标脏;到了一定时机,脏页被回写,进入块层;块层会把相邻的请求合并、按磁盘的物理顺序排序,再交给设备驱动;驱动把请求翻译成设备能懂的协议命令,投进队列,最后由控制器搬运到闪存颗粒或盘片。反过来读,就是这条路径倒着走一遍,中间夹着页缓存的命中和预读。

读路径上还有两个常被忽略的帮手:命中与预读。命中意味着页缓存里已经有这页,磁盘一次都不用动;预读则是内核看到你在顺序读,就提前把后面几页也拉进来,让磁盘的机械或闪存并行能力被充分利用。这两个机制合起来,解释了为什么"顺序扫全表"的实际吞吐往往远超随机点查——不是硬件变了,是缓存和预读帮顺序读把路铺平了。

这条栈的好处是每一层都只管自己的事,坏处是层层有开销——锁、拷贝、上下文切换,一个不少。所以追求极致性能的人会想绕开它:要么用直接 IO 跳过页缓存,要么干脆把驱动搬到用户态直接跟硬件对话(SPDK 那一路)。但绕开不是免费的,你省下了开销,也就放弃了内核帮你做的合并、缓存和故障兜底。抽象的价值,恰恰在于这些"看不见的兜底"。

一节小结

  • 文件是操作系统给的统一接口:VFS 用超级块、索引节点、目录项、文件对象四件套屏蔽了文件系统差异
  • 页缓存是内存当磁盘的缓存:写回策略用延迟换吞吐,但脏页掉电会丢,回写会带来延迟尖峰
  • mmap 省一次拷贝:适合大文件随机读,不适合需要精确落盘控制的随机小写
  • 持久化是分层的:write 进页缓存、fsync 到设备、冲刷设备缓存到介质,层层递进
  • fsync 只保证到设备边界:设备写缓存不冲刷,极端掉电仍可能丢数据
  • I/O 栈层层有开销:直接 IO 和用户态驱动是绕开它的两条路,代价是失去内核兜底

硬件给了物理约束,操作系统给了接口,接下来就该看数据本身怎么被组织起来了。

下一章,我们将进入存储引擎与索引:看 B+ 树和 LSM 树如何利用顺序写、页缓存和预读这些特性,把数据摆放得既省空间又查得快。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U