Ceph 分布式存储技术深度解析:CRUSH 算法、存储池与数据分片原理
CRUSH 真正替换掉的不是元数据服务器,而是"数据在哪"这件事的可协商性。这篇讲清一次写入要经过几次映射,以及为什么恢复时间是设计出来的而不是故障带来的。
#开篇:一个不确定性问题,和一个"把查询变成计算"的机制
一块 OSD 盘掉线的时候,你失去的其实不是那块盘。硬件的损失是确定的(一块盘、几 TB),真正不确定的是接下来几个小时集群在干什么:多少数据要搬家、搬家的流量从哪里挤、搬完之后有没有人发现别的盘也快扛不住了。本文要解决的问题,就是把这份不确定性变成可预测、可设计、可验证的东西。它背后最基础的机制只有一句话:Ceph 把"数据放在哪"从查表改成了计算——客户端持有一份集群拓扑地图(osdmap),用两个确定性算法(对象名哈希与 CRUSH),自己就能算出任意对象放在哪几块 OSD 上,整个过程不需要任何中心节点应答"这个对象在哪儿"。
这个交换在稳定期几乎没有存在感,在故障期和扩容期却会以非常具体的方式呈现出来:你没有"慢一点迁移"以外的调节手段。所以本文按因果链组织:先讲清对象如何映射到 PG、PG 如何落到 OSD、副本写入如何保证一致,再给出一套可以在测试集群上完整复现的命令流程,然后是验证方法与常见误区。
#原理层:从对象到盘,一次写入经过几次映射
#对象是 RADOS 唯一的数据单元
RADOS(Reliable Autonomic Distributed Object Store,可靠自治分布式对象存储)是 Ceph 的底座。它管理的最小单位是对象:一段数据,附一组扩展属性(xattr)和一组键值对(omap),在同一个存储池(pool)内拥有唯一名字。对象没有目录树、没有路径,只属于某个池,名字决定位置——这是后面一切推导的起点。
存储池是隔离与策略的单元:每个池独立声明 pg_num(归置组数量)、size(副本数)、min_size(最低可用副本数)、crush_rule(放置规则)和应用类型(rbd/cephfs/rgw)。你在池层面做的每一个决定,都会沿因果链一直传导到恢复时间。
#第一次映射:对象名 → PG(纯哈希)
写入一个对象,位置是这样被决定的:
- pool + 对象名 → PG:Ceph 对对象名做哈希(默认 rjenkins),把池 ID 一并混入后对
pg_num取模,得到 PG 编号。这一步是纯哈希,与集群拓扑无关。 - PG → 一组 OSD:CRUSH 按
crush_rule从拓扑树上选size个 OSD,并指定其中谁当 primary。 - OSD → 物理位置:BlueStore 决定对象落在块的哪一段(这部分与 CRUSH 无关)。
关键在于只有第 2 步需要拓扑信息,而第 1 步把"对象数量"这个维度提前折叠掉了。这就是 PG 存在的全部理由:让几百万个对象在拓扑视角里退化成几千个 PG,再让这几千个 PG 去做分布。所以 PG 不是"为了减少迁移"才有的中间层——它是把不可枚举的东西变成可枚举的。同一 PG 内的对象共享同一组副本、同一个恢复进度,这是故障影响范围的基本颗粒度。
为什么不是直接哈希到 OSD?直接对 OSD 编号做一致性哈希也能做到无中心、可扩展,但一致性哈希只解决"分布",不解决"约束":副本怎么避开同一主机、新盘怎么按容量加权多承接、删盘后替代者怎么选在同一个故障域里——这些都需要在算法里表达。CRUSH 的价值正是带约束的确定性放置:它把"三份副本、跨主机、按权重"的约束与分布合在一次计算里完成。哈希负责把对象折叠成 PG,CRUSH 负责把 PG 摊到拓扑上,两层各管一件事。
#第二次映射:PG → OSD(CRUSH,无中心)
CRUSH(Controlled Replication Under Scalable Hashing,可控复制的可扩展哈希)出自 Weil 等人在 FAST '06 上的论文(2006),到今天快二十年,核心思路没变。它的输入是:PG 编号、osdmap(含每个 OSD 的权重与设备树)、放置规则(crush_rule);输出是:一个有序的 OSD 列表,第一个即 primary。
教科书会告诉你"Ceph 用 CRUSH 替代了中心化的元数据服务,所以没有单点、可以无限扩展"。这句话没错,但它把重点放错了。真正被替换掉的是问题本身的形式:
| 传统做法 | CRUSH | |
|---|---|---|
| 数据位置从哪来 | 查一张表(元数据服务器维护) | 算出来(客户端自己算) |
| 谁决定数据在哪 | 一个可以被人干预的调度器 | 一份写死在拓扑里的规则 |
| 加减一块盘 | 更新表、按策略选择迁移谁 | 规则重新计算,迁移量由算法决定 |
| 能不能商量 | 能,但要写调度逻辑 | 不能,除非改 crush map |
CRUSH 靠两样东西做放置:权重概率与故障域约束。每个 OSD 的权重(weight)默认按容量折算(ceph-volume 初始化时写入),CRUSH 在候选集里按权重做随机化选择——权重大的盘更常被选中;同时规则里的 chooseleaf firstn 3 type host 强制三份副本落在三台不同主机上。两者结合保证了"副本散开"与"按容量加权"。
再强调"确定性"这个性质:同一 PG、同一 osdmap,在任何客户端、任何时刻算出的结果都一致。集群不靠商量、不靠临时投票决定数据在哪,只靠大家持有同一份 osdmap。osdmap 由 MON(Monitor)用 Paxos 维护并持续发布,epoch 递增;客户端落后了可以拉新,但不能自己改。
#为什么 CRUSH 的迁移量是"最小"的
CRUSH 保证最小迁移,靠的不是一张"迁移预算表",而是两个结构性性质:
- 确定性:位置是 osdmap 的纯函数。osdmap 没变,PG 的位置就不变。
- 局部性:osdmap 只在变化点附近改变。删除 OSD B 时,唯一受影响的是"当前恰好由 B 承载的 PG"——它们需要另选一个替代 OSD;其他 PG 的输入没变,结果也不会变。新增 OSD C 时,只有重新计算后命中 C 的那部分 PG 会搬过去,比例大致与 C 的权重占集群总权重的比例相当。
所以"最小"是结构意义上的:迁移只发生在与变化相关的 PG 上,而不是全域重排。要诚实地说,这不是数学上的全局最优——尤其是删除后替代 OSD 的选择还受故障域约束,可能会让某个邻居盘多扛一些 PG。但它保证了迁移量可预估:大致与"受影响的 PG 数 × 数据量 ÷ 集群规模"成正比。这正好解释了运维感受:加减一块盘,你知道大概要搬多少数据,但没法跟它谈判"少搬一点"。
#副本写路径:primary 串行化与副本确认
对象最终落在 primary OSD 上。一次写请求的完整路径是:
- 客户端根据对象名算出 PG,再根据 osdmap 找到该 PG 的 acting 集;写请求发给 primary(acting 集第一个)。
- primary 把写操作记入自己的日志(FileStore 的 journal / BlueStore 的 WAL),应用到本地数据。
- primary 并行把 sub-op(子操作)发给各 secondary(副本 OSD)。
- 每个 secondary 记录并应用同一操作,回 COMMIT。
- primary 收到存活副本的确认后,向客户端返回 ACK。
- 关键约束:该 PG 的所有写都经由同一个 primary 串行化,primary 为每次写分配递增序号,副本按同一顺序应用——这就是单对象顺序一致性(对客户端表现为"最后一个到达的写赢")。
up 与 acting 两个概念在这里登场:up 是"应该在哪",acting 是"实际在哪"。平时两者必须完全相同;一旦出现差异,说明有 OSD 掉线或正在 peering 恢复,这时候看 ceph -s 里的 degraded 才有意义。用一条命令看对象到底落在哪:
# 看一个对象到底落在哪
ceph osd map <pool> <object>
# osdmap e412 pool 'rbd' (1) object 'foo' -> pg 1.abcd1234 (1.4) -> up ([3,7,5], p3) acting ([3,7,5], p3)
# 看某一个 PG 的映射是怎么算出来的
ceph pg 1.4 query | head -30副本数下降时,写是否还能继续,取决于 min_size:
- acting 数 ≥
min_size:PG 保持 active 但 degraded,业务写继续,但此刻每份新写只确认在存活副本上——这就是降级期间写入的风险敞口。 - acting 数 <
min_size:PG 进入 peered/inactive,写请求挂起,直到副本恢复。默认副本池 size=3、min_size=2(未显式指定时由 size 推导),意思是"再坏一块就不能写了"。这是数据安全优先于可用性的默认立场。
primary 本身挂掉时,剩余副本通过 peering 协商:比较每个副本的日志与对象版本号(epoch + version),选出包含所有已确认写入的最新副本作为权威,重新指定 primary,然后补副本、回填。整个恢复过程对客户端透明,代价是这段时间该 PG 处于降级或恢复状态。读路径同样走 primary,由它返回数据,保证读到的一定是已经过写路径确认的版本,不用在多个副本间临时仲裁。
#三个接口,同一个底座
RBD、CephFS、RGW 都只是 RADOS 的客户端,它们不实现"数据放哪、怎么备份、怎么恢复",全部委托给 librados:
| 接口 | 面向场景 | 对象形态 | 入口组件 |
|---|---|---|---|
| RBD | 块存储(虚拟机磁盘) | 镜像切成定长对象(默认 4 MiB 条带),rbd_data.<image-id>.<seq> + rbd_header.* 等元数据对象 | librbd / 内核 krbd / QEMU |
| CephFS | 文件系统(POSIX) | inode/dentry 存元数据池,文件数据按对象存数据池 | MDS(元数据)+ librados(数据直读直写) |
| RGW | 对象存储(S3/Swift) | 桶、对象、桶索引 .dir.*、用户信息都落在 RADOS | radosgw 无状态网关 |
RBD 镜像的快照与克隆基于对象的写时复制(copy-on-write);CephFS 客户端读写数据不经过 MDS(只走元数据路径),数据面同样享受 RADOS 的并行扩展;RGW 网关本身无状态,可横向加节点分摊 CPU,真正的容量与可靠性仍在 RADOS。三层接口共享同一套副本、恢复、监控机制,这是 Ceph"一个底座、三种语义"的根本原因。
#操作层:在测试集群上完整复现对象 → PG → OSD
以下操作在 cephadm 部署的 Ceph Reef 18.2.x 集群上验证:3 个 MON、3 个 OSD 节点(h1/h2/h3,每节点一块盘,OSD 编号 0/1/2,均为 BlueStore 后端),客户端为同版本 ceph-common。命令在 Pacific(16.x)之后的大版本基本通用,个别输出字段以你的版本为准——动手前先确认版本。
#第一步:看清拓扑与规则
ceph osd treeID CLASS WEIGHT TYPE NAME STATUS REWEIGHT PRI-AFF
-1 0.08789 root default
-3 0.02930 host h1
0 hdd 0.02930 osd.0 up 1.00000 1.00000
-5 0.02930 host h2
1 hdd 0.02930 osd.1 up 1.00000 1.00000
-7 0.02930 host h3
2 hdd 0.02930 osd.2 up 1.00000 1.00000再看默认放置规则声明了什么故障域:
ceph osd crush rule dump[
{
"rule_id": 0,
"rule_name": "replicated_rule",
"ruleset": 0,
"type": 1,
"min_size": 1,
"max_size": 10,
"steps": [
{"op": "take", "item": -1, "item_name": "default"},
{"op": "chooseleaf_firstn", "num": 3, "type": "host"},
{"op": "emit"}
]
}
]take default 表示从根往下选,chooseleaf firstn 3 type host 明确写着:三份副本,任何两份不在同一台主机上。
#第二步:建池
ceph osd pool create mypool 128 replicated
ceph osd pool application enable mypool rbd
ceph osd pool set mypool size 3
ceph osd pool set mypool min_size 2
ceph osd pool autoscale-status | grep mypool128 是 pg_num(新版本 pgp_num 已并入 pg_num,无需单独设置)。min_size 2 是显式声明"至少两份落盘才确认写"。autoscale-status 会告诉你建议值与当前偏差,验证层还要用它。
#第三步:放一个对象,看它落在哪
echo "hello ceph" | rados -p mypool put hello.txt -
rados -p mypool ls
rados -p mypool stat hello.txt
ceph osd map mypool hello.txtrados put 的 - 表示从标准输入读取。ceph osd map 会回答"这个对象到底在哪":
osdmap e120 pool 'mypool' (17) object 'hello.txt' -> pg mypool.a3 (17.a3) -> up ([1,2,3], p1) acting ([1,2,3], p1)逐字段读一遍:pool 'mypool' (17) 是池名与池 ID;pg mypool.a3 (17.a3) 是对象经过哈希映射到的 PG(a3 是 PG 序号);up ([1,2,3], p1) 是"应该由 1、2、3 三块 OSD 承载,primary 是 osd.1";acting ([1,2,3], p1) 是"实际承载"。两个集合此时相同,符合健康预期。再对照 ceph osd tree 可以确认 osd.1、osd.2、osd.0 恰好分散在 h1/h2/h3:CRUSH 的故障域约束确实生效了。这个计算是确定性的——换一台客户端执行同一条 ceph osd map,结果一字不差。
#第四步:追踪 PG 的落地细节
ceph pg 17.a3 query | head -30{
"pgid": "17.a3",
"up": [1, 2, 3],
"acting": [1, 2, 3],
"acting_primary": 1,
"peering_state": "active+clean",
"last_peered": "active+clean",
"info": {
"stats": {
"stat_sum": {
"num_objects": 1
}
}
}
}peering_state: active+clean 是该 PG 的健康终态:active 表示可服务,clean 表示副本齐全、无恢复进行中。num_objects: 1 说明这个 PG 正好装着刚写入的那一个对象。
再看池级 PG 与容量分布:
ceph pg ls-by-pool mypool | wc -l
ceph osd df treeceph pg ls-by-pool 统计池内 PG 数;ceph osd df tree 同时给出每块 OSD 的容量使用与 PG 数。这是检查"均衡"最有用的一条命令——它把容量均衡和 PG 数均衡两个问题同时摊开。
#第五步:故障演练——踢出一块盘,观察迁移与恢复
在测试集群上执行(生产环境请先评估影响,选低峰期并备好回退预案):
ceph osd out osd.2out 不等于下线,它只是让 CRUSH 在计算时不再选择 osd.2:原先落在它上面的 PG 立即需要重算。观察:
ceph -s | grep -E "recovery|degraded"
ceph pg stat预期看到 degraded 与 recovering/backfill 出现。这里值得区分两个词:recovery 是把掉线或损坏的副本从其他副本补回来;backfill 是把整份数据搬到新加入的 OSD 上。这个场景 osd.2 只是被 out,数据还在,其他 OSD 需要恢复的是"本应有三份、现在只有两份"的缺口,同时没有新盘可承接,所以 PG 的 acting 集收缩到 [1,2](h1/h2 两台主机),保持 active(≥ min_size=2)但 degraded。ceph -s 会给出剩余恢复量;全部恢复后,osd.2 的数据被搬空(ceph osd df tree 可见其 used 趋近于 0)。
演练结束把盘放回来:
ceph osd in osd.2
ceph -s | grep -E "recovery|degraded" # 再次出现 backfill,是往 osd.2 回填
ceph pg stat # 等待全部 active+clean这一个循环完整展示了三件事:位置是算出来的、迁移量由拓扑变化决定、恢复期间系统处于冗余下降状态。
#扩展验证:RBD 镜像也是一批普通对象
rbd create mypool/demo-image --size 1G
rbd info mypool/demo-image
rados -p mypool ls | headblock_name_prefix: rbd_data.1d5b5c8b4567rbd info 给出的 block_name_prefix 就是镜像数据对象的公共前缀;rados ls 能看到 rbd_data.<id>.<seq> 和 rbd_header.<id> 等对象——块设备在 RADOS 眼里就是一批普通对象,照样走同一套 PG/CRUSH/副本机制。CephFS 与 RGW 同理,只是对象形态不同:
ceph fs ls
ceph fs statusradosgw-admin bucket stats --bucket=mybucket#验证层:怎么确认我做对了
健康判据:ceph -s 输出 HEALTH_OK,没有 degraded/recovering/backfill 残留;ceph pg stat 中所有 PG 处于 active+clean。
对象级验证:写入与读取一致。
rados -p mypool get hello.txt -
# 输出应为 hello ceph
rados -p mypool stat hello.txt
ceph osd map mypool hello.txt # up == acting,与 ceph osd tree 交叉核对PG 级验证:
ceph pg 17.a3 query | grep -E '"up"|"acting"|peering_state'
ceph pg ls-by-pool mypool | grep -c "active+clean"
ceph osd df tree # 容量与 PG 数两个维度各自是否均衡
ceph osd pool autoscale-status # 建议值与当前 pg_num/PG 数的差距判定标准:up 与 acting 完全一致;全池 PG 状态都是 active+clean;容量不均衡要调 crush weight,PG 数不均衡要调 pg_num 与 autoscaler profile——两者不能混着调,把两种失衡混在一起是很多"越调越乱"的起点。
副本一致性验证:生产环境最常用的是 scrub 与 deep-scrub,让集群自己比对副本字节:
ceph pg deep-scrub 17.a3
ceph pg 17.a3 query | head -60 # 观察 scrub 统计与 last_deep_scrub_stamp 是否更新
ceph health detail # 有 scrub 错误会在这里报 PG_DAMAGED / 损坏对象写路径性能验证:ceph osd perf 是排查"慢盘"的第一站,它会直接摊开每块 OSD 的 apply/commit 延迟。当集群整体变慢而 ceph -s 一切正常时,先看这里,而不是去调恢复参数。
#边界与坑:操作手册之外必须知道的事
#迁移量可预估,但不可谈判
元数据中心的架构里,迁移是一个可以被调度器排序的队列:你可以让重要的池先迁、让不重要的池等夜间再迁。CRUSH 没有这个位置——迁移量由拓扑变化和 crush_rule 直接决定。想少迁,唯一干净的做法是一次加一批 OSD 而不是一块一块加,或者用 ceph osd crush reweight 做渐进式引入:每次小步调整权重,观察恢复流量,再走下一步。要注意 ceph osd crush reweight osd.2 0.0 这类操作会立即改变 osd.2 被选中的概率、触发真实搬迁,生产环境不要随手执行。
#noout / norebalance 不是取消迁移,只是推迟
ceph osd set noout 把系统的平衡状态挂起,代价是集群长期运行在非平衡状态:某些 OSD 明显更满、某些 PG 副本数不足。挂起期间每多坏一块盘,离数据不可用就更近一步。这是典型的工程选择被当成开关来用:它看起来是个开关,实际是一次风险转移,把"现在抖动"换成"未来某一刻的不可用概率"。这一类决定没有技术上的正确答案,只有"你更怕哪一个"——所以它其实是价值选择。
#pg_num:一个几乎改不动、又必须在第一天定下的数
pg_num 的棘手之处在于两个性质同时成立:
- 它必须在建池时定下来:
pg_num影响对象名哈希的取模结果,改了意味着大量对象的位置会变; - 它对恢复时间的影响是超线性的:PG 太大会让单个 PG 的恢复变成一根很长的尾巴,PG 太小又会让每块 OSD 承载的 PG 数暴涨,监控和 peering 开销压不住。
"每 OSD 100 个 PG"是个粗糙的起点,Ceph 自带的 pg_autoscaler 会给你一组更贴合的建议值——但要注意 autoscaler 建议的是均衡值,不是你的恢复目标,它不知道你能忍受多长的恢复窗口。关于调整:早期版本增加 pg_num 的代价很高;Luminous(12.x,2017)之后 PG split 的开销大幅降低(大致变成元数据操作),Nautilus(14.2,2019)又补上了 PG merge。具体到你手上的版本,行为差别不小,动手前请以对应版本文档为准——这一点不建议凭记忆操作。
#故障域:把"什么叫同时坏"写进拓扑
CRUSH map 最容易被低估的部分是它的层次结构。host/rack/row/room 不是描述性标签,它们直接定义了"同时坏掉"的判定。chooseleaf firstn 3 type host 保证的是"一台机器整机掉电后数据仍可用",而不是"不怕坏盘"——这是两个不同的强度,取决于你把 type 写成什么:
| 你写的 type | 你实际声明的容错能力 | 代价 |
|---|---|---|
osd | 不怕坏盘 | 基本没有容错意义,一般只用于测试 |
host | 不怕单机整机故障 | 最常见;同机架掉电会击穿 |
rack | 不怕机架级故障 | 跨机架带宽成为恢复瓶颈 |
room/机房 | 不怕一个机房失联 | 跨机房延迟写进每一次写入路径 |
越往下写,容错越强,恢复越慢。这个权衡里没有"更安全"的选项,只有"安全在哪一层"。现实中大部分集群停在 host,不是因为 rack 不好,而是因为跨机架网络带宽常常撑不起一次恢复。
#恢复时间才是真正的 SLA
数据安全性由副本数决定,但业务可用性由恢复时间决定:恢复期间集群处于冗余下降状态,第二次故障的代价被放大。恢复时间由一组参数直接调出:
# 恢复并发与回填上限:调大 = 恢复快、业务抖动大
ceph config get osd osd_recovery_max_active
ceph config get osd osd_max_backfills
ceph config get osd osd_recovery_sleep
# 看当前恢复进度与剩余量
ceph -s | grep -E "recovery|degraded"
ceph pg stat需要调整时(立即生效,也会立即影响业务延迟):
ceph config set osd osd_recovery_max_active 1这几个参数之间的关系是纯价值选择:你是在用前台业务的延迟,换集群回到冗余状态的速度。调高,恢复窗口缩短、用户投诉变多;调低,用户无感、但集群裸奔的时间拉长。任何一个"推荐值"都隐含了"我更怕哪边"的立场。更新的版本(Quincy 17.x,2022)引入了 mClock 调度器,试图把这件事从"几个互相牵制的阈值"变成"按客户端类别分配份额"。这部分我没有在生产上跑过,属于读文档得到的理解,不作为结论——如果你在规划新版集群,值得单独验证它在你负载下的行为。
#BlueStore 之后,边界变了但没消失
老 FileStore 时代,写路径上有一层 Journal:先写日志盘再写数据盘,小 IO 的性能瓶颈和"journal 盘挂了怎么办"都是经典题。BlueStore(Luminous 起成为默认后端)直接把对象写进裸块设备,自己管元数据(RocksDB),Journal 这个独立故障点消失了。但这不是"少了一个组件"那么简单,而是故障边界平移了:现在要在意的是 WAL/DB 所在设备与数据设备是否同盘、RocksDB 的延迟会不会拖住整个 OSD、以及 bluestore_* 那批参数。
# 看一个 OSD 的 BlueStore 配置与延迟贡献
ceph osd metadata 2 | grep -i bluestore # 把 2 换成你的 OSD 编号
ceph osd perf # 每块 OSD 的 apply/commit 延迟,排查"慢盘"的第一站ceph osd perf 是 BlueStore 时代最值钱的一条命令之一:它把"哪块盘的 commit 延迟拖后腿"直接摊开。当集群整体变慢而 ceph -s 一切正常时,先看这里,而不是去调恢复参数。
#这篇没写什么
- 没写完整的 CRUSH map 语法。 那种内容抄一遍不如直接看官方
crush.txt,而且要跟着版本走。这里只保留"层次结构 = 故障域声明"这一个判断。 - 没写容量规划的数字。 任何一个"每 TB 需要多少 OSD"的经验值,脱离你的盘型、网络和副本策略都是错的。
- 没写纠删码池。 纠删码把这里的每一个权衡都改写了一遍(恢复变成网络密集、
k+m取代size),塞进一篇只会让两边都讲不清,单独写。 - 没写性能调优清单。 理由见上:没有负载画像的调参建议,本质是让别人替你承担试错成本。
#收尾:把恢复时间从手感变成参数
CRUSH 把"数据在哪"做成了计算,于是恢复时间从运维手感变成了设计参数。你没法再和它商量,只能在一开始就把话说明白:拓扑的故障域写对、pg_num 第一天定对、副本与 min_size 按数据重要度选好、恢复参数按你能接受的抖动窗口配好,然后用演练验证。反过来,如果只是几十 TB、几台机器、想要极简运维,Ceph 的 MON 维护、PG 均衡与故障演练成本是过重的,托管对象存储或单机存储反而更合适;一旦决定用它,"位置可计算、迁移可预估、恢复可设计"就是整座集群的底层契约。
相关阅读
Related留言讨论
0 条还没有留言,来说点什么吧。