Ceph 深度实战:从 CRUSH 确定性放置、RADOS 一致性到 BlueStore 元数据栈的工程全解
很多团队第一次接触 Ceph,是把它当成"能挂 PVC 的分布式硬盘"。一旦集群超过几十个 OSD,或者开始出现 slow request、scrub 风暴、写放大暴涨,人们才会意识到:Ceph 并不是一个存储服务器,而是一套把一致性、放置策略和故障域全部下推到算法层的分布式系统。它最激进的设计决策是——没有中心化的元数据服务器。而在没有元数据服务器的前提下还能精确定位数据,靠的就是 CRUSH。
一、为什么 Ceph 敢不要元数据表
传统分布式文件系统(HDFS NameNode、GFS Master)都维护一张"文件块 → 节点"的映射表。这张表是全局状态的单点,规模一大就成为瓶颈。Ceph 换了个思路:映射不用查表,用算。
数据写入时,客户端只需要三样东西:
- 对象名(object name)
- 集群当前的拓扑描述(CRUSH map)
- 集群当前的故障域状态(OSD map)
有了这三者,任何一端——客户端、OSD、MDS——都能独立且确定地算出同一份数据落在哪几个 OSD 上。这就是所谓的 *pseudo-random deterministic placement*。它的代价是:拓扑一变(扩容、宕机),映射就变,需要数据重平衡;它的收益是:集群状态本身被压缩成一份极小的 map,可以在几千个节点间 gossip 传播。
二、CRUSH:一次哈希,四层选择
CRUSH(Controlled Replication Under Scalable Hashing)的输入是 (pg_id, crush_map, rule),输出是一个 OSD 列表。流程分四步:
obj_name --hash--> pg_id --CRUSH(rule)--> [OSD.12, OSD.87, OSD.203]
↑
pool + pg_num
第一步,对象到 PG。 客户端对对象名做一次稳定哈希,取模 pg_num 得到归置组。PG 是 Ceph 引入的关键中间层:它把"无限的对象空间"收敛成"有限的可迁移单元",让 rebalance 能以 PG 为粒度并发搬运,而不是逐对象决策。
第二步,沿 bucket 树下降。 CRUSH map 是一棵树:root → datacenter → rack → host → osd。每个 bucket 有自己的选择算法:
uniform:仅当设备权重完全一致时用,O(1)list:适用于追加型 bucket,扩容时移动最少tree:Rush 变体,O(log n)straw2:生产默认,加权公平抽签,扩容时数据迁移量最优
第三步,故障域约束。 rule 里声明 step chooseleaf firstn 0 type rack,意味着三副本必须落在不同机架上。CRUSH 在选择过程中若发现候选与已选项违反约束,会重试并标记失败位置。
第四步,应对失效。 找不到位置时会退化为 -1(CRUSH_ITEM_NONE),此时 PG 进入 degraded 状态,靠 OSD map 的 epoch 递增触发新一轮计算。
下面是 straw2 核心逻辑的极简实现,帮助理解"为什么它扩容时迁移量最小":
import hashlib
def crush_hash(x, r, item_id):
"""稳定哈希:同一 (x, r, item) 组合永远得到同样的伪随机值"""
s = f"{x}:{r}:{item_id}".encode()
return int.from_bytes(hashlib.md5(s).digest()[:8], "little")
def bucket_straw2_choose(x, r, items):
"""items: [(id, weight)],返回权重加权抽签的胜出者"""
high = 0.0
high_item = None
# 先把权重归一化为对数抽奖空间,避免浮点精度下的偏斜
for item_id, weight in items:
if weight <= 0:
continue
draw = crush_hash(x, r, item_id) & 0xFFFF
# 关键:straw2 用 weight 直接缩放 draw,而不是 straw 的 cross-log 距离
straw = draw * 1.0 / 0x10000
# 对数变换让不同量级的权重在同一尺度上竞争
ln_draw = -1.0 / (straw + 1e-9)
high_ln = ln_draw / weight if weight else 0.0
if high_ln > high:
high = high_ln
high_item = item_id
return high_item
# 选择逻辑:r 递增即可选出第 2、第 3 副本
def select_osds(pg_id, buckets, num_rep=3):
chosen = []
for r in range(num_rep):
for bucket in buckets: # 按 rule 逐层下降
pass # 真实实现此处递归进入子 bucket
chosen.append(bucket_straw2_choose(pg_id, r, [(1, 4.0), (2, 4.0), (3, 8.0)]))
return list(dict.fromkeys(chosen))
这段代码刻意省略了递归,但保留了 straw2 的灵魂:每个 item 独立抽签,权重参与缩放,因此新增一个 OSD 只影响它自己赢得的那些 PG,不会连锁打乱其他 OSD 的归属。早期 straw 算法用 item 间的对数距离比较,新增设备会导致"距离"全部重算,迁移量显著高于理论值——这正是 straw2 存在的理由。
三、RADOS:一致性不靠主节点仲裁
选出了 OSD 列表,写入路径由 RADOS 接管。以三副本为例:
- 客户端把写请求发给 primary OSD
- primary 并行转发给两个 replica,全部落盘(journal/data)后各回一次 ack
- primary 收到全部 ack 才向客户端返回 commit
注意这里没有 Raft、没有两阶段提交。Ceph 用的是主从复制 + epoch 版本控制:每个 OSD map 变更都会递增 epoch,PG 在每个 epoch 下有确定的 acting set 和 up set。当网络分区恢复,peering 过程通过比对各副本的 pg_log(记录最近操作的摘要日志,而非全量数据)来推算缺失对象,只同步差集。
这个设计带来一个重要工程后果:pg_log 的长度直接决定 OSD 宕机后的恢复代价。osd_min_pg_log_entries(默认 3000)调得太小,长时间离线的 OSD 回来后无法用日志增量恢复,只能回退到全量 backfill,瞬间吃满网络。生产上如果频繁出现 OSD 抖动,这个值应该往上调。
纠删码(EC)路径则不同:数据被切成 k 个数据块 + m 个校验块,写入必须凑齐一个完整 stripe 才能编码,因此 EC pool 不支持部分写,随机小写要走 overwrite 绕行路径或干脆用副本池。这也是为什么 RBD 的热数据几乎总是副本池,而对象网关的冷数据才用 EC。
四、BlueStore:为什么 Ceph 放弃了本地文件系统
Ceph 最早的 FileStore 构建在 ext4/XFS 之上,很快撞上三堵墙:
- 双重写放大:Ceph 已经写了 WAL/journal,底层文件系统再写一次 journal
- 无法高效原子批量写:对象 + 元数据的原子性要靠文件系统事务,XFS 的局限让实现极其别扭
- 元数据性能:XFS 的目录遍历和 inode 分配在海量小对象下退化严重
BlueStore 的答案是直接管理裸块设备:
BlueStore
/ \
BlueFS 数据块(裸盘直接写)
|
RocksDB (元数据: 对象→物理偏移)
|
WAL / SST
关键点有三个:
- 元数据进 RocksDB。 对象的
onode(对象元数据)、extent 映射、磁盘空间分配器(bitmap/freelist-manager)全部存进 RocksDB,把"文件系统该干的事"交给一个久经考验的 LSM-Tree。 - BlueFS 是 RocksDB 的专用后端。 RocksDB 本身需要 POSIX 文件接口,BlueFS 提供一个极简的用户态文件系统,直接操作裸盘,避免绕回内核 VFS。
- 数据走直接写。 大于
min_alloc_size(HDD 默认 64KB,SSD 4KB)的新写操作直接分配物理 extent,写入时对齐落盘;小于该阈值的写与元数据合并进 RocksDB 的 WAL,从而把小 IO 转成顺序写。
这就是为什么 BlueStore 集群上你经常会看到 RocksDB 的 compaction 成为延迟毛刺的来源,而解决办法通常是给 RocksDB 单独一块 NVMe(block.db):
# 把 OSD 的 RocksDB 元数据放到独立 NVMe,显著降低尾延迟
ceph-volume lvm create --bluestore \
--data /dev/sdb \
--block.db /dev/nvme0n1p1
block.db 建议不小于 data 容量的 4%(EC/小对象场景可放宽到 1~2%)。一旦 block.db 写满,BlueFS 会把数据溢出到主设备,性能断崖式下跌——这是一个在容量规划阶段就要算清楚的账。
五、生产实战清单
PG 数量不是越多越好。 经典公式 PGs = (OSDs × 100) / pool_size,再向上取整到 2 的幂。PG 太少导致数据分布不均与并发度不足,PG 太多则每个 PG 的 peering 与 scrub 开销叠加,OSD 内存吃紧。变更 pg_num 会触发大规模 rebalance,务必在业务低峰做,并先设 osd_max_backfills 与 osd_recovery_op_priority 限流:
ceph osd set norebalance # 扩容前先暂停,避免抖动期双重搬迁
ceph osd pool set rbd pg_num 4096
ceph osd unset norebalance
故障域要提前设计,事后改代价极高。 CRUSH map 里 rack 层级一旦缺失,扩容到跨机架时几乎要重建整棵树。集群搭建第一天就应该 ceph osd crush add-bucket rack1 rack。
scrub 要限速。 deep scrub 逐字节校验,默认并发会与前台 IO 争抢。生产上常见配置是 osd_scrub_begin_hour = 23、osd_scrub_end_hour = 6,并限制 osd_scrub_sleep。
理解 slow request 的含义。 slow request > 30s 几乎总是指向底层:磁盘延迟、网络丢包、RocksDB compaction 卡死,或者某个 OSD 的 block.db 溢出。它很少是"Ceph 本身慢",而是慢在它下面那一层。
六、工程上的取舍
Ceph 把复杂度从"运维一张元数据表"转移到了"理解算法与调参"。它非常适合需要统一块/对象/文件三种接口、且规模会持续增长的场景;但如果你的规模恒定在几 TB、只需要共享块设备,一套带 DRBD 的主备方案反而更省心。
真正值得学习的,是 CRUSH 所代表的那种思路:用确定性计算替代中心化查询,让每个参与者都能独立推导出全局结论。这个思想在一致性哈希、Dynamo 风格的分区、乃至某些分布式调度器里都以不同形式重现。理解它,远比记住几条 ceph osd 命令更有迁移价值。

发表评论 取消回复