以 Ceph OSDMap 为例,结合自研分布式文件系统的设计决策
一、背景:什么是 Map 增量
在分布式系统中,通常有一个"集群拓扑视图"(Map),比如:
- Ceph 的 OSDMap:描述每个 OSD 的状态、CRUSH 拓扑
- 自研系统的 PartitionMap:描述每个 partition 的归属 MDS 和状态
这个 Map 会随集群状态变化频繁更新,每次更新产生一个新 epoch。
全量推送:每次变更都推送完整的 Map(简单,但数据量大)
增量推送(Delta):只推送变化的部分(节省带宽,但实现复杂)
核心问题来了:这些增量(Delta)需要持久化到 DB 吗?
二、Ceph OSDMap 为什么必须持久化增量
2.1 OSD 需要历史 epoch 定位 PG
Ceph 使用 CRUSH 算法根据 OSDMap 计算 PG(Placement Group)的位置。
同一个 PG 在不同 epoch 的 OSDMap 下,计算出的 OSD 位置可能不同。
PG 1.0a 在 epoch=100 时 → 存在 [osd.1, osd.5, osd.9] PG 1.0a 在 epoch=95 时 → 存在 [osd.1, osd.3, osd.9] (osd.3 还活着)当 OSD 之间做数据迁移(backfill/recovery)时,需要知道:
- “这个 PG 在旧 epoch 时应该在哪些 OSD 上”
- “数据从哪里迁移到哪里”
如果 Monitor 只保留最新全量 OSDMap,OSD 拿着一个老 epoch 的视图来询问 Monitor,
Monitor 无法重建那个历史时刻的拓扑,就无法指导正确的数据迁移。
2.2 具体场景:落后很多的 OSD 追赶
当前 epoch = 500 某 OSD 宕机了一段时间,重启后持有的 epoch = 450 OSD 向 Monitor 请求追赶:build_incremental(450 → 500) Monitor 需要: epoch 451 的 Incremental(包含:哪个 OSD 出入/权重变化) epoch 452 的 Incremental ... epoch 500 的 Incremental 每个 Incremental 都记录了"从 N-1 到 N 发生了什么变化" OSD 逐步 apply,最终追赶到最新 epoch 如果 Monitor 没有保存历史 Incremental: 只能发 epoch=500 的全量 OSD 知道了"现在是什么样",但不知道"中间发生了什么" → 无法正确计算哪些 PG 需要迁移数据(因为不知道迁移路径)2.3 OSDMap Incremental 的持久化内容
每个 epoch 的 Incremental 存储在 Monitor 的 RocksDB 中:
key: "osdmap/v{epoch}" → 存该 epoch 的 Incremental(变化集) key: "osdmap/full/{epoch}" → 每隔若干 epoch 存一个全量快照Incremental 记录:
structOSDMap::Incremental{epoch_t epoch;map<int32_t,entity_addr_t>new_up_client;// 新上线的 OSDset<int32_t>new_down;// 新下线的 OSDmap<int32_t,uint32_t>new_weight;// 权重变化map<int32_t,pg_t>new_pg_temp;// PG 临时映射变化// ...};2.4 Trim 策略:历史不会无限保留
Monitor 会定期清理太老的历史 epoch:
version_tOSDMonitor::get_trim_to()const{// 至少保留 mon_min_osdmap_epochs 个 epoch(默认 500)// 至少保留到所有 OSD 都 clean 到的最老 epoch(min_last_epoch_clean)}超出保留范围的历史 epoch 被 GC 删除。
客户端/OSD 落后太多时,直接发全量快照 + 从该时间点之后的增量。
三、什么情况下不需要持久化增量
与 OSDMap 形成对比,很多系统的 Map 不需要持久化增量:
条件一:接收方不依赖"历史路径",只需要"当前状态"
如果接收方(MDS、客户端)只需要知道"partition 现在在哪里",而不需要知道"中间经历了哪些迁移路径",那么:
- 全量快照持久化(DB 里始终有最新的完整 Map)
- 增量只在内存中缓存少量历史(用于追赶近期落后的接收方)
- 落后太多时降级为推全量
内存缓存的增量是性能优化,不是正确性保证。
条件二:接收方重连时能接受"全量重建"
如果接收方(MDS)重启后,从 mgmtd 拉一次全量 PartitionMap 就能正常工作,不需要回放历史变更序列,则增量不需要持久化。
条件三:Map 数据量小,全量推送开销可接受
PartitionMap 即使有 1000 个 partition,全量序列化也就几十 KB,全量推送的代价很低。
四、决策框架:如何判断是否需要持久化增量
问题一:接收方是否需要"历史路径"来保证正确性? 是 → 必须持久化增量(如 Ceph OSDMap) 否 → 继续下一个问题 问题二:接收方落后时,全量推送是否可接受(带宽/延迟)? 可接受 → 不需要持久化增量,内存缓存少量即可 不可接受(Map 极大)→ 考虑持久化增量,或分片推送 问题三:mgmtd 重启后,是否需要快速恢复增量推送能力? 否(重启后推全量给所有 MDS 即可)→ 不需要持久化 是(重启后要能继续发增量,不想推全量)→ 需要持久化增量五、两类系统的对比
| 系统 | Map 类型 | 增量是否持久化 | 原因 |
|---|---|---|---|
| Ceph OSDMap | 拓扑路由 Map | ✅ 必须持久化 | OSD recovery 依赖历史路径计算数据迁移 |
| Ceph FSMap | MDS 状态 Map | ❌ 不持久化增量 | MDS 只需要最新状态,全量很小 |
| Ceph MDSMap | MDS 状态 Map | ❌ 不持久化增量 | 同上 |
| 自研 PartitionMap | partition 路由 Map | ❌ 不需要持久化 | MDS/客户端只需当前状态,全量可接受 |
| Kubernetes etcd | 所有资源状态 | ✅ 持久化(WAL) | 强一致性,事件溯源需要完整历史 |
| TiKV PD Region Map | Region 路由 Map | ❌ 只存全量 | TiKV 只需要当前 Region 分布 |
六、自研 PartitionMap 的推荐实现
持久化(DB): PartitionMap 全量快照(每次变更后持久化最新全量) key: "partition_map/v{epoch}" → 全量 key: "partition_map/latest" → 最新 epoch 指针 内存(不持久化): delta_cache: RingBuffer<PartitionMapDelta, 20> // 保留最近 20 个 epoch 的增量,用于追赶近期落后的 MDS // mgmtd 重启后 delta_cache 清空,落后的 MDS 推全量 推送策略: MDS 心跳携带 current_epoch if mds_epoch >= delta_cache.oldest_epoch: 推送增量(Delta)追赶到最新 else: 推送全量(Full)七、总结
OSDMap 持久化增量是因为 OSD recovery 在语义上依赖历史 epoch 的拓扑路径,这是正确性需求,不是性能优化。
PartitionMap 不需要持久化增量,因为 MDS 和客户端只需要"当前状态",接收方重建时拉全量即可。增量只是减少推送量的性能优化,放内存缓存就够了。
判断标准一句话:接收方是否需要"历史路径"来保证行为正确?需要 → 持久化;不需要 → 内存缓存即可。