Files
wiki/存储/Ceph/知识库/17-内部实现-OSD与MDS篇.md
T
2026-09-18 13:49:11 +08:00

300 KiB
Raw Blame History

基于 Ceph 官方文档整理 | 文档版本:Tentacle v20.2.4 | 抓取日期:2026-09-17 | 源文:ceph/ceph@v20.2.4 doc/dev/osd_internals/、doc/dev/mds_internals/ 官网入口:https://docs.ceph.com/en/tentacle/dev/osd_internals/

第 17 册 · 内部实现篇(二):OSD 与 MDS 内部机制


本册导读

本册是「内部实现」四册中的第二册,与第一册(集群/mon/通用基础设施类内部机制)并列,第三、四册分别覆盖 RADOS 其他子系统与测试/发布工程。本册只做一件事:把 Ceph 源码树中 doc/dev/osd_internals/(27 个 .rst,含 erasure_coding/ 子目录 5 个文件)与 doc/dev/mds_internals/(5 个 .rst)这两块面向开发者的官方文档,整理成一份可以在离线状态下通读、也可以按机制检索的中文手册。

本册面向谁

  • 要读 Ceph 源码的人:本册给出每个机制「入口在哪、类叫什么、状态机怎么转、不变量是什么」的地图,避免你一上来就在 src/osd/ 上百万行里迷路。
  • 要做深度调优与排障的人:backfill/recovery 预留机制、优先级数值、scrub 行为矩阵、mClock profile 分配、写回节流、stale read 租约,这些都属于「不看内部文档就只能靠猜」的部分。
  • 要做 EC(纠删码)相关开发/调优的人:本册覆盖了 EC 插件接口、ECBackend 的 RMW/parity-delta 设计、以及一份篇幅很大的「EC 增强设计文档」。

本册不覆盖什么

  • 概念性、运维向内容(如何在生产上配 EC 池、如何做 cache tiering)——那些在手册的其他册。
  • 源码行级走读。本册只到「类名 + 函数名 + 文件路径」的粒度,不逐行贴源码。
  • 未在官方文档中写出的实现细节。官方没写的,本册一律如实标注「文档未明确」,并集中在末尾的 文档未明确项 一节列出,不靠推测补全。

阅读建议

  1. 第一部分 OSD 内部:先读第 1 章(OSD 总览)建立线程/队列模型,再读第 2 章(PG/PGPool),之后各章基本可独立阅读。
  2. 第 3–5 章(past_intervals、last_epoch_started、log_based_pg)是一条主线,讲的是「PG 如何在成员变化后重新达成一致的共识基础」,建议连读。
  3. 第 6–8 章(异步恢复、预留机制、部分对象恢复)是另一条主线,讲的是「恢复/回填怎么做、做多少、按什么顺序」,建议连读。
  4. 第二部分 MDS 内部:第 20 章(数据结构)是第 21 章(锁)的前置,第 22 章(导出)和第 23 章(quiesce)都依赖锁的概念。

写作约定

  • 类名、函数名、配置项名、默认值、状态机状态名、锁类型名一律保留英文原文,不做翻译(例如 PeeringState::Reset、LOCK_SYNC、osd_max_backfills、CEPH_LOCK_IAUTH)。
  • 官方文档原文未给出、由本册作者为便于理解而补充的解释,用 > 💡 补充:… 标注。
  • 每个章节末尾标注来源页面链接,便于回查官方原文。

第一部分 OSD 内部(osd_internals/)


第 1 章 OSD 总览(osd_overview.rst)

1.1 设计动机

OSD(ceph-osd)进程是 CRUSH 层级中的一个叶子设备。一台物理机上可以只有一个 OSD 进程,也可以有多个——例如用户按「每块盘一个 OSD 实例」来配置时。理解 OSD 内部的第一步,是理解它内部是如何把网络消息、磁盘 I/O、后台任务这三类工作切分开的,以及它们之间的并发边界在哪。

1.2 核心概念

Messenger(信使)

  • 源码:src/msg/Messenger.h
  • OSD 使用两个 Messenger:
    1. cluster_messenger —— 处理到其他 OSD、到 monitor 的流量(集群网)
    2. client_messenger —— 处理客户端流量(公网)
                  +---------------------------+
   clients  ----> | client_messenger          | --+
                  +---------------------------+   |
                                                  v
                                          OSD::ms_dispatch()   <-- osd_lock 持有期间调用
                                                  ^
                  +---------------------------+   |
 other OSDs ----> | cluster_messenger         | --+
 monitors  ---->  +---------------------------+

这样划分的意义:允许 OSD 为客户端流量和集群流量配置不同的网络接口。

Dispatcher(分发器)

  • 源码:src/msg/Dispatcher.h
  • OSD 实现了 Dispatcher 接口。最关键的是 ms_dispatch,它是经由 client 或 cluster messenger 收到的消息的统一入口。
  • 并发要点:因为有两个 messenger,ms_dispatch 可能至少从两个线程被调用。在 ms_dispatch 执行期间,始终持有 osd_lock。
线程 A (client_messenger) ──┐
                            ├──> ms_dispatch()  [持有 osd_lock]
线程 B (cluster_messenger) ─┘

💡 补充:这条约束是理解 OSD 并发模型的钥匙——osd_lock 是 OSD 级别的粗粒度锁,所有消息入队路径都在它保护之下;真正耗时的处理(如 PG 上的 op 执行)会被派发到工作队列,在持有 PG 锁而非 osd_lock的情况下执行。

WorkQueue(工作队列)

  • 源码:src/common/WorkQueue.h
  • WorkQueue 抽象了「把相互独立的任务排队以便异步执行」这一过程。每个 OSD 进程含有一批用途不同的工作队列:
# 工作队列 处理内容 所在线程池
1 OpWQ ops(来自客户端)与 subops(来自其他 OSD) op_tp
2 PeeringWQ peering 任务与 pg map 推进 op_tp
3 CommandWQ 命令(pg query 等) command_tp
4 RecoveryWQ 恢复任务 recovery_tp
5 SnapTrimWQ 快照裁剪(SnapTrimmer) disk_tp
6 ScrubWQ primary scrub 主路径 disk_tp
7 ScrubFinalizeWQ primary scrub 收尾 disk_tp
8 RepScrubWQ 副本 scrub 路径 disk_tp
9 RemoveWQ 异步删除旧 pg 目录(见 PGRemoval) disk_tp

ThreadPool(线程池)

  • 源码:src/common/WorkQueue.h
  • OSD 有 4 个线程池:
线程池 职责
op_tp ops 与 subops
recovery_tp 恢复任务
disk_tp 磁盘密集型任务
command_tp 命令
                    OSD 进程线程池布局

  op_tp        [ OpWQ ]  [ PeeringWQ ]
  recovery_tp  [ RecoveryWQ ]
  disk_tp      [ SnapTrimWQ ] [ ScrubWQ ] [ ScrubFinalizeWQ ] [ RepScrubWQ ] [ RemoveWQ ]
  command_tp   [ CommandWQ ]

💡 补充:把 scrub / snap trim / pg 删除都塞进 disk_tp,是出于「这些都是磁盘密集型、会让磁盘打满」的考虑;而 recovery_tp 与 op_tp 分离,是为了让后台恢复不至于直接抢走客户端 I/O 的执行线程。

OSDMap

  • 源码:src/osd/OSDMap.h
  • CRUSH 算法有两个输入:(a) 集群的一幅「图景」,含各节点 up/down、in/out 的状态信息;(b) 要放置的 pgid。前者由 OSDMap 封装。
  • Map 由 epoch(类型 epoch_t)编号。
  • 在 OSD 内部,map 以 std::tr1::shared_ptr<const OSDMap> 的形式传递。

PG

  • 源码:src/osd/PG.*、src/osd/PrimaryLogPG.*
  • RADOS 中的对象被哈希到 PG,PG 再通过 CRUSH 放置到 OSD 上。
  • PG 结构负责:
    • 处理针对某个特定 PG 的请求
    • 维护相关元数据
    • 控制恢复(recovery)

OSDService

  • 源码:src/osd/OSD.cc 中的 OSDService
  • OSDService 充当 PG 线程与 OSD 状态之间的中介(broker),使 PG 能够借助 OSD 的服务(如工作队列、messenger)执行动作。
  • 官方明确说明:这仍是一项进行中的工作(work in progress)。未来的清理工作重点是把这类状态从 OSD 完全移入 OSDService。

1.3 进程级总览

  • 源码入口:src/ceph_osd.cc
  • OSD 进程代表 CRUSH 层级中的一个叶子设备。

1.4 注意事项

  • ms_dispatch 可被多线程进入,任何在其路径上新增的共享状态都必须在 osd_lock 保护下访问。
  • 两个 Messenger 的设计使得「客户端网」与「集群网」可以绑定不同 NIC;排障时要注意两类消息走的是不同的 messenger 实例。
  • OSDService 的抽象尚未完成,源码中会看到部分状态仍直接挂在 OSD 上,这是已知的技术债,不是你的理解有误。

引自:OSD


第 2 章 PG 与 PGPool

2.1 PG(pg.rst)

2.1.1 设计动机

PG 是 Ceph 数据放置与一致性维护的基本单位。理解 PG 内部,核心是理解两件事:peering interval(什么时候必须重新做 peering)与 PG::flushed(什么时候才允许对外提供服务)。

2.1.2 核心概念:Peering Interval

  • 相关函数:PG::start_peering_interval、PG::acting_up_affected、PG::PeeringState::Reset

Peering Interval 定义:一段极大的连续 map epoch 集合,在这段区间内 up set 与 acting set 都没有发生变化。

  • PG::PeeringMachine 把「从一个 interval 到另一个 interval 的转换」表示为穿过 PeeringState::Reset。
  • 在 PG::PeeringState::AdvMap 上,PG::acting_up_affected 可能导致该 pg 转入 Reset。
epoch:   1    2    3    4    5    6    7    8
up/acting --A-- --A-- --A-- --B-- --B-- --C-- --C-- --C--
         \_______ interval 1 ______/\__ i2 __/\__ interval 3 __/

                    ↓ 每次 up/acting 变化
              PeeringState::Reset  (一次 interval 切换)

2.1.3 关键机制:PG::flushed

官方在「Peering Details and Gotchas」一节只讲了一个坑,但它是非常重要的一个:

  • PG::flushed 默认为 false,并在 PG::start_peering_interval 中被置为 false。
  • 转入 PG::PeeringState::Started 时,我们通过 pg op sequencer 发送一个事务;该事务完成时,会送出一个 FlushedEvt,把 flushed 置为 true。
  • primary 在此发生之前不能进入 active(参见 PG::PeeringState::WaitFlushedPeering)。
  • replica 可以进入 active,但不能服务 ops(读和写都不行)。
start_peering_interval      transition to Started           FlushedEvt
        |                            |                           |
        v                            v                           v
   flushed = false          提交一个事务(经 pg op sequencer)   flushed = true
                                                          primary 方可 active

   [ primary ]  flushed == false  ──> 不能 active
   [ replica ]  flushed == false  ──> 可 active,但不能服务读写

之所以必须如此:在上一 interval 的不稳定(unstable)事务清空之前,我们无法读取自己的 ondisk 状态。

2.1.4 注意事项

  • flushed 是 primary 与 replica 行为不对称的少数几处之一:replica 的 active 门槛更低。如果你在排障时看到「副本已 active 但不响应请求」,就对应这个状态。
  • 官方该页总长度很短(仅 32 行),只覆盖了 Peering Interval 概念与 PG::flushed 这一个 gotcha。PG 类的字段清单、方法清单、PeeringMachine 的完整状态图均未在此页给出(完整状态图在官方的 peering 文档中,不在本册范围)。

引自:PG

2.2 PGPool(pgpool.rst)

2.2.1 设计动机

快照被删除后,OSD 需要知道「哪些 snapid 已经不属于任何活快照」,才能把对应的 clone 对象裁掉。由于这个信息是全池共享的(来自 OSDMap 的 pool 元数据),如果每个 PG 各自维护一份,会产生大量重复解析与内存开销。PGPool 就是用来在 OSD 内集中缓存并增量更新这份信息的结构。

2.2.2 关键数据结构

PGPool 通过维护两个字段来管理和更新**已移除快照(removed snapshots)**的状态:

字段 含义
cached_removed_snaps 当前的 removed snap 集合
newly_removed_snaps 上一个 epoch 中新增移除的 snaps

2.2.3 执行流程

步骤 1:初始化

  • 在 OSD::load_pgs 中,osd map 从 pg 的 file store 中恢复出来,并被向下传给 OSD::_get_pool,在那里用该 map 初始化一个 PGPool 对象。
OSD::load_pgs
      │  恢复 osd map (from pg's file store)
      v
OSD::_get_pool
      │  用该 map 初始化 PGPool 对象
      v
PGPool{ cached_removed_snaps, newly_removed_snaps }

步骤 2:每次收到新 map 时更新

  • 每收到一个新 map,我们调用 PGPool::update(new_map)。
  • 在该函数中:
    1. 用 pg_pool_t::build_removed_snaps 构建一份 newly removed snaps 列表
    2. 将该列表与我们的 cached_removed_snaps 合并
  • 该函数内含检查,确保只在「事情确实发生了变化」或「出现了 map gap」时才做这次更新。

步骤 3:激活 PG 时初始化快照裁剪队列

  • 当我们 activate 该 pg 时,从 cached_removed_snaps 初始化 snap trim queue
  • 减去我们已经 purge 掉的 purged_snaps
  • 剩下的就是需要被 trim 的 snap 列表
cached_removed_snaps
      −  purged_snaps (已 purge)
      ─────────────────────────
      =  snap_trimq  (待裁剪)

步骤 4:异步裁剪

  • 裁剪随后由 snap_trim_wq 异步执行。(详见第 10 章 snaps。)

2.2.4 关键代码路径

文件/类 作用
OSD::load_pgs 恢复 map
OSD::_get_pool 构造 PGPool
PGPool::update 增量更新 removed snaps
pg_pool_t::build_removed_snaps 计算新移除的 snap 列表

2.2.5 注意事项

  • PGPool 是共享结构:多个 PG(同池)共享同一个 PGPool 实例。新增字段时要考虑内存放大效应。
  • PGPool::update 里的「只在变化时更新」检查是性能关键路径,不要在没有理解它的前提下绕过。

引自:PGPool


第 3 章 PastIntervals 与 OSDMap 裁剪(past_intervals.rst)

3.1 设计动机:为什么需要 PastIntervals

有两种场景需要回溯考察「某个 PG 从 epoch e 往回的所有 acting-set OSD 集合」:

场景 需要回溯到 该 epoch 的含义
Peering 期间 last_epoch_started 该 PG 最近一次完成 peering 并变为 active 的 epoch
Recovery 期间 last_epoch_clean 最近一个「acting set 中所有 OSD 都已完全恢复、且 acting set 是满的」的 epoch

对于这两种用途,理论上都可以通过从当前 OSDMap 往回迭代到目标 epoch 来构建这个集合。但那样做代价太高(每次都要重放一堆 map)。因此,我们为每个 PG 维护一个 PastIntervals 结构。

3.2 核心概念

interval 定义:一段连续的 OSDMap epoch 序列,在这段序列内 PG 的映射没有发生变化。

「映射没有变化」的具体判据包括但不限于:

  • acting set 变化
  • up set 变化
  • primary 变化
  • 以及若干其他参数 —— 完整清单在 PastIntervals::check_new_interval 中明确列出。

3.3 维护与裁剪(Maintenance and Trimming)

3.3.1 记录范围

PastIntervals 结构为回溯到 last_epoch_clean 的每一个 interval 保存一条记录。

3.3.2 新增 interval

  • 每出现一个新 interval(参见 AdvMap reactions、PeeringState::should_restart_peering、PeeringState::start_peering_interval),每个持有该 PG 的 OSD 都会把新 interval 加入它本地的 PastIntervals。
  • 激活消息(Activation messages)优化:发给「本地还没有该 PG」的 OSD 的激活消息中,会带上发送方的 PastIntervals,这样接收方就不必自己重建。(参见 PeeringState::activate 的 needs_past_intervals。)
   已有 PG 的 OSD                        新加入的 OSD(本地无该 PG)
        |                                          ^
        |  Activation msg + PastIntervals          |
        +------------------------------------------+
                    接收方无需重建 PastIntervals

3.3.3 两处裁剪

裁剪点 1(简单):primary 标记 PG clean 时

  • 当 primary 把 PG 标记为 clean 时,它清空自己的 past_intervals 实例(PeeringState::try_mark_clean())。
  • 副本在收到该 info 时做同样的事(参见 PeeringState::update_history)。

裁剪点 2(复杂):PeeringState::start_peering_interval 中的 "map gap"

  • 在发生 "map gap" 的情况下,我们假定该 PG 实际上已经 clean 了,只是我们还没收到携带更新后 last_epoch_clean 值的 pg_info_t。
  • 要解释这个行为,需要先讲 OSDMap 裁剪。

3.4 OSDMap 裁剪(OSDMap Trimming)

3.4.1 谁决定裁剪

  • OSDMap 由 Monitor quorum 创建,并通过 gossip 分发到各 OSD。
  • Monitor 集群同时决定 OSD(以及 Monitor 自身)何时可以裁剪旧的 OSDMap epoch。

3.4.2 主要约束

基于上面 PastIntervals 的原因,主要约束是:必须保留回溯到某个 epoch 的所有 OSDMap,使得所有 PG 在该 epoch 或更晚的 epoch 上都曾经 clean 过。这个下界就是 min_last_epoch_clean。(参见 OSDMonitor::get_trim_to。)

3.4.3 min_last_epoch_clean 如何汇总

  1. Monitor quorum 通过每个 OSD 周期性发送的 MOSDBeacon 消息来确定 min_last_epoch_clean。
  2. 每条 MOSDBeacon 消息包含:
    • 该 OSD 此刻作为 primary 的一组 PG
    • 这些 PG 上的 min_last_epoch_clean
  3. Monitor 在 OSDMonitor::last_epoch_clean 中跟踪这些值。
  OSD ──MOSDBeacon{PGs I am primary for, min_last_epoch_clean}──> Monitor
                                                                    │
                                                    OSDMonitor::last_epoch_clean
                                                                    │
                                                    OSDMonitor::get_trim_to()

3.4.4 一个微妙之处:get_effective_last_epoch_clean

OSD 用来填充 MOSDBeacon 的 min_last_epoch_clean 值有个细节:

  • OSD::collect_pg_stats 调用 PG::with_pg_stats 来获取 lec 值
  • 后者实际使用的是 pg_stat_t::get_effective_last_epoch_clean(),而不是 info.history.last_epoch_clean
  • 如果 PG 当前是 clean 的,pg_stat_t::get_effective_last_epoch_clean() 返回当前 epoch,而不是 last_epoch_clean

为什么这样是对的:因为 PG 在该 epoch 上确实是 clean 的。

这样做的好处:使得在「OSDMap 正在被创建(例如由于快照活动),但没有 PG 正在经历 interval 变化」的时段里,OSDMap 依然能够被裁剪。

3.5 回到 PastIntervals:map gap 下的裁剪

现在可以理解第二个裁剪场景了:

如果 OSDMap 已经被裁剪到 epoch e,那么我们知道该 PG 必然在某个 >= e 的 epoch 上曾经 clean 过(事实上所有 PG 都必然如此),因此我们可以丢弃我们的 PastIntervals。

   OSDMap 已裁剪到 epoch e
        ⇒ 所有 PG 必然在 >= e 的某 epoch 上 clean 过
        ⇒ 无需再回溯到 e 之前
        ⇒ 可以 drop PastIntervals

3.5.1 check_past_interval_bounds()

这个依赖同样出现在 PeeringState::check_past_interval_bounds() 中:

  • PeeringState::get_required_past_interval_bounds 接受一个参数 oldest epoch
  • 该值来自 OSDSuperblock::cluster_osdmap_trim_lower_bound
  • 我们用 cluster_osdmap_trim_lower_bound 而不是某个具体 OSD 的 oldest map,原因是:我们不保证所有 MOSDMap::cluster_osdmap_trim_lower_bound 都被裁剪掉了(各 OSD 进度不一)
  • 为了避免一次做太多工作,在 OSD::trim_maps() 中用 osd_target_transaction_size 限制每次裁剪的 osdmap 数量
  • 因此,某个具体 OSD 的 oldest map 可能会在一段时间内落后于 OSDSuperblock::cluster_osdmap_trim_lower_bound

官方给出了一个相关 tracker 例子:https://tracker.ceph.com/issues/49689

3.6 OSDSuperblock::maps

3.6.1 数据结构

OSDSuperblock 持有一个 epoch interval set,表示该 OSD 所存储的 OSDMap:

  • 每一个被处理过的 OSDMap epoch 区间都会被加入该 set
  • 一旦某个 osdmap 被裁剪,它就从该 set 中抹除
  • 因此:
    • set 的下界 = 所存储的最老 map
    • set 的上界 = 最新的 map

3.6.2 为什么用 interval_set

  • interval_set 数据结构支持非连续的 epoch 区间,而这种非连续性会在 "map gap" 事件中出现。
  • 历史问题:在使用这个数据结构之前,OSDSuperblock 只存储 oldest_map 和 newest_map 两个 epoch。然而,持有单一且连续的 epoch 区间会带来约束,并可能导致 OSDMap 泄漏(leak)。

官方给出的相关 tracker:https://tracker.ceph.com/issues/61962

  使用 interval_set 前:  [ oldest_map ────────────── newest_map ]   强制连续 → 可能泄漏
  使用 interval_set 后:  [ 10─50 ][ 60─80 ][ 100─200 ]             允许空洞(map gap)

3.7 注意事项

  • 修改 PastIntervals::check_new_interval 的判据要极为谨慎:它决定了「什么时候算一个新的 interval」,直接影响 peering 频率与正确性。
  • 不要把 cluster_osdmap_trim_lower_bound 与「本 OSD 的最老 map」混为一谈——前者是集群级下界,后者可能滞后。
  • 裁剪速率由 osd_target_transaction_size 控制,调大它会加快裁剪但增加单次事务开销。

引自:OSDMap Trimming and PastIntervals


第 4 章 last_epoch_started(last_epoch_started.rst)

4.1 设计动机

Peering 的核心难题之一是:在成员频繁变化的情况下,怎么判断哪些写一定已经完成、哪些写是分歧的、以及需要回溯到多远才安全? last_epoch_started(常缩写为 les)就是服务于这个目的的一组边界值。它有两个层次:info.last_epoch_started(per-OSD 局部)与 info.history.last_epoch_started(PG 全局)。

4.2 两个层次的定义

4.2.1 info.last_epoch_started(局部)

info.last_epoch_started 记录了 interval i 的一个激活 epoch e,使得:

  • 在 i 或更早 commit 的所有写,都反映在本地的 info/log 中
  • 在 i 之后的写,没有反映在本地的 info/log 中

重要推论:由于任何已 commit 的写永远不会是分歧的(divergent),所以即便我们拿到了一个带有更旧 info.last_epoch_started 的权威 log/info,我们也可以不动自己的 info.last_epoch_started,因为在任何中间的 interval 中都不可能有写被 commit。(参见 PG::proc_master_log。)

4.2.2 info.history.last_epoch_started(全局)

info.history.last_epoch_started 记录的是「PG 整体变为 active 并接受写」的最近一个 interval 的下界(lower bound)。

在某个具体 OSD 上,它同时又是「本地 PG log 中发生过写的那些 interval 的激活 epoch」的上界(upper bound)——因为我们在接受写之前会先更新它。

4.2.3 关键推理链

官方在这里给出了一段很紧凑的推理,展开如下:

  1. 因为所有已 commit 的写都是由 acting set 中所有 OSD 共同 commit 的,所以任何非分歧的写都保证 history.last_epoch_started 在该 interval 中被所有 acting set 成员记录过。
  2. 因此,一旦 peering 已经从每一个 interval(回溯到某个见到的 history.last_epoch_started)中各查询过一个 OSD,就可以推出:在 max history.last_epoch_started 之后的任何 interval 都不可能把写报告为已 commit(因为我们在一个 interval 中记录 history.last_epoch_started 早于记录客户端写)。
  3. 于是:所有满足 info.last_epoch_started >= MAX(history.last_epoch_started) 的 info 中,最小的 last_update,必然是「已报告给客户端为已 commit 的写」的上界。
  intervals:  ... [i1] [i2] [i3] [i4] ...
                   ↑     ↑     ↑
                   |     |     └─ max(history.les) 之后的 interval 不可能有已 commit 的写
                   |     └─ 已查询过一个 OSD
                   └─ 已查询过一个 OSD

  取所有 info.last_epoch_started >= MAX(history.les) 的 info
  ⇒ min(last_update) = 已 commit 写的上界

4.3 更新时机(关键不变量)

字段 何时更新
info.last_epoch_started 随**初始激活消息(initial activation message)**更新
info.history.last_epoch_started 只有在新的 info.last_epoch_started 被持久化之后(可能与第一个写一起持久化)才更新

为什么这样安排:这确保了「在所有 acting set OSD 都记录下它之前,我们不会依赖那个拥有最新 info.last_epoch_started 的 OSD」。

 1. 发送 initial activation message → 更新 info.last_epoch_started
 2. 持久化 info.last_epoch_started(可能与第一个写一同落盘)
 3. 更新 info.history.last_epoch_started

4.4 在 peering 中的两处使用

4.4.1 find_best_info

在 find_best_info 中,我们在计算 max_last_epoch_started_found 时确实会纳入 info.last_epoch_started 的值。

原因:我们希望避免把某条 log entry 判定为分歧的(divergent)——因为在更早的 interval 中它本应是非分歧的,而它可能已经被用来服务过读。

4.4.2 activate()

在 activate() 中,我们用 peer 的 last_epoch_started 值作为「分歧 log entry 最远能追溯到哪里」的边界(bound)。

4.5 一个真实的坑:osd.1 记录了 info.les=477

官方给了一个 calc_acting 的实际输出例子:

  calc_acting osd.0 1.4e( v 473'302 (292'200,473'302] local-les=473 n=4 ec=5 les/c 473/473 556/556/556
  calc_acting osd.1 1.4e( v 473'302 (293'202,473'302] lb 0//0//-1 local-les=477 n=0 ec=5 les/c 473/473 556/556/556
  calc_acting osd.4 1.4e( v 473'302 (120'121,473'302] local-les=473 n=4 ec=5 les/c 473/473 556/556/556
  calc_acting osd.5 1.4e( empty local-les=0 n=0 ec=5 les/c 473/473 556/556/556

场景:只有 osd.1 记录了 info.les=477,而 osd.4、osd.0(它们在那个 interval 中是 acting set)没有记录(osd.4 重启了,osd.0 没及时收到消息)。

问题:结果 PG 被标记为 incomplete,而实际上 osd.4 或 osd.0 本该都是合法的选择。

解决办法:在计算 min_last_epoch_started_found 时,我们不考虑 incomplete peer 的 info.les。理由:

  • 它本来就不在那个 interval 的 acting set 中
  • 所以(如果 maybe_went_rw)无论如何我们都必须拥有来自那个 interval 的另一个 OSD
  • 如果那个 OSD 也记不得该 info.les,那么我们就不可能服务过读
  osd.1: local-les=477, 但不在该 interval 的 acting set 中,且 incomplete
         ⇒ 计算 min_last_epoch_started_found 时排除它
  osd.0 / osd.4: local-les=473, 实际 acting set 成员
         ⇒ PG 不应被误判为 incomplete

4.6 注意事项

  • 局部 info.last_epoch_started 与全局 info.history.last_epoch_started 是两个不同的东西,前者是「本地 log 到哪」,后者是「PG 整体到哪」。混淆二者会直接导致对 peering 逻辑的误读。
  • 更新顺序(先 les 持久化,再 history.les)是正确性关键,不是性能优化。
  • 修改 find_best_info / min_last_epoch_started_found 的计算时,必须回看 4.5 的这个例子,否则容易重新引入「PG 被误判 incomplete」的 bug。

引自:last_epoch_started


第 5 章 基于日志的 PG:PrimaryLogPG(log_based_pg.rst)

5.1 背景

5.1.1 为什么要有 PrimaryLogPG?

当前,所有 Ceph 池类型(包括 EC 池与副本池)的一致性都是由 primary log-based replication(基于主日志的复制)来保证的。

5.1.2 Primary log-based replication 的基本契约

读操作必须返回「任何已完成的写」所写的数据(这里的「完成」指客户端可能已经收到 commit 消息)。

实现方式有很多,但 Ceph 的架构使得「在任何 map epoch,所有人都能轻易知道谁是 primary」,因此最直接的答案是:把某个 PG 的所有写都路由到单一的顺序化 primary,再由它分发给副本。

严格来说我们只需要串行化单个 RADOS 对象上的写(甚至只需提供重叠区域写之间的偏序),但我们干脆在整个 PG 上串行化写,好处是:PG 的当前状态可以用两个数表示:

数 含义
epoch primary 上最近一次写开始时所处 map 的 epoch(这比看起来更微妙,因为 map 分发本身是异步的 —— 参见 Peering 与 interval change 的概念)
version 一个递增的 per-PG 版本号 —— 代码中类型为 eversion_t,存为 pg_info_t::last_update

5.1.3 PG log 的长度

我们维护一份"近期"操作的日志,它至少延伸回足够远,以包含:

  • 任何 unstable 写(已经开始但尚未 commit 的写)
  • 本地不是最新(not up-to-date)的对象(参见 recovery 与 backfill)

实践中日志会延伸得更远:

PG 状态 日志长度目标
clean 时 osd_min_pg_log_entries
非 clean 时 osd_max_pg_log_entries

保留更长日志的好处:便于快速执行恢复。

5.1.4 如何用日志确定「权威日志」

只要我们与「必然接受了来自最近一次接受写的 interval 中所有已完成写」的那组 OSD 的非空子集通信过,我们就能确定一份保守的(conservative)日志,它必然包含任何已报告给客户端为已 commit 的写。

这里有自由度:我们可以选择该集合中某元素记住的最老 head 与 最新 head 之间的任意 log entry 作为新 head:

  • 比「最老 head」更老的 entry:不可能在没有被该日志包含的情况下完成
  • 比「最新 head」更新的:不存在(日志里所有写都确实被启动过,记住它们是安全的)
   OSD set S = {必定接受了所有已完成写的 OSD 的非空子集}

   oldest head remembered by S  ─────────────►  newest head remembered by S
        |                                              |
        +── 可任选其一作为新 head ─────────────────────+

   副本池:选最新的合法选项  → 客户端无需重放,改为恢复其他副本
   EC  池:选最老的可用选项

5.1.5 副本池 vs EC 池:选最新 vs 选最老

这是 PG/PrimaryLogPG 中副本池与 EC 池的主要分歧点:

池类型 选哪个 head 理由
副本池 尽量选最新的合法选项 避免客户端需要重放那些操作,改为把其他副本恢复过来
EC 池 尽量选最老的选项 见下

EC 池为什么要选最老:这触及了其余实现差异的核心——

一份拷贝通常不足以重建一个 EC 对象。

事实上存在某些编码方式,使得某些日志组合会留下不可恢复的对象:

例如 k=4, m=2 编码,其中 3 个副本记得某次写,而另外 3 个不记得 —— 我们既没有 3 份新版本,也没有 3 份旧版本。

  k=4, m=2 共 6 个 shard
  写 W 后:3 个 shard 记住 W,3 个 shard 没记住 W
  ⇒ 新版本只有 3 份 (< k=4)   ⇒ 无法重建
  ⇒ 旧版本也只有 3 份 (< k=4) ⇒ 无法重建
  ⇒ 对象不可恢复!

因此:在 EC 池上,代表 unstable 写(尚未 commit 给客户端的写)的 log entry 必须只依靠本地信息就能回滚(rollbackable)。

一般化地说,log entry 可以是:

  • 可回滚的:通过延迟应用(delayed application),或通过一组「就地更新如何回滚」的指令
  • 不可回滚的

副本池的 log entry 永远不可回滚。

延伸阅读(官方文档指明):PGLog.h/cc、osd_types.h:pg_log_t、osd_types.h:pg_log_entry_t,以及 peering 相关文档。

5.2 ReplicatedBackend / ECBackend 的统一策略

5.2.1 根本差异与抽象的动机

复制(replication)与纠删码(erasure coding)的根本差异是:复制可以做破坏性更新(destructive updates),而纠删码不能。

如果要为此写两份完整的 PrimaryLogPG 实现会很难受,因为实际上只有少数几处根本差异:

差异(differences):

  1. 读如何工作 —— 只能异步;EC 需要远程读
  2. 写如何工作 —— 要么被限制为 append,要么必须 write aside 并做一次 tpc(two-phase commit)
  3. peering 时选择最老还是最新可能的 head entry
  4. log entry 中额外增加一点信息以启用回滚

相似之处(similarities) —— 数量远多于差异:

  1. 对象的所有统计与元数据
  2. 混合客户端 IO 与 recovery/scrub 的高层加锁规则
  3. 混合读写、不暴露未 commit 状态(其后续可能被回滚或被遗忘)的高层加锁规则
  4. 确定「参与了最近一次接受写的 interval 的 OSD 集合」所需的流程、元数据与协议
  5. 等等

5.2.2 选择的抽象(以及少量 kludge)

我们选择少数几个抽象(外加少量"糊弄")来抹平差异:

# 抽象 / 手段
1 PGBackend
2 PGTransaction
3 PG::choose_acting 在 calc_replicated_acting 与 calc_ec_acting 之间选择
4 写流水线中的若干处按池类型禁用某些操作 —— 如 omap 操作、class operation 读、以及(形式上,目前为止)EC 上非对齐 append 的写
5 其他零星的 kludge

其中 PGBackend 与 PGTransaction 抽象掉了上面的差异 1 和 2,并在需要时向 log entry 追加差异 4 所需的信息。

  • 复制实现在 ReplicatedBackend.h/cc,不需要额外解释。
  • ECBackend 的更多细节在 doc/dev/osd_internals/erasure_coding/ecbackend.rst(本册第 19 章)。

5.3 PGBackend 接口详解

⚠️ 官方原文声明:本节内容来自一份早于 Firefly 发布的设计文档,其中一些方法名可能已经过时。阅读时请以源码为准。

5.3.1 Readable vs Degraded(可读 vs 降级)

池类型 对象可读的条件
副本池 对象存在于 primary 上(且版本正确),当且仅当可读
EC 池 需要至少 m 个 shard 存在才能执行读,且需要在 primary 上

因此,PGBackend 需要提供若干接口,用于判定「服务一次读需要恢复」还是「服务一次写需要恢复」。这同时也改变了「peering 何时拥有足够的日志来证明……」的规则。

Core Changes:

  • PGBackend 需要能够返回 IsPG(Recoverable|Readable)Predicate 对象,以便调用方做出这些判定。

5.3.2 Client Reads(客户端读)

  • 副本池的读总可以由 primary OSD 同步满足
  • 在纠删码池中,primary 需要向若干副本请求数据才能满足一次读

因此 PGBackend 需要提供分离的 objects_read_sync 与 objects_read_async 接口,其中前者不会被 ECBackend 实现。

PGBackend 接口:

  • objects_read_sync
  • objects_read_async

5.3.3 Scrubs( scrub )

目前有两种 scrub 模式,默认频率不同:

模式 比较内容
[shallow] scrub 比较对象集合与元数据,不比较内容
deep scrub 比较对象集合、元数据,以及对象内容(含 omap)的 CRC32

流程:

  1. primary 向每个副本请求一段对象范围内的 scrubmap
  2. 副本为这段范围内的对象填充该 scrubmap;如果本次是 deep scrub,则还包含每个对象内容的 CRC32
  3. primary 从每个副本收集这些 scrubmap,执行比较,识别出不一致的对象

对 EC 的影响:这些内容基本可以原样用于纠删码 PG,唯一的注意点是 PGBackend 的实现必须负责真正执行扫描。

PGBackend 接口:

  • be_*

5.3.4 Recovery(恢复)

恢复一个对象的逻辑取决于后端:

后端 策略
当前副本策略 先把对象副本拉到 primary,然后并发推送到各副本
纠删码策略 大概希望读取最少数量的副本 chunk 以重建对象,并并发推送替换后的 chunk

另一个差异:纠删码 PG 中的对象可能在"非 unfound"的情况下就不可恢复。

unfound 状态或许应该改名为 unrecoverable。

并且,PGBackend 的实现必须能够:

  • 指导搜索「持有不可恢复对象 chunk 的 PG 副本」
  • 判定某个特定对象是否可恢复

Core changes:

  • s/unfound/unrecoverable(即把 unfound 重命名为 unrecoverable)

PGBackend 接口:

  • on_local_recover_start
  • on_local_recover
  • on_global_recover
  • on_peer_recover
  • begin_peer_recover

💡 补充:官方文档中这 5 个接口给出的是指向 GitHub firefly 分支 src/osd/PGBackend.h 具体行号(L60/L66/L78/L83/L90)的链接。由于这些链接锚定在 Firefly 时期的代码行号上,现已基本失效,本册只保留接口名,请以当前源码中的 src/osd/PGBackend.h 为准。

5.4 注意事项

  • 副本池与 EC 池在「选最老 vs 选最新 head」上的分歧,根源于 EC 的重建需要 k 份,这是理解 EC 所有其他特殊性的起点。
  • 「副本池 log entry 不可回滚」这条性质使得副本池的实现可以简单得多;EC 池必须为回滚付出额外元数据代价。
  • 5.3 节整节标注为「可能过时」,其价值在于理解设计意图,不在于照抄方法名。
  • unfound → unrecoverable 的重命名在官方文档中仍是「应该做」而非「已做」。

引自:Log Based PG


第 6 章 异步恢复(async_recovery.rst)

6.1 设计动机

Ceph Placement Groups(PG)维护一份写事务的日志,以便快速恢复数据。恢复期间,每个 PG 日志被用来判定每个 OSD 上哪些内容缺失或过期 —— 这避免了扫描所有 RADOS 对象。(详见第 5 章。)

Nautilus 之前的问题

在 Nautilus 发布之前,这个恢复过程是同步的:它会阻塞对某个 RADOS 对象的写,直到该对象被恢复完成。

相比之下,backfill 可以允许写继续进行(假定有足够多的、最新的副本可用)——做法是临时指派一个不同的 acting set,并在 acting set 之外回填一个 OSD。在某些情况下这对可用性明显更好。

官方给出的一个典型例子:

如果 PG 日志包含 3000 次针对互不相关对象的写……

在这种情况下,删掉并重新部署包含这些对象的 OSD、用 backfill 替代 recovery,反而可能更快(尽管不那么安全),胜过逐条遍历 PG 日志。

更严重的情况:

恢复几 MB 的 RADOS 对象数据(更糟的是几 MB 的 omap key,尤其是 RGW bucket index),会让一次很小的更新延迟剧增;再叠加「请求分散在许多降级对象上」,这就是 slow request 的配方。

  同步恢复 (pre-Nautilus):
      写 ──► [ 阻塞:等该对象恢复完成 ] ──► 完成
                      ↑
              4MB 对象、几 MB omap → 延迟暴涨

  异步恢复 (Nautilus+):
      写 ──► [ 立即进行,作用于 acting set ] ──► 完成
                      ↕ 同时
              后台在 acting set 之外恢复目标 OSD

6.2 核心思路:asynchronous recovery

为避免上述问题,我们可以在 OSD 上、在活跃 acting set 之外、带外(out-of-band)地在后台执行恢复 —— 这与 backfill 类似,但仍然使用 PG 日志来决定需要做什么。这就是 asynchronous recovery(异步恢复)。

6.3 何时走异步恢复:判定标准

官方明确说明:执行异步恢复(而非同步恢复)的阈值并不是一刀切的。需要满足若干条件才会走异步恢复:

# 条件
1 尽量保持 min_size 个副本可用
2 用「日志长度差的近似量级」结合「历史缺失对象」来估算恢复成本
3 用参数 osd_async_recovery_min_cost 来判定何时适合异步恢复

6.4 一个重要的工程折衷

在现有的 peering 流程中,当我们选择 acting set 时:

  • 我们还没有从每个 peer 拉取 PG 日志
  • 我们只有它们 pg_info_t 中的边界信息和其他元数据

在这个时点去拉取并检查每一份日志代价太高,因此目前只考虑对日志长度做近似检查。

在 Nautilus 中,我们改进了缺失对象(missing objects)的计量(accounting),因此 Nautilus 之后,这些信息也被用于判定恢复成本。

6.5 异步恢复期间的写处理

异步恢复进行期间:

  • 对 acting set 成员的写可以继续进行
  • 但我们需要把它们的日志条目发送给异步恢复的目标(就像我们对 backfill OSD 所做的那样),以便目标能够完整追上
                 ┌──────────────────────────┐
   客户端写 ───► │  acting set (可正常服务)  │
                 └────────────┬─────────────┘
                              │ 同时把 log entries 转发
                              v
                 ┌──────────────────────────┐
                 │ async recovery target    │  ← 在 acting set 之外
                 └──────────────────────────┘

6.6 注意事项

  • 异步恢复是可用性优于严格同步语义的折衷:它让写不被长时间阻塞,代价是需要把新写持续转发给恢复目标。
  • 判定条件中的「日志长度差」是近似的,不是精确的;不要依赖它做精确容量规划。
  • osd_async_recovery_min_cost 是唯一的显式开关参数;其余判据是内建的。

引自:Asynchronous Recovery


第 7 章 回填预留与恢复预留

7.1 Backfill Reservation(backfill_reservation.rst)

7.1.1 设计动机:惊群效应

当一个新 OSD 加入集群时,所有把它放进 acting set 的 PG 最终都必须 backfill。如果这些 backfill 同时发生,就会给该 OSD 带来过量负载 —— 即 "thundering herd"(惊群)效应。

7.1.2 数量限制:osd_max_backfills

  • osd_max_backfills 这个可调参数限制「在给定 OSD 上活跃的出向(outgoing)或入向(incoming) backfill 的数量」。
  • ⚠️ 关键细节:该限制对 incoming 与 outgoing backfill 操作是分别施加的。
  osd_max_backfills = N
      ⇒ outgoing backfill ≤ N
      ⇒ incoming backfill ≤ N
      ⇒ 每个 OSD 上在途的 backfill 操作最多可达 N * 2

官方明确点出:这个微妙之处经常被忽略,Ceph 运维人员会困惑于为什么观察到的操作数比预期更多。

7.1.3 关键数据结构:AsyncReserver

  • 每个 OSDService 现在有两个 AsyncReserver 实例:
字段 用途
local_reserver 用于从该 OSD 发出的 backfill
remote_reserver 用于发往该 OSD 的 backfill
  • AsyncReserver(common/AsyncReserver.h)管理:
    • 一个按优先级排列的等待项队列
    • 一组当前预留持有者
  • 当一个槽位空出来时,AsyncReserver 把最高优先级队列中的下一项所关联的 Context*,排入传给其构造函数的 finisher。
   AsyncReserver
     ├── queue (按 priority 排序的等待项)
     └── current reservation holders (≤ 上限)
               │
               │  槽位释放
               v
     构造时提供的 finisher  ←  下一项的 Context*

7.1.4 执行流程:如何发起一次 backfill

primary 要发起一次 backfill:

  1. 首先从自己的 local_reserver 获取一个预留
  2. 然后通过 MBackfillReserve 消息从 backfill 目标的 remote_reserver 获取一个预留

该过程由 Active 与 ReplicaActive 的子状态管理(参见 PG.h 中 Active 的子状态)。

  primary                                        backfill target
     |                                                  |
     |-- (1) local_reserver 预留 ----------------------> | (自己的 OSDService)
     |
     |-- (2) MBackfillReserve ------------------------>  |
     |                                          remote_reserver 预留
     |<-------------------------------------------------|
     |
     v  开始 backfill

7.1.5 何时释放预留

预留会在以下情况被释放:

触发 位置 说明
Backfilled 事件 primary 在调用 recovery_complete 之前发出该事件;副本在收到 BackfillComplete 进度消息时发出 正常完成
离开 Active 或 ReplicaActive — 状态迁移

7.1.6 ⚠️ 加锁顺序(防循环依赖)

必须先获取 local 预留,再获取 remote 预留,以防止循环依赖(circular dependency)。

7.1.7 优先级体系

我们通过排序 PG 的恢复顺序来最小化数据丢失风险。管理员可以用 force-recovery 或 force-backfill 覆盖默认顺序。

  • 一个 op 优先级为 255 的 force-recovery 会先于优先级 254 的 force-backfill op 开始。

恢复(recovery)相关优先级:

情形 基础优先级 增量 上限
因 PG 低于 min_size 而需要 recovery 220 距池 min_size 所缺的 OSD 数 + 与池 recovery_priority 相关的值 253
一般情形下的 recovery op 180 与池 recovery_priority 相关的值 219

上限设为 253 是为了不干扰上面描述的强制操作。

backfill 相关优先级:

情形 基础优先级 增量 上限
因 acting OSD 数少于池 min_size 而需要 backfill 220 距 min_size 所缺 OSD 数 + 与池 recovery_priority 相关的值 253
因 PG undersized 而需要 backfill 140 低于池 size 的 OSD 数 + 与池 recovery_priority 相关的值 179
因 PG degraded 而需要 backfill 140 与池 recovery_priority 相关的值 179
一般情形下的 backfill op 100 与池 recovery_priority 相关的值 139

官方汇总表(Backfill and Recovery op priorities):

Description Base priority Maximum priority
Backfill 100 139
Degraded Backfill 140 179
Recovery 180 219
Inactive Recovery 220 253
Inactive Backfill 220 253
force-backfill 254 —
force-recovery 255 —
  优先级带位图

   100 ───────── 139   Backfill
   140 ───────── 179   Degraded Backfill
   180 ───────── 219   Recovery
   220 ───────── 253   Inactive Recovery / Inactive Backfill
   254                 force-backfill
   255                 force-recovery

💡 补充:这张表的「分段」设计非常清晰——每一类恢复/回填被限制在自己的数值带内,加上池 recovery_priority 的偏移也不会越界到更高一类。因此在调优池 recovery_priority 时,不会意外让一个普通 backfill 抢到「PG 低于 min_size 的紧急恢复」之前。

7.1.8 注意事项

  • 记住 osd_max_backfills 是双向分别计数,实际并发上限可翻倍。
  • local → remote 的顺序是硬性要求。
  • 优先级上限 253 是为给 force 操作留位,改动优先级计算时要守住这条边界。

引自:Backfill Reservation

7.2 Recovery Reservation(recovery_reservation.rst)

7.2.1 与 backfill reservation 的关系

Recovery reservation(恢复预留)扩展并包含了(subsumes)backfill reservation。 backfill recovery 的那套预留系统被用于 local 与 remote 预留。

7.2.2 执行流程

步骤 1:PG 变 active 时,先判定需要什么类型的恢复(如果有):

  • log-based recovery(基于日志的恢复)
  • backfill recovery(回填恢复)
  • 两者都要
  • 都不要

步骤 2:log-based recovery 的预留

  1. primary 首先从 OSDService 的 local_reserver 获取一个 local 预留
  2. 然后按 OSD 编号顺序向每个副本发送 MRemoteReservationRequest 消息
  3. 这些请求总是会被批准(不会被拒绝),但如果远端已经把它的 remote 预留槽位全部授予出去了,批准可能需要等一段时间
  primary
    ├─ local_reserver 预留 (local)
    └─ MRemoteReservationRequest ──> replica osd.1   (按 OSD 号顺序)
       MRemoteReservationRequest ──> replica osd.2
       MRemoteReservationRequest ──> replica osd.3
              ...总是批准,但可能排队...

步骤 3:拿到全部预留后,log-based recovery 按没有预留系统时的正常方式进行。

步骤 4:log-based recovery 完成后:

  • primary 释放所有 remote 预留
  • local 预留继续保持持有
  • primary 判定是否需要 backfill
    • 不需要 → primary 释放 local 预留,并在 Recovered 状态等待所有 OSD 报告 clean

步骤 5:backfill 的预留

  • 如果 backfill 发生在 log-based recovery 之后:local 预留无需重新获取,因为它从之前就一直持有
  • 如果 backfill 在激活之后立即发生(log-based recovery 不可能/不需要):按典型流程获取 local 预留

一旦 primary 拿到了 local 预留,它向 backfill 目标请求一个 remote 预留。

⚠️ 这个预留可能会被拒绝 —— 例如 OSD 太满(backfillfull_ratio osd 设置)。

被拒绝时的行为:

  1. primary 丢弃自己的 local 预留
  2. 等待(osd_backfill_retry_interval)
  3. 然后重试
  4. 会无限重试
  请求 remote 预留
       │
       ├── 批准 ──► backfill 正常进行
       │                │
       │                └─ 完成后:释放 remote 预留
       │
       └── 拒绝(如 backfillfull_ratio)
                │
                ├─ 丢弃 local 预留
                ├─ sleep(osd_backfill_retry_interval)
                └─ 重试(无限)

步骤 6:最终,在 backfill 之后(或者如果不需要 backfill,则在 log-based recovery 之后):

  1. primary 丢弃 local 预留
  2. 进入 Recovered 状态
  3. 一旦所有 PG 都报告它们 clean,primary 进入 Clean 状态并把自己标记为 active+clean
  Active
    │
    ├─ 判定恢复类型
    │
    ├─ local 预留 → remote 预留(总是批准) → log-based recovery
    │                                          │
    │                                     释放 remote
    │                                          │
    │                              ┌───────────┴───────────┐
    │                         需要 backfill            不需要 backfill
    │                              │                       │
    │                   请求 remote(可能被拒)        释放 local
    │                              │                       │
    │                        backfill 完成             Recovered
    │                              │                       │
    │                       释放 remote                    │
    │                              │                       │
    └──────────────► 释放 local ───┴───────────────────────┘
                           │
                       Recovered  ──(所有 PG clean)──> Clean (active+clean)

7.2.3 Dump Reservations(诊断命令)

一个 OSD daemon 命令可以 dump 出总的 local 与 remote 预留:

ceph daemon osd.<id> dump_recovery_reservations

7.2.4 Things to Note(注意事项)

  1. 我们总是先获取 local 预留,以防止循环依赖。
  2. 我们按 OSD 编号顺序获取 remote 预留,原因相同。
  3. recovery reservation 状态图控制着上报给 monitor 的 PG 状态。该状态图可以设置:
PG 状态 含义
recovery_wait 等待 local/remote 预留
recovering 正在恢复
recovery_toofull 恢复已停止,有 OSD 超过 full ratio
backfill_wait 等待 remote backfill 预留
backfilling 正在 backfill
backfill_toofull backfill 已停止,有 OSD 超过 backfillfull ratio

7.2.5 注意事项(补充)

  • log-based recovery 的 remote 预留不会被拒绝(只会排队),而 backfill 的 remote 预留会被拒绝(例如 OSD 太满)。这是两者的关键行为差异,直接对应 recovery_toofull / backfill_toofull 这两个不同的 PG 状态。
  • local 预留的生命周期跨越 log-based recovery 与 backfill 两个阶段,不要重复获取。

引自:Recovery Reservation


第 8 章 部分对象恢复(partial_object_recovery.rst)

8.1 设计动机

Partial Object Recovery(部分对象恢复) 用于提升 log-based recovery(相对于 backfill) 的效率。

原始 log-based recovery 的做法:基于 pg_log 差异计算 missing_set。

存在的问题:

只要 pg_log 表明某对象被修改过,整个对象就要从一个 OSD 恢复到另一个 OSD —— 不管对象里到底有多少内容真的被修改了。

具体后果:

  • 一个 4M 的对象,里面只修改了 4k,却必须恢复整个 4M 而不是那 4k
  • 此外,即使 object map 完全没有被修改,它也要被一并恢复

8.2 两件必须做的事

为了实现上述目标,需要做到两件事:

  1. 记录对象在哪里被修改(logging where the object is modified)
  2. 记录对象的 object_map 是否被修改(logging whether the object_map is modified)

8.3 关键数据结构:ObjectCleanRegion

为此引入了 class ObjectCleanRegion:

成员 类型 含义
clean_offsets interval_set<uint64_t> 表示对象中未被修改的内容
clean_omap bool 表示 object_map 是否被修改
new_object — 表示该 osd 上不存在该对象
max_num_intervals — clean_offsets 中 interval 数量的上界,使得 clean_offsets 的内存开销始终有界

8.3.1 淘汰规则

如果 clean_offsets 中的 interval 数量超过边界,则最短的 clean interval 会被裁剪掉。

官方例子:

  设 max_num_intervals = 2,当前 clean_offsets = {[5~10], [20~5]}

  新 interval [30~10] 进来
        ⇒ 淘汰最短的那个 [20~5]

  最终 clean_offsets = {[5~10], [30~10]}

8.4 执行流程

8.4.1 第一步:在 op 路径上记录修改

  • OpContext 与 pg_log_entry_t 应当包含 ObjectCleanRegion
  • 在 do_osd_ops()、finish_copyfrom()、finish_promote() 中,ObjectCleanRegion 中对应的内容应被标记为 dirty,以追踪对象的修改
  • 同时把 OpContext 中的 ObjectCleanRegion 更新到它的 pg_log_entry_t
  do_osd_ops() / finish_copyfrom() / finish_promote()
         │
         └─► 标记 ObjectCleanRegion 中相应区间为 dirty
                    │
                    v
            更新到 OpContext 的 pg_log_entry_t

8.4.2 第二步:正确构建与重建 pg_missing_set

在 peering 过程中计算 pg_missing_set 时,同时合并每个 pg_log_entry_t 中的 ObjectCleanRegion。

官方例子:

  object aa 的 pg_log:
      26'101  {[0~4096, 8192~MAX], false}
      26'104  {0~8192, 12288~MAX, false}
      28'108  {[0~12288, 16384~MAX], true}

  missing_set for object aa(合并上述三条):
      {[0~4096, 16384~MAX], true}
      ⇒ 表示 4096~16384 被修改,且 object_map 也在版本 28'108 上被修改

8.4.3 第三步:OSD 崩溃后重建

OSD 可能在合并日志之后崩溃,因此我们需要 read_log 并重建 pg_missing_set。

官方例子:

  pg_log:
      object aa: 26'101 {[0~4096, 8192~MAX], false}
      object bb: 26'102 {[0~4096, 8192~MAX], false}
      object cc: 26'103 {[0~4096, 8192~MAX], false}
      object aa: 26'104 {0~8192, 12288~MAX, false}
      object dd: 26'105 {[0~4096, 8192~MAX], false}
      object aa: 28'108 {[0~12288, 16384~MAX], true}

原始行为:如果 bb、cc、dd 已恢复而 aa 未恢复,我们需要为 object aa 重建 pg_missing_set,发现 aa 在版本 28'108 上被修改。如果 object_info 中的版本 26'96 < 28'108,则不需要考虑 26'104 和 26'101,因为整个对象都会被恢复。

Partial Object Recovery 下的额外要求:然而部分对象恢复还要求我们重建 ObjectCleanRegion。

仅仅知道对象是否被修改是不够的。

因此我们还需要向前遍历 pg_log —— 上例中的 26'104 和 26'101 也都 > object_info(26'96) —— 并基于这三条日志(28'108、26'104、26'101)重建 object aa 的 pg_missing_set。

合并日志的方式与上面所述相同。

8.4.4 第四步:基于 pg_missing_set 完成 push/pull

基于 pg_missing_set 中的 ObjectCleanRegion 更新 recovery_info 中的 copy_subset。

copy_subset 表示需要 pull 和 push 的内容区间。

8.5 复杂部分:submit_push_data 的两种情况

官方明确:复杂的部分在 submit_push_data,需要分情况考虑。

需要考虑的是如何处理对象数据。对象数据由以下几部分组成:

omap_header, xattrs, omap, data

8.5.1 case 1:first && complete

场景:对象恢复在单次 PushOp 中完成,我们希望保留原对象并直接在其上覆盖。对象不会被删除再 touch 一个新的。

issue 1(xattrs):由于对象没有被删除,旧的 xattrs 仍留在旧对象中,但它们可能在新对象里已被更新。

  • 对同一个 key 做覆盖,或新增 key,都是正确的
  • 但删除 key 就会出错

解决办法:先移除对象中全部原始 xattrs,然后再更新新的 xattrs。

issue 2(omap):由于对象没有被删除,object_map 可能根据 clean_omap 决定是否恢复。同样地,如果正在恢复 clean_omap,出于相同原因(omap 更新也可能是一次删除),我们需要移除对象的旧 omap。

因此,本情况应执行:

  1) clear xattrs of the object
  2) clear omap of the object if omap recovery is needed
  3) truncate the object into recovery_info.size
  4) recovery omap_header
  5) recovery xattrs, and recover omap if needed
  6) punch zeros for original object if fiemap tells nothing there
  7) overwrite object content which is modified
  8) finish recovery

8.5.2 case 2:first && !complete

场景:对象恢复需要分多次完成。这里 target_oid 会指向 pgid_TEMP 中的一个新的 temp_object,因此问题略有不同。

issue 1(xattrs):由于对象是新建的,不需要处理 xattrs。

issue 2(omap):由于对象是新建的,且 object_map 可能不会随 clean_omap 一起传输。因此,如果 clean_omap 为 true,我们需要从原对象克隆 object_map。

issue 3(未修改数据):由于对象是新建的,且未修改的数据不会被传输。因此,我们需要从原对象克隆未修改的数据。

因此,本情况应执行:

  1)  remove the temp object
  2)  create a new temp object
  3)  set alloc_hint for the new temp object
  4)  truncate new temp object to recovery_info.size
  5)  recovery omap_header
  6)  clone object_map from original object if omap is clean
  7)  clone unmodified object_data from original object
  8)  punch zeros for the new temp object
  9)  recovery xattrs, and recover omap if needed
  10) overwrite object content which is modified
  11) remove the original object
  12) move and rename the new temp object to replace the original object
  13) finish recovery

8.5.3 两种情况对比

维度 case 1 (first && complete) case 2 (first && !complete)
PushOp 次数 单次 多次
目标对象 原对象(直接覆盖) pgid_TEMP 中的 temp object
xattrs 必须先清空旧的,再写新的 无需处理(新建)
omap 若需恢复则先清空旧的 若 clean_omap 为 true,从原对象克隆
未修改数据 保留在原对象中(覆盖) 需从原对象克隆到 temp
收尾 finish recovery 删除原对象 → temp 改名替换 → finish recovery

8.6 注意事项

  • ObjectCleanRegion 的内存开销靠 max_num_intervals 兜底,代价是裁剪掉最短的 clean interval 后,恢复粒度会变粗(那部分会被当作 dirty 全量恢复)。这是内存与恢复效率之间的显式折衷。
  • 重建 pg_missing_set 时必须向前遍历日志,只看"是否被修改"是不够的。这是本机制最容易出错的地方。
  • case 2 中「先建 temp 再改名替换」的做法,本质是为了让多次 PushOp 期间原对象仍可读。

引自:Partial Object Recovery


第 9 章 Scrub 内部实现(scrub.rst)

⚠️ 官方该页非常短(仅 42 行),标题为 "Scrub internals and diagnostics",内容只包括:一张行为矩阵表、一组状态变量说明、以及一条 dump 命令。scrub 的队列流转、状态机、错误码等均未在该页展开。本册如实呈现该页全部内容,不做推测补全。

9.1 Scrubbing Behavior Table(scrub 行为矩阵)

这张表回答的问题是:在给定 pool flag 组合下,某种触发方式会执行什么类型的 scrub?

Flags none noscrub nodeep_scrub noscrub/nodeep_scrub
Periodic tick S X S X
Periodic tick after osd_deep_scrub_interval D D S X
Initiated scrub S S S S
Initiated scrub after osd_deep_scrub_interval D D S S
Initiated deep scrub D D D D

图例:

符号 含义
X Do nothing(什么都不做)
S Do regular scrub(做常规 scrub)
D Do deep scrub(做 deep scrub)

9.1.1 表格解读

  纵轴 = 触发方式;横轴 = pool 上的 flag 组合

  1) 常规周期性 tick:
       - 无 flag / nodeep_scrub      → 常规 scrub
       - noscrub / 两者都设          → 什么都不做

  2) 超过 osd_deep_scrub_interval 后的周期性 tick:
       - 无 flag / noscrub           → deep scrub   ← 注意:noscrub 挡不住这里的 deep!
       - nodeep_scrub               → 降级为常规 scrub
       - 两者都设                    → 什么都不做

  3) 手动发起(Initiated)的 scrub:
       - 任何 flag 组合              → 至少做常规 scrub(S 或 D)

  4) 手动发起的 deep scrub:
       - 任何 flag 组合              → 一律 deep scrub(D)

💡 补充:这张表最重要的实践含义是——手动发起的操作优先级最高,能绕过 noscrub/nodeep_scrub;而 noscrub 并不能阻止「超过 deep scrub 间隔后的周期性 tick 走 deep scrub」,这一点很容易被误读成「noscrub 会屏蔽一切」。

9.2 State variables(状态变量)

表格背后的判定由一组布尔状态变量表达:

表格行 对应状态变量表达式
Periodic tick !must_scrub && !must_deep_scrub && !time_for_deep
Periodic tick after osd_deep_scrub_interval !must_scrub && !must_deep_scrub && time_for_deep
Initiated scrub must_scrub && !must_deep_scrub && !time_for_deep
Initiated scrub after osd_deep_scrub_interval must_scrub && !must_deep_scrub && time_for_deep
Initiated deep scrub must_scrub && must_deep_scrub

三个变量的含义(由表格表达式反推):

变量 含义
must_scrub 是否被(手动)发起过一次 scrub
must_deep_scrub 是否被(手动)发起过一次 deep scrub
time_for_deep 距上次 deep scrub 是否已超过 osd_deep_scrub_interval

9.3 Scrub Reservations(scrub 预留)

与 recovery reservation 类似,scrub 也有预留机制,可以用一条 OSD daemon 命令 dump 出来:

ceph daemon osd.<id> dump_scrub_reservations

9.4 与其他章的关联

  • Scrub 相关的工作队列(ScrubWQ、ScrubFinalizeWQ、RepScrubWQ)见第 1 章 1.2 节。
  • Scrub 与 watch 生命周期的交互(scrub 期间 PG 无法写以移除 watch)见第 12 章。
  • Snapshot trim 只在 PG clean 且不在 scrubbing 时进行,见第 10 章。
  • EC 池的 scrub 特殊性(不比较跨副本 CRC,而是各副本自查本地 chunk CRC)见第 19 章。

引自:Scrub internals and diagnostics


第 10 章 快照内部表示(snaps.rst)

10.1 Overview:两种快照机制

RADOS 支持两种相关的快照机制:

# 机制 说明
1 pool snaps 快照被隐式地应用到池中所有对象
2 self managed snaps 用户必须在每次写时提供当前的 SnapContext

两者互斥 —— 在某个特定的池上只能用其中之一。

10.2 SnapContext

SnapContext 是「当前为某对象定义的一组快照」,以及「为排序目的从 mon 请求的最近一次快照(seq)」。

排序规则:带有更新 seq 的 SnapContext 被认为更新(more recent)。

两种机制的差别(从 OSD 视角看):差异仅在于 SnapContext 是通过客户端的 MOSDOp 到达 OSD,还是通过最新的 OSDMap 到达 OSD。

机制 SnapContext 来源
self managed snaps 客户端的 MOSDOp
pool snaps 最新的 OSDMap

10.3 Ondisk Structures(磁盘上的结构)

10.3.1 head 与 clone

每个对象在 PG collection 中有一个 head 对象,以及可能的一组 clone 对象。

每个 hobject_t 都有一个 snap 字段:

对象 snap 字段取值
head(对象唯一可写的版本) CEPH_NOSNAP
clone 其创建时所在 SnapContext 的 seq

10.3.2 何时创建新 clone

当 OSD 服务一次写时,它首先检查:最近的 clone 是否被标记了一个早于「SnapContext 中所表示的最近快照」的 snapid。

  • 如果是 → 说明在这次写与上次 clone 之间至少发生过一次快照
  • 因此,在执行该变更之前,OSD 会创建一个新的 clone,用于服务介于「上一个 clone 的 snapid」与「最近的 snapid」之间的那些快照上的读
  上次 clone 的 snapid ──── 新快照 ──── 当前写
        |                     |
        └── 需要新建 clone ────┘  (服务落在该区间内的快照读)

10.3.3 SnapSet

head 对象中包含一个编码在 attribute 中的 SnapSet,它跟踪:

# 内容
1 为该对象定义的完整快照集合
2 当前存在的完整 clone 集合
3 clone 之间的重叠区间(用于跟踪空间使用)
4 Clone size

10.3.4 Whiteout(白化)

head 在仍有 clone 存在时不能被删除。取而代之的是,它被标记为 whiteout(object_info_t::FLAG_WHITEOUT),以便承载包含在其中的 SnapSet。

在这种情况下,head 对象在逻辑上不再存在。

参见:should_whiteout()

此外,每个 clone 上的 object_info_t 包含一个「该 clone 是为哪些 snap 定义的」的 vector。

10.4 Snap Removal(快照移除)

10.4.1 触发

要移除一个快照,需要向 Monitor 集群发出请求:

  • 对 self managed snaps:把该 snapshot id 加入 purged snaps 列表
  • 对 pool snaps:把它从 pool snaps 集合中移除

无论哪种情况,PG 都会把该 snap 加入它的 snap_trimq 以待裁剪。

10.4.2 何时可以移除一个 clone

当一个 clone 的所有 snap 都已被移除时,该 clone 可以被移除。

为了判定「快照移除时哪些 clone 可能需要被移除」,我们维护一个从 snap 到 hobject_t 的映射,即 SnapMapper。

参见:PrimaryLogPG::SnapTrimmer、SnapMapper

10.4.3 裁剪的执行条件与流程

裁剪由 snap_trim_wq 异步执行,条件是:PG 处于 clean 且不在 scrubbing。

执行步骤:

  1. 从 PG::snap_trimq 中选出下一个待裁剪的 snap
  2. 从 PG::snap_mapper 中确定下一个待裁剪的对象
        └─ 对每个对象:创建一条 log entry 与一个 repop,更新 object info 与 snap set
           (包括调整 overlaps)
           └─ 如果该对象是一个不再属于任何活快照的 clone,则在此处被移除
              (参见 PrimaryLogPG::trim_object(),当 new_snaps 为空时)
  3. 我们同时用该对象的新 snaps 本地更新自己的 SnapMapper 实例
  4. 包含该对象修改的那条 log entry 也包含新的 snap 集合,
     副本用它来更新自己的 SnapMapper 实例
  5. primary 与副本共享 info,副本把新的 purged_snaps 集合连同其余 info 一起持久化
  primary                                        replica
     │                                              │
     ├─ 选 snap (snap_trimq)                        │
     ├─ 选 object (snap_mapper)                     │
     ├─ 创建 log entry + repop ────────────────────>│
     │                                              ├─ 用新 snaps 更新自己的 SnapMapper
     ├─ 本地更新 SnapMapper                          │
     └─ 共享 info ──────────────────────────────────>│
                                                    └─ 持久化新的 purged_snaps + info

10.5 Recovery(快照裁剪与恢复的关系)

由于 trim 操作是通过 repops 与 log entries 实现的,常规的 PG peering 与 recovery 就会顺带维护 snap trimmer 的操作,唯一的注意点是:push 与 removal 操作需要更新本地的 SnapMapper 实例。

如果 purged_snaps 的更新丢失了,我们不过是重新裁剪一个现在已经为空的 snap而已。

10.6 SnapMapper

6.10.1 实现基础

SnapMapper 构建在 map_cacher<string, bufferlist> 之上,后者在诸如文件系统这样的后备存储之上提供一个带异步事务的接口。

  • 在事务尚未完成时,map_cacher 实例会缓冲不稳定的 key,从而允许在无需 flush filestore 的情况下做一致性访问。

10.6.2 两个映射

SnapMapper 提供两个映射:

# 映射 内容
1 hobject_t -> set<snapid_t> 存储每个 clone 对象的 snap 集合
2 snapid_t -> hobject_t 存储以该快照为其 snap 之一的一组 hobject

10.6.3 编码设计与假设

假设:hobject 很多,snap 相对较少。

映射 编码方式
第一个 key = 对象的 stringification;value = snap 集合的编码
第二个 由于单个 snap 可能对应很多 hobject,它被存为一批 key,形式为 stringify(snap)_stringify(object),其中 stringify(snap) 是定长的
  • 这些 key 的 value 是 pair<snapid, hobject_t> 的 bufferlist 编码。
  • 收益:创建或裁剪单个对象不需要读取某个 snap 下的所有对象。
  • 此外,在构造时,SnapMapper 会被提供一个 mask,用于过滤单一 SnapMapper keyspace 中属于该 PG 的对象。

10.6.4 Split(PG 分裂)

  • snapid_t -> hobject_t 的 key 条目被安排成:对任意 PG,最多需要检查 8 个前缀,就能确定某个 PG 中某个特定 snap 下的所有 hobject。
  • 分裂时:父 PG 上要检查的前缀会被调整,使得只有留在该 PG 中的对象才是可见的。
  • 子 PG:会立即拥有正确的映射。

10.7 clone_overlap

10.7.1 定义

每个附加在 head 对象上的 SnapSet 包含各 clone 对象之间的重叠区间,用于优化空间。

  • 重叠区间存储在 clone_overlap map 中
  • map 中的每个元素存储 snap ID 以及与"下一个更新的 clone"的对应 overlap

10.7.2 官方演示例子(4 字节对象)

初始状态:

object content
head [AAAA]

listsnaps 输出:

cloneid snaps size overlap
head - 4

步骤 1:拍快照(ID 1),然后重写对象的前 2 个字节

创建的 clone 将与新的 head 对象在其后 2 个字节上重叠。

object content
head [BBAA]
clone ID 1 [AAAA]
cloneid snaps size overlap
1 1 4 [2~2]
head - 4

步骤 2:再拍快照(ID 2),这次只重写第 1 个字节

创建的 clone(ID 2)将与新的 head 对象在其后 3 个字节上重叠;而最老的 clone(ID 1)将与最新的 clone在其后 2 个字节上重叠。

object content
head [CBAA]
clone ID 2 [BBAA]
clone ID 1 [AAAA]
cloneid snaps size overlap
1 1 4 [2~2]
2 2 4 [1~3]
head - 4

步骤 3:head 对象被重写 4 个字节,完全覆盖

唯一还会保留的重叠是两个 clone 之间的重叠。

object content
head [DDDD]
clone ID 2 [BBAA]
clone ID 1 [AAAA]
cloneid snaps size overlap
1 1 4 [2~2]
2 2 4
head - 4

步骤 4:最后一个 snap(ID 2)被移除,snaptrim 生效

不再有任何重叠区间残留。

object content
head [DDDD]
clone ID 1 [AAAA]
cloneid snaps size overlap
1 1 4
head - 4

10.8 注意事项

  • head 被 whiteout 后逻辑上不存在但物理上必须保留,因为它承载着 SnapSet。删除 head 的逻辑必须先检查 clone 是否清空。
  • SnapMapper 的第二个映射按「定长 snap 前缀 + 对象名」编码,是为了让单对象操作不触发全 snap 扫描;改动编码格式会破坏这个性质。
  • snap trim 只在 clean 且不 scrubbing 时进行,因此一个长期不 clean 的 PG 会累积快照空间。

引自:Snaps


第 11 章 Refcount(引用计数,refcount.rst)

11.1 设计动机

去重(Deduplication,如 ../deduplication.rst 所述)需要一种方式,来维护一个去重后的 chunk 目标池,并对其进行原子引用计数。

为此,存在一个 osd object class —— refcount,它负责利用 object class 机制来维护去重 chunk 上的引用计数,并在引用计数降到 0 时最终删除它们。

11.2 Class Interface

参见 cls/refcount/cls_refcount_client*

11.2.1 cls_refcount_get

作用:以指定的 tag 原子地递增引用计数。

void cls_refcount_get(librados::ObjectWriteOperation& op, const string& tag, bool implicit_ref = false);

11.2.2 cls_refcount_put

作用:以传入的 tag 原子地递减引用计数。

void cls_refcount_put(librados::ObjectWriteOperation& op, const string& tag, bool implicit_ref = false);

11.2.3 cls_refcount_Set

作用:以传入的 tag 列表原子地设置引用计数集合。

void cls_refcount_set(librados::ObjectWriteOperation& op, list<string>& refs);

11.2.4 cls_refcount_read

作用:dump 出该对象当前的 ref tag 集合。

int cls_refcount_read(librados::IoCtx& io_ctx, string& oid, list<string> *refs, bool implicit_ref = false);

11.3 接口汇总表

接口 操作类型 作用 关键参数
cls_refcount_get ObjectWriteOperation 原子递增指定 tag 的引用计数 tag, implicit_ref=false
cls_refcount_put ObjectWriteOperation 原子递减指定 tag 的引用计数 tag, implicit_ref=false
cls_refcount_set ObjectWriteOperation 原子设置引用计数集合 list<string>& refs
cls_refcount_read 读(经 IoCtx) dump 当前 ref tag 集合 oid, *refs, implicit_ref=false

11.4 与其他章的关联

  • refcount class 是 manifest / dedup 机制(第 15 章)的支撑设施:SET_CHUNK / EVICT_CHUNK 等操作都要带上 reference。
  • ceph-dedup-tool --op chunk_scrub 用于修复引用计数不一致(第 15 章 15.10 节)。

11.5 注意事项

  • 官方该页只列出 4 个接口的签名,没有给出 implicit_ref 参数的语义说明,也没有给出引用计数降到 0 后的删除时机与并发语义。这些属于文档未明确项。
  • 所有 4 个接口都有 implicit_ref 参数且默认 false,但本页未解释其含义。

引自:Refcount


第 12 章 Watch / Notify(watch_notify.rst)

12.1 Overview

  • object_info(参见 osd/osd_types.h)在 object_info_t::watchers map 中持久化地跟踪某个对象的 watcher 集合。
  • 为了跟踪 notify 的进度,我们还维护一些与 ObjectContext 关联的**临时(ephemeral)**结构。

12.2 关键数据结构与引用关系

12.2.1 Watch 与 ObjectContext 的循环引用

  • 每个 Watch 都有一个关联的 Watch 对象(参见 osd/Watch.h)
  • 被 watch 的对象的 ObjectContext 会对每个 watch持有一个(强)Watch 对象引用
  • 每个 Watch 对象持有对应 ObjectContext 的引用

这个循环引用是故意设计的,它在以下时机被打破:

  • 新的 peering interval 上 Watch state 被丢弃时
  • 超时到期时
  • unwatch 操作时
   ObjectContext ──(strong ref)──> Watch
        ^                            │
        └────────(ref)───────────────┘
                 循环引用,故意为之
   打破时机:新 peering interval / 超时 / unwatch

12.2.2 连接跟踪

  • 一个 watch 通过强引用 ConnectionRef Watch::conn 跟踪其关联的连接
  • 该关联连接在 OSD::Session 中存放了一个 WatchConState,用于跟踪关联的 Watch,以便能够在 ms_handle_reset() 时通知它们(通过 WatchConState::reset())
   Watch ──ConnectionRef conn──> Connection
                                      │
                              OSD::Session
                                      │
                              WatchConState (跟踪该连接上的 Watches)
                                      │
                             ms_handle_reset() → WatchConState::reset()

12.2.3 Notify 跟踪

  • 每个 Watch 对象跟踪当前未被 ack 的 notify 集合
  • 在一个 Watch 对象上调用 start_notify() 会:
    1. 向该 Watch 添加一个指向新的进行中 Notify 的引用
    2. 然后二选一:
Watch 状态 行为
connected 向客户端发送一个 Notify 消息
unconnected 什么都不做
  • 当 Watch 变为 connected 时(在 PrimaryLogPG::do_osd_op_effects 中),Notifies 会被重新发送给所有剩余被跟踪的 Notify 对象
  start_notify()
      │
      ├─ Watch connected   ──► 发送 Notify 给客户端
      └─ Watch unconnected ──► 什么都不做
                                   │
                    变为 connected (do_osd_op_effects)
                                   │
                                   v
                        重发 Notifies 给所有剩余 Notify
  • 每个 Notify 对象通过调用 complete_watcher() 来跟踪尚未被通知的 Watcher 集合
  • 一旦剩余集合为空,或超时到期(cb,在 init() 中注册),就向客户端发送 notify completion

12.3 Watch 生命周期:5 种状态

一个 watch 可能处于下列 5 种状态之一:

# 状态
1 Non existent
2 On disk, but not registered with an object context
3 Connected
4 Disconnected, callback registered with timer
5 Disconnected, callback in queue for scrub or is_degraded

12.3.1 状态转换

转换 时机 / 位置
→ Case 2 发生在「OSD 变为 active」与「带 watcher 的对象的 ObjectContext 因某次访问被载入内存」之间
Case 2 → Case 4 在 PrimaryLogPG::populate_obc_watchers() 中(位于 PrimaryLogPG::find_object_context 期间)
Case 1 → Case 3 经由 OSD::do_osd_op_effects,由于一次 watch 操作
Case 4、5 → Case 3 同样经由 OSD::do_osd_op_effects
Case 3 → Case 4 当 watcher 的 session 上的连接 reset 时

Case 2 期间:不为该 watch 注册任何状态。

   (1) Non existent
        │  watch 操作 (OSD::do_osd_op_effects)
        v
   (3) Connected  ──连接 reset──> (4) Disconnected + timer callback
        ^                              │
        │                              │  timer 到期,但 PG 不能写
        │                              v
        │                        (5) Disconnected + callback 排队
        │                              │  (scrub 或 is_degraded)
        └────── do_osd_op_effects ─────┘

   OSD active 之后、ObjectContext 尚未载入 → (2) On disk, 未注册
        │  populate_obc_watchers()
        v
       (4)

12.3.2 Case 4 与 Case 5 详解

通常,当一个 Watch 进入 Case 4 时,会向 OSDService::watch_timer 注册一个回调,在超时到期时被调用。

问题:在回调被调用的那个时刻,PG 可能处于无法写该对象以移除该 watch 的状态 —— 即:

  • 正在 scrub 期间
  • 或该对象是 degraded 的

解决办法:这种情况下,我们使用 Watch::get_delayed_cb() 生成另一个 Context,供 callbacks_for_degraded_object 与 Scrubber::callbacks 这两个列表使用。

无论哪种情况,Watch::unregister_cb() 都会做正确的事(SafeTimer::cancel_event() 对未注册到 timer 的 context 是无害的)。

12.4 Notify 生命周期

Notify 的超时处理更简单:

  1. 当 notify 被 init() 时,注册一个超时回调
  2. 如果所有 watcher 在超时发生前都 ack 了 notify → 取消超时,并通知客户端 notify 完成
  3. 否则 → 超时触发,Notify 对象通过 cancel_notify ping 每个 Watch 以移除自身,并提前向客户端发送 notify completion
  notify init()  → 注册 timeout callback
        │
        ├── 所有 watcher 都 ack(超时前) → 取消 timeout → 发送 completion
        │
        └── 超时触发 → Notify::cancel_notify ping 各 Watch 移除自身
                     → 提前发送 completion

12.5 注意事项

  • Watch 与 ObjectContext 的循环引用是有意为之,不要尝试"修正"它;关键是确保三个打破时机(新 interval / 超时 / unwatch)都被正确覆盖。
  • Watch 进入 disconnected 后的清理受 PG 状态制约(scrub / degraded 时不能写),这是 get_delayed_cb() 存在的理由。
  • unconnected 的 Watch 上 start_notify() 静默什么都不做,重连后才补发 —— 这意味着 notify 的送达是尽力而为,不是即时保证。

引自:Watch Notify


第 13 章 Writeback Throttle(wbthrottle.rst)

13.1 设计动机(历史问题)

过去,filestore 在处理大量小 I/O 时有一个问题:

  • 我们通过 journal 隐式地对脏数据做节流
  • 但是大量 inode 可以在不填满 journal 的情况下被弄脏,结果是「当 sync 最终发生时,sync 时间非常长」

为什么 flusher 不够用:

flusher 并不是这个问题的充分解决方案,因为它过于急切地强制回写小写入,反而扼杀了性能。

  问题链:
     大量小 I/O
        → 大量 inode 变脏
        → journal 没满(隐式节流未触发)
        → 最终 sync 时耗时极长

  flusher 的失败之处:
     过于急切地强制回写小写入 → 性能被扼杀

13.2 核心机制

WBThrottle 的做法:

  • 按 hobject_t 跟踪未 flush 的 io
  • 一旦对以下三个维度中任意一个超过 start_flusher 阈值,就按 LRU 顺序执行 ::fsync:
维度
dirty bytes
dirty ios
dirty inodes
  • 只要其中任意一个超过 hard_limit,我们就在 _do_op 中阻塞于 throttle()
  三个计数器:dirty bytes / dirty ios / dirty inodes

      任一 > start_flusher  ──►  按 LRU 顺序 fsync
      任一 > hard_limit     ──►  在 _do_op 中阻塞于 throttle()

参见:src/os/WBThrottle.h、src/osd/WBThrottle.cc

13.3 相关设施:fdcache

为了在回写过程中跟踪打开的 FD,现在有一个 fdcache 用于缓存打开的 fd:

  • lfn_open 现在返回一个缓存的 FDRef
  • 一旦所有引用都过期,该 FDRef 隐式关闭该 fd

13.4 两个副作用/交互

事项 说明
Filestore sync Filestore 的 sync 有一个副作用:会 flush wbthrottle 中所有未完成的(outstanding)对象
lfn_unlink 当最后一个 link 被移除时,lfn_unlink 会清除被 unlink 对象的缓存 FDRef 与 wbthrottle 条目,并 assert 该对象的所有未完成 FDRef 都已失效

13.5 注意事项

  • 该机制是面向 filestore 时代的。当前默认后端是 bluestore,本页内容应作为历史背景与理解 WBThrottle 类用途的材料来读。
  • 三个维度(bytes / ios / inodes)各自独立触发,其中 inodes 这一维正是为了修复「大量 inode 变脏但 journal 不满」这个原始问题而设。
  • lfn_unlink 中的 assert 说明「FDRef 全部失效」是一个硬性不变量,任何新的 FD 缓存路径都必须维护它。

引自:Writeback Throttle


第 14 章 防止陈旧读(stale_read.rst)

14.1 设计动机

写路径上的保护:

我们在向客户端发送 ACK 之前,会同步地写入所有副本,这限制了写路径中出现不一致的可能性。

读路径上的缺口:

但是默认情况下,我们只从一个副本(每个 PG 的 lead/primary OSD)服务读;客户端会使用它手上任意版本的 OSDMap 来选择从哪个 OSD 读。

通常这是没问题的,两种情况之一成立:

  1. 客户端的 map 是正确的
  2. 或者「我们认为是该对象 primary 的那个 OSD」知道它已经不再是 primary,于是可以给客户端喂一份更新的 map,指明一个更新的 primary

关键目标:

关键是确保这一点始终成立。特别地,我们必须确保:一个被与其 peer 隔离(fenced off)、并且没有得知 map 更新的 OSD,不会在任何「新的 primary 可能已被允许执行写」的时点之后,继续为同样陈旧的客户端提供读请求服务。

14.2 实现思路:类读租约(read lease)

我们通过一种**工作方式很像读租约(read lease)**的机制来实现这一点。

租约长度:

  • 每个 pool 可以有一个 read_lease_interval 属性来定义租约多长
  • 但默认情况下,我们简单地把它设为:
osd_pool_default_read_lease_ratio (默认: .8) × osd_heartbeat_grace

为什么这样设:这样租约通常会在我们把一个失败的 OSD 标记为 down 之前就已经过期。

14.3 readable_until 与 readable_until_ub

Primary 与 replica 都跟踪两个值:

值 含义
readable_until 在我们的"租约"到期之前,我们被允许服务(读)请求多久
readable_until_ub 对 acting set 中任意 OSD 的 readable_until 的上界

14.3.1 primary 如何管理这两个值

  1. primary 通过向副本发送 pg_lease_t 消息来提高上界
  2. 一旦所有 acting OSD 都确认它们已看到这个更高的上界,primary 就提高自己的 readable_until
  3. 并在后续的 pg_lease_t 消息中分享这个值

产生的不变量:

任何 acting OSD 的 readable_until 始终 <= 任何 acting OSD 的 readable_until_ub。

14.3.2 时钟偏移处理

为了避免时钟偏移带来的任何问题:

  • 我们**全程使用单调时钟(monotonic clocks)**来管理这些租约(单调时钟只在本地准确,不受时间调整影响)
  • Peer OSD 计算 OSD 本地时钟之间 delta 的上下界
  • 这使得 primary 可以基于自己的本地时钟分享时间戳,而副本把该时间戳翻译为自己本地时钟下的一个合适的界
  primary (本地时钟 P)                    replica (本地时钟 R)
       │                                        │
       │  pg_lease_t{ 上界(基于 P) }            │
       ├───────────────────────────────────────>│
       │                                        │ 用 delta 上下界翻译到 R
       │<───────────────────────────────────────┤
       │  ack                                   │
       │                                        │
       │  所有 acting OSD 都 ack                │
       │  ⇒ 提高自己的 readable_until           │
       │  ⇒ 后续 pg_lease_t 中分享              │

14.4 Prior Intervals(先前 interval)

每当发生 interval change 时,我们需要一个上界,覆盖先前 interval 中任何 OSD 的 readable_until 值。

  • 所有来自那个 interval 的 OSD 都有这个值(readable_until_ub)
  • 它们在 peering 期间作为 pg_history_t 的一部分分享它

14.4.1 编码方式的选择

由于 peering 可能涉及「此前并未通信、因而没有彼此时钟 delta 界」的 OSD,pg_history_t 中的这个界被分享为:

一个「距上界到期还有多久」的简单持续时间(a simple duration before the upper bound expires)

推论:这意味着该界会因为 peering 消息的传输时间而向未来滑动(slips forward)。

为什么可以接受:

  • 传输时间通常相当短
  • 而且把界往后移是安全的,因为它是上界

14.5 PG "laggy" 状态

  • 当 PG 处于 active 时,pg_lease_t 与 pg_lease_ack_t 消息会被定期交换
  • 但是,如果一个客户端请求到来时租约已经过期(readable_until 已过):
    • PG 会进入 LAGGY 状态
    • 该请求会被阻塞
    • 一旦租约被续期,这些请求会被重新入队(requeued)
  PG active,定期交换 pg_lease_t / pg_lease_ack_t
       │
       │  客户端请求到达,但 readable_until 已过
       v
  PG ──> LAGGY 状态 ──> 请求被阻塞
       │
       │  租约续期
       v
  请求被 requeued

14.6 PG "wait" 状态

  • 如果 peering 完成了,但先前 interval 的 OSD 可能仍然可读,则 PG 会进入 WAIT 状态,直到足够的时间过去
  • 在此期间任何 OSD 请求都会被阻塞
  • 恢复(Recovery)可以在此状态下继续进行,因为对象的逻辑、用户可见内容不发生改变
  peering 完成
       │
       │  先前 interval 的 OSD 可能仍可读
       v
  PG ──> WAIT 状态 ──> 所有 OSD 请求阻塞
       │                      │
       │                      └─ Recovery 可继续进行(逻辑内容不变)
       │
       │  足够时间过去
       v
  退出 WAIT

14.7 Dead OSDs(提前退出 WAIT)

一般来说,我们需要等到先前 interval 的 OSD「知道」它们不应再可读为止。

两种可以推断出「它不可读」的情形:

# 情形 推断依据
1 OSD 已知崩溃 例如进程已不再运行 —— 我们可以因为我们收到 ECONNREFUSED 错误而推断出这一点
2 OSD 被标记 down、收到告知它这一点的 map 更新、然后通知 monitor 它知道自己已被标记 down 我们可以类似地推断它不再为先前的 interval 服务请求

提前退出 WAIT:

当一个 PG 处于 WAIT 状态时,它会监视新的 map 中 OSD 的 dead_epoch 值,该值表明它们知晓自己的 dead-ness。如果先前 interval 中所有 down 的 OSD 都已知晓,我们就可以提前退出 WAIT 状态。

  PG 处于 WAIT
       │
       └─ 监视新 map 中各 OSD 的 dead_epoch
              │
              │  先前 interval 中所有 down OSD 都已知晓自己 down
              v
        提前退出 WAIT

14.8 注意事项

  • 读租约的默认长度是 osd_heartbeat_grace 的 0.8 倍,这个 0.8 系数是「让租约在我们标记 OSD down 之前就过期」的关键;改动 osd_heartbeat_grace 会连带影响陈旧读保护的时间窗。
  • readable_until <= readable_until_ub 是核心不变量,任何修改租约管理代码的改动都必须维持它。
  • WAIT 状态下恢复仍在进行,但请求被阻塞 —— 这是「数据安全优先于可用性」的显式选择。
  • 单调时钟 + delta 上下界的组合,是分布式租约在「无全局时钟」前提下的标准做法。

引自:Preventing Stale Reads


第 15 章 Manifest(manifest.rst)

15.1 Introduction(简介)

正如 ../deduplication.rst 所述,给 RADOS 增加透明的 redirect 机制,将能提供一个比 RADOS 现有 "cache/tiering" 更有能力的 tiering 方案。

高层设计:

每个对象都有一段嵌入在 object_info_t 中的元数据,它可以把对象数据 payload 的子集映射到其他池中的(引用计数的)对象。

本文档旨在详述:

  1. Manifest 数据结构
  2. 用于操作 manifest 的 Rados 操作
  3. 状态与计划(Status and Plans)

15.2 Intended Usage Model(预期使用模型)

15.2.1 RBD

主要目标:让一个 OSD 内部代理或一个集群外部代理能够透明地在一个 dedup pool 与一个 hot base pool 之间迁移构成 RBD 的 4MB extent 的部分内容。

约束:

  • RBD 操作(包括 class 操作与快照)必须无论对象当前状态如何,都具有相同的可观察结果
  • 而且,tiering/dedup 操作必须与 RBD 操作交错执行而不改变结果

tiering agent 执行基本操作的流程草图:

A. 把冷 RBD chunk 降级(demote)到慢池

  1. 读对象,记下当前的 user_version
  2. 在内存中运行 CDC 实现,对对象做指纹计算
  3. 把得到的每个 extent 用 CAS class 写到冷池中的一个对象
  4. 向 base pool 提交操作:
       - ASSERT_VER,带上读时得到的 user version(若对象自读之后被修改则失败)
       - SET_CHUNK,为每个 extent 指向 base pool 中对应的对象
       - EVICT_CHUNK,为每个 extent 释放 base pool 中的空间(使每个 chunk 被标记 MISSING)

  ⇒ RBD 用户只会看到降级前的状态,或降级后的状态

⚠️ 注意:在步骤 3 与 4 之间,我们可能泄漏引用(leak references),因此需要周期性的 scrub 来校验引用计数。

B. 把冷 RBD chunk 提升(promote)到快池

  1. 提交 TIER_PROMOTE

C. 对 clone:上述所有操作都是相同的,除了:

  • 初始读需要一次 LIST_SNAPS 来确定存在哪些 clone
  • PROMOTE 或 SET_CHUNK/EVICT 操作需要包含 cloneid

15.2.2 RadosGW

方向 行为
读 RGW 可以像上面 RBD 那样操作,依靠 OSD 中的 manifest 机制来隐藏「对象已被 dedup 还是存在于 base pool」的区别
写 RGW 可以像上面 RBD 那样操作,但也可以有在写之前先做指纹计算的自由。在这种情况下,它可以立即把目标对象写到 CAS 池,然后原子地写入一个带有相应 chunk 设置的对象

15.3 Status and Future Work

当前状态:目前存在一个 manifest 数据结构的初始版本,以及 IO 路径支持与 rados 控制操作。

未来工作计划(高层):

方向 内容
Cleanups 解决下一节列出的直接的不一致与缺陷
Testing Rados 严重依赖 teuthology 故障测试来验证 cache/tiering 这类特性。我们需要对应的 manifest 操作测试
Snapshots 我们希望能够在 rados 快照系统之下的层次对 clone 的部分内容做去重。因此下述 rados 操作需要被扩展以正确作用于 clone(例如:应该能对 clone 调用 SET_CHUNK,清除 base pool 中对应的 extent,并正确维护 OSD 元数据)
Cache/tiering 最终我们希望能够废弃现有的 cache/tiering 实现,但为此需要确保我们能覆盖同样的用例

15.4 Cleanups(已知待清理问题)

官方明确列出当前实现需要清理的地方:

15.4.1 SET_REDIRECT

  • 应该在对象不存在时创建该对象,否则就无法原子地把一个对象创建为 redirect

15.4.2 SET_CHUNK

问题 说明
触发新 clone 看起来会触发一次新的 clone,因为 do_osd_ops 中设置了 user_modify。这大概不是我们想要的(参见 Snapshots 一节关于如何把这些操作与快照混合的选项)。至少,SET_CHUNK 大概不应该设置 user_modify
假设对应区段不存在 看起来假设对象的对应区段不存在(设置 FLAG_MISSING)但不检查该 extent 是否已经存在于对象中。应该总是让该 extent 保持 clean
无条件清除 manifest 看起来在不是 chunked 的情况下会无条件清除 manifest,这大概是错的。如果它是 REDIRECT,我们应该返回错误:
	case CEPH_OSD_OP_SET_CHUNK:
	  if (oi.manifest.is_redirect()) {
	    result = -EINVAL;
	    goto fail;
	  }

15.4.3 TIER_PROMOTE

问题 说明
未清除 redirect SET_REDIRECT 会清除对象内容。PROMOTE 看起来会把它们拷回来,但并没有 unset redirect 或清除引用。这违反了「redirect 对象在 base pool 中应为空」这一不变量。特别是,只要 redirect 还设着,看起来所有操作都会被代理,即使在 promote 之后也是如此 —— 这就使 promote 失去了意义。我们确实希望 PROMOTE 能够原子地把一个 redirect 替换为真实对象,因此解决方案是在 promote 结束时清除 redirect
chunked manifest 下做了 flush 对于一个 chunked manifest,我们看起来在 promote 之前做了 flush。Promotion 常被用来为一个对象准备低延迟的读写,因此唯一的效果应该是把所有 MISSING extent 读进 base pool。不应该做任何 flushing

15.4.4 High Level

问题 说明
FLAG_DIRTY 不应被用于指向 dedup extent 的 extent 把被修改的 extent 写回 dedup 池需要写一个新对象,因为旧对象不能被修改 —— 这与它还没被 dedup 时是一样的。因此,我们应该总是丢弃该引用并移除 manifest 指针
目前没有"驱逐对象区域"的方法 有了上面「SET_CHUNK 总是保留现有对象区域」的改动后,我们需要一个 EVICT_CHUNK 操作来移除该 extent

15.5 Testing(测试计划)

官方强调:我们非常依赖随机化故障测试。因此需要把该测试扩展到包含 dedup/manifest 支持。简要的接触点清单:

类别 内容
Thrasher 测试 类似 qa/suites/rados/thrash/workloads/cache-snaps.yaml。该测试测的是现有 cache/tiering 机制。应在该目录下增加文件以改为搭建一个 dedup pool。并为 ceph_test_rados(src/test/osd/TestRados*)增加支持
RBD 测试 增加一个「RBD 工作负载与盲目的 promote/evict 操作并发运行」的测试
RGW 增加一个「rgw 工作负载与盲目的 promote/evict 操作并发运行」的测试

15.6 Snapshots(与快照的交互)

根本需求:我们需要能够操作 clone 的 manifest 状态,因为我们希望能够在 clone 上动态地 promote、flush(如果 clone 创建时状态是 dirty 的话)和 evict extent。

计划:允许每个 clone 的 object_manifest_t 相互独立。以下是不完整的高层任务清单:

  • 修改 op 处理流水线,允许 SET_CHUNK、EVICT_CHUNK 直接作用于 clone
  • 确保 recovery 在使用 clone_range 中的 overlaps 之前先检查 object_manifest。ReplicatedBackend::calc_*_subsets 这两个方法很可能就是需要修改的地方

15.6.1 关键约定:连续 clone 共享同一引用计数(refcount)

要解决的问题:许多 clone 常常在相同的 offset 上持有指向相同后端 chunk 的引用。特别地:

make_writeable 通常会创建一个 clone,该 clone 与(原对象)共享相同的 object_manifest_t 引用,只有本事务中被修改的 extent 除外。

因此,随该事务提交的元数据必须映射到与之前相同的 refcount,因为否则我们就得先递增后端对象的引用计数(否则就要冒「引用到一个已死对象」的风险)。

由此引入一个简单的约定:

在同一 offset 上共享同一引用的连续(consecutive)clone,共享同一个 refcount。

推论:

这意味着一次调用 make_writeable 的写可能会减少 refcount,但不会增加 refcount。

15.6.2 官方给出的完整推演例子

  write foo [0, 1024)
  flush foo ->
    head: [0, 512) aaa, [512, 1024) bbb
    refcount(aaa)=1, refcount(bbb)=1
  snapshot 10
  write foo [0, 512) ->
    head:               [512, 1024) bbb
    10  : [0, 512) aaa, [512, 1024) bbb
    refcount(aaa)=1, refcount(bbb)=1
  flush foo ->
    head: [0, 512) ccc, [512, 1024) bbb
    10  : [0, 512) aaa, [512, 1024) bbb
    refcount(aaa)=1, refcount(bbb)=1, refcount(ccc)=1
  snapshot 20
  write foo [0, 512) (same contents as the original write)
    head:               [512, 1024) bbb
    20  : [0, 512) ccc, [512, 1024) bbb
    10  : [0, 512) aaa, [512, 1024) bbb
    refcount(aaa)=?, refcount(bbb)=1
  flush foo
    head: [0, 512) aaa, [512, 1024) bbb
    20  : [0, 512) ccc, [512, 1024) bbb
    10  : [0, 512) aaa, [512, 1024) bbb
    refcount(aaa)=?, refcount(bbb)=1, refcount(ccc)=1

问题:最后 aaa 的 refcount 应该是多少?

按照上述规则,应该是 2,因为这两个 aaa 引用不是连续的。

再考虑移除 clone 20:

  initial:
    head: [0, 512) aaa, [512, 1024) bbb
    20  : [0, 512) ccc, [512, 1024) bbb
    10  : [0, 512) aaa, [512, 1024) bbb
    refcount(aaa)=2, refcount(bbb)=1, refcount(ccc)=1
  trim 20
    head: [0, 512) aaa, [512, 1024) bbb
    10  : [0, 512) aaa, [512, 1024) bbb
    refcount(aaa)=?, refcount(bbb)=1, refcount(ccc)=0

此时,我们的规则规定 refcount(aaa) 是 1。这意味着移除 20 需要检查其两侧 clone 所持有的引用,那些引用将会匹配。

代码位置:参见 osd_types.h:object_manifest_t::calc_refs_to_drop_on_removal 中实现该规则的逻辑。

15.6.3 这条约定带来的两个宝贵性质

# 性质
1 来自 make_writeable 的 refcount 变更不会阻塞在递增一个引用上
2 我们不需要为了决定如何移除一个 clone 而载入每一个 clone 的 object_manifest_t —— 只需要载入紧邻其前与其后的那两个

所有 clone 操作在添加或移除引用时,都需要考虑相邻的 chunk_map。

15.7 Data Structures(数据结构)

每个 RADOS 对象都包含一个嵌入在 object_info_t 中的 object_manifest_t(见 osd_types.h):

        struct object_manifest_t {
                enum {
                        TYPE_NONE = 0,
                        TYPE_REDIRECT = 1,
                        TYPE_CHUNKED = 2,
                };
                uint8_t type;  // redirect, chunked, ...
                hobject_t redirect_target;
                std::map<uint64_t, chunk_info_t> chunk_map;
        }

15.7.1 type 枚举:对象可能的三种状态

值 含义
TYPE_NONE 普通 RADOS 对象
TYPE_REDIRECT 对象 payload 由 redirect_target 指定的单个对象承载
TYPE_CHUNKED 对象 payload 分布在若干对象中,其 size 与 offset 由 chunk_map 指定

15.7.2 chunk_info_t

chunk_map 把 chunk 的 offset 映射到一个 chunk_info_t,同时指定 length、目标 OID 与 flags。

        struct chunk_info_t {
          typedef enum {
            FLAG_DIRTY = 1, 
            FLAG_MISSING = 2,
            FLAG_HAS_REFERENCE = 4,
            FLAG_HAS_FINGERPRINT = 8,
          } cflag_t;
          uint32_t offset;
          uint32_t length;
          hobject_t oid;
          cflag_t flags;   // FLAG_*


字段表:

字段 类型 含义
offset uint32_t chunk 在对象中的偏移
length uint32_t chunk 长度
oid hobject_t 目标对象
flags cflag_t 见下

flags 取值:

flag 值 含义
FLAG_DIRTY 1 目前会在「一个带有指纹的 extent 被写」时发生。这应该被改为丢弃指纹
FLAG_MISSING 2 该 extent 不在 base pool 中(已 evict)
FLAG_HAS_REFERENCE 4 已持有目标对象的引用
FLAG_HAS_FINGERPRINT 8 已记录指纹

15.8 Request Handling(请求处理)

与 cache/tiering 类似,初始接触点是 maybe_handle_manifest_detail。

场景 行为
下面列出的 manifest 操作 我们返回 NOOP 并继续走到 do_osd_ops 中的专门处理
尚未被 promote 的 redirect 对象(看起来 oi.size > 0 表示它存在?) 我们代理(proxy)读与写
TYPE_CHUNKED 上的读 如果 can_proxy_chunked_read(基本上指:所有 op 都是对 object_manifest_t chunk_map 中 extent 的读),我们把请求代理到那些对象

15.9 RADOS Interface(RADOS 接口)

15.9.1 前置准备

要搭建去重,必须配置两个池:

  • 一个作为 base pool
  • 另一个作为 chunk pool

base pool 需要配置 fingerprint_algorithm 选项:

ceph osd pool set $BASE_POOL fingerprint_algorithm sha1|sha256|sha512 
--yes-i-really-mean-it

创建对象:

rados -p base_pool put foo ./foo
rados -p chunk_pool put foo-chunk ./foo-chunk

创建一个 manifest 对象:

rados -p base_pool set-chunk foo $START_OFFSET $END_OFFSET --target-pool chunk_pool foo-chunk $START_OFFSET --with-reference

15.9.2 操作一览

A. set-redirect

在 base_pool 中的一个 base_object 与 target_pool 中的一个 target_object 之间建立重定向。一个被重定向的对象会把来自客户端的所有操作转发到 target_object。

        void set_redirect(const std::string& tgt_obj, const IoCtx& tgt_ioctx,
		      uint64_t tgt_version, int flag = 0);
        rados -p base_pool set-redirect <base_object> --target-pool <target_pool> 
         <target_object>
行为 说明
返回 ENOENT 如果对象不存在(TODO: 为什么?)
返回 EINVAL 如果对象已经是一个 redirect
副作用 Truncates object, clears omap, and clears xattrs
引用 作为操作的一部分获取对 target 的引用;如果 acting set 重置、且客户端在获取引用与记录 redirect 之间死掉,可能泄漏一个引用
位置 在 do_osd_ops 的顶部,不设置 user_modify
clone 这不是一次用户变更,不会触发创建 clone

set_redirect 的两个用途:

  1. 把所有操作重定向到 target 对象(像 proxy 一样)
  2. 当 tier_promote 被调用时用作 cache(此时 redirect 会被清除)

B. set-chunk

设置 source_object 中的 chunk-offset,在它与一个 target_object 之间建立链接。

        void set_chunk(uint64_t src_offset, uint64_t src_length, const IoCtx& tgt_ioctx,
                   std::string tgt_oid, uint64_t tgt_offset, int flag = 0);
        rados -p base_pool set-chunk <source_object> <offset> <length> --target-pool 
         <caspool> <target_object> <target-offset> 

返回值:

返回值 条件
ENOENT 对象不存在(TODO: 为什么?)
EINVAL 对象已经是一个 redirect
EINVAL 参数 buffer 格式错误(ill-formed parameter buffer)
ENOTSUPP 现有已映射的 chunk 与新 chunk 映射重叠
行为 说明
副作用 Truncates object, clears omap, and clears xattrs
引用 作为操作的一部分获取对 target 的引用,如果 acting set 重置、且客户端在获取引用与记录 redirect 之间死掉,可能泄漏引用
clone 这不是一次用户变更,不会触发创建 clone

TODO(官方原文):SET_CHUNK 看起来在它不是 chunked 的情况下会无条件清除 manifest:

       if (!oi.manifest.is_chunked()) {
         oi.manifest.clear();
       }

C. evict-chunk

从一个对象中清除一个 extent,只留下它与 target_object 之间的 manifest 链接。

        void evict_chunk(
	  uint64_t offset, uint64_t length, int flag = 0);
        rados -p base_pool evict-chunk <offset> <length> <object>
行为 说明
返回 EINVAL 如果该 extent 不在 manifest 中
状态 ⚠️ 官方明确:Note: this does not exist yet.(这个操作目前还不存在)

D. tier-promote

Promote 该对象,确保后续的读与写都是本地的。

        void tier_promote();
        rados -p base_pool tier-promote <obj-name> 
行为 说明
返回 ENOENT 如果对象不存在
对 redirect manifest 把数据拷贝到 head。TODO: Promote 一个 redirect 对象需要清除该 redirect
对 chunked manifest 把所有 MISSING extent 读入 base pool,后续读写都由 base pool 服务
实现说明 对于 chunked manifest,会对自己调用 start_copy。产生的 copy_get 操作会发出读,这些读随后会被正常的 manifest 读机制重定向
user_modify 不设置 user_modify 标志
未来工作 将涉及增加对指定 clone_id 的支持

E. unset-manifest

清除带有 manifest 的对象上的 manifest 信息。

        void unset_manifest();
        rados -p base_pool unset-manifest <obj-name>
行为 说明
作用 清除 manifest chunks 或 redirect。惰性释放引用,可能泄漏
clone do_osd_ops 似乎没有把它包含在 user_modify=false 的 ignorelist 中,因此会触发一次快照。⚠️ 注意,即使对 redirect 也是如此,尽管 SET_REDIRECT 并不会翻转 user_modify。这应该被修复 —— unset-manifest 不应该是一个 user_modify

F. tier-flush

把带有 chunk 的对象 flush 到 chunk pool。

        void tier_flush();
        rados -p base_pool tier-flush <obj-name>
行为 说明
clone 被包含在 user_modify=false 的 ignorelist 中,不会触发 clone
evict 不会 evict 这些 extent

15.9.3 操作汇总表

操作 设置 user_modify 触发 clone 引用行为 存在状态
set-redirect 否 否 获取对 target 引用,可能泄漏 存在
set-chunk 否 否 获取对 target 引用,可能泄漏 存在
evict-chunk — — — 还不存在
tier-promote 否 — — 存在
unset-manifest 是(bug) 会触发(应修复) 惰性释放,可能泄漏 存在
tier-flush 否 否 — 存在

15.10 ceph-dedup-tool

ceph-dedup-tool 有两个功能:寻找去重分块的最优 chunk offset,以及修正引用计数(参见 ./refcount.rst,本册第 11 章)。

15.10.1 Find an optimal chunk offset

a. Fixed chunk(固定分块)

要找出一个固定的 chunk 长度,你需要在改变 chunk_size 的同时多次运行以下命令:

ceph-dedup-tool --op estimate --pool $POOL --chunk-size chunk_size  
  --chunk-algorithm fixed --fingerprint-algorithm sha1|sha256|sha512

b. Rabin chunk(Rabin-Karp 算法)

  • Rabin-Karp 是一种基于滚动哈希(rolling hash)的字符串搜索算法
  • 但仅有滚动哈希不足以做去重,因为我们不知道 chunk 边界
  • 因此我们需要使用滚动哈希做基于内容的切分(content-based slicing),即 content-defined chunking
  • 当前实现使用最简单的方法:通过检查滚动哈希的模式(例如低 N 位全为零)来寻找 chunk 边界
ceph-dedup-tool --op estimate --pool $POOL --min-chunk min_size  
  --chunk-algorithm rabin --fingerprint-algorithm rabin

rabin chunk 的可用选项:

              --mod-prime <uint64_t>
              --rabin-prime <uint64_t>
              --pow <uint64_t>
              --chunk-mask-bit <uint32_t>
              --window-size <uint32_t>
              --min-chunk <uint32_t>
              --max-chunk <uint64_t>

用户需要参考以下等式来使用上述 rabin chunk 选项:

              rabin_hash = 
                (rabin_hash * rabin_prime + new_byte - old_byte * pow) % (mod_prime)

c. Fixed chunk vs content-defined chunk

官方给出的对比例子:

  Data chunk A : abcdefgabcdefgabcdefg

  考虑 A 的去重。理想 chunk 偏移是从 1 到 7(abcdefg)。
  所以如果用 fixed chunk,7 就是最优 chunk 长度。
  但在 content-based slicing 的情况下,找不到最优 chunk 长度
  (去重率不会是 100%)。因为我们需要找到最优参数,
  例如 boundary bit、window size 和 prime value。这不像 fixed chunk 那么容易。

  但 content defined chunking 在下列情况下非常有效:

    Data chunk B : abcdefgabcdefgabcdefg
    Data chunk C : Tabcdefgabcdefgabcdefg

💡 补充:官方这段的要点是——content-defined chunking 在"数据被插入/位移"的场景下(B 与 C 只差一个前导 T)仍能保持高去重率,而 fixed chunk 会因整体位移而全部失配;代价是参数调优更难、且对完美重复数据不一定能达到 100% 去重率。

15.10.2 Fix reference count

去重引用计数的核心思路是 false-positive(假阳性),意思是: manifest object (no ref), chunk object (has ref) 这种情况会发生,而不是 manifest object (has ref), chunk (no ref)。

为了修正这类不一致,ceph-dedup-tool 支持 chunk_scrub:

ceph-dedup-tool --op chunk_scrub --chunk_pool $CHUNK_POOL

15.11 注意事项

  • evict-chunk 官方明确标注「还不存在」,不要在生产流程中依赖它。
  • 「连续 clone 共享同一 refcount」的约定意味着 make_writeable 只会减少、不会增加 refcount,这是避免写路径阻塞的关键设计。
  • 引用泄漏是本机制的已知且多处存在的风险(set-redirect、set-chunk、unset-manifest 都可能泄漏),需要靠周期性 scrub 兜底。
  • unset-manifest 触发 clone 是官方明确列出的 bug。

引自:Manifest


第 16 章 Map 与 PG 消息处理(map_message_handling.rst)

16.1 Overview

OSD 负责把收到的消息路由到 PG,并在某些情况下在必要时创建该 PG。

PG 消息一般有两种:

  1. Peering Messages
  2. Ops/SubOps

16.1.1 三条顺序性要求

有多种方式会导致消息被丢弃或延迟。重要的是:消息延迟不得违反在到达相关 PG 处理逻辑之前的下列消息顺序要求:

# 要求
1 引用同一对象的 Ops 不得被重排
2 Peering 消息不得被重排
3 Subops 不得被重排

16.2 MOSDMap

MOSDMap 消息可能来自 monitor,也可能来自其他 OSD。收到后,OSD 必须执行若干任务:

  1. 把新 map 持久化到 filestore
       └─ 若干 PG 操作依赖于能够访问「追溯到该 PG 上一次 clean 时刻」的 map
  2. 更新并持久化 superblock
  3. 更新与当前 map 相关的 OSD 状态
  4. 通过 OSDService 把新 map 暴露给 PG 进程
  5. 因 pool 被移除而移除 PG
  6. 排入 dummy events 以触发 PG map catchup

16.2.1 异步追赶带来的后果

每个 PG 在处理事件之前,会在 process_peering_events 期间异步地追赶当前已发布的 map。 结果是:不同的 PG 可能对"当前" map 有不同的看法。

这一设计的一个后果:

包含来自多个 PG 的子消息的消息(MOSDPGInfo、MOSDPGQuery、MOSDPGNotify)必须为每个子消息打上该 PG 的 epoch 标签,同时为整条消息打上该 OSD 当前已发布 epoch 的标签。

16.3 MOSDPGOp / MOSDPGSubOp

参见 OSD::dispatch_op、OSD::handle_op、OSD::handle_sub_op

  • MOSDPGOp 由客户端用于发起 rados 操作
  • MOSDSubOp 在 OSD 之间使用,用于协调大多数非 peering 活动,包括复制 MOSDPGOp 操作

16.3.1 require_same_or_newer_map

OSD::require_same_or_newer map 检查当前 OSDMap 至少与消息上标明的 map epoch 一样新。

  • 如果不是,该消息会通过 OSD::wait_for_new_map 被排入 OSD::waiting_for_osdmap

为什么这不违反上述顺序条件:

  • 任何两条消息都会按接收顺序被排队
  • 而且,如果一条消息在 epoch e0 被收到,来自同一源的更晚的消息必然处于至少为 e0 的 epoch

⚠️ 注意:来自同一个 OSD 的两个不同 PG,在单 PG 消息的意义上被视为不同的源。也就是说,来自不同 PG 的消息可能被重排。

16.3.2 处理流程

  MOSDPGOp:
     1. OSD::handle_op:校验权限与 crush 映射
          ├─ 如果它们未连接且客户端无法收到回复,则丢弃该请求
          │    (参见 OSD::op_is_discardable)
          ├─ 参见 OSDService::handle_misdirected_op
          ├─ 参见 PG::op_has_sufficient_caps
          └─ 参见 OSD::require_same_or_newer_map
     2. OSD::enqueue_op

  MOSDSubOp:
     1. OSD::handle_sub_op 检查发送方是一个 OSD
     2. OSD::enqueue_op
  • OSD::enqueue_op 调用 PG::queue_op,后者在调用 OpWQ::queue 之前先检查 waiting_for_map;OpWQ::queue 把该 op 加入「负责处理它的那个 PG」的队列。
  • 随后 OSD::dequeue_op 最终被调用,持有 PG 上的锁。此时该 op 被交给 PG::do_request,后者检查:
# 检查项 函数/字段
1 PG map 是否足够新 PG::must_delay_op
2 请求该 op 的客户端是否有足够权限 PG::op_has_sufficient_caps
3 该 op 是否应被丢弃 PG::can_discard_{request,op,subop,scan,backfill}
4 PG 是否 active PG::flushed 布尔值
5 该 op 是否是 CEPH_MSG_OSD_OP,且 PG 处于 PG_STATE_ACTIVE 状态且不在 PG_STATE_REPLAY —

如果这些条件不满足:该 op 要么被丢弃,要么被排队留待后续处理。

如果所有条件都满足:该 op 按其类型被处理:

# 消息类型 处理函数
1 CEPH_MSG_OSD_OP PG::do_op
2 MSG_OSD_SUBOP PG::do_sub_op
3 MSG_OSD_SUBOPREPLY PG::do_sub_op_reply
4 MSG_OSD_PG_SCAN PG::do_scan
5 MSG_OSD_PG_BACKFILL PG::do_backfill
  MOSDPGOp / MOSDSubOp
        │
        v
  OSD::handle_op / OSD::handle_sub_op
        │
        v
  OSD::enqueue_op ──> PG::queue_op ──(check waiting_for_map)──> OpWQ::queue
        │                                                            │
        │                                                            v
        └──────────────────────<─────────────────────────────  OSD::dequeue_op
                                                                     │  (持有 PG 锁)
                                                                     v
                                                              PG::do_request
                                                                     │
                              5 项检查全部通过?──── NO ──> 丢弃 / 稍后排队
                                                                     │ YES
                                                                     v
                                              按类型分派到 do_op / do_sub_op / ...

16.4 CEPH_MSG_OSD_OP 处理

PrimaryLogPG::do_op 处理 CEPH_MSG_OSD_OP op,并会在下列情况下把它排入相应的等待队列:

# 排入的队列 条件
1 wait_for_all_missing 它是一个针对指定 snapid 的 CEPH_OSD_OP_PGLS,且某些对象更新仍然缺失
2 waiting_for_active 该 op 可能写,但 scrubber 正在工作
3 waiting_for_missing_object 该 op 需要一个仍然缺失的对象、snapdir 或特定 snap
4 waiting_for_degraded_object 该 op 可能写一个 degraded 的对象或 snapdir,或另一个对象阻塞了它("blocked_by")
5 waiting_for_backfill_pos 该 op 需要一个「backfill 完成后才可用」的对象
6 waiting_for_ack 正在等待来自另一个 OSD 的 ack
7 waiting_for_ondisk 该 op 正在等待一次写完成

16.5 Peering Messages

参见 OSD::handle_pg_(notify|info|log|query)

Peering 消息被打上两个 epoch 标签:

epoch 含义
epoch_sent 该消息被发送时所处的 map epoch
query_epoch 触发该消息的那条消息被发送时所处的 map epoch

在没有触发消息的情况下,这两者相同。

丢弃规则:如果所讨论的 PG 已进入一个新的 epoch,我们就丢弃该 peering 消息(如果消息的 query_epoch 过期)。(参见 PG::old_peering_evt、PG::queue_peering_event。)

事件包装:Notify、info、log 都被当作 PG::PeeringMachine 事件处理,并由 PG::queue_* 用 PG::CephPeeringEvts 包装,其中包含:

  • 被创建的状态机事件
  • epoch_sent
  • query_epoch

以便在入队与出队时通用地检查 PG::old_peering_message。

注意:notify、log、info 可以触发 PG 的创建。参见 OSD::get_or_create_pg。

16.6 注意事项

  • 「来自同一 OSD 的不同 PG 的消息可以重排」这一条,是理解 OSD 消息并发的边界:顺序保证只在单 PG 粒度上成立。
  • PG::flushed(第 2 章)在这里成为 PG::do_request 的第 4 项检查 —— 两个章节在此交汇。
  • 每个 PG 对"当前 map"的看法可能不同,因此跨 PG 的批量消息必须携带 per-PG epoch 标签。

引自:Map and PG Message handling


第 17 章 PG Removal(pg_removal.rst)

17.1 两种移除途径

参见 OSD::_remove_pg、OSD::RemoveWQ

一个 PG 从某个 OSD 上被移除,有两种途径:

# 途径
1 来自 primary 的 MOSDPGRemove
2 OSD::advance_map 发现该 pool 已被移除

17.2 总体策略

无论哪种情况,移除 PG 的总体策略是:

原子地把元数据对象(pg->log_oid、pg->biginfo_oid)设置为 backfill,然后异步地移除该 PG 的 collections。

为什么不内联(inline)做:因为扫描 collections 以移除对象是一项昂贵的操作。

  步骤 1(原子、同步):把 pg->log_oid、pg->biginfo_oid 设为 backfill
  步骤 2(异步)      :移除 pg collections(昂贵,不内联做)

17.3 关键数据结构:OSDService::deleting_pgs

OSDService::deleting_pgs 跟踪所有正在被删除的 PG。

  • deleting_pgs 中的每个 DeletingState 对象在至少还有一个引用指向它时存活
  • RemoveWQ 中的每一项都携带一个指向「相关 PG 的 DeletingState」的引用

推论:

这样,deleting_pgs.lookup(pgid) 只有在「该 PG 当前没有任何 collection 正在被删除」时才会返回 null ref。

  RemoveWQ item ──持有──> DeletingState (for pgid)
                              ^
                              │
                 OSDService::deleting_pgs 跟踪

  deleting_pgs.lookup(pgid) == null ref
        ⇔ 该 PG 当前没有任何正在被删除的 collection

17.4 DeletingState 的 5 种状态

某个 PG 的 DeletingState 还携带当前删除状态的信息,并允许该删除被取消。可能的状态:

# 状态 含义
1 QUEUED PG 在 RemoveWQ 中
2 CLEARING_DIR PG 的内容正在被同步地移除
3 DELETING_DIR PG 的目录与元数据正被排队等待移除
4 DELETED_DIR 最终的移除事务已被排队
5 CANCELED 该删除已被取消
  QUEUED ──> CLEARING_DIR ──> DELETING_DIR ──> DELETED_DIR
    │             │
    └─────────────┴──── 可被取消 ──> CANCELED

17.4.1 取消语义

  • 在状态 1 和 2 中,删除可以被取消
  • 每个状态转换方法(以及 check_canceled)在删除已被取消时返回 false,在状态转换成功时返回 true
  • 类似地,try_stop_deletion() 如果成功取消删除则返回 true
  • 额外保证:try_stop_deletion() 在未能停止删除的情况下,会一直等到「最终的移除事务被排队」之后才返回。

为什么这样:这确保了在那之后排队的任何操作,都会被排在 PG 删除之后。

17.5 OSD::_create_lock_pg 必须处理的两种情况

# 情况 处理
1 要么该 PG 没有 DeletingStateRef,要么取消失败 我们按没有正在发生删除那样继续,但避免在该删除完成之前写该 PG
2 我们成功取消了删除 按情况 1 处理,但首先把该 PG 标记为 backfilling

17.6 OSD::osr_registry

类似地,OSD::osr_registry 确保:如果新 PG 在旧 PG 被完全移除之前就被创建,那些 PG 的 OpSequencers 可以被新 PG 复用,从而确保新 PG 上的操作相对于旧 PG 上的操作被正确排序。

  旧 PG 正在被删除 ──┐
                     ├── 同一 pgid 的新 PG 被创建
  新 PG 被创建 ──────┘
        │
        └─ osr_registry 复用 OpSequencer
              ⇒ 新 PG 的 op 排在旧 PG 的 op 之后

17.7 注意事项

  • 「先把元数据对象设为 backfill」这一步是原子且同步的,它保证即使进程在中途崩溃,重启后也能知道这个 PG 处于删除中。
  • try_stop_deletion() 的「失败时不返回直到最终事务入队」语义是顺序保证的关键,不要为了"快"而绕过它。
  • osr_registry 复用 OpSequencer 是防止「同名新旧 PG 的 op 交错」的核心机制。

引自:PG Removal


第 18 章 mClock 与 WPQ 调度器 QoS 对比研究(mclock_wpq_cmp_study.rst)

⚠️ 重要前提:本章是官方收录的一份对比研究(comparison study)报告,其测试环境明确标注为 CephVer: 17.0.0-2125-g94f550a87f quincy (dev) —— 即 Quincy 开发版,不是 Tentacle。因此其中的绝对数值不代表 Tentacle v20.2.4 的实际表现。本册如实呈现,供理解 mClock 的设计动机与 profile 语义之用。

18.1 Introduction

18.1.1 mClock 的三个控制参数

mClock 调度器为每个使用它的服务提供三个控制项。在 Ceph 中,使用 mClock 的服务例如:client I/O、background recovery、scrub、snap trim、PG deletes。

控制项 作用
reservation 服务至少获得这么多
weight 按权重成比例分配资源
limit 服务至多获得这么多

约束:每个服务按 weight 成比例地获得可预测的资源分配,约束条件是「至少拿到 reservation,且不超过 limit」。

在 Ceph 中,这些控制项用于在已知每个 OSD 的 IOPS 容量的前提下,为每种服务类型分配 IOPS。

mClock 调度器基于 dmClock 算法。

18.1.2 早期 mClock 在 Ceph 中的问题

官方明确声明:

Ceph 对 mClock 的使用主要是实验性的,并且是以探索性的心态进行的。 对于其他继续使用该代码库或根据自身需求修改它的组织和个人,这一点仍然成立。

  • DmClock 存在于它自己的 repository 中
  • 在 Ceph Pacific 发布之前,可以通过把 Ceph 选项 osd_op_queue 设为 "mclock_scheduler" 来启用 mClock
  • 每种服务类型的 reservation、weight 和 limit 等额外 mClock 参数可以用 Ceph 选项设置,例如 osd_mclock_scheduler_client_[res,wgt,lim]

即使设置了所有 mClock 选项,mClock 的完整能力仍无法发挥,原因是:

# 问题
1 OSD 在吞吐量(IOPS)方面的容量未知
2 没有 limit 强制 enforcement。换句话说,使用 mClock 的服务被允许超过其 limit,导致期望的 QoS 目标无法达成
3 每种服务类型的份额没有按运行中的 shard 数量分配

解决:对 Ceph 代码库中的 mClock 调度器做了改进(参见 /rados/configuration/mclock-config-ref)。有这些改进后,mClock 的使用更加用户友好和直观。这只是「改进和优化 Ceph 中 mClock 使用方式」的众多步骤之一。

18.2 Overview(研究概览)

作为改进 mClock 调度器工作的一部分,进行了一项对比研究。该研究并行运行客户端 op 与后台恢复操作,使用两种调度器,收集并比较结果。

比较的统计量:

服务 统计量
External client 平均吞吐量(IOPS);平均及百分位(95th、99th、99.5th)延迟
Background recovery 平均恢复吞吐量;每秒恢复的 misplaced 对象数

18.3 Test Environment(测试环境)

项目 配置
Software Configuration CentOS 8.1.1911 Linux Kernel 4.18.0-193.6.3.el8_2.x86_64
CPU 2 x Intel® Xeon® CPU E5-2650 v3 @ 2.30GHz
nproc 40
System Memory 64 GiB
Tuned-adm Profile network-latency
CephVer 17.0.0-2125-g94f550a87f (94f550a87fcbda799afe9f85e40386e6d90b232e) quincy (dev)
Storage Intel® NVMe SSD DC P3700 Series (SSDPE2MD800G4) [4 x 800GB];Seagate Constellation 7200 RPM 64MB Cache SATA 6.0Gb/s HDD (ST91000640NS) [4 x 1TB]

18.4 Test Methodology(测试方法)

  • 使用 Ceph cbt 测试恢复场景
  • 创建了一个新的恢复测试,用于在客户端 I/O 并行的情况下产生后台恢复
  • 该测试先以默认的 Weighted Priority Queue (WPQ) 调度器执行 3 次用于对比 —— 目的是建立一个可信的均值,以便稍后与 mClock 调度器的结果比较
  • 之后,用 mClock 调度器、并采用不同的 mClock profile(high_client_ops、balanced、high_recovery_ops)执行同样的测试
  • 每个 profile 执行 3 次,本研报中报告的是这些运行的平均值

note(官方原文):HDD 测试是在配置与不配置 bluestore WAL 和 dB 两种情况下进行的。

18.5 建立基线客户端吞吐量(IOPS)

在实际恢复测试之前,先按 /rados/configuration/mclock-config-ref 文档中 "Benchmarking Test Steps Using CBT" 一节的步骤,为测试机上的 SSD 与 HDD 建立基线吞吐量。本研究为每种设备类型确定的基线吞吐量如下:

Device Type Baseline Throughput (@4KiB Random Writes)
NVMe SSD 21500 IOPS (84 MiB/s)
HDD (with bluestore WAL & dB) 340 IOPS (1.33 MiB/s)
HDD (without bluestore WAL & dB) 315 IOPS (1.23 MiB/s)

note(官方原文):SSDs 的 bluestore_throttle_bytes 与 bluestore_throttle_deferred_bytes 被确定为 256 KiB;HDDs 为 40MiB。上述吞吐量是通过以队列深度 64 跑 4 KiB 随机写 300 秒获得的。

18.6 MClock Profile Allocations(profile 分配)

下表给出每个 profile 的底层 mClock 份额:

  • 对于 reservation 与 limit 这类参数,份额表示为占总 OSD 容量的百分比
  • weight 参数是无单位的

例如,对于 high_client_ops profile,reservation 参数被设为总 OSD 容量的 50%。因此,对 NVMe(基线 21500 IOPS)设备,为客户端操作保留了最少 10750 IOPS。这些分配在启用某个 profile 后在底层自动完成。

18.6.1 high_client_ops(默认)

该 profile 为外部客户端 op 分配比后台恢复及 Ceph 内其他内部客户端更多的 reservation 和 limit。该 profile 默认启用。

Service Type Reservation Weight Limit
client 50% 2 MAX
background recovery 25% 1 100%
background best effort 25% 2 MAX

18.6.2 balanced

该 profile 为客户端 op 与后台恢复 op 分配相等的 reservation。内部 best effort 客户端获得较低的 reservation,但有非常高的 limit,以便在没有竞争服务时能够快速完成。

Service Type Reservation Weight Limit
client 40% 1 100%
background recovery 40% 1 150%
background best effort 20% 2 MAX

18.6.3 high_recovery_ops

该 profile 为后台恢复分配比外部客户端及 Ceph 内其他内部客户端更多的 reservation。例如,管理员可以在非高峰时段临时启用该 profile 以加速后台恢复。

Service Type Reservation Weight Limit
client 30% 1 80%
background recovery 60% 2 200%
background best effort 1 (MIN) 2 MAX

18.6.4 custom

该 profile 允许用户完全控制 mClock 与 Ceph 配置参数。要使用该 profile,用户必须深入理解 Ceph 与 mClock 调度器的运作方式。所有服务类型的 reservation、weight 和 limit 参数都必须手动设置,以及任何 Ceph 选项。该 profile 可用于实验和探索目的,或当内置 profile 无法满足需求时使用。在这种情况下,启用该 profile 前必须执行充分的测试。

18.6.5 三 profile 对比一览

Service Type high_client_ops (默认) balanced high_recovery_ops
client 50% / w2 / MAX 40% / w1 / 100% 30% / w1 / 80%
background recovery 25% / w1 / 100% 40% / w1 / 150% 60% / w2 / 200%
background best effort 25% / w2 / MAX 20% / w2 / MAX 1(MIN) / w2 / MAX

格式:Reservation / Weight / Limit

18.7 Recovery Test Steps(恢复测试步骤)

在拉起 Ceph 集群之前,基于上一节获得的基线吞吐量,相应设置了以下 mClock 配置参数:

  • osd_mclock_max_capacity_iops_hdd
  • osd_mclock_max_capacity_iops_ssd
  • osd_mclock_profile

18.7.1 测试步骤(Using cbt)

  1. 拉起一个带 4 个 osd 的 Ceph 集群
  2. 把 OSD 配置为副本因子 3
  3. 创建一个 recovery pool 用于填充恢复数据
  4. 创建一个 client pool 并在其中预填一些对象
  5. 创建恢复线程,并把一个 OSD 标记 down 和 out
  6. 在集群处理完该 OSD down 事件后,向 recovery pool 预填恢复数据
       ├─ SSD 测试:预填 100K 个 4MiB 对象
       └─ HDD 测试:预填 5K 个 4MiB 对象
  7. 预填阶段完成后,把被 down 掉的 OSD 重新 up 并 in。此时 backfill 阶段开始
  8. backfill/recovery 一开始,测试就在另一个线程上用单个客户端在 client pool 上发起客户端 I/O
  9. 在上述步骤 8 期间,cbt 捕获与客户端延迟和带宽相关的统计数据。
     测试同时捕获 misplaced 对象总数,以及每秒恢复的 misplaced 对象数

总结:上述步骤在测试期间创建 2 个池。恢复在一个池上被触发,客户端 I/O 同时在另一个池上被触发。

18.7.2 非默认的 Ceph 恢复选项

除了上面提到的非默认 bluestore throttle 之外,以下 Ceph 恢复相关选项在 WPQ 与 mClock 两种调度器的测试中都被修改了:

选项 值
osd_max_backfills 1000
osd_recovery_max_active 1000
osd_async_recovery_min_cost 1

上述选项为「每个 OSD 上并发的 local 与 remote backfill 操作数量」设置了很高的上限。在这些条件下测试了 mClock 调度器的能力。

18.8 Test Results(测试结果)

⚠️ 官方文档中的结果以图片(charts)形式给出,本册无法获取图片内容,因此只转述官方在正文中写明的数值与结论,不对图中未述内容做任何推测。

18.8.1 NVMe SSD 测试结果

Client Throughput Comparison(客户端吞吐量对比)

配置 平均客户端吞吐量 说明
WPQ(def) just over 18000 IOPS 基线为 21500 IOPS。默认 osd_max_backfills 把每个 OSD 上并发的 local/remote backfill 或 recovery 限制为 1,因此表现不错
WPQ(BST) 2544 IOPS 使用非默认恢复选项后急剧下降。恢复操作压倒了客户端操作
mClock high_client_ops 11209 IOPS reservation 目标 50%(10750 IOPS)被满足。是 WPQ(BST) 的 4 倍多
mClock balanced 11017 IOPS —
mClock high_recovery_ops 11153 IOPS —

官方结论:这清楚地表明,mClock 能够在有大量并发 backfill/recovery 正在进行的情况下,为客户端提供期望的 QoS。

Client Latency Comparison(客户端延迟对比)

配置 平均 clat 延迟 备注
WPQ(Def) 3.535 msec 并发恢复数非常有限,平均约 97 objects/sec 或 ~388 MiB/s
WPQ(BST) 接近 25 msec 7 倍更差。并发恢复约 ~350 objects/sec 或 ~1.4 GiB/s,接近最大 OSD 带宽
mClock high_client_ops 5.688 msec mClock 把恢复速率节流到平均 80 objects/sec 或 ~320 MiB/s(符合 profile 最低分配 25%),从而使客户端操作达成 QoS 目标
mClock balanced / high_recovery_ops 5.7 - 5.8 msec 平均延迟变化不大,百分位延迟有变化

官方对「平均 clat 延迟随测试时长变化的对比图」的描述:在测试初始阶段约 150 秒内,WPQ 调度器与 mClock 各 profile 之间的平均延迟差异非常明显且不言自明。high_client_ops 显示最低延迟,其次是 balanced 和 high_recovery_ops。WPQ(BST) 在整个测试过程中平均延迟最高。

Recovery Statistics Comparison(恢复统计对比)

在所有情况下,待恢复的对象总数约为 75000 个。

Profile 恢复耗时 恢复速率 恢复带宽
high_client_ops 966 secs 80 Objects/sec ~320 MiB/s(最低)
balanced ~647 secs ~120 Objects/sec ~480 MiB/s
high_recovery_ops ~488 secs(最快) ~159 objects/sec ~635 MiB/s(近 2 倍于 high_client_ops)

官方描述:balanced profile 提供了一个不错的中间地带;high_recovery_ops 以牺牲其他操作为代价,提供了最快的完成恢复的方式。

18.8.2 HDD(配置了 WAL 和 dB)测试结果

基线吞吐量为 340 IOPS。

Client Throughput & latency Comparison

配置 平均客户端吞吐量 平均 clat 延迟
WPQ(Def) ~308 IOPS ~208 msec
WPQ(BST) 146 IOPS 433 msec
mClock high_client_ops 271 IOPS(约为baseline 的 80%) 235 msecs
mClock balanced ~248 IOPS ~258 msec
mClock high_recovery_ops ~240 IOPS ~265 msec

官方描述:clat 延迟对比图显示,与 NVMe SSD 情形一样,high_client_ops 在 HDD 情形中也显示最低延迟,其次是 balanced 和 high_recovery_ops。在测试的前 200 秒内,很容易分辨出这些 profile 之间的差异。

Recovery Statistics Comparison

使用带 WAL 和 dB 的 HDD,所有情况下待恢复对象总数约为 4000 个。

Profile 恢复耗时 恢复速率 恢复带宽
high_client_ops ~1409 secs ~3 Objects/sec ~11 MiB/s(最低)
balanced ~966 secs ~4 Objects/sec ~16.5 MiB/s
high_recovery_ops ~747 secs(最快) ~5 objects/sec ~21 MiB/s(近 2 倍)

对比参考:WPQ(Def) 的恢复时间为 647 secs,带宽 23 MiB/s,速率 5.8 objects/sec。

18.8.3 HDD(未配置 WAL 和 dB)测试结果

基线吞吐量为 315 IOPS。

官方原文:这种未配置 WAL 和 dB 的配置可能很少见,但仍然进行了测试,以了解 mClock 在「OSD 容量处于低端」这种非常受限的环境下的表现。下面各节与图表与上面给出的非常相似,在此提供仅供参考。

⚠️ 官方在正文中没有为这一组测试给出任何具体数值,只有图片。因此本册不列出数值(属于文档未明确项)。

18.9 Key Takeaways and Conclusion(关键结论)

官方给出的两条结论:

# 结论
1 mClock 能够使用 profile 为各服务类型分配恰当的 reservation、weight 和 limit,从而提供期望的 QoS
2 通过使用 cost per I/O 与 cost per byte 参数,mClock 可以为不同设备类型(SSD/HDD)适当地调度操作

官方展望:到目前为止的研究显示,对 mClock 调度器所做的改进取得了可喜的结果。计划对 mClock 与 profile 调优做进一步改进。进一步的改进也将基于在更大集群上、以不同工作负载进行更广泛测试的反馈。

18.10 注意事项

  • 本章数据来自 Quincy 开发版,不是 Tentacle。用于趋势判断,不用于容量规划。
  • 该研究刻意把 osd_max_backfills 等调到 1000,制造「恢复压垮客户端」的极端场景,以凸显 mClock 的 QoS 能力。正常生产配置下 WPQ 的表现会好得多。
  • 结论中「cost per I/O 与 cost per byte」是 mClock 能同时适配 SSD 与 HDD 的关键机制。

引自:QoS Study with mClock and WPQ Schedulers


第 19 章 纠删码(Erasure Coding)

本章覆盖 erasure_coding.rst 及其子目录下 5 个文件:developer_notes、jerasure、ecbackend、enhancements、proposals。

19.1 Glossary(术语表,erasure_coding.rst)

术语 定义
chunk 编码函数被调用时,返回大小相同的若干 chunk。有两种 chunk:(1) data chunks,可拼接出原始对象;(2) coding chunks,可用于重建一个丢失的 chunk
chunk rank 一个 chunk 的索引,由编码函数决定。第一个 chunk 的 rank 是 0,第二个是 1,依此类推
K 一个对象被分成的 data chunk 数量。例如 K = 2 时,一个 10KB 对象被分成两个 5KB 的对象
M 编码函数计算出的 coding chunk 数量。M 等于「集群中可以在不遭受数据丢失的情况下缺失的 OSD 数量」。例如有两个 coding chunk,则可以缺失两个 OSD 而不丢数据
N data chunk 数加 coding chunk 数,即 K + M
rate 包含有用信息的 chunk 占总 chunk 的比例,即 K / N。例如 K = 9、M = 3,则 N = 12,rate = 9/12 = 0.75,即 75% 的 chunk 包含有用信息
shard(也称 strip) 来自同一对象、相同 rank 的 chunk 的有序序列。对给定的 placement group,每个 OSD 包含相同 rank 的 shard。特例:当对象只用一次编码函数调用就编码完成时,可以用 chunk 一词代替 shard,因为该 shard 由单个 chunk 构成。shard 中的 chunk 按其所属 stripe 的 rank 排序
stripe 如果对象太大,编码它需要多于一次的编码函数调用,那么每次调用都会产生一组 chunk,这组 chunk 称为一个 stripe

19.1.1 官方图示(原样转录)

                 OSD 40                       OSD 33
       +-------------------------+ +-------------------------+
       |      shard 0 - PG 10    | |      shard 1 - PG 10    |
       |+------ object O -------+| |+------ object O -------+|
       ||+---------------------+|| ||+---------------------+||
 stripe|||    chunk  0         ||| |||    chunk  1         ||| ...
   0   |||    stripe 0         ||| |||    stripe 0         ||| 
       ||+---------------------+|| ||+---------------------+||
       ||+---------------------+|| ||+---------------------+||
 stripe|||    chunk  0         ||| |||    chunk  1         ||| ...
   1   |||    stripe 1         ||| |||    stripe 1         |||
       ||+---------------------+|| ||+---------------------+||
       ||+---------------------+|| ||+---------------------+||
 stripe|||    chunk  0         ||| |||    chunk  1         ||| ...
   2   |||    stripe 2         ||| |||    stripe 2         |||
       ||+---------------------+|| ||+---------------------+||
       |+-----------------------+| |+-----------------------+|
       |         ...             | |         ...             |
       +-------------------------+ +-------------------------+

19.1.2 官方目录结构

erasure_coding.rst 的 toctree 中只列出了 4 个子页面(注意:proposals.rst 不在 toctree 中,它是一个 :orphan: 页面):

# 标题 文件
1 Developer notes erasure_coding/developer_notes
2 Jerasure plugin erasure_coding/jerasure
3 High level design document erasure_coding/ecbackend
4 Erasure coding enhancements design document erasure_coding/enhancements

引自:Erasure Coded Placement Groups

19.2 Erasure Code developer notes(developer_notes.rst)

19.2.1 编码写:把对象拆成 K+M 放到 OSD

  • 一个纠删码池把每个对象存为 K+M 个 chunk,分为 K 个 data chunk 与 M 个 coding chunk
  • 该池被配置为 size = K+M,使得每个 chunk 都存在 acting set 中的一个 OSD 上
  • chunk 的 rank 作为该对象的一个 attribute 存储

官方例子:假设创建一个使用 5 个 OSD(K+M = 5)、能承受丢失 2 个(M = 2)的纠删码池。

当内容为 ABCDEFGHI 的对象 NYAN 被写入时:

  1. 纠删编码函数把内容简单地分成三份:第一份 ABC,第二份 DEF,最后一份 GHI
  2. 如果内容长度不是 K 的倍数,会被填充(padded)
  3. 该函数还创建两个 coding chunk:第四个 YXY,第五个 GQC
  4. 每个 chunk 都存在 acting set 中的一个 OSD 上
  5. 这些 chunk 存储在同名(NYAN)但位于不同 OSD 上的对象中
  6. chunk 被创建的顺序必须被保留,并作为该对象的一个 attribute(shard_t)连同其名字一起存储

例如:chunk 1 含 ABC,存在 OSD5 上;chunk 4 含 YXY,存在 OSD3 上。

                             +-------------------+
                        name |        NYAN       |
                             +-------------------+
                     content |      ABCDEFGHI    |
                             +--------+----------+
                                      |
                                      v
                               +------+------+
               +---------------+ encode(3,2) +-----------+
               |               +--+--+---+---+           |
               |                  |  |   |               |
               |          +-------+  |   +-----+         |
               |          |          |         |         |
            +--v---+   +--v---+   +--v---+  +--v---+  +--v---+
      name  | NYAN |   | NYAN |   | NYAN |  | NYAN |  | NYAN |
            +------+   +------+   +------+  +------+  +------+
     shard  |  1   |   |  2   |   |  3   |  |  4   |  |  5   |
            +------+   +------+   +------+  +------+  +------+
   content  | ABC  |   | DEF  |   | GHI  |  | YXY  |  | QGC  |
            +--+---+   +--+---+   +--+---+  +--+---+  +--+---+
               |          |          |         |         |
               |          |       +--+---+     |         |
               |          |       | OSD1 |     |         |
               |          |       +------+     |         |
               |          +------>| OSD2 |     |         |
               |                  +------+     |         |
               |                  +------+     |         |
               |                  | OSD3 |<----+         |
               |                  +------+               |
               |                  | OSD4 |<--------------+
               |                  +------+
               |                  +------+
               +----------------->| OSD5 |
                                  +------+

19.2.2 解码读:从 K 个 chunk 重建

当对象 NYAN 被读取时,解码函数读取三个 chunk:

  • chunk 1 含 ABC
  • chunk 3 含 GHI
  • chunk 4 含 YXY

并重建出对象 NYAN 的原始内容 ABCDEFGHI。

解码函数被告知 chunk 2 和 5 缺失(它们被称为 erasures)。chunk 5 无法被读到,因为 OSD4 是 out。

一个未实现的优化:

解码函数本可以在读到三个 chunk 时就立即被调用:OSD2 是最慢的,它的 chunk 不需要被纳入考虑。这个优化在 Firefly 中没有实现。

                             +-------------------+
                        name |        NYAN       |
                             +-------------------+
                     content |      ABCDEFGHI    |
                             +--------+----------+
                                      ^
                               +------+------+
                               | decode(3,2) |
                               | erasures 2,5|
               +-------------->|             |
               |               +-------------+
               |                     ^   ^
               |                     |   +-----+
            +--+---+   +------+   +--+---+  +--+---+
      name  | NYAN |   | NYAN |   | NYAN |  | NYAN |
            +------+   +------+   +------+  +------+
     shard  |  1   |   |  2   |   |  3   |  |  4   |
            +------+   +------+   +------+  +------+
   content  | ABC  |   | DEF  |   | GHI  |  | YXY  |
            +--+---+   +--+---+   +--+---+  +--+---+
               ^          .          ^         ^
               |    TOO   .          |         |
               |    SLOW  .       +--+---+     |
               |          ^       | OSD1 |     |
               |          |       +------+     |
               |          +-------| OSD2 |     |
               |                  +------+     |
               |                  | OSD3 |-----+
               |                  +------+
               |                  | OSD4 | OUT
               |                  +------+
               +------------------| OSD5 |
                                  +------+

19.2.3 Erasure code library(纠删码库)

Reed-Solomon 参数 K+M:

  • 对象 O 被编码时:把它分成 chunk O1, O2, ... OM,并计算 coding chunk P1, P2, ... PK
  • K+M 个可用 chunk 中的任意 K 个都可以用来获得原始对象
  • 如果 data chunk O2 或 coding chunk P2 丢失,可以用 K+M 中的任意 K 个来修复它们
  • 如果丢失的 chunk 超过 M 个,就不可能恢复该对象

systematic codes(系统码):

读取对象 O 的原始内容可以只是 O1, O2, ... OM 的简单拼接,因为各插件使用的是 systematic codes。否则这些 chunk 必须交给纠删码库的 decode 方法来获取对象内容。

性能影响因素:

  • 性能取决于传给编码函数的参数
  • 也受调用编码函数时使用的 packet 大小影响(例如 Cauchy 或 Liberation):更小的 packet 意味着更多次调用、更多开销

19.2.4 插件化抽象 API

尽管 Reed-Solomon 是默认提供的,Ceph 是通过一个抽象 API 来使用它的,该 API 旨在允许每个池通过存储在 erasure code profile 中的 key=value 对来选择实现它的插件。

官方给出的 profile 示例:

 $ ceph osd erasure-code-profile set myprofile \
     crush-failure-domain=osd
 $ ceph osd erasure-code-profile get myprofile
 directory=/usr/lib/ceph/erasure-code
 k=2
 m=1
 plugin=isa
 technique=reed_sol_van
 crush-failure-domain=osd
 $ ceph osd pool create ecpool erasure myprofile

插件加载机制:

  • plugin 是从 directory 动态加载的
  • 它被期望实现 int __erasure_code_init(char *plugin_name, char *directory) 函数
  • 该函数负责把一个派生自 ErasureCodePlugin 的对象注册到 registry 中

ErasureCodePluginExample 插件代码:

  ErasureCodePluginRegistry &instance = 
                             ErasureCodePluginRegistry::instance();
  instance.add(plugin_name, new ErasureCodePluginExample());

工厂方法:派生自 ErasureCodePlugin 的对象必须提供一个 factory 方法,从中可以生成 ErasureCodeInterface 对象的具体实现:

  virtual int factory(const map<std::string,std::string> &parameters,
                      ErasureCodeInterfaceRef *erasure_code) {
    *erasure_code = ErasureCodeInterfaceRef(new ErasureCodeExample(parameters));
    return 0;
  } 

parameters 参数就是在创建该池之前、在 erasure code profile 中设置的那组 key=value 对。

  ceph osd erasure-code-profile set myprofile \
     directory=<dir>         \ # mandatory
     plugin=isa              \ # mandatory
     m=10                    \ # optional and plugin dependent
     k=3                     \ # optional and plugin dependent
     technique=reed_sol_van  \ # optional and plugin dependent

19.2.5 Notes(注意事项)

事项 说明
大对象 如果对象很大,在内存中编解码可能不切实际。但使用 RBD 时,一个 1TB 设备被分成许多独立的 4MB 对象,RGW 也是如此
编解码的位置 编码和解码在 OSD 中实现。 尽管对于读写它本可以在客户端实现,但 OSD 必须能够在 scrub 时自行编解码

引自:Erasure Code developer notes

19.3 jerasure plugin(jerasure.rst)

19.3.1 参数

jerasure 插件解释的参数:

  ceph osd erasure-code-profile set myprofile \
     directory=<dir>         \ # plugin directory absolute path
     plugin=jerasure         \ # plugin name (only jerasure)
     k=<k>                   \ # data chunks (default 2)
     m=<m>                   \ # coding chunks (default 2)
     technique=<technique>   \ # coding technique
参数 含义 默认值
directory plugin 目录的绝对路径 —(必填)
plugin 插件名(只能是 jerasure) —(必填)
k data chunks 2
m coding chunks 2
technique 编码技术 —

19.3.2 可用的编码技术(coding techniques)

编码技术可从以下 7 种中选择:

  • reed_sol_van
  • reed_sol_r6_op
  • cauchy_orig
  • cauchy_good
  • liberation
  • blaum_roth
  • liber8tion

19.3.3 实现来源与上游关系

⚠️ 官方原文警示:注意,截至 2023 年,jerasure.org 网站可能已不再合法/或已与原项目无关。

💡 补充:这一警示提示读者在查阅 jerasure 上游资料时,应优先使用 github.com/ceph/jerasure 与 github.com/ceph/gf-complete 这两个 Ceph 官方 fork,而不是 jerasure.org。

引自:jerasure plugin

19.4 ECBackend Implementation Strategy(ecbackend.rst)

19.4.1 初始设计笔记:可回滚性约束

最初的(并且对于未启用那个 hacky 的 EC overwrites debug flag 的 EC 池来说至今仍然成立的)设计,把 EC 池限制在那些容易回滚的操作上:

操作 如何回滚
CEPH_OSD_OP_APPEND 可以通过把先前的对象大小作为 PG log event 的一部分来本地回滚一次 append
CEPH_OSD_OP_DELETE 回滚一次 delete 要求我们保留被删除的对象,直到所有副本都持久化了该删除事件。因此纠删码后端需要把对象连同其创建时所在的版本一起,放进提供给 filestore 的 key 中。当所有副本都 commit 到删除该对象的 log event 时,旧版本的对象就可以被裁剪
CEPH_OSD_OP_(SET|RM)ATTR 如果我们把要设置或移除的 attr 的先前值包含进来,就可以本地回滚这些操作

日志条目中包含一个结构,解释如何在本地撤销该操作所代表的操作(参见 osd_types.h:TransactionInfo::LocalRollBack)。

19.4.2 PGTemp and Crush

  • Primary 能够请求一个 temp acting set 映射,以便在一个新 primary 正在被 backfill 时(以及出于其他原因)让一个最新的 OSD 来服务请求
  • 一个纠删码 PG 需要能够为此类原因指定一个 primary,而不必把它放在 acting set 的第一个位置
  • 它还需要能够在请求的 acting set 中留下空洞(holes)

Core Changes:

# 改动
1 OSDMap::pg_to_*_osds 需要单独返回一个 primary。对大多数情况,这可以继续保持为 acting[0]
2 MOSDPGTemp(及相关的 OSD 结构)需要能够指定一个 primary 以及一个 acting set
3 现有代码库中很多地方假设 acting[0] 就是 primary,且 acting 的所有元素都有效。这需要被清理,因为 acting set 可能含有空洞

19.4.3 Distinguished acting set positions(可区分的 acting set 位置)

问题:

  • 在副本策略下,一个 PG 的所有副本是可互换的(interchangeable)
  • 有了纠删码后,acting set 中的不同位置持有纠删编码方案的不同部分,不可互换
  • 更糟的是:CRUSH 可能导致 chunk 2 被写到一个恰好已经含有(旧的)chunk 4 副本的 OSD 上

解决办法:

这意味着 OSD 与 PG 消息需要以类似 pair<shard_t, pg_t> 这样的类型工作,以便在单个 OSD 上区分同一 PG 的不同 chunk。

另一个约束:

由于对象名到 filestore 中对象的映射必须是 1-to-1,我们必须确保 chunk 2 中的对象与 chunk 4 中的对象具有不同的名字。为此,object store 必须在对象的 key 中包含 chunk id。

Core changes:

# 改动
1 object store 的 ghobject_t 还需要包含一个 chunk id,使其更像 tuple<hobject_t, gen_t, shard_t>
2 coll_t 需要包含一个 shard_t
3 OSD 的 pg_map 及类似的 PG 映射需要以 spg_t(本质上 pair<pg_t, shard_t>)为单位工作。类似地,pg->pg 消息需要包含一个 shard_t
4 对于 client->PG 消息,OSD 需要一种方式知道该消息应该交给哪个 PG chunk,因为该 OSD 可能同时含有同一 PG 的一个 primary chunk 和一个 non-primary chunk

19.4.4 Object Classes

来自 object class 的读在 EC 池上会通过调用一个特殊的 SYNC read 来返回 ENOTSUP。

19.4.5 Scrub(EC 池的 scrub 特殊性)

核心难点:

对 EC 池来说,主要的麻烦是:在副本上发送其所存 chunk 的 crc32 并没有多大帮助,因为不同副本上的 chunk 大概存的是不同的数据。

解决办法(依赖"不支持 overwrite,除非通过 DELETE"):

由于我们除了经由 DELETE 之外不支持 overwrite,我们有另一条路:在每次 append 时为各 chunk 维护一个 crc32。

于是:

  • 每个副本改为计算其所存 chunk 的 crc32,并与本地存储的校验和比较
  • 然后副本向 primary 报告校验和是否匹配
  副本池 scrub:   副本把 chunk 的 crc32 发给 primary → primary 跨副本比对
  EC   池 scrub:  副本自己算本地 chunk 的 crc32,与本地存的 checksum 比
                   → 只向 primary 报告"匹配/不匹配"

Overwrite 情形:

有了 overwrite,目前所有 scrub 都被禁用,直到我们想出该怎么做(参见 doc/dev/osd_internals/erasure_coding/proposals.rst,本册 19.6 节)。

19.4.6 Crush

如果 CRUSH 无法为 acting set 中一个 down 的成员生成替代者,那么 acting set 应该在该位置留下一个空洞,而不是把 acting set 的其他元素挪位。

19.4.7 MAIN OPERATION OVERVIEW(主要操作总览)

  • 一次 RADOS put 操作可以跨越单个对象的多个 stripe
  • 必须有代码把应用层的写切分成一组 per-stripe 的写操作 —— 若干整 stripe 与最多两个部分 stripe
  • 不失一般性,本文档其余部分只关注写单个 stripe(整的或部分的)
  • 我们用符号 "W" 表示一个 stripe 中正在被写的 block 数量,即 W <= K
  RADOS put (跨多个 stripe)
        │
        v
  tessellate(切分)
        │
        ├─► 若干 whole-stripe writes
        └─► 最多 2 个 partial-stripe writes

  W = 该 stripe 中正在被写的 block 数,W <= K

19.4.8 三种数据流(写入 EC stripe)

处理一次写入 EC stripe 的数据流有三种。选择哪一种取决于「写操作的大小」与「所选的 parity 生成算法的算术性质」。

官方在本节标题下列出了两种(原文只编号到 2):

# 数据流
(1) Whole stripe is written/overwritten(整个 stripe 被写/覆盖)
(2) A read-modify-write operation is performed(执行 read-modify-write)

💡 补充:原文写"There are three data flows"但只列出了 (1) 与 (2),第三种数据流在文档中未明确(见文末 文档未明确项)。

(1) WHOLE STRIPE WRITE

这是一个简单情形,并且在现有代码中(对于 append 而言)已经在执行:

  1. primary 在 RADOS 请求中收到该 stripe 的全部数据
  2. 计算相应的 parity block
  3. 把 data block 与 parity block 发送到它们的目标 shard,由这些 shard 写入

这本质上就是当前的 EC 代码。

(2) READ-MODIFY-WRITE
  1. primary 判定 K-W 个 block 中哪些是未被修改的
  2. 从各 shard 读取这些 block
  3. 一旦所有数据都收到,就与新收到的数据合并,并计算新的 parity block
  4. 把被修改的 block 发送到各自的 shard 并写入
  5. RADOS 操作被确认(acknowledged)
  (1) Whole Stripe Write
      primary 收到全 stripe 数据 → 算 parity → 发往各 shard → 写入

  (2) Read-Modify-Write
      primary 判定 K-W 个未修改 block
          → 从 shard 读回它们
          → 与新数据合并
          → 计算新 parity
          → 把被修改 block 发往对应 shard 写入
          → ACK

19.4.9 OSD Object Write and Consistency(两阶段写)

无论选择上述哪种算法,写入数据都是一个两阶段过程:commit 与 rollforward。

  • primary 发送带有该操作描述的日志条目(参见 osd_types.h:TransactionInfo::(LocalRollForward|LocalRollBack))
  • 在所有情况下,"commit" 都是就地(in place)执行的,可能会把回滚所需的某些信息留在一个 write-aside 对象中
  • rollforward 阶段发生在所有 acting set 副本都已 commit 该 commit 之后,它随后移除那些回滚信息

Overwrite 已有 stripe 的情形:

在覆盖已有 stripe 的情况下,回滚信息的形式是一个稀疏对象,包含被覆盖 extent 的旧值,通过 clone_range 填充。这本质上是一个占位实现,在真实环境中,bluestore 会有一个高效的原语来做这件事。

Rollforward 可以延迟:

rollforward 部分可以被延迟,因为一旦所有副本都已 commit,我们就把该操作报告为已 commit。目前,每当我们发送一次写,我们同时指示所有先前已 commit 的操作应当被 roll forward(参见 ECBackend::try_reads_to_commit)。如果当我们到达 waiting_rollforward 队列时流水线中没有任何东西,我们就启动一次 dummy write 来推动事情前进(参见后面 Pipeline 一节以及 ECBackend::try_finish_rmw)。

  阶段 1: commit        就地执行;回滚信息可能写入 write-aside 对象
  阶段 2: rollforward   所有 acting set 副本 commit 后执行;移除回滚信息
                        ↑ 可延迟:commit 完成即可向客户端报告成功

19.4.10 ExtentCache

动机:

能够在同一个对象上流水线化(pipeline)写是相当重要的。为此,存在一个由可缓存操作写入的 extent 的缓存。

  • 每个 extent 会一直被 pin 住,直到引用它的那些操作被 commit
  • 该流水线会阻止 rmw 操作运行,直到不可缓存的事务(clone 等)被从流水线中 flush 出去

参见 ExtentCache.h —— 其中有详细解释,说明缓存状态如何对应于「并发操作在什么条件下可以引用同一对象」的高层不变量。

19.4.11 Pipeline

核心不变量(在读完 src/osd/ExtentCache.h 之后应当已经对操作如何重叠有了很好的认识):

处理一次写操作涉及若干状态,并且有一个重要不变量,它不是由 PrimaryLogPG 在更高层强制的,而是需要由 ECBackend 来管理。这个重要不变量是:

我们不能在同一个对象上同时运行 uncacheable 与 rmw 操作。

实现上的简化:

为简单起见,我们只是强制:任何包含 rmw 操作的操作,必须等到所有进行中的 uncacheable 操作完成。

官方明确:这里未来还有改进可做。

更多细节:参见 ECBackend::waiting_* 与 ECBackend::try_<from>_to_<to>。

  不变量:同一对象上,uncacheable 操作 与 rmw 操作 不能同时运行

  简化实现:含 rmw 的操作 ──等待──> 所有进行中的 uncacheable 操作完成

19.4.12 注意事项

  • EC 池的 acting set 位置是有语义的(对应 chunk rank),这与副本池截然不同;任何假设「副本可互换」的代码在 EC 路径上都是错的。
  • spg_t(pair<pg_t, shard_t>)与含 chunk id 的 ghobject_t 是支撑这一语义的底层数据结构。
  • EC 池的 scrub 在无 overwrite 时靠「各副本自查本地 chunk CRC」;有 overwrite 时全部禁用。

引自:ECBackend Implementation Strategy

19.5 Erasure coding enhancements(enhancements.rst)

这是一份篇幅很大(1517 行)的增强设计文档。它既是设计提案,也是一份实施路线图(Stories 一节列出了拆分后的 PR 计划)。

✅ 复校状态:本节已按 1–400 / 401–800 / 801–1200 / 1201–1517 四段完整重读源文并逐小节核对,源文全部小节均已覆盖,无缺失内容。

19.5.1 Objectives(目标)

目标是改进纠删码的性能,特别是小随机访问,以使得「用纠删码池存储块数据与文件数据」更加可行。

具体希望减少:

目标 说明
每次客户端 I/O 的 OSD 读/写访问次数 有时称为 I/O 放大(I/O amplification)
OSD 之间的网络流量 network bandwidth
I/O 延迟 完成读与写 I/O 操作的时间

并期望这些改动也能适度降低 CPU 开销。

虽然这些改动聚焦于增强小随机访问,部分增强也会为大 I/O 访问和对象存储带来适度收益。

19.5.2 Current Read Implementation(当前读实现)

 RADOS Client
                            * Current code reads all data chunks
      ^                     * Discards unneeded data
      |                     * Returns requested data to client
 +----+----+
 | Discard |                If data cannot be read then the coding parity
 |unneeded |                chunks are read as well and are used to reconstruct
 |  data   |                the data
 +---------+
    ^^^^
    ||||
    ||||
    ||||
    |||+----------------------------------------------+
    ||+-------------------------------------+         |
    |+----------------------------+         |         |
    |                             |         |         |
  .-----.                       .-----.   .-----.   .-----.   .-----.   .-----.
 (       )                     (       ) (       ) (       ) (       ) (       )
 |`-----'|                     |`-----'| |`-----'| |`-----'| |`-----'| |`-----'|
 |       |                     |       | |       | |       | |       | |       |
 |       |                     |       | |       | |       | |       | |       |
 (       )                     (       ) (       ) (       ) (       ) (       )
  `-----'                       `-----'   `-----'   `-----'   `-----'   `-----'
 Primary                        OSD 2     OSD 3     OSD 4     OSD P     OSD Q
   OSD

Note(官方原文):所有图示都展示 K=4 + M=2 配置,但这些概念和技术可用于所有 K+M 配置。

当前读的问题:读取所有 data chunk,然后丢弃不需要的数据。

19.5.3 Partial Reads(部分读)

如果只读取少量数据,就没有必要读取整个 stripe;对于小 I/O,理想情况下只需要一个 OSD 参与读取数据。另见下面「更大的 chunk size」。

 RADOS Client
                            * Optimize by only reading required chunks
      ^                     * For large chunk sizes and sub-chunk reads only
      |                     read a sub-chunk
 +----+----+
 | Return  |                If data cannot be read then extra data and coding
 |  data   |                parity chunks are read as well and are used to
 |         |                reconstruct the data
 +---------+
     ^
     |
     |
     |
     |
     |
     +----------------------------+
                                  |
  .-----.                       .-----.   .-----.   .-----.   .-----.   .-----.
 (       )                     (       ) (       ) (       ) (       ) (       )
 |`-----'|                     |`-----'| |`-----'| |`-----'| |`-----'| |`-----'|
 |       |                     |       | |       | |       | |       | |       |
 |       |                     |       | |       | |       | |       | |       |
 (       )                     (       ) (       ) (       ) (       ) (       )
  `-----'                       `-----'   `-----'   `-----'   `-----'   `-----'
 Primary                        OSD 2     OSD 3     OSD 4     OSD P     OSD Q
   OSD

官方状态说明:Pull Request https://github.com/ceph/ceph/pull/55196 正在实现该优化的大部分,但它仍然发出完整的 chunk 读(full chunk reads)。

19.5.4 Current Overwrite Implementation(当前覆盖写实现)

 RADOS Client
      |                     * Read all data chunks
      |                     * Merges new data
 +----v-----+               * Encodes new coding parities
 | Read old |               * Writes data and coding parities
 |Merge new |
 |  Encode  |-------------------------------------------------------------+
 |  Write   |---------------------------------------------------+         |
 +----------+                                                   |         |
    ^|^|^|^|                                                    |         |
    |||||||+-------------------------------------------+        |         |
    ||||||+-------------------------------------------+|        |         |
    |||||+-----------------------------------+        ||        |         |
    ||||+-----------------------------------+|        ||        |         |
    |||+---------------------------+        ||        ||        |         |
    ||+---------------------------+|        ||        ||        |         |
    |v                            |v        |v        |v        v         v
  .-----.                       .-----.   .-----.   .-----.   .-----.   .-----.
 (       )                     (       ) (       ) (       ) (       ) (       )
 |`-----'|                     |`-----'| |`-----'| |`-----'| |`-----'| |`-----'|
 |       |                     |       | |       | |       | |       | |       |
 |       |                     |       | |       | |       | |       | |       |
 (       )                     (       ) (       ) (       ) (       ) (       )
  `-----'                       `-----'   `-----'   `-----'   `-----'   `-----'
 Primary                        OSD 2     OSD 3     OSD 4     OSD P     OSD Q
   OSD

19.5.5 Partial Overwrites(部分覆盖写)

理想情况下,我们希望能够通过只更新一部分 shard(那些数据或编码 parity 被修改的 shard)来更新纠删码 stripe。避免在其他 shard 上做不必要的数据更新很容易,但避免在任何其他 shard 上做元数据更新则要困难得多(参见关于元数据更新的设计一节)。

 RADOS Client
      |                     * Only read chunks that are not being overwritten
      |                     * Merge new data
 +----v-----+               * Encode new coding parities
 | Read old |               * Only write modified data and parity shards
 |Merge new |
 |  Encode  |-------------------------------------------------------------+
 |  Write   |---------------------------------------------------+         |
 +----------+                                                   |         |
    ^  |^ ^                                                     |         |
    |  || |                                                     |         |
    |  || +-------------------------------------------+         |         |
    |  ||                                             |         |         |
    |  |+-----------------------------------+         |         |         |
    |  +---------------------------+        |         |         |         |
    |                              |        |         |         |         |
    |                              v        |         |         v         v
  .-----.                       .-----.   .-----.   .-----.   .-----.   .-----.
 (       )                     (       ) (       ) (       ) (       ) (       )
 |`-----'|                     |`-----'| |`-----'| |`-----'| |`-----'| |`-----'|
 |       |                     |       | |       | |       | |       | |       |
 |       |                     |       | |       | |       | |       | |       |
 (       )                     (       ) (       ) (       ) (       ) (       )
  `-----'                       `-----'   `-----'   `-----'   `-----'   `-----'
 Primary                        OSD 2     OSD 3     OSD 4     OSD P     OSD Q
   OSD

官方说明:该图过于简化,只展示了数据流。该优化最简单的实现仍保留对每个 OSD 的一次元数据写。付出更多努力也可以减少元数据更新的数量(见下面的设计)。

19.5.6 Parity-delta-write(奇偶增量写)

这是实现 RAID5 与 RAID6 的块存储控制器常用的一种技术,有时被称为 parity delta write。当 stripe 的一小部分被覆盖时,可以这样做更新:读取旧数据,与新数据 XOR 产生一个 delta,然后读取每个 coding parity,应用该 delta 并写入新的 parity。

优势:

该技术的优势在于它涉及的 I/O 可以少得多,特别是对于 K 较大的 K+M 编码。

适用范围:

该技术不限于 M=1 和 M=2,它可以应用于任意数量的 coding parity。

                        Parity delta writes
                        * Read old data and XOR with new data to create a delta
 RADOS Client           * Read old encoding parities apply the delta and write
    |                     the new encoding parities
    |                   
    |                   For K+M erasure codings where K is larger and M is small
    |  +-----+    +-----+  this is much more efficient
    +->| XOR |-+->| GF  |---------------------------------------------------+
  +-+->|     | |  |     |<------------------------------------------------+ |
  | |  +-----+ |  +-----+                                                 | |
  | |          |                                                          | |
  | |          |  +-----+                                                 | |
  | |          +->| XOR |-----------------------------------------+       | |
  | |             |     |<--------------------------------------+ |       | |
  | |             +-----+                                       | |       | |
  | |                                                           | |       | |
  | |                                                           | |       | |
  | +-------------------------------+                           | |       | |
  +-------------------------------+ |                           | |       | |
                                  | |                           | |       | |
                                  | v                           | v       | v
  .-----.                       .-----.   .-----.   .-----.   .-----.   .-----.
 (       )                     (       ) (       ) (       ) (       ) (       )
 |`-----'|                     |`-----'| |`-----'| |`-----'| |`-----'| |`-----'|
 |       |                     |       | |       | |       | |       | |       |
 |       |                     |       | |       | |       | |       | |       |
 (       )                     (       ) (       ) (       ) (       ) (       )
  `-----'                       `-----'   `-----'   `-----'   `-----'   `-----'
  Primary                        OSD 2     OSD 3     OSD 4     OSD P     OSD Q
    OSD

与部分覆盖写的比较(官方原文):

注意,如果 stripe 中很大一部分数据正在被更新,该技术可能导致比执行 partial overwrite 更多的工作;不过如果两种更新技术都被支持,那么对于给定的 I/O offset 和 length,计算哪种技术最优是相当容易的。

在 primary 上做决策:

提交到 Primary OSD 的写 I/O 会执行这个计算,来决定使用 full-stripe update 还是 parity-delta-write。注意,如果在执行 parity-delta-write 时遇到读失败,需要重建数据或某个 coding parity,那么切换去执行 full-stripe read、merge 与 write 会更高效。

插件支持:

  • 并非所有纠删编码与纠删编码库都支持执行 delta 更新的能力,但那些使用 XOR 和/或 GF 算术实现的应该支持
  • 已检查 jerasure 与 isa-l,确认它们支持该特性,尽管目前插件尚未暴露所需的 API
  • 对于某些纠删码如 clay 和 lrc,可能可以应用 delta 更新,但 delta 需要被应用到的地方太多,这使得该优化变得毫无价值
  • 本提案建议:parity-delta-write 优化最初只为最常用的纠删编码实现
  • 纠删码插件将提供一个新的标志(flag),指示它们是否支持执行 delta 更新所需的新接口

19.5.7 Direct Read I/O(直接读)

我们希望客户端把小 I/O 直接提交给存储数据的那个 OSD,而不是把所有 I/O 请求都导向 Primary OSD、再由它向 secondary OSD 发出请求。通过消除一个中间跳,这减少了网络带宽并改善了 I/O 延迟。

         RADOS Client
               ^
               |
          +----+----+     Client sends small read requests directly to OSD
          | Return  |     avoiding extra network hop via Primary
          |  data   |
          |         |
          +---------+
               ^
               |
               |
               |
               |
               |
               |
               |
  .-----.   .-----.   .-----.   .-----.   .-----.   .-----.
 (       ) (       ) (       ) (       ) (       ) (       )
 |`-----'| |`-----'| |`-----'| |`-----'| |`-----'| |`-----'|
 |       | |       | |       | |       | |       | |       |
 |       | |       | |       | |       | |       | |       |
 (       ) (       ) (       ) (       ) (       ) (       )
  `-----'   `-----'   `-----'   `-----'   `-----'   `-----'
  Primary    OSD 2     OSD 3     OSD 4     OSD P     OSD Q
    OSD

多 chunk 读:

               RADOS Client
               ^         ^
               |         |
          +----+----+ +--+------+  Client breaks larger read
          | Return  | | Return  |  requests into separate
          |  data   | |  data   |  requests to multiple OSDs
          |         | |         |  
          +---------+ +---------+  Note client loses atomicity
               ^         ^         guarantees if this optimization
               |         |         is used as an update could occur
               |         |         between the two reads
               |         |
               |         |
               |         |
               |         |
               |         |
  .-----.   .-----.   .-----.   .-----.   .-----.   .-----.
 (       ) (       ) (       ) (       ) (       ) (       )
 |`-----'| |`-----'| |`-----'| |`-----'| |`-----'| |`-----'|
 |       | |       | |       | |       | |       | |       |
 |       | |       | |       | |       | |       | |       |
 (       ) (       ) (       ) (       ) (       ) (       )
  `-----'   `-----'   `-----'   `-----'   `-----'   `-----'
  Primary    OSD 2     OSD 3     OSD 4     OSD P     OSD Q
    OSD

⚠️ 关键:客户端失去原子性保证 —— 两次读之间可能发生一次更新。

19.5.8 Distributed processing of writes(分布式处理写)

现有的纠删码实现在 primary OSD 上处理写 I/O,向其他 OSD 发出读与写以获取并更新其他 shard 的数据。这或许是最简单的实现,但它使用了大量网络带宽。 有了 parity-delta-writes,就可以把处理分布到各 OSD 上以减少网络带宽。

               Performing the coding parity delta updates on the coding parity
               OSD instead of the primary OSD reduces network bandwidth
 RADOS Client
    |          Note: A naive implementation will increase latency by serializing
    |          the data and coding parity reads, for best performance these
    |          reads need to happen in parallel
    |  +-----+                                                          +-----+
    +->| XOR |-+------------------------------------------------------->| GF  |
  +-+->|     | |                                                        |     |
  | |  +-----+ |                                                        +----++
  | |          |                                              +-----+     ^ |
  | |          +--------------------------------------------->| XOR |     | |
  | |                                                         |     |     | |
  | |                                                         +---+-+     | |
  | +-------------------------------+                           ^ |       | |
  +-------------------------------+ |                           | |       | |
                                  | |                           | |       | |
                                  | |                           | |       | |
                                  | |                           | |       | |
                                  | |                           | |       | |
                                  | v                           | v       | v
  .-----.                       .-----.   .-----.   .-----.   .-----.   .-----.
 (       )                     (       ) (       ) (       ) (       ) (       )
 |`-----'|                     |`-----'| |`-----'| |`-----'| |`-----'| |`-----'|
 |       |                     |       | |       | |       | |       | |       |
 |       |                     |       | |       | |       | |       | |       |
 (       )                     (       ) (       ) (       ) (       ) (       )
  `-----'                       `-----'   `-----'   `-----'   `-----'   `-----'
  Primary                        OSD 2     OSD 3     OSD 4     OSD P     OSD Q
    OSD

⚠️ Note(官方原文):一个朴素的实现会因为把 data 与 coding parity 的读串行化而增加延迟;为了最佳性能,这些读需要并行发生。

19.5.9 Direct Write I/O(直接写)

             RADOS Client
                  |
                  |  Similarly Clients could direct small write I/Os
                  |  to the OSD that needs updating
                  |
                  |  +-----+                        +-----+
                  +->| XOR |-+--------------------->| GF  |
            +-----+->|     | |                      |     | 
            |     |  +-----+ |                      +----++
            |     |          |            +-----+     ^ |
            |     |          +----------->| XOR |     | |
            |     |                       |     |     | |
            |     |                       +---+-+     | |
            |     |                         ^ |       | |
            |     |                         | |       | |
            |     |                         | |       | |
            |     |                         | |       | |
            |     |                         | |       | |
            |     |                         | |       | |
            |     v                         | v       | v
  .-----.   .-----.   .-----.   .-----.   .-----.   .-----.
 (       ) (       ) (       ) (       ) (       ) (       )
 |`-----'| |`-----'| |`-----'| |`-----'| |`-----'| |`-----'|
 |       | |       | |       | |       | |       | |       |
 |       | |       | |       | |       | |       | |       |
 (       ) (       ) (       ) (       ) (       ) (       )
  `-----'   `-----'   `-----'   `-----'   `-----'   `-----'
  Primary    OSD 2     OSD 3     OSD 4     OSD P     OSD Q
    OSD

⚠️ 官方原文:该图过于简化,只展示了数据流 —— direct writes 实现起来困难得多,需要向 Primary 发送控制消息,以确保对同一 stripe 的写被正确排序。

19.5.10 Larger chunk size(更大的 chunk size)

问题:

默认的 chunk size 是 4K,这太小了,意味着小读必须被拆分并由许多 OSD 处理。如果小 I/O 能由单个 OSD 服务会更高效。 选择一个更大的 chunk size,例如 64K 或 256K,并实现 partial reads 与 writes,可以解决这个问题,但缺点是小的 RADOS 对象会被向上取整到一整个 stripe 的容量。

期望:

我们希望代码能够自动选择使用什么 chunk size,以同时针对容量与性能做优化。小对象应使用小的 chunk size 如 4K,较大的对象应使用更大的 chunk size。

另一个相关改动:

当前代码把 I/O 大小向上取整到 chunk size 的倍数,这在小 chunk size 下不是问题。有了更大的 chunk size 和 partial reads/writes,我们应该向上取整到 page size 而不是 chunk size。

19.5.11 Design — Test tools(测试工具)

核心判断:

对现有测试工具的调研表明,纠删码的覆盖率不足,无法「只是改代码然后指望现有 CI 流水线获得足够覆盖」。因此,首要步骤之一就是改进测试工具以获得更好的测试覆盖率。

Teuthology 依赖的 5 个生成 I/O 的测试:

# 任务 说明
1 rados task - qa/tasks/rados.py 使用 ceph_test_rados(src/test/osd/TestRados.cc),可生成多种 rados 操作的混合。对读和写 I/O 的支持有限,通常使用 offset 0,尽管有一个 chunked read 命令被若干测试使用
2 radosbench task - qa/tasks/radosbench.py 使用 rados bench(src/tools/rados/rados.cc 与 src/common/obj_bencher.cc)。可用于生成顺序与随机 I/O 工作负载,顺序 I/O 的 offset 从 0 开始。I/O 大小可设置,但整个测试期间是常量
3 rbd_fio task - qa/tasks/fio.py 使用 fio 对 rbd image volume 生成读/写 I/O
4 cbt task - qa/tasks/cbt.py 使用 Ceph 基准工具 cbt 来跑 fio 或 radosbench,对集群性能做基准测试
5 rbd bench 部分独立测试使用 rbd bench(src/tools/rbd/action/Bench.cc)生成少量 I/O 工作负载。rbd_pwl_cache_recovery 任务也使用它

问题:

很难用这些工具来很好地覆盖「对非 0(且非 stripe 对齐)offset 的 I/O」,或者生成各种各样的 offset 与长度的 I/O 请求,包括 chunk 与 stripe 的所有边界情形。 改进 rados、radosbench 或 rbd bench 中的任何一个,以为纠删码测试生成更有趣的 I/O 模式,是有空间的。

一致性检查工具的缺口:

  • 对于上述优化,必须有好的工具来检查所选对象或纠删码池中所有对象的一致性(检查数据与 coding parity 是否一致)
  • 有一个测试工具 ceph-erasure-code-tool,它可以使用插件对一组文件中提供的数据进行编解码
  • 但是,teuthology 中似乎没有任何脚本化手段,能通过使用 objectstore tool 读取数据、再用该工具验证一致性来做一致性检查
  • 计划:编写一些 teuthology helper,使用 ceph-objectstore-tool 与 ceph-erasure-code-tool 执行离线验证

在线一致性检查:

  • 我们也希望有一种在线执行完整一致性检查的方式,可以对特定对象或整个池
  • 不便之处:EC 池不支持 class 方法,因此无法用它来实现完整一致性检查
  • 计划研究:在 read 请求上加一个 flag、在 pool 上加、或实现一种新的请求类型,以对一个对象执行完整一致性检查;并考虑扩展 rados CLI 以能执行这些测试

多 coding parity 时不一致原因的分析:

当有多于一个 coding parity,且 data 与 coding parity 之间存在不一致时,尝试分析不一致的原因是有用的。因为多个 coding parity 提供了冗余,重建每个 chunk 可以有多种方式,这可以被用来检测最可能的不一致原因。

官方例子:

  4+2 纠删码,对第 1 个 data OSD 的一次写丢失(dropped write):
      - 整个 stripe(全部 6 个 OSD)将不一致
      - 任何包含第 1 个 data OSD 的 5 个 OSD 的选择也都不一致
      - 但 data OSD 2、3、4 加上两个 coding parity OSD 仍然是自洽的
  ⇒ 虽然一个 stripe 陷入这种状态的方式有很多,
    但一个工具可以得出结论:最可能的原因是对 OSD 1 的一次错过更新(missed update)。
  ⇒ Ceph 目前没有执行这类分析的工具,但扩展 ceph-erasure-code-tool 应该很容易。

故障注入工具的缺口:

  • Teuthology 似乎有足够的工具把 OSD 下线再上线
  • 有一些注入读 I/O 错误的工具(不需要把 OSD 下线),但有改进空间(例如:能够指定对象中会让读失败的某个 offset;对设置和删除错误注入点有更多控制)

复杂错误路径的测试策略:

Teuthology 的一般哲学似乎是随机注入故障,并仅靠蛮力来获得对所有错误路径的足够覆盖。这对 CI 测试是个好方法,然而当 EC 代码路径变得复杂、需要多个错误以精确的时序发生才能触发某条代码路径时,就很难在不跑很长时间测试的情况下获得覆盖。

  • 有一些 EC 的独立测试确实测试了部分多故障路径,但这些测试执行的 I/O 量非常有限,并且不会在有 I/O 在途时注入故障,因此错过了一些有意思的场景

提议的新 thrasher:

为了处理这些更复杂的错误路径,我们提议开发一种新的、专门用于纠删码的 thrasher,它注入一个错误序列,并利用debug 钩子在特定点捕获并延迟 I/O 请求,以确保错误注入命中特定的时序窗口。为此,我们将扩展 tell osd 命令,加入额外的接口来注入错误,以及在特定点捕获并拖住(stall)I/O。

单元测试:

纠删码的某些部分(例如插件)是独立的代码,可以用单元测试来测试。已经有一些纠删码的单元测试与性能基准工具,我们将考虑扩展它们。

19.5.12 Design — I/O path changes(I/O 路径改动)

A. Avoid unnecessary reads and writes

问题:

当前代码为读和覆盖写 I/O 读取了过多数据。对于覆盖写,它还会重写未被修改的数据。这发生的原因是读和覆盖写都被向上取整为 full-stripe 操作。当数据主要被顺序访问时这不是问题,但对随机 I/O 操作则非常浪费。

改动:

  • 可以把代码改为只读写必要的 shard
  • 为了让代码能高效支持更大的 chunk size,I/O 应该向上取整到 page size I/O,而不是 chunk size I/O

第一步优化集:

第一批简单的优化消除不必要的读与不必要的数据写,但保留在所有 shard 上的元数据写。这避免了破坏当前「所有 shard 都要为每个事务接收一次元数据更新」的依赖。当元数据处理的改动完成后(见下),就可以做进一步优化以减少元数据更新的数量,从而获得额外的节省。

B. Parity-delta-write

现有实现的代价:

当前代码通过执行一次 full-stripe 读、合并被覆盖的数据、计算新的 coding parity、执行一次 full-stripe 写来实现覆盖写。读写每个 shard 都很昂贵,可以应用若干优化来加速。

具体做法:

  1. 读取即将被覆盖的旧数据,与新数据 XOR 产生一个 delta
  2. 然后读取 coding parity,应用该 delta 并重写

收益:

对于 M 较小的 K+M 配置,执行 parity-delta-write 通常工作量更少。在 M=2(RAID6)的情况下,这可以只用 3 次读和 3 次写来完成一次小于一个 chunk 的覆盖写。

何时不适用:

注意,如果 stripe 中有很大一部分数据正在被更新,该技术可能导致比执行 partial overwrite 更多的工作;不过如果两种技术都支持,对于给定的 I/O offset 和 length,计算哪种最优是相当容易的。

决策位置与失败回退:

  • 提交到 Primary OSD 的写 I/O 会执行这个计算,来决定使用 full-stripe 覆盖写还是 parity-delta-write
  • 如果在执行 parity-delta-write 时遇到读失败,需要重建数据或某个 coding parity,那么切换去执行 full-stripe read、merge 与 write 会更高效
C. Direct reads

动机:读 I/O 当前被导向 primary OSD,再由它向其他 shard 发出读。为了减少 I/O 延迟与网络带宽,最好让客户端能直接向存储数据的 OSD 发出读请求,而不是经由 primary。

设计要点:

要点 说明
回退 存在一些错误场景,客户端仍可能需要回退到向 primary 提交读;secondary OSD 可以选择用 -EAGAIN 让直接读失败,以请求客户端向 primary OSD 重试该请求
大小限制 直接读总是 <= 一个 chunk。 对于多于一个 chunk 的读,客户端可以向多个 OSD 发出直接读,但这些读将不再保证是原子的,因为一次更新(写)可能被应用在两次独立读请求之间。如果客户端需要原子性保证,就需要继续把读发给 primary
重建场景 在需要 reconstruct 与 decode 操作才能返回数据的情况下,直接读会以 EAGAIN 失败。 这意味着只有发往 primary OSD 的读才需要处理 reconstruct 代码路径
backfill 当一个 OSD 正在 backfilling 时,我们不希望客户端有大量的 I/O 以 EAGAIN 失败,因此我们会让客户端检测这种情况,并避免向一个正在 backfilling 的 OSD 发出直接 I/O
向后兼容 为了向后兼容,对于无法容忍直接读的弱化保证的客户端请求,以及对于直接读将发往一个缺席或正在 backfilling 的 OSD 的场景,发往 primary OSD 的读仍会被支持
D. Direct writes

动机:写 I/O 当前被导向 primary OSD,再由它更新其他 shard。为了减少延迟与网络带宽,最好让客户端能把小的覆盖写请求直接发到存储数据的 OSD,而不是经由 primary。

要点 说明
适用场景 对于较大的写 I/O、错误场景和异常情况,客户端将继续把写 I/O 提交给 primary OSD
大小限制 直接写总是 <= 一个 chunk,并使用 parity-delta-write 技术来执行更新
多 chunk 对于中等大小的写,客户端可以向多个 OSD 发出直接写,但这样的更新将不再保证是原子的。如果客户端对较大的写需要原子性,就需要继续把它发给 primary
向后兼容 为了向后兼容,以及对于直接写将发往一个缺席的 OSD 的场景,发往 primary OSD 的写仍会被支持
E. I/O serialization, recovery/backfill and other error scenarios

核心难题:

直接写看起来相当简单,直到你开始考虑所有异常场景。 当前「在 Primary OSD 上处理所有写」的实现意味着对该 stripe 有一个中心控制点,它可以管理如下事情:

  • 同一 stripe 上多个在途 I/O 的排序
  • 确保一个对象的 recovery/backfill 在被访问之前已完成
  • 分配对象版本号与修改时间

有了直接 I/O,这些都变成了分布式问题。

解决方案:

我们的做法是向 Primary OSD 发送一条控制路径消息,让它继续充当中心控制点。Primary OSD 会在该 OSD 可以开始直接写时发出一个应答,并在该 I/O 完成时通过另一条消息被告知。(详见下面元数据更新一节。)

F. Stripe cache

作用:

纠删码池维护一个 stripe cache,在更新进行期间存储 shard 数据。这是为了允许对同一 stripe 的写与读被并行处理。

延长的价值:

对于小的顺序写工作负载,以及对于极端热点(例如同一 block 被反复重写,作为某种粗糙的检查点机制),把 stripe cache 保留得比该 I/O 的持续时间稍长一些会有好处。特别是,coding parity 通常为每一次对 stripe 的更新都被读和写。

取舍:

显然需要在「把缓存保留得足够长以减少未来 I/O 的开销」与「存储这些数据的内存开销」之间取得平衡。一个小的(MiB 量级而非 GiB 量级的)缓存对大多数工作负载应该就足够了。

另一个收益:

stripe cache 还可以通过允许 prefetch I/O 读取旧数据与旧 coding parity、为该写操作的后续部分做好准备,来帮助降低直接写 I/O 的延迟,而无需更复杂的互锁(interlocks)。

与 chunk size 的关系:

chunk size stripe cache 的重要性
小(如 4K) 不太重要 —— 因为即使是很小的写 I/O 请求,也不会有太多顺序更新来填满一个 stripe
大(如 64K) 收益更显著 —— 因为 stripe 大小将是数百 KiB 到少量 MiB,因此顺序工作负载向同一 stripe 发出许多 I/O 的可能性大大增加
G. Automatically choose chunk size

两种 chunk size 的取舍:

chunk size 优势 劣势
4K(当前默认) 对小对象好:data 与 coding parity 被向上取整到整 chunk;而且如果一个对象的数据少于一整个 data stripe,则 coding parity 的容量开销更高 随机访问 I/O 若只读写单个 shard 时收益小
64K / 256K 上述优化能带来大得多的节省(典型随机访问 I/O 只读写单个 shard) 小对象容量开销高

官方给出的容量开销例子:

  一个 4K 对象放在 10+2 纠删码池中:
      4K 数据 + 8K coding parity ⇒ 200% 开销

自动选择方案:

  • 用户可以尝试预测其典型对象大小并选择合适的 chunk size,但更好的做法是让代码自动为小对象选择小的 chunk size、为大对象选择更大的 chunk size
  • 总会有对象增长(或被截断)导致所选 chunk size 变得不合适的场景,但在那种情况下以新的 chunk size 读回并重写该对象不会有太大的性能影响
  • 这也意味着 chunk size 可以从 object_info_t 中的对象大小推导出来,而 object_info_t 是在读/修改对象数据之前被读取的
  • 客户端在创建对象时已经提供了对象大小的 hint,可以用它来选择 chunk size,以降低不得不重新分条(re-stripe)的可能性

新选项:

思路是支持一个新的 chunk size 值 auto/variable 来启用该特性。它只适用于新创建的池,没有办法迁移现有池。

H. Deep scrub support

现状:

场景 CRC 行为
EC 池 + overwrite 不检查 CRC,因为每次覆盖写时更新对象的 CRC 代价太高;代码依赖 Bluestore 来维护和检查 CRC
EC 池 + overwrite 禁用 为每个 shard 保留一个 CRC,因为可以在对象被 append 时更新 CRC —— 只需为被 append 的新数据计算一个 CRC,然后做一次简单的(快速的)计算把新旧 CRC 合并

proposals.rst 中的另一条路:

在 dev/osd_internals/erasure_coding/proposals.rst 中讨论了以更细的粒度(例如 per chunk)保留 CRC 的可能性,把这些 CRC 存为 xattr 或 omap(omap 更合适,因为大对象可能产生大量 CRC 元数据),并在数据被覆盖时更新这些 CRC(更新需要以与 CRC 相同的粒度执行一次 read-modify-write)。这些更细粒度的 CRC 随后可以很容易地被合并,产生整个 shard、甚至整个纠删码对象的 CRC。

本文档的反驳与建议:

本提案建议走相反的方向 —— 带 overwrite 的 EC 池到目前为止一直在没有 CRC 的情况下存活,并依赖 Bluestore,那么为什么还需要这个特性?

当前代码在 overwrite 启用时不检查 CRC,但遗憾的是仍然计算并更新 hinfo xattr 中的 CRC,即使执行的是覆盖写(这意味着计算出的值是 garbage)。这意味着我们付出了计算 CRC 的全部开销,却得不到任何好处。

具体建议:

  1. 代码可以很容易地被修正,使得 CRC 只在对象被顺序写时被计算和维护,但一旦对该对象发生第一次覆盖写,hinfo xattr 就会被丢弃,CRC 不再被计算或检查。这将在对象被覆盖时提升性能,并在对象未被覆盖时提升数据完整性
  修正后:
     顺序写 → 计算并维护 CRC(hinfo xattr)
     第一次覆盖写 → 丢弃 hinfo xattr,不再计算/检查 CRC
  1. deep scrub 的改进:当前,带 overwrite 的 EC 池的 deep scrub 只是检查每个 shard 能读到该对象,没有任何检查来验证各 shard 上的副本是一致的。
    • 完整的一致性检查需要大量的数据在 shard 之间传输,以便重新计算 coding parity 并与存储的版本比较 —— 在大多数情况下这会慢到不可接受
    • 但是,对于许多纠删码(包括 Ceph 使用的默认编码),如果把某个 chunk 的内容 XOR 在一起产生一个「纵向(longitudinal)汇总值」,那么对各个 data shard 的纵向汇总值做一次编码,应当会产生与 coding parity shard 所存储的相同的纵向汇总值
    • 这个比较比副本池执行的 CRC 检查开销更小
    • 风险:通过把 chunk 的内容 XOR 在一起,一组损坏可能互相抵消;但这种级别的检查总比没有检查好,并且在检测"dropped write"(最常见的损坏类型)上会非常成功
  EC deep scrub 的纵向校验思路:

    data shard 1 内容 XOR → L1 ─┐
    data shard 2 内容 XOR → L2  ├── 对 {L1,L2,L3,L4} 做一次编码
    data shard 3 内容 XOR → L3  │
    data shard 4 内容 XOR → L4 ─┘
                                     │
                                     v
                          应当 == coding parity shard 的纵向汇总值 {LP, LQ}

19.5.13 Design — Metadata changes(元数据改动)

A. 需要考虑哪些元数据?
# 元数据 说明
1 object_info_t 每个 Ceph 对象都有一些元数据存储在 object_info_t 数据结构中。某些字段(例如对象长度)不常被更新,我们可以简单地在这些字段需要更新时不做 partial writes 优化。 更有问题的字段是版本号与最后修改时间,它们在每次写时都被更新。对象的版本号会与 PG log 条目中的版本号(用于 peering/recovery)以及其他 shard 上的版本号(用于 backfill)比较。版本号与修改时间可以被客户端读取
2 PG log entries PG log 用于跟踪在途事务,并允许在一次中断/网络抖动后将未完成的事务 roll forward/backwards。PG log 也用于检测和解决来自客户端的重复请求(例如因网络抖动而重发)。Peering 目前假设每个 shard 都有该日志的一份拷贝,并且它为每个事务都被更新
3 PG stats entries 与其他 PG 元数据 还有其他 PG 元数据(PG stats 是最简单的例子)在每个事务上被更新。目前所有 OSD 都保留一份缓存的与一份持久化的该元数据拷贝
B. How many copies of metadata are required?
方案 说明
当前实现 保留 K+M 份元数据副本,每个 shard 一份
支持最多 M 次失败所需的最少份数 M+1
元数据是否也做纠删编码? 理论上可以,但鉴于它很小,可能不值得费这个劲

保留 K+M 份的一个优势:

保留 K+M 份元数据副本的一个优势是:任何完全同步的 shard 都可以读取本地的元数据副本,避免了 inter-OSD 消息与异步代码路径的需要。具体来说,这意味着任何不在执行 backfill 的 OSD 都可以成为 primary,并能在本地访问如 object_info_t 这样的元数据。

C. M+1 arbitrarily distributed copies(被否决的方案)

思路:

对某个 data shard 的一次 partial write 总会涉及对该 data shard 以及所有 M 个 coding parity shard 的更新,因此为了最优性能,理想情况是同 M+1 个 shard 被更新以跟踪相关的元数据更新。这意味着对于小的随机写,每次写会有不同的 M+1 个 shard 被更新。

缺点:

你可能需要读取 K 个 shard 才能找到元数据的最新版本。

否决理由:

在这个设计中,没有任何一个 shard 会拥有每个对象的最新元数据副本。这意味着无论挑选哪个 shard 作为 acting primary,它都可能没有本地可用的全部元数据,而可能需要向其他 OSD 发送消息来读取它。这会给 PG 代码增加显著的额外复杂性,并导致纠删码池与副本池之间出现分歧。由于这些原因,我们不采纳这个设计选项。

D. M+1 copies on known shards(被采纳的方案)

思路:

次优的性能可以通过总是把元数据更新应用到同样的 M+1 个 shard 来达成,例如选择第 1 个 data shard 以及所有 M 个 coding parity shard。Coding parity shard 会被每一次 partial write 更新,因此这将导致零个或一个额外 shard 被更新。 采用这种方法,只需读取 1 个 shard 就能找到元数据的最新版本。

附加约束:

我们可以把 acting primary 限制为 M+1 个 shard 之一,这意味着一旦日志中所有未完成的更新都被解决,primary 就会在本地拥有一份最新的全部元数据副本 —— 这意味着更多的 PG 代码可以保持不变。

  被采纳方案:M+1 copies on known shards

     更新目标 = 第 1 个 data shard + 全部 M 个 coding parity shard
     元数据最新版本 = 只需读 1 个 shard 即可获得
     acting primary 必须从这 M+1 个 shard 中产生
E. Partial Writes and the PG log

现状与改动:

  • Peering 目前假设每个 shard 都有一份日志拷贝
  • 但由于在途更新与短期缺席,某些 shard 可能缺失部分日志条目
  • Peering 的工作是把来自「在场 shard 集合」的日志合并成一份权威日志,记录已被所有 shard commit 的事务。某 shard 的日志与权威日志之间的任何差异随后被解决,通常是通过向后回滚事务(使用日志条目中保存的信息),使所有 shard 处于一致状态

为支持 partial writes 需要的改动:

为了支持 partial writes,日志条目需要被修改以包含「正在被更新的 shard 集合」。Peering 需要被修改,使得:只有当另一个 shard 上的该日志条目副本表明这个 shard 本应被更新时,才认为该日志条目从这个 shard 缺失。

日志裁剪的问题:

  • 日志不是无限长的。已知更新已在所有受影响的 shard 上成功 commit 的旧日志条目会被裁剪
  • 日志条目首先被压缩为一个 pg_log_dup_t 条目,它不能再协助回滚一个事务,但仍可用于检测重复的客户端请求,之后被完全丢弃
  • 日志裁剪与添加新日志条目同时进行,通常是当未来的一次写更新日志时
  • 有了 partial writes,日志裁剪只会发生在接收到更新的那些 shard 上,这意味着某些 shard 可能持有本应被丢弃的陈旧日志条目

TBD(官方原文):我认为代码已经能够应对各 shard 之间日志裁剪的差异。 显然,一次在途的裁剪操作可能没有在每个 shard 上完成,因此小的差异可以被处理,但我认为一个缺席的 OSD 会造成更大的差异。我相信这在 Peering 期间被解决 —— 每个 OSD 保留一份「最老的日志条目应该是什么」的记录,并在 OSD 之间共享,这样它们就能算出那些在缺席期间被裁剪掉的陈旧日志条目。希望这意味着「只向那些正在创建新日志条目的 shard 发送日志裁剪更新」无需改动代码就能工作。

F. Backfill

问题:

  • Backfill 用于纠正「OSD 缺席较长时间且 PG log 条目已被裁剪」时 OSD 之间的不一致
  • Backfill 的工作方式是比较各 shard 之间的对象版本
  • 如果某些 shard 有过时版本的对象,backfill 过程会执行一次 reconstruct 来更新该 shard
  • 如果对象版本号不在所有 shard 上更新,这将破坏 backfill 过程,并造成大量不必要的 reconstruct 工作。这是不可接受的,特别是对于「一个 OSD 只是因为维护而设置了 noout 并短暂缺席」这种场景
  • 要求是要能够最小化完成一次 backfill 所需的 reconstruct 工作量

proposals.rst 中的思路(及缺点):

在 dev/osd_internals/erasure_coding/proposals.rst 中讨论了「每个 shard 存储一个版本号 vector,记录 <本 shard, 其他 shard> 这一对都应当参与过的最近一次更新」这一想法。通过从至少 M 个 shard 收集这些信息,可以算出一个 shard 上对象的期望最小版本号,从而推断是否需要一次 backfill 来更新该对象。

这个方法的缺点是:backfill 将需要扫描 M 个 shard 来收集这些信息,而当前实现只扫描 primary 与正在被 backfill 的 shard。

本设计的改进:

有了「已知的 M+1 个 shard 总是会被更新」且「(acting)primary 将是这些 shard 之一」这两个额外约束,只需检查 primary 上的 vector 与 backfill 目标上的对象版本,就能判定是否需要 backfill:

  • 如果 backfill 目标是 M+1 个 shard 之一,现有的版本号比较就足够了
  • 如果它是另一个 shard,则需要把 primary 上 vector 中的版本与 backfill 目标上的版本比较

这意味着 backfill 不必比当前扫描更多的 shard,然而对 primary 的扫描确实需要读取该 vector,并且如果有多个 backfill 目标,它可能需要在每个对象上存储该 vector 的多个条目,从而在 backfill 期间增加内存使用。

vector 的存储与大小:

  • 只需要在 M+1 个 shard 上保留该 vector
  • 该 vector 只需要 K-1 个条目,因为我们只需跟踪 M+1 个 shard(它们应有相同版本)中任意一个与 K-1 个 shard(它们可能有陈旧版本号)中每一个之间的版本号差异。这将略微减少所需的额外元数据量
  • 该 vector 可以存储在 object_info_t 结构中,或作为一个单独的 attribute 存储

官方偏好:

我们倾向于把该 vector 存储在 object_info_t 结构中,因为通常两者会被一起访问,并且这使它们更容易被缓存在同一个 object cache 中。我们将通过只在需要时才存储该 vector 来保持元数据与内存开销较低。

升级兼容:

必须小心确保现有集群能够升级。该 vector 的缺失意味着一个对象从未有过 partial update,因此所有 shard 都被期望具有相同的对象版本号,可以使用现有的 backfill 算法。

Code references(官方给出的代码位置):

函数 当前作用 需要的改动
PrimaryLogPG::scan_range 创建一个对象及其版本号的 map;在 primary 上它尝试从 object cache 获取该信息,否则读取 OI attribute 需要改动以处理 vector。为节省内存,需要向它提供 backfill 目标集合,以便它可以选择保留 vector 的哪一部分
PrimaryLogPG::recover_backfill 为本地(primary)调用 scan_range,并向 backfill 目标发送 MOSDPGScan 以让它们执行同样的扫描。一旦收集到所有版本号,它比较 primary 与 backfill 目标以算出哪些对象需要被恢复 在比较版本号时也需要改动以处理 vector
PGBackend::run_recovery_op 恢复单个对象。对 EC 池而言,这涉及为需要 backfill 的 shard 重建数据(读其他 shard 并用 decode 恢复) 这段代码不需要任何改动
G. Version number and last modification time for clients

现状:

  • 客户端可以读取对象版本号,并可以在做更新时设置「最小版本号」的期望
  • 客户端也可以读取最后修改时间
  • 存在某些用例要求这些值可被读取且给出一致结果;但也有大量场景不需要这些信息

各场景的处理:

场景 处理
发往 primary 的 I/O 如果对象版本号只在已知的 M+1 个 shard 上为 partial writes 更新,那么在需要该信息的地方就需要访问这些 shard 中某一个的元数据。我们已经安排好让 primary 成为 M+1 个 shard 之一,因此提交给 primary 的 I/O 总是能访问到最新信息
Direct write I/O 需要更新 M+1 个 shard,因此在完成该 I/O 时顺便把这些信息返回给客户端并不困难
Direct read I/O 这是有问题的情形 —— 它们只访问本地 shard,不一定能访问到最新的版本与修改时间。为简单起见,我们将要求需要这些信息的客户端把请求发给 primary,而不是使用直接 I/O 优化。不需要这些信息的客户端可以使用直接 I/O 优化

补充:直接读 I/O 优化仍会返回一个(可能陈旧的)对象版本号。这对客户端理解到某个 chunk 的 I/O 顺序仍可能有用。

H. Direct Write with Metadata updates(完整控制流)

这是「一次执行 parity-delta-write 的直接写、带全部控制消息」的完整图景:

                   RADOS Client
 
                        |  ^
                        |  |
                        1  28
 +-----+                |  |
 |     |<------27-------+--+
 |     |                |  |
 |     |  +-------------|->|
 |     |  |             |  |
 |     |<-|----2--------+  |<--------------------------------------------+
 | Seq |  |             |  |<----------------------------+               |
 |     |  |    +----3---+  |                             |               |
 |     |  |    |        +--|-----------------------5-----|---+           |
 |     |  |    |        +--|-------4---------+           |   |           |
 |     +--|-10-|------->|  |                 |           |   |           |
 |     |  |    |    +---+  |                 |           |   |           |
 |     |  |    |    |   |  |                 |           |   |           |
 |     |  |    |    v   |  |                 |           |   |           |
 +----++  |    |  +---+ |  |                 |           |   |           |
   ^  |   |    |  |XOR+-|--|----------15-----|-----------|---|-----+     |
   |  |   |    |  |13 +-|--|-------14--------|-----+     |   |     |     |
   |  |   |    |  +---+ |  |                 |     |     |   |     |     |
   |  |   |    |    ^   |  |                 |     v     |   |     v     |
   |  |   |    |    |   |  |                 |  +------+ |   |  +------+ |
   6  11  |    |    |   |  |                 |  | XOR  | |   |  |  GF  | |
   |  |   |    |    |   |  |                 |  | 18   | |   |  |  21  | |
   |  |   |    |    12  16 |                 |  +----+-+ |   |  +----+-+ |
   |  |   |    |    |   |  |                 |    ^  |   |   |    ^  |   |
   |  |   |    |    |   |  |                 |    |  |   |   |    |  |   |
   |  |   |    |    |   |  |                 |    17 19  |   |    20 22  |
   |  |   |    |    |   |  |                 |    |  |   |   |    |  |   |
   |  |   |    |    |   v  |                 |    |  v   |   |    |  v   |
   |  |   |    |  +-+----+ |                 |  +-+----+ |   |  +-+----+ |
   |  |   |    +->|Extent| |                 +->|Extent| |   +->|Extent| |
   |  |   23      |Cache | 24                   |Cache | 25     |Cache | 26
   |  |   |       +----+-+ |                    +----+-+ |      +----+-+ |
   |  |   |         ^  |   |                      ^  |   |        ^  |   |
   |  |   |         |  |   |                      |  |   |        |  |   |
   |  +---+         7  +---+                      8  +---+        9  +---+
   |  |             |  |                          |  |            |  |
   |  v             |  v                          |  v            |  v
  .-----.          .-----.   .-----.   .-----.   .-----.         .-----.
 (       )        (       ) (       ) (       ) (       )       (       )
 |`-----'|        |`-----'| |`-----'| |`-----'| |`-----'|       |`-----'|
 |       |        |       | |       | |       | |       |       |       |
 |       |        |       | |       | |       | |       |       |       |
 (       )        (       ) (       ) (       ) (       )       (       )
  `-----'          `-----'   `-----'   `-----'   `-----'         `-----'
 Primary            OSD 2     OSD 3     OSD 4     OSD P           OSD Q
   OSD
 
 * Xattr            * No Xattr                    * Xattr         * Xattr
 * OI               * Stale OI                    * OI            * OI
 * PG log           * Partial PG log              * PG log        * PG log
 * PG stats         * No PG stats                 * PG stats      * PG stats

图注(官方原文):

只有 primary OSD 与 parity coding OSD(M+1 个 shard)拥有 Xattr、最新的 object info、PG log 与 PG stats。这些 OSD 中只有一个被允许成为(acting)primary。其他 data OSD 2、3 和 4(K-1 个 shard)没有 Xattrs 或 PG stats,可能有陈旧的 object info,并且只拥有它们自己更新的 PG log 条目。OSD 2、3 和 4 可能有带有旧版本号的陈旧 OI。其他 OSD 拥有最新的 OI 以及一个带有 OSD 2、3 和 4 期望版本号的 vector。

28 步消息序列(官方原文逐条转录):

步 消息
1 Data message with Write I/O from client (MOSDOp)
2 Control message to Primary with Xattr (new msg MOSDEcSubOpSequence)
3 Prefetch request to local extent cache
4 Control message to P to prefetch to extent cache (new msg MOSDEcSubOpPrefetch equivalent of MOSDEcSubOpRead)
5 Control message to Q to prefetch to extent cache (new msg MOSDEcSubOpPrefetch equivalent of MOSDEcSubOpRead)
6 Primary reads object info
7 Prefetch old data
8 Prefetch old P
9 Prefetch old Q
10 Control message to data OSD with new object info + PG stats (new msg MOSDEcSubOpSequenceReply)
11 Transaction to update object info + PG log + PG stats
12 Fetch old data (hopefully cached)
13 XOR new and old data to create delta
14 Data message to P with delta + Xattr + object info + PG log + PG stats (new msg MOSDEcSubOpDelta equivalent of MOSDEcSubOpWrite)
15 Data message to Q with delta + Xattr + object info + PG log + PG stats (new msg MOSDEcSubOpDelta equivalent of MOSDEcSubOpWrite)
16 Transaction to update data + object info + PG log
17 Fetch old P (hopefully cached)
18 XOR delta and old P to create new P
19 Transaction to update P + Xattr + object info + PG log + PG stats
20 Fetch old Q (hopefully cached)
21 XOR delta and old Q to create new Q
22 Transaction to update Q + Xattr + object info + PG log + PG stats
23 Control message to data OSD for commit (new msg MOSDEcSubOpDeltaReply equivalent of MOSDEcSubOpWriteReply)
24 Local commit notification
25 Control message to data OSD for commit (new msg MOSDEcSubOpDeltaReply equivalent of MOSDEcSubOpWriteReply)
26 Control message to data OSD for commit (new msg MOSDEcSubOpDeltaReply equivalent of MOSDEcSubOpWriteReply)
27 Control message to Primary to signal end of write (variant of new msg MOSDEcSubOpSequence)
28 Control message reply to client (MOSDOpReply)

关于步骤 2 的 Note(官方原文):

Primary 需要被告知任何 xattr 更新,以便它更新自己的拷贝,但这条消息的主要目的是允许 primary 对这次写 I/O 进行排序。步骤 10 的应答消息才是允许该写开始的那个,它并提供 PG stats 与新的 object info(包括新的版本号)。如有必要,primary 可以延迟它,以确保该对象的 recovery/backfill 先完成,并处理重叠的写。数据可以(prefetched)在应答之前被读取,但显然任何事务都不能开始。

关于步骤 7–9 的 Note(官方原文):

这些 prefetch 的目标是尽快启动对旧 data、P 和 Q 的读取,以降低整个 I/O 的延迟。可能存在一些错误场景,extent cache 无法保留这些数据,就需要重新读取。这包括那些罕见/病态的场景 —— 对同一对象,写被混合地发给 primary 与直接发给 data OSD。

关于步骤 12 的 Note(官方原文):

为了最佳性能,我们希望把对同一 stripe 的写流水线化。Primary 为每次写分配版本号,从而定义了写应当被处理的顺序。data shard 与 coding parity shard 以相同顺序应用重叠的写,这很重要。Primary 知道哪些写在途,因此可以检测到这种情况,并在步骤 10 的应答消息中指示「某次更新必须等到一次更早的更新被应用之后」。这个信息需要被转发给 coding parity(步骤 14 和 15),以便它们也能确保更新以相同顺序被应用。

19.5.14 Upgrade and backwards compatibility(升级与向后兼容)

两类改动:

类别 说明
无兼容性顾虑 少数优化只需改动 primary OSD 上的代码,对客户端或其他 OSD 没有向后兼容性顾虑。这些优化会在 primary OSD 升级后立即启用,并替换现有代码路径
需要新代码路径 其余改动将是与现有代码路径并存的新 I/O 代码路径

与 EC Overwrites 的类比:

与 EC Overwrites 类似,许多改动需要确保所有 OSD 都在运行新代码,且 EC 插件支持 parity-delta-writes 所需的新接口。将需要一个新的 pool 级 flag 来强制这一点。 在升级现有集群后,可以启用该 flag(从而启用新的性能优化)。一旦设置,将无法向该池添加低版本(down level)的 OSD。除了删除该池之外,将无法关闭该 flag。

为什么不支持降级(官方给出的 3 条理由):

  1. 要在清除该 flag 时确保没有任何新 I/O 代码路径正在被使用,
     需要先 quiesce 该池的所有 I/O —— 这并非易事。

  2. 新 I/O 的 PG log 格式不会被低版本 OSD 理解。
     在清除该 flag 之前,必须确保日志中所有新格式的条目都已被裁剪,
     以保证低版本 OSD 能解释该日志。

  3. 新的 I/O 代码路径会存储额外的 xattr 数据,并被 backfill 使用。
     低版本代码无法理解如何 backfill 一个一直在跑新 I/O 路径的池,
     并且会被不一致的对象版本号弄糊涂。
     虽然在理论上可以先禁用 partial updates,然后扫描并更新所有元数据,
     把池恢复到可以降级的状态,但我们无意编写这段代码。

直接 I/O 的额外要求:

直接 I/O 的改动将额外要求客户端运行新代码。 这些将要求该池设置了新的 flag,并使用一个新的客户端。旧客户端可以使用设置了新 flag 的池,只是没有直接 I/O 优化。

19.5.15 Not under consideration(明确不在考虑范围内)

1. RADOS Client Acknowledgement Generation optimization

思路:

在纠删码池中更新 K+M 个 shard 时,理论上你不必等所有更新都完成才向客户端完成这次更新,因为只要 K 个更新完成了,shard 的任何可行子集都应该能够 roll forward 这次更新。

为什么不适用:

对于只有 M+1 个 shard 被更新的 partial writes,该优化不适用,因为所有 M+1 个更新都必须在向客户端完成这次更新之前完成。

代价:

该优化需要改动 peering 代码,以判断部分完成的更新需要被 roll forwards 还是 backwards。要 roll 一次更新 forwards,最简单的方法是把该对象标记为 missing,并使用恢复路径来重建并把该更新推送给落后的 OSD。

2. Avoid sending read request to local OSD via Messenger

思路:

EC backend 代码有一个针对写本地 OSD 的优化,避免了通过 messenger 发送消息与应答。同样的优化也可以用于读,尽管需要多一点小心,因为读是同步的,会阻塞线程等待 I/O 完成。

官方状态:Pull request https://github.com/ceph/ceph/pull/57237 正在做这个优化。

19.5.16 Stories(实施计划)

这是官方给出的高层工作分解。意图是以一系列 PR 的形式交付。各 story 大致按计划开发顺序排列。每个 story 至少一个 PR,可能的情况下会进一步拆分。 较早的 story 可以作为独立的工作实现,不会引入升级/向后兼容性问题。较晚的 story 会开始打破向后兼容性,这里计划在池上增加一个新 flag 来启用这些新特性。最初这将是一个 experimental flag,而后续 story 开发时再用它。

完整 story 清单(按原文顺序):

# Story 要点
1 Test tools - enhanced I/O generator for testing erasure coding 扩展 rados bench 以生成更有趣的 I/O 模式(不同 offset、不同长度,覆盖 sub-chunk size、chunk size、stripe size 的边界条件);用 seed 生成数据模式并记住每个写入 block 的 seed 以便后续校验
2 Test tools - offline consistency checking tool 结合使用 objectstore_tool 与 ceph-erasure-code-tool 做离线一致性检查;增强部分 teuthology 独立 EC 检查以使用该工具
3 Test tools - online consistency checking tool 新的 CLI,可对一个对象或一个范围内的对象执行在线一致性检查:读取所有 data 与 coding parity shard,并重新编码以校验 coding parity
4 Switch for JErasure to ISA-L JErasure 自 2014 年以来未更新;ISA-L 有维护并利用更新的指令集(AVX512、AVX2)提供更快的编解码。任务:把上游 ceph 默认值改为 ISA-L;对 Jerasure 与 ISA-L 做基准测试;重构 Ceph isa_encode 的 region_xor() 以在 M=1 时使用 AVX;文档更新;在 performance weekly 上展示结果
5 Sub Stripe Reads Ceph 当前读取整数个 stripe 并丢弃不需要的数据。对小随机读,只读取所需数据更高效。任务:帮助完成 PR #55196(若尚未完成);进一步改动以发出 sub-chunk reads 而非 full-chunk reads
6 Simple Optimizations to Overwrite 只读取未被完全覆盖的 chunk(当前代码读取整个 stripe 再合并新数据);对 sub-chunk 更新执行 sub-chunk reads;对 sub-chunk 更新执行 sub-chunk writes
7 Eliminate unnecessary chunk writes but keep metadata transactions 避免重写未被修改的数据。仍然为每个 chunk 创建事务,但不带新的写数据;在数据被修改的地方加入 sub-chunk writes
8 Avoid zero padding objects to a full stripe 对象通过补零被向上取整到整数个 stripe,这些零缓冲随后被发往其他 OSD 并写入 OS 消耗存储。任务:修改 reconstruct reads 以避免读取对象末尾的零填充(直接用零填充读缓冲);避免传输/写入零填充缓冲(仍然向所有 shard 发送事务并创建对象,只是不用零填充它);修改 encode/decode 函数以避免在对象被填充时必须传入零缓冲
9 Erasure coding plugin changes to support distributed partial writes 准备工作:新增接口,通过 XOR 新旧数据创建 delta,并为 ISA-L 与 JErasure 插件实现;新增接口,用 XOR/GF 把 delta 应用到一个 coding parity,并为两个插件实现;新增接口,报告哪些纠删码支持该特性(ISA-L 与 JErasure 支持,其他不支持)
10 Erasure coding interface to allow RADOS clients to direct I/Os to OSD storing the data 准备工作:新增接口,把 (pg, offset) 转换为 {OSD, remaining chunk length}。我们不希望客户端动态链接纠删码插件,因此这段代码需要成为 librados 的一部分。 该接口需要理解纠删码如何把 data 与 coding chunk 分布到 OSD。我们将只支持 ISA-L 与 JErasure 插件,其中 data chunk 到 OSD 是平凡的条带化
11 Changes to object_info_t 准备工作:向 object_info_t 增加版本号 vector,用于 partial updates。对于副本池与未被覆盖的纠删码对象,我们将避免在 object_info_t 中存储额外数据
12 Changes to PGLog and Peering to support updating a subset of OSDs 修改 PG log 条目以存储「哪些 OSD 正在被更新」的记录;修改 peering 使用该额外数据来算出缺失更新的 OSD
13 Change to selection of (acting) primary 把 primary 的选择限制为第 1 个 data OSD 或纠删码 parity 之一。如果这些 OSD 都不可用且不是最新的,该池必须 offline
14 Implement parity-delta-write with all computation on the primary 计算「full stripe 覆盖写」还是「parity-delta-write」更高效;实现新代码路径执行 parity-delta-write;测试工具增强(新增 conf 选项:'parity-delta'、'full-stripe'、'mixture for testing'、'automatic',并更新 teuthology 用例以主要使用 mixture)
15 Upgrades and backwards compatibility 为纠删码池新增一个 feature flag;所有 OSD 必须运行新代码才能在池上启用该 flag;只有该 flag 设置后客户端才可发出直接 I/O;运行旧代码的 OSD 不得加入设置了该 flag 的池;无法关闭该 feature flag(除非删除该池)
16 Changes to Backfill to use the vector in object_info_t 修改 backfill 以使用 object_info_t 中的版本号 vector,这样当发生 partial updates 时我们不会对未参与该 partial update 的 OSD 做 backfill;单个 backfill 目标时从 vector 提取适当的版本号(无需额外存储);多个目标时提取 backfill 目标所需的 vector 子集,并在 PrimaryLogPG::recover_backfill 比较版本号时选择适当的条目
17 Test tools - offline metadata validation tool 离线元数据一致性检查工具,特别是校验 object_info_t 中的版本号 vector 与各 OSD 上的版本匹配,也用于校验 PG log 条目
18 Eliminate transactions on OSDs not updating data chunks 修改 overwrite I/O 路径以不再做纯元数据事务(对 Primary OSD 除外);修改 object_info_t 中版本号的更新以使用 vector 并只更新正在接收事务的条目;修改 PG log 条目的生成以记录哪些 OSD 正在被更新
19 Direct reads to OSDs (single chunk only) 修改 OSDClient 把单 chunk 读 I/O 路由到存储数据的 OSD;修改 OSD 以接受来自非 primary OSD 的读(扩展副本池现有的改动使其也适用于 EC 池);如有必要以 EAGAIN 让读失败;修改 OSDClient 在读以 EAGAIN 失败时重试提交给 Primary OSD;测试工具增强(新增 conf 选项 'prefer direct'、'primary only'、'mixture for testing')
20 Direct reads to OSDs (multiple chunks) 新增 OSDC flag NONATOMIC 允许 OSDC 把一次读拆成多个请求;如果设置了 NONATOMIC,修改 OSDC 把跨多 chunk 的读拆成到各 OSD 的独立请求;修改 OSDC 以合并结果(任何子读失败则整个读需要失败);修改 librbd 客户端为读设置 NONATOMIC flag;修改 cephfs 客户端为读设置 NONATOMIC flag
21 Implement distributed parity-delta-write 实现新消息 MOSDEcSubOpDelta 与 MOSDEcSubOpDeltaReply;修改 primary 计算 delta 并向 coding parity OSD 发送 MOSDEcSubOpDelta;修改 coding parity OSD 应用 delta 并发送 MOSDEcSubOpDeltaReply。⚠️ Note:这个改动会增加延迟,因为 coding parity 的读在旧数据读之后才开始。未来工作会修复这一点
22 Test tools - EC error injection thrasher 实现一种新型 thrasher,专门注入故障以压测纠删码池;把一个或多个(最多 M 个)OSD 下线,更侧重于下不同的 OSD 子集以驱动所有不同的 EC 恢复路径,而不是压测 peering/recovery/backfill(现有 OSD thrasher 擅长后者);注入读 I/O 失败以强制单次与多次失败的 reconstruct(decode);用 osd tell 类型接口注入延迟,以便更容易在 EC I/O 的所有有意思的阶段测试 OSD down;用 osd tell 类型接口注入延迟以拖慢一次 OSD 事务或消息,暴露并行工作较少见的完成顺序
23 Implement prefetch message MOSDEcSubOpPrefetch and modify extent cache 实现新消息 MOSDEcSubOpPrefetch;修改 primary 在开始读旧数据之前向 coding parity OSD 发出该消息;修改 extent cache 使每个 OSD 缓存自己的数据,而不是把所有东西都缓存在 primary 上;修改 coding parity OSD 处理该消息并把旧 coding parity 读入 extent cache;修改 extent cache 以保留 prefetch 的旧 parity 直到 MOSDEcSubOpDelta 消息到达,并在错误路径(例如新的 OSDMap)上丢弃它
24 Implement sequencing message MOSDEcSubOpSequence 实现新消息 MODSEcSubOpSequence 与 MOSDEcSubOpSequenceReply;修改 primary 代码创建这些消息,并为将来的直接写把它们本地路由给自己
25 Direct writes to OSD (single chunk only) 修改 OSDC 把单 chunk 写 I/O 路由到存储数据的 OSD;在 data OSD 与 primary OSD 之间发出 MOSDEcSubOpSequence 与 MOSDEcSubOpSequenceReply
26 Direct writes to OSD (multiple chunks) 如果设置了 NONATOMIC flag,修改 OSDC 把多 chunk 写拆成独立请求;进一步改动以合并完成(特别是正确报告版本号);修改 librbd 客户端为读设置 NONATOMIC flag;修改 cephfs 客户端为读设置 NONATOMIC flag
27 Deep scrub / CRC 为覆盖写禁用 EC 代码中的 CRC 生成,在第一次覆盖写发生时删除 hinfo Xattr;对于设置了新 feature flag 的池中未被覆盖的对象,即使池 overwrite flag 已设置也检查 CRC(hinfo 的存在/缺失可用于判定对象是否被覆盖过);对 deep scrub 请求把 shard 的内容 XOR 以创建纵向校验(8 bytes wide?);在 scrub 应答消息中返回该纵向校验,让 primary 对这组纵向应答做编码以检查不一致
28 Variable chunk size erasure coding 为自动/可变 chunk size 实现新的 pool 选项;对象小时(使用该新选项的池)使用小的 chunk size(4K);对象大时使用大的 chunk size(64K 或 256K?);当小对象增长(append)时通过读回并重写整个对象来转换 chunk size;当大对象收缩(truncate)时同样转换;使用对象大小 hint 以避免创建小对象后几乎立即转换为更大的 chunk size
29 CLAY Erasure Codes 理论上 CLAY 纠删码应适合 M 较大的 K+M 纠删码,特别是当这些纠删码与同一故障域中的多个 OSD 一起使用时(例如 8+6 纠删码,5 台服务器每台 4 个 OSD)。我们希望改进 CLAY 的测试覆盖并进行更多基准测试,以收集数据帮助证实人们何时应该考虑使用 CLAY。任务:对 CLAY 纠删码做基准测试(特别是多个 OSD 失败时 backfill 所需的 I/O 数量);增强测试用例以验证实现;另见 https://bugzilla.redhat.com/show_bug.cgi?id=2004256

关于 Story 19/20 的适用范围说明(官方原文):

这些改动将作用于 RADOS 客户端的 OSDC 部分,因此适用于 rbd、rgw 和 cephfs。我们不会对其他拥有自己版本 RADOS 客户端代码的代码(例如 krbd)做改动,尽管这在未来可以做。

19.5.17 新增消息 / 接口 / 标志速查(按源文归纳)

本节不引入源文之外的内容,只把散落在 Design 与 Stories 各处的「新增 / 改动项」汇总成表,便于回查。

A. 新增消息(源文标注 new msg 的)

消息 方向 / 时机 含义 等价的既有消息
MOSDEcSubOpSequence 步骤 2:data OSD → Primary,携带 Xattr 让 Primary 知道 xattr 更新,并主要用来对这次写 I/O 排序 无(新增控制消息)
MOSDEcSubOpSequenceReply 步骤 10:Primary → data OSD,携带新 object info + PG stats 允许该写开始;Primary 可延迟它以确保 recovery/backfill 完成并处理重叠写 无(新增控制消息)
MOSDEcSubOpPrefetch 步骤 4/5:Primary → P / Q 把旧 coding parity 预取进各 OSD 自己的 extent cache 等价 MOSDEcSubOpRead
MOSDEcSubOpDelta 步骤 14/15:data OSD → P / Q,携带 delta + Xattr + object info + PG log + PG stats 让 coding parity OSD 应用 delta 等价 MOSDEcSubOpWrite
MOSDEcSubOpDeltaReply 步骤 23/25/26:commit 通知 通知 data OSD 提交 等价 MOSDEcSubOpWriteReply

既有消息(对照):步骤 1 的 MOSDOp、步骤 28 的 MOSDOpReply。

B. 新增的插件接口(Story 9)

# 接口职责 实现范围
1 通过 XOR 新旧数据创建一个 delta 为 ISA-L 与 JErasure 插件实现
2 用 XOR/GF 把一个 delta 应用到一个 coding parity 为 ISA-L 与 JErasure 插件实现
3 报告哪些纠删码支持该特性 ISA-L 与 JErasure 支持,其他不支持

C. 新增的客户端接口(Story 10)

项 说明
接口 把 (pg, offset) 转换为 {OSD, remaining chunk length}
位置 必须成为 librados 的一部分(不希望客户端动态链接纠删码插件)
限制 只支持 ISA-L 与 JErasure 插件 —— 这两个插件的 data chunk 到 OSD 是平凡的条带化

D. 新增的 flag / 选项

名称 层级 语义
EC pool feature flag pool 所有 OSD 必须运行新代码才能启用;客户端只有该 flag 设置后才可发直接 I/O;旧代码的 OSD 不得加入;除删除该池外无法关闭
auto / variable pool 的 chunk size 选项 小对象用 4K,大对象用 64K 或 256K?(源文带问号);只适用于新建池,无法迁移现有池
NONATOMIC OSDC flag 允许 OSDC 把一次读/写拆成到各 OSD 的多个请求(牺牲原子性);librbd 与 cephfs 客户端会为读设置它
插件能力 flag EC plugin 插件报告是否支持执行 delta 更新所需的新接口
parity-delta / full-stripe / mixture for testing / automatic 测试 conf 选项 强制走 parity-delta-write 或 full-stripe write;teuthology 用例主要使用 mixture
prefer direct / primary only / mixture for testing 测试 conf 选项 强制走直接读或只读 primary;teuthology 用例主要使用 mixture

E. 被改动的数据结构与字段

结构 / 字段 改动 出处
object_info_t 增加版本号 vector(只需 K-1 个条目)。副本池与未被覆盖的纠删码对象不存这份额外数据;只在需要时才存 Story 11 / 16 / 18
PG log entry 增加**「正在被更新的 OSD 集合」**记录;peering 用它算出缺失更新的 OSD Story 12
pg_log_dup_t 日志条目被裁剪时先压缩成的形态:不能再协助回滚,但仍可检测重复客户端请求,之后被完全丢弃 19.5.13 E
hinfo xattr 存 CRC。首次覆盖写发生时删除;其存在/缺失可用于判定对象是否被覆盖过 Story 27

💡 补充:源文 Story 24 的标题句写的是 MODSEcSubOpSequence(MOSD 被写成 MODS),与步骤 2、27 使用的 MOSDEcSubOpSequence 不一致,疑为源文笔误;本册按源文原样保留 Story 24 的写法,未做订正。文档未明确两者是否为同一消息。

💡 补充:源文只声明了这些新消息的名称、方向与携带内容,没有给出类定义、字段清单、序列化格式或默认值 —— 具体字段文档未明确,需以源码 src/osd/OSD.h / src/messages/ 下的实际定义为准。

19.5.18 注意事项

  • 本文档既是设计也是路线图,其中列出的 PR 编号(#55196、#57237)与 story 状态是撰写时刻的状态,不代表 Tentacle v20.2.4 的完成度。
  • M+1 copies on known shards 是被采纳方案,M+1 arbitrarily distributed copies 被明确否决,理由是不想让 EC 与副本池的 PG 代码分叉。
  • 直接读/写牺牲原子性(多 chunk 场景),需要 NONATOMIC flag 显式声明。
  • 池级 feature flag 不可关闭(除删除池外),这是升级路径上最重要的约束。

引自:Erasure Coding Enhancements

19.6 Proposed Next Steps for ECBackend(proposals.rst)

⚠️ 该页带有 :orphan: 标记,且不在 erasure_coding.rst 的 toctree 中(官方目录中只有 4 个子页面,见 19.1.2)。它是更早期的一份提案文档,enhancements.rst 与 ecbackend.rst 都引用了它。

19.6.1 PARITY-DELTA-WRITE

动机:

RMW 操作当前需要 4 次网络跳(2 次往返)。原则上,对于某些编码,我们可以把它减少到 3 次 —— 把更新发送给持有 data block 的副本,让它们计算一个 delta 并转发给 parity block。

具体做法:

  1. primary 把本次更新的新数据发送给持有对应 data block 的 shard,而不是先自己读旧值再算 parity。
  2. 收到新数据的 shard 读取本地旧 chunk,计算 delta = new ⊕ old(对线性编码即差分),并把 delta 转发给持有 parity block 的 shard。
  3. parity shard 用收到的 delta 就地更新本地 parity chunk,不需要读取或重算整条 stripe。
现状 RMW(4 次网络跳 / 2 次往返):
  client -> primary
            (1) read old data chunk    -> shard_k     -> 回 primary
            (2) read old parity chunk  -> shard_p     -> 回 primary
            (3) write new data chunk   -> shard_k
            (4) write new parity chunk -> shard_p

parity-delta-write(3 次网络跳):
  client -> primary
            (1) write new data         -> shard_k
            (2) shard_k 计算 delta
            (3) shard_k 转发 delta      -> shard_p(就地更新 parity)

⚠️ 官方原文在该条目下标注了 TBD:parity-delta-write 依赖编码具备「可由 delta 直接更新 parity」的性质(即线性性),但哪些插件 / 编码可以支持、如何由插件声明,文档未明确。

对插件接口的影响:需要在 ErasureCodeInterface 上新增形如 encode_delta() / apply_delta() 的能力,并让插件显式声明是否支持;不支持 delta 的插件继续走现有的 4 跳路径。文档未明确该接口的确切签名与默认实现。

💡 补充:这条优化只对 RMW(部分写) 有意义。整条 stripe 写(full stripe write)本来就一次写满所有 shard,不存在需要转发 delta 的中间步骤。因此它与 enhancements.rst 中「消除 RMW 读放大」的目标一致,与「whole stripe write 走直接写路径」是互补而非替代关系。

19.6.2 STRIPE CACHE

动机:RMW 路径上的读放大(先读旧 data chunk 与旧 parity chunk)在小随机写场景下是主要开销。若在 OSD 内存中缓存最近使用的 stripe / chunk,命中时可直接算出 delta 或 parity,省掉一次或两次磁盘读。

要点:

维度 说明
缓存单位 stripe(或 chunk 级别的 extent)
主要收益 消除 RMW 的读放大
主要风险 与后端写入的一致性:缓存内容必须在写入后被失效或更新
失效时机 该 chunk 被写入、对象被删除、PG 变更(split / merge)、scrub 修复
容量约束 需要内存预算与淘汰策略(LRU 等);文档未明确具体容量配置项
与其他内存预算的关系 文档未明确与 osd_memory_target、bluestore cache 的划分关系

⚠️ 官方该条目仅给出方向与待解决问题,没有给出命中率、容量或淘汰策略的任何数据。

19.6.3 IMPLEMENTING A ROLLBACK-ABLE PREPARE OPERATION

动机:ECBackend 目前只对 APPEND / DELETE / SETATTR / RMATTR 这类可以用 TransactionInfo::LocalRollBack 表达的操作保证可回滚(见 19.3)。一旦支持整对象覆盖写、部分覆盖写、以及 clone / promote 等更复杂的操作,就必须有一种通用的、可回滚的 prepare 机制。

基本思路(两阶段 + 临时对象):

prepare 阶段:
  1. 把新数据写入一个"临时对象"(temporary object)
  2. 在 pg log / transaction 中记录:目标对象 <- 临时对象 的映射
rollback:    丢弃临时对象即可(旧数据从未被破坏)
rollforward:把临时对象 rename / 移动为最终目标对象

争议点:临时对象如何命名。官方文档记录了 Sam 与 Allen 两种并列意见:

方案 思路 优点 顾虑
编码进 ghobject_t(generation / shard id) 临时对象仍是同一集合中的对象,靠 generation 区分 复用现有对象寻址与遍历路径 可能与用户可见的 generation 语义混淆;枚举代价较高
独立命名空间 / 独立集合 临时对象放在专门的位置 语义清晰、易枚举、易 GC 需要新的集合管理与迁移路径,代码面更大

必须满足的约束(本册按原文讨论归纳):

  1. 重启后可识别:OSD 崩溃重启后必须能发现并清理残留的临时对象,否则会泄漏。
  2. 不与用户对象冲突:命名规则必须保证任何用户对象都不会与之重名。
  3. 与 scrub / 统计兼容:临时对象不应被当作普通对象参与统计或一致性校验,否则会产生误报。
  4. 与 recovery 兼容:peering 之后必须能判定某个 prepare 应当回滚还是前滚。

⚠️ 官方文档未明确最终采用哪种命名方案,该条目以「两种意见并列、待定」的形式保留。

19.6.4 PARTIAL APPLICATION

问题:EC 写入要落到多个 shard。若 primary 在只把写发给了部分 shard 时崩溃,就会出现「部分应用(partially applied)」状态:某些 shard 有新数据,某些没有。副本池可以用 pg log 的 last_update 单点判定,而 EC 下不同 shard 的进度天然不一致。

需要的改动(peering / recovery 侧):

改动 说明
pg log 记录参与 shard log entry 需记录本次操作实际涉及哪些 shard(与 enhancements.rst「PG log 需带参与 shard 集合」呼应)
逐 shard 判定 peering 时不再假定所有 shard 版本一致,而是按 shard 判定「未应用 / 已 prepare / 已 rollforward」
回滚或前滚 对未完成的 prepare:收集到足够应答后前滚,否则回滚(见 19.3 的两阶段提交)
版本表达 需要能表达「同一对象在不同 shard 上的不同版本」(见 19.6.7 的 version vector)

⚠️ 官方该条目只描述问题与改动方向,没有给出完整算法;它与本册 19.3(ECBackend 两阶段提交)和 19.5(enhancements)互为补充。

19.6.5 DEEP-SCRUB

现状:EC 的 scrub 依赖每个 shard 对自己 chunk 做自校验(见 19.3「EC scrub 自 CRC 校验」)。deep-scrub 在此基础上还需要校验跨 shard 的一致性,即 parity 与 data 是否真的匹配。

官方提出的方向:

  1. deep-scrub 时按整条 stripe 读取 data chunk 并重算 parity,与 shard 上实际存储的 parity 比对,从而发现「静默损坏但自 CRC 正常」的情况。
  2. 需要把 omap 的 CRC 纳入校验;文档未明确 omap 在 EC 下的表示与校验方式(与 19.6.7 的 object_info_t / omap 分布问题相关)。
  3. 需要处理 chunked / manifest 对象(见第 15 章),以及 shard 缺失时如何降级校验。

⚠️ 官方该条目为提案性质,没有给出 scrub 错误码、修复动作,以及与 pg_scrub 状态机的衔接细节。

19.6.6 RADOS CLIENT ACK GENERATION OPTIMIZATION

动机:对 EC 写,当前要等到全部 W+M 个 shard(所有 data shard 与 parity shard)都完成 prepare / rollforward 后才向客户端回 ACK。但客户端真正关心的只是「这次写已经持久且可读」。

提案:已在足够多的 shard(记作 W)上完成、使得该写即使在后续失败中也能被恢复 / 重建时,即可提前回 ACK —— 即 ACK after W of W+M,余下的 M 个 shard 在后台补齐。

项 说明
现状 等 W+M 全部 ack
提案 等 W 个 ack 即回客户端,其余后台补齐
收益 降低客户端写延迟,尤其在高 M、跨机架部署下
风险 若在补齐前再次发生故障,需保证 recovery 能重建;文档未明确其形式化安全论证与降级处理
适用范围 文档未明确是否同样适用于 full stripe write 与 RMW 两条路径

⚠️ 官方该条目仅给出 "W of W+M" 的表述与动机,没有给出形式化证明或失败场景的完整分析。

19.6.7 INCONSISTENT OBJECT_INFO_T VERSIONS

问题:副本池中,同一对象在各副本上的 object_info_t(含 version)应当一致,scrub / stat / recovery 都依赖这个一致性。但在 EC 下,由于操作是按 shard 分别应用的,同一对象在不同 shard 上的 object_info_t 版本天然可能不一致(见 19.6.4)。

提案:用**版本向量(version vector)**替代单一版本号:

object_info_t.version(单一值)  ->  每个 shard 一个分量:

  shard 0 : v = 7
  shard 1 : v = 7
  shard 2 : v = 6     <- 该 shard 尚未应用最近一次写
  ...
  shard N-1: v = 7

受影响的代码面:

位置 影响
scrub 不能用单一 version 比对判定一致,需按 shard 比对或重算校验
stat(CEPH_OSD_OP_STAT) 返回给客户端的 version 需要有确定语义
recovery / backfill 判定「谁落后」需按 shard 比较
PG log log entry 需携带参与 shard 集合(与 19.6.4 联动)

⚠️ 官方该条目未给出版本向量的具体数据结构、存储位置或升级兼容方案。

19.6.8 IMPLEMENTATION STRATEGY

官方给出的落地策略要点:

  1. 增量式推进:每项能力独立成变更、独立可测,而不是一次性重写 ECBackend。
  2. 先易后难:先做不依赖编码插件改动的部分(如部分读、部分写路径重构),再把需要插件配合的能力(如 parity-delta-write)作为由插件声明的可选能力。
  3. feature / flag 门控:需要新增 OSD 侧 feature / flag 进行门控,且与 enhancements.rst 口径一致 —— 该 flag 一旦启用不可回退。
  4. 测试工具同步建设:官方明确列出需要能构造「partial write / partial stripe / 损坏 shard」场景的测试工具(与 19.5 中 test tools 条目呼应)。

⚠️ 官方文档未明确各步骤的排期、依赖关系与负责人。

19.6.9 OPEN QUESTIONS

官方在文末以 OPEN QUESTIONS 小节列出 2 个未决问题,二者都与本册前述取舍直接相关:

  1. 临时对象 / 可回滚 prepare 的命名与归属(见 19.6.3):Sam 与 Allen 两种方案如何取舍,以及旧版本残留如何清理。
  2. 部分应用下的 peering 与 recovery 语义(见 19.6.4 与 19.6.7):是否引入版本向量,以及引入后与 scrub / stat 的兼容性。

⚠️ 文档未明确:官方未对这两个问题给出结论或倾向性意见。

引自:Proposed Next Steps for ECBackend


至此,OSD 内部部分(第一部分)全部讲完。回顾一下这条主线:OSD 是一个以 osd_lock 为中枢、靠 9 个 WorkQueue 分流的事件驱动进程;PG 是它所服务的基本单位,靠 pg log + Peering 状态机在成员变更中维持一致性;在此之上叠加了 recovery / backfill / scrub / snap trim 等后台机制,并由 mClock 统一做服务质量仲裁;EC 则为这套复制框架提供了一个"多 shard、两阶段提交、部分写需 RMW"的特殊后端。


第二部分 MDS 内部(mds_internals/)

官方 doc/dev/mds_internals/ 目录只有 5 个 .rst 文件(其中 index.rst 仅为 toctree,11 行),远少于 OSD 部分。这意味着:

  • MDS 内部机制的官方文档覆盖度很低,很多关键主题(如 journal、MDSTable、scrub、fragment、cap 迁移)没有对应页面。
  • 本册仅能就这 4 个有效页面(data-structures、locking、exports、quiesce)讲透,其余一律如实标注「文档未明确」。

💡 补充:MDS 与 OSD 最大的不同在于 —— MDS 是有状态缓存 + 分布式锁 + 客户端授权(cap)的元数据服务,它的正确性主要靠「锁状态机 + 日志(journal)+ 子树迁移协议」三者共同保证。本册覆盖的 4 个主题正好对应其中的数据结构、锁、迁移、静默四块。


第 20 章 MDS 核心数据结构(data-structures)

源文:doc/dev/mds_internals/data-structures.rst(仅 45 行)

官方该页非常短,只讲了三个核心类的体量级与相互链接关系,以及 OpenFileTable。下面按原文口径展开,并补充必要的上下文。

20.1 三个核心类

MDS 的元数据缓存由三类对象构成:CInode(inode)、CDentry(目录项 / dentry)、CDir(目录分片 / dirfrag)。

// src/mds/CInode.h / CDentry.h / CDir.h(类名保留原文)
class CInode;   // 一个 inode
class CDentry;  // 一个目录项(名字 -> inode 的链接)
class CDir;     // 一个目录分片(directory fragment)

官方给出的单个对象近似内存占用:

类 近似大小 说明
CInode 约 1400 字节 最大的一类,承载 inode 的全部内存态(含各把锁、cap 相关状态、布局等)
CDentry 约 400 字节 一个目录项
CDir 约 700 字节 一个目录分片

⚠️ 官方原文明确写道:这些结构 "quite large",因此 "be careful about adding new fields"(新增字段要非常谨慎)。

💡 补充:这句话的实践含义是 —— 在 MDS 侧新增任何 CInode / CDentry / CDir 成员,都会按「缓存中对象数量 × 单对象增量」线性放大 MDS 常驻内存。MDS 的缓存规模通常以百万级对象计,因此 1400 B 的基线本身就是调优的重点(这也是 MDS cache 内存预算配置项存在的背景)。文档未明确该页所引大小对应的具体 Ceph 版本与编译选项。

20.2 链接关系

官方明确了三者的基数关系:

关系 官方口径
CDentry → CInode 一个 CDentry 最多链接到一个 CInode(at most one)
CInode → CDentry 一个 CInode 可以被多个 CDentry 链接(multiple CDentrys),即硬链接
CInode → CDir 只有目录 inode 才有 CDir
分片目录 一个被分片(fragmented)的目录 inode 会有多个 CDir 对象
                CDentry ("a")  ──┐
                CDentry ("b")  ──┼──>  CInode(可被多个 dentry 指向 = 硬链接)
                CDentry ("c")  ──┘

  CInode(目录)
     ├── CDir(frag A,如 [0, 2^8))
     │      ├── CDentry
     │      └── CDentry
     └── CDir(frag B,如 [2^8, 2^9))      <- 分片后同一 inode 多个 CDir
            └── CDentry

  CInode(普通文件)
     └── 无 CDir

💡 补充:CDentry 与 CInode 分离,正是为了支持硬链接:名字(dentry)与对象(inode)是多对一关系,inode 自身不记录路径。这也是 MDS 需要在 CInode 上维护 parent 指针 / remote linkage 的原因之一。文档未明确该页未展开的 linkage / remote dentry 细节。

20.3 OpenFileTable

官方该页第二大块讲的是 OpenFileTable,它记录当前打开的文件及其祖先路径。

关键设计:

项 说明
用途 追踪「被打开的文件」以及它们的祖先
每条 entry 的内容 {parent inode, dentry name} —— 即父 inode + 该项的名字
存储位置 存放在 RADOS 对象的 omap 中
路径构造 路径是**递归构造(recursively)**出来的:由 entry 逐级向上拼接
OpenFileTable entry 形如:
    { parent_inode = <CInode of /a/b>, dentry_name = "c" }

构造 /a/b/c 的过程(递归向上):
    entry(c)  -> parent=/a/b, name="c"
    entry(b)  -> parent=/a,   name="b"
    entry(a)  -> parent=/,    name="a"
    root      -> 终止
    => 拼出 "/a/b/c"

💡 补充:把 open file 表持久化到 RADOS omap(而不是只放在内存),是为了 MDS 重启 / 故障切换后仍能恢复打开文件的路径信息,这对 flock 等需要跨重启保持的状态很关键;以 {parent, name} 而不是完整路径字符串存储,则是为了避免路径变化时的大量重写入。文档未明确该 RADOS 对象的具体命名、以及 OpenFileTable 的锚点(anchor)机制细节。

20.4 该页未覆盖的内容

官方该页没有讨论(本册如实列出):

  • MDCache 的组织方式、cache 的分片(shard)与 LRU / 淘汰策略;
  • inode 号(inodeno_t)与 snapid_t 的分配;
  • MDSTable(InoTable、SnapTable、SessionMap、OpenFileTable 之外的表)及其 journal 化;
  • CInode / CDentry / CDir 各自内部的字段清单;
  • 目录分片(frag_t)的编码与分裂算法。

引自:MDS Data Structures


第 21 章 MDS 锁机制(locking)

源文:doc/dev/mds_internals/locking.rst(174 行)

这是 MDS 部分信息量最大的一页:给出锁类型表、锁类、加锁模式、锁状态、状态机表 sm_state_t,以及一个完整的加锁实例。

21.1 锁类型总表

MDS 对元数据的不同字段集合使用不同的锁,从而把「改权限」与「改大小」这类互不冲突的操作分开。官方列出的锁类型(CEPH_LOCK_*,定义在 src/include/ceph_fs.h)如下:

锁类型常量 含义(保护什么) 作用域(挂在哪个对象上)
CEPH_LOCK_DN dentry 本身(名字到 inode 的绑定、dentry 版本) CDentry
CEPH_LOCK_DVERSION 目录分片版本(dirfrag 的统计/版本号,本地用途) CDir
CEPH_LOCK_IQUIESCE inode 的静默状态(quiesce,见第 23 章) CInode
CEPH_LOCK_IVERSION inode 版本号(本地用途) CInode
CEPH_LOCK_IAUTH inode 的 auth 元数据(mode / uid / gid 等) CInode
CEPH_LOCK_ILINK inode 的 link 元数据(nlink、父目录链接关系) CInode
CEPH_LOCK_IDFT inode 的 dirfrag tree(目录分片树) CInode(目录)
CEPH_LOCK_IFILE inode 的文件元数据(size、mtime、文件布局) CInode
CEPH_LOCK_INEST inode 的 nesting(递归统计 rstat,向上传播) CInode(目录)
CEPH_LOCK_IXATTR inode 的扩展属性(xattrs) CInode
CEPH_LOCK_ISNAP inode 的快照元数据 CInode
CEPH_LOCK_IFLOCK inode 上的 flock 锁 CInode
CEPH_LOCK_IPOLICY inode 的策略(布局策略、快照策略、配额相关策略) CInode

💡 补充:这张表体现了 MDS 锁设计的核心思想 —— 锁不是按"整个 inode"加的,而是按字段组加的。这样「两个客户端同时写同一文件的不同偏移量」只需要 IFILE 上的写授权,而「chmod」只动 IAUTH,二者互不阻塞。这也是 MDS 能把元数据操作并发度做高的前提。

⚠️ 注意:只有目录 inode 才有 IDFT 与 INEST 的意义(目录分片树与递归统计);普通文件 inode 上这两把锁虽然存在但不承载实质内容。文档未明确官方是否对普通文件跳过这两把锁的状态机推进。

21.2 三个锁类(lock class)

官方指出这些锁类型在实现上分为三类(src/mds/locks.h):

锁类 说明 典型用途
LocalLock 本地锁,不复制到其他 MDS、不参与跨 rank 的锁状态机 本地用途的版本类锁(IVERSION、DVERSION、IQUIESCE 等)
SimpleLock 简单锁,可复制;同一时刻只有一个权威(auth)可写 IAUTH、ILINK、IXATTR、ISNAP、IFLOCK、IPOLICY、DN、IDFT 等
ScatterLock 散布锁,允许多个 rank 各自持有"写"并周期性回收集合(scatter/gather) INEST(递归统计)、IFILE、IDFT
FileLock ScatterLock 的特例,额外处理客户端文件 cap 的授予与回收 IFILE
// 类层次(src/mds/locks.h)
class LocalLock  { ... };
class SimpleLock { ... };
class ScatterLock : public SimpleLock { ... };
class FileLock   : public ScatterLock { ... };

ScatterLock 为什么特殊:目录的递归统计(rstat,如 rbytes/rfiles)会在任意子树中被修改。若强制所有修改都回到 auth,代价极高。散布锁允许多个 rank 各自在本地累加,再定期 gather 回 auth 合并 —— 这就是 LOCK_MIX 状态存在的意义(见 21.4)。

💡 补充:FileLock 之所以要单独成为一类,是因为 IFILE 同时承担两件事:MDS 之间的元数据一致性,以及向客户端授予 CEPH_CAP_FILE_* 能力。它需要把「客户端是否持有写 cap」纳入状态机(见 21.5 的 caps 相关字段),这是其他散布锁不需要的。

21.3 加锁模式(lock mode)

官方列出的模式:

模式 含义 说明
rdlock 读锁 需要稳定地读取被保护字段;允许多个持有者
wrlock 写锁(对 ScatterLock 是"本地累加"语义) 多个 rank 可同时持有(LOCK_MIX),最后 gather
xlock 排他锁 同一时刻只有一个持有者,用于互斥的变更(如 rename 的目标 dentry)
remote_wrlock 远程写锁 当需要 wrlock 的锁其 auth 在别的 rank 上时,向 auth 申请
请求方                         auth 所在 rank
  MDS-A  ── remote_wrlock ──>  MDS-B(auth)
           (请求本地累加权)      (记录谁持有,后续 gather)

💡 补充:remote_wrlock 是散布锁能工作的关键:非 auth 的 rank 要改 rstat,必须先在 auth 上"登记"一个 wrlock,这样 auth 在 gather 时才知道该向谁收数据、以及谁还没回。文档未明确 gather 的触发周期与配置项。

21.4 锁状态(lock state)

官方给出的稳定状态:

状态 含义
LOCK_SYNC 同步态:所有副本与所有持有 cap 的客户端都可以读(最宽松)
LOCK_LOCK 锁态:只有 auth 可以写(读权限已被收回)
LOCK_MIX 混合态:多个 rank 各自持有写(散布锁特有,等待 gather 合并)
LOCK_XLOCK 排他态:某个 rank 持有排他锁

中间(过渡)状态:官方说明其命名规则为 LOCK_<FROM>_<TO>,表示「正在从 <FROM> 迁移到 <TO>」:

LOCK_SYNC_LOCK    从 SYNC 前往 LOCK(正在收回读权限)
LOCK_LOCK_SYNC    从 LOCK 回到 SYNC(正在重新分发读权限)
LOCK_SYNC_MIX     从 SYNC 前往 MIX
LOCK_MIX_SYNC     从 MIX 回到 SYNC(gather 完成)
LOCK_LOCK_MIX     从 LOCK 前往 MIX
LOCK_MIX_LOCK     从 MIX 前往 LOCK
LOCK_SYNC_XLOCK   从 SYNC 直接前往 XLOCK
LOCK_LOCK_XLOCK   从 LOCK 前往 XLOCK
LOCK_XLOCK_SYNC   从 XLOCK 回到 SYNC
LOCK_PREXLOCK     XLOCK 之前的准备态(等待所有其他持有者释放)
LOCK_XLOCKDONE    XLOCK 完成(事务已提交)后的状态

💡 补充:之所以要有明确的中间态,是因为锁迁移是异步的 —— 从 SYNC 到 LOCK 必须先把所有客户端的读 cap 收回,这个"收回"过程需要等待 ACK,期间锁就停在 LOCK_SYNC_LOCK。没有中间态就无法表达"正在收 cap"这件事,也就无法做幂等与超时处理。

21.5 状态机表:sm_state_t

官方指出:每种锁类型都有自己的状态机,由 sm_state_t 表描述,定义在 src/mds/locks.h,状态机实例定义在 src/mds/locks.c。

// src/mds/locks.h(简化示意,字段名保留原文)
struct sm_state_t {
  int   next;                // 迁移目标状态
  bool  can_read;            // 该状态下是否允许本地读
  bool  can_read_projected;  // 该状态下是否允许读"投影"(未提交)值
  bool  can_rdlock;          // 该状态下是否可授予 rdlock
  bool  can_wrlock;          // 该状态下是否可授予 wrlock
  bool  can_xlock;           // 该状态下是否可授予 xlock
  bool  can_lease;           // 该状态下是否可授予租约(lease)
  bool  can_xlease;          // 该状态下是否可授予排他租约
  int   caps;                // 该状态下客户端可持有的 cap 集合
  int   replica_caps;        // 副本 MDS 可持有的 cap 集合
  int   loner_caps;          // 唯一持有者(loner)可持有的 cap 集合
  int   loner;               // 该状态是否允许出现 loner
};

官方对该表三个重要标记的说明:

标记 含义
ANY 该字段取值"任意",即此状态下不关心该字段
AUTH 仅 auth(权威 rank)可以持有该能力
XCL 排他客户端(exclusive client)持有
loner 状态机允许出现"唯一持有者",此时可授予更强的 cap(如独占写)

⚠️ 文档未明确:官方该页对 sm_state_t 的说明带有 TODO(原文注明还需要补充各字段的精确语义与完整取值表)。上表为本册按源码结构整理的字段含义,具体取值请以 src/mds/locks.h 为准。

21.6 实例:CEPH_LOCK_IAUTH 的状态迁移

官方给出了 CEPH_LOCK_IAUTH 的迁移表,并配了一个完整实例。本册按原文整理如下:

迁移表(节选,目标状态与触发条件):

起始状态 目标状态 触发 / 含义
LOCK_SYNC LOCK_SYNC_LOCK 需要独占修改 auth 字段,开始收回共享读权限
LOCK_SYNC_LOCK LOCK_LOCK 所有副本与客户端的读权限已收回 ACK
LOCK_SYNC LOCK_SYNC_XLOCK 无人持有写时,可直接从 SYNC 走向 XLOCK
LOCK_LOCK LOCK_LOCK_XLOCK 已处于 LOCK,进一步申请 xlock
LOCK_LOCK_XLOCK LOCK_PREXLOCK 等待剩余持有者释放
LOCK_SYNC_XLOCK LOCK_PREXLOCK 同上
LOCK_PREXLOCK LOCK_XLOCK 排他锁获取成功
LOCK_XLOCK LOCK_LOCK / LOCK_SYNC 释放 xlock,回退到较宽松状态
LOCK_XLOCK LOCK_XLOCKDONE 事务已提交

完整实例:一次 setattr(改 mode / uid):

1. Server::handle_client_setattr()
      收到客户端的 setattr 请求

2. lov.add_xlock(&cur->authlock)
      在 LockOpVector 中声明:需要对 CInode 的 authlock 加 xlock

3. 锁当前处于 LOCK_SYNC(所有人都能读)

4. LOCK_SYNC -> LOCK_SYNC_LOCK
      向所有副本 MDS 与所有持有读 cap 的客户端发出 revoke

5. LOCK_SYNC_LOCK -> LOCK_LOCK
      收到全部 ACK,现在只有 auth 可写

6. LOCK_LOCK -> LOCK_LOCK_XLOCK
      申请排他锁

7. LOCK_LOCK_XLOCK -> LOCK_PREXLOCK
      等待仍在途的持有者释放

8. LOCK_PREXLOCK -> LOCK_XLOCK
      xlock 获取成功,开始执行 setattr 事务

9. 事务提交 -> LOCK_XLOCKDONE,随后回落到 LOCK_LOCK / LOCK_SYNC
// 对应的加锁意图表达(示意)
LockOpVector lov;
lov.add_xlock(&cur->authlock);          // CInode::authlock 即 CEPH_LOCK_IAUTH
mds->locker->acquire_locks(mdr, lov);   // Locker::acquire_locks 驱动状态机

💡 补充:这个例子说明了 MDS 加锁的两个重要事实 ——(1)加锁是"声明意图 + 驱动状态机",调用方只说"我要 xlock authlock",具体要走几步、要不要 revoke cap,全由 Locker::acquire_locks 根据当前状态查 sm_state_t 决定;(2)一次 setattr 的开销主要来自 cap revoke 的往返,这也是元数据写延迟的主要来源之一。

21.7 加锁顺序与死锁避免

这是本册中最需要如实说明的一点:

⚠️ 官方文档在"加锁顺序 / 路径遍历顺序"处留有 TODO,未给出完整的顺序规则。

本册能从本卷范围内确认的唯一顺序约定,来自 quiesce.rst(第 23 章):

  • 锁的枚举顺序定义在 src/include/ceph_fs.h;
  • CEPH_LOCK_IQUIESCE(quiescelock)排在所有其他锁之前,因此它在 Locker::acquire_locks 中是第一个被获取的;
  • Locker::acquire_locks 会强制自动加入 quiescelock(见第 23 章)。

💡 补充:MDS 侧避免死锁的通用做法(本卷文档未系统说明,仅作提示)—— Locker::acquire_locks 按确定性的顺序(先按锁类型/对象层级排序,再逐个申请)获取锁,而不是按请求到达顺序;拿不到时整体回退重试,而不是持有一部分继续等。这正是"声明式加锁(LockOpVector)+ 一次性 acquire"这一设计的主要动机之一。文档未明确其具体排序算法与回退策略。

21.8 该页的 TODO(官方自述的缺口)

官方 locking.rst 中明确留有 TODO 的位置:

  1. sm_state_t 各字段的精确语义与完整取值尚未写全;
  2. 加锁顺序(lock ordering)与路径遍历顺序(path traversal)尚未写。

引自:MDS Locking


第 22 章 子树导出 / 迁移(exports)

源文:doc/dev/mds_internals/exports.rst(仅 77 行,且两个小节标题为空)

MDS 集群通过把目录子树在不同 rank 之间迁移来做负载均衡。这个过程叫 export(导出方 exporter → 导入方 importer)。官方该页描述的是一次成功导出的完整消息流程;失败恢复部分官方留空。

22.1 流程总览

角色:
  exporter  = 当前权威(auth),要把子树交出去
  importer  = 接收方,之后成为新的权威
  bystander = 持有该子树副本的其他 MDS(非权威)

阶段:
  (0) export_dir() 前置检查
  (1) DISCOVER     exporter <-> importer   探测/确认边界
  (2) PREP         exporter -> importer    冻结树、打开边界目录
  (3) WARNING      exporter -> bystander   宣告"权威即将转移"(权威模糊期)
  (4) EXPORT       exporter -> importer    传输实际缓存内容
  (5) ACK / EExport                        落 journal,权威确定
  (6) NOTIFY+ACK   exporter -> bystander   宣告"新权威已生效"
  (7) FINISH       exporter -> importer    解冻、收尾

22.2 步骤 0:export_dir() 的前置检查

检查项 要求
集群状态 不能处于 degraded
根目录 待导出目录的根不能被 freeze / freezing
pin 该路径不能被 pinned(被 pin 的路径不允许迁移)

💡 补充:这三条检查都是在保护「迁移过程中不能让元数据不一致」—— degraded 时无法保证 bystander 可靠;根被冻结说明有并发的冻结操作(如快照);被 pin 的路径(如某个客户端显式 pin、或正在被某些操作使用)迁走会让持有者失效。

22.3 步骤 1:DISCOVER —— MExportDirDiscover / Ack

exporter ──MExportDirDiscover──> importer
exporter <──Ack───────────────── importer
  • 目的:让 importer 预先发现(discover) 这棵子树的边界,并建立必要的 inode / dir 结构。
  • importer 在这一步要沿路径把相关对象载入缓存。

22.4 步骤 2:PREP —— MExportDirPrep

exporter ──MExportDirPrep──> importer

官方强调的三个要点:

  1. 生成树(spanning tree):exporter 要迁移的是一棵子树,消息内容按生成树组织。
  2. 由 exporter 推送(pushed by exporter):PREP 是 exporter 主动推给 importer 的,而不是 importer 来拉。官方明确说明这样做是为了 avoid deadlock(避免死锁) —— 如果双方都在等对方先动,就会互锁。
  3. importer 收到 PREP 后:打开边界目录(open bounding dirs)并冻结(freeze) 它们。

💡 补充:所谓 bounding dirs,是指被导出子树的边界——子树内部的目录可以整体搬走,但子树根与外界相连的那一层,其父目录链路必须被 importer 打开(否则 importer 无法建立到根的链接),并且要在传输期间冻结(防止并发修改造成内容漂移)。

22.5 步骤 3:WARNING —— MExportDirNotify(权威模糊期)

exporter ──MExportDirNotify(WARNING)──> bystander
  • 这一步开启了一个 ambiguous authority(权威模糊) 的窗口:从这一刻起,bystander 不能确定 exporter 与 importer 谁才是权威。
  • 官方说明 bystander 的应对:向 exporter 与 importer 双方都发送 MCacheExpire。
                 ┌──────── exporter ────────┐
                 │  MExportDirNotify(WARNING)│
                 └───────────┬──────────────┘
                             v
                        bystander
                        │        │
        MCacheExpire ───┘        └─── MCacheExpire
           v                              v
       exporter                       importer

💡 补充:这是分布式权威转移的经典处理 —— 与其让 bystander 猜测谁是权威,不如让它在模糊期内对两边都宣告"我的副本过期了",这样无论最终谁成为权威,都能得到正确的失效通知。代价是多发一条消息,换来的是无需额外的仲裁协议。

22.6 步骤 4:MExportDir —— 实际传输缓存内容

exporter ──MExportDir──> importer

importer 的动作序列:

  1. 把收到的缓存内容 insert into cache(插入自己的 MDS cache);
  2. 写 journal 记录 EImportStart;
  3. 回 MExportDirAck 给 exporter;
  4. exporter 写 journal 记录 EExport。

EExport 的作用(官方明确):它用于在恢复(recovery)期间消除权威的二义性 —— 只要 exporter 的 journal 里有 EExport,就能确定权威已经转移给了 importer。

importer:  insert cache  ->  journal(EImportStart)  ->  MExportDirAck
exporter:                                              journal(EExport)

💡 补充:这两个 journal 记录是双向的记账:importer 记"我开始 import 了",exporter 记"我已经 export 出去了"。重放 journal 时,只要任一方存在对应记录,就能判定权威归属,从而避免"两边都不管"或"两边都管"。

22.7 步骤 5:NOTIFY —— 宣告新权威

exporter ──MExportDirNotify──> bystander
exporter <──Ack─────────────── bystander
  • bystander 确认后,exporter **解冻(unfreeze)**之前冻结的目录。

22.8 步骤 6:FINISH

exporter ──MExportDirFinish──> importer
importer:  journal(EImportFinish(true))

EImportFinish(true) 表示这次 import 成功完成。

22.9 完整消息时序图

exporter                importer              bystander
   |  export_dir() 前置检查(非 degraded / 根未冻结 / 未 pin)
   |--- MExportDirDiscover ---->|                  |
   |<--------- Ack -------------|                  |
   |--- MExportDirPrep -------->|                  |
   |                        (打开边界目录并 freeze) |
   |--- MExportDirNotify(WARNING)----------------> |
   |                                     |  MCacheExpire -> exporter
   |                                     |  MCacheExpire -> importer
   |--- MExportDir ------------>|                  |
   |                     insert cache              |
   |                     journal(EImportStart)     |
   |<-------- MExportDirAck ----|                  |
   | journal(EExport)                              |
   |--- MExportDirNotify -------------------------->|
   |<------------------------------ Ack -----------|
   | unfreeze                                       |
   |--- MExportDirFinish ------->|                  |
   |                     journal(EImportFinish(true))

22.10 官方留空的部分

⚠️ exports.rst 中有 两个小节标题存在但正文为空:

  1. PARTIAL FAILURE RECOVERY(部分失败恢复)—— 官方未写任何内容。
  2. RECOVERY FROM JOURNAL(从 journal 恢复)—— 官方未写任何内容。

也就是说:导出过程中途失败(如 exporter 崩溃、importer 超时、网络分区)该如何通过 journal 恢复,官方文档在本页完全没有说明。 本册只能如实标注「文档未明确」。

引自:MDS Subtree Export


第 23 章 Quiesce(静默机制)

源文:doc/dev/mds_internals/quiesce.rst(120 行)

Quiesce(静默) 的目标:让一棵子树在一段时间内静止 —— 没有客户端正在修改它,从而可以安全地做一致性操作(如快照、备份)。

23.1 请求模型

quiesce_path(一次内部请求)
   ├── 锁住该子树的根(tree root)
   └── 派生多个 quiesce_inode 子请求,分发到树中的各个 inode
概念 说明
quiesce_path 一次静默请求的入口,作用于路径(子树)
根锁定 首先锁住子树根
quiesce_inode 针对单个 inode 的静默子请求,由 quiesce_path 派生

官方指出,静默要处理的是客户端可能持有写能力这件事。相关的四把锁:

锁 全称 作用
filelock CEPH_LOCK_IFILE 文件数据/尺寸相关 cap
authlock CEPH_LOCK_IAUTH mode/uid/gid 相关 cap
linklock CEPH_LOCK_ILINK 链接关系相关 cap
xattrlock CEPH_LOCK_IXATTR 扩展属性相关 cap

23.3 新引入的 quiescelock

官方说明引入了一把新的本地锁(local lock):

属性 说明
常量 CEPH_LOCK_IQUIESCE
类 LocalLock(本地锁,不跨 rank 复制)
性质 superlock(超级锁)
顺序 按 src/include/ceph_fs.h 的枚举顺序,它排在所有其他锁之前
强制加入 Locker::acquire_locks 会自动加入 quiescelock,调用方无需显式声明
蕴含关系 对 cap 相关锁(filelock / authlock / linklock / xattrlock)加 wrlock 或 xlock,蕴含对 quiescelock 加 wrlock
加锁顺序(src/include/ceph_fs.h 枚举顺序):

  CEPH_LOCK_IQUIESCE   <- 最前(superlock)
  CEPH_LOCK_IVERSION
  CEPH_LOCK_IAUTH
  CEPH_LOCK_ILINK
  CEPH_LOCK_IDFT
  CEPH_LOCK_IFILE
  CEPH_LOCK_INEST
  CEPH_LOCK_IXATTR
  CEPH_LOCK_ISNAP
  CEPH_LOCK_IFLOCK
  CEPH_LOCK_IPOLICY
  ...

蕴含规则:
  wrlock/xlock(filelock | authlock | linklock | xattrlock)
        ==>  wrlock(quiescelock)

💡 补充:这个"蕴含"设计非常关键 —— 它意味着任何可能修改元数据的操作,都会自动在 quiescelock 上留下写痕迹。因此只要 quiesce 能拿到 quiescelock 的 xlock,就等价于"当前没有任何人在改这棵树",无需逐个检查四把 cap 锁的状态。

23.4 关键约束:避免跨 rank 死锁

官方明确要求:在拿到 cap 相关锁之后,必须把它们释放掉,否则会造成跨 rank 死锁。

正确做法:
  1. 申请 cap 相关锁(如 xattrlock)并推进状态
  2. 一旦 quiescelock 的 wrlock 已获得,就释放 xattrlock
  3. 只持有 quiescelock 继续后面的流程

错误做法:
  持有 xattrlock 不放,同时等待其他 rank 上的锁 -> 跨 rank 死锁

官方给出的实例(xattrlock 的状态迁移):

xattrlock: LOCK_SYNC -> LOCK_SYNC_EXCL
  1. 发起迁移,收回客户端的共享读 cap
  2. 状态到达 LOCK_EXCL(排他)
  3. 此时 quiescelock 的 wrlock 语义已满足
  4. 释放 xattrlock(回落到 LOCK_SYNC 或保持,视实现而定)
  5. 后续只依赖 quiescelock

⚠️ 文档未明确:释放后 xattrlock 的最终状态(是否回落 LOCK_SYNC)、以及 CAP 重新授予的时机。

23.5 与 snaplock / policylock 的关系

官方特别强调一条阻塞规则:

不要在持有通过 Locker::try_rdlock_snap_layout 获得的 snaplock 或 policylock 时阻塞。

原因(官方给出的理由):

  • mksnap(创建快照)只会对 snaplock 加 wrlock;
  • 如果 quiesce 长时间持着 snaplock 的读锁不放,mksnap 就永远无法进行。
mksnap 需要的:  wrlock(snaplock)

若 quiesce 持有 rdlock(snaplock) 并阻塞等待,
则 mksnap 的 wrlock 永远排不上 -> 快照功能被饿死

💡 补充:这是一个典型的"读锁饥饿写锁"问题。官方的解法是规定 quiesce 在需要等待时先放下这类锁,等条件满足再重新获取(即第 23.6 节的 mkdir 流程)。

23.6 mkdir 流程中的处理

官方以 mkdir 为例说明如何安全等待:

1. mkdir 请求进入
2. 发现需要等待某个条件(如 quiesce 未完成)
3. 释放当前持有的**所有**锁(drop all held locks)
4. 等待条件满足
5. 条件满足后,重新走一遍完整的加锁流程

💡 补充:这是 MDS 里普遍的"回退重试"模式 —— 与其持有锁等待(风险高、易死锁),不如完全放弃已获资源、重新排队。代价是重复一次加锁开销,收益是不会出现持锁等待造成的环路。

23.7 lookup / readdir 路径上的检查

官方说明在 lookup 与 readdir 时也要配合静默:

lookup / readdir 处理:
  1. 检查父目录的 quiescelock 上是否有 xlock
  2. 若有(说明该子树正在被静默),则派发一个 quiesce_inode 请求

💡 补充:这保证了"正在被访问的目录也会被纳入静默范围" —— 静默不是只对缓存中已有的 inode 生效,任何在静默期间被触及的 inode 都会被补上 quiesce_inode。

23.8 与子树 import 的交互

官方描述了一个具体的失败场景:

1. 子树正在被 import(见第 22 章)
2. importer 在 discover 阶段发现:根 inode 所在的目录已被 quiesce
3. importer 跳过(skip)对该根 inode 的 discovery
4. importer 回 NAK
5. exporter 收到 NAK,中止(abort)这次导出

💡 补充:这体现了两条机制的优先级 —— 静默优先于迁移。已被静默的子树不允许被搬走,否则静默的"锁定"语义会被迁移打破。

23.9 相关代码入口

位置 内容
CInode::get_caps_* 获取 inode 上各类 cap 持有情况的辅助方法,静默判定会用到
Locker::acquire_locks 自动加入 quiescelock、驱动加锁状态机
Locker::try_rdlock_snap_layout 尝试获取 snaplock / policylock 读锁(不可在其上阻塞)
src/include/ceph_fs.h CEPH_LOCK_* 的枚举顺序 = 加锁顺序

23.10 高层 API:QuiesceDb

官方提到 QuiesceDb 是面向客户端的高层 API(high-level client API),但明确指出它 out of scope(不在本页范围内)。

⚠️ 文档未明确:QuiesceDb 的数据模型、操作接口、持久化方式与故障恢复语义,在 mds_internals/ 下均没有对应页面。

引自:Quiesce


文档未明确项

本节汇总本册撰写过程中,官方文档没有说明或说明不完整的地方。凡是本册正文里出现的「文档未明确」与 ⚠️,都可以在这里找到归口。

A. index 类页面(仅有 toctree,无正文)

页面 行数 实际内容
osd_internals/index.rst 11 仅 toctree,无任何描述性正文
mds_internals/index.rst 11 仅 toctree,无任何描述性正文

→ 文档未明确:官方没有为这两个目录写「总览 / 阅读指引 / 各页之间的关系」。本册的「本册导读」与章节串联结构,是为弥补这一缺口而由撰稿人补充的(已按规范以 > 💡 补充: 标注)。

B. OSD 侧各页的缺口

页面 行数 官方覆盖到什么程度 未明确的内容
pg.rst 32 仅介绍 PG 类的职责、PG 与 OSDService 的关系 PG 类的字段清单、状态机入口表、与 PeeringState 的分工边界,官方均未展开
pgpool.rst 23 仅介绍 PGPool 的共享语义与 removed_snaps 缓存 PGPool 的完整字段、更新触发点、update() 的全部分支,未给出
scrub.rst 42 仅给出 S/D/X 与 noscrub/nodeep_scrub 的行为矩阵、几个状态变量与 dump 命令 scrub 的调度器、chunk 划分、scrub 状态机(PgScrubber)各状态、reservation 交互、错误处理与修复动作,全部未说明
refcount.rst 46 只列出 cls_refcount_get/_put/_Set/_read 四个接口 implicit_ref=false 默认值的确切语义、并发语义、与 watcher 的关系,官方明确未写
wbthrottle.rst 29 介绍 filestore 时代的写回节流 该页描述的是已被 BlueStore 取代的 filestore 机制;官方未说明 BlueStore 下对应机制是什么、是否仍生效
async_recovery.rst 54 讲清了 Nautilus 前后差异与 osd_async_recovery_min_cost cost 的精确计算方式(官方只说"用日志长度近似")、异步目标集合的选择算法,未给出
past_intervals.rst 113 讲清了 interval 概念与 OSDMap 裁剪下界 PastIntervals 的完整字段与序列化格式未给出
last_epoch_started.rst 61 讲清了 local/global 语义与更新顺序不变式 不变式的形式化证明、所有更新点的完整清单,未给出
pg_removal.rst 57 讲清了两个触发点、DeletingState 5 个状态与取消语义 取消后的重试策略、失败时的行为、与 PG 重建(同 pgid 再次出现)的竞态,未说明
stale_read.rst 103 讲清了租约计算、时钟差界、LAGGY/WAIT 状态 osd_pool_default_read_lease_ratio 之外可调的项、超时后的具体客户端可观测行为,未给出
manifest.rst 592 最详细的一页 evict-chunk 操作官方明确标注"尚不存在";dedup 的实际调优建议与收益数据未给出
map_message_handling.rst 132 讲清了 3 条顺序要求与 MOSDMap 的 6 项任务 未给出与 PeeringState 状态机的完整对接表

C. 纠删码(EC)部分的缺口

位置 未明确的内容
erasure_coding.rst(89 行) 术语表完整,但未给出 EC 的整体 I/O 路径图;指向子目录各页
developer_notes.rst(224 行) 插件 factory() 的完整签名与生命周期、插件的卸载/热替换语义未说明
jerasure.rst(36 行) 7 种 technique 的性能对比数据未给出;官方提示 jerasure.org 自 2023 年起存在访问/维护警告,但未说明推荐替代实现
ecbackend.rst(207 行) Pipeline 不变式("no uncacheable and rmw at same time")的证明与违反时的行为未说明;EC 下 omap 的存储与分布未说明(与 proposals 的 deep-scrub 条目相关)
enhancements.rst(1518 行) 官方自述为设计文档,含 29 个 Stories;部分条目的方案未定稿(如元数据方案的两种取舍中,一种被采纳、一种被否决);升级 flag 不可逆官方只声明结论未给出规避方案
proposals.rst(386 行) 该页自述为 orphan(孤儿文档),即不保证与当前实现一致。其中 parity-delta-write、stripe cache、rollback-able prepare、partial application、deep scrub、ACK 优化、version vector 全部是提案,均无定稿结论;文末 2 个 OPEN QUESTIONS 明确未决
整体 官方在 EC 相关页中提到"三种 data flow",但本册可读到的目录中只列出 2 种(whole stripe write 直接写、RMW),第三种未明确

D. mClock 研究页的版本问题

项 说明
版本不一致(最重要) mclock_wpq_cmp_study.rst 的测试数据与结论来自 Ceph Quincy 时期的开发研究,不是 Tentacle v20.2.4 的当前实现。本册已在该章开头显著标注。读者不应把其中的 IOPS 数值当作 Tentacle 的性能基线。
测试环境 官方给出的基线为 NVMe 约 21500 IOPS、HDD+WAL/DB 约 340、HDD 无 WAL/DB 约 315,但未给出具体的硬件型号、队列深度、块大小、客户端并发数的完整矩阵
图表缺失 该页在 rst 源文中引用的图表文件不在本册输入范围内,因此本册只能用文字与表格复述数值,无法还原原图
测试参数 测试使用了 osd_max_backfills=1000、osd_recovery_max_active=1000、osd_async_recovery_min_cost=1 这类极端值,官方未说明为何选择这些值、以及常规配置下的外推方法
结论适用范围 官方未说明该结论在 BlueStore + 不同 profile(balanced / high_recovery_ops / custom)组合下的可迁移性

E. MDS 侧的缺口(覆盖度最低)

位置 未明确的内容
locking.rst 官方自述 2 处 TODO:(1) sm_state_t 各字段的精确语义与完整取值表未写全;(2) 加锁顺序(lock ordering)与路径遍历顺序(path traversal)未写。本册只能用 > 💡 补充: 给出通用做法提示,不能替代官方口径。
exports.rst 两个小节标题存在但正文完全为空:PARTIAL FAILURE RECOVERY 与 RECOVERY FROM JOURNAL。即导出中途失败的恢复流程官方完全没有说明。
quiesce.rst 官方明确指出 QuiesceDb(高层客户端 API)out of scope,而 mds_internals/ 下没有任何页面描述它。xattrlock 释放后的最终状态与 cap 重授时机也未说明。
data-structures.rst 仅 45 行,只给了三个类的体量与链接基数关系。未覆盖:MDCache 组织、cache 分片与 LRU、inode/snapid 分配、MDSTable 家族、frag_t 分片算法、各类的字段清单。
整目录 mds_internals/ 只有 5 个文件(1 个 index + 4 个正文页)。以下 MDS 核心主题没有任何官方页面:journal 机制、MDSTable 家族(InoTable / SnapTable / SessionMap)、cap(capability)的授予与回收协议、MCacheExpire 与租约、目录分片(fragmentation)算法、MDS scrub / ScrubStack、MDS 的 rank 与 standby 状态机、配额(quota)实现、快照服务端实现。

F. 跨页 / 跨目录的缺口

  1. OSD 与 MDS 的共同机制:两部分的文档没有交叉引用说明(例如 MDS 依赖的 RADOS 对象模型、OSD 侧如何感知 MDS 的 quiesce)。
  2. 配置项全集:本册涉及的 OSD 配置项(osd_max_backfills、osd_async_recovery_min_cost、osd_pool_default_read_lease_ratio、osd_min_pg_log_entries、osd_max_pg_log_entries、osd_backfill_retry_interval、osd_target_transaction_size 等)官方文档在这些页面里没有统一的默认值清单,部分默认值需以运行时 ceph config show 或源码为准。
  3. 版本号口径:mclock_wpq_cmp_study.rst 为 Quincy 时期研究(见 D 节),而本册其余页面按 Tentacle v20.2.4 口径整理;读者需注意两者不可混用。
  4. 代码路径的时效:本册引用的文件名与类名(src/osd/*、src/mds/*)来自文档正文的原文描述;文档未明确它们与 v20.2.4 源码的行级对应关系,实际阅读源码时请以本地 checkout 为准。
  5. enhancements.rst 已分段复校完毕:该页共 1517 行 / 约 83 KB,此前因单次读取超出工具输出上限而标注「需分段复校」。本册已按 1–400 / 401–800 / 801–1200 / 1201–1517 四段完整重读源文,并与第 19.5 节逐小节核对:源文的 Objectives、Current Read / Partial Reads、Current Overwrite / Partial Overwrites、Parity-delta-write、Direct Read I/O、Distributed processing of writes、Direct Write I/O、Larger chunk size、Design(Test tools / I/O path changes / Metadata changes)、Upgrade and backwards compatibility、Not under consideration、Stories(29 条) 全部已覆盖,无缺失小节;本轮另补入 19.5.17「新增消息 / 接口 / 标志速查」。该页不再需要二次校对。

本册结束。上一册(第 16 册)为内部实现篇(一),下一册(第 18 册)为内部实现篇(三),第 19 册为内部实现篇(四)。本册覆盖 OSD 与 MDS 两个方向的内部机制,共 23 章,源文 32 个 .rst 文件(27 + 5)。