Ceph Monitor 订阅推送机制深度解析

基于 Ceph 源码(mon/Session.hmon/Monitor.ccmon/MonClient.ccmon/ConfigMonitor.cc)分析


一、背景

在分布式存储系统中,集群状态(OSDMap、FSMap、配置等)随时可能发生变化,如何让几百上千个 daemon 和客户端实时感知这些变化,是一个核心工程问题。

Ceph 的答案是:基于版本号的持久订阅 + Paxos 提交触发推送


二、核心数据结构

2.1 MonSessionMap:订阅的全局视图

复制代码
MonSessionMap
    │
    ├─ sessions: xlist<MonSession*>          ← 所有已连接的 session
    │
    └─ subs: map<string, xlist<Subscription*>*>
               │
               ├─ "osdmap"  → [sub1, sub2, ...]
               ├─ "fsmap"   → [sub4, sub5, ...]
               ├─ "config"  → [sub7, sub8, ...]
               └─ "monmap"  → [...]

每个 Monitor 节点(Leader 和 Peon)都独立维护一份 MonSessionMap,记录连到自己的所有 session 及其订阅关系。

2.2 Subscription:订阅的最小单元

cpp 复制代码
// mon/Session.h
struct Subscription {
    MonSession *session;               // 反向指向哪个 session
    string      type;                  // 订阅类型:"osdmap"/"fsmap"/"config"
    version_t   next;                  // 期望的下一个版本号(只推送 >= next 的版本)
    bool        onetime;               // 是否一次性订阅(拉完就删)
};

next 是整个机制的关键:Monitor 只推送版本号 >= next 的更新,避免重复推送已知数据。

2.3 MonSession:一个连接的完整上下文

cpp 复制代码
// mon/Session.h
struct MonSession {
    ConnectionRef con;                 // 底层 TCP 连接
    entity_name_t name;                // 对端身份(osd.0、mds.a、client.admin)
    map<string, Subscription*> sub_map;// 该 session 的所有订阅
    MonCap caps;                       // 权限
    map<string,string> last_config;    // 上次推送的配置快照(用于 diff)
};

为什么用 xlist(侵入式链表)?

普通 list 每次插入需要 new 一个节点,侵入式链表把 list_item 内嵌在对象内,插入/删除是 O(1) 且零内存分配,适合高频操作的路径。


三、推送触发流程

3.1 完整链路

复制代码
运维人员执行变更(如 ceph config set / osd 上下线)
       │
       ▼
ConfigMonitor / OSDMonitor 的 prepare_update() 写入 pending
       │
       ▼
Paxos 多数节点确认,提交到 RocksDB(持久化)
       │
       ▼
update_from_paxos() 被回调(Commit-then-Apply)
       │
       ▼
load 最新数据到内存,version++
       │
       ▼
check_all_subs()  ← 立即!不等心跳,不延迟
       │
       for each Subscription in subs["config"/"osdmap"/"fsmap"]:
           if sub->next <= current_version:
               计算该 entity 的专属视图
               与上次推送内容对比(有变化才发)
               send_message(new MConfig / MOSDMap / MFSMap)
               sub->next = version + 1

推送不依赖心跳:配置/地图的推送和心跳(beacon)是完全独立的两条通道,心跳只是 daemon 告诉 Monitor "我还活着",而推送是 Monitor 主动发起的。

3.2 Config 推送的增量感知

ConfigMonitor 在每个 session 上记录 last_config,推送前做 diff:

cpp 复制代码
// ConfigMonitor.cc
bool ConfigMonitor::maybe_send_config(MonSession *s) {
    map<string,string> out;
    config_map.generate_entity_map(s->entity_name, ...); // 按 entity 计算专属配置

    if (out == s->last_config && s->any_config) {
        return false;   // 和上次一样,不发
    }
    s->last_config = out;
    s->con->send_message(new MConfig(out));
    return true;
}

3.3 OSDMap 的增量推送

OSDMap 支持增量(Incremental)模式,只推送变化的部分(新增/删除的 OSD 状态),而不是每次推全量 Map,节省带宽。


四、Follower 节点承担推送

4.1 Peon 可以直接推送

handle_subscribe() 中没有 is_leader() 的限制:

cpp 复制代码
// Monitor.cc
void Monitor::handle_subscribe(MonOpRequestRef op) {
    // ...
    if (p->first == "fsmap") {
        mdsmon()->check_sub(sub);        // Peon 也执行
    } else if (p->first == "osdmap") {
        osdmon()->check_osdmap_sub(...); // Peon 也执行
    } else if (p->first == "config") {
        configmon()->check_sub(sub);     // Peon 也执行
    }
}

原因:Peon 通过 Paxos 同步,本地 RocksDB 里存有和 Leader 完全相同的已提交数据。推送的是已提交数据,Peon 可以独立完成,不需要经过 Leader。

4.2 写操作才转发 Leader

复制代码
客户端
  │
  ├─ 读/订阅请求 ──────► 连接的任意 Monitor(Peon 直接处理)
  │
  └─ 写请求(变更)─────► Peon 收到后转发给 Leader,由 Leader 发起 Paxos

这个设计有效分散了 Leader 的压力:Leader 只处理写,读和推送由所有 Monitor 节点分担


五、MDS/OSD 如何打散连接到不同 Monitor

5.1 随机 Shuffle 机制

每个 daemon/客户端启动时,MonClient::_add_conns() 执行以下步骤:

cpp 复制代码
// MonClient.cc
void MonClient::_add_conns(uint64_t global_id) {
    // 1. 找出所有最高优先级的 Monitor
    // 2. 收集它们的 rank
    vector<unsigned> ranks = ...;

    // 3. 随机打散顺序
    std::shuffle(ranks.begin(), ranks.end(), rng);   // ← 每次独立随机

    // 4. 并行连接前 n 个(默认 3,由 mon_client_hunt_parallel 控制)
    for (unsigned i = 0; i < n; i++) {
        _add_conn(ranks[i], global_id);
    }
}

效果:

复制代码
5 个 Monitor: mon.a, mon.b, mon.c, mon.d, mon.e

OSD-0  → shuffle → [c, a, e, b, d] → 连 mon.c
OSD-1  → shuffle → [b, e, a, d, c] → 连 mon.b
OSD-2  → shuffle → [e, c, b, a, d] → 连 mon.e
MDS-0  → shuffle → [a, d, c, e, b] → 连 mon.a
MDS-1  → shuffle → [d, b, e, c, a] → 连 mon.d

每个 daemon 独立随机,天然打散,无需 Monitor 侧负载均衡

5.2 Hunt 模式:并行握手加速

同时向多个 Monitor 发起连接,哪个先完成认证握手就用哪个,其余丢弃。这保证:

  • 即使某个 Monitor 短暂不可用,连接也能快速建立
  • 不需要客户端知道哪个是 Leader(连上任意一个即可)

5.3 Priority 机制

mon_info.priority 字段允许运维人员给 Monitor 设置优先级,客户端优先连接最高优先级的 Monitor。可用于:

  • 让客户端优先连本地 AZ 的 Monitor(减少跨 AZ 延迟)
  • 将部分 Monitor 设为备用,正常情况下不承接连接

六、高性能的关键设计总结

设计 作用
xlist 侵入式链表 O(1) 订阅增删,遍历无内存分配
版本号过滤(sub->next 只推送未知版本,杜绝重复发送
Peon 直接推送 Leader 不是推送瓶颈,所有节点分担推送流量
随机 Shuffle 打散 连接自然均衡,无额外负载均衡开销
并行 Hunt 连接建立更快,容忍单节点短暂不可用
增量 OSDMap 只推变更部分,减少带宽消耗
Config diff(last_config) 只在内容真正变化时才发 MConfig 消息

七、自己实现订阅推送机制

如果你在自研分布式系统(如 MON)中需要实现类似的订阅推送,可以参考以下设计。

7.1 核心要素

一个完整的订阅推送机制需要解决四个问题:

复制代码
1. 订阅管理:谁订阅了什么?订阅从哪个版本开始?
2. 推送触发:什么时候推?谁来推?
3. 推送内容:推全量还是增量?
4. 可靠性:推失败了怎么办?客户端重连后怎么补推?

7.2 数据结构设计

cpp 复制代码
// 订阅项
struct Subscription {
    std::string session_id;           // 哪个连接
    std::string topic;                // 订阅的主题("volume_map"/"mds_map"/"config")
    uint64_t    next_version;         // 期望的下一个版本号
    bool        onetime;              // 是否一次性
    ConnectionRef conn;               // 发送通道
};

// 订阅中心(每个 MON 节点独立持有)
struct SubscriptionManager {
    // topic → 订阅列表(侵入式链表或 vector,按访问模式选择)
    std::unordered_map<std::string, std::list<Subscription*>> subs_by_topic;

    // session_id → 该 session 的所有订阅
    std::unordered_map<std::string, std::vector<Subscription*>> subs_by_session;
};

7.3 版本号是核心

所有需要推送的数据都要有版本号,且版本号单调递增。

cpp 复制代码
struct VolumeMap {
    uint64_t version;                              // 单调递增
    std::map<std::string, VolumeInfo> volumes;
};

struct MdsMap {
    uint64_t version;
    std::map<std::string, MdsInfo> mds_info;
};

客户端在订阅时携带自己已知的版本号:

复制代码
客户端发送:subscribe("volume_map", known_version=42)
服务端:只推送 version > 42 的变更

这样天然解决了断线重连后的补推:客户端重连后携带旧版本号重新订阅,服务端会自动推送期间所有的变更。

7.4 推送触发点

Commit-then-Notify 模式(和 Ceph 一样):

复制代码
数据变更请求
    │
    ▼
持久化到存储(Paxos 提交 / 写 KV)
    │
    ▼
version++,更新内存数据
    │
    ▼
notify_all("volume_map")  ← 立即触发,不延迟
    │
    for each Subscription in subs["volume_map"]:
        if sub->next_version <= current_version:
            send(sub->conn, build_response(sub->session_id, current_version))
            sub->next_version = current_version + 1

关键原则:先持久化再推送,确保推出去的数据一定是已持久化的,不会因为 MON 崩溃导致客户端收到后续又"消失"的数据。

7.5 全量 vs 增量

根据数据特点选择策略:

场景 推荐策略 理由
数据量小(如 MDS 列表) 全量推送 实现简单,客户端直接替换
数据量大(如 OSDMap) 增量推送 节省带宽,每次只推变化部分
配置(Key-Value) diff 推送 只推变化的 key,其余不变

增量推送需要服务端保留历史变更日志,客户端能从任意版本追上当前版本:

复制代码
版本历史:v1 → v2 → v3 → v4(当前)

客户端持有 v2:
    服务端推送 [v3 的变更] + [v4 的变更],客户端追上 v4

7.6 可靠性:客户端重连处理

复制代码
客户端重连后:
    1. 发送 subscribe(topic, known_version=N)
    2. 服务端检查:current_version > N ?
           是 → 立即推送最新数据(补推)
           否 → 登记订阅,等待下次变更时推送

特殊情况:历史版本日志被 GC 了怎么办?

如果客户端的版本太旧,历史增量日志已经被清理,需要推全量快照(Snapshot):

复制代码
if (client_version < oldest_retained_version):
    // 增量日志已不可用,推全量
    send_full_snapshot(conn, current_version)
else:
    // 推增量
    send_incremental(conn, client_version, current_version)

7.7 连接打散(负载均衡)

方案一:客户端随机选择(Ceph 的做法)

  • 客户端持有所有 MON 节点的地址列表(来自配置文件或 DNS)
  • 连接时随机 shuffle,连最先响应的节点
  • 优点:实现简单,无需服务端做负载均衡
  • 缺点:无法感知服务端负载差异

方案二:Leader 下发路由(更主动的控制)

  • 客户端先连 Leader,Leader 根据当前各节点负载,告知客户端"你去连 node-2"
  • 优点:可以精确控制负载分布
  • 缺点:Leader 成为连接分配的瓶颈(虽然只有初次连接才走)

方案三:DNS 负载均衡(最简单)

  • MON 集群对外暴露一个 DNS 域名
  • DNS 返回多个 A 记录,客户端随机选一个
  • 优点:对客户端完全透明
  • 缺点:DNS TTL 导致下线的节点短暂仍被连接

7.8 完整推送架构图

复制代码
                    ┌─────────────────────────────────────┐
                    │         MON 集群                   │
                    │                                     │
                    │  node-0 (Leader)  node-1 (Follower) │
                    │  node-2 (Follower)                  │
                    │                                     │
                    │  写操作:Leader 执行,Paxos 同步      │
                    │  读/推送:任意节点独立处理            │
                    │                                     │
                    │  每个节点:                          │
                    │    SubscriptionManager               │
                    │    subs["volume_map"] → [...]        │
                    │    subs["config"]     → [...]        │
                    └──────────┬──────────────────────────┘
                               │ Paxos 同步
                    ┌──────────▼──────────────────────────┐
                    │  数据变更 → version++ → notify_all   │
                    │  → 遍历订阅列表 → send_message        │
                    └─────────────────────────────────────┘
                         ▲           ▲           ▲
                   随机连接       随机连接      随机连接
                   MDS-0         MDS-1         Client-A
                  (node-0)      (node-2)       (node-1)

八、总结

Ceph Monitor 的订阅推送机制展示了一个高性能分布式推送系统的完整思路:

  1. 版本号是核心:所有状态数据都有单调递增版本,客户端只需记住"我已知到第几版"
  2. Commit-then-Notify:先持久化再推送,确保推出去的数据不会消失
  3. Peon 承担推送:读写分离,Leader 只处理共识,所有节点分担推送流量
  4. 客户端随机选择:通过 shuffle 实现天然的连接打散,无需额外负载均衡组件
  5. 增量推送:大数据量的 Map 只推变化部分,节省带宽

这些设计原则在任何自研的分布式 Monitor 系统(如 MON)中都可以直接借鉴。

相关推荐
一个行走的民16 小时前
Ceph OSD 故障恢复机制与 PG Log 深度解析
ceph
bukeyiwanshui21 小时前
20260527 Ceph 集群安装过程
ceph
AOwhisky1 天前
Ceph系列第一期:Ceph分布式存储核心概念与架构初识
linux·运维·笔记·分布式·ceph·学习·架构
bukeyiwanshui1 天前
20260527 ceph添加节点
ceph
bukeyiwanshui1 天前
20260527 Ceph 集群组件管理
ceph
AOwhisky1 天前
Ceph系列第二期:Ceph集群部署实战(cephadm)
linux·运维·笔记·分布式·ceph·云计算·存储
信创工程师-小杨2 天前
openEuler24.03搭建Ceph高可用
ceph
潮起鲸落入海2 天前
ceph简介及部署安装
ceph
刘某的Cloud4 天前
ceph-s & ceph_osd_tree_ceph缩容恢复_集群状态.md
linux·运维·服务器·ceph