首页
/ Linux 内核 dm-vdo(Virtual Data Optimizer)设计解析:去重、压缩与精简配置的实现原理

Linux 内核 dm-vdo(Virtual Data Optimizer)设计解析:去重、压缩与精简配置的实现原理

2026-09-07 22:15:01作者:冯梦姬Eddie

dm-vdo(virtual data optimizer)是 Linux 内核 device mapper 框架下的一个块级 target,能够在文件系统之下以"内联"方式完成数据去重(deduplication)、压缩(compression)、零块消除与精简配置(thin provisioning)。本文以内核源码树中的设计文档 vdo-design.rst 为骨架,结合 drivers/md/dm-vdo/ 目录下的实际实现源码,完整剖析其两大子系统(UDS 去重索引 + 引用计数数据存储)、无锁分区线程模型、13 步写路径与崩溃恢复机制,并给出可复用的 dmsetup 配置示例与调优方向。读完本文,你将理解 dm-vdo 如何仅用 4K 物理块换来最高 254:1 的去重率与 14:1 的压缩率,以及为何任何单次写入都会牵动十余个元数据结构协同工作。

总览:dm-vdo 的能力边界与定位

dm-vdo 是一个块级 device mapper target,提供四种内联数据优化能力:

  • 去重:识别重复数据块,使多个逻辑块共享同一份物理存储;
  • 压缩:对不可去重的数据做压缩,并将多个压缩块打包进同一个物理块;
  • 零块消除:全零块不占用任何实际存储;
  • 精简配置:物理空间按需分配。

规模边界(以设计文档给出的官方规格为准):一个 dm-vdo target 可由最多 256 TB 的底层存储作为后备,对外可呈现最大 4 PB 的逻辑容量。由于去重率会随块尺寸增大而急剧下降,dm-vdo 的物理块大小上限固定为 4K;在 4K 粒度下,它最高可获得 254:1 的去重率——即同一 4K 物理块最多被 254 份逻辑副本引用;压缩率可达 14:1;全零块则不消耗任何空间。这些目标于 2009 年起在 Permabit Technology Corp. 研发,2013 年首次发布并长期运行于生产环境,2017 年随 Permabit 被 Red Hat 收购而开源。内核中的全部实现集中于 drivers/md/dm-vdo/(其子系统构造可参考构建文件 Makefile),本设计文档即为该实现配套的权威原理说明。

理论模型:把去重拆成两个问题

dm-vdo 的设计基于一个重要判断:去重本质上包含两个独立子问题——

  1. 识别重复数据;
  2. 避免重复存储这些数据。

因此 dm-vdo 由两大部件构成(对应源码中的目录结构):

  • 去重索引 UDS(位于 drivers/md/dm-vdo/indexer/),负责发现重复数据。该目录下的 volume-index.cchapter-index.cdelta-index.copen-chapter.csparse-cache.cconfig.cgeometry.c 等文件分别对应本文后续的卷索引、章节索引、增量索引、开放章节、稀疏缓存等概念;
  • 数据存储(data store),核心是一个带引用计数的块图(block map),负责把逻辑块地址(LBA)映射到数据实际所在物理块。实现对应 block-map.cslab-depot.crecovery-journal.c

Zones 与线程化:以无锁为目标的并行模型

一次写操作牵动的元数据结构远多于其他 device mapper target,且为了去重率必须工作在 4K 小块上,没有并行就无法获得可接受的性能。因此 vdo 的设计追求接近无锁(lock-free)。

  • vdo 的主要数据结构被设计成可划分为若干 zone(区域),使得任意一个 bio 在任意"被分区"的结构上只访问一个 zone;
  • 正常运行时,每个 zone 被固定指派给一个线程,且只有该线程能访问该 zone 对应的数据部分,从而实现"最小化加锁下的安全性";
  • 每个线程伴生一个工作队列。每个 bio 对应一个请求对象 data_vio,当某阶段需要访问某个 zone 内的结构时,该 data_vio 就被投递到与该 zone 关联的工作队列;
  • 等价地理解:每个 zone 的工作队列等于对该队列所管理结构持有隐式锁,因为 vdo 保证没有其他线程会改动这些结构。

分区只存在于运行期内存中,不会反映到各结构的磁盘表示上。因此每次启动 vdo target 时,各结构的 zone 数量(进而线程数量)都可以重新配置。这一设计约束在常量头文件 constants.h 中可直接验证:逻辑 zone 上限 MAX_VDO_LOGICAL_ZONES = 60(第 58 行)、物理 zone 上限 MAX_VDO_PHYSICAL_ZONES = 16(第 61 行)、全系统最大线程数 MAXIMUM_VDO_THREADS = 100(第 84 行)。

UDS 去重索引:时序性优先的设计哲学

为了高效识别重复数据,vdo 依据对真实数据集的长期观测提炼出两条经验法则,这两条法则几乎决定了 UDS 的全部结构取舍:

  1. 重复具有时间局部性(temporal locality):一旦出现一个重复块,往往意味着同一时间窗口内刚写入的数据还有更多重复。因此索引记录按时间顺序保存;
  2. 新数据更容易与更近的数据重复,回溯越久,收益越低。因此索引写满时应优先淘汰最老记录,为新记录腾出空间。

另一个贯穿始终的经济学原则是:去重的终极目标是降低存储成本,而在"省下的空间"与"为此投入的资源"之间存在权衡,所以 vdo 并不试图找出每一个重复块——消除绝大部分冗余即已足够。

块名、记录的"提示"属性与哈希碰撞

  • 每个数据块被哈希成一个 16 字节的块名(block name)
  • 一条索引记录 = 块名 + 该数据在底层存储上的推测位置

索引并不能保证绝对准确,原因有两个:

  • 更新成本过高:块被覆写或丢弃时同步更新索引,要么需要把块名和块数据一同落盘(在块存储上难以高效实现),要么需要在覆写前读回并重新哈希每个块;
  • 哈希碰撞:两个不同块可能产生同名。虽然概率极低,但由于 vdo 使用的不是密码学哈希,恶意构造的工作负载可以人为制造碰撞(源码中哈希实现可见 murmurhash3.c)。

正因如此,vdo 把索引给出的位置一律视为 hint(提示):在让新块共享某个既有物理块之前,会先读回该块并验证数据确实一致。

章节(chapter):从"可追加"到"可检索"

记录被收集成组,称为 章节(chapter)

  • 新记录写入最新的开放章节(open chapter),其存储格式针对"添加与修改"优化,内容直到空间用尽才定型;
  • 开放章节写满后即被关闭,并新建一个开放章节。

关闭章节会把数据转换为面向读取优化的格式:

  • 记录按到达顺序写进一系列记录页(record page)。时间上邻近的记录因此聚集在少量页面上,大幅降低检索所需 I/O;
  • 章节会编译一份章节索引(chapter index),指明某个块名可能落在哪一页。这样查询某个名字时无需把整章读入内存,即可精确定位候选记录页;
  • 章节索引只使用块名的一个子集作为键,因此它只能保证"若存在该名字的记录,必在指示页上",而不能断定记录一定存在;
  • 已关闭章节是只读结构,内容永不改变。

当累积记录填满整个索引空间后,最老的章节会被移除以为新章节腾位。任何一次成功命中的记录都会被拷贝进开放章节,从而保证"仍然有用的块名"留在索引中,无人引用的块名则随时间被遗忘。每章默认可容纳 256 个记录页(DEFAULT_RECORD_PAGES_PER_CHAPTER = 256,见 geometry.h),小型配置为 64 页(SMALL_RECORD_PAGES_PER_CHAPTER = 64)。

卷索引(volume index):跨章节的新旧判别

为了在老章节中找记录,索引还维护一个更上层的 卷索引,其条目把每个块名映射到含该块名最新记录的章节。当块名的记录被拷贝或更新时,该映射随之刷新,从而保证只有最新记录能被找到——即便某块名的老记录仍残留在章节中,也不会再被命中。

卷索引同章节索引一样,只用块名的部分位作为键,无法断言某名字的记录是否存在,只能指出"若存在则应在哪一章"。卷索引完全驻留内存,仅在 vdo target 关停时才保存到存储。

一次块名查询的三级下落

从一次对某块名的请求视角看,查找过程逐级下落:

  1. 先在卷索引中查块名:结果要么表明这是新名字,要么给出应搜的章节号;
  2. 若命中章节,再在该章节索引中查:结果要么是新名字,要么给出候选记录页;
  3. 若可能命中,最后在指定的记录页中查找块名。

整个过程单次请求最多需要 2 次页面读(一次章节索引页、一次记录页)。不过近期访问过的页会驻留缓存,使页面读取在大量块名请求之间被摊销。

delta index 与 Huffman 编码:把内存占用压到极致

卷索引与章节索引都用一种内存高效的结构——delta index(增量索引)——实现:

  • 不为每条记录存整个块名(键),而是把条目按名字排序,只存相邻键之间的差值(delta)
  • 由于哈希近似随机分布,差值大小服从指数分布;据此再对差值施加 Huffman 编码进一步压缩空间;
  • 整条有序键列称为 delta list(增量表)。它比传统哈希表每条目占用少得多字节,但查找略贵——请求必须从头累加 delta 才能定位目标记录;
  • 为摊薄这一成本,delta index 把键空间切分为大量以固定键值开头的子表,使每条子表都很短。

这一设计在源码中对应 delta-index.c

去重窗口:256 GB 的默认"记忆范围"

默认索引规模可容纳 6400 万条记录,对应约 256 GB 数据。换言之,索引能识别"最近 256 GB 写入范围内"出现的重复——这段范围就是去重窗口(deduplication window)。若新写入与更早数据重复,因老记录已被淘汰,索引将无法发现它。

由此引出两个直观示例:

  • 向 vdo target 写入一个 200 GB 文件后立刻再写一遍,两份副本会完美去重
  • 换成 500 GB 文件重复两遍,则基本得不到任何去重——第二次写开始时文件开头早已滑出索引(假定文件内部自身无重复)。

若预计工作负载需要超过 256 GB 窗口的去重能力,vdo 提供两种扩展途径。注意:该配置只能在 target 创建(格式化)时设定,事后不可更改,务必先评估工作负载再动手。

  1. 增大索引内存:同步放大底层索引存储。索引规模加倍 → 去重窗口长度加倍,同时存储占用与内存需求也大致加倍;
  2. 启用稀疏索引(sparse indexing):把去重窗口放大 10 倍,存储占用同样放大 10 倍,但内存需求不增加。代价是每次请求多一点点计算量、检测到的去重总量略降。对含大量重复数据的大多数工作负载而言,稀疏索引仍能检测出普通索引 97%–99% 的去重效果。

稀疏抽样率等参数在 config.h 中定义为编译期常量(DEFAULT_SPARSE_SAMPLE_RATE = 32DEFAULT_CACHE_CHAPTERS = 7DEFAULT_VOLUME_INDEX_MEAN_DELTA = 4096,见第 20–22 行)。

vio 与 data_vio:应用请求的基本工作单元

vio(Vdo I/O)在概念上类似 bio,但额外携带 vdo 专属字段与数据。struct vio 持有指向 bio 的指针,并跟踪 vdo 运行所需的其他字段。vio 与其 bio 分离维护——因为存在大量"bio 已完成、vdo 仍需为去重或压缩继续干活"的场景。

  • vdo 内部发起的元数据读写及其他写入,直接使用 struct vio
  • 应用层的读写则使用更大的 data_vio 结构:它内嵌一个 struct vio,并附加去重等特性所需的多项字段。data_vio 是应用工作的首要单元,每个 data_vio 依序走完一组处理步骤后会被重置并归还池中复用;
  • 全系统存在固定 2048 个 data_vio 的池。该数字意在约束崩溃恢复所需工作量,且基准测试表明继续扩大池子并不能显著提升性能。源码常量 MAXIMUM_VDO_USER_VIOS = 2048constants.h)与 data-vio.c 中池的"限量器(limiter)"实现(第 89–111 行注释)与此完全吻合。

数据存储:Slab Depot、块图与恢复日志

数据存储由三个协同工作的主结构实现,目标是把元数据更新尽量跨多个数据写入摊薄/延后

Slab Depot(slab 仓库)

vdo 卷的大部分空间属于 slab depot,它包含一组 slab

  • 每个 slab 最高可达 32 GB,划分为三个部分:主体是一段线性排列的 4K 块序列(用于存数据或存放块图页);每块配 1 字节引用计数(reference counter);每个 slab 还有自己的 slab journal(slab 日志)
  • slab 尺寸在格式化时确定。源码 constants.h 给出取值域:默认 DEFAULT_VDO_SLAB_BLOCKS = 1 << 19(约 2 GB)、最小 MIN_VDO_SLAB_BLOCKS = 1 << 13、最大 MAX_VDO_SLAB_BLOCKS = 1 << 23(即 32 GB);slab 总数上限 MAX_VDO_SLABS = 8192(第 73 行);
  • 引用更新写入 slab journal。slab journal 块在"写满"或"被恢复日志要求释放空间"时落盘。slab journal 一方面保证主恢复日志能定期腾出空间,另一方面摊销了单块引用块的更新成本;
  • 引用计数驻留内存,仅在需要回收 slab journal 空间时按"最老先脏"顺序整块写出,且全部在后台按需执行,不增加具体 I/O 的延迟;
  • 每个 slab 彼此独立,以**轮询(round-robin)**方式归属到各"物理 zone":若有 P 个物理 zone,slab n 属于 zone n mod P

depot 还维护一个小型结构 slab summary(slab 摘要),用于缩短崩溃后重启的工作量:每个 slab 一条记录,标明该 slab 是否被用过、引用计数更新是否全部持久化、大致占用程度。恢复时,每个物理 zone 尝试恢复至少一个 slab,一旦恢复到含空闲块的 slab 即停止。当每个 zone 都有空间(或确定没有)后,target 即可以降级模式恢复服务——此时读写已可响应(性能可能不佳),其余脏 slab 在后台继续恢复。

块图(Block Map):5 字节的逻辑到物理映射

块图保存逻辑到物理映射,可视为按逻辑地址索引的数组:

  • 每个表项 5 字节:其中 36 位是物理块号,4 位用于标记映射性质;
  • 4 位共 16 种状态:1 种表示"未映射"(从未写过或已被 discard),1 种表示"未压缩块",其余 14 种表示"数据已压缩、并落在压缩块的第几个压缩槽中"。这与 types.h 中定义的 block_mapping_state 完全对应:VDO_MAPPING_STATE_UNMAPPED = 0VDO_MAPPING_STATE_UNCOMPRESSED = 1VDO_MAPPING_STATE_COMPRESSED_BASE = 2VDO_MAPPING_STATE_COMPRESSED_MAX = 15,从而 VDO_MAX_COMPRESSION_SLOTS = 14
  • 实际实现中,映射数组被切成块图页(block map page),每页恰好放进一个 4K 块:一页 = 页头 + 812 个映射表项VDO_BLOCK_MAP_ENTRIES_PER_PAGE = 812constants.h);
  • 每个映射页其实是一棵 radix 树的叶子,树各层都由块图页构成。共 60 棵 radix 树,以轮询方式归属"逻辑 zone"(若有 L 个逻辑 zone,树 n 属 zone n mod L);
  • 各层树之间是交错排布的:逻辑地址 0–811 属树 0,812–1623 属树 1,依此类推,直至最顶层的 60 个根节点。选 60 棵树是为了让大量可能的逻辑 zone 数目都能得到较均匀的每 zone 树数分配。这 60 个树根在格式化时分配存储,其余块图页按需从 slab 中分配——这种弹性分配既避免预分配整张逻辑映射空间,也使得在线扩大逻辑容量比较容易。相关常量见 constants.h:树高 VDO_BLOCK_MAP_TREE_HEIGHT = 5、根树数 DEFAULT_VDO_BLOCK_MAP_TREE_ROOT_COUNT = 60
  • 运行期块图维护两层缓存:把整棵树的叶子层全部常驻内存是不现实的,因此每个逻辑 zone 有自己独立的叶子页缓存(容量可在启动时配置,对应 table 参数 block map cache size);第二层缓存则在启动时分配,足够容纳整棵块图的全部非叶子页,随用随填。

恢复日志(Recovery Journal):跨块图与 slab depot 的摊销层

恢复日志用于摊销块图与 slab depot 两侧的更新。要点如下:

  • 每次写请求都会写一条日志条目。条目分两类:data remapping(数据重映射)——记录受影响的逻辑地址及其旧/新物理映射;block map remapping(块图重映射)——记录块图页号及为它分配的物理块。块图页永不回收或另作他用,因此旧映射恒为 0;
  • 每条日志条目本质是一份意图记录(intent record),概括某个 data_vio 所需的全部元数据更新;
  • 日志在每个日志块写入前先发 flush,确保该块新映射对应的物理数据已稳定落盘;日志块写入全部带 FUA 位,保证恢复日志条目本身先稳定。日志条目及其代表的数据写,必须先于其他元数据结构被更新,这一顺序使 vdo 在断电等意外中断后能够重建逻辑到物理映射。

写路径:13 个步骤全分解

vdo 的全部写 I/O 都是异步的。每个 bio 一旦被 vdo 保证"终将能完成写入",就会尽快得到确认;一般而言,已确认但未刷盘的写数据可视为"缓存在内存中"。若应用要求数据稳定落盘,就必须像对待其他异步 I/O 一样显式发 flush 或置 FUA 位;关停 vdo target 也会冲刷剩余 I/O。

应用写 bio 依下列步骤推进:

  1. 取 data_vio 并拷贝数据:从池中取一个 data_vio 与 bio 绑定;池空时 bio 阻塞等待,形成对应用的背压(池由自旋锁保护)。新取的 data_vio 被重置;若是写且数据不全为零,则把 bio 数据拷入 data_vio。必须拷贝的原因有二:应用 bio 可能在 data_vio 处理完之前就被确认,后续步骤将无法再访问该 bio;且 bio 可能小于 4K,此时 data_vio 已预先读入底层块,新数据是覆盖进大块的对应扇区。
  2. 占逻辑锁(logical lock)data_vio 对 bio 的逻辑地址提出声明。由于去重涉及共享物理块,必须防止同一逻辑地址被并发修改。声明以哈希表项实现:键为逻辑地址,值为正在处理该地址的 data_vio 指针。若发现已有别的 data_vio 在处理该地址,新来的就等待其结束,并通知当前锁持有者"有人在等"。最值得注意的是,等待逻辑锁的新 data_vio 会把前一个持有者从压缩打包器(packer,见 8d 步)中冲刷出来,而不是任其继续排队等待打包。本步需要取得相应逻辑 zone 的隐式锁,以防止并发修改哈希表——这正是前述 zone 划分提供的隐式加锁。
  3. 遍历块图树、按需分配树节点data_vio 尝试为其逻辑地址找到叶子页,以确认所有必要内部节点已分配;缺失的内部树页此刻从存放应用数据的同一物理池中分配。 a. 若某页节点尚未分配,则须先分配再继续写。这要求 data_vio 锁住待分配的页节点(同第 2 步一样是哈希表项,令其他 data_vio 等待分配完成)。分配期间会释放隐式逻辑 zone 锁,让同 zone 的其他操作继续;分配细节与第 4 步相同。新节点分配后,通过"与新增数据块映射几乎相同的流程"接入树:data_vio 记录向块图树添加节点的意图(第 10 步)、更新新块的引用计数(第 11 步)、重新取得隐式逻辑 zone 锁以向父节点添加新映射(第 12 步)。树更新完毕后继续向下走,所有等待本次分配的 data_vio 也同时放行。 b. 稳态情形下树节点已齐,data_vio 只需下溯至目标叶子,把映射位置(block map slot)记入自身,后续步骤无须再次下树,然后释放隐式逻辑 zone 锁。
  4. 零块则跳步;否则分配空闲数据块:若块全零,直接跳第 9 步。否则尝试分配一个空闲数据块作为兜底(即使去重、压缩都不可行也保证能落盘)。本步取得某物理 zone 的隐式锁以在其中寻找空闲块:data_vio 逐个 slab 查找,若首个 zone 无空闲则依次换取下一个物理 zone 的隐式锁继续,直到找到块或所有 zone 都找遍。找到的空闲块会被加一个 struct pbn_lock(物理块锁),该锁记录各类对物理块的声明(reference/claim 类型),并像第 2 步那样放入哈希表(该表同样受隐式物理 zone 锁保护)。空闲块的引用计数随即被更新,防止其他 data_vio 再把它当空闲块。
  5. (条件)确认 bio:若已拿到分配,data_vio 便拥有了完成写所需的一切资源,此时可安全确认应用 bio。确认发生在独立线程上,避免应用回调阻塞其他 data_vio。若分配失败,data_vio 仍会继续尝试去重/压缩,但 bio 暂不确认——因为 vdo 设备可能已无空间。
  6. 哈希:对 data_vio 的数据做哈希,得到"记录名(record name)"记入 data_vio
  7. 汇入/创建 hash_lockstruct hash_lock 管理所有正在写同一份数据的 data_vio。活跃 hash lock 记录在类似第 2 步的哈希表中,受哈希 zone 的隐式锁保护。若无同名 hash lock,则 data_vio 从池中取一个加入哈希表并自任 agent(代理)(hash lock 池同样受隐式哈希 zone 锁保护),由 agent 完成"数据写到哪里"的全部决策。若已存在同名 hash lock 且数据与 agent 相同,新 data_vio 等待 agent 完工后共享其结果。罕见情况:存在同名 hash lock 但数据与 agent 不一致(例如两个不同数据块碰巧同哈希),则该 data_vio 跳到 8h 直接写自己的数据。
  8. agent 尝试去重或压缩,按以下子步骤展开: a. agent 初始化并发送内嵌的去重请求 struct uds_request 给去重索引。本步无需任何锁(索引组件自管锁),agent 等待索引响应或超时; b. 若索引返回建议(advice),agent 尝试对建议的物理地址取得物理块锁,以便读回数据验证与自身数据一致、且还能接受更多引用。若该物理地址已被别的 data_vio 锁定,其中的数据可能很快被覆写,不适合用于去重; c. 若数据匹配且物理块还能加引用,agent 及所有等待它的 data_vio 把该物理块记为各自的新物理地址,进入第 9 步记录新映射。若 hash lock 内 data_vio 数超过可用引用槽位,剩余 data_vio 中会有一个成为新 agent,视同"未获得有效建议"继续 8d; d. 若未找到可用重复块,agent 先确认自己已有第 3 步分配的物理块可写;没有分配则由 hash lock 中有分配的其他 data_vio 接管 agent;若大家都没有分配,这些写即为空间不足,直接转入第 13 步清理; e. agent 尝试压缩数据:若压不动则跳到 8h 直写;若压缩后足够小,则释放隐式哈希 zone 锁,进入打包器 struct packer,被放入一个 bin(struct packer_bin)与其他 data_vio 合流。所有压缩操作都要求 packer zone 的隐式锁。packer 最多能把 14 个压缩块塞进一个 4K 物理块(源码 packer.hstruct packer_bin 保存各片段尺寸的 sizes[VDO_MAX_COMPRESSION_SLOTS] 数组与 types.h 的 14 槽常量互为印证)。压缩只有至少容纳 2 个 data_vio 时才有意义,所以 data_vio 可能为凑满压缩块而等待任意长的时间;vdo 内置了驱逐机制——应用 flush、设备关停、后续 data_vio 覆写同一逻辑地址、或迟迟无法与任何压缩块配对且 bin 需要让位给更可压数据时,都会触发驱逐,被驱逐者转 8h 直写; f. agent 一旦填满某个 packer bin(14 槽用尽或无剩余空间),就借助其中某个 data_vio 已分配的物理块把 bin 写盘(8d 已确保分配就绪); g. 每个 data_vio 把该压缩块设为自己的新物理地址:取得物理 zone 隐式锁,为压缩块获取 struct pbn_lock 并转为共享锁,随后释放隐式物理 zone 锁进入 8i; h. 任何从 packer 被驱逐的 data_vio,用第 3 步的分配直接写自身数据; i. 数据写盘后,若 data_vio 是某 hash lock 的 agent,它重新取得隐式哈希 zone 锁,把物理地址尽可能多地共享给 hash lock 内其他 data_vio,随后每个 data_vio 进入第 9 步记录新映射; j. 若 agent 确实写了新数据(无论压缩与否),就去更新去重索引以反映新数据的位置,然后释放隐式哈希 zone 锁。
  9. 确定逻辑地址旧映射:由于叶子页通常太多无法全部驻留内存,vdo 维护"块图缓存"。若所需叶子页不在缓存,data_vio 需预留缓存槽并载入目标页(可能逐出更老的缓存页),然后查出该逻辑地址当前的物理地址(旧物理映射,若有)并记录。本步需要锁块图缓存结构(受隐式逻辑 zone 锁保护)。
  10. 写恢复日志条目:条目含逻辑块地址、旧物理映射、新物理映射。写条目需持隐式恢复日志锁;data_vio 会一直等到包含自己条目在内的所有恢复块都写完并刷盘,保证事务稳定。
  11. 写 slab journal 条目:日志条目稳定后,data_vio 写两条 slab journal 条目——新映射的增计数(increment)与旧映射的减计数(decrement)。两步各需持有受影响物理 slab 的锁(受其隐式物理 zone 锁保护)。为保证恢复正确性,同一 slab journal 内条目的顺序必须与对应恢复日志条目的顺序一致:若两条分属不同 zone 则并发执行;若同属一个 zone 则总是先增后减,避免下溢。每条 slab journal 条目入内存后,内存中的引用计数同步更新。
  12. 更新块图映射:两次引用计数更新完成后,data_vio 取得隐式逻辑 zone 锁,把块图中的逻辑到物理映射指向新物理块。至此写操作完成。
  13. 清理与归还:若持有 hash lock,先取隐式哈希 zone 锁把 hash lock 还回池;再取隐式物理 zone 锁释放所持分配块的 pbn_lock——若分配了却没用上,还要把该块引用计数清零,将其释放给后续 data_vio;随后取隐式逻辑 zone 锁释放第 2 步的逻辑锁;最后,若 bio 此前未被确认则在此确认,并把 data_vio 归还池中。

读路径:简单直接

应用读 bio 的步骤简单得多:完成写路径第 1、2 步(取 data_vio、锁逻辑地址)。若该逻辑地址恰好有保证会完成的写 data_vio 在处理,读 data_vio 直接拷走写 data_vio 中的数据返回;否则像第 3 步那样下溯块图树查逻辑到物理映射,再读出指定物理地址的数据并按需解压。关键差异:读 data_vio 不会分配缺失的块图树节点——若内部块图节点尚不存在,该逻辑地址必然仍未映射,读路径直接返回全零。清理与确认同写路径第 13 步,只是仅需释放逻辑锁并归还自身。

小写(Small Writes):512 字节的读写改写

vdo 内部一切存储都以 4K 块管理,但可接受小至 512 字节的写入。小于 4K 的写需要一次读-改-写(read-modify-write):读入相关 4K 块,把新数据覆盖到该块的相应扇区,再为修改后的数据块启动一次写。该操作的读、写两阶段与普通读、写几乎相同,全程复用一个 data_vio

崩溃恢复与只读重建

崩溃恢复:vdo 崩溃后重启,会尝试从恢复日志恢复。在下次启动的 pre-resume 阶段读取恢复日志:先取有效条目的"增量"部分灌入块图;再按必需顺序把有效条目灌入各 slab journal;最后每个物理 zone 尝试重放至少一个 slab journal 以重建一个 slab 的引用计数。一旦每个 zone 都有空闲空间(或确定没有),vdo 即恢复在线,其余 slab journal 在后台继续重建引用计数。

只读模式(read-only)与重建:vdo 若遭遇不可恢复错误会进入只读模式,表示"部分此前已确认的数据可能已丢失"。由于存在丢数据可能,vdo 绝不自动重建,必须显式指示才会尽力重建以回到可写状态。只读重建时:块图仍按上述方式从恢复日志恢复;但引用计数不再由 slab journal 重建——而是把引用计数清零,随后遍历整张块图,依据块映射重建全部引用计数。这样虽可能丢失部分数据,却能保证块图与引用计数彼此一致,使 vdo 恢复常态运行并继续接受写入。在只读模式中准备重建的配套工具是 vdoforcerebuild,属用户空间组件,见下文。

配套使用速览与运行特性

vdo-design.rst 全文聚焦设计,其 同目录使用文档 vdo.rst 提供面向运维的完整说明。此处提炼要点以串联设计结论:

表行(table line)语法(详见 vdo.rst):

<offset> <logical device size> vdo V4 <storage device>
<storage device size> <minimum I/O size> <block map cache size>
<block map era length> [optional arguments]
  • offset:vdo 逻辑空间起始扇区;logical device size:对外提供的扇区数,须与 vdo 卷当前逻辑尺寸一致;
  • storage device:承载卷数据与元数据的设备;storage device size:以 4096 字节块计、须与卷当前尺寸一致;
  • minimum I/O size:接受的最小 I/O,合法值 512 或 4096,推荐 4096;
  • block map cache size:块图缓存大小(4096 字节块数),最小/推荐 32768 块;若逻辑线程数非零,缓存不得小于每逻辑线程 4096 块;
  • block map era length:块图缓存写回脏页的速度,值越小重建越快但常态写放大越大;最大/推荐 16380,最小 1;
  • 可选参数含线程组配置(ack 默认 1、bio 默认 4、bioRotationInterval 默认 64、cpu 默认 1、hash/logical/physical 默认 0 且三者须同为 0 或同非零、logical 上限 60、physical 上限 16),以及 maxDiscarddeduplication(默认 on)、compression(默认 off)。hash/logical/physical 的"默认 0 与上限"与本文前述 zone 常量、每 data_vio 一步一 zone 的设计直接呼应。

在线修改:已运行且未挂起的 vdo 卷可载入新表,下次 resume 生效;可改参数为逻辑尺寸、物理尺寸、maxDiscardcompressiondeduplication。逻辑/物理尺寸只增不减(逻辑上限 4 PB;物理扩容至少新增 32832 个 4K 块,且不得超过 8192 slab 对应规模)。经典命令序列(完整示例见 vdo.rst):

# 创建:1GB 逻辑、1GB 物理,落到 /dev/dm-1
dmsetup create vdo0 --table \
  "0 2097152 vdo V4 /dev/dm-1 262144 4096 32768 16380"

# 扩逻辑到 4GB 后 reload+resume
dmsetup reload vdo0 --table \
  "0 8388608 vdo V4 /dev/dm-1 262144 4096 32768 16380"
dmsetup resume vdo0

dmsetup remove vdo0

运行期特性提醒(对上层文件系统的影响,务必知晓):

  • 已有块的覆写不保证成功——底层物理块可能被多引用,覆写通常需要一个空闲块;
  • 块不再使用时发出 discard 可让 vdo 释放相关引用;对精简配置的 vdo,及时 discard 闲置块是防止卷写满的关键;但受共享影响,任何一次 discard 都不保证回收空间;
  • 只要底层存储正确实现 flush,vdo 对崩溃是稳健的,但未刷盘的写在崩溃后不保证存留
  • 单次写入处理量大但高度可并行:vdo 在更高 I/O 深度下吞吐更好,可支撑最多 2048 个并发请求——与 2048 个 data_vio 的池规模互为印证。

内存估算参考(来自 vdo.rst):固定 38 MB 之外,按块图缓存 1 MB→1.15 MB RAM(缓存本身最低 150 MB)、逻辑空间 1 TB→1.6 MB、物理存储 1 TB→268 MB 线性增长;去重索引另按窗口计——密集索引 1 GB RAM 对应 1 TB 窗口,稀疏索引 1 GB RAM 对应 10 TB 窗口(与前文稀疏窗口 10 倍关系吻合)。索引配置在格式化时固定、不可修改。

调优主线(详见 vdo.rst):首要调整块图缓存大小——默认 RAM 中 128 MB 元数据缓存支持约 100 GB 逻辑空间的高效访问,工作集超出缓存即掉性能,应按比例放大;其次调整逻辑/物理线程数(各自分管互不相交的块图/数据块区段,增多可提吞吐,但过多会浪费资源并加剧争用);bio 提交线程越少越利于 I/O 重排但单请求排队更久;bio 确认线程通常在回调繁重时才需要多于 1;CPU 线程承担哈希与压缩,启用压缩的负载可适当增加;哈希线程用于按哈希归类请求,多数场景 1 个足够。

小结:一份文档对应一整套工程实现

对照 vdo-design.rstdrivers/md/dm-vdo/ 源码可以看到,几乎每个设计要点都能在代码中找到一一对应的实体:zone/线程约束落在 constants.hMAX_VDO_LOGICAL_ZONESMAX_VDO_PHYSICAL_ZONES 等常量上;14 个压缩槽与 5 字节映射项落在 types.hblock_mapping_state 上;每页 812 表项与 60 棵树落在 constants.hVDO_BLOCK_MAP_ENTRIES_PER_PAGEDEFAULT_VDO_BLOCK_MAP_TREE_ROOT_COUNT 上;章节、卷索引、增量索引、稀疏窗口则由 indexer/ 子目录整组文件承载。理解这份设计文档,等于同时掌握了这些源码模块的阅读地图——从一次 4K 写入出发,你可以顺着 data_vio 的流转依次进入 data-vio.cdedupe.cpacker.cblock-map.cslab-depot.crecovery-journal.c,亲历一次数据从"识别重复"到"安全落盘"的完整旅程。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
898
5.82 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
921
1.84 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.8 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
596
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.02 K
519
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
391