文件系统:数据如何在磁盘上安家

举报
yd_232225224 发表于 2026/10/21 19:05:54 2026/10/21
【摘要】 在计算机中保存一份文档、一张照片或一段代码,看起来只是一次简单的"保存"操作。但在这背后,操作系统需要回答一系列问题:文件的内容应该写在磁盘的什么位置?文件名如何与内容对应起来?如何在海量文件中快速找到某一个?磁盘上的空间如何分配和回收?写入到一半时突然断电,数据会不会损坏?多个程序同时读写同一个文件会发生什么?负责回答这些问题的,是文件系统(File System)。它在原始的、以块为单位...

在计算机中保存一份文档、一张照片或一段代码,看起来只是一次简单的"保存"操作。但在这背后,操作系统需要回答一系列问题:文件的内容应该写在磁盘的什么位置?文件名如何与内容对应起来?如何在海量文件中快速找到某一个?磁盘上的空间如何分配和回收?写入到一半时突然断电,数据会不会损坏?多个程序同时读写同一个文件会发生什么?

负责回答这些问题的,是文件系统(File System)。它在原始的、以块为单位的存储设备之上,构建出了人们熟悉的文件和目录的抽象。本文介绍文件系统的基本抽象、磁盘上的数据组织方式、空间分配策略、崩溃一致性问题及其解决方案、写时复制文件系统、缓存与持久化的语义,以及面向固态存储和分布式环境的文件系统设计。

一、文件系统提供的抽象

从块设备到文件

存储设备,无论是机械硬盘还是固态硬盘,向操作系统提供的接口都非常原始:它们被视为一个由固定大小的块组成的巨大数组,例如每块 512 字节或 4KB。操作系统只能按照块的编号读取或写入整块数据。

在这种接口上直接存储数据,使用者需要自己记住每份数据存放在哪些块中、哪些块是空闲的,这显然是不现实的。文件系统在块设备之上建立了更高层的抽象:

  • 文件:一个具有名称的、可变长度的字节序列。使用者可以从任意位置读写任意长度的数据,文件可以增长和截断,而不必关心数据实际存放在哪些块中;
  • 目录:一种特殊的文件,记录着名称与文件之间的对应关系。目录可以包含其他目录,从而形成层次化的树形结构;
  • 元数据:描述文件属性的信息,例如文件大小、所有者、访问权限、创建和修改时间。

文件操作接口

在类 Unix 系统中,程序通过一组系统调用与文件系统交互:

  • 打开(open):根据路径名找到文件,检查访问权限,返回一个文件描述符。后续的操作都通过这个描述符进行;
  • 读取(read)与写入(write):从文件的当前位置读取或写入指定长度的数据,并移动当前位置;
  • 定位(lseek):改变文件的当前读写位置,实现随机访问;
  • 同步(fsync):要求将文件已修改的数据和元数据真正写入存储设备;
  • 关闭(close):释放文件描述符;
  • 重命名(rename)、删除(unlink)、创建目录(mkdir)等,用于操作文件和目录的结构。

一切皆文件

Unix 系统的一个重要设计理念是"一切皆文件":不仅磁盘上的普通文件,设备、管道、套接字,甚至进程和内核的状态信息,都可以通过统一的文件接口来访问。这使得大量的工具和程序可以用相同的方式处理不同类型的数据源。

为了支持这一理念,并允许多种不同的文件系统共存,操作系统内核中通常有一个虚拟文件系统(Virtual File System,VFS)层。它定义了一组统一的接口和数据结构,各个具体的文件系统只需实现这些接口。应用程序的文件操作首先到达虚拟文件系统层,再被分派给负责相应路径的具体文件系统。这样,同一台机器上可以同时挂载多种不同格式的文件系统、网络文件系统和各种虚拟文件系统,对应用程序而言,它们的使用方式完全相同。

二、磁盘上的数据组织

基本结构

一个典型的类 Unix 文件系统,在磁盘上通常包含以下几类区域:

  • 超级块(Superblock):记录整个文件系统的全局信息,例如文件系统的类型、大小、块的大小、各个区域的位置、空闲块和空闲索引节点的数量。超级块是挂载文件系统时首先读取的结构,由于其重要性,通常会在磁盘的多个位置保存副本;
  • 空闲空间管理结构:记录哪些数据块和索引节点是空闲的,常用位图表示,每一位对应一个块或一个索引节点;
  • 索引节点表:存放所有的索引节点;
  • 数据块:存放文件的实际内容和目录的内容。

索引节点

索引节点(inode,即 index node)是文件系统中描述一个文件的核心数据结构。每个文件都对应一个索引节点,其中记录了:

  • 文件的类型:普通文件、目录、符号链接、设备文件等;
  • 文件的大小;
  • 文件的所有者和所属的组;
  • 访问权限;
  • 各种时间戳:最后访问时间、最后修改内容的时间、最后修改元数据的时间;
  • 硬链接计数:有多少个目录项指向这个索引节点;
  • 文件内容所在的数据块位置。

一个重要的细节是:索引节点中并不包含文件名。文件名存储在目录中。每个索引节点有一个唯一的编号,目录通过这个编号来引用它。

定位文件数据

索引节点需要记录文件内容存放在哪些数据块中。由于文件大小差异极大,从几个字节到数 TB,这种记录方式需要兼顾小文件的效率和大文件的支持。

多级索引是经典的方法:

  • 索引节点中包含若干个直接指针,例如 12 个,每个指针直接指向一个数据块。对于小文件,所有的数据块都可以通过直接指针找到,访问非常高效;
  • 一个一级间接指针,指向一个间接块。这个块本身不存储文件数据,而是存储一组指向数据块的指针。如果块大小为 4KB,每个指针 4 字节,一个间接块可以容纳 1024 个指针,能够额外寻址 4MB 的数据;
  • 一个二级间接指针,指向一个块,其中的每个指针又指向一个间接块,能够寻址约 4GB 的数据;
  • 一个三级间接指针,再增加一层间接,能够寻址约 4TB 的数据。

这种结构反映了文件大小的实际分布:大多数文件都很小,只需直接指针即可;少数大文件则通过多级间接块支持。

区段

多级索引的缺点在于:对于大文件,需要为每一个数据块都存储一个指针,元数据的体积很大;读取大文件时,还需要额外读取大量的间接块。

现代文件系统普遍采用区段(Extent)来描述文件的数据位置。一个区段由三部分组成:文件中的逻辑起始位置、磁盘上的物理起始块号、以及连续块的数量。如果一个 1GB 的文件在磁盘上是连续存放的,只需一个区段就可以完整描述,而用块指针则需要数十万个。

区段大大减少了元数据的体积,并且天然地鼓励文件数据在磁盘上连续存放,提高了顺序读写的性能。当一个文件的区段数量较多时,文件系统会使用一棵树来组织这些区段,以便快速查找。

目录

目录本身也是一个文件,其内容是一组目录项,每个目录项记录一个名称和对应的索引节点编号。

最简单的目录实现是一个线性的列表。查找一个文件名时,需要逐个比较目录中的所有目录项。对于包含少量文件的目录,这完全足够;但当一个目录中有数万甚至数百万个文件时,线性查找会变得非常缓慢。

现代文件系统通常为大目录使用哈希树或 B 树来组织目录项,使得查找一个名称的时间与目录大小基本无关,或仅呈对数增长。

路径解析

当程序打开一个路径,例如一个位于多层目录下的文件时,文件系统需要逐级进行路径解析:

  1. 从根目录的索引节点开始,根目录的索引节点编号是一个固定的值;
  2. 读取根目录的内容,在其中查找第一级目录名,得到其索引节点编号;
  3. 读取该目录的索引节点和内容,查找下一级名称;
  4. 依此类推,直到找到最终的文件。

在每一步,文件系统还需要检查当前进程对该目录是否有进入的权限。

路径解析可能涉及多次磁盘读取。为了加速这一过程,操作系统会在内存中维护一个目录项缓存,缓存最近解析过的路径组件与索引节点之间的对应关系,大部分路径解析可以直接在缓存中完成。

硬链接与符号链接

由于文件名存储在目录中,而不是索引节点中,同一个索引节点可以被多个目录项引用,这就是硬链接(Hard Link)。多个硬链接是完全平等的,它们都指向同一份数据和元数据,没有"原始"与"副本"之分。

索引节点中的硬链接计数记录了指向它的目录项数量。删除一个文件时,实际上只是删除了一个目录项,并将链接计数减一。只有当链接计数降为零,并且没有任何进程打开着这个文件时,文件系统才会真正释放其索引节点和数据块。这也是为什么在类 Unix 系统中,可以删除一个正在被程序使用的文件,而该程序仍然能够继续读写它:文件的数据在程序关闭它之前不会被释放。

符号链接(Symbolic Link,也称软链接)则是一个独立的特殊文件,其内容是另一个路径名。访问符号链接时,文件系统会读取其中存储的路径,并继续解析该路径。符号链接可以跨越不同的文件系统,也可以指向目录,但如果其目标被删除或移动,符号链接就会失效,成为一个悬空的链接。

三、空间分配

空间分配的目标

文件系统在为文件分配数据块时,需要考虑以下几个目标:

  • 局部性:同一个文件的数据块应当尽量连续存放,同一个目录中的文件也应当尽量靠近存放,以减少读取时的寻道和随机访问;
  • 减少碎片:避免空闲空间被切割成大量零散的小块,导致后续无法分配连续的空间;
  • 分配效率:查找空闲块的操作应当快速。

块组

对于机械硬盘,磁头在不同磁道之间移动的寻道时间,是访问延迟的主要来源。早期的文件系统将所有的索引节点集中存放在磁盘的开头,而数据块分布在整个磁盘上,读取一个文件时,需要先到磁盘开头读取索引节点,再长距离寻道到数据所在的位置,效率很低。

一种经典的改进是将磁盘划分为多个块组(Block Group,也称柱面组)。每个块组都有自己的位图、索引节点表和数据块。文件系统在分配时遵循一些启发式规则:

  • 尽量将一个文件的索引节点和数据块分配在同一个块组中;
  • 尽量将同一个目录中的文件放在同一个块组中;
  • 将新的子目录分散到不同的块组中,以平衡各块组的负载。

这些策略显著提高了文件访问的局部性。

延迟分配

延迟分配(Delayed Allocation)是现代文件系统中一项重要的优化。

当程序写入数据时,文件系统并不立即为其分配磁盘块,而是先将数据保存在内存的页缓存中,并预留相应的空间额度。等到数据真正需要写回磁盘时,再进行实际的块分配。

这样做的好处是:在实际分配时,文件系统已经知道了该文件需要写入的全部数据量,可以一次性为其分配一段连续的空间,而不是在每次小的写入时零散地分配。对于那些很快就被删除的临时文件,甚至可以完全不分配磁盘块。

延迟分配也带来了一个副作用:在实际分配和写回之前,数据只存在于内存中。如果此时系统崩溃,这部分数据会丢失。这一点在后面讨论数据持久化时会进一步说明。

预分配

一些应用,例如数据库和虚拟机镜像,需要一个大文件并会在后续逐步写入其内容。如果在写入时才逐步分配空间,文件可能会变得碎片化,并且在空间不足时写入会失败。

文件系统通常提供预分配接口,允许应用在写入之前,一次性为文件预留指定大小的空间。文件系统会尽量为其分配连续的块,并标记为已分配但尚未写入数据。

稀疏文件

稀疏文件是指文件中存在一些从未写入过数据的"空洞"。例如,创建一个文件后,直接定位到 1GB 的位置写入一个字节,文件的大小为 1GB,但中间的部分从未写入过。

文件系统不会为这些空洞分配实际的磁盘块。读取空洞部分时,文件系统直接返回零。这使得稀疏文件的逻辑大小可以远大于其实际占用的磁盘空间,在虚拟机磁盘镜像等场景中非常有用。

四、崩溃一致性

问题的由来

文件系统中的许多操作,需要同时修改磁盘上的多个数据结构。以向一个文件追加一个数据块为例,需要:

  1. 在空闲块位图中,将一个块标记为已使用;
  2. 更新文件的索引节点,添加指向新块的指针,并增大文件大小;
  3. 将数据写入新分配的块。

这三处修改位于磁盘的不同位置,无法通过一次原子的写入完成。如果在执行过程中发生断电或系统崩溃,可能只有其中一部分写入成功,导致磁盘上的数据结构处于不一致的状态:

  • 只更新了位图:一个块被标记为已使用,却没有任何文件引用它,这块空间就泄漏了;
  • 只更新了索引节点:索引节点指向一个块,但位图中这个块仍然是空闲的,之后可能被分配给另一个文件,两个文件共享同一个块,数据相互覆盖;同时,块中的内容可能是之前其他文件留下的旧数据,造成数据泄露;
  • 只写入了数据:数据写入了磁盘,但没有任何结构引用它,相当于写入从未发生,不会造成损坏,但数据丢失了;
  • 更新了位图和索引节点,但数据尚未写入:文件指向了一个块,但块中是无意义的旧数据。

此外,存储设备和操作系统为了提高性能,可能会重新排序写入操作,程序发出写入的顺序,并不一定是数据实际落到磁盘上的顺序,这使得问题变得更加复杂。

崩溃一致性(Crash Consistency)问题,就是要保证文件系统在任何时刻崩溃后,都能恢复到一个一致的状态。

文件系统检查

早期文件系统的做法是:允许不一致发生,在系统重启后,运行一个文件系统检查工具扫描整个文件系统,发现并修复不一致的地方。

检查工具会遍历所有的索引节点和目录,重新计算哪些块被使用、每个索引节点的链接计数是多少,与磁盘上记录的信息进行比对,修正位图和计数,处理被多个文件引用的块,将找不到所属目录的索引节点放入一个专门的目录中。

这种方式的问题在于:

  • 速度太慢:检查需要扫描整个文件系统的元数据,耗时与文件系统的大小成正比。对于 TB 级别的大容量磁盘,一次检查可能需要数小时,在此期间系统无法使用;
  • 只能保证元数据一致:检查工具能修复结构上的不一致,但无法知道文件内容是否正确,例如上面提到的索引节点指向了包含旧数据的块,检查工具无法发现。

随着磁盘容量的不断增长,这种方式变得越来越难以接受。

日志

日志(Journaling),也称预写日志,是现代文件系统解决崩溃一致性问题的主流方法,其思想与数据库中的预写日志相同。

文件系统在磁盘上划出一块专门的区域作为日志。在对文件系统的实际结构进行修改之前,先将要进行的修改写入日志:

  1. 写入日志:将本次操作需要修改的所有块,例如位图块、索引节点块、数据块,连同一个事务开始标记,写入日志区域;
  2. 提交:所有内容写入日志后,写入一个事务提交标记。提交标记写入完成,即表示这个事务已经提交;
  3. 检查点:将日志中记录的修改,写入它们在文件系统中的实际位置;
  4. 释放:实际位置写入完成后,日志中这个事务所占的空间可以被回收复用。

如果系统在不同的阶段崩溃,恢复时的处理方式不同:

  • 在提交标记写入之前崩溃:这个事务不完整,恢复时直接忽略,文件系统的实际结构没有被修改过,仍然是一致的;
  • 在提交之后、检查点完成之前崩溃:恢复时,重新读取日志中所有已提交的事务,将其中的修改重新写入实际位置,这称为重放。由于重放的内容是完整的块镜像,重复写入多次也不会出错。

恢复只需要扫描日志区域,而日志的大小是有限的,恢复时间通常只有几秒钟,与文件系统的总大小无关。

为了保证正确性,写入的顺序至关重要:提交标记必须在事务的其他内容全部写入磁盘之后才能写入;检查点必须在提交标记写入磁盘之后才能进行。文件系统通过写屏障或缓存刷新命令,强制要求存储设备按照指定的顺序将数据持久化,而不能随意重排。

日志的模式

将所有的数据块都写入日志,意味着每份数据都要写两次:一次写入日志,一次写入实际位置,写入量翻倍。为了减少这一开销,文件系统通常提供几种不同的日志模式:

  • 数据日志模式:元数据和文件数据都写入日志。一致性保证最强,但性能开销最大;
  • 有序模式:只有元数据写入日志,文件数据直接写入其最终位置,但文件系统保证在相关的元数据事务提交之前,先将文件数据写入磁盘。这样,即使发生崩溃,也不会出现元数据指向包含旧数据的块的情况。这是许多文件系统的默认模式,在一致性与性能之间取得了较好的平衡;
  • 回写模式:只有元数据写入日志,对文件数据的写入顺序不做保证。性能最好,但崩溃后,文件可能包含过时的或无关的数据。

软更新

除了日志之外,还有一种称为软更新(Soft Updates)的方法,它不使用额外的日志,而是通过精心安排元数据写入的顺序,保证磁盘上的结构在任何时刻都不会出现危险的不一致。例如,总是先写入数据块,再写入指向它的索引节点;先初始化索引节点,再写入引用它的目录项。在这种顺序下,崩溃最多只会导致一些空间泄漏,可以在后台慢慢回收,而不会导致数据损坏。

软更新避免了日志的双写开销,但需要追踪复杂的块之间的依赖关系,实现难度很大。

五、写时复制文件系统

基本思想

日志文件系统通过"先记录、再修改"的方式实现一致性,但它仍然是原地更新的:数据最终会被写回其原来的位置,覆盖旧的内容。

写时复制(Copy-on-Write,COW)文件系统采用了完全不同的方法:永远不覆盖已有的数据。

当需要修改一个数据块时,写时复制文件系统将修改后的内容写入一个新的、空闲的位置。然后,需要更新指向这个块的父节点。但父节点同样不能原地修改,于是也将修改后的父节点写入一个新的位置。这一过程沿着树向上传播,一直到达树的根节点。

最后,文件系统通过一次原子的写入,更新一个记录当前根节点位置的超级块,使其指向新的根节点。

在这次原子更新之前,磁盘上的旧树仍然完整无损,新树的各个部分虽然已经写入,但尚未被引用;在原子更新之后,新树生效,旧树中不再被引用的块可以被回收。无论在哪个时刻崩溃,文件系统看到的要么是完整的旧状态,要么是完整的新状态,永远不会处于不一致的中间状态。

快照

写时复制带来的一个重要特性是几乎零成本的快照(Snapshot)。

创建快照时,只需保留当前根节点的一个引用,阻止其对应的旧数据块被回收即可,不需要复制任何数据。此后,对文件系统的修改都会写入新的位置,快照所引用的旧版本数据保持不变。快照与当前文件系统之间共享所有未被修改的数据块,只有被修改的部分才会占用额外的空间。

快照可以用于:

  • 在进行系统升级或配置变更之前创建快照,出现问题时快速回滚;
  • 定期创建快照,保留历史版本,应对误删除或勒索软件;
  • 基于快照进行一致的备份,备份过程中文件系统仍可正常使用;
  • 计算两个快照之间的差异,只传输变化的数据,实现高效的增量备份和远程复制。

类似地,写时复制文件系统还可以高效地实现克隆:创建一个文件或目录树的可写副本,初始时与原数据共享所有块,只有在修改时才分配新的空间。

数据完整性校验

写时复制文件系统通常还会为每个数据块和元数据块计算校验和,并将校验和存储在指向该块的父节点中,而不是块本身。

这样,从根节点开始,每一个块的校验和都由其父节点保证,形成了一棵默克尔树。读取任何一个块时,文件系统都会计算其校验和,与父节点中记录的值进行比较。如果不匹配,说明数据已经损坏。

将校验和存储在父节点中,而不是与数据存放在一起,非常重要:这能够检测出一类特别隐蔽的错误,即存储设备将数据写到了错误的位置,或者读取时返回了错误位置的数据。如果校验和与数据存放在一起,这类错误中数据和校验和会一起出错,彼此仍然匹配,无法被发现。

存储设备并非完全可靠,会出现静默数据损坏:数据在存储期间由于介质老化、固件缺陷、宇宙射线等原因发生了比特翻转,而设备本身没有报告任何错误。传统的文件系统无法发现这类损坏,会将损坏的数据当作正确的数据返回给应用程序。端到端的校验和使得这类损坏能够被及时发现。

如果文件系统同时管理多块磁盘,并配置了镜像或奇偶校验等冗余,在检测到某个副本损坏时,还可以从其他副本中读取正确的数据,并自动修复损坏的副本。文件系统还可以定期在后台进行数据巡检,主动读取并校验所有数据,在损坏影响到使用之前就发现并修复它。

写时复制的代价

写时复制也有其代价:

  • 碎片化:由于每次修改都写入新的位置,一个原本连续的文件在经历多次随机修改后,其数据块会散布在磁盘各处。对于数据库文件、虚拟机镜像这类频繁进行小的随机写入的大文件,碎片化尤为严重,会影响后续的顺序读取性能。为此,一些写时复制文件系统允许对特定文件关闭写时复制;
  • 写放大:修改一个小的数据块,需要沿着树向上重写一系列的元数据块。文件系统通常会将一段时间内的多次修改合并到同一次提交中,以分摊这一开销;
  • 空间管理复杂:由于快照和克隆的存在,一个块可能被多个版本共享,判断一个块是否可以被释放变得复杂,需要维护引用计数或其他的追踪机制。磁盘接近写满时,写时复制文件系统的性能可能会急剧下降,因为每次写入都需要寻找新的空闲空间。即使是删除文件,也需要写入新的元数据,在空间完全耗尽时甚至可能无法删除文件;
  • 内存需求:一些写时复制文件系统为了获得良好的性能,需要较多的内存用于缓存元数据。

日志结构文件系统

日志结构文件系统(Log-Structured File System)将写时复制的思想推向了极致:将整个磁盘视为一个只追加的日志,所有的写入,无论是数据还是元数据,都以顺序追加的方式写入日志的末尾。

这种设计的出发点是:随着内存容量的增大,大部分读请求可以由缓存满足,磁盘的主要负载来自写入;而顺序写入的性能远高于随机写入。将所有写入转化为顺序写入,可以充分发挥磁盘的带宽。

其主要挑战在于垃圾回收:随着文件被修改和删除,日志中早期写入的许多块会失效。文件系统需要在后台进行段清理,选择一些包含较多失效块的日志段,将其中仍然有效的块复制到日志末尾,然后回收整个段。清理的开销在磁盘使用率较高时会变得很大。

日志结构的思想对后来的许多存储系统产生了深远的影响,包括 LSM 树存储引擎、固态硬盘内部的闪存转换层,以及专为闪存设计的文件系统。

六、缓存与持久化语义

页缓存

磁盘的访问速度远慢于内存,因此操作系统会将文件的内容缓存在内存中,称为页缓存(Page Cache)。

  • 读取时,如果数据已在页缓存中,直接从内存返回,无需访问磁盘;如果不在,则从磁盘读取并放入页缓存。操作系统还会检测顺序读取的模式,提前读入后续的数据,称为预读;
  • 写入时,数据通常只是被写入页缓存,并将相应的页面标记为"脏",写入系统调用随即返回。脏页会在稍后由内核的后台线程批量写回磁盘,称为回写。

延迟写回能够合并多次对同一位置的写入,将零散的写入聚合为较大的顺序写入,显著提升写入性能。但这也意味着:写入系统调用成功返回,并不代表数据已经持久化到磁盘上。如果在数据写回之前系统崩溃或断电,这些数据就会丢失。

fsync

为了确保数据真正持久化,应用程序需要显式地调用 fsync 系统调用。它会阻塞,直到该文件所有已修改的数据和元数据都写入存储设备,并要求设备将其内部的写缓存也刷新到持久化介质中。

fsync 的开销很大,可能需要数毫秒甚至更长的时间。频繁调用 fsync 会严重影响写入性能。因此,需要可靠持久化的应用,例如数据库,通常会将多个事务的日志写入合并在一起,统一调用一次 fsync,这称为组提交。

还有一个与之相关的系统调用 fdatasync,它只保证文件数据以及读取数据所必需的元数据,例如文件大小,被持久化,而不强制写回修改时间等不影响数据读取的元数据,开销通常略小。

持久化的陷阱

正确地实现数据持久化,比看上去要困难得多,存在许多容易被忽视的细节。

新建文件需要同步目录。 当创建一个新文件并对其调用 fsync 后,文件的内容和索引节点已经持久化了,但指向该文件的目录项是存储在父目录中的。如果没有对父目录也调用 fsync,崩溃后文件的数据可能存在于磁盘上,却在目录中找不到它。

原子地替换文件。 一个常见的需求是安全地更新一个配置文件:要么保留完整的旧版本,要么得到完整的新版本,不能出现写了一半的内容。正确的做法是:

  1. 将新内容写入一个临时文件;
  2. 对临时文件调用 fsync,确保其内容已经持久化;
  3. 使用 rename 将临时文件重命名为目标文件名。在大多数文件系统上,rename 对于同一文件系统内的操作是原子的:目标路径要么指向旧文件,要么指向新文件;
  4. 对父目录调用 fsync,确保重命名操作本身被持久化。

如果省略了第 2 步,在某些文件系统上,由于延迟分配等原因,重命名操作可能先于文件内容被持久化,崩溃后会得到一个空文件或内容不完整的文件。这类问题曾在一些文件系统中引起广泛的讨论,促使文件系统针对这种常见的使用模式加入了额外的保护措施,但应用程序不应依赖这些特定的行为。

fsync 的错误处理。 如果 fsync 返回了错误,例如由于磁盘故障导致写回失败,应用程序应该如何处理?一个常见的错误做法是重试 fsync,期望第二次能够成功。然而,在某些操作系统的实现中,写回失败后,相应的脏页可能已经被标记为干净,甚至被丢弃;再次调用 fsync 会返回成功,但数据实际上并没有被写入磁盘。这一问题曾导致知名数据库在特定故障下出现数据丢失。目前,对于 fsync 失败,最稳妥的做法通常是将其视为致命错误,停止服务,并从日志等可靠的来源进行恢复。

存储设备的写缓存。 许多存储设备内部带有易失性的写缓存。设备可能在数据只写入其缓存时就报告写入完成。文件系统依靠专门的刷新命令,要求设备将缓存中的数据持久化。如果设备的固件没有正确地实现这些命令,或者在断电时没有足够的电力保护措施,即使应用和文件系统都做了正确的事情,数据仍然可能丢失。企业级的存储设备通常带有断电保护电容,能够在断电时将缓存中的数据写入持久化介质。

直接 I/O

一些应用,例如数据库,会自行管理数据的缓存,它们比操作系统更清楚哪些数据需要缓存、何时需要写回。对于这类应用,页缓存不仅没有帮助,反而会造成双重缓存,浪费内存,并使写回的时机变得不可控。

直接 I/O(Direct I/O)允许应用绕过页缓存,直接在用户缓冲区与存储设备之间传输数据。使用直接 I/O 时,通常要求缓冲区的地址、读写的偏移量和长度都与块大小对齐。

七、面向固态存储的文件系统

固态硬盘的特点

传统的文件系统设计在很大程度上是为机械硬盘优化的,例如努力让数据连续存放以减少寻道。而固态硬盘具有完全不同的特性:

  • 没有寻道开销:随机读取的性能与顺序读取相差不大,访问延迟比机械硬盘低几个数量级;
  • 不能原地覆盖:闪存的基本写入单位是页,通常为几 KB 到十几 KB,但擦除的单位是块,包含数百个页。一个已写入数据的页,必须先擦除其所在的整个块,才能再次写入;
  • 有限的擦写寿命:每个闪存块只能经受有限次数的擦除,超过后就会失效;
  • 内部并行:固态硬盘内部有多个闪存芯片和通道,可以同时处理多个请求,需要足够的并发请求才能充分发挥其性能。

闪存转换层

为了让固态硬盘能够像传统块设备一样被使用,其内部有一个闪存转换层(Flash Translation Layer,FTL):

  • 维护一个从逻辑块地址到物理闪存页的映射表;
  • 当操作系统覆盖写一个逻辑块时,闪存转换层将新数据写入一个空闲的物理页,更新映射表,并将旧的物理页标记为无效。这本质上是一种日志结构的写入方式;
  • 在后台进行垃圾回收:选择包含较多无效页的块,将其中仍然有效的页复制到其他位置,然后擦除整个块;
  • 进行磨损均衡:使各个闪存块的擦除次数尽量均衡,延长整个设备的寿命。

垃圾回收会导致写放大:设备内部实际写入闪存的数据量,大于主机写入的数据量。写放大不仅降低性能,也加速闪存的磨损。

TRIM 命令

当文件系统删除一个文件时,它只是在自己的元数据中将相应的块标记为空闲,存储设备并不知道这些块中的数据已经没有用了。闪存转换层仍然认为这些数据是有效的,在垃圾回收时会继续复制它们,增加了写放大。

TRIM 命令(在不同的接口标准中名称有所不同)允许文件系统通知存储设备:某些逻辑块中的数据已经不再需要。闪存转换层可以据此将对应的物理页标记为无效,在垃圾回收时不再复制它们。文件系统可以在删除文件时立即发送 TRIM 命令,也可以定期批量发送。

闪存友好的文件系统

一些文件系统专门针对闪存的特性进行了设计,通常采用日志结构的思想:所有写入都以顺序追加的方式进行,避免原地覆盖;将冷热数据分开存放,经常被修改的数据和很少被修改的数据写入不同的区域,以降低垃圾回收的开销;与设备的特性对齐,以减少写放大。这类文件系统被广泛用于手机等移动设备中。

分区命名空间

更进一步,一种新的固态存储接口将设备划分为多个区域(Zone),每个区域只能顺序写入,并且只能整体重置。设备不再在内部进行复杂的地址映射和垃圾回收,而是将这些职责交给主机端的软件,例如文件系统或存储引擎。由于主机端更了解数据的生命周期,可以将具有相似生命周期的数据放在同一个区域中,整体失效后直接重置,从而大幅降低写放大,减少设备内部所需的冗余空间,并获得更可预测的性能。这需要文件系统或应用采用日志结构式的设计来适配。

八、网络与分布式文件系统

网络文件系统

网络文件系统允许一台计算机通过网络访问另一台计算机上的文件,并且使用方式与访问本地文件相同。客户端的虚拟文件系统层将文件操作转换为网络请求,发送给文件服务器,服务器在本地文件系统上执行这些操作并返回结果。

网络文件系统面临的主要挑战是一致性与性能的权衡:

  • 为了获得较好的性能,客户端需要缓存文件的数据和属性,避免每次访问都经过网络;
  • 但当多个客户端同时访问同一个文件时,一个客户端的修改,其他客户端可能无法立即看到。

不同的网络文件系统采用了不同的一致性模型。一种常见的模型是打开时一致性:客户端在打开文件时检查服务器上的文件是否有更新,关闭文件时将修改写回服务器。这保证了顺序打开同一文件的客户端之间能够看到彼此的修改,但不保证同时打开同一文件的客户端之间的一致性。需要更强一致性的应用,需要借助文件锁或其他同步机制。

分布式文件系统

当数据量达到 PB 级别,单台服务器无法满足存储容量和访问带宽的需求时,就需要分布式文件系统,将数据分散存储在大量的服务器上。

一类经典的分布式文件系统架构包括:

  • 一个元数据服务,负责管理文件系统的命名空间、文件到数据块的映射、数据块所在的位置;
  • 大量的数据节点,负责实际存储数据块;
  • 客户端从元数据服务获取文件的块位置后,直接与数据节点进行数据传输,大量的数据流量不经过元数据服务。

为了保证可靠性,每个数据块通常会在多个数据节点上保存副本,或者使用纠删码以较低的存储开销提供冗余。当某个节点故障时,系统会自动在其他节点上补充副本。

这类系统针对大文件的顺序读写进行了优化,块的大小通常设置得很大,例如数十 MB 到上百 MB,以减少元数据的数量和元数据服务的负载。对于大量小文件的场景,元数据服务往往会成为瓶颈。为此,新一代的分布式文件系统会将元数据也分布到多个节点上,并借助分布式数据库或共识算法来管理。

对象存储

近年来,对象存储已成为存储海量非结构化数据的主要方式。与文件系统不同,对象存储提供的是一个扁平的命名空间:每个对象通过一个唯一的键来访问,对象一旦写入通常不可修改,只能整体替换;访问通过基于 HTTP 的接口进行,而不是传统的文件系统调用。

对象存储放弃了文件系统的许多复杂特性,例如层次化的目录、原地修改、文件锁、严格的 POSIX 语义,换来了极高的可扩展性、可用性和较低的成本。许多大数据和数据湖系统都以对象存储作为底层的存储。

需要注意的是,对象存储不是文件系统,将其模拟为文件系统使用时,诸如重命名目录、追加写入、列出大量文件等操作,可能非常低效,甚至不具备原子性。

九、实践建议

根据工作负载选择文件系统。 不同的文件系统在不同的工作负载下表现差异很大:大量小文件、少量大文件、频繁的随机写入、需要快照与校验、对延迟的要求等,都会影响选择。在做出选择之前,应当用接近实际的负载进行测试。

正确地实现持久化。 需要可靠保存数据的应用,必须理解写入与持久化之间的区别,在必要的地方调用 fsync,新建文件后同步父目录,使用"写入临时文件、同步、重命名、同步目录"的模式原子地替换文件,并谨慎处理 fsync 的错误。

为空间预留余量。 当文件系统接近写满时,许多文件系统的性能会显著下降,碎片会加剧,写时复制文件系统甚至可能无法正常工作。应当监控磁盘的使用率,保持足够的空闲空间。

注意索引节点的耗尽。 在一些文件系统中,索引节点的数量在创建文件系统时就已经确定。如果存储大量的小文件,可能在磁盘空间还很充足时,就耗尽了所有的索引节点,无法再创建新文件。

避免单个目录中存放过多文件。 尽管现代文件系统能够高效地处理大目录,但许多工具和操作,例如列出目录内容、备份、同步,在面对包含数百万个文件的目录时仍然会变得缓慢。可以按照文件名的哈希前缀或日期等方式,将文件分散到多级子目录中。

善用快照与校验。 对于重要的数据,使用支持快照和端到端校验的文件系统或存储方案,并定期进行数据巡检,及时发现静默数据损坏。

快照不等于备份。 快照与原始数据位于同一个存储系统中,如果整个存储系统损坏,快照也会随之丢失。重要数据仍然需要备份到独立的存储系统,最好是异地的存储中。

结语

文件系统是操作系统中最基础、也最精巧的组件之一。它在原始的块设备之上,构建出了文件与目录这一直观而强大的抽象,让应用程序可以用名称来组织和访问数据,而不必关心数据在物理介质上的具体位置。

在这一抽象之下,文件系统需要解决一系列困难的问题:用索引节点、区段和目录组织数据与元数据;通过块组、延迟分配等策略提高局部性、减少碎片;借助日志或写时复制,确保在任何时刻崩溃后都能恢复到一致的状态;利用页缓存提升性能,同时为需要持久化保证的应用提供明确的同步语义;用端到端的校验和发现存储设备的静默损坏;并随着存储介质从机械硬盘演进到闪存,在设计上不断调整。

对于应用开发者而言,理解文件系统的工作原理,尤其是缓存与持久化的语义,是编写可靠软件的重要基础。许多数据丢失和损坏的问题,并不是因为文件系统本身出了错,而是因为应用对"写入成功"的含义存在误解。了解文件系统真正保证了什么、没有保证什么,才能在正确的地方采取正确的措施,让数据真正安全地保存下来。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。