Ceph OSD EC(纠删码)实现过程与原理分析

Ceph OSD EC(纠删码)实现过程与原理分析

1. 概述

EC(Erasure Code,纠删码)是 Ceph 中一种重要的数据冗余方式,相比传统的多副本复制,EC 可以提供更高的存储效率。例如,使用 k=4, m=2 的 EC 配置(即 4+2),可以将 6 个数据块编码为 4 个数据块和 2 个校验块,在保证可以容忍 2 个块丢失的同时,存储效率为 66.7%(相比 3 副本的 33.3%)。

1.1 核心概念

  • k(数据块数):原始数据被分割成的数据块数量
  • m(校验块数):通过编码生成的校验块数量
  • 条带(Stripe):数据被分割和编码的基本单位
  • 分片(Shard):每个条带块存储在不同的 OSD 上,称为一个分片
  • 条带宽度(Stripe Width):一个条带的总大小 = k * chunk_size

1.2 架构设计

EC 实现采用了分层架构:

1
2
3
4
5
6
7
8
9
PrimaryLogPG (上层)

PGBackend (接口层)

ECBackend (EC 实现层)

ECCommon (通用功能层)

ErasureCodeInterface (编码算法接口)

2. 核心组件

2.1 PGBackend 接口

PGBackend 是后端抽象接口,定义了统一的接口规范:

  • Listener:由上层(PrimaryLogPG)实现,提供回调接口
  • RecoveryHandle:恢复操作句柄
  • 核心方法
    • submit_transaction():提交事务
    • objects_read_sync():同步读取对象
    • objects_read_and_reconstruct():异步读取并重构对象
    • recover_object():恢复对象

2.2 ECBackend

ECBackend 是 EC 实现的核心类,继承自 ECCommon

1
2
3
4
5
6
7
8
9
10
11
12
class ECBackend : public ECCommon {
// 核心组件
ReadPipeline read_pipeline; // 读取管道
RMWPipeline rmw_pipeline; // 读-修改-写管道
ECRecoveryBackend recovery_backend; // 恢复后端
ErasureCodeInterfaceRef ec_impl; // 编码算法实现

// 核心方法
void submit_transaction(...); // 提交事务
void objects_read_and_reconstruct(...); // 读取并重构
int recover_object(...); // 恢复对象
};

2.3 ECCommon

ECCommon 提供 EC 操作的通用接口和数据结构:

  • ec_extent_t:扩展结构,包含错误码、扩展映射和分片扩展映射
  • read_request_t:读取请求结构
  • read_result_t:读取结果结构
  • shard_read_t:分片读取结构

2.4 ECUtil

ECUtil 提供 EC 相关的工具函数和数据结构:

  • stripe_info_t:条带信息,包含 k、m、chunk_size 等
  • shard_extent_set_t:分片扩展集合
  • shard_extent_map_t:分片扩展映射
  • 对齐操作:EC_ALIGN_SIZE = 4KB,所有操作必须按 4KB 对齐

3. 写入流程

3.1 整体流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
客户端写入请求

PrimaryLogPG::do_op()

ECBackend::submit_transaction()

ECTransaction::generate_transactions() // 生成写入计划

RMWPipeline::start() // 读-修改-写管道

读取现有数据(如果需要)

编码数据

发送到各分片 OSD

等待所有分片确认

提交事务

3.2 写入计划(WritePlan)

ECTransaction::WritePlan 负责规划写入操作:

1
2
3
4
5
6
7
8
9
10
11
12
struct WritePlan {
bool want_read; // 是否需要读取
std::list<WritePlanObj> plans; // 写入计划列表
};

class WritePlanObj {
const hobject_t hoid; // 对象 ID
std::optional<shard_extent_set_t> to_read; // 需要读取的分片
shard_extent_set_t will_write; // 将要写入的分片
bool invalidates_cache; // 是否使缓存失效
bool do_parity_delta_write; // 是否进行校验增量写入
};

写入计划生成逻辑

  1. 分析写入范围:确定哪些条带需要更新
  2. 确定读取需求
    • 部分写入(Partial Write):需要读取现有数据
    • 全条带写入:不需要读取
  3. 确定写入分片
    • 数据分片(0 到 k-1):写入更新的数据块
    • 校验分片(k 到 k+m-1):写入重新计算的校验块

3.3 读-修改-写(RMW)流程

对于部分写入,需要执行 RMW 操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
1. 读取现有数据
- 确定需要读取的分片(通常是所有 k+m 个分片)
- 发送 ECSubRead 消息到各分片

2. 等待读取完成
- 收集各分片的响应
- 如果某些分片不可用,使用可用分片解码

3. 修改数据
- 将新数据覆盖到读取的数据中
- 重新计算受影响的条带

4. 编码数据
- 使用 ErasureCodeInterface::encode() 编码
- 生成 k 个数据块和 m 个校验块

5. 写入数据
- 发送 ECSubWrite 消息到各分片
- 等待所有分片确认

3.4 编码过程

编码使用 ErasureCodeInterface::encode()

1
2
3
4
5
6
7
8
9
// 伪代码
int encode(
const bufferlist &in, // 输入数据
map<int, bufferlist> &encoded // 输出:分片ID -> 编码后的数据块
) {
// 1. 将输入数据按 chunk_size 分割成 k 个数据块
// 2. 使用编码算法(如 Reed-Solomon)计算 m 个校验块
// 3. 返回 k+m 个编码块
}

3.5 分片写入

编码完成后,将数据块发送到对应的分片 OSD:

1
2
3
4
5
6
7
8
9
10
11
12
// ECBackend::handle_sub_write()
void handle_sub_write(
pg_shard_t from,
OpRequestRef msg,
ECSubWrite &op,
const ZTracer::Trace &trace,
ECListener &eclistener
) {
// 1. 验证写入请求
// 2. 应用事务到本地存储
// 3. 发送确认消息
}

4. 读取流程

4.1 整体流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
客户端读取请求

PrimaryLogPG::do_op()

ECBackend::objects_read_and_reconstruct()

ReadPipeline::start() // 读取管道

确定需要读取的分片

发送 ECSubRead 消息

等待响应(可能需要解码)

重构原始数据

返回给客户端

4.2 读取策略

EC 读取有两种策略:

  1. 快速读取(Fast Read)

    • 只从 k 个数据分片读取
    • 不需要解码,直接拼接数据
    • 适用于所有数据分片可用的情况
  2. 降级读取(Degraded Read)

    • 某些数据分片不可用
    • 需要从 k 个可用分片(可能包括校验分片)读取
    • 使用 ErasureCodeInterface::decode() 解码重构数据

4.3 解码过程

解码使用 ErasureCodeInterface::decode()

1
2
3
4
5
6
7
8
9
// 伪代码
int decode(
const map<int, bufferlist> &chunks, // 输入:可用分片的数据
map<int, bufferlist> &decoded // 输出:所有分片的数据
) {
// 1. 确定需要重构的分片
// 2. 使用解码算法重构缺失的分片
// 3. 返回完整的数据(k 个数据块)
}

4.4 读取管道(ReadPipeline)

ReadPipeline 管理异步读取流程:

1
2
3
4
5
6
7
class ReadPipeline {
// 1. 确定读取分片集合
// 2. 发送读取请求
// 3. 收集响应
// 4. 如果数据不完整,触发解码
// 5. 调用完成回调
};

5. 恢复流程

5.1 恢复触发

恢复在以下情况触发:

  1. Peering 完成后:发现某些分片缺失数据
  2. OSD 上线:需要回填数据
  3. 手动触发:管理员命令

5.2 恢复流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
检测到缺失对象

ECBackend::recover_object()

ECRecoveryBackend::recover_object()

确定恢复源(从哪些分片读取)

读取数据(使用 ReadPipeline)

解码重构数据

编码数据(如果需要写入到新分片)

推送到目标分片(PushOp)

等待确认

更新恢复状态

5.3 恢复源选择

恢复源选择遵循以下原则:

  1. 优先使用数据分片:如果 k 个数据分片都可用,直接读取
  2. 使用校验分片:如果某些数据分片不可用,使用校验分片解码
  3. 最小化网络传输:优先选择本地或网络距离近的分片

5.4 恢复后端(ECRecoveryBackend)

ECRecoveryBackend 专门处理恢复操作:

1
2
3
4
5
6
7
8
9
10
class ECRecoveryBackend : public RecoveryBackend {
// 恢复操作句柄
ECRecoveryHandle *open_recovery_op();

// 运行恢复操作
void run_recovery_op(ECRecoveryHandle &h, int priority);

// 处理恢复推送
void handle_recovery_push(const PushOp &op, ...);
};

6. 条带对齐与扩展管理

6.1 对齐要求

EC 操作必须按 4KB(EC_ALIGN_SIZE)对齐:

  • 原因:编码算法要求数据块大小固定
  • 实现:所有读写操作都对齐到 4KB 边界
  • 影响:部分写入可能需要读取整个条带

6.2 扩展(Extent)管理

EC 使用扩展(Extent)来管理数据范围:

1
2
3
4
5
6
7
8
9
10
11
// 扩展集合:表示连续的偏移量区间
using extent_set = interval_set<uint64_t, ...>;

// 扩展映射:将偏移量区间映射到缓冲区
using extent_map = interval_map<uint64_t, bufferlist, ...>;

// 分片扩展集合:每个分片的扩展集合
using shard_extent_set_t = shard_id_map<extent_set>;

// 分片扩展映射:每个分片的扩展映射
using shard_extent_map_t = shard_id_map<extent_map>;

6.3 扩展缓存(ExtentCache)

ECExtentCache 缓存已读取的扩展,避免重复读取:

1
2
3
4
5
6
7
8
9
10
11
12
13
class ECExtentCache {
// LRU 缓存
LRU &cache;

// 查询缓存
bool get(const hobject_t &hoid,
const extent_set &extents,
shard_extent_map_t *out);

// 更新缓存
void put(const hobject_t &hoid,
const shard_extent_map_t &data);
};

7. 部分写入优化

7.1 问题

传统 RMW 流程需要:

  1. 读取整个条带(k+m 个分片)
  2. 修改数据
  3. 重新编码
  4. 写入所有分片(k+m 个)

这会产生大量网络 I/O。

7.2 优化策略

Ceph 实现了多种优化:

  1. 校验增量写入(Parity Delta Write)

    • 只读取受影响的数据分片
    • 计算校验增量(delta)
    • 只更新校验分片
  2. 子块(Sub-chunk)优化

    • 对于大对象,可以按子块处理
    • 减少需要读取的数据量
  3. 零去重(Zero Deduplication)

    • 检测零缓冲区
    • 用共享的零缓冲区替换,减少内存使用

8. 消息类型

EC 使用专门的消息类型进行分片间通信:

8.1 ECSubWrite

分片写入消息:

1
2
3
4
5
6
7
struct ECSubWrite {
pg_shard_t from; // 来源分片
hobject_t soid; // 对象 ID
eversion_t version; // 版本
ObjectStore::Transaction txn; // 事务
// ...
};

8.2 ECSubRead

分片读取消息:

1
2
3
4
5
6
struct ECSubRead {
pg_shard_t from; // 来源分片
hobject_t soid; // 对象 ID
shard_read_t reads; // 读取请求
// ...
};

8.3 ECSubWriteReply / ECSubReadReply

对应的回复消息。

9. 关键数据结构

9.1 stripe_info_t

条带信息:

1
2
3
4
5
6
7
8
9
10
11
12
class stripe_info_t {
uint64_t stripe_width; // 条带宽度 = k * chunk_size
uint64_t chunk_size; // 块大小
uint32_t k; // 数据块数
uint32_t m; // 校验块数

// 计算条带号
uint64_t logical_to_stripe(uint64_t logical) const;

// 计算分片内的偏移量
uint64_t logical_to_prev_chunk_start(uint64_t logical) const;
};

9.2 shard_id_t

分片 ID 类型,用于标识不同的分片(0 到 k+m-1)。

9.3 ec_align_t

对齐区域:

1
2
3
4
5
struct ec_align_t {
uint64_t offset; // 偏移量(对齐到 4KB)
uint64_t length; // 长度(对齐到 4KB)
uint32_t flags; // 标志位
};

10. 性能优化

10.1 并行处理

  • 并行读取:同时从多个分片读取
  • 并行写入:同时写入多个分片
  • 异步操作:使用回调机制,避免阻塞

10.2 缓存策略

  • 扩展缓存:缓存已读取的扩展
  • 对象上下文缓存:缓存对象元数据
  • 零缓冲区共享:共享零缓冲区,减少内存

10.3 网络优化

  • 批量消息:合并多个小消息
  • 压缩:对大数据进行压缩
  • 优先级队列:区分恢复和客户端 I/O 的优先级

11. 错误处理

11.1 分片故障

当某些分片不可用时:

  1. 读取:使用可用分片解码重构数据
  2. 写入:如果写入分片不足,等待或使用临时分片
  3. 恢复:触发恢复流程,修复缺失数据

11.2 数据不一致

检测到数据不一致时:

  1. Scrub:定期检查数据完整性
  2. Repair:修复不一致的数据
  3. 日志记录:记录错误信息

12. 总结

EC 实现的核心要点:

  1. 分层架构:清晰的接口和实现分离
  2. 异步处理:使用管道和回调机制
  3. 对齐要求:所有操作按 4KB 对齐
  4. 优化策略:多种优化减少 I/O
  5. 容错能力:可以容忍 m 个分片故障

EC 相比多副本复制的优势:

  • 存储效率高:例如 4+2 配置效率为 66.7%,而 3 副本为 33.3%
  • 可配置性强:可以根据需求选择不同的 k 和 m
  • 适合大对象:对于大对象,EC 的优势更明显

EC 的劣势:

  • 计算开销:编码和解码需要 CPU 计算
  • 部分写入性能:部分写入需要 RMW,性能较差
  • 恢复复杂度:恢复流程比多副本复杂

13. 相关文件

核心文件

  • ECBackend.h/cc:EC 后端主实现
  • ECCommon.h/cc:EC 通用功能
  • ECUtil.h/cc:EC 工具函数
  • ECTransaction.h/cc:EC 事务处理
  • ECExtentCache.h/cc:扩展缓存

消息类型

  • ECMsgTypes.h/cc:EC 消息类型定义
  • MOSDECSubOpWrite.h:分片写入消息
  • MOSDECSubOpRead.h:分片读取消息

编码算法

  • erasure-code/ErasureCodeInterface.h:编码算法接口
  • 具体实现:jerasure、isa、shec 等插件

14. EC 与三副本(Replicated)详细对比

14.1 基本概念对比

特性 EC(纠删码) 三副本(Replicated)
数据组织 数据被编码成 k 个数据块 + m 个校验块 数据完整复制 3 份
存储位置 每个块存储在不同的 OSD(分片) 每个副本存储在不同的 OSD
容错能力 可以容忍 m 个分片丢失 可以容忍 2 个副本丢失
存储效率 高(例如 4+2 为 66.7%) 低(33.3%)
典型配置 k=4, m=2(4+2)或 k=8, m=3(8+3) size=3

14.2 架构实现对比

EC 架构

1
2
3
4
5
6
7
PrimaryLogPG

ECBackend (实现 PGBackend 接口)
├── ReadPipeline (读取管道)
├── RMWPipeline (读-修改-写管道)
├── ECRecoveryBackend (恢复后端)
└── ErasureCodeInterface (编码算法)

三副本架构

1
2
3
4
5
PrimaryLogPG

ReplicatedBackend (实现 PGBackend 接口)
├── Push/Pull 机制
└── 直接复制操作

14.3 写入流程对比

EC 写入流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1. 客户端写入请求
2. 生成写入计划(WritePlan)
- 分析需要更新的条带
- 确定是否需要读取现有数据(RMW)
3. 如果需要 RMW:
- 读取现有数据(k+m 个分片)
- 解码重构原始数据
- 合并新数据
4. 编码数据
- 使用 ErasureCodeInterface::encode()
- 生成 k 个数据块 + m 个校验块
5. 并行写入所有分片(k+m 个)
- 发送 ECSubWrite 消息
6. 等待所有分片确认
7. 提交事务

特点

  • 需要编码计算(CPU 开销)
  • 必须写入所有 k+m 个分片
  • 部分写入需要 RMW,性能较差
  • 所有操作必须 4KB 对齐

三副本写入流程

1
2
3
4
5
6
7
1. 客户端写入请求
2. 主副本接收请求
3. 并行写入 3 个副本
- 主副本本地写入
- 发送 RepOp 消息到 2 个副本
4. 等待所有副本确认
5. 提交事务

特点

  • 无需编码计算
  • 只需写入 3 个副本
  • 部分写入直接覆盖,性能好
  • 无对齐要求

14.4 读取流程对比

EC 读取流程

1
2
3
4
5
6
7
8
9
10
1. 客户端读取请求
2. 确定读取策略:
- 快速读取:从 k 个数据分片读取(如果都可用)
- 降级读取:从 k 个可用分片读取(可能包括校验分片)
3. 发送 ECSubRead 消息
4. 收集响应
5. 如果需要解码:
- 使用 ErasureCodeInterface::decode()
- 重构原始数据
6. 返回给客户端

特点

  • 快速读取:只需读取 k 个分片,无需解码
  • 降级读取:需要解码计算
  • 最小读取分片数:k 个

三副本读取流程

1
2
3
1. 客户端读取请求
2. 主副本直接读取本地数据
3. 返回给客户端

特点

  • 直接读取,无需解码
  • 只需读取 1 个副本
  • 性能最优

14.5 恢复流程对比

EC 恢复流程

1
2
3
4
5
6
7
8
9
10
1. 检测到缺失分片
2. 确定恢复源(从哪些分片读取)
- 优先使用数据分片
- 如果数据分片不足,使用校验分片
3. 读取数据(使用 ReadPipeline)
- 从 k 个可用分片读取
4. 解码重构数据
5. 编码数据(如果需要写入到新分片)
6. 推送到目标分片(PushOp)
7. 等待确认

特点

  • 需要编码/解码计算
  • 恢复源选择复杂
  • 恢复流程复杂

三副本恢复流程

1
2
3
4
5
1. 检测到缺失副本
2. 从其他副本 Pull 数据
- 选择可用的副本作为源
3. 直接复制数据
4. 等待确认

特点

  • 无需编码/解码
  • 恢复源选择简单(任意可用副本)
  • 恢复流程简单

14.6 性能对比

操作类型 EC 三副本 说明
全条带写入 中等 EC 需要编码,但可以并行写入
部分写入 EC 需要 RMW,三副本直接覆盖
顺序读取 最快 EC 快速读取只需 k 个分片
随机读取 中等 EC 需要对齐,可能读取更多数据
降级读取 EC 需要解码计算
恢复速度 EC 需要编码/解码
CPU 开销 EC 需要编码/解码计算
网络开销 中等 EC 写入 k+m 个分片,三副本写入 3 个副本

14.7 存储效率对比

EC 存储效率计算

1
2
3
4
5
6
7
配置:k=4, m=2 (4+2)
存储效率 = k / (k + m) = 4 / 6 = 66.7%
容错能力:可以容忍 2 个分片丢失

配置:k=8, m=3 (8+3)
存储效率 = k / (k + m) = 8 / 11 = 72.7%
容错能力:可以容忍 3 个分片丢失

三副本存储效率

1
2
3
配置:size=3
存储效率 = 1 / 3 = 33.3%
容错能力:可以容忍 2 个副本丢失

14.8 适用场景对比

EC 适用场景

适合的场景

  • 大对象存储:对象越大,EC 优势越明显
  • 冷数据/归档数据:访问频率低,对性能要求不高
  • 存储成本敏感:需要更高的存储效率
  • 顺序读写为主:避免部分写入的 RMW 开销
  • 数据量巨大:存储效率带来的成本节省显著

不适合的场景

  • 小对象频繁写入:RMW 开销大
  • 随机写入为主:对齐要求导致额外 I/O
  • 对延迟敏感:编码/解码增加延迟
  • CPU 资源受限:编码/解码需要 CPU 计算

三副本适用场景

适合的场景

  • 高性能要求:需要低延迟、高吞吐
  • 小对象频繁写入:直接覆盖,无额外开销
  • 随机访问:无对齐要求,性能好
  • 热数据:访问频率高,性能优先
  • 简单运维:恢复流程简单

不适合的场景

  • 存储成本敏感:存储效率低
  • 大容量冷数据:存储成本高
  • 存储空间受限:需要更高的存储效率

14.9 代码实现对比

EC 关键代码路径

1
2
3
4
5
6
7
8
9
10
11
// 写入
ECBackend::submit_transaction()
→ ECTransaction::generate_transactions() // 生成写入计划
→ RMWPipeline::start() // RMW 流程
→ ErasureCodeInterface::encode() // 编码
handle_sub_write() // 分片写入

// 读取
ECBackend::objects_read_and_reconstruct()
→ ReadPipeline::start() // 读取管道
→ ErasureCodeInterface::decode() // 解码(如需要)

三副本关键代码路径

1
2
3
4
5
6
7
8
9
// 写入
ReplicatedBackend::submit_transaction()
→ 直接写入主副本
→ 发送 RepOp 到副本
→ 等待确认

// 读取
ReplicatedBackend::objects_read_sync()
→ 直接读取主副本

14.10 消息类型对比

EC 消息类型

  • ECSubWrite:分片写入消息
  • ECSubRead:分片读取消息
  • ECSubWriteReply:分片写入确认
  • ECSubReadReply:分片读取响应
  • PushOp:恢复推送操作

三副本消息类型

  • RepOp:复制操作消息
  • RepOpReply:复制操作确认
  • PushOp:恢复推送操作
  • PullOp:恢复拉取操作

14.11 数据一致性对比

特性 EC 三副本
写入一致性 需要所有 k+m 个分片确认 需要所有 3 个副本确认
读取一致性 从 k 个分片读取,可能解码 从主副本读取
部分写入 需要 RMW,可能影响一致性 直接覆盖,一致性简单
对齐要求 必须 4KB 对齐 无对齐要求

14.12 运维复杂度对比

方面 EC 三副本
配置复杂度 需要选择 k 和 m 只需设置 size=3
恢复复杂度 复杂(需要编码/解码) 简单(直接复制)
故障排查 复杂(涉及分片和解码) 简单(直接查看副本)
性能调优 复杂(涉及对齐、RMW 优化) 相对简单

14.13 成本分析

存储成本(以 100TB 原始数据为例)

EC (4+2)

  • 实际存储:100TB / 0.667 ≈ 150TB
  • 存储成本:150TB × 单价

三副本

  • 实际存储:100TB × 3 = 300TB
  • 存储成本:300TB × 单价

节省:EC 可以节省约 50% 的存储空间

计算成本

EC

  • CPU 开销:编码/解码需要 CPU 计算
  • 网络开销:写入 k+m 个分片

三副本

  • CPU 开销:几乎无额外计算
  • 网络开销:写入 3 个副本

14.14 总结对比表

维度 EC 三副本 胜者
存储效率 66.7% (4+2) 33.3% ✅ EC
写入性能(全条带) 中等 ✅ 三副本
写入性能(部分) ✅ 三副本
读取性能 最快 ✅ 三副本
CPU 开销 ✅ 三副本
恢复速度 ✅ 三副本
运维复杂度 复杂 简单 ✅ 三副本
适用对象大小 大对象 任意 -
成本 ✅ EC

14.15 选择建议

选择 EC 的情况

  1. 存储成本是主要考虑因素
  2. 数据主要是大对象
  3. 以顺序读写为主
  4. 对延迟不敏感
  5. 有足够的 CPU 资源

选择三副本的情况

  1. 性能是主要考虑因素
  2. 数据主要是小对象
  3. 随机访问较多
  4. 对延迟敏感
  5. CPU 资源受限
  6. 需要简单的运维

混合使用

  • 热数据使用三副本(性能优先)
  • 冷数据使用 EC(成本优先)
  • 通过 Ceph 的 tiering 功能自动迁移

文章互动

阅读 --

留言

0 条留言

正在加载留言…