Skip to content

Lec 18 隔离性

阅读参考书 §9.4 中 §9.4.1之前的内容, §9.5

阅读论文:The Zettabyte File System

上节lec, 我们基本实现了通过Logging为单台机器上的单个用户工作。这节Lec解决单台上的多用户提供服务。这里需要用到隔离性, 隔离性的实现主要由2阶段锁提供。

目标: 并发执行事务T1, T2 ... Tn,要使得它看起来像顺序执行一般

思考题

  • 从高层次看,隔离性意味着什么?
  • 为什么串行运行调度(而不是并行)会导致性能不佳?
  • 冲突可序列化性(Conflict Serializability)
    • 两个操作什么情况下会发生冲突?
    • 如何为一组事务调度创建冲突图?
    • 冲突图对调度的冲突可序列化性有什么作用?
  • 两阶段锁(2PL)
    • 2PL 的目标是什么?
    • 它有哪两个阶段?
    • 2PL 的工作原理是什么?包括初始版本和带有读写锁定的版本。如何判断是否符合2PL?
      • 为什么添加读写锁定可以提高性能?
    • 2PL 可能导致死锁。发生死锁时我们可以采取什么措施?

Outline

  • 隔离性示例
  • 冲突可串行化
  • 两阶段锁
  • 论文阅读:ZFS

示例

T1 
begin 
T1.1 read(x) 
T1.2 tmp = read(y) 
T1.3 write(y, tmp+10) 
commit


T2 
begin 
T2.1 write(x, 20) 
T2.2 write(y, 30) 
commit

(assume x, y initialized to zero)

要使得它看起来像顺序执行一般,最简单粗暴的方法,加一个全局锁,让他们串行执行。

image-20250416121648121

来看一下稍微不一样的调度。

截屏2024-07-02 15.58.03

中间的调度结果,不属于串行调度的任何一种。第三个调度下,首先,T1.1读取x=0 ,在第四步下 T1.2才读取y=30,也不属于串行调度的任何一种。

这种情况是否可以接受?

取决于应用需求:根据不同的应用场景,对于并发执行的需求也不同。在某些应用中,严格的可串行化性是必须的,而在另一些应用中,允许一定程度的并发并不影响最终结果的正确性,因此可以接受更松的可串行化性。

冲突可串行化

冲突(conflict)定义:如果两个操作作用于同一对象且其中至少有一个是写操作,那么这两个操作就存在冲突。在任何调度中,两次冲突操作A和B将有一个顺序:要么A在B之前执行,要么B在A之前执行。我们将此称为(在该调度中的)冲突顺序(order of conflicts)

image-20250416122946607

请注意,如果我们串行地执行T1和T2,那么在冲突的排序中,我们看到的要么是T1的所有操作先发生,要么是T2的所有操作先发生。

截屏2024-07-12 19.56.13

我们看下面的不一样的调度。左边的冲突顺序,如同T2完全发生在T1之前;而右边的冲突顺序,与任何一个串行调度都不一样。

截屏2024-07-12 20.01.04目标:并发地运行事务,但要生成一个冲突可串行化的调度。

系统如何做到这一点呢?一种方法可能是生成所有可能的调度并检查它们的冲突图,然后运行其中一个具有无环冲突图的调度,但这将花费一些时间。

冲突图示例:

image-20250416123531152

两阶段锁

  1. 每个共享变量都有一个锁
  2. 在对变量进行任何操作之前,事务必须获取相应的锁。
  3. 在事务释放锁之后,它不能再获取任何其他锁。

image-20250416124503318

需要注意的是,采用这种两阶段锁(2PL)的方式,实际上会迫使这两个事务串行执行。目前还存在一些与潜在死锁和性能相关的遗留问题,这些问题我们稍后会处理。

现在首先来理解为什么2PL能产生一个冲突可串行化的调度。

Proof:(反证法) 假设不能够产生,则在冲突图上存在一个有向环。为了造成冲突,每对冲突事务必须有共享变量。接着每个事务需要获取该共享变量的锁,冲突的顺序也告诉我们,哪个事务先获得了锁。为了让下图调度继续进行,T1 必须在 T2 获取 x1 的锁之前就已经释放了对 x1 的锁。 但是,T1在释放了一个x1的锁之后,又获取了另外一个锁xk,与假设的2PL调度的相矛盾。因此,2PL能产生一个冲突可串行化的调度。

截屏2024-07-12 20.23.56

死锁问题

  • 解决这个问题的一种方法是对所有锁施加全局排序。
  • 更好的解决方案是利用原子性,直接中止其中一个事务。我们可以通过多种方式检测死锁 —— 比如,构建一个事务之间互相等待的图,并据此判断,或者在某个事务在尝试获取锁时被阻塞的那一刻就立即中止它。

这些技术各有性能上的权衡:性能更好的方法通常也会在某些时候中止其实本不需要中止的事务。

性能问题

上述的方法会迫使事务串行执行,这会带来并行度不高,影响性能。带有读/写锁的两阶段锁能允许并发读操作。

具体协议为:

  1. 每个共享变量都有2个锁:一个用来读,一个用来写
  2. 在对变量进行任何操作之前,事务必须获取适当的锁。
  3. 多个事务能够持有对相同变量的读锁;一个事务只能在变量没有被持有读锁情况下,才能持有其写锁
  4. 在事务释放锁之后,它不能再获取任何其他锁。

image-20250416131956145

读锁(共享锁)不会修改数据,持有时间不需要像写锁那样一直到提交;提前释放读锁可以减少锁竞争,提升并发性能;当然,前提是读锁的释放不会影响事务的正确性(比如不会导致读到脏数据)

补充

与读捕获策略(Read-Capture Discipline)类似,2PL不要求事务提前知道需要获取哪些锁。2PL被广泛使用,但是正确性比简单锁更难证明。该策略允许事务在执行过程中动态获取锁,并且只要获取了某个数据对象的锁,事务就可以立即读取或写入该对象。该策略的核心约束是:一、在事务到达锁点(lock point)之前,不能释放任何锁;二、事务到达锁点后,如果事务对某个数据对象进行了读操作、且不再需要访问该对象(即使是为了执行回滚操作)、则可以随时释放该对象的锁。

”两阶段“是指事务获取和释放锁的过程分为两个阶段:

  • 增长阶段(Growing Phase):在事务到达锁点之前,事务可以不断获取锁,但不能释放任何锁。
  • 收缩阶段(Shrinking Phase):在事务到达锁点之后,事务可以释放已持有的锁,但不能再获取新锁

与简单锁一样,两阶段锁通过对并发事务进行排序,使其执行结果等价于某种串行执行顺序,并且该顺序由事务达到锁点的先后决定。

两阶段锁的实现,锁管理器可以通过拦截所有数据读写操作来实现两阶段锁:

  1. 第一次使用共享变量时,事务会请求相应的锁(如果锁被其他事务占用,需要等待锁释放)。
  2. 锁在事务提交、会馆或记录END日志时,同一释放事务持有的所有锁

两阶段锁提供了比简单锁更大的并发性,但其正确性更难证明。非正式地说,两阶段锁保证了“前后原子性”(Before-or-After Atomicity),基于以下两点直观理解:

  1. 锁定数据后读取的值,在事务达到锁点前不会发生变化,因此立即读取与等待至锁点再读取具有相同的结果。
  2. 在锁点之后,如果事务对某个数据对象仅执行了读取操作且不再需要该对象,释放该锁是安全的,不会影响事务的原子性。

两阶段锁的局限性。 尽管能提供更高的并发性,但是仍然会不必要地则色某些可序列化的事务执行顺序。例如事务T1: 读取X并写入Y; 事务T2: 仅执行对Y的写入(即“盲写”),假设顺序是1,T1读X;2,T2写Y;3,T1写Y,这个没问题,但是根据两阶段锁的要求,事务T2和T1 都需要获取Y的锁,导致他们必须严格按照顺序执行。设计一种既能最大限度支持并发、又能确保前后原子性的锁策略是非常困难的,是NP完全问题。

锁和日志在处理事务中止(Abort)和系统恢复(System Recovery)时有两个需要特别注意的交互问题。

事务中止的处理。因为我们要求中止的事务必须在释放任何锁之前恢复数据对象的原始值。因此确保事务不提前释放修改对象的锁,如果在事务回滚前之前释放了锁,其他事务可能会修改该数据对象,则将无法正确回滚。

系统恢复处理,主要涉及到,锁是否需要持久化? 通常来说不需要持久化,因为在系统崩溃后,所有未完成的事务将在恢复过程回滚,恢复完成之前,系统不允许新事务启动。因此,锁的存在仅用于协调活动事务,恢复完成后,不应再有任何锁。这一结论表明,锁应保存在易失性存储中(如内存),而不应记录在持久化日志中。

根据恢复算法(如图 9.23),在崩溃发生时:

  1. 任何未完成的事务必然持有彼此不重叠的锁集。
  2. 恢复过程根据日志中的UNDO 和 REDO 操作恢复数据:
    • UNDO 以日志的逆序执行,回滚未完成事务的修改。
    • REDO 以日志的顺序执行,重做已提交事务的修改。

由于事务锁集不重叠,恢复过程中不会出现原子性破坏。因此,恢复算法可以精确地重建事务原始执行的序列化顺序。

论文阅读: ZFS

摘要

本文我们介绍一个新的文件系统, 他提供数据强完整性保证,简单的管理和极大的容量。我们展示了,通过对传统高级文件系统架构做出一些改变——包括重新设计文件系统与卷管理器之间的接口、引入存储池机制、采用事务性写时复制模型(transactional copy-on-write)以及自校验的校验和机制(self-validating checksums),我们可以消除许多传统文件系统的弊端。

我们进一步描述了一种基于这种新架构的通用、可用于生产环境的文件系统,即 Zettabyte 文件系统(ZFS)。这种新架构降低了实现的复杂度,带来了新的性能提升。

1. 介绍

2. 设计原理

我们根据“强数据完整性、简化管理以及巨大容量”这三大目标,描述了我们在设计 ZFS 时采用的设计原则。

2.1 简化管理

在大多数系统中,对磁盘进行分区、创建逻辑设备以及创建新文件系统是一件既繁琐又缓慢的事情。这些操作通常不常见,通常只由系统管理员执行,因此外界对简化和加快这些任务的呼声并不高。但与此同时,在执行这些管理任务时,发生错误或意外却很常见,而且往往是容易犯错且可能迅速导致大量数据丢失的操作。正因为这些操作并不频繁,人们也就很难真正成为这类任务的专家。

随着越来越多的人开始“自己做系统管理员”,文件系统的设计者不能再默认坐在键盘前的人是合格的专业人员。存储管理应该被更大程度地简化和自动化。手动配置磁盘空间的过程应当是非必需的,如果用户确实需要进行配置,也应该是简单、直观且快速的。管理员应当能够在不中断文件系统服务的前提下,轻松地为现有文件系统添加存储空间;同样,移除存储空间也应当是一个同样简单的过程。

我们的总体目标是:让管理员只需表达自己的意图(比如“创建一个新的文件系统”),而不需要亲自去执行具体的细节步骤(如找到空闲磁盘、分区、写入磁盘格式等)。在现有的文件系统之上增加更多的自动化层(例如 GUI 界面)并不能解决根本问题,因为文件系统本身才是管理的基本单元。靠用户界面隐藏文件系统分区的划分,并不能解决诸如文件超出分区大小、磁盘空间静态分配、或文件系统修复时数据不可用等底层问题。

2.2 存储池化

在现代文件系统中,一个文件系统通常对应一个特定的存储设备(或其部分),这种一对一的绑定关系是一项显著的设计特征。虽然卷管理器(volume manager)在一定程度上对底层存储进行了虚拟化,但最终,文件系统仍然是绑定在某个逻辑存储设备的一段特定块范围上。

这种绑定方式其实是违背直觉的,因为文件系统本身的目的就是为了对物理存储进行虚拟化。然而,在实际设计中,逻辑命名空间仍然被固定地绑定在某个(逻辑或物理)设备上。

为了更清楚地说明这个问题,我们可以类比内存的情况:想象一下,如果管理员每次添加内存后,都必须运行“formatmem”命令来对新内存分区,并手动把这些分区分配给各个应用程序——显然,这是不现实的。我们今天之所以不需要这样操作,是因为虚拟地址空间和动态内存分配器已经自动处理了这些事情。

同样地,文件系统也应该像虚拟内存一样,从物理存储中解耦出来。多个文件系统应当能够共享一个统一的存储池。文件系统中的存储分配功能应当移交给一个专门的“存储池分配器”(Storage Pool Allocator)来处理,这个分配器会在多个设备组成的存储池中分配永久性空间,响应文件系统的请求。

image-20250416210006178

图 1 中展示了传统的卷管理方法,而图 2 中展示的是我们倡导的存储池方法。尽管逻辑卷(logical volumes)已经向这一方向迈出了一步,但它们仍然本质上看起来像是一段需要先进行分区的字节范围。文件系统与卷管理器之间的接口将“分配”逻辑放在了错误的一侧,这导致了系统在进行文件系统扩展、收缩、共享空间、或迁移数据时面临很多困难。

image-20250416210014610

2.3 动态文件系统大小

如果一个文件系统只能使用来自其分区的空间,那么系统管理员在创建文件系统时就必须预测(也就是猜)这个文件系统未来可能的最大空间需求。 一些文件系统试图通过提供“增长”和“收缩”文件系统的工具来解决这个问题,但这些工具只能在特定条件下使用,而且速度慢,且需要手动运行(无法自动进行)。它们还依赖于具有扩展和收缩逻辑分区能力的逻辑卷管理器(LVM)。

除了“磁盘空间不足”的场景之外,这种方式也限制了在磁盘已经被完全分区时创建新文件系统。即使磁盘上还有大量未使用的空间,如果没有为新分区预留空间,那么就无法再创建新的文件系统。虽然如今磁盘容量的增长使得这个限制看起来没那么严重(用户可以“浪费”一些磁盘空间,因为许多系统并未完全利用所有容量),但磁盘空间的分区问题仍然是系统管理上的一大痛点。

一旦文件系统所使用的存储空间可以通过添加或移除设备进行动态扩展或缩减,下一步就是实现一个可以随着用户添加或删除数据而自动扩展或收缩的文件系统。 这个过程应当是全自动的,不需要管理员干预。当然,如果有需要,管理员可以对某个文件系统或一组文件系统设置配额(quota)或预留(reservation),以防止某个用户对存储池的“霸占式”使用。

由于文件系统不再具有固定大小,那么在创建文件系统时预先分配文件系统元数据(如 inode)的做法就不再合理。总之,这类事情本来就不该由管理员负责,应该由文件系统自己自动处理。例如,像 inode 这样的结构,应该随着文件的创建和删除而动态地进行分配和释放。

2.4 永远一致的磁盘数据

如今大多数文件系统仍允许磁盘上的数据在某些时刻处于不一致状态。如果在数据尚未一致的时刻发生了系统崩溃或突然断电,那么系统下一次启动时就必须对文件系统进行某种形式的修复。

例如:

  • 类似 FFS(Fast File System)风格的文件系统依赖 fsck 工具进行修复;
  • 元数据日志型文件系统依赖日志回放(log replay);
  • Soft Updates 技术会留下一些“泄漏”的块或 inode,这些资源必须在事后被回收;
  • 日志结构化文件系统(Log-Structured File Systems)会周期性创建一致性检查点(checkpoint),但频繁创建校验点(checksum)代价太高 。

为什么“开机后修复”不是一个理想的方案?举个例子,启动加载器(bootloader)通常需要读取根文件系统,以找到启动内核所需的文件。 如果必须先进行日志回放才能让文件系统变得足够一致以读取这些文件,那就意味着恢复逻辑也得嵌入在 bootloader 中。虽然使用 soft updates 的文件系统没有这个问题,但它们仍然需要在启动后进行某些修复活动,比如清理泄漏的 inode 和数据块。 此外,soft updates 的实现极其复杂,它要求对缓存数据之间的依赖关系非常了解 ,并需要支持部分元数据的回滚和前滚,还要对每个文件系统操作进行细致分析,以确定正确的更新顺序 。

最好的方式是:让磁盘上的数据在任何时候都保持一致,例如 WAFL 文件系统 就做到了这一点。为了实现这一目标,文件系统需要一种简单而安全的方式,在两个一致状态之间切换,不留下一丝可能因系统崩溃导致不一致的时间窗口。这个机制的实现必须尽量简单、健壮且通用,这样开发者在新增功能或修 bug 时不需要每次都思考如何维持一致性。

2.5 海量容量

许多现在仍在使用的文件系统使用的是 32 位的块地址,这通常限制了它们的最大容量只有几 TB(太字节)。但正如我们在引言中提到的,一个普通家用电脑就可以轻松配备几 TB 的存储,因此 32 位块地址已经远远不够用。一些新的文件系统改用了 64 位地址,这让它们可以支持大约 16 EB(2⁶⁴ 字节 = 16 exabytes)。这看上去很多,但请注意:

  • 如今 PB(2⁵⁰ 字节)级的数据集已经不罕见 [7];
  • 存储容量每 9~12 个月就翻一番 [21];
  • 只需再翻 14 番,就可以从 2⁵⁰ 到达 2⁶⁴,也就是说,16 EB 级别的数据可能在 10.5 年内出现。

一个文件系统的生命周期通常以“十年”为单位来计算,因此我们决定使用 128 位的块地址,为未来做好准备。不过,要想真正处理 16 EB 级别的数据,仅仅扩大地址位数还远远不够,还需要:

  • 可扩展的目录查找算法、
  • 元数据和数据块的分配机制、
  • I/O 调度策略,
  • 以及其他日常文件系统操作的高效算法。

磁盘上的数据格式(on-disk format)也必须格外小心设计,不能在某些方面天生限制系统的扩展性。也许这点不提也显而易见,但我们还是要强调: ZFS 不应该依赖 fsck 来维护磁盘数据的一致性。虽然 fsck(文件系统检查工具)已在学术界逐渐失宠,但很多生产环境下的文件系统仍依赖它进行常规维护。在不涉及硬件故障、人为误操作或其他严重异常的情况下,修复文件系统应避免任何 O(n) 级别的操作(即避免全盘扫描)。

2.6 错误检测与纠正

在理想世界中,磁盘永远不会损坏,硬件 RAID 永远没有 bug,读取总是返回写入时的数据。但现实世界不是这样 —— 固件也会有 bug。例如,磁盘控制器固件中的错误可能导致各种异常,包括:

  • 读取数据被定向到错误位置(misdirected reads)、
  • 写入数据写错位置(misdirected writes)、
  • 本不该发生的写操作却发生了(phantom writes)。

除了硬件故障,文件系统损坏也可能由软件或人为操作错误引起,例如:

  • 磁盘驱动的 bug,
  • 错误地把某个分区设为 swap 区。

在块设备接口层做校验只能捕获其中一部分问题。传统上,文件系统信任从磁盘读入的数据。但如果不进行校验,一旦数据被破坏,后果可能轻则返回错误数据,重则系统崩溃。因此,文件系统应当对读入的数据进行校验,以发现多种可能的文件系统损坏(尽管对于文件系统内部的 bug,它是无能为力的)。此外,如果可以自动修复这些错误,文件系统应当尝试修复,例如重新将正确的块写回磁盘。

2.7 卷管理器的集成

传统方法中,如果我们想要加入镜像等功能,通常做法是编写一个卷管理器(volume manager),它向文件系统导出一个逻辑块设备,看起来就像一个普通的物理磁盘。 这种方法的好处是:任意文件系统都可以和任意卷管理器配合使用,无需修改文件系统代码。

但这种设计有严重缺陷:块接口会抹去所有的语义信息,因此卷管理器必须非常保守地处理磁盘一致性问题。它无法了解各个块之间的依赖关系,也不知道哪些块是已分配的、哪些是空闲的于是不得不假设所有块都在使用并需要保持一致。更糟的是,它无法做任何基于高层语义的优化

实际上,很多文件系统早已配备了自己的卷管理器,例如:

  • VxFS 与 VxVM 配套使用,
  • XFS 与 XLV 配套使用,
  • UFS 与 SVM 配套使用。

为了提高整个存储栈的性能与效率,我们应当重新定义文件系统与卷管理器之间的接口,用一种比传统“块设备”更有用的方式进行通信。与此同时,这套新方案还应该足够轻量,在仅有单一磁盘设备的情况下,不应引入可感知的性能开销。

2.8 卓越性能

最后一点,但同样重要:性能必须卓越。高性能与丰富特性并不矛盾。我们在设计 ZFS 时从零开始,正因为如此,才有机会彻底摆脱几十年来遗留下来的笨重接口,重新设计每一个细节。影响性能的一个关键观察是:文件系统的瓶颈越来越集中在写入性能 [16, 8]。因此,块分配算法应该优先优化写入而不是读取。此外,文件系统应将许多小的写入操作合并成大的顺序写入,而不是将它们随机地散布在磁盘各处。

3. ZFS

Zettabyte 文件系统(ZFS)是一个通用文件系统,基于上一节所阐述的设计原则。ZFS 在 Solaris 操作系统上实现,目标是支持从桌面计算机到数据库服务器等各种系统。本节将对 ZFS 架构进行一个高层次的概述,并在介绍过程中说明其设计决策是如何对应于上一节中的原则的。

3.1 存储模型(Storage model)

ZFS 引入的最根本变革之一,是对系统软件各部分职能的重新划分。 传统文件系统的结构如图 3 左侧所示:

  • 设备驱动程序向卷管理器导出一个块设备接口;
  • 卷管理器再向文件系统导出一个块设备接口;
  • 文件系统最终通过 vnode 操作向系统调用层导出标准的文件接口。

ZFS 的结构如图 3 右侧所示:

  • 从底层开始,设备驱动程序向 存储池分配器(SPA) 导出一个块设备;
  • SPA 负责块的分配与 I/O,并向 数据管理单元(DMU) 导出具有虚拟地址、支持显式分配与释放的块;
  • DMU 再将这些 SPA 提供的虚拟地址块,转换为一个面向事务的对象接口;
  • 最后,ZFS POSIX 层(ZPL) 在 DMU 对象之上实现 POSIX 文件系统,并向系统调用层导出 vnode 操作。

这两个结构图按照功能对齐,大致可以看出传统文件系统与 ZFS 中各组件的对等关系。 需要特别指出的是,我们在 ZFS 中将传统意义上的“文件系统”功能拆分为两个独立部分:ZPL 与 DMU。 同时,我们也用“虚拟地址块接口”取代了传统文件系统与卷管理器之间的“块设备接口”。

3.2 存储池分配器(The Storage Pool Allocator)

SPA(Storage Pool Allocator) 负责从一个存储池中的所有设备中分配块。 一个系统可以拥有多个存储池,但大多数系统只需要一个。

与传统卷管理器不同,SPA 并不会将自己表现为逻辑块设备。 它提供的是一个接口,允许调用者分配与释放虚拟地址块 —— 本质上,就是磁盘空间上的 malloc()free()。 我们将磁盘块的虚拟地址称为 DVA(Data Virtual Address)

使用虚拟地址块能够轻松实现我们前述的几个设计原则。例如:

  • 当设备被动态添加或移除时,SPA 可以在不打断服务的情况下立即生效。
  • 在 SPA 之上的代码并不知道某个块具体位于哪个物理设备上,因此:
    • 添加新设备时,SPA 可立即从新设备中开始分配块;
    • 移除设备时,SPA 可以在后台将块迁移至其它设备,只需更新 DVA 的映射,无需通知其他模块。

SPA 同时也大大简化了系统管理。管理员不再需要创建逻辑设备或对存储进行分区配置,只需告诉 SPA 要使用哪些设备即可。 默认情况下,每个文件系统可以从其存储池中按需使用任意大小的空间。 如果需要,管理员可以通过设置配额(quota)与保留量(reservation)来限制单个或一组文件系统使用的最大 / 最小存储空间。

SPA 的容量设计足够“未来可期”: 它使用的是 128 位块地址,因此每个存储池最多可以寻址多达:

3.2.1 错误检测与纠正(Error detection and correction)

为了防止数据损坏,每个数据块在写入磁盘前都会生成校验和(checksum)。 该块的校验和会被存储在它的父间接块中(见图 4)。如 3.3 节将详细说明的那样,所有磁盘上的数据与元数据都以块的树结构存储,并以uberblock(超级块)为根。 uberblock 是唯一一个将自己的校验和存储在自身中的块

将校验和存放在块的父块中,有几个好处:

  • 数据与校验和在磁盘上是分离的,降低了它们被同时损坏的概率;
  • 使得校验和具备“自我验证性”,因为每个校验和也会被它的父块再校验一次;
  • 在访问数据块时,父间接块本来就会被读取,因此无需额外读取校验和

ZFS 支持可插拔的校验和函数模块;SPA 默认使用 64 位 Fletcher 校验和 [6]。

只要一个块从磁盘中读取或写出,ZFS 就会验证或更新其校验和。由于存储池中的所有数据(包括元数据)都在树结构中,因此 ZFS 写入磁盘的每一字节都有校验和保护

在某些情况下,校验和还能支持数据的“自我修复(self-healing)”:

  • 当 SPA 从磁盘读取一个块时,如果检测到数据损坏;
  • 且该存储池为镜像配置,那么 SPA 可以从另一个“好”的副本中读取数据,并自动修复损坏的那一份(前提是介质本身没有完全失效)。

3.2.2 虚拟设备(Virtual devices)

SPA 还实现了传统卷管理器中的常见功能,如条带化(striping),但实现方式更加灵活:

  • 所有写操作会被均匀地分布到所有顶层 vdev 上;
  • 每个写请求都会分布到一个合适的设备上,不需要固定条带宽度,因为 SPA 通过虚拟地址来管理块的位置;
  • 同样,读操作也会分布在多个顶层 vdev 上。

当新设备被添加到存储池中,SPA 会立刻开始从新设备中分配块,无需像传统卷管理器那样人工创建新的条带组,从而立即提升磁盘带宽。

分配机制:

SPA 使用了一种基于 slab 分配器 [2] 的变体来管理块的分配:

  • 存储被划分为多个 metaslab
  • 每个 metaslab 被划分为一定大小的块;
  • 与 extent 不同,SPA 选择了多种块大小而非 extent,是因为:
    • extent 不适合实现写时复制(copy-on-write)
    • 并且基于块的文件系统也可以获得类似于 extent 的性能优势 [14]。

为了实现良好的写性能,写时复制文件系统需要找到大块的连续空闲空间用于写入新块。 Slab 分配器已经被证明能高效地应对可变大小分配而不易产生碎片,且无需额外的“碎片整理线程”

相比之下,日志结构文件系统(LFS)通常需要创建如 512KB 或 1MB 的连续空闲段 [16, 17],但其“清理器(segment cleaner)”在某些情况下引入了显著开销 [19],尽管后续研究 [22] 已有所优化。

因此,使用 slab 分配器为 ZFS 带来了更大的块分配策略灵活性


卷管理(虚拟设备层的设计理念)

为了实现镜像、条带化、拼接等复杂组合,ZFS 提出了一个简洁、模块化、轻量的方案 —— 通过“小型、可组合的虚拟设备驱动 vdev” 来搭建任意复杂的存储结构

一个 vdev 是一组实现某种功能(如镜像或条带)的例程:

  • 一个 mirror vdev 接收到写请求时,会将请求发送给所有子设备; 读请求则随机发送到其中一个子设备;
  • 一个 stripe vdev 接收到 I/O 请求后,会计算出是哪个子设备包含了目标块,然后只将请求发送给那个设备。

大多数 vdev 的实现代码都在 100 行以内,因为磁盘一致性由 DMU 层而非块层维护

每个存储池包含一个或多个顶层 vdev,每个顶层 vdev 本质上是一个任意深度的 vdev 树结构。

创建命令示例:

bash


复制编辑
zpool create mirror(/dev/dsk/a, /dev/dsk/b)

我们还允许更简洁的语法(在不产生歧义的情况下):

bash


复制编辑
zpool create mirror /dev/dsk/a /dev/dsk/b

图 5 展示了一个较复杂的 vdev 树示例:管理员在资源受限情况下,用两个 50GB 磁盘和一个 100GB 磁盘构造出一个 100GB 镜像池 —— 虽然这是一个不常见的情况。


3.2.3 块分配策略(Block allocation strategy)

SPA 使用轮询(round-robin)方式,在所有顶层 vdev 之间分配块。

当一个存储池拥有多个顶层 vdev 时,SPA 可以实现一种称为“动态条带化(dynamic striping)”的策略,以提升整体磁盘带宽。

由于每个新块都可能被分配给任意一个顶层 vdev,SPA 实现了动态...