dm-vdo 的设计¶
dm-vdo(虚拟数据优化器)目标提供内联重复数据删除、压缩、零块消除和精简配置。dm-vdo 目标最多可由 256TB 的存储支持,并可呈现高达 4PB 的逻辑大小。此目标最初由 Permabit Technology Corp. 从 2009 年开始开发。它于 2013 年首次发布,并已在生产环境中使用了很长时间。在 Permabit 被 Red Hat 收购后,它于 2017 年开源。本文档描述了 dm-vdo 的设计。有关用法,请参见与此文件位于同一目录中的 dm-vdo。
由于重复数据删除率随着块大小的增加而急剧下降,因此 vdo 目标的最大块大小为 4K。但是,它可以实现 254:1 的重复数据删除率,即给定 4K 块的最多 254 个副本可以引用单个 4K 的实际存储。它可以实现 14:1 的压缩率。所有零块都不会消耗任何存储空间。
操作原理¶
dm-vdo 的设计基于这样一个思想:重复数据删除是一个由两部分组成的问题。第一部分是识别重复数据。第二部分是避免存储多个重复项的副本。因此,dm-vdo 有两个主要部分:一个用于发现重复数据的重复数据删除索引(称为 UDS)和一个带有引用计数块映射的数据存储,该映射从逻辑块地址映射到数据的实际存储位置。
区域和线程¶
由于数据优化的复杂性,对 vdo 目标的单个写入操作中涉及的元数据结构数量大于大多数其他目标。此外,由于 vdo 必须以小块大小运行才能实现良好的重复数据删除率,因此只能通过并行性来实现可接受的性能。因此,vdo 的设计试图是无锁的。
vdo 的大多数主要数据结构都被设计为可以轻松地划分为“区域”,这样,任何给定的 bio 都只能访问任何已分区结构的单个区域。通过确保在正常操作期间,每个区域都分配给一个特定的线程,并且只有该线程将访问该区域中数据结构的对应部分,从而在最少的锁定下实现安全性。每个线程都关联一个工作队列。每个 bio 都与一个请求对象(“data_vio”)相关联,当其操作的下一阶段需要访问与该队列关联的区域中的结构时,该对象将被添加到工作队列中。
关于此安排的另一种思考方式是,每个区域的工作队列对其所有操作所管理的结构具有隐式锁,因为 vdo 保证没有其他线程会更改这些结构。
尽管每个结构都分为多个区域,但是这种划分并未反映在每个数据结构的磁盘表示形式中。因此,每次启动 vdo 目标时,都可以重新配置每个结构的区域数量,进而重新配置线程数。
重复数据删除索引¶
为了有效地识别重复数据,vdo 被设计为利用重复数据的一些常见特征。根据经验观察,我们收集了两个关键的见解。第一个是,在大多数具有大量重复数据的数据集中,重复项往往具有时间局部性。当重复项出现时,更有可能检测到其他重复项,并且这些重复项将在大致相同的时间写入。这就是为什么索引按时间顺序保存记录的原因。第二个见解是,新数据比重复旧数据更有可能重复最近的数据,并且通常来说,回顾过去的时间收益会递减。因此,当索引已满时,它应清除其最旧的记录,以便为新记录腾出空间。索引设计背后的另一个重要思想是,重复数据删除的最终目标是降低存储成本。由于在节省的存储空间与为实现这些节省而花费的资源之间存在权衡,因此 vdo 不会尝试查找每一个重复的块。找到并消除大部分冗余就足够了。
每个数据块都会被散列以生成一个 16 字节的块名称。索引记录由这个块名称与该数据在底层存储上的假定位置配对组成。但是,不可能保证索引是准确的。在最常见的情况下,发生这种情况是因为在块被覆盖或丢弃时更新索引的成本太高。这样做要么需要将块名称与块一起存储,这在基于块的存储中很难高效地执行,要么需要在覆盖每个块之前读取并重新散列每个块。不准确也可能是由于哈希冲突导致两个不同的块具有相同的名称。在实践中,这是极不可能的,但是由于 vdo 不使用加密哈希,因此可以构造恶意工作负载。由于存在这些不准确之处,vdo 将索引中的位置视为提示,并在共享现有块和新块之前,读取每个指示的块以验证它是否确实是重复项。
记录收集到称为章节的组中。新记录被添加到最新的章节中,称为开放章节。本章以一种针对添加和修改记录进行优化的格式存储,并且只有当开放章节用尽新记录的空间时,其内容才会被最终确定。当开放章节填满时,它会被关闭,并创建一个新的开放章节以收集新记录。
关闭章节会将其转换为另一种针对读取进行优化的格式。记录根据接收到的顺序写入一系列记录页。这意味着具有时间局部性的记录应该在少量页面上,从而减少检索它们所需的 I/O。本章还编译了一个索引,指示哪个记录页包含任何给定名称。此索引表示对名称的请求可以准确确定哪个记录页可能包含该记录,而不必从存储中加载整个章节。此索引仅使用块名称的子集作为其键,因此它不能保证索引条目引用所需的块名称。它只能保证如果存在此名称的记录,它将位于指示的页面上。关闭的章节是只读结构,它们的内容永远不会以任何方式更改。
一旦写入的记录足够多,填满所有可用的索引空间,最旧的章节会被移除,以便为新章节腾出空间。任何时候,当请求在索引中找到匹配的记录时,该记录会被复制到打开的章节中。这确保了有用的块名称在索引中保持可用,而未被引用的块名称会随着时间的推移而被遗忘。
为了在较旧的章节中查找记录,索引还维护了一个称为卷索引的更高级别结构,其中包含将每个块名称映射到包含其最新记录的章节的条目。当块名称的记录被复制或更新时,此映射会更新,以确保只能找到给定块名称的最新记录。即使块名称的较旧记录没有从其章节中删除,也无法再找到它。与章节索引一样,卷索引仅使用块名称的子集作为其键,并且无法明确说明某个名称是否存在记录。它只能说明如果存在记录,哪个章节会包含该记录。卷索引完全存储在内存中,仅在 vdo 目标关闭时才保存到存储设备。
从对特定块名称的请求的角度来看,它首先会在卷索引中查找该名称。此搜索将指示该名称是新的,还是指示要搜索的章节。如果返回一个章节,则请求会在章节索引中查找其名称。这将指示该名称是新的,还是指示要搜索的记录页。最后,如果它不是新的,请求将在指示的记录页中查找其名称。此过程可能需要每个请求最多读取两个页面(一个用于章节索引页,一个用于请求页)。但是,最近访问的页面会被缓存,以便这些页面读取可以分摊到许多块名称请求中。
卷索引和章节索引使用一种称为增量索引的内存高效结构来实现。条目不是为每个条目存储整个块名称(键),而是按名称排序,并且仅存储相邻键之间的差异(增量)。因为我们期望哈希值是随机分布的,所以增量的大小遵循指数分布。由于这种分布,增量使用霍夫曼代码表示,以占用更少的空间。整个排序后的键列表称为增量列表。这种结构允许索引每个条目使用比传统哈希表少得多的字节,但是查找条目的成本稍高,因为请求必须读取增量列表中的每个条目,以便累加增量以找到它需要的记录。增量索引通过将其键空间拆分为许多子列表来减少此查找成本,每个子列表都从一个固定的键值开始,从而使每个单独的列表都很短。
默认索引大小可以容纳 6400 万条记录,对应约 256GB 的数据。这意味着如果原始数据是在最近 256GB 的写入中写入的,则索引可以识别重复数据。此范围称为去重窗口。如果新写入复制的数据比这更旧,则索引将无法找到它,因为较旧数据的记录已被删除。这意味着,如果应用程序将一个 200 GB 的文件写入 vdo 目标,然后立即再次写入,则这两个副本将完美地去重。对一个 500 GB 的文件执行相同的操作将不会导致任何去重,因为当第二次写入开始时,文件的开头将不再在索引中(假设文件本身没有重复)。
如果应用程序预计数据工作负载会看到超出 256GB 阈值的有用的去重,则可以将 vdo 配置为使用更大的索引,并具有相应的更大的去重窗口。(此配置只能在创建目标时设置,以后不能更改。在配置 vdo 目标之前,考虑预期工作负载非常重要。)有两种方法可以做到这一点。
一种方法是增加索引的内存大小,这也增加了所需的后备存储量。将索引的大小加倍会使去重窗口的长度加倍,但代价是存储大小和内存要求加倍。
另一种选择是启用稀疏索引。稀疏索引将去重窗口增加 10 倍,但代价是将存储大小也增加 10 倍。但是,使用稀疏索引,内存要求不会增加。权衡是在每个请求中进行更多的计算,并且检测到的去重量略有减少。对于大多数具有大量重复数据的工作负载,稀疏索引将检测到标准索引将检测到的 97-99% 的去重。
vio 和 data_vio 结构¶
一个 vio(Vdo I/O 的缩写)在概念上类似于一个 bio,具有额外的字段和数据来跟踪 vdo 特有的信息。一个 struct vio 维护一个指向 bio 的指针,但也跟踪特定于 vdo 操作的其他字段。vio 与其相关的 bio 分开保存,因为在很多情况下,vdo 完成 bio 但必须继续执行与去重或压缩相关的工作。
元数据读取和写入,以及源自 vdo 内的其他写入,直接使用 struct vio。应用程序读取和写入使用一个称为 data_vio 的更大结构来跟踪其进度的信息。一个 struct data_vio 包含一个 struct vio,还包括与去重和其他 vdo 功能相关的其他几个字段。data_vio 是 vdo 中应用程序工作的主要单元。每个 data_vio 都会经过一系列步骤来处理应用程序数据,之后它会被重置并返回到 data_vios 池中以供重用。
有一个固定的 2048 个 data_vios 池。选择此数字是为了限制从崩溃中恢复所需的工作量。此外,基准测试表明,增加池的大小并不会显着提高性能。
数据存储¶
数据存储由三个主要数据结构实现,所有这些结构协同工作,以减少或分摊尽可能多的数据写入中的元数据更新。
Slab 仓库
大多数 vdo 卷都属于 Slab 仓库。仓库包含一系列 Slab。Slab 的大小可以高达 32GB,并分为三个部分。Slab 的大部分由 4K 块的线性序列组成。这些块用于存储数据,或用于保存块映射的一部分(见下文)。除了数据块之外,每个 Slab 都有一组引用计数器,每个数据块使用 1 个字节。最后,每个 Slab 都有一个日志。
引用更新会写入 Slab 日志。Slab 日志块会在它们已满时,或者在恢复日志请求它们这样做时写入,以便允许主恢复日志(见下文)释放空间。Slab 日志既用于确保主恢复日志可以定期释放空间,也用于分摊更新单个引用块的成本。引用计数器保存在内存中,只有在需要回收 Slab 日志空间时,才会按最旧脏污顺序一次写入一个块。写入操作会根据需要在后台执行,因此它们不会为特定的 I/O 操作增加延迟。
每个 Slab 都独立于其他 Slab。它们以循环方式分配给“物理区域”。如果存在 P 个物理区域,则 Slab n 会分配给区域 n mod P。
Slab 仓库维护一个额外的小数据结构,即“Slab 摘要”,用于减少崩溃后重新上线所需的工作量。Slab 摘要维护每个 Slab 的条目,指示 Slab 是否曾被使用过、其所有引用计数更新是否已持久保存到存储设备以及它的大致填充程度。在恢复期间,每个物理区域将尝试恢复至少一个 Slab,每当它恢复一个具有一些空闲块的 Slab 时就会停止。一旦每个区域都有一些空间,或者已确定没有可用空间,目标就可以在降级模式下恢复正常运行。可以提供读取和写入请求,可能性能会下降,同时恢复剩余的脏 Slab。
块映射
块映射包含逻辑到物理的映射。可以将其视为一个数组,每个逻辑地址有一个条目。每个条目为 5 个字节,其中 36 位包含保存给定逻辑地址数据的物理块号。其他 4 位用于指示映射的性质。在 16 种可能的状态中,一种表示未映射的逻辑地址(即,它从未被写入或已被丢弃),一种表示未压缩的块,其他 14 种状态用于指示映射的数据已压缩,以及压缩块中哪个压缩槽包含此逻辑地址的数据。
实际上,映射条目的数组被划分为“块映射页”,每个页都适合单个 4K 块。每个块映射页由一个标头和 812 个映射条目组成。每个映射页实际上是一个基数树的叶子,该基数树由每一级的块映射页组成。有 60 个基数树以循环方式分配给“逻辑区域”。(如果有 L 个逻辑区域,树 n 将属于区域 n mod L。)在每个级别,这些树是交错的,因此逻辑地址 0-811 属于树 0,逻辑地址 812-1623 属于树 1,依此类推。交错一直保持到 60 个根节点。选择 60 个树会导致每个区域的树数量均匀分布,适用于大量可能的逻辑区域计数。60 个树根的存储空间在格式化时分配。所有其他块映射页都根据需要从 Slab 中分配。这种灵活的分配避免了为整个逻辑映射集预先分配空间的需要,并且还使得扩展 vdo 的逻辑大小相对容易。
在运行中,块映射维护两个缓存。将树的整个叶级保存在内存中是禁止的,因此每个逻辑区域都维护其自己的叶页缓存。此缓存的大小可在目标启动时配置。第二个缓存在启动时分配,并且足够大以容纳整个块映射的所有非叶页。此缓存会在需要时填充页面。
恢复日志
恢复日志用于分摊块映射和 Slab 仓库中的更新。每个写入请求都会在日志中创建一个条目。条目要么是“数据重新映射”,要么是“块映射重新映射”。对于数据重新映射,日志会记录受影响的逻辑地址及其旧的物理映射和新的物理映射。对于块映射重新映射,日志会记录块映射页码和为其分配的物理块。块映射页永远不会被回收或重新利用,因此旧的映射始终为 0。
每个日志条目都是一个意图记录,概述了 data_vio 所需的元数据更新。恢复日志会在每次日志块写入之前执行刷新操作,以确保该块中新块映射的物理数据在存储上是稳定的,并且所有日志块写入都会设置 FUA 位,以确保恢复日志条目本身是稳定的。日志条目及其代表的数据写入必须在磁盘上稳定后,才能更新其他元数据结构以反映该操作。这些条目允许 vdo 设备在意外中断(例如断电)后重建逻辑到物理的映射。
写入路径
所有到 vdo 的写入 I/O 都是异步的。每个 bio 一旦 vdo 完成足够的工作以保证最终可以完成写入,就会被确认。通常,已确认但未刷新的写入 I/O 的数据可以被视为缓存在内存中。如果应用程序要求数据在存储上稳定,则必须发出刷新或使用设置了 FUA 位的写入数据,就像任何其他异步 I/O 一样。关闭 vdo 目标也会刷新任何剩余的 I/O。
应用程序写入 bio 遵循以下概述的步骤。
从 data_vio 池中获取一个 data_vio,并将其与应用程序 bio 相关联。如果没有可用的 data_vio,则传入的 bio 将被阻塞,直到有 data_vio 可用为止。这会给应用程序提供背压。data_vio 池受自旋锁保护。
重新设置新获取的 data_vio,如果它是写入并且数据不全为零,则将 bio 的数据复制到 data_vio 中。必须复制数据,因为应用程序 bio 可以在 data_vio 处理完成之前被确认,这意味着后面的处理步骤将不再能够访问应用程序 bio。应用程序 bio 也可能小于 4K,在这种情况下,data_vio 将已经读取了底层块,并且数据会复制到较大块的相关部分。
data_vio 在 bio 的逻辑地址上放置一个声明(“逻辑锁”)。防止同时修改相同的逻辑地址至关重要,因为重复数据删除涉及共享块。此声明以哈希表中的条目形式实现,其中键是逻辑地址,值是指向当前正在处理该地址的 data_vio 的指针。
如果 data_vio 在哈希表中查找时发现另一个 data_vio 已经在该逻辑地址上运行,则它会等待前一个操作完成。它还会发送一条消息,通知当前的锁持有者它正在等待。最值得注意的是,等待逻辑锁的新 data_vio 将会将之前的锁持有者从压缩打包器(步骤 8d)中刷新出去,而不是允许它继续等待打包。
此阶段要求 data_vio 获取适当逻辑区域的隐式锁,以防止同时修改哈希表。此隐式锁定由上述区域划分处理。
data_vio 遍历块映射树,通过尝试查找其逻辑地址的叶子页面来确保已分配所有必要的内部树节点。如果缺少任何内部树页面,则此时会从用于存储应用程序数据的相同物理存储池中分配。
如果树中的任何页面节点尚未分配,则必须在写入继续之前分配它。此步骤要求 data_vio 锁定需要分配的页面节点。此锁(如步骤 2 中的逻辑块锁)是一个哈希表条目,会导致其他 data_vio 等待分配过程完成。
在发生分配时,会释放隐式逻辑区域锁,以允许同一逻辑区域中的其他操作继续进行。分配的详细信息与步骤 4 中的相同。一旦分配了新节点,则使用与添加新数据块映射类似的过程将该节点添加到树中。data_vio 将添加新节点到块映射树的意图记录到日志中(步骤 10),更新新块的引用计数(步骤 11),并重新获取隐式逻辑区域锁,以将新映射添加到父树节点(步骤 12)。一旦树更新,data_vio 将沿树向下进行。任何其他等待此分配的 data_vio 也会继续进行。
在稳态情况下,块映射树节点将已经被分配,因此 data_vio 只会遍历该树,直到找到所需的叶子节点。映射的位置(“块映射槽”)记录在 data_vio 中,以便后续步骤无需再次遍历该树。然后,data_vio 释放隐式逻辑区域锁。
如果该块是零块,则跳到步骤 9。否则,将尝试分配一个空闲数据块。此分配确保 data_vio 即使在重复数据删除和压缩不可能的情况下,也可以将其数据写入某个位置。此阶段会获取物理区域的隐式锁,以在该区域内搜索空闲空间。
data_vio 将搜索区域中的每个 slab,直到找到空闲块或确定没有空闲块为止。如果第一个区域没有空闲空间,它将通过获取该区域的隐式锁并释放上一个区域的锁,继续搜索下一个物理区域,直到找到空闲块或用完要搜索的区域。data_vio 将在空闲块上获取 struct pbn_lock(“物理块锁”)。struct pbn_lock 也有几个字段来记录 data_vio 可以对物理块拥有的各种类型的声明。pbn_lock 会添加到哈希表中,就像步骤 2 中的逻辑块锁一样。此哈希表也由隐式物理区域锁覆盖。更新空闲块的引用计数以防止任何其他 data_vio 将其视为空闲。引用计数器是 slab 的子组件,因此也由隐式物理区域锁覆盖。
如果获得了分配,则 data_vio 拥有完成写入所需的所有资源。此时可以安全地确认应用程序 bio。确认操作发生在单独的线程上,以防止应用程序回调阻塞其他 data_vio 操作。
如果无法获得分配,则 data_vio 将继续尝试重复数据删除或压缩数据,但不会确认 bio,因为 vdo 设备可能已用完空间。
此时,vdo 必须确定在哪里存储应用程序数据。对 data_vio 的数据进行哈希处理,并将哈希值(“记录名称”)记录在 data_vio 中。
data_vio 保留或加入一个 struct hash_lock,该结构管理当前正在写入相同数据的所有 data_vio。活动哈希锁以与步骤 2 中跟踪逻辑块锁类似的方式在哈希表中进行跟踪。此哈希表由哈希区域的隐式锁覆盖。
如果此 data_vio 的 record_name 没有现有哈希锁,则 data_vio 会从池中获取一个哈希锁,将其添加到哈希表中,并将自己设置为新哈希锁的“代理”。哈希锁池也由隐式哈希区域锁覆盖。哈希锁代理将完成所有工作,以确定将应用程序数据写入何处。如果 data_vio 的 record_name 的哈希锁已经存在,并且 data_vio 的数据与代理的数据相同,则新的 data_vio 将等待代理完成其工作,然后共享其结果。
在极少数情况下,如果 data_vio 的哈希存在哈希锁,但数据与哈希锁的代理不匹配,则 data_vio 将跳到步骤 8h,并尝试直接写入其数据。例如,如果两个不同的数据块产生相同的哈希,则可能会发生这种情况。
哈希锁代理尝试通过以下步骤对其数据进行重复数据删除或压缩。
代理初始化并将其嵌入式重复数据删除请求 (struct uds_request) 发送到重复数据删除索引。这不需要 data_vio 获取任何锁,因为索引组件管理自己的锁定。data_vio 等待,直到它从索引获得响应或超时。
如果重复数据删除索引返回建议,则 data_vio 会尝试获取指示物理地址的物理块锁,以便读取数据并验证它是否与 data_vio 的数据相同,并且它可以接受更多引用。如果物理地址已经被另一个 data_vio 锁定,则该地址上的数据可能很快会被覆盖,因此使用该地址进行重复数据删除是不安全的。
如果数据匹配且物理块可以添加引用,则代理和任何其他等待它的 data_vio 将记录此物理块作为它们的新物理地址,并继续执行步骤 9 以记录它们的新映射。如果哈希锁中的 data_vio 比可用引用多,则剩余的 data_vio 之一将成为新的代理,并继续执行步骤 8d,就像没有返回有效的建议一样。
如果没有找到可用的重复块,则代理首先检查它是否有一个已分配的物理块(来自步骤 3),它可以写入该物理块。如果代理没有分配,则哈希锁中具有分配的其他 data_vio 将接管作为代理。如果 data_vio 中没有一个拥有已分配的物理块,则这些写入将超出空间,因此它们将继续执行步骤 13 进行清理。
代理尝试压缩其数据。如果数据未压缩,则 data_vio 将继续执行步骤 8h 以直接写入其数据。
如果压缩后的大小足够小,则代理将释放隐式哈希区域锁,并转到打包器 (struct packer),在那里它将与其他 data_vio 一起放入一个 bin (struct packer_bin) 中。所有压缩操作都需要打包器区域的隐式锁。
打包器可以在单个 4k 数据块中组合多达 14 个压缩块。如果 vdo 可以在单个数据块中打包至少 2 个 data_vio,则压缩才会有帮助。这意味着 data_vio 可能会在打包器中等待任意长时间,等待其他 data_vio 填充压缩块。vdo 有一种机制可以在继续等待会导致问题时驱逐正在等待的 data_vio。导致驱逐的情况包括应用程序刷新、设备关闭或后续 data_vio 尝试覆盖相同的逻辑块地址。如果 data_vio 在更多可压缩块需要使用其 bin 之前无法与任何其他压缩块配对,则也可能从打包器中驱逐 data_vio。被驱逐的 data_vio 将继续执行步骤 8h 以直接写入其数据。
如果代理填充了一个打包器 bin,是因为其所有 14 个插槽都已使用或因为它没有剩余空间,则会使用其一个 data_vio 中的已分配物理块将其写出。步骤 8d 已确保分配可用。
每个 data_vio 将压缩块设置为其新的物理地址。data_vio 获取物理区域的隐式锁,并获取压缩块的 struct pbn_lock,该锁被修改为共享锁。然后,它释放隐式物理区域锁,并继续执行步骤 8i。
任何从打包器中逐出的 data_vio 都将具有步骤 3 中的分配。它会将其数据写入该分配的物理块。
在写入数据后,如果 data_vio 是哈希锁的代理,它将重新获取隐式哈希区域锁,并尽可能与其他哈希锁中的 data_vio 共享其物理地址。然后,每个 data_vio 将继续执行步骤 9 以记录其新的映射。
如果代理实际写入了新数据(无论是否压缩),则会更新重复数据删除索引以反映新数据的位置。然后,代理释放隐式哈希区域锁。
data_vio 确定逻辑地址的先前映射。有一个用于块映射叶页的缓存(“块映射缓存”),因为通常有太多块映射叶节点无法完全存储在内存中。如果所需的叶页不在缓存中,data_vio 将在缓存中保留一个插槽,并将所需的页面加载到其中,可能会逐出较旧的缓存页面。然后,data_vio 查找此逻辑地址的当前物理地址(“旧的物理映射”),如果有,则记录它。此步骤需要对块映射缓存结构进行锁定,该锁定由隐式逻辑区域锁覆盖。
data_vio 在恢复日志中创建一个条目,其中包含逻辑块地址、旧的物理映射和新的物理映射。创建此日志条目需要持有隐式恢复日志锁。data_vio 将在日志中等待,直到包含其条目的所有恢复块都已写入并刷新到存储,以确保事务在存储上是稳定的。
一旦恢复日志条目稳定,data_vio 将创建两个 slab 日志条目:一个用于新映射的增量条目,以及一个用于旧映射的减量条目。这两个操作都需要持有受影响物理 slab 的锁,该锁由其隐式物理区域锁覆盖。为了在恢复期间的正确性,任何给定的 slab 日志中的 slab 日志条目必须与相应的恢复日志条目顺序相同。因此,如果这两个条目位于不同的区域中,则它们是并发执行的;如果它们位于同一区域中,则始终在减量之前进行增量,以避免下溢。在内存中创建每个 slab 日志条目后,也会在内存中更新关联的引用计数。
一旦完成所有引用计数更新,data_vio 将获取隐式逻辑区域锁,并更新块映射中的逻辑到物理映射,以指向新的物理块。此时,写入操作完成。
如果 data_vio 具有哈希锁,它将获取隐式哈希区域锁,并将其哈希锁释放到池中。
然后,data_vio 获取隐式物理区域锁,并释放其为分配的块持有的 struct pbn_lock。如果它有未使用的分配,它也会将该块的引用计数设置回零,以供后续 data_vio 使用。
然后,data_vio 获取隐式逻辑区域锁,并释放步骤 2 中获取的逻辑块锁。
如果应用程序 bio 之前未被确认,则会对其进行确认,并且 data_vio 将返回到池中。
读取路径
应用程序读取 bio 遵循一组更简单的步骤。它执行写入路径中的步骤 1 和 2,以获取 data_vio 并锁定其逻辑地址。如果已经有一个正在进行的针对该逻辑地址的写入 data_vio,并且保证完成,则读取 data_vio 将从写入 data_vio 复制数据并返回它。否则,它将按照步骤 3 中的方式,通过遍历块映射树来查找逻辑到物理的映射,然后在指示的物理块地址读取并可能解压缩指示的数据。如果缺少内部块映射节点,则读取 data_vio 将不会分配它们。如果内部块映射节点尚不存在,则逻辑块映射地址必须仍然是未映射的,并且读取 data_vio 将返回所有零。读取 data_vio 按照步骤 13 中的方式处理清理和确认,尽管它只需要释放逻辑锁并将自身返回到池中。
小写入
vdo 中的所有存储都以 4KB 块管理,但它可以接受小至 512 字节的写入。处理小于 4K 的写入需要读取-修改-写入操作,该操作读取相关的 4K 块,将新数据复制到块的相应扇区,然后为修改后的数据块启动写入操作。此操作的读取和写入阶段与正常的读取和写入操作几乎相同,并且在整个操作中使用单个 data_vio。
恢复
当 vdo 在崩溃后重新启动时,它将尝试从恢复日志中恢复。在下次启动的预恢复阶段,将读取恢复日志。有效条目的增量部分将按顺序写入到块映射中。接下来,按照需要将有效条目按顺序写入到 slab 日志中。最后,每个物理区域尝试重播至少一个 slab 日志,以重建一个 slab 的引用计数。一旦每个区域都有一些可用空间(或已确定没有可用空间),vdo 将重新上线,而其余的 slab 日志将用于在后台重建其余的引用计数。
只读重建
如果 vdo 遇到无法恢复的错误,它将进入只读模式。此模式表示可能丢失了一些先前已确认的数据。可以指示 vdo 尽可能地重建,以便返回到可写状态。但是,由于可能丢失了数据,因此永远不会自动执行此操作。在只读重建期间,将如前所述从恢复日志中恢复块映射。但是,引用计数不会从 slab 日志中重建。相反,引用计数被清零,遍历整个块映射,并从块映射更新引用计数。虽然这可能会丢失一些数据,但它确保了块映射和引用计数彼此一致。这使 vdo 能够恢复正常运行并接受进一步的写入。