注意
本文档适用于 Ceph 的开发版本。
ECBackend 的下一步建议
奇偶校验增量写入(PARITY-DELTA-WRITE)
RMW(读取-修改-写入)操作当前需要 4 次网络跳数(2 个往返)。原则上,对于某些编码,我们可以通过将更新发送到持有数据块的副本,并让它们计算增量转发给奇偶校验块,从而将跳数减少到 3。
主 OSD 读取“W”块的当前值,然后使用“W”块的新值来计算每个奇偶校验块的奇偶校验增量。W 块和奇偶校验增量块被发送到它们各自的分片。
选择使用读取-修改-写入还是奇偶校验增量写入是一个复杂的策略问题,其细节待定,并且很可能在很大程度上取决于奇偶校验增量与常规奇偶校验生成操作相关的计算成本。然而,据信奇偶校验增量方案在可用时可能是首选。
纠删码库插件的内部接口需要扩展,以支持查询所选算法是否可以进行奇偶校验增量计算,以及在可用时提供实际的奇偶校验增量计算算法接口。
条带缓存(Stripe Cache)
扩展当前 ExtentCache 的用法可能是一个好主意,以便在固定操作释放数据后缓存一些数据。一个重要的优化应用模式是小块顺序写入操作(例如日志文件系统或数据库事务日志的日志)。无论选择何种冗余算法,主 OSD 保留/缓冲条带最近读取/写入的部分都是有利的,以减少网络流量。该缓存的动态内容可用于确定执行读取-修改-写入还是奇偶校验增量写入。此缓存的大小待定,但我们应该计划允许每个活动客户端至少有几个完整的条带。限制每个客户端的缓存占用量将减少“吵闹邻居”问题。
恢复和回滚细节
实现可回滚的准备操作(Prepare Operation)
准备操作通过模拟版本控制或写时复制功能来实现,用于修改对象的一部分。
执行准备操作时,新数据被写入一个临时对象中。PG 操作日志将包含对临时对象的引用,以便可以将其用于恢复目的,以及记录参与操作的所有分片。
为了避免碎片化(以及随之而来的未来读取性能问题),临时对象的创建需要特别注意。临时对象的名称会影响其在 KV 存储中的位置。目前尚不清楚是希望名称靠近基础对象,还是应该为临时对象使用单独的键空间子集。Sam 认为与基础对象共存是首选(他建议使用 ghobject 的代计数器作为临时对象)。而 Allen 认为使用单独的键空间子集是可取的,因为这些键是短暂的,我们不希望将它们与基础对象键共存。也许在这里进行一些建模可以帮助解决这个问题。临时对象的数据希望尽可能靠近基础对象的数据。这最好通过添加一个新的 ObjectStore 创建原语来实现,该原语将基础对象作为额外的参数,作为对分配器的提示。
Sam:我认为短暂性可能是一个误导。我们将原子地更新捐赠对象和主对象,所以无论捐赠对象的生命周期如何,我们似乎都希望它们在键空间中相邻。
应用操作将数据从临时对象移动到基础对象中的正确位置,并删除相关的临时对象。此操作使用专门的 ObjectStore 原语完成。在当前的 ObjectStore 接口中,这可以使用 clonerange 函数后跟 delete 来完成,但使用专门的移动原语可以更高效地完成。在 FileStore 上实现专门的原语可以通过复制数据来完成。一些文件系统具有扩展功能,也可以实现此操作(例如交换文件块的碎片整理 API)。预计 NewStore 将能够高效且原生支持此功能(有人指出,此序列要求临时对象分配(通常很小)能高效地转换为主要对象的块,并且以前在主要对象中的块必须能够以最小的开销重用)。
准备操作和应用操作可以在时间上任意分开。如果读取操作访问了一个已被准备操作更改(但没有相应的应用操作)的对象,它必须返回准备操作之后的数据。这是通过创建一个内存数据库来实现的,该数据库包含已执行准备操作但没有相应应用操作的对象。所有读取操作都必须查阅此内存数据结构以获取正确的数据。应明确认识到,很可能存在针对单个基础对象的多次准备操作,代码必须正确处理这种情况。此代码作为 ObjectStore 和所有现有读取器之间的一层实现。令人恼火的是,当间隔更改时,我们希望丢弃此状态,因此激活后需要做的第一件事是主 OSD 和副本应用直到 last_update,以便空缓存是正确的。
在 peering 期间,现在很明显,未应用的准备操作可以通过简单地删除相关的临时对象并从内存数据结构中删除该条目来轻松回滚。
部分应用 Peering/恢复修改
有些写入足够小,不需要更新持有数据块的所有分片。为了最大限度地减少写入放大,最好根本避免写入这些分片,甚至延迟发送日志条目,直到下一次实际命中该分片的写入。
延迟(缓冲)传输给见证 OSD 的准备和应用操作会产生 peering 必须处理的新情况。特别是,确定权威 last_update 值(以及因此选择具有权威日志的 OSD)的逻辑必须修改,以考虑权威 OSD 仅作为见证者的有效但缺失(即延迟/缓冲)pglog 条目。
因为部分写入可能在未在每个副本上持久化日志条目的情况下完成,所以我们必须做更多的工作来确定权威的 last_update。约束条件(与复制的 PG 一样)是 last_update >= 发送提交给客户端的最新的日志条目(称之为 actual_last_update)。其次,我们希望 last_update 尽可能小,因为 actual_last_update 之后的任何日志条目(我们在向客户端发送提交之前不应用日志条目)必须能够回滚。因此,我们选择的 last_update 越小,需要的恢复就越少(我们总是可以回滚,但将副本向前滚动可能需要对象重建)。因此,我们将 last_update 设置为我们能证明未提交的最旧日志条目之前 1。在当前 master 中,这只是该间隔最短日志的 last_update(因为该日志在该点之后没有持久化任何条目——这是向客户端发送提交的先决条件)。对于此设计,我们必须考虑任何日志都可能缺少其未参与的头部日志条目的可能性。因此,我们必须确定我们进入活动的最近间隔(这基本上是 find_best_info 当前所做的)。然后,我们从该间隔中提取每个活动 osd 的日志,回溯到它们之间的最小 last_update。然后,我们将权威间隔中的所有日志扩展到每个日志命中一个它应该参与但未记录的条目。这些扩展日志中最短的日志因此必须包含我们向客户端发送提交的任何日志条目——最后一个条目为我们提供了 last_update。
深度 scrub 支持
最简单的答案可能对我们来说是最好的选择。EC 池目前根本不能使用 omap 命名空间。最简单的解决方案是获取 omap 空间的一个前缀,并将 N 个 M 字节的 L 位校验和打包到每个键/值中。添加前缀似乎是一种明智的预防措施,以防我们最终想要在 omap 空间中存储其他东西。看起来任何写入都需要至少读取包含修改范围的块。然而,对于能够计算奇偶校验增量的编码,我们可能不需要读取整个条带。即使没有这个,我们也不希望写入不参与写入的块。因此,每个分片应该只存储自己的校验和。看起来你可以在奇偶校验块上存储所有分片的校验和,但可能没有在所有写入上都修改的指定奇偶校验块(LRC 或 shec 提供了两个例子)。L 应该有固定的选项数量(16, 32, 64?),并且可以在池创建时按池配置。N 和 M 也应该可以在池创建时配置,并提供合理的默认值。
我们需要处理在线升级。我认为正确的答案是,对带有“仅追加”校验和的对象进行第一次覆盖写入时,会删除“仅追加”校验和,并写入实际写入的条带校验和。下一次深度 scrub 然后写入完整的校验和 omap 条目。
RADOS 客户端确认生成优化
既然恢复方案已经理解,我们可以讨论主 OSD 生成 RADOS 操作确认(ACK)(上面的“足够”)。主 OSD 不需要等待所有分片完成各自的准备操作。使用我们的例子,RADOS 操作只写入条带的“W”块,主 OSD 将生成并发送 W+M 个准备操作(可能包括发送给自己)。主 OSD 只需要等待足够的写入分片以确保数据恢复,因此在写入 W + M 块后,你可以承受 M 个块的丢失。因此,主 OSD 可以在 W + M - M => W 个准备操作完成后生成 RADOS ACK。
不一致的 object_info_t 版本
只写入实际更改的块的一个自然结果是,我们不希望更新未更改的对象的 object_info_t。我实际上认为这样做会带来问题:pg ghobject 命名空间通常很大,除非 osd 在一小部分对象上看到大量覆盖写入,否则我期望每次写入在底层 ghobject_t->data 映射中相距足够远,足以构成随机元数据更新。因此,我们必须接受并非每个分片都会在其 object_info_t 中具有当前版本。我们甚至无法限定特定分片上的版本有多旧。特别是,主 OSD 不一定具有当前版本。有人可能会争辩说奇偶校验分片总是具有当前版本,但并非每个代码都必然有指定的奇偶校验分片会看到每一次写入(LRC 肯定如此,如果我没记错 shec 也是如此,即使使用更普通的代码,也可能希望根据对象哈希轮换分片)。即使你选择指定一个分片作为见证所有写入的分片,pg 也可能在缺少该特定分片的情况下处于降级状态。这有点棘手,目前读取和写入隐式返回写入的最新对象版本。在读取时,我们必须读取 K 个分片才能回答这个问题。我们可以通过添加一个“不要告诉我当前版本”的标志来解决这个问题。写入更成问题:我们需要来自最近一次写入的 object_info 才能形成新的 object_info 和 log_entry。
一个真正可怕的选择是完全从 object_info_t 中消除 version 和 prior_version。它有几个特定的用途:
在 OSD 启动时,我们通过从 last_update 向后扫描到 last_complete 来初始化 missing set,将存储对象的 object_info_t 与最近日志条目的版本进行比较。
在回填期间,我们比较主 OSD 和目标之间的版本以避免一些推送。我们也在其他地方使用它。
在推送和拉取对象时,我们验证版本。
我们在读取和写入时返回它,并允许 librados 用户在写入时原子地断言它,以允许用户处理写入竞争(rbd 广泛使用)。
情况 (3) 实际上不是必需的,只是方便。好吧。(4) 更令人恼火。写入很容易,因为我们知道版本。读取很棘手,因为我们可能不需要从所有副本读取。最简单的解决方案是在 rados 操作中添加一个标志,以便在读取时不返回用户版本。我们也可以暂时不支持 ec 上的用户版本断言(我想?唯一的用户是 rgw bucket indices iirc,它们将始终在 replicated 上,因为它们使用 omap)。
我们可以通过显式维护 missing set 来避免 (1)。在没有相应日志条目的情况下,已经可能存在缺失对象(考虑最近一次写入是一个数周未更新的对象的情况。如果该写入发散,则需要根据不在日志中的 prior_version 将写入对象标记为缺失。)PGLog 已经有一种处理这些边缘情况的方法(参见 divergent_priors)。我们将简单地将其扩展为包含整个 missing set,并与日志和对象原子地维护它。这并不是一个不合理的选择,额外的键将少于现有日志键 + divergent_priors,并且无论如何都不会在快速写入路径中更新。
第二种情况有点棘手。它实际上是针对 pg 处于非 acting set 的时间足够长,以至于日志不再重叠,但又不足以让 PG 愈合并删除旧副本的情况的优化。不幸的是,这描述了在 noout 设置下节点因维护而关闭的情况。在这种情况下重新回填整个 OSD 可能不可接受,因此我们需要能够快速确定给定有效的 acting set 的其他分片,特定分片是否是最新的。
让不更改对象大小的普通写入根本不触及 object_info。这意味着 object_info 版本将与 pg 日志条目版本不匹配。在 pg_log_entry_t 中包含当前 object_info 版本以及哪些分片参与了(如上所述)。除了 object_info_t attr 之外,在每个分片 s 上记录一个向量,记录对于每个其他分片 s',跨越 s 和 s' 的最近一次写入。在操作上,我们在每个分片上维护一个包含该向量的 attr。写入 S 会更新 S 中每个分片在 S 的属性中 S' 的版本戳条目(并保持其余不变)。如果在回填期间我们有一个有效的 acting set,我们必须有一个见证了所有已完成写入的分片——因此,在所有 acting set 分片上取每个条目的最大值必须为我们提供每个分片的当前版本。在恢复期间,我们将 recovery 目标的属性设置为该最大向量(问题:对于 LRC,我们可能不需要触及 acting set 的大部分来恢复特定分片——我们是只使用我们用于恢复的分片的最大值,还是需要从 acting set 的其余部分获取版本向量?我不确定,无论如何都不是大问题,我想)。
上述方法使我们能够在不知道当前对象版本(即日志条目版本)的情况下执行盲写入,同时仍允许我们避免回填最新的对象。唯一的缺点是我们的回填扫描将扫描所有副本,而不仅仅是主 OSD 和回填目标。
值得在 scrub 中添加检查收集到的版本向量的一致性的能力——可能只需获取 3 个随机有效子集并验证它们是否生成相同的权威版本向量。
实施策略
不用说,试图在一个庞大的 PR 中完成所有这些是不明智的。合并未经测试的代码也不是一个好主意。为此,值得考虑哪些部分可以单独测试(可能需要一些临时脚手架)。
我们可以使用当前的实现轻松实现对覆盖写入友好的校验和方案。我们希望在每个池的基础上启用它(可能使用一个标志,我们稍后会将其重新用于实际的覆盖写入支持)。我们可以在套件中的一些 ec thrashing 测试中启用它。我们还可以添加一个简单的测试来验证在现有 ec 池上启用它的行为(稍后,我们希望能够将仅追加 ec 池转换为覆盖 ec 池,因此该测试将随着我们的进行而扩展)。该标志应由实验功能标志控制,因为我们不希望将其作为有效配置支持——仅用于测试。我们需要在深度 scrub 期间原地升级仅追加的池。
同样,我们可以使用当前的实现实现不稳定的 extent cache,它甚至允许我们取消副本在提交后发送给主 OSD 的可读 ack,从而释放锁。同样,实现它,用实验标志控制,添加到一些自动化测试中。我看不出有什么理由不使用与上面相同的标志。
我们当然可以在有任何用户之前实现 move-range 原语并进行单元测试。为现有 objectstore 测试添加覆盖范围就足够了。
显式 missing set 现在可以实现,与上面相同——甚至可以使用相同的功能位。
上面概述的 TPC 协议实际上可以在仅追加 EC 池上实现。与上面相同,甚至可以使用相同的功能位。
抑制读取操作用户版本返回的 RADOS 标志可以立即实现。主要只需要单元测试。
版本向量问题很有趣。对于仅追加 EC 池,它是毫无意义的,因为所有写入都会增加大小,因此会更新 object_info。我们可以在 replicated 池上实现它。这有点愚蠢,因为所有“分片”都看到所有写入,但它仍然允许我们实现并部分测试增强的回填代码以及额外的 pg log entry 字段——这取决于显式 pg log entry 分支是否已经合并。我不太清楚这是否值得单独进行。代码量足够大,我宁愿独立完成,但它也是大量的脚手架,稍后将被丢弃。
PGLog 条目需要能够记录参与者,并且需要修改日志比较以扩展它们本不会见证的条目。此逻辑应抽象在 PGLog 后面,以便可以进行单元测试——这将允许我们在实际的 ec 覆盖写入代码合并之前对其进行一定程度的测试。
无论 ec 插件接口需要发生什么变化,都可能独立于其余部分完成(等待下面问题的解决)。
在我看来,执行 ec 覆盖写入的实际细节在上述内容完成之前无法有效地测试(因此无法实现),因此最好先将所有支持代码放入。
开放问题
是否有我们可以使用的代码允许我们在不重新读取和重新编码整个条带的情况下计算奇偶校验增量?如果是这样,这是我们现在需要设计的东西,还是可以合理推迟?
EC 插件接口需要发生什么变化?