基于 Ceph 源码(
mon/Session.h、mon/Monitor.cc、mon/MonClient.cc、mon/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 的订阅推送机制展示了一个高性能分布式推送系统的完整思路:
- 版本号是核心:所有状态数据都有单调递增版本,客户端只需记住"我已知到第几版"
- Commit-then-Notify:先持久化再推送,确保推出去的数据不会消失
- Peon 承担推送:读写分离,Leader 只处理共识,所有节点分担推送流量
- 客户端随机选择:通过 shuffle 实现天然的连接打散,无需额外负载均衡组件
- 增量推送:大数据量的 Map 只推变化部分,节省带宽
这些设计原则在任何自研的分布式 Monitor 系统(如 MON)中都可以直接借鉴。