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 的写入中写入的,索引就可以识别重复数据。这个范围被称为重复数据删除窗口。如果新写入的数据重复了比这更旧的数据,索引将无法找到它,因为旧数据的记录已被删除。这意味着如果一个应用程序将一个 200GB 的文件写入 vdo 目标,然后立即再次写入,这两个副本将完美地进行重复数据删除。对一个 500GB 的文件进行相同的操作将导致没有重复数据删除,因为在第二次写入开始时,文件的开头将不再在索引中(假设文件本身没有重复数据)。
如果应用程序预计数据工作负载的有效重复数据删除将超出 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_vio 池中以便重用。
有一个固定的 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 都是异步的。一旦 vdo 完成了足够的工作以保证最终可以完成写入,每个 bio 就会被确认。通常,已确认但未刷新的写入 I/O 数据可以被视为已缓存在内存中。如果应用程序要求数据在存储上稳定,它必须像任何其他异步 I/O 一样,发出一个刷新或将数据写入时设置 FUA 位。关闭 vdo 目标也将刷新所有剩余的 I/O。
应用程序写入 bios 遵循以下步骤。
从 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_vios 等待分配过程完成。
在分配发生时,隐式逻辑区域锁被释放,以允许同一逻辑区域中的其他操作继续进行。分配的详细信息与步骤 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 将继续尝试对数据进行重复数据删除或压缩,但由于 vdo 设备可能空间不足,因此 bio 未被确认。
此时,vdo 必须确定应用程序数据的存储位置。data_vio 的数据被哈希处理,并且哈希值(“记录名称”)被记录在 data_vio 中。
data_vio 保留或加入一个 struct hash_lock,该结构管理所有当前写入相同数据的 data_vio。活动的哈希锁在哈希表中进行跟踪,类似于步骤 2 中跟踪逻辑块锁的方式。此哈希表受哈希区域上的隐式锁保护。
如果此 data_vio 的 record_name 没有现有哈希锁,则 data_vio 从池中获取一个哈希锁,将其添加到哈希表,并将自身设置为新哈希锁的“代理”。hash_lock 池也受隐式哈希区域锁的保护。哈希锁代理将完成所有决定应用程序数据写入位置的工作。如果 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_vios 将把此物理块记录为其新的物理地址,然后继续执行步骤 9 以记录其新映射。如果哈希锁中的 data_vios 数量多于可用引用数量,则剩余的 data_vios 之一将成为新的代理,并继续执行步骤 8d,如同未返回有效建议一样。
如果未找到可用的重复块,代理首先检查它是否有一个已分配的物理块(来自步骤 3)可以写入。如果代理没有分配,哈希锁中其他具有分配的 data_vio 将接替代理。如果所有 data_vios 都没有已分配的物理块,则这些写入操作将因空间不足而失败,因此它们将继续执行步骤 13 进行清理。
代理尝试压缩其数据。如果数据无法压缩,data_vio 将继续执行步骤 8h 以直接写入其数据。
如果压缩大小足够小,代理将释放隐式哈希区域锁,并转到打包器(struct packer),在那里它将与其他 data_vios 一起放入一个 bin(struct packer_bin)中。所有压缩操作都需要打包器区域上的隐式锁。
打包器可以在单个 4K 数据块中组合多达 14 个压缩块。只有当 vdo 可以在单个数据块中打包至少 2 个 data_vios 时,压缩才有用。这意味着一个 data_vio 可能会在打包器中等待任意长的时间,直到其他 data_vios 填充压缩块。vdo 有一种机制,可以在继续等待会导致问题时驱逐等待中的 data_vios。导致驱逐的情况包括应用程序刷新、设备关闭,或后续的 data_vio 尝试覆盖相同的逻辑块地址。如果一个 data_vio 在更多可压缩块需要使用其 bin 之前无法与任何其他压缩块配对,它也可能被从打包器中驱逐。被驱逐的 data_vio 将继续执行步骤 8h 以直接写入其数据。
如果代理填满了打包器 bin,无论是由于其所有 14 个槽位都已使用,还是因为它没有剩余空间,它都会使用其一个 data_vios 的已分配物理块进行写入。步骤 8d 已确保分配可用。
每个 data_vio 将压缩块设置为其新的物理地址。data_vio 在物理区域上获得一个隐式锁,并获取压缩块的 struct pbn_lock,该锁被修改为共享锁。然后它释放隐式物理区域锁并继续执行步骤 8i。
任何从打包器中被驱逐的 data_vio 都将拥有步骤 3 中的分配。它会将数据写入该已分配的物理块。
数据写入后,如果 data_vio 是哈希锁的代理,它将重新获取隐式哈希区域锁,并将其物理地址尽可能多地共享给哈希锁中的其他 data_vios。然后每个 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 恢复正常操作并接受进一步的写入。