注意

本文档适用于 Ceph 的开发版本。

SeaStore

目标和基础

  • 目标 NVMe 设备。不主要关注 pmem 或 HDD。

  • 使用 SPDK 进行用户空间驱动的 IO

  • 使用 Seastar futures 编程模型来促进 run-to-completion 和分片内存/处理模型

  • 当与使用 DPDK 的 seastar-based messenger 结合使用时,允许在读写路径上进行零(或最小)数据复制

动机和背景

所有闪存设备在内部都以段(segments)的形式组织,这些段可以高效写入,但必须整体擦除。NVMe 设备通常对段中的哪些数据仍然“存活”(尚未被逻辑丢弃)知之甚少,这使得设备内部不可避免的垃圾收集效率低下。我们可以设计一种有利于较低层 GC 的磁盘布局,并在较高层驱动垃圾收集。

原则上,细粒度的丢弃(discard)可以向设备传达我们的意图,但实际上,丢弃在设备和中间的软件层中实现得很差。

基本思想是所有数据都将按顺序流式传输到设备上的大段中。在 SSD 硬件中,段的大小可能在 100MB 到数十 GB 的数量级。

SeaStore 的逻辑段理想情况下应与硬件段完美对齐。实际上,确定几何结构并充分提示设备所写入的 LBA 应与底层硬件对齐可能具有挑战性。在最坏的情况下,我们可以将逻辑段结构化为对应于例如物理段大小的 5 倍,这样我们就有大约 ~20% 的数据未对齐。

当我们达到某个利用率阈值时,我们将清理工作与正在进行的写入工作负载混合在一起,以便从先前写入的段中撤出(evacuate)存活数据。一旦它们完全空闲,我们就可以丢弃整个段,以便设备可以擦除和回收它。

关键是将少量清理工作与每次写入事务混合在一起,以避免写入延迟的峰值和波动。

数据布局基础

一个或多个核心/分片将同时读取和写入设备。每个分片将拥有自己独立操作的数据,并流式传输到自己的开放段。支持流的设备可以相应地得到提示,以便来自不同分片的数据不会在底层介质上混在一起。

持久内存

随着上面最初的顺序设计成熟,我们将引入持久内存支持来存储元数据和缓存结构。

设计

该设计在很大程度上基于 f2fs 和 btrfs。每个 reactor 管理自己的根。在重用段之前,我们会将任何存活的块重写到开放段中。

因为我们只顺序写入开放段,所以在稳态时,我们必须“清理”每写入一个字节的现有段中的一个字节。通常,我们需要保留一部分可用容量以确保写入放大保持在可接受的低水平(20% 对应 2x?-- TODO:查找先前的工作)。作为设计选择,我们希望避免后台 GC 方案,因为它往往会使操作成本估算复杂化,并倾向于引入非确定性延迟行为。因此,我们希望有一组结构允许我们将现有段中的块与正在进行的客户端 IO 内联(inline)迁移。

为此,从高层次上讲,我们将维护两个基本的元数据树。首先,我们需要一个将 ghobject_t 映射到 onode_t 的树(onode_by_hobject)。其次,我们需要一种方法来查找段中的存活块,以及一种将内部引用与物理位置解耦的方法(lba_tree)。

每个 onode 直接包含 xattrs,以及 omap 和 extent 树的顶部(优化:我们应该能够将足够小的对象放入 onode 中)。

段布局

底层存储被抽象为一组段。每个段可以处于 3 种状态之一:empty(空)、open(开放)、closed(关闭)。段的字节内容是记录序列。记录以标头(包括长度和校验和)为前缀,并包含一系列增量(deltas)和/或块。每个增量描述了某个块的逻辑突变。每个包含的块都是一个对齐的 extent,可以通过 <segment_id_t, segment_offset_t> 来寻址。可以通过构建一个结合了增量和更新块的记录并将其写入开放段来实现事务。

请注意,段通常会很大(例如 >=256MB),因此通常不会有很多段。

记录:[ header | delta | delta… | block | block … ] 段:[ record … ]

有关 Journal 实现,请参阅 src/crimson/os/seastore/journal.h。有关大多数 seastore 结构,请参阅 src/crimson/os/seastore/seastore_types.h。

每个分片将为写入保持 N 个开放段

  • HDD:N 在一个分片上可能是 1

  • NVME/SSD:N 在每个分片上可能是 2,一个用于“journal”,一个用于已完成的数据记录,因为它们的生命周期不同。

我认为要保持开放的确切数量以及如何在其间划分写入将是一个调优问题 -- GC/布局应该灵活。在实际可行的情况下,目标可能是按预期生命周期划分块,以便一个段要么具有长寿命块,要么具有短寿命块。

底层物理层通过基于段的接口公开。请参阅 src/crimson/os/seastore/segment_manager.h

Journal 和原子性

一个开放段被指定为 journal。事务由原子写入的记录表示。记录将包含作为事务一部分写入的块以及增量,增量是对现有物理 extent 的逻辑突变。事务增量总是写入 journal。如果事务与写入其他段的块相关联,则只有在其他块持久化之后才应写入包含增量的最终记录。崩溃恢复是通过查找包含当前 journal 开头的段、加载根节点、重放增量以及根据需要将块加载到缓存中来完成的。

请参阅 src/crimson/os/seastore/journal.h

块缓存

每个块处于两种状态之一

  • clean(干净):可能在缓存中,也可能不在,读取可能导致缓存驻留,也可能不导致

  • dirty(脏):记录的当前版本需要覆盖来自 journal 的增量。必须完全存在于缓存中。

我们需要定期修剪 journal(否则,我们将不得不从头开始重放 journal 增量)。为此,我们需要通过重写根块和所有当前脏块来创建检查点。请注意,我们可以相对不频繁地进行 journal 检查点,并且它们不需要阻塞写入流。

请注意,增量可能不是字节范围修改。考虑一个 btree 节点,其结构为左侧键和右侧值(一种用于提高点查询/键扫描性能的常见技巧)。将键/值插入到该节点的最小值处将涉及移动大量字节,如果纯粹表示为一系列字节操作,这将是昂贵(或冗长)的。因此,每个增量都指示相应 extent 的类型和位置。因此,每种块类型都可以根据需要实现 CachedExtent::apply_delta。

请参阅 src/os/crimson/seastore/cached_extent.h。请参阅 src/os/crimson/seastore/cache.h。

GC

在重用段之前,我们必须迁移所有存活块。因为我们只顺序写入空段,所以对于我们写入当前开放段的每个字节,我们需要清理现有关闭段的一个字节。作为设计选择,我们希望避免后台工作,因为它会使操作成本估算复杂化,并倾向于产生非确定性延迟峰值。因此,在正常操作下,每个 seastore reactor 将插入足够的工作量来以与传入操作相同的速率清理段。

为了使稀疏段的成本低廉,我们需要一种方法来正向识别死块。因此,对于写入的每个块,都将在 lba 树中添加一个条目,其中包含指向段中先前 lba 的指针。任何移动块或修改现有块引用集的事务都将包含更新 lba 树所需的增量/块,以更新或删除先前的块分配。因此,GC 状态只需要维护一个迭代器(某种形式),指向当前正在清理的段的 lba 树段链表,以及一个指向要检查的下一个记录的指针 -- lba 分配树中不存在的记录可能仍然包含根(如分配树块),因此必须检查记录元数据以获取指示根块的标志。

对于每个事务,我们评估当前可用空间和当前存活空间的启发式函数,以确定是否需要执行清理工作(可以只是存活/使用空间比率的范围)。

TODO:目前还没有 GC 实现

逻辑布局

使用上述块和增量语义,我们构建了两个根级别树:- onode 树:将 hobject_t 映射到 onode_t - lba_tree:将 lba_t 映射到 lba_range_t

上述每个结构都由块组成,突变以增量编码。上述树的每个节点都映射到一个块。每个块要么是物理寻址的(根块和 lba_tree 节点),要么是逻辑寻址的(所有其他)。物理寻址的块由 paddr_t:<segment_id_t, segment_off_t> 元组定位,并在记录中标记为物理寻址。逻辑块由 laddr_t 寻址,需要查找 lba_tree 才能寻址。

因为缓存/事务机制位于 lba 树级别以下,所以我们可以通过简单地将它们都包含在事务中来表示 lba 树和其他结构的原子突变。

LBAManager/BtreeLBAManager

LBAManager 接口的实现负责管理逻辑->物理映射 -- 请参阅 crimson/os/seastore/lba_manager.h。

BtreeLBAManager 使用 wandering btree 方法直接在 Journal 和 SegmentManager 之上实现此接口。

因为 SegmentManager 不允许我们预测已提交记录的位置(这是 SMR 和区域设备的属性),所以在同一事务中创建的块引用必然是*相对*地址。BtreeLBAManager 维护一个不变量,即内存中任何块的副本将只包含绝对地址,当 !is_pending() 时 -- on_commit 和 complete_load 根据实际的块地址填充绝对地址,on_delta_write 根据刚刚提交的记录填充绝对地址。当 is_pending() 时,如果 is_initial_pending,内存中的引用是 block_relative(因为它们将被写入原始块位置),否则是 record_relative(值将被写入增量)。

TransactionManager

TransactionManager 负责在 Journal、SegmentManager、Cache 和 LBAManager 之上提供统一接口。用户可以根据逻辑地址分配和突变 extent,并在后台处理段清理。

请参阅 crimson/os/seastore/transaction_manager.h

后续步骤

Journal

  • 支持扫描段以查找物理寻址的块

  • 添加对修剪 journal 和释放段的支持。

缓存

  • 支持重写脏块

    • 需要向 CachedExtent 添加支持以查找/更新依赖块

    • 需要添加支持以将脏块写入添加到 try_construct_record

LBAManager

  • 添加对 pinning 的支持

  • 添加 segment -> laddr 以用于 GC

  • 支持定位段中剩余的使用块

GC

  • 初步实现

  • 支持 BtreeLBAManager 跟踪段中的使用块

  • 用于识别要清理段的启发式方法

其他

  • 添加对定期生成 journal 检查点的支持。

  • Onode 树

  • Extent 树

  • 剩余的 ObjectStore 集成

ObjectStore 考虑因素

拆分、合并和分片

当前的 ObjectStore 要求之一是能够以 O(1) 时间拆分集合(PG)。从 mimic 开始,我们还需要能够将两个集合合并为一个(即,与拆分完全相反)。

然而,根据当前的分片方案,我们拆分成的 PG 将哈希到 OSD 的不同分片。可以想象用一个临时映射替换该分片方案,将较小的子 PG 指向正确的分片,因为我们通常无论如何都会将该 PG 迁移到另一个 OSD,但这在合并情况下对我们没有帮助,因为组成部分可能最初位于不同的分片上,最终需要由同一个集合处理(并通过单个事务进行操作)。

这表明我们可能需要一种方法,让通过一个分片写入的数据能够“切换所有权”,并稍后由另一个分片读取和管理。

由 Ceph 基金会为您呈现

Ceph 文档是由非营利性 Ceph 基金会 资助和托管的社区资源。如果您希望支持这项工作和我们的其他努力,请考虑 立即加入