文件系统:数据如何在磁盘上安家
在计算机中保存一份文档、一张照片或一段代码,看起来只是一次简单的"保存"操作。但在这背后,操作系统需要回答一系列问题:文件的内容应该写在磁盘的什么位置?文件名如何与内容对应起来?如何在海量文件中快速找到某一个?磁盘上的空间如何分配和回收?写入到一半时突然断电,数据会不会损坏?多个程序同时读写同一个文件会发生什么?
负责回答这些问题的,是文件系统(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 树来组织目录项,使得查找一个名称的时间与目录大小基本无关,或仅呈对数增长。
路径解析
当程序打开一个路径,例如一个位于多层目录下的文件时,文件系统需要逐级进行路径解析:
- 从根目录的索引节点开始,根目录的索引节点编号是一个固定的值;
- 读取根目录的内容,在其中查找第一级目录名,得到其索引节点编号;
- 读取该目录的索引节点和内容,查找下一级名称;
- 依此类推,直到找到最终的文件。
在每一步,文件系统还需要检查当前进程对该目录是否有进入的权限。
路径解析可能涉及多次磁盘读取。为了加速这一过程,操作系统会在内存中维护一个目录项缓存,缓存最近解析过的路径组件与索引节点之间的对应关系,大部分路径解析可以直接在缓存中完成。
硬链接与符号链接
由于文件名存储在目录中,而不是索引节点中,同一个索引节点可以被多个目录项引用,这就是硬链接(Hard Link)。多个硬链接是完全平等的,它们都指向同一份数据和元数据,没有"原始"与"副本"之分。
索引节点中的硬链接计数记录了指向它的目录项数量。删除一个文件时,实际上只是删除了一个目录项,并将链接计数减一。只有当链接计数降为零,并且没有任何进程打开着这个文件时,文件系统才会真正释放其索引节点和数据块。这也是为什么在类 Unix 系统中,可以删除一个正在被程序使用的文件,而该程序仍然能够继续读写它:文件的数据在程序关闭它之前不会被释放。
符号链接(Symbolic Link,也称软链接)则是一个独立的特殊文件,其内容是另一个路径名。访问符号链接时,文件系统会读取其中存储的路径,并继续解析该路径。符号链接可以跨越不同的文件系统,也可以指向目录,但如果其目标被删除或移动,符号链接就会失效,成为一个悬空的链接。
三、空间分配
空间分配的目标
文件系统在为文件分配数据块时,需要考虑以下几个目标:
- 局部性:同一个文件的数据块应当尽量连续存放,同一个目录中的文件也应当尽量靠近存放,以减少读取时的寻道和随机访问;
- 减少碎片:避免空闲空间被切割成大量零散的小块,导致后续无法分配连续的空间;
- 分配效率:查找空闲块的操作应当快速。
块组
对于机械硬盘,磁头在不同磁道之间移动的寻道时间,是访问延迟的主要来源。早期的文件系统将所有的索引节点集中存放在磁盘的开头,而数据块分布在整个磁盘上,读取一个文件时,需要先到磁盘开头读取索引节点,再长距离寻道到数据所在的位置,效率很低。
一种经典的改进是将磁盘划分为多个块组(Block Group,也称柱面组)。每个块组都有自己的位图、索引节点表和数据块。文件系统在分配时遵循一些启发式规则:
- 尽量将一个文件的索引节点和数据块分配在同一个块组中;
- 尽量将同一个目录中的文件放在同一个块组中;
- 将新的子目录分散到不同的块组中,以平衡各块组的负载。
这些策略显著提高了文件访问的局部性。
延迟分配
延迟分配(Delayed Allocation)是现代文件系统中一项重要的优化。
当程序写入数据时,文件系统并不立即为其分配磁盘块,而是先将数据保存在内存的页缓存中,并预留相应的空间额度。等到数据真正需要写回磁盘时,再进行实际的块分配。
这样做的好处是:在实际分配时,文件系统已经知道了该文件需要写入的全部数据量,可以一次性为其分配一段连续的空间,而不是在每次小的写入时零散地分配。对于那些很快就被删除的临时文件,甚至可以完全不分配磁盘块。
延迟分配也带来了一个副作用:在实际分配和写回之前,数据只存在于内存中。如果此时系统崩溃,这部分数据会丢失。这一点在后面讨论数据持久化时会进一步说明。
预分配
一些应用,例如数据库和虚拟机镜像,需要一个大文件并会在后续逐步写入其内容。如果在写入时才逐步分配空间,文件可能会变得碎片化,并且在空间不足时写入会失败。
文件系统通常提供预分配接口,允许应用在写入之前,一次性为文件预留指定大小的空间。文件系统会尽量为其分配连续的块,并标记为已分配但尚未写入数据。
稀疏文件
稀疏文件是指文件中存在一些从未写入过数据的"空洞"。例如,创建一个文件后,直接定位到 1GB 的位置写入一个字节,文件的大小为 1GB,但中间的部分从未写入过。
文件系统不会为这些空洞分配实际的磁盘块。读取空洞部分时,文件系统直接返回零。这使得稀疏文件的逻辑大小可以远大于其实际占用的磁盘空间,在虚拟机磁盘镜像等场景中非常有用。
四、崩溃一致性
问题的由来
文件系统中的许多操作,需要同时修改磁盘上的多个数据结构。以向一个文件追加一个数据块为例,需要:
- 在空闲块位图中,将一个块标记为已使用;
- 更新文件的索引节点,添加指向新块的指针,并增大文件大小;
- 将数据写入新分配的块。
这三处修改位于磁盘的不同位置,无法通过一次原子的写入完成。如果在执行过程中发生断电或系统崩溃,可能只有其中一部分写入成功,导致磁盘上的数据结构处于不一致的状态:
- 只更新了位图:一个块被标记为已使用,却没有任何文件引用它,这块空间就泄漏了;
- 只更新了索引节点:索引节点指向一个块,但位图中这个块仍然是空闲的,之后可能被分配给另一个文件,两个文件共享同一个块,数据相互覆盖;同时,块中的内容可能是之前其他文件留下的旧数据,造成数据泄露;
- 只写入了数据:数据写入了磁盘,但没有任何结构引用它,相当于写入从未发生,不会造成损坏,但数据丢失了;
- 更新了位图和索引节点,但数据尚未写入:文件指向了一个块,但块中是无意义的旧数据。
此外,存储设备和操作系统为了提高性能,可能会重新排序写入操作,程序发出写入的顺序,并不一定是数据实际落到磁盘上的顺序,这使得问题变得更加复杂。
崩溃一致性(Crash Consistency)问题,就是要保证文件系统在任何时刻崩溃后,都能恢复到一个一致的状态。
文件系统检查
早期文件系统的做法是:允许不一致发生,在系统重启后,运行一个文件系统检查工具扫描整个文件系统,发现并修复不一致的地方。
检查工具会遍历所有的索引节点和目录,重新计算哪些块被使用、每个索引节点的链接计数是多少,与磁盘上记录的信息进行比对,修正位图和计数,处理被多个文件引用的块,将找不到所属目录的索引节点放入一个专门的目录中。
这种方式的问题在于:
- 速度太慢:检查需要扫描整个文件系统的元数据,耗时与文件系统的大小成正比。对于 TB 级别的大容量磁盘,一次检查可能需要数小时,在此期间系统无法使用;
- 只能保证元数据一致:检查工具能修复结构上的不一致,但无法知道文件内容是否正确,例如上面提到的索引节点指向了包含旧数据的块,检查工具无法发现。
随着磁盘容量的不断增长,这种方式变得越来越难以接受。
日志
日志(Journaling),也称预写日志,是现代文件系统解决崩溃一致性问题的主流方法,其思想与数据库中的预写日志相同。
文件系统在磁盘上划出一块专门的区域作为日志。在对文件系统的实际结构进行修改之前,先将要进行的修改写入日志:
- 写入日志:将本次操作需要修改的所有块,例如位图块、索引节点块、数据块,连同一个事务开始标记,写入日志区域;
- 提交:所有内容写入日志后,写入一个事务提交标记。提交标记写入完成,即表示这个事务已经提交;
- 检查点:将日志中记录的修改,写入它们在文件系统中的实际位置;
- 释放:实际位置写入完成后,日志中这个事务所占的空间可以被回收复用。
如果系统在不同的阶段崩溃,恢复时的处理方式不同:
- 在提交标记写入之前崩溃:这个事务不完整,恢复时直接忽略,文件系统的实际结构没有被修改过,仍然是一致的;
- 在提交之后、检查点完成之前崩溃:恢复时,重新读取日志中所有已提交的事务,将其中的修改重新写入实际位置,这称为重放。由于重放的内容是完整的块镜像,重复写入多次也不会出错。
恢复只需要扫描日志区域,而日志的大小是有限的,恢复时间通常只有几秒钟,与文件系统的总大小无关。
为了保证正确性,写入的顺序至关重要:提交标记必须在事务的其他内容全部写入磁盘之后才能写入;检查点必须在提交标记写入磁盘之后才能进行。文件系统通过写屏障或缓存刷新命令,强制要求存储设备按照指定的顺序将数据持久化,而不能随意重排。
日志的模式
将所有的数据块都写入日志,意味着每份数据都要写两次:一次写入日志,一次写入实际位置,写入量翻倍。为了减少这一开销,文件系统通常提供几种不同的日志模式:
- 数据日志模式:元数据和文件数据都写入日志。一致性保证最强,但性能开销最大;
- 有序模式:只有元数据写入日志,文件数据直接写入其最终位置,但文件系统保证在相关的元数据事务提交之前,先将文件数据写入磁盘。这样,即使发生崩溃,也不会出现元数据指向包含旧数据的块的情况。这是许多文件系统的默认模式,在一致性与性能之间取得了较好的平衡;
- 回写模式:只有元数据写入日志,对文件数据的写入顺序不做保证。性能最好,但崩溃后,文件可能包含过时的或无关的数据。
软更新
除了日志之外,还有一种称为软更新(Soft Updates)的方法,它不使用额外的日志,而是通过精心安排元数据写入的顺序,保证磁盘上的结构在任何时刻都不会出现危险的不一致。例如,总是先写入数据块,再写入指向它的索引节点;先初始化索引节点,再写入引用它的目录项。在这种顺序下,崩溃最多只会导致一些空间泄漏,可以在后台慢慢回收,而不会导致数据损坏。
软更新避免了日志的双写开销,但需要追踪复杂的块之间的依赖关系,实现难度很大。
五、写时复制文件系统
基本思想
日志文件系统通过"先记录、再修改"的方式实现一致性,但它仍然是原地更新的:数据最终会被写回其原来的位置,覆盖旧的内容。
写时复制(Copy-on-Write,COW)文件系统采用了完全不同的方法:永远不覆盖已有的数据。
当需要修改一个数据块时,写时复制文件系统将修改后的内容写入一个新的、空闲的位置。然后,需要更新指向这个块的父节点。但父节点同样不能原地修改,于是也将修改后的父节点写入一个新的位置。这一过程沿着树向上传播,一直到达树的根节点。
最后,文件系统通过一次原子的写入,更新一个记录当前根节点位置的超级块,使其指向新的根节点。
在这次原子更新之前,磁盘上的旧树仍然完整无损,新树的各个部分虽然已经写入,但尚未被引用;在原子更新之后,新树生效,旧树中不再被引用的块可以被回收。无论在哪个时刻崩溃,文件系统看到的要么是完整的旧状态,要么是完整的新状态,永远不会处于不一致的中间状态。
快照
写时复制带来的一个重要特性是几乎零成本的快照(Snapshot)。
创建快照时,只需保留当前根节点的一个引用,阻止其对应的旧数据块被回收即可,不需要复制任何数据。此后,对文件系统的修改都会写入新的位置,快照所引用的旧版本数据保持不变。快照与当前文件系统之间共享所有未被修改的数据块,只有被修改的部分才会占用额外的空间。
快照可以用于:
- 在进行系统升级或配置变更之前创建快照,出现问题时快速回滚;
- 定期创建快照,保留历史版本,应对误删除或勒索软件;
- 基于快照进行一致的备份,备份过程中文件系统仍可正常使用;
- 计算两个快照之间的差异,只传输变化的数据,实现高效的增量备份和远程复制。
类似地,写时复制文件系统还可以高效地实现克隆:创建一个文件或目录树的可写副本,初始时与原数据共享所有块,只有在修改时才分配新的空间。
数据完整性校验
写时复制文件系统通常还会为每个数据块和元数据块计算校验和,并将校验和存储在指向该块的父节点中,而不是块本身。
这样,从根节点开始,每一个块的校验和都由其父节点保证,形成了一棵默克尔树。读取任何一个块时,文件系统都会计算其校验和,与父节点中记录的值进行比较。如果不匹配,说明数据已经损坏。
将校验和存储在父节点中,而不是与数据存放在一起,非常重要:这能够检测出一类特别隐蔽的错误,即存储设备将数据写到了错误的位置,或者读取时返回了错误位置的数据。如果校验和与数据存放在一起,这类错误中数据和校验和会一起出错,彼此仍然匹配,无法被发现。
存储设备并非完全可靠,会出现静默数据损坏:数据在存储期间由于介质老化、固件缺陷、宇宙射线等原因发生了比特翻转,而设备本身没有报告任何错误。传统的文件系统无法发现这类损坏,会将损坏的数据当作正确的数据返回给应用程序。端到端的校验和使得这类损坏能够被及时发现。
如果文件系统同时管理多块磁盘,并配置了镜像或奇偶校验等冗余,在检测到某个副本损坏时,还可以从其他副本中读取正确的数据,并自动修复损坏的副本。文件系统还可以定期在后台进行数据巡检,主动读取并校验所有数据,在损坏影响到使用之前就发现并修复它。
写时复制的代价
写时复制也有其代价:
- 碎片化:由于每次修改都写入新的位置,一个原本连续的文件在经历多次随机修改后,其数据块会散布在磁盘各处。对于数据库文件、虚拟机镜像这类频繁进行小的随机写入的大文件,碎片化尤为严重,会影响后续的顺序读取性能。为此,一些写时复制文件系统允许对特定文件关闭写时复制;
- 写放大:修改一个小的数据块,需要沿着树向上重写一系列的元数据块。文件系统通常会将一段时间内的多次修改合并到同一次提交中,以分摊这一开销;
- 空间管理复杂:由于快照和克隆的存在,一个块可能被多个版本共享,判断一个块是否可以被释放变得复杂,需要维护引用计数或其他的追踪机制。磁盘接近写满时,写时复制文件系统的性能可能会急剧下降,因为每次写入都需要寻找新的空闲空间。即使是删除文件,也需要写入新的元数据,在空间完全耗尽时甚至可能无法删除文件;
- 内存需求:一些写时复制文件系统为了获得良好的性能,需要较多的内存用于缓存元数据。
日志结构文件系统
日志结构文件系统(Log-Structured File System)将写时复制的思想推向了极致:将整个磁盘视为一个只追加的日志,所有的写入,无论是数据还是元数据,都以顺序追加的方式写入日志的末尾。
这种设计的出发点是:随着内存容量的增大,大部分读请求可以由缓存满足,磁盘的主要负载来自写入;而顺序写入的性能远高于随机写入。将所有写入转化为顺序写入,可以充分发挥磁盘的带宽。
其主要挑战在于垃圾回收:随着文件被修改和删除,日志中早期写入的许多块会失效。文件系统需要在后台进行段清理,选择一些包含较多失效块的日志段,将其中仍然有效的块复制到日志末尾,然后回收整个段。清理的开销在磁盘使用率较高时会变得很大。
日志结构的思想对后来的许多存储系统产生了深远的影响,包括 LSM 树存储引擎、固态硬盘内部的闪存转换层,以及专为闪存设计的文件系统。
六、缓存与持久化语义
页缓存
磁盘的访问速度远慢于内存,因此操作系统会将文件的内容缓存在内存中,称为页缓存(Page Cache)。
- 读取时,如果数据已在页缓存中,直接从内存返回,无需访问磁盘;如果不在,则从磁盘读取并放入页缓存。操作系统还会检测顺序读取的模式,提前读入后续的数据,称为预读;
- 写入时,数据通常只是被写入页缓存,并将相应的页面标记为"脏",写入系统调用随即返回。脏页会在稍后由内核的后台线程批量写回磁盘,称为回写。
延迟写回能够合并多次对同一位置的写入,将零散的写入聚合为较大的顺序写入,显著提升写入性能。但这也意味着:写入系统调用成功返回,并不代表数据已经持久化到磁盘上。如果在数据写回之前系统崩溃或断电,这些数据就会丢失。
fsync
为了确保数据真正持久化,应用程序需要显式地调用 fsync 系统调用。它会阻塞,直到该文件所有已修改的数据和元数据都写入存储设备,并要求设备将其内部的写缓存也刷新到持久化介质中。
fsync 的开销很大,可能需要数毫秒甚至更长的时间。频繁调用 fsync 会严重影响写入性能。因此,需要可靠持久化的应用,例如数据库,通常会将多个事务的日志写入合并在一起,统一调用一次 fsync,这称为组提交。
还有一个与之相关的系统调用 fdatasync,它只保证文件数据以及读取数据所必需的元数据,例如文件大小,被持久化,而不强制写回修改时间等不影响数据读取的元数据,开销通常略小。
持久化的陷阱
正确地实现数据持久化,比看上去要困难得多,存在许多容易被忽视的细节。
新建文件需要同步目录。 当创建一个新文件并对其调用 fsync 后,文件的内容和索引节点已经持久化了,但指向该文件的目录项是存储在父目录中的。如果没有对父目录也调用 fsync,崩溃后文件的数据可能存在于磁盘上,却在目录中找不到它。
原子地替换文件。 一个常见的需求是安全地更新一个配置文件:要么保留完整的旧版本,要么得到完整的新版本,不能出现写了一半的内容。正确的做法是:
- 将新内容写入一个临时文件;
- 对临时文件调用 fsync,确保其内容已经持久化;
- 使用 rename 将临时文件重命名为目标文件名。在大多数文件系统上,rename 对于同一文件系统内的操作是原子的:目标路径要么指向旧文件,要么指向新文件;
- 对父目录调用 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 的错误。
为空间预留余量。 当文件系统接近写满时,许多文件系统的性能会显著下降,碎片会加剧,写时复制文件系统甚至可能无法正常工作。应当监控磁盘的使用率,保持足够的空闲空间。
注意索引节点的耗尽。 在一些文件系统中,索引节点的数量在创建文件系统时就已经确定。如果存储大量的小文件,可能在磁盘空间还很充足时,就耗尽了所有的索引节点,无法再创建新文件。
避免单个目录中存放过多文件。 尽管现代文件系统能够高效地处理大目录,但许多工具和操作,例如列出目录内容、备份、同步,在面对包含数百万个文件的目录时仍然会变得缓慢。可以按照文件名的哈希前缀或日期等方式,将文件分散到多级子目录中。
善用快照与校验。 对于重要的数据,使用支持快照和端到端校验的文件系统或存储方案,并定期进行数据巡检,及时发现静默数据损坏。
快照不等于备份。 快照与原始数据位于同一个存储系统中,如果整个存储系统损坏,快照也会随之丢失。重要数据仍然需要备份到独立的存储系统,最好是异地的存储中。
结语
文件系统是操作系统中最基础、也最精巧的组件之一。它在原始的块设备之上,构建出了文件与目录这一直观而强大的抽象,让应用程序可以用名称来组织和访问数据,而不必关心数据在物理介质上的具体位置。
在这一抽象之下,文件系统需要解决一系列困难的问题:用索引节点、区段和目录组织数据与元数据;通过块组、延迟分配等策略提高局部性、减少碎片;借助日志或写时复制,确保在任何时刻崩溃后都能恢复到一致的状态;利用页缓存提升性能,同时为需要持久化保证的应用提供明确的同步语义;用端到端的校验和发现存储设备的静默损坏;并随着存储介质从机械硬盘演进到闪存,在设计上不断调整。
对于应用开发者而言,理解文件系统的工作原理,尤其是缓存与持久化的语义,是编写可靠软件的重要基础。许多数据丢失和损坏的问题,并不是因为文件系统本身出了错,而是因为应用对"写入成功"的含义存在误解。了解文件系统真正保证了什么、没有保证什么,才能在正确的地方采取正确的措施,让数据真正安全地保存下来。
- 点赞
- 收藏
- 关注作者
评论(0)