很多搞 Ceph 的朋友第一次接触 CRUSH 算法时,心里都会有个疑问:所有 OSD 明明都参与分布,为什么有的节点磁盘快满了、有的还很空?为什么加了一台机器,整个集群会搬一大堆数据?这些现象背后的账,其实都记在 Ceph 的数据分布算法上。上一篇我们讲了 CRUSH 的分层结构和 PG 的基本生成逻辑,这一篇继续往深了挖,把几个真正影响分布结果的关键点一次性说透:bucket 选择逻辑、rule 规则设计、权重调整,以及增删节点之后重平衡到底是怎么发生的。
1. 映射流程里最容易被忽略的"确定性"环节
先说一个很多人理解错的地方:Ceph 的分布不是"随机"分布。虽然我们把 CRUSH 定义成一种伪随机算法,但它在同一份 CRUSH map、同一个输入下,永远会给出相同的结果。这个"确定性"是整个分布式存储的根基,没有它,客户端就不知道去哪个 OSD 取数据,PG 的主从关系也完全没法维护。
完整的映射路径是这样的:对象名(object name)先做一个 hash,得到的是 32 位的值;这个值再向右位运算,得到 pgid(placement group ID)。然后把"PG id"作为输入,交给 CRUSH 算法,在当前的 map 里选择出若干个 OSD,PG 就落在这些 OSD 上。很多人在这条链上死磕对象 hash,其实真正决定分布的,是后一步——CRUSH 对 PG 的选择过程。
你可能听过一个说法:"只要 PG 数量足够,整个集群的分布会很均匀。"这话对,但有个前提:PG 之间没有热点,同时所有 OSD 的权重都一致。现实中往往不是这样,比如一个集群由混用机械盘和固态盘组成,权重天然不同;又比如某些业务创建的对象集中在极少数 object 上,那这些对象所属的 PG 再怎么平衡,这几个 OSD 也会偏高。
真正理解这个"确定性"环节,对我们排障有实际帮助。当用户反馈某个数据目录写入很慢时,我通常先定位这个对象属于哪个 PG,然后看这个 PG 的 acting set 是哪几个 OSD,再查这些 OSD 的负载状态。查找命令很简单:
ceph osd map <pool_name> <object_name>这条命令会直接输出该对象所在的 PG、以及 PG 对应的 OSD 列表。如果同一个 PG 的多个副本 OSD 全都在高延迟状态,那这个对象的访问必然慢。用这种"逆向追踪"的方式,可以很快把问题从业务层拉到存储层。
再说回 PG 数量的设计。很多部署文档会给出公式——PG 总数=(OSD 数 ×100)/副本数,但实际生产环境远远不够。PG 数量太少,数据分布粒度太粗,容易出现单 OSD 塞了过多 PG;PG 数量太多,占用的内存资源和主从切换开销又会线性上升。更可靠的思路是:先按每个 OSD 能承受的 PG 上限去估算,再留出 20% 到 30% 的冗余,因为后面扩容时往往不想再动 PG 数。这里有一条实用分界线,单个 OSD 上 PG 数控制在 100 到 300 之间,大多数场景都可以。
2. Bucket 算法选择:均匀分布的底层变量
CRUSH map 里的"层级节点"用 bucket 表示,host、rack、room 这些都是 bucket。bucket 的核心职责是把子节点按某种策略混在一起,让选择结果尽量均匀。Ceph 支持多种 bucket 算法,不同算法的时间复杂度、分布均匀度和实际资源消耗差别不小。
先列一个对比表格,帮你快速回忆各种算法:
| 算法 | 时间复杂度 | 分布均匀性 | 说明 |
|---|---|---|---|
| uniform | O(1) | 高,但要求所有子节点权重相同 | 适合容量一样的场景 |
| list | O(n) | 低,前部节点容易过载 | 简单追加,老节点长期被优先选中 |
| tree | O(log n) | 中 | 适合 bucket 数量很多的情况,重算成本可控 |
| straw | O(n) | 高 | 每个节点有一定"抽签"能力,增加删除节点影响小 |
| straw2 | O(n) | 极高 | Ceph 的默认算法,也是如今绝大多数集群的选择 |
早期 Linux 内核版本的 Ceph 集群里,list bucket 带来的"前端倾斜"问题很明显。新加进来一个 OSD 时确实能接到数据,但它前面的老 OSD 长久占据先发优势,整体分布越来越不均衡。后来 straw 算法解决了这个倾斜问题,straw2 又在计算随机性和权重处理上做了改进。生产环境基本不用纠结选型,直接用 straw2 就行了。
但这里有比选型更值得注意的事:bucket 的最大长度。假设一个 rack bucket 下面挂了 80 个 OSD,straw2 在每次选择时都要遍历这 80 个节点并计算抽签值。这个开销本身不大,但是当 bucket 层级很多、每一层都要做一次选择时,计算总量就上来了。更深层的问题是:一个 bucket 内节点太多,权重差异不可能完全靠采样抹平。你别指望 80 个 2TB OSD 和 10 个 16TB OSD 混在一个 host bucket 下还能获得精确均匀的输出。
所以设计 CRUSH map 时,我倾向于遵循三个原则:
- 每个 host bucket 下的 OSD 数量尽量一致,容量差异也要控制在 30% 以内;
- rack bucket 下的 host 数量保持接近,避免某一排机器承载了超过一半的副本;
- 如果真有超大容量的 OSD,通过权重设置让它"少承担一点",而不是硬凑进同一层。
这三个原则背后是同一句大实话:CRUSH 是概率算法,它能做到"统计均匀",做不到"人工精确"。把所有不均衡问题都想让算法兜底,最后复杂度会反噬运维成本。
3. RULE 规则与故障域:一份规则管住副本应该落在哪
CRUSH map 有个容易被人忽视的模块叫 rule,它决定了数据副本的放置路径。rule 通常由三类语句构成:take、choose、chooseleaf。
take 表示从哪个 bucket 开始选择,一般会取一个顶层节点,比如 root datacenter;choose 表示在某个 bucket 下选指定数量的子 bucket;chooseleaf 则是"下沉式选择"——它会一路选到叶子 OSD,并把一个 subtree 的副本约束出来。
一个典型的 3 副本规则可以这样描述:先从 root 开始,选择 3 个不同的 rack bucket,再从每个 rack 下选择 1 个 host,最终每个 host 下选 1 个 OSD。这就保证了同一个 PG 的 3 个副本位于 3 个不同的机架,任何一个机架整体断电,数据依然可用。这就是"故障域"的含义。
实际写规则时,人们常常被"choose 的数量写多少"困扰。以 3 副本跨 3 机架为例,如果你写的是:
rule replicated_rule { ruleset 0 type replicated min_size 1 max_size 10 step take root step chooseleaf firstn 3 type rack step emit }这里的firstn 3表示选择 3 个 rack。如果集群只有 2 个 rack,这条规则就无法装满 3 副本,PG 会进入degraded状态。这个问题在实际中经常被忽略:扩容机架时只加物理机器,忘了检查规则里设定的故障域数量,结果副本长期缺失。
如果你想让 3 副本落在同一个机架里的 3 个不同机器上,可以这样改:
step chooseleaf firstn 0 type hostfirstn 0表示"根据副本数自动匹配",后面接的type host就是故障域。这个写法的好处是不用硬编码 3 或 2,健康检查会根据 PG 大小自适应。但缺点也很明显:它不能保证跨机架,安全性由底层树结构保证。所以选择哪个写法,本质上是选择你要牺牲什么——是可扩展性,还是容灾级别。
一些云环境还会开primary-affinity,它控制主副本落在哪些 OSD 上。如果某一台老机器性能弱,可以调低它的 primary affinity,让它尽量少承担主副本的读请求,但是仍保存副本。这个参数不是通过 rule 实现的,而是直接作用于单个 OSD:
ceph osd primary-affinity <osd_id> <weight>需要注意,primary affinity不影响数据分布本身,只影响"谁当主副本"的概率。所以它不能解决容量不均衡问题,但能解决"小盘机械 OSD 被大量读请求压垮"的问题。两者不要混淆。
4. 增删 OSD 时的重平衡:从概率迁移到带宽洪峰
一个集群不是搭好之后就不变了,扩容、缩容、坏盘换盘都是日常工作。每一件都会触发重平衡(rebalancing)。
Ceph 的重平衡机制基于一个朴素逻辑:PG 原本分散在 A、B、C 三个 OSD 上,现在加入 D,那么部分 PG 会被重新计算,从而从原来的 OSD 迁到 D。但这些 PG 的数量并没有增加,它们只是在调整物理映射关系。也就是说,重平衡的本质是"PG 搬家",而不是"PG 分裂"。
很多刚接触 Ceph 的运维看到pg_num这个参数,误以为增加它可以提升性能。实际上pg_num确定的是 PG 总数,它直接影响数据分布的粒度,但不会在扩容时自动让新 OSD 立即充满数据。想让新 OSD 尽快接数据,需要的是提高它的权重,或者等重平衡自然发生。
重平衡期间最头疼的是迁移带宽。默认配置下,Ceph 会限制并发回填的 PG 数量,避免影响业务 IO。比如osd_max_backfills = 1表示每个 OSD 同时最多做 1 个 PG 的回填。数据迁移虽然慢了,但业务抖动小。如果允许osd_max_backfills = 4,迁移速度会快很多,但磁盘和带宽压力陡然上升,很容易出现读延迟飙升。
我遇到过一次线上事故:集群加入 10 个新 OSD 后,运维直接调高回填数量希望快点填满,结果磁盘队列深度全线飘红,业务写延迟从 5ms 跳到 200ms。之后我学乖了,重平衡前先压降业务负载,再按 2、4、8 的阶梯逐步调整回填上限。这个方法听起来笨,但在混部场景里非常管用。
对于"坏盘替换"这种单 OSD 失效的情况,过程会更微妙。旧 OSD 的 PG 会先被标记 degraded,然后新的 OSD 加入时,那些需要迁移的 PG 会从最远端开始回填。这里有个常见误区:替换 1 块 4TB 盘,以为迁移的数据量最多就是 4TB。其实不对,因为每个 PG 有 3 个副本,其中一份落在坏盘上,但这块盘上可能有 200 个 PG,每个 PG 在其他 OSD 上都有副本。真正迁移的量是"这 200 个 PG 的完整数据量",而不是坏盘上的那部分。所以替换一个 4TB OSD,可能需要迁 10TB 甚至 20TB 数据,这在容量规划时一定要留余量。
如果你观察重平衡过程中哪个 PG 先迁、哪个后迁,可以用:
ceph pg ls | grep -w down | head -20 ceph pg dump_stuck inactive,unclean看到大量inactive时,说明有副本缺失,且不满足 min_size,业务已经停止写入;如果是unclean但 active,说明副本还在回填,业务可以继续。这两个状态要分清楚,报警逻辑不应该把unclean直接当成业务中断。
5. 权重调整:一个被误用的"调平衡"手段
说到权重,大多数人的第一反应是修改 OSD 的weight参数。确实,CRUSH 选择 OSD 时是按权重比例来的,但你如果以为"把高容量 OSD 权重调高,它就会多接数据",那方向就反了。
权重的作用是决定 PG 落在这个 OSD 上的"概率权重",而不是让这个 OSD 立即接收更多的数据。Ceph 在创建 PG 映射时,确实会更大概率选择权重高的 OSD,但整个集群已经稳定后,改变单个 OSD 权重并不会立刻引发那部分 OSD 上的历史 PG 搬家。你需要等下一次 PG 重映射或触发 rebalance 时才生效。
正确调整权重的思路,通常服务于两种场景:
第一种是混用磁盘时需要让容量大的盘承担更多 PG。比如一块 16TB 盘和一块 4TB 盘同组,你可以把 16TB 盘权重设为 4.0,4TB 盘设为 1.0。这样 CRUSH 选择 PG 时,16TB 盘被选中的概率大约是 4 倍,整体存储占比就能匹配容量。
第二种是紧急规避故障盘。某些 OSD 开始出现 IO error,你不想立刻拔盘,但希望它别再新增 PG,可以把它的 weight 临时设为0。命令如下:
ceph osd crush reweight osd.23 0这个操作会把 OSD 从 CRUSH map 中摘除,但不会马上清空它上面的数据。这个 OSD 上的 PG 会先转移到其他 OSD 上,数据回填完才能彻底下线。如果只是顺手设成 0 然后忘掉,集群就等于永久少了一个 OSD 的容量,而且数据虽然不在它上面,但它还在 map 里,会带来无意义的回填和容错负担。
还有一个参数容易被混淆:ceph osd reweight和ceph osd crush reweight完全不是一回事。前者是临时调整个别 OSD 在 reweight 阶段的权重,在 PG 重新计算时有效,但不会修改 CRUSH map 里的真实权重;后者直接改动 CRUSH map 里的权重,是持久化配置。线上改容量搭配时,一定要用后者。
如果你想看看权重调整后各 OSD 的 PG 数分布是否合理,可以直接执行:
ceph osd tree ceph pg dump | awk '{print $1}' | sort | uniq -c | sort -nr | head -20有人期望看到每个 OSD 的 PG 数完全一致,这几乎不可能实现。CRUSH 是概率算法,允许 5% 以内的均值偏差,这是正常现象。只要没有某一个 OSD 承载了超过均值 20% 以上的 PG,一般不必干预。
6. 两个真实调优场景:一个扩容案例,一个故障案例
前面讲了不少理论,下面用两个案例把上面所有环节串起来。
场景一:从 9 个 OSD 扩到 18 个 OSD
初始状态:集群有 3 台机器,每台 3 块 4TB 盘,副本数 3,当前 PG 总数 512,存储池使用率约 60%。扩容目标是再加 9 块 4TB 盘,让整体容量翻倍。
扩容前先算 PG:当前 9 个 OSD、每 OSD 约 57 个 PG,不算多。扩到 18 个 OSD 后,每 OSD 的 PG 数会降到 28,这偏低,不利于将来数据倾斜。我建议把 PG 总数提高到 1024。为什么不是 2048?因为 512 和 1024 都是 2 的幂,PG 再映射时的对半分裂逻辑更简单;如果设成 1536,某些旧 PG 会碎裂成不均匀的小块,反而增加回填量。
扩容操作主要分几步:
- 先新增加 9 个 OSD 并加入 CRUSH map,不用急着调权重;
- 观察
ceph -w,等集群 idle 后再调整 pg_num; ceph osd pool set <pool_name> pg_num 1024,此时 PG 会逐步分裂,数据开始回填;- 新的 9 个 OSD 会逐渐接到一部分 PG,等
active+clean全部恢复后,再做一轮ceph rebalance状态检查。
这里有一个亲身经验:先扩 OSD,再扩 PG 数。因为新增的 OSD 会立即参与 CRUSH 选择,如果先扩 PG 数,旧 PG 已经大量重新映射到新 OSD 上,反而在新增 OSD 时又触发一遍全局映射变化,双重迁移得不偿失。
场景二:一台机器离线导致副本不足
某次机架断电,一个 rack 下的 3 个 host 全掉了,整个存储池只能从剩下 9 个 OSD 里继续服务。由于副本数正好是 3,每个 PG 都会有副本落在故障域内,此时必然出现大量degraded。
很多人的第一反应是赶紧把机架恢复,这是本能。但在恢复前,我们可以先确认是否有足够节点满足min_size。如果 pool 的 min_size 是 2,集群会继续读写;如果 min_size 是 3,就等于写入全部停了。
当时我的做法是:
- 用
ceph health detail查看具体哪些 PG 处于peering或inactive; - 确认故障机架内 OSD 是否彻底无法访问,如果不确定,不要急着 down;
- 机架恢复后,让它自动回填,回填期间下调
osd_max_backfills,避免机器刚启动就被 IO 打崩; - 全部 PG 回到
active+clean后,再检查osd_perf,异常 OSD 及时剔除。
在这个过程里,最关键的其实不是任何一条命令,而是确保 CRUSH rule 里type rack设置正确,否则副本本来就可能落在同一个机架内,停电时整个池直接不可用。这也是为什么我在第三节反复强调 rule 设计——它在故障面前不是优化项,是安全底线。
Ceph 的数据分布算法是整个存储系统的中枢神经,不过很少有人能只看文档就完全理解它在线上环境里的行为。我自己也是在经历过扩容迁移、坏盘回填、机架断电之后,才意识到每个参数背后都连着具体的迁移开销和可用性风险。希望这篇"第二篇"能帮你把 CRUSH map、rule、权重、重平衡这几个关键节点串成一条线。如果你正在上线一个生产集群,建议先去crushtool -i /etc/ceph/ceph.client.admin.keyring --test跑一遍映射模拟,确认规则没问题再正式切换。纸上谈兵少一点,动手验证多一点,Ceph 这条船才不容易翻。