5695 lines
300 KiB
Markdown
5695 lines
300 KiB
Markdown
> 基于 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> ¶meters,
|
||
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)。 |