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

5695 lines
300 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
> 基于 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` —— 处理客户端流量(公网)
```text
+---------------------------+
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`**。
```text
线程 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` | 命令 |
```text
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](https://docs.ceph.com/en/tentacle/dev/osd_internals/osd_overview/)
---
## 第 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`。
```text
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(读和写都不行)**。
```text
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](https://docs.ceph.com/en/tentacle/dev/osd_internals/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` 对象。
```text
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 列表**
```text
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](https://docs.ceph.com/en/tentacle/dev/osd_internals/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`。)
```text
已有 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` 中跟踪这些值。
```text
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`。
```text
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
```text
使用 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](https://docs.ceph.com/en/tentacle/dev/osd_internals/past_intervals/)
---
## 第 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 的写」的上界。**
```text
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」。
```text
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` 的实际输出例子:
```text
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`,那么我们就不可能服务过读
```text
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](https://docs.ceph.com/en/tentacle/dev/osd_internals/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」更新的:不存在(日志里所有写都确实被启动过,记住它们是安全的)
```text
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 份旧版本。
```text
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](https://docs.ceph.com/en/tentacle/dev/osd_internals/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 的配方。
```text
同步恢复 (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 所做的那样),以便目标能够**完整追上**
```text
┌──────────────────────────┐
客户端写 ───► │ acting set (可正常服务) │
└────────────┬─────────────┘
│ 同时把 log entries 转发
v
┌──────────────────────────┐
│ async recovery target │ ← 在 acting set 之外
└──────────────────────────┘
```
### 6.6 注意事项
- 异步恢复是**可用性优于严格同步语义**的折衷:它让写不被长时间阻塞,代价是需要把新写持续转发给恢复目标。
- 判定条件中的「日志长度差」是**近似**的,不是精确的;不要依赖它做精确容量规划。
- `osd_async_recovery_min_cost` 是唯一的显式开关参数;其余判据是内建的。
> 引自:[Asynchronous Recovery](https://docs.ceph.com/en/tentacle/dev/osd_internals/async_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 操作是分别施加的**。
```text
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**。
```text
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` 的子状态)。
```text
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 | — |
```text
优先级带位图
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](https://docs.ceph.com/en/tentacle/dev/osd_internals/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 预留槽位全部授予出去了,批准**可能需要等一段时间**
```text
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. **会无限重试**
```text
请求 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**
```text
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 预留:
```bash
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](https://docs.ceph.com/en/tentacle/dev/osd_internals/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 会被裁剪掉**。
官方例子:
```text
设 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`**
```text
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`**。
官方例子:
```text
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`。
官方例子:
```text
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**。
**因此,本情况应执行**:
```text
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(未修改数据)**:由于对象是新建的,且**未修改的数据不会被传输**。因此,**我们需要从原对象克隆未修改的数据**。
**因此,本情况应执行**:
```text
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](https://docs.ceph.com/en/tentacle/dev/osd_internals/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 表格解读
```text
纵轴 = 触发方式;横轴 = 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 出来:
```bash
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](https://docs.ceph.com/en/tentacle/dev/osd_internals/scrub/)
---
## 第 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」之间的那些快照上的读
```text
上次 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**。
**执行步骤**:
```text
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 一起持久化
```
```text
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](https://docs.ceph.com/en/tentacle/dev/osd_internals/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 **原子地递增**引用计数。
```cpp
void cls_refcount_get(librados::ObjectWriteOperation& op, const string& tag, bool implicit_ref = false);
```
#### 11.2.2 `cls_refcount_put`
**作用**:以传入的 tag **原子地递减**引用计数。
```cpp
void cls_refcount_put(librados::ObjectWriteOperation& op, const string& tag, bool implicit_ref = false);
```
#### 11.2.3 `cls_refcount_Set`
**作用**:以传入的 tag 列表**原子地设置**引用计数集合。
```cpp
void cls_refcount_set(librados::ObjectWriteOperation& op, list<string>& refs);
```
#### 11.2.4 `cls_refcount_read`
**作用**:dump 出该对象当前的 ref tag 集合。
```cpp
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](https://docs.ceph.com/en/tentacle/dev/osd_internals/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` 操作时
```text
ObjectContext ──(strong ref)──> Watch
^ │
└────────(ref)───────────────┘
循环引用,故意为之
打破时机:新 peering interval / 超时 / unwatch
```
#### 12.2.2 连接跟踪
- 一个 watch 通过**强引用 `ConnectionRef Watch::conn`** 跟踪其关联的连接
- 该关联连接在 **`OSD::Session`** 中存放了一个 **`WatchConState`**,用于跟踪关联的 Watch,以便能够在 **`ms_handle_reset()`** 时通知它们(通过 **`WatchConState::reset()`**)
```text
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 对象**
```text
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 注册任何状态。
```text
(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
```text
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](https://docs.ceph.com/en/tentacle/dev/osd_internals/watch_notify/)
---
## 第 13 章 Writeback Throttle(`wbthrottle.rst`)
### 13.1 设计动机(历史问题)
过去,filestore 在处理**大量小 I/O** 时有一个问题:
- 我们通过 journal **隐式地**对脏数据做节流
- 但是**大量 inode 可以在不填满 journal 的情况下被弄脏**,结果是「当 sync 最终发生时,sync 时间非常长」
**为什么 flusher 不够用**:
> flusher 并不是这个问题的充分解决方案,因为它**过于急切地强制回写小写入**,反而扼杀了性能。
```text
问题链:
大量小 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()`**
```text
三个计数器: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](https://docs.ceph.com/en/tentacle/dev/osd_internals/wbthrottle/)
---
## 第 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 可以基于自己的本地时钟分享时间戳**,而**副本把该时间戳翻译为自己本地时钟下的一个合适的界**
```text
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)**
```text
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)可以在此状态下继续进行**,因为**对象的逻辑、用户可见内容不发生改变**
```text
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 状态**。
```text
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](https://docs.ceph.com/en/tentacle/dev/osd_internals/stale_read/)
---
## 第 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)到慢池**
```text
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)到快池**
```text
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`,我们应该返回错误**: |
```cpp
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 官方给出的完整推演例子
```text
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**:
```text
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`):
```cpp
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`**。
```cpp
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`** 选项:
```bash
ceph osd pool set $BASE_POOL fingerprint_algorithm sha1|sha256|sha512
--yes-i-really-mean-it
```
创建对象:
```bash
rados -p base_pool put foo ./foo
rados -p chunk_pool put foo-chunk ./foo-chunk
```
创建一个 manifest 对象:
```bash
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`**。
```cpp
void set_redirect(const std::string& tgt_obj, const IoCtx& tgt_ioctx,
uint64_t tgt_version, int flag = 0);
```
```bash
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` 之间建立链接。
```cpp
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);
```
```bash
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:
```cpp
if (!oi.manifest.is_chunked()) {
oi.manifest.clear();
}
```
**C. `evict-chunk`**
从一个对象中清除一个 extent,**只留下它与 `target_object` 之间的 manifest 链接**。
```cpp
void evict_chunk(
uint64_t offset, uint64_t length, int flag = 0);
```
```bash
rados -p base_pool evict-chunk <offset> <length> <object>
```
| 行为 | 说明 |
|------|------|
| 返回 `EINVAL` | 如果该 extent 不在 manifest 中 |
| 状态 | **⚠️ 官方明确:Note: this does not exist yet.(这个操作目前还不存在)** |
**D. `tier-promote`**
Promote 该对象,确保**后续的读与写都是本地的**。
```cpp
void tier_promote();
```
```bash
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 信息。
```cpp
void unset_manifest();
```
```bash
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**。
```cpp
void tier_flush();
```
```bash
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` 的同时多次运行**以下命令:
```bash
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 边界
```bash
ceph-dedup-tool --op estimate --pool $POOL --min-chunk min_size
--chunk-algorithm rabin --fingerprint-algorithm rabin
```
**rabin chunk 的可用选项**:
```bash
--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 选项:
```text
rabin_hash =
(rabin_hash * rabin_prime + new_byte - old_byte * pow) % (mod_prime)
```
**c. Fixed chunk vs content-defined chunk**
官方给出的对比例子:
```text
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`**:
```bash
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](https://docs.ceph.com/en/tentacle/dev/osd_internals/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 必须执行若干任务:
```text
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 处理流程
```text
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` |
```text
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](https://docs.ceph.com/en/tentacle/dev/osd_internals/map_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 以移除对象是一项昂贵的操作**。
```text
步骤 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**。
```text
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` | 该删除已被取消 |
```text
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 上的操作被正确排序**。
```text
旧 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](https://docs.ceph.com/en/tentacle/dev/osd_internals/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)
```text
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](https://docs.ceph.com/en/tentacle/dev/osd_internals/mclock_wpq_cmp_study/)
---
## 第 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 官方图示(原样转录)
```text
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](https://docs.ceph.com/en/tentacle/dev/osd_internals/erasure_coding/)
### 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* 上。
```text
+-------------------+
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 中没有实现。**
```text
+-------------------+
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 示例**:
```bash
$ 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` 插件代码:
```cpp
ErasureCodePluginRegistry &instance =
ErasureCodePluginRegistry::instance();
instance.add(plugin_name, new ErasureCodePluginExample());
```
**工厂方法**:派生自 `ErasureCodePlugin` 的对象必须提供一个 **factory 方法**,从中可以生成 `ErasureCodeInterface` 对象的具体实现:
```cpp
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` 对**。
```bash
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](https://docs.ceph.com/en/tentacle/dev/osd_internals/erasure_coding/developer_notes/)
### 19.3 jerasure plugin(`jerasure.rst`)
#### 19.3.1 参数
`jerasure` 插件解释的参数:
```bash
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 实现来源与上游关系
- **`src/erasure-code/jerasure`** 目录包含其实现
- 它是对 https://github.com/ceph/jerasure 与 https://github.com/ceph/gf-complete 中代码的**一层包装(wrapper)**,这两个仓库在 **`.gitmodules`** 中被**固定(pinned)到最新的稳定版本**
- 这两个仓库是上游仓库 http://jerasure.org/jerasure/jerasure 与 http://jerasure.org/jerasure/gf-complete 的**副本**
- 两者之间(如果有)的差异**应当与针对上游的 pull request 相匹配**
> ⚠️ **官方原文警示**:**注意,截至 2023 年,`jerasure.org` 网站可能已不再合法/或已与原项目无关。**
> 💡 补充:这一警示提示读者在查阅 jerasure 上游资料时,应优先使用 `github.com/ceph/jerasure` 与 `github.com/ceph/gf-complete` 这两个 Ceph 官方 fork,而不是 jerasure.org。
> 引自:[jerasure plugin](https://docs.ceph.com/en/tentacle/dev/osd_internals/erasure_coding/jerasure/)
### 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 报告校验和是否匹配**
```text
副本池 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**
```text
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)**
```text
(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`**)。
```text
阶段 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>`**。
```text
不变量:同一对象上,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](https://docs.ceph.com/en/tentacle/dev/osd_internals/erasure_coding/ecbackend/)
### 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(当前读实现)
```text
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」。
```text
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(当前覆盖写实现)
```text
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 上做元数据更新则要困难得多**(参见关于元数据更新的设计一节)。
```text
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**。
```text
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 延迟**。
```text
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 读**:
```text
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 上以减少网络带宽**。
```text
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(直接写)
```text
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 可以有多种方式,这可以被用来检测最可能的不一致原因**。
官方例子:
```text
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) | 小对象容量开销高 |
官方给出的容量开销例子:
```text
一个 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 不再被计算或检查**。这将在对象被覆盖时提升性能,并在对象未被覆盖时提升数据完整性
```text
修正后:
顺序写 → 计算并维护 CRC(hinfo xattr)
第一次覆盖写 → 丢弃 hinfo xattr,不再计算/检查 CRC
```
2. **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"(最常见的损坏类型)上会非常成功**
```text
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 代码可以保持不变**。
```text
被采纳方案: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 的直接写、带全部控制消息」的完整图景:
```text
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 条理由):
```text
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](https://docs.ceph.com/en/tentacle/dev/osd_internals/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**。
```text
现状 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 机制。
**基本思路(两阶段 + 临时对象)**:
```text
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)**替代单一版本号:
```text
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](https://docs.ceph.com/en/tentacle/dev/osd_internals/erasure_coding/proposals/)
---
至此,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)。
```cpp
// 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 CDentry`s),即硬链接 |
| `CInode` → `CDir` | 只有**目录 inode** 才有 `CDir` |
| 分片目录 | 一个被分片(fragmented)的目录 inode 会有**多个** `CDir` 对象 |
```text
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 逐级向上拼接 |
```text
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](https://docs.ceph.com/en/tentacle/dev/mds_internals/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` |
```cpp
// 类层次(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 申请 |
```text
请求方 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>`」:
```text
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`。
```cpp
// 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)**:
```text
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
```
```cpp
// 对应的加锁意图表达(示意)
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](https://docs.ceph.com/en/tentacle/dev/mds_internals/locking/)
---
## 第 22 章 子树导出 / 迁移(exports)
> 源文:`doc/dev/mds_internals/exports.rst`(**仅 77 行**,且两个小节标题为空)
MDS 集群通过把**目录子树**在不同 rank 之间迁移来做负载均衡。这个过程叫 **export**(导出方 exporter → 导入方 importer)。官方该页描述的是**一次成功导出**的完整消息流程;**失败恢复部分官方留空**。
### 22.1 流程总览
```text
角色:
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
```text
exporter ──MExportDirDiscover──> importer
exporter <──Ack───────────────── importer
```
- 目的:让 importer **预先发现(discover)** 这棵子树的边界,并建立必要的 inode / dir 结构。
- importer 在这一步要沿路径把相关对象载入缓存。
### 22.4 步骤 2:PREP —— `MExportDirPrep`
```text
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`(权威模糊期)
```text
exporter ──MExportDirNotify(WARNING)──> bystander
```
- 这一步开启了一个 **ambiguous authority(权威模糊)** 的窗口:从这一刻起,bystander **不能确定** exporter 与 importer 谁才是权威。
- 官方说明 bystander 的应对:**向 exporter 与 importer 双方都发送 `MCacheExpire`**。
```text
┌──────── exporter ────────┐
│ MExportDirNotify(WARNING)│
└───────────┬──────────────┘
v
bystander
│ │
MCacheExpire ───┘ └─── MCacheExpire
v v
exporter importer
```
> 💡 补充:这是分布式权威转移的经典处理 —— 与其让 bystander 猜测谁是权威,不如让它在模糊期内**对两边都宣告"我的副本过期了"**,这样无论最终谁成为权威,都能得到正确的失效通知。代价是多发一条消息,换来的是无需额外的仲裁协议。
### 22.6 步骤 4:`MExportDir` —— 实际传输缓存内容
```text
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。
```text
importer: insert cache -> journal(EImportStart) -> MExportDirAck
exporter: journal(EExport)
```
> 💡 补充:这两个 journal 记录是**双向的记账**:importer 记"我开始 import 了",exporter 记"我已经 export 出去了"。重放 journal 时,只要任一方存在对应记录,就能判定权威归属,从而避免"两边都不管"或"两边都管"。
### 22.7 步骤 5:NOTIFY —— 宣告新权威
```text
exporter ──MExportDirNotify──> bystander
exporter <──Ack─────────────── bystander
```
- bystander 确认后,exporter **解冻(unfreeze)**之前冻结的目录。
### 22.8 步骤 6:FINISH
```text
exporter ──MExportDirFinish──> importer
importer: journal(EImportFinish(true))
```
`EImportFinish(true)` 表示这次 import **成功完成**。
### 22.9 完整消息时序图
```text
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](https://docs.ceph.com/en/tentacle/dev/mds_internals/exports/)
---
## 第 23 章 Quiesce(静默机制)
> 源文:`doc/dev/mds_internals/quiesce.rst`(120 行)
**Quiesce(静默)** 的目标:让一棵子树在一段时间内**静止** —— 没有客户端正在修改它,从而可以安全地做一致性操作(如快照、备份)。
### 23.1 请求模型
```text
quiesce_path(一次内部请求)
├── 锁住该子树的根(tree root)
└── 派生多个 quiesce_inode 子请求,分发到树中的各个 inode
```
| 概念 | 说明 |
| --- | --- |
| `quiesce_path` | 一次静默请求的**入口**,作用于路径(子树) |
| 根锁定 | 首先锁住子树根 |
| `quiesce_inode` | 针对**单个 inode** 的静默子请求,由 `quiesce_path` 派生 |
### 23.2 与 cap 相关的锁(cap-related locks)
官方指出,静默要处理的是**客户端可能持有写能力**这件事。相关的四把锁:
| 锁 | 全称 | 作用 |
| --- | --- | --- |
| `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` |
```text
加锁顺序(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 死锁。
```text
正确做法:
1. 申请 cap 相关锁(如 xattrlock)并推进状态
2. 一旦 quiescelock 的 wrlock 已获得,就释放 xattrlock
3. 只持有 quiescelock 继续后面的流程
错误做法:
持有 xattrlock 不放,同时等待其他 rank 上的锁 -> 跨 rank 死锁
```
**官方给出的实例**(`xattrlock` 的状态迁移):
```text
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` 就**永远无法进行**。
```text
mksnap 需要的: wrlock(snaplock)
若 quiesce 持有 rdlock(snaplock) 并阻塞等待,
则 mksnap 的 wrlock 永远排不上 -> 快照功能被饿死
```
> 💡 补充:这是一个典型的"读锁饥饿写锁"问题。官方的解法是规定 quiesce 在需要等待时**先放下这类锁**,等条件满足再重新获取(即第 23.6 节的 mkdir 流程)。
### 23.6 mkdir 流程中的处理
官方以 `mkdir` 为例说明如何安全等待:
```text
1. mkdir 请求进入
2. 发现需要等待某个条件(如 quiesce 未完成)
3. 释放当前持有的**所有**锁(drop all held locks)
4. 等待条件满足
5. 条件满足后,重新走一遍完整的加锁流程
```
> 💡 补充:这是 MDS 里普遍的"回退重试"模式 —— 与其持有锁等待(风险高、易死锁),不如**完全放弃已获资源、重新排队**。代价是重复一次加锁开销,收益是不会出现持锁等待造成的环路。
### 23.7 lookup / readdir 路径上的检查
官方说明在 **lookup** 与 **readdir** 时也要配合静默:
```text
lookup / readdir 处理:
1. 检查父目录的 quiescelock 上是否有 xlock
2. 若有(说明该子树正在被静默),则派发一个 quiesce_inode 请求
```
> 💡 补充:这保证了"正在被访问的目录也会被纳入静默范围" —— 静默不是只对缓存中已有的 inode 生效,任何在静默期间被触及的 inode 都会被补上 `quiesce_inode`。
### 23.8 与子树 import 的交互
官方描述了一个具体的失败场景:
```text
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](https://docs.ceph.com/en/tentacle/dev/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)。