注意
本文档适用于 Ceph 的开发版本。
纠删码增强功能
目标
我们的目标是提高纠删码的性能,特别是对于小随机访问,使其更适合使用纠删码池来存储块数据和文件数据。
我们希望减少每个客户端 I/O 的 OSD 读取和写入访问次数(有时称为 I/O 放大),减少 OSD 之间的网络流量(网络带宽),并减少 I/O 延迟(完成读取和写入 I/O 操作的时间)。我们预计这些更改还将适度降低 CPU 开销。
虽然这些更改侧重于增强小随机访问,但某些增强功能也将为较大的 I/O 访问和对象存储提供适度的好处。
以下部分简要描述了我们希望进行的改进。请参阅后面的设计部分了解更多详细信息
当前读取实现
作为参考,这是纠删码读取目前的工作方式
注意:所有图表都说明了 K=4 + M=2 配置,但这些概念和技术可用于所有 K+M 配置。
部分读取
如果只读取少量数据,则无需读取整个条带,对于小型 I/O,理想情况下只需要单个 OSD 参与读取数据。另请参阅下面的更大块大小。
拉取请求 https://github.com/ceph/ceph/pull/55196 正在实现大部分此优化,但它仍会发出完整的块读取。
当前覆盖写入实现
作为参考,这是纠删码覆盖写入目前的工作方式
部分覆盖写入
理想情况下,我们的目标是能够通过仅更新分片子集(那些具有修改数据或编码奇偶校验的分片)来对纠删码条带执行更新。避免对其他分片执行不必要的数据更新很容易,但避免对其他分片执行任何元数据更新则困难得多(请参阅有关元数据更新的设计部分)。
此图过于简单化,仅显示数据流。此优化的最简单实现保留了对每个 OSD 的元数据写入。通过更多的努力,还可以减少元数据更新的数量,有关详细信息,请参阅下面的设计。
奇偶校验增量写入
块存储控制器实现 RAID5 和 RAID6 时使用的一种常见技术是实现有时称为奇偶校验增量写入的技术。当条带的一小部分被覆盖写入时,可以通过读取旧数据,将其与新数据进行异或运算以创建增量,然后读取每个编码奇偶校验,应用增量并写入新奇偶校验来执行更新。这种技术的优点是它可以涉及更少的 I/O,特别是对于 K 值较大的 K+M 编码。该技术并非特定于 M=1 和 M=2,它可以应用于任意数量的编码奇偶校验。
直接读取 I/O
我们希望客户端将小型 I/O 直接提交给存储数据的 OSD,而不是将所有 I/O 请求定向到主 OSD,然后由主 OSD向辅助 OSD发出请求。通过消除中间跳,这减少了网络带宽并改善了 I/O 延迟
写入的分布式处理
现有的纠删码实现处理主 OSD 上的写入 I/O,向其他 OSD 发出读取和写入请求以获取和更新其他分片的数据。这可能是最简单的实现,但它使用了大量的网络带宽。通过奇偶校验增量写入,可以将处理分布到 OSD 之间以减少网络带宽。
直接写入 I/O
此图过于简单化,仅显示数据流 - 直接写入更难实现,需要控制消息发送给主 OSD 以确保对同一条带的写入按正确顺序排列
更大块大小
默认块大小为 4K,这太小了,这意味着小的读取必须被分割并由许多 OSD 处理。如果小型 I/O 可以由单个 OSD 提供服务,则效率更高。选择更大的块大小(例如 64K 或 256K)并实现部分读取和写入将解决此问题,但缺点是小型 RADOS 对象的大小会向上取整为整个条带容量。
我们希望代码能够自动选择使用哪个块大小来优化容量和性能。小对象应使用小的块大小(如 4K),大对象应使用更大的块大小。
代码目前将 I/O 大小向上取整为块大小的倍数,这对于小的块大小来说不是问题。对于更大的块大小和部分读取/写入,我们应该向上取整为页面大小而不是块大小。
设计
我们将分三部分描述我们计划进行的更改,第一部分着眼于现有的纠删码测试工具,并讨论我们认为必要的改进,以使这些更改获得足够的测试覆盖率。
第二部分涵盖了对读取和写入 I/O 路径的更改。
第三部分讨论了元数据的更改,以避免每次元数据更新都需要更新所有分片上的元数据。虽然可以在不减少元数据更新次数的情况下实现许多 I/O 路径更改,但如果元数据更新次数也可以减少,则会带来更大的性能优势。
测试工具
对现有测试工具的调查显示,纠删码的覆盖范围不足,无法仅仅更改代码并期望现有的 CI 流水线获得足够的覆盖范围。因此,第一步将是改进测试工具以获得更好的测试覆盖率。
Teuthology 是用于获取测试覆盖率的主要测试工具,它严重依赖以下测试来生成 I/O
rados 任务 - qa/tasks/rados.py。这使用 ceph_test_rados (src/test/osd/TestRados.cc),它可以生成各种不同的 rados 操作。对读取和写入 I/O 的支持有限,通常使用偏移量 0,尽管有几个测试使用分块读取命令。
radosbench 任务 - qa/tasks/radosbench.py。这使用 rados bench (src/tools/rados/rados.cc 和 src/common/obj_bencher.cc)。可用于生成顺序和随机 I/O 工作负载,顺序 I/O 的偏移量从 0 开始。I/O 大小可以设置,但在整个测试中是恒定的。
rbd_fio 任务 - qa/tasks/fio.py。这使用 fio 向 rbd 映像卷生成读取/写入 I/O
cbt 任务 - qa/tasks/cbt.py。这使用 Ceph 基准测试工具 cbt 来运行 fio 或 radosbench 来对集群性能进行基准测试。
rbd bench。一些独立测试使用 rbd bench (src/tools/rbd/action/Bench.cc) 来生成少量 I/O 工作负载。它也用于 rbd_pwl_cache_recovery 任务。
使用这些工具很难获得对非零(和非条带对齐)偏移量的 I/O 的良好覆盖率,或者生成各种偏移量和 I/O 请求长度,包括块和条带的所有边界情况。有改进 rados、radosbench 或 rbd bench 的空间,以生成更有趣的 I/O 模式来测试纠删码。
对于上面描述的优化,至关重要的是,我们必须拥有良好的工具来检查选定对象或纠删码池中所有对象的一致性,方法是检查数据和编码奇偶校验是否一致。有一个测试工具 ceph-erasure-code-tool,它可以使用插件对一组文件中提供的数据进行编码和解码。然而,teuthology 中似乎没有任何脚本可以执行一致性检查,方法是使用 objectstore tool 读取数据,然后使用此工具验证一致性。我们将编写一些 teuthology 助手,使用 ceph-objectstore-tool 和 ceph-erasure-code-tool 执行离线验证。
我们还希望有一种在线方法来执行完整的一致性检查,无论是针对特定对象还是针对整个池。不方便的是,EC 池不支持类方法,因此无法将其用作实现完整一致性检查的方法。我们将研究在读取请求、池上设置一个标志,或者实现一种新的请求类型来对对象执行完整的一致性检查,并研究对 rados CLI 进行扩展以能够执行这些测试。另请参阅下面关于深度 scrub 的讨论。
当有多个编码奇偶校验并且数据和编码奇偶校验之间存在不一致时,尝试分析不一致的原因很有用。由于多个编码奇偶校验提供了冗余,因此可以有多种方法来重建每个块,这可用于检测最可能的不一致原因。例如,对于 4+2 纠删码和对第一个数据 OSD 的写入丢失,条带(所有 6 个 OSD)将不一致,包括第一个数据 OSD 的任何 5 个 OSD 选择也将不一致,但数据 OSD 2、3 和 4 以及两个编码奇偶校验 OSD 仍将保持一致。虽然条带可能以多种方式进入这种状态,但工具可以得出结论,最可能的原因是 OSD 1 错过了更新。Ceph 没有工具来执行这种类型的分析,但扩展 ceph-erasure-code-tool 应该很容易。
Teuthology 似乎有足够的工具来使 OSD 脱机并再次联机。有一些工具可以注入读取 I/O 错误(而无需使 OSD 脱机),但有改进的空间(例如,能够指定对象中将导致读取失败的特定偏移量,对设置和删除错误注入站点的更多控制)。
teuthology 的总体理念似乎是随机注入故障,并通过蛮力获得对所有错误路径的足够覆盖。这对于 CI 测试来说是一种很好的方法,但是当 EC 代码路径变得复杂并且需要多个错误以精确的时序发生才能导致特定的代码路径执行时,如果没有运行很长时间的测试,就很难获得覆盖率。有一些 EC 的独立测试确实测试了一些多故障路径,但这些测试执行的 I/O 量非常有限,并且在 I/O 正在进行时不会注入故障,因此会错过一些有趣的场景。
为了处理这些更复杂的错误路径,我们建议开发一种新型的纠删码 thrash,它注入一系列错误,并利用调试挂钩在特定点捕获和延迟 I/O 请求,以确保错误注入命中特定的时序窗口。为此,我们将扩展 tell osd 命令以包括额外的接口来注入错误,并在特定点捕获和停止 I/O。
纠删码的某些部分(例如插件)是独立的位代码,可以使用单元测试进行测试。纠删码已经有一些单元测试和性能基准测试工具,我们将寻求扩展这些工具以获得可以独立运行的代码的进一步覆盖。
I/O 路径更改
避免不必要的读取和写入
当前代码为读取和覆盖写入 I/O 读取了太多数据。对于覆盖写入,它还会重写未修改的数据。发生这种情况是因为读取和覆盖写入被向上取整为全条带操作。当数据主要按顺序访问时,这不是问题,但对于随机 I/O 操作来说非常浪费。可以更改代码以仅读取/写入必要的分片。为了使代码能够有效地支持更大的块大小,I/O 应取整为页面大小 I/O 而不是块大小 I/O。
第一组简单的优化消除了不必要的读取和不必要的数据写入,但保留了所有分片上的元数据写入。这避免了破坏当前的设计,该设计依赖于所有分片接收每个事务的元数据更新。当元数据处理的更改完成后(见下文),就可以进行进一步的优化以减少元数据更新的数量,从而获得额外的节省。
奇偶校验增量写入
当前代码通过执行全条带读取、合并覆盖写入的数据、计算新的编码奇偶校验并执行全条带写入来实现覆盖写入。读取和写入每个分片都很昂贵,可以应用许多优化来加快速度。对于 M 较小的 K+M 配置,执行奇偶校验增量写入通常工作量较小。这是通过读取即将被覆盖写入的旧数据并将其与新数据进行异或运算以创建增量来实现的。然后可以读取编码奇偶校验,更新它们以应用增量并重新写入。使用 M=2 (RAID6),这可以导致只需 3 次读取和 3 次写入即可执行小于一个块的覆盖写入。
请注意,当条带中的大部分数据正在更新时,此技术可能会导致比执行部分覆盖写入更多的工作,但是如果两种更新技术都受支持,则计算给定 I/O 偏移量和长度的最佳技术非常容易。
提交给主 OSD 的写入 I/O 将执行此计算以决定是使用全条带更新还是奇偶校验增量写入。请注意,如果在执行奇偶校验增量写入时遇到读取故障,并且需要重建数据或编码奇偶校验,则切换到执行全条带读取、合并和写入将更有效。
并非所有纠删码和纠删码库都支持执行增量更新的能力,但使用 XOR 和/或 GF 算术实现的库应该支持。我们已经检查了 jerasure 和 isa-l,并确认它们支持此功能,尽管插件目前未公开必要的 API。对于某些纠删码,例如 clay 和 lrc,可能可以应用增量更新,但增量可能需要在太多地方应用,这使得这种优化毫无价值。本提案建议奇偶校验增量写入优化最初仅针对最常用的纠删码实现。纠删码插件将提供一个新标志,指示它们是否支持执行增量更新所需的新接口。
直接读取
读取 I/O 当前被定向到主 OSD,然后主 OSD向其他分片发出读取。为了减少 I/O 延迟和网络带宽,如果客户端可以直接向存储数据的 OSD 发出读取请求,而不是通过主 OSD,那会更好。在某些错误场景中,客户端可能仍需要回退到向主 OSD 提交读取,辅助 OSD 将可以选择使用 -EAGAIN 使直接读取失败,以请求客户端重试向主 OSD 发出请求。
直接读取将始终针对 <= 一个块。对于超过一个块的读取,客户端可以向多个 OSD 发出直接读取,但是这些读取不再保证是原子的,因为在单独的读取请求之间可能会应用更新(写入)。如果客户端需要原子性保证,他们将需要继续将读取发送给主 OSD。
如果需要重建和解码操作才能返回数据,则直接读取将失败并显示 EAGAIN。这意味着只有对主 OSD 的读取才需要处理重建代码路径。当 OSD 正在回填时,我们不希望客户端有大量的 I/O 因 EAGAIN 而失败,因此我们将使客户端检测到这种情况,并避免向回填 OSD 发出直接 I/O。
为了向后兼容性,对于无法应对直接读取减少的保证的客户端请求,以及对于直接读取将发送到不存在或正在回填的 OSD 的场景,仍将支持定向到主 OSD 的读取。
直接写入
写入 I/O 当前被定向到主 OSD,然后主 OSD更新其他分片。为了减少延迟和网络带宽,如果客户端可以直接向存储数据的 OSD 发出小型覆盖写入请求,而不是通过主 OSD,那会更好。对于较大的写入 I/O 以及对于错误场景和异常情况,客户端将继续向主 OSD 提交写入 I/O。
直接写入将始终针对 <= 一个块,并将使用奇偶校验增量写入技术来执行更新。对于中等大小的写入,客户端可以向多个 OSD 发出直接写入,但此类更新不再保证是原子的。如果客户端需要较大的写入原子性,他们将需要继续将其发送给主 OSD。
为了向后兼容性,以及对于直接写入将发送到不存在的 OSD 的场景,仍将支持定向到主 OSD 的写入。
I/O 序列化、恢复/回填和其他错误场景
当前在主 OSD 上处理所有写入的实现意味着条带有一个中心控制点,可以管理诸如同一条带的多个进行中 I/O 的排序、确保对象的恢复/回填已在访问之前完成以及分配对象版本号和修改时间之类的事务。
对于直接 I/O,这些问题变为分布式问题。我们的方法是向主 OSD 发送控制路径消息,并让它继续作为中心控制点。主 OSD 将在 OSD 可以开始直接写入时发出回复,并在 I/O 完成时通过另一条消息通知。有关详细信息,请参阅下面有关元数据更新的部分。
条带缓存
纠删码池维护一个条带缓存,用于在更新进行中时存储分片数据。这需要允许对同一条带的写入和读取并行处理。对于小型顺序写入工作负载和极端的“热点”(例如,同一块被重复重写以进行某种粗略的检查点机制),将条带缓存保留比 I/O 持续时间稍长的时间会有好处。特别是,编码奇偶校验通常在每次更新条带时都会被读取和写入。在将缓存保留足够长的时间以减少未来 I/O 的开销与存储此数据的内存开销之间显然需要权衡取舍。对于大多数工作负载来说,一个小的(MiB 而不是 GiB 大小)缓存应该足够了。条带缓存还可以通过允许预取 I/O 读取旧数据和编码奇偶校验来帮助减少直接写入 I/O 的延迟,以便在写入操作的后续部分中使用,而无需更复杂的互锁。
当默认块大小较小(例如 4K)时,条带缓存不太重要,因为即使使用小型写入 I/O 请求,也不会有许多顺序更新来填充条带。对于更大的块大小(例如 64K),良好的条带缓存的好处变得更加显着,因为条带大小将是 100 KiB 到小数 MiB,因此顺序工作负载向同一条带发出许多 I/O 的可能性要大得多。
自动选择块大小
默认块大小 4K 对小对象有好处,因为数据和编码奇偶校验向上取整为整个块,并且因为如果对象的数据少于一个数据条带,则编码奇偶校验的容量开销更高(例如,10+2 纠删码池中的 4K 对象有 4K 数据和 8K 编码奇偶校验,因此开销为 200%)。然而,只要对象足够大,使得更大的块大小(例如 64K)会更好,那么上述所有优化都会提供更大的节省,因为典型的随机访问 I/O 只读取或写入单个分片。
虽然用户可以尝试预测他们的典型对象大小并选择合适的块大小,但如果代码可以自动为小对象选择小的块大小,为大对象选择更大的块大小,那会更好。总会有对象增长(或被截断)且所选块大小变得不合适的场景,但是当发生这种情况时,使用新的块大小读取和重新写入对象不会对性能产生太大影响。这也意味着可以从 object_info_t 中的对象大小推断出块大小,该大小在读取/修改对象数据之前读取。客户端在创建对象时已经提供了对象大小的提示,因此这可用于选择块大小,以减少必须重新条带化对象的可能性
想法是支持一个新的块大小 auto/variable 来启用此功能,它将仅适用于新创建的池,无法迁移现有池。
深度 scrub 支持
启用覆盖写入的 EC 池不检查 CRC,因为在每次覆盖写入时更新对象的 CRC 成本太高,而是依赖 Bluestore 来维护和检查 CRC。当 EC 池禁用覆盖写入时,会为每个分片保留一个 CRC,因为可以通过计算新追加数据的 CRC,然后执行一个简单的(快速)计算来组合新旧 CRC 来更新 CRC。
在 dev/osd_internals/erasure_coding/proposals.rst 中,讨论了以更细的粒度(例如每个块)保留 CRC 的可能性,将这些 CRC 存储为 xattr 或 omap(omap 更适合,因为大对象最终可能会有很多 CRC 元数据),并在数据被覆盖写入时更新这些 CRC(更新需要执行与 CRC 粒度相同的读取-修改-写入)。然后,这些更细粒度的 CRC 可以轻松组合以生成整个分片甚至整个纠删码对象的 CRC。
本提案建议朝相反的方向发展 - EC 覆盖写入池在没有 CRC 的情况下幸存下来并依赖 Bluestore 直到现在,那么为什么需要此功能呢?当前代码在启用覆盖写入时不检查 CRC,但遗憾的是仍会在 hinfo xattr 中计算和更新 CRC,即使执行覆盖写入也意味着计算出的值将是垃圾。这意味着我们支付了计算 CRC 的所有开销,但没有获得任何好处。
可以轻松修复代码,以便在按顺序写入对象时计算和维护 CRC,但一旦发生对对象的第一次覆盖写入,hinfo xattr 将被丢弃,并且不再计算或检查 CRC。这将提高对象被覆盖写入时的性能,并提高未被覆盖写入时的数据完整性。
虽然想法是放弃 EC 存储被覆盖写入对象的 CRC,但可以对深度 scrub 进行改进。目前,深度 scrub 带有覆盖写入池的 EC 只是检查每个分片是否可以读取对象,没有检查来验证分片上的副本是否一致。完整的一致性检查需要分片之间的大量数据传输,以便可以重新计算编码奇偶校验并将其与存储的版本进行比较,在大多数情况下,这将是不可接受的慢。然而,对于许多纠删码(包括 Ceph 使用的默认码),如果将块的内容进行异或运算以产生纵向摘要值,那么每个数据分片的纵向摘要值的编码应产生与编码奇偶校验分片存储的纵向摘要值相同的值。这种比较不如复制池执行的 CRC 检查昂贵。存在通过异或运算块内容导致一组损坏相互抵消的风险,但这种级别的检查优于不检查,并且在检测写入丢失(最常见的损坏类型)方面将非常成功。
元数据更改
我们需要考虑哪些元数据?
object_info_t。每个 Ceph 对象都在 object_info_t 数据结构中存储一些元数据。其中一些字段(例如对象长度)不经常更新,我们可以简单地在需要更新这些字段时避免执行部分写入优化。更有问题的字段是版本号和上次修改时间,它们在每次写入时都会更新。对象的版本号与用于 peering/恢复的 PG 日志条目中的版本号以及用于回填的其他分片上的版本号进行比较。客户端可以读取版本号和修改时间。
PG 日志条目。PG 日志用于跟踪进行中的事务,并允许在停机/网络故障后向前/向后回滚不完整的事务。PG 日志还用于检测和解决来自客户端的重复请求(例如,由于网络故障而重新发送)。Peering 目前假设每个分片都有一个日志副本,并且每个事务都会更新此副本。
PG stats 条目和其他 PG 元数据。还有其他 PG 元数据(PG stats 是最简单的例子)在每个事务中都会更新。目前,所有 OSD 都保留此元数据的缓存和持久副本。
需要多少份元数据副本?
当前的实现保留 K+M 个元数据副本,每个分片上一个副本。支持最多 M 个故障所需的最小副本数为 M+1。理论上元数据可以进行纠删编码,但是鉴于它很小,可能不值得这样做。保留 K+M 个元数据副本的一个优点是,任何完全同步的分片都可以读取元数据的本地副本,从而避免了 OSD 间消息和异步代码路径的需要。具体来说,这意味着任何未执行回填的 OSD 都可以成为主 OSD,并且可以本地访问 object_info_t 等元数据。
M+1 个任意分布式副本
对一个数据分片的部分写入将始终涉及更新数据分片和所有 M 个编码奇偶校验分片,因此为了获得最佳性能,理想情况下,将更新相同的 M+1 个分片以跟踪关联的元数据更新。这意味着对于小的随机写入,每次写入都会更新不同的 M+1 个分片。这种方法的缺点是您可能需要读取 K 个分片才能找到最新的元数据版本。
在这种设计中,没有分片会拥有每个对象的最新元数据副本。这意味着无论选择哪个分片作为 acting primary,它都可能无法在本地获得所有元数据,并且可能需要向其他 OSD 发送消息才能读取它。这将给 PG 代码增加显着的额外复杂性,并导致纠删码池和复制池之间的分歧。由于这些原因,我们不考虑这种设计选项。
已知分片上的 M+1 个副本
通过始终将元数据更新应用于相同的 M+1 个分片,可以实现次佳性能,例如选择第一个数据分片和所有 M 个编码奇偶校验分片。编码奇偶校验分片将通过每次部分写入进行更新,因此这将导致更新零个或一个额外的分片。使用此方法,只需读取 1 个分片即可找到最新的元数据版本。
我们可以将 acting primary 限制为 M+1 个分片中的一个,这意味着一旦日志中任何不完整的更新得到解决,主 OSD 将拥有所有元数据的最新本地副本,这意味着 PG 代码的更多部分可以保持不变。
部分写入和 PG 日志
Peering 目前假设每个分片都有一个日志副本,但是由于进行中的更新和短期缺席,某些分片可能缺少一些日志条目。peering 的工作是组合来自现有分片集的日志,以形成已由所有分片提交的事务的权威日志。然后解决分片日志与权威日志之间的任何差异,通常是通过向后回滚事务(使用日志条目中保存的信息),使所有分片处于一致状态。
为了支持部分写入,需要修改日志条目以包含正在更新的分片集。需要修改 Peering 以仅当另一个分片上的日志条目副本指示应更新此分片时才将日志条目视为缺少。
日志大小不是无限的,并且已知更新已在所有受影响的分片上成功提交的旧日志条目会被修剪。日志条目首先被压缩为 pg_log_dup_t 条目,该条目不再有助于回滚事务,但仍可用于检测重复的客户端请求,然后稍后完全丢弃。日志修剪与添加新日志条目同时执行,通常是在未来的写入更新日志时。对于部分写入,日志修剪只会发生在接收更新的分片上,这意味着某些分片可能具有应该被丢弃的陈旧日志条目。
待定:我认为代码已经可以处理分片之间日志修剪的差异。显然,进行中的修剪操作可能尚未在每个分片上完成,因此可以处理小的差异,但我认为不存在的 OSD 可能会导致较大的差异。我相信这在 Peering 期间得到解决,每个 OSD 都保留了最旧日志条目应该是什么的记录,并且这在 OSD 之间共享,以便它们可以找出在缺席时被修剪的陈旧日志条目。希望这意味着仅将日志修剪更新发送到正在创建新日志条目的分片将无需代码更改即可工作。
回填
回填用于纠正当 OSD 缺席较长时间且 PG 日志条目已被修剪时发生的 OSD 之间的不一致。回填通过比较分片之间的对象版本来工作。如果某些分片具有过时的对象版本,则回填过程会执行重建以更新分片。如果未在所有分片上更新对象的版本号,则会破坏回填过程并导致大量不必要的重建工作。这是不可接受的,特别是对于 OSD 只是为了维护而缺席相对较短时间且未设置 noout 的场景。要求是能够最大限度地减少完成回填所需的重建工作量。
在 dev/osd_internals/erasure_coding/proposals.rst 中,讨论了每个分片存储一个版本号向量的想法,该向量记录了 <此分片,其他分片> 都应该参与的最近更新。通过从至少 M 个分片收集此信息,可以计算出分片上对象的预期最小版本号,从而推断是否需要回填来更新对象。这种方法的缺点是回填需要扫描 M 个分片来收集此信息,而当前实现仅扫描主 OSD 和正在回填的分片。
有了额外的约束条件,即始终更新已知的 M+1 个分片,并且 (acting) primary 将是这些分片之一,就可以仅通过检查主 OSD 上的向量和正在回填的分片上的对象版本来确定是否需要回填。如果回填目标是 M+1 个分片之一,则现有的版本号比较就足够了,如果它是另一个分片,则需要将主 OSD 上向量中的版本与回填目标上的版本进行比较。这意味着回填不必扫描比当前更多的分片,但是对主 OSD 的扫描确实需要读取向量,并且如果存在多个回填目标,则它可能需要为每个对象存储多个向量条目,从而增加回填期间的内存使用量。
只有在 M+1 个分片上保留向量的要求,并且向量只需要 K-1 个条目,因为我们只需要跟踪任何 M+1 个分片(应该具有相同的版本)和每个 K-1 个分片(可以具有陈旧的版本号)之间的版本号差异。这将稍微减少所需的额外元数据量。版本号向量可以存储在 object_info_t 结构中或作为单独的属性存储。
我们的偏好是将向量存储在 object_info_t 结构中,因为通常两者会一起访问,并且因为这使得在同一对象缓存中缓存两者更容易。我们将通过仅在需要时存储向量来保持元数据和内存开销较低。
需要注意确保现有集群可以升级。版本号向量的缺失意味着对象从未进行过部分更新,因此所有分片都应具有相同的对象版本号,并且可以使用现有的回填算法。
代码引用
PrimaryLogPG::scan_range - 此函数创建对象及其版本号的映射,在主 OSD 上,它尝试从对象缓存中获取此信息,否则它会读取 OI 属性。这将需要更改来处理向量。为了节省内存,它需要提供回填目标集,以便它可以选择要保留的向量部分。
PrimaryLogPG::recover_backfill - 此函数调用 scan_range 用于本地(主 OSD),并向回填目标发送 MOSDPGScan 以使它们执行相同的扫描。一旦收集了所有版本号,它就会比较主 OSD 和回填目标,以找出需要恢复的对象。这也将需要更改来处理比较版本号时的向量。
PGBackend::run_recovery_op - 恢复单个对象。对于 EC 池,这涉及重建需要回填的分片数据(读取其他分片并使用解码进行恢复)。此代码不应需要任何更改。
客户端的版本号和上次修改时间
客户端可以读取对象版本号,并在进行更新时设置对最小版本号的期望。客户端还可以读取上次修改时间。在某些用例中,这些值可以被读取并给出一致的结果很重要,但在许多场景中不需要此信息。
如果对象版本号仅在部分写入的已知 M+1 个分片上更新,则在需要此信息时,它将需要涉及对这些分片之一的元数据访问。我们已安排主 OSD 成为 M+1 个分片之一,因此提交给主 OSD 的 I/O 将始终能够访问最新信息。
直接写入 I/O 需要更新 M+1 个分片,因此在完成 I/O 时将此信息返回给客户端并不困难。
直接读取 I/O 是问题所在,它们将只访问本地分片,并且不一定能访问最新版本和修改时间。为了简单起见,我们将要求需要此信息的客户端将请求发送给主 OSD,而不是使用直接 I/O 优化。如果客户端不需要此信息,他们可以使用直接 I/O 优化。
直接读取 I/O 优化仍将返回一个(可能陈旧的)对象版本号。这对于客户端理解对块的 I/O 排序可能仍然有用。
带有元数据更新的直接写入
这是执行奇偶校验增量写入的直接写入的完整图景,其中包含所有控制消息
注意:只有主 OSD 和奇偶校验编码 OSD(M+1 个分片)具有 Xattr、最新的对象信息、PG 日志和 PG 统计信息。只允许其中一个 OSD 成为 (acting) primary。其他数据 OSD 2、3 和 4(K-1 个分片)没有 Xattrs 或 PG 统计信息,可能有陈旧的对象信息,并且只有自己的更新的 PG 日志条目。OSD 2、3 和 4 可能具有带有旧版本号的陈旧 OI。其他 OSD 具有最新的 OI 和一个向量,其中包含 OSD 2、3 和 4 的预期版本号。
来自客户端的带有写入 I/O 的数据消息 (MOSDOp)
带有 Xattr 的控制消息到主 OSD (新消息 MOSDEcSubOpSequence)
注意:需要告知主 OSD 任何 xattr 更新,以便它可以更新其副本,但此消息的主要目的是允许主 OSD 对写入 I/O 进行排序。步骤 10 的回复消息允许写入开始并提供 PG 统计信息和新的对象信息,包括新的版本号。如有必要,主 OSD 可以延迟此操作,以确保首先完成对象的恢复/回填并处理重叠写入。数据可以在回复之前读取(预取),但显然不能开始任何事务。
预取请求到本地范围缓存
控制消息到 P 预取到范围缓存 (新消息 MOSDEcSubOpPrefetch 等同于 MOSDEcSubOpRead)
控制消息到 Q 预取到范围缓存 (新消息 MOSDEcSubOpPrefetch 等同于 MOSDEcSubOpRead)
主 OSD 读取对象信息
预取旧数据
预取旧 P
预取旧 Q
注意:这些预取的目标是尽快开始读取旧数据、P 和 Q,以减少整个 I/O 的延迟。在某些错误场景中,范围缓存可能无法保留此数据,需要重新读取。这包括罕见/病理场景,其中写入被发送到主 OSD 和写入被直接发送到同一对象的数据 OSD 的混合。
带有新对象信息 + PG 统计信息的控制消息到数据 OSD (新消息 MOSDEcSubOpSequenceReply)
更新对象信息 + PG 日志 + PG 统计信息的事务
获取旧数据(希望已缓存)
注意:为了获得最佳性能,我们希望对同一条带的写入进行流水线操作。主 OSD 为每次写入分配版本号,因此定义了应处理写入的顺序。数据分片和编码奇偶校验分片以相同的顺序应用重叠写入非常重要。主 OSD 知道哪些写入正在进行中,因此可以检测到这种情况,并在步骤 10 的回复消息中指示更新必须等到较早的更新应用后才能开始。此信息需要转发到编码奇偶校验(步骤 14 和 15),以便它们也可以确保以相同的顺序应用更新。
异或新旧数据以创建增量
带有增量 + Xattr + 对象信息 + PG 日志 + PG 统计信息的数据消息到 P (新消息 MOSDEcSubOpDelta 等同于 MOSDEcSubOpWrite)
带有增量 + Xattr + 对象信息 + PG 日志 + PG 统计信息的数据消息到 Q (新消息 MOSDEcSubOpDelta 等同于 MOSDEcSubOpWrite)
更新数据 + 对象信息 + PG 日志的事务
获取旧 P(希望已缓存)
异或增量和旧 P 以创建新 P
更新 P + Xattr + 对象信息 + PG 日志 + PG 统计信息的事务
获取旧 Q(希望已缓存)
异或增量和旧 Q 以创建新 Q
更新 Q + Xattr + 对象信息 + PG 日志 + PG 统计信息的事务
用于提交的控制消息到数据 OSD (新消息 MOSDEcSubOpDeltaReply 等同于 MOSDEcSubOpWriteReply)
本地提交通知
用于提交的控制消息到数据 OSD (新消息 MOSDEcSubOpDeltaReply 等同于 MOSDEcSubOpWriteReply)
用于提交的控制消息到数据 OSD (新消息 MOSDEcSubOpDeltaReply 等同于 MOSDEcSubOpWriteReply)
控制消息到主 OSD 以指示写入结束 (新消息 MOSDEcSubOpSequence 的变体)
控制消息回复给客户端 (MOSDOpReply)
升级和向后兼容性
一些优化只需更改主 OSD 上的代码即可实现,无需担心客户端或其他 OSD 的向后兼容性问题。这些优化将在主 OSD 升级后立即启用,并将替换现有的代码路径。
其余的更改将是新的 I/O 代码路径,它们将与现有的代码路径并存。
与 EC 覆盖写入类似,许多更改需要确保所有 OSD 都运行新代码,并且 EC 插件支持奇偶校验增量写入所需的新接口。将需要一个新的池级别标志来强制执行此操作。在升级现有集群后,可以启用此标志(从而启用新的性能优化)。设置后,将无法向池添加降级 OSD。除了删除池之外,无法关闭此标志。不支持降级是因为
静止对池的所有 I/O 以确保在清除标志时没有使用新的 I/O 代码路径并不简单。
旧 OSD 无法理解新 I/O 的 PG 日志格式。有必要确保在清除标志之前日志已修剪所有新格式条目,以确保降级 OSD 将能够解释日志。
新的 I/O 代码路径将存储额外的 xattr 数据并用于回填。降级代码将无法理解如何回填已运行新 I/O 路径的池,并且会被不一致的对象版本号混淆。虽然理论上可以禁用部分更新,然后扫描和更新所有元数据以将池返回到可以降级的状态,但我们不打算编写此代码。
直接 I/O 更改将额外要求客户端运行新代码。这些将要求池设置新标志并使用新客户端。旧客户端可以使用设置了新标志的池,只是没有直接 I/O 优化。
未考虑
doc/dev/osd_internals/erasure_coding/proposals.rst 中讨论了许多增强功能,以下未考虑
RADOS 客户端确认生成优化
在纠删码池中更新 K+M 个分片时,理论上您不必等待所有更新完成才能完成对客户端的更新,因为只要 K 个更新完成,任何可行的分片子集都应该能够向前回滚更新。
对于仅更新 M+1 个分片的部分写入,此优化不适用,因为所有 M+1 个更新都需要完成才能完成对客户端的更新。
此优化需要更改 peering 代码以确定部分完成的更新是需要向前回滚还是向后回滚。要向前回滚更新,最简单的方法是将对象标记为丢失,并使用恢复路径重建并推送到落后的 OSD。
避免通过 Messenger 向本地 OSD 发送读取请求
EC 后端代码对本地 OSD 的写入进行了优化,避免了通过 messenger 发送消息和回复。读取也可以进行等效的优化,尽管需要更小心,因为读取是同步的,并且会阻塞等待 I/O 完成的线程。
拉取请求 https://github.com/ceph/ceph/pull/57237 正在进行此优化
故事
这是我们对工作的高级别分解。我们的意图是分批交付这项工作。这些故事大致按照我们计划开发的顺序排列。每个故事至少一个 PR,尽可能将其进一步分解。较早的故事可以作为独立的工作部分实现,不会引入升级/向后兼容性问题。后面的故事将开始破坏向后兼容性,在这里我们计划为池添加一个新标志来启用这些新功能。最初这将是一个实验性标志,直到后面的故事开发完成。
测试工具 - 增强型 I/O 生成器用于测试纠删码
扩展 rados bench 以能够生成更有趣的纠删码 I/O 模式,特别是以不同的偏移量和不同的长度进行读取和写入,并确保我们获得良好的边界条件覆盖率,例如子块大小、块大小和条带大小
通过使用种子来生成数据模式并记住写入每个块所使用的种子,以便以后可以验证数据,从而提高数据完整性检查
测试工具 - 离线一致性检查工具
用于执行离线一致性检查的测试工具,结合使用 objectstore_tool 和 ceph-erasure-code-tool
增强一些 teuthology 独立纠删码检查以使用此工具
测试工具 - 在线一致性检查工具
新的 CLI,能够对对象或对象范围执行在线一致性检查,读取所有数据和编码奇偶校验分片并重新编码数据以验证编码奇偶校验
从 JErasure 切换到 ISA-L
JErasure 库自 2014 年以来未更新,ISA-L 库得到维护并利用较新的指令集(例如 AVX512、AVX2),从而提供更快的编码/解码
将上游 ceph 中的默认值更改为 ISA-L
对 Jerasure 和 ISA-L 进行基准测试
重构 Ceph isa_encode region_xor() 以在 M=1 时使用 AVX
文档更新
在性能周报中展示结果
子条带读取
Ceph 当前读取整数个条带并丢弃不需要的数据。特别是对于小型随机读取,仅读取所需数据会更有效
帮助完成拉取请求 https://github.com/ceph/ceph/pull/55196(如果尚未完成)
进一步更改以发出子块读取而不是全块读取
覆盖写入的简单优化
Ceph 覆盖写入当前读取整数个条带,合并新数据并写入整数个条带。这个故事通过进行与子条带读取相同的优化以及对于小型(子块)更新减少读取/写入每个分片的数据量来做出简单的改进。
仅读取未被完全覆盖写入的块(代码当前读取整个条带,然后合并新数据)
对子块更新执行子块读取
对子块更新执行子块写入
消除不必要的块写入,但保留元数据事务
这个故事避免了重写未修改的数据。事务仍然应用于每个 OSD 以更新对象元数据、PG 日志和 PG 统计信息。
继续为所有块创建事务,但没有新的写入数据
在数据被修改的地方向事务添加子块写入
避免将对象用零填充到完整的条带
对象通过添加零填充向上取整为整数个条带。然后将这些零缓冲区发送到其他 OSD 的消息中,并写入 OS 消耗的存储。这个故事进行优化以消除对这种填充的需求
修改重建读取以避免读取对象末尾的零填充 - 只需用零填充读取缓冲区
避免传输/写入零填充缓冲区。仍然向所有分片发送事务并创建对象,只是不使用零填充它
修改编码/解码函数以避免在对象被填充时必须传入零缓冲区
纠删码插件更改以支持分布式部分写入
这是未来故事的准备工作,它向纠删码插件添加了新的 API。
添加新接口以通过异或运算旧数据和新数据来创建增量,并为 ISA-L 和 JErasure 插件实现此功能
添加新接口以通过使用 XOR/GF 对一个编码奇偶校验应用增量,并为 ISA-L 和 JErasure 插件实现此功能
添加新接口,报告哪些纠删码支持此功能(ISA-L 和 JErasure 将支持它,其他不支持)
允许 RADOS 客户端将 I/O 直接发送到存储数据的 OSD 的纠删码接口
这是未来故事的准备工作,它为客户端添加了新的 API
新接口,用于将对 (pg, offset) 转换为 {OSD, remaining chunk length}
我们不希望客户端必须动态链接到纠删码插件,因此此代码需要成为 librados 的一部分。然而,此接口需要了解纠删码如何分发数据和编码块才能执行此转换。
我们将只支持 ISA-L 和 JErasure 插件,其中数据块到 OSD 的条带化很简单。
对 object_info_t 的更改
这是未来故事的准备工作。
这将版本号向量添加到 object_info_t 中,该向量将用于部分更新。对于复制池和未覆盖写入的纠删码对象,我们将避免在 object_info_t 中存储额外的数据。
更改 PGLog 和 Peering 以支持更新 OSD 子集
这是未来故事的准备工作。
修改 PG 日志条目以存储正在更新的 OSD 记录
修改 peering 以使用此额外数据来找出缺少更新的 OSD
更改 (acting) primary 的选择
这是未来故事的准备工作。
将主 OSD 的选择限制为第一个数据 OSD 或其中一个纠删码奇偶校验。如果这些 OSD 均不可用且未更新,则池必须脱机。
在主 OSD 上实现所有计算的奇偶校验增量写入
计算更新是执行全条带覆盖写入还是奇偶校验增量写入更有效
实现新的代码路径来执行奇偶校验增量写入
测试工具增强功能。我们希望确保奇偶校验增量写入和全条带写入都得到测试。我们将添加一个新的 conf 文件选项,可以选择“parity-delta”、“full-stripe”、“mixture for testing”或“automatic”,并更新 teuthology 测试用例以主要使用混合。
升级和向后兼容性
为纠删码池添加新功能标志
所有 OSD 必须运行新代码才能在池上启用该标志
只有在设置了标志的情况下,客户端才能发出直接 I/O
运行旧代码的 OSD 不得加入设置了标志的池
无法关闭功能标志(除了删除池)
更改 Backfill 以使用 object_info_t 中的向量
这是未来故事的准备工作。
修改回填过程以使用 object_info_t 中的版本号向量,以便在发生部分更新时,我们不会回填未参与部分更新的 OSD。
当只有一个回填目标时,从向量中提取适当的版本号(无需额外存储)
当有多个回填目标时,提取回填目标所需的向量子集,并在 PrimaryLogPG::recover_backfill 中比较版本号时选择适当的条目
测试工具 - 离线元数据验证工具
用于执行元数据离线一致性检查的测试工具,特别是检查 object_info_t 中的版本号向量是否与每个 OSD 上的版本匹配,以及验证 PG 日志条目
消除未更新数据块的 OSD 上的事务
Peering、日志恢复和回填现在都可以使用 object_info_t 中的版本号向量来处理部分更新。
修改覆盖写入 I/O 路径,使其不必担心仅元数据事务(主 OSD 除外)
修改 object_info_t 中版本号的更新,以使用向量并仅更新正在接收事务的条目
修改 PG 日志条目的生成,以记录正在更新的 OSD
直接读取到 OSD(仅限单个块)
修改 OSDClient 将单个块读取 I/O 路由到存储数据的 OSD
修改 OSD 以接受来自非主 OSD 的读取(扩展现有更改,使复制池也适用于 EC 池)
如果 OSD 无法直接处理读取,则在必要时使用 EAGAIN 使读取失败
修改 OSDClient,如果读取因 EAGAIN 失败,则通过向主 OSD 提交来重试读取
测试工具增强功能。我们希望确保对直接读取和对主的读取都进行测试。我们将添加一个新的配置文件选项,其中包含“prefer direct”、“primary only”或“mixture for testing”的选择,并更新 teuthology 测试用例以主要使用混合模式。
这些更改将应用于 RADOS 客户端的 OSDC 部分,因此适用于 rbd、rgw 和 cephfs。
我们不会更改具有自己版本的 RADOS 客户端代码的其他代码,例如 krbd,尽管这将来可以完成。
对 OSD 的直接读取(多个块)
添加一个新的 OSDC 标志 NONATOMIC,允许 OSDC 将读取拆分为多个请求
如果设置了 NONATOMIC 标志,则修改 OSDC 将跨越多个块的读取拆分为对每个 OSD 的单独请求
修改 OSDC 以合并结果(如果任何子读取失败,则整个读取需要失败)
更改 librbd 客户端以设置读取的 NONATOMIC 标志
更改 cephfs 客户端以设置读取的 NONATOMIC 标志
我们只更改非常有限的一组客户端,重点关注那些发出较小读取且对延迟敏感的客户端。未来的工作可能会考虑扩展客户端集(包括 krbd)。
实现分布式奇偶校验增量写入
实现新消息 MOSDEcSubOpDelta 和 MOSDEcSubOpDeltaReply
更改主节点以计算增量并将 MOSDEcSubOpDelta 消息发送到编码奇偶校验 OSD
修改编码奇偶校验 OSD 以应用增量并发送 MOSDEcSubOpDeltaReply 消息
注意:此更改会增加延迟,因为编码奇偶校验读取是在旧数据读取之后开始的。未来的工作将解决这个问题。
测试工具 - EC 错误注入 thrashers
实现一种新型 thrashers,专门注入故障来对纠删码池进行压力测试
关闭一个或多个(最多 M 个)OSD,更侧重于关闭 OSD 的不同子集以驱动所有不同的 EC 恢复路径,而不是对对等/恢复/回填进行压力测试(现有的 OSD thrasher 在这方面表现出色)
注入读取 I/O 故障,以强制使用解码进行单次和多次故障的重建
使用 osd tell 类型接口注入延迟,以便更容易在 EC I/O 的所有有趣阶段测试 OSD 关闭
使用 osd tell 类型接口注入延迟,以减慢 OSD 事务或消息,从而暴露出并行工作不太常见的完成顺序
实现预取消息 MOSDEcSubOpPrefetch 并修改范围缓存
实现新消息 MOSDEcSubOpPrefetch
更改主节点以在开始读取旧数据之前向编码奇偶校验 OSD 发出此消息
更改范围缓存,使每个 OSD 缓存自己的数据,而不是在主节点上缓存所有内容
更改编码奇偶校验 OSD 以处理此消息并将旧的编码奇偶校验读取到范围缓存中
更改范围缓存以保留预取的旧奇偶校验,直到收到 MOSDEcSubOpDelta 消息,并在错误路径上丢弃此消息(例如,新的 OSDMap)
实现排序消息 MOSDEcSubOpSequence
实现新消息 MODSEcSubOpSequence 和 MOSDEcSubOpSequenceReply
修改主代码以创建这些消息并将其本地路由到自身,为直接写入做准备
对 OSD 的直接写入(仅限单个块)
修改 OSDC 以将单个块写入 I/O 路由到存储数据的 OSD
更改以在数据 OSD 和主 OSD 之间发出 MOSDEcSubOpSequence 和 MOSDEcSubOpSequenceReply
对 OSD 的直接写入(多个块)
如果设置了 NONATOMIC 标志,则修改 OSDC 以将多个块写入拆分为单独的请求
进一步更改以合并完成(特别是正确报告版本号)
更改 librbd 客户端以设置读取的 NONATOMIC 标志
更改 cephfs 客户端以设置读取的 NONATOMIC 标志
我们只更改非常有限的一组客户端,重点关注那些发出较小写入且对延迟敏感的客户端。未来的工作可能会考虑扩展客户端集。
深度擦洗 / CRC
禁用 EC 代码中覆盖的 CRC 生成,在第一次覆盖发生时删除 hinfo Xattr
对于设置了新功能标志但未被覆盖的池中的对象,检查 CRC,即使设置了池覆盖标志。hinfo 的存在/缺失可用于确定对象是否已被覆盖
对于深度擦洗请求,对 shard 的内容进行 XOR 以创建纵向检查(8 字节宽?)
在擦洗回复消息中返回纵向检查,让主节点编码纵向回复集以检查不一致性
可变块大小纠删码
实现用于自动/可变块大小的新池选项
当对象大小较小时,如果池使用新选项,则使用较小的块大小(4K)
当对象大小较大时,使用较大的块大小(64K 或 256K?)
当小对象增长时(追加),通过读取和重新写入整个对象来转换块大小
当大对象缩小时(截断),通过读取和重新写入整个对象来转换块大小
使用对象大小提示来避免创建小对象,然后几乎立即将其转换为更大的块大小
CLAY 纠删码
理论上,CLAY 纠删码对于具有较大 M 值的 K+M 纠删码应该是有益的,特别是当这些纠删码与同一故障域中的多个 OSD 一起使用时(例如,具有 5 个服务器,每个服务器有 4 个 OSD 的 8+6 纠删码)。我们希望提高 CLAY 的测试覆盖率并执行更多基准测试以收集数据,帮助证明何时人们应该考虑使用 CLAY。
基准测试 CLAY 纠删码 - 特别是当多个 OSD 失败时回填所需的 I/O 数量
增强测试用例以验证实现