Ceph
Ceph(OSDI 2006)在这条线上站的位置很特殊:前面九篇里,"数据在哪"这件事都是查出来的 —— GFS 查 master 的内存表、Bigtable 查 METADATA 表、Haystack 把全部元数据搬进内存查。Ceph 把这张表整个删掉了:数据的名字由 inode 号构造、数据的位置由一个分布函数算出来。
一句话概括是"消除文件分配表,用生成函数替代"。这个替换后来被承认是设计中的简化力量:
我们很意外地发现,用分布函数替换文件分配元数据,竟然成了一个简化设计的因素。它虽然对函数本身提出了更高的要求,但一旦想清楚这些要求是什么,CRUSH 就能给出所需的扩展性、可定制性与可靠性。这极大地简化了元数据负载,同时让客户端与 OSD 都能各自独立地掌握完整的数据分布。
后一句才是关键:正因为每一方都能自己算出分布,才可能把复制、迁移、故障检测、恢复全部下推给 OSD。
目标规模是几百 PB 乃至更大。目标负载里有一类极端场景被反复拿来当标尺:几万到几十万台主机同时读写同一个文件、或在同一个目录里建文件 —— 这类 HPC 场景正在变成"明天的一般负载"。
三个设计特征
架构可以归结为三条,后面每一节都在这三条底下:
| 特征 | 做法 |
|---|---|
| 解耦数据与元数据 | 元数据操作(open、rename)归 MDS 集群;文件 I/O 由客户端直接跟 OSD 通信;没有分配表,文件数据条带化到可预测命名的对象上,对象位置由 CRUSH 算出 |
| 动态分布式元数据管理 | Dynamic Subtree Partitioning —— 按当前访问模式把目录层级切给几十甚至上百个 MDS,近线性扩展 |
| 可靠自主的分布式对象存储 | 复制、集群扩容、故障检测、恢复全部交给 OSD 分布式完成 |
三个组件的分工:客户端(暴露 near-POSIX 接口,原型跑在用户态、经 FUSE 挂载)、OSD 集群(数据与元数据都存)、MDS 集群(管命名空间,以及安全、一致性、连贯性)。
它被称作 "near-POSIX" 而不是 POSIX,这是刻意的:扩展接口、并选择性地放宽一致性语义,以贴合应用需要并换性能。
一次 open 做了什么:capability
客户端 open 一个文件,请求发给 MDS 集群;一个 MDS 沿层级下行,把文件名翻译成 inode(含唯一 inode 号、owner、mode、size 等 per-file 元数据)。文件存在且授权通过,MDS 返回inode 号、文件大小,以及把文件数据映射到对象所用的条带策略信息。
MDS 还可能给客户端发一个 capability,规定它被允许做哪些操作。当前 capability 是四位:读、缓存读、写、缓冲写。这个位置留给了安全:将来的 capability 会带上安全密钥,让客户端能向 OSD 证明自己有权读写 —— 原型当前信任所有客户端。
元数据这一半:为什么它敢做小
第一条依据是量级:文件系统元数据操作能占到典型负载的一半(引自既有数据),所以元数据侧的扩展性直接决定整体。
Ceph 的元数据非常小,几乎只有目录项(文件名)与 inode,inode 是 80 字节。 因为不需要任何分配元数据 —— 对象名用 inode 号构造、位置用 CRUSH 算 —— 所以 MDS 能管住非常大的文件工作集,且这个能力与文件大小无关。
80 字节这个数字要和 Haystack 那篇并读
Haystack 的对照组是 Linux 的 xfs inode = 536 字节,它的做法是把元数据压进内存;Ceph 的做法是让元数据本身不需要那么多字段。两条路都在减少"每个文件要付多少字节",但一个动存储位置、一个动内容。
两级存储与日志
元数据更新必须落盘。做法是一组大而有界、惰性刷写的 journal:每个 MDS 的 journal 几百 MB,把更新顺序地流到 OSD 集群。
这里有个很漂亮的副作用:journal 会吸收掉重复的元数据更新(这在多数负载里很常见),于是当旧 journal 条目最终刷进长期存储时,许多条目已经过时了 —— 重写量由此大幅下降,长期存储布局可以专门为将来的读优化。
具体优化见下一节那个"inode 内嵌进目录"的设计。另外两点:inode 号按区间分配给 MDS,原型里视为不可变(将来在文件删除时回收是件小事);一张辅助的 anchor table 用来让少数有多个硬链接的 inode 仍可按 inode 号全局寻址,代价不落在"绝大多数单链接文件"这个常见情形上。
动态子树分区:三种策略的对照
设计先立在主副本缓存这个前提上:对任一块元数据,只有唯一一个权威 MDS 负责缓存连贯性与更新串行化。剩下的问题就是怎么把这份权威分下去。三条路对照:
| 策略 | 问题 |
|---|---|
| 静态子树分区(多数既有系统) | 通常要管理员手工把数据集切成静态卷 → 应付不了动态负载与数据集 |
| 哈希分散(部分实验系统按哈希分发目录与文件元数据) | 牺牲局部性换负载均衡 —— 破坏元数据局部性与预取、存储的机会 |
| Ceph:动态子树分区 | 按当前访问模式自适应地分层分发 |
动态那套的机制是:每个 MDS 用带指数时间衰减的计数器衡量目录层级里的元数据热度;任何操作都会给受影响的 inode 及其所有祖先(一直到根目录)的计数器加一,于是每个 MDS 手里都有一棵描述最近负载分布的加权树。MDS 之间周期性比较负载值,把大小合适的子树迁移走,以保持负载均衡。
几个实现细节值得记:迁移靠共享的长期存储 + 精心设计的命名空间锁,只传内存缓存里的相应内容,对连贯性锁与客户端 capability 影响很小;移入的元数据写进新 MDS 的 journal 以求安全,两端额外的 journal 条目保证权威转移对中间故障免疫(类似两阶段提交);子树分区保持粗粒度,以减少前缀复制的开销、保住局部性。
元数据被复制到多个 MDS 时,inode 内容按一致性语义分成三组,各自有独立的有限状态机:
- security(owner、mode)—— 路径遍历时的安全校验要用,但很少变,所以状态数很少;
- file(size、mtime)—— 反映更宽的客户端访问模式,因为它控制 MDS 发放客户端 capability 的能力;
- immutable(inode 号、ctime、layout)—— 永不改变。
流量控制:热点才特殊处理
分区能平衡宽范围的负载,但应付不了热点或突发流量(大量客户端访问同一个目录或文件)。Ceph 用热度知识只在需要时对热点做宽分发,以免一般情形承担开销、丢掉目录局部性:
- 读很重的目录(如大量
open)→ 内容选择性复制到多个节点分摊负载; - 特别大或写很重的目录(如大量建文件)→ 内容按文件名哈希到整个集群,以牺牲目录局部性换均衡分布。
客户端侧的配合也很关键:每个 MDS 响应都会把相关 inode 及其祖先的权威与复制信息告诉客户端,客户端据此学到自己交互的那部分文件系统的分区。后续操作按已知路径的最深前缀去权威处(更新)或随机副本处(读)。而对热门元数据,客户端被告知它在别的或多个 MDS 上,从而使"相信某块元数据在某个特定 MDS 上"的客户端数量有界,把潜在热点在发生之前就分散掉。
对象存储这一半:三层寻址
整条链是三跳:
文件 --striped--> 对象 --hash(oid) & mask--> PG --CRUSH(pgid)--> OSD 有序列表
(ino, ono) pgid- 文件条带化到多个对象上(对象名由 inode 号与对象序号构成);
- 对象用简单哈希函数 + 一个可调位掩码映射进 placement group(PG),位掩码控制 PG 数量;
- PG 由 CRUSH 映射到一串有序 OSD,即副本放置位置。
PG 的数量有个明确取值依据:选一个值让每个 OSD 上大约 100 个 PG,用来平衡 OSD 利用率的方差 与 每个 OSD 维护的复制相关元数据量 这两件事。
CRUSH
CRUSH = Controlled Replication Under Scalable Hashing,是一个伪随机的数据分布函数,把每个 PG 映射成一串有序 OSD。定位任一对象,CRUSH 只需要 PG 与一张 OSD cluster map。
cluster map 是一份存储集群设备的紧凑分层描述,结构对齐集群的物理/逻辑组成与潜在故障源。一个四层的例子:OSD 装在 shelf 里、shelf 装在机柜里、机柜排成行。每个 OSD 还有一个 weight 值,控制它被分配到多少数据。PG 到 OSD 的映射由 placement rules 决定,规则规定复制级别与放置约束 —— 例子很具体:每个 PG 复制到 3 个 OSD,都放在同一行(限制跨行复制流量)但分在不同机柜(降低对上电回路或边缘交换机故障的暴露)。
map 里还有一份 down / inactive 设备清单与一个 epoch 号,每次 map 变化即递增。所有 OSD 请求都带着客户端的 map epoch,使各方对当前数据分布达成一致;增量更新在协作的 OSD 之间共享,若客户端 map 过期就搭在 OSD 应答上捎回去。
这套做法一次解决两个问题:CRUSH 同时解决了数据分布问题("我该把数据存哪")与数据定位问题("我把数据存哪了")。并且有两条可验证的好处:任何一方(客户端、OSD、MDS)都能独立算出任一对象的位置;map 极少更新,几乎消除了分布相关元数据的交换。此外对存储集群的小改动对已有 PG 映射影响很小,把设备故障或扩容引发的数据迁移压到最小。
代价是计算量,但很小:CRUSH 计算是
与一致性哈希的分工差异
一致性哈希解决的是"节点增减时尽量少搬数据"(见 一致性哈希算法)。CRUSH 在这个基础上多解决了两件事:故障域约束(副本必须落在指定的层级组合里)与设备权重。这正是与 Sorrento 的分野 —— Sorrento 也用一致性哈希,但没有 CRUSH 的数据迁移、设备权重与故障域支持。
CRUSH 这一节还有一处工程细节值得补上:那张 cluster map 是“输入”,而不是“描述”。 它同时决定了三件事 —— 数据分布(哪些 OSD 拿多少,由 weight 决定)、故障域约束(副本必须落成什么层级组合,由 rules 决定)、以及定位的确定性(同样的 map + 同样的 pgid + 同样的 rules 必须给出同样的 OSD 列表)。三者共用一份 map,正是“各方都能独立算出同一答案”的物理基础。
由此推出一条实际后果:改 map 就是改分布。 调一个 OSD 的 weight、加一层 rack、改一条 rule,都会让一部分 PG 的映射发生变化 —— 而 CRUSH 的设计目标之一正是让这个变化的范围尽量小(只有与权重变动的那一项相关的映射会动)。这也是它与“手工分配表”最实际的差别:手工表能让你精确控制谁放哪,但它同时也意味着每次调整都要人来重算。
复制:primary-copy 的一个变体
Ceph 的前提假设与 Lustre 那条路相反:在 PB 到 EB 级系统里,故障是常态而非例外,任何时刻都有若干个 OSD 不可用(Lustre 假设可以用 RAID 或 SAN 上的 failover 造出足够可靠的 OSD)。所以 RADOS 自己管复制。
数据以 PG 为单位复制,每个 PG 映射到
- 客户端把写发给 PG 里第一个未失败的 OSD(primary);
- primary 为该对象与 PG 分配新的版本号,把写转发给其余副本 OSD;
- 各副本应用更新并应答 primary 后,primary 本地应用,再向客户端确认。
读也走 primary。这样做的好处是客户端完全不用管副本间的同步与串行化(在有其他写者或故障恢复时这很麻烦),而且把复制消耗的带宽从客户端挪到 OSD 集群内部网络,那里预期资源更多。中间发生的副本 OSD 故障被直接忽略 —— 后续恢复会可靠地重建副本一致性。
把"可见"与"安全"拆开
有两个不同的写诉求要区分:一是让更新对别的客户端可见(要快,尤其在有多个写者或读写混合时客户端是同步操作的);二是确知数据已安全复制、已落盘、能扛断电。RADOS 的做法是在确认更新时把同步与安全解耦:
- ack:更新写进所有 OSD 的内存缓冲后就回 ack,使客户端同步 POSIX 调用可以返回;
- commit:数据安全落盘后(可能是几秒之后)再发一个最终的 commit 通知。
这里有一个刻意的取舍:ack 只在更新被完整复制之后才发,虽然抬高了客户端延迟,但换来无缝容忍任一单个 OSD 故障。默认情况下客户端还会缓冲写直到 commit,以防 PG 里所有 OSD 同时断电丢数据;恢复这种情形时,RADOS 允许在固定区间内重放此前已确认(因而有序)的更新,之后才接受新更新。
故障检测:down 与 out 是两件事
部分故障OSD 能自报(磁盘错误、数据损坏);网络不可达的故障需要主动监测,而 RADOS 把这件事也分散了:每个 OSD 监测与自己共享 PG 的那些对端。多数情况下现有的复制流量本身就是对存活性的被动确认,不产生额外通信开销;若某 OSD 一段时间没听到对端消息,才发显式 ping。
关键设计是两个维度分开:
- reachable(是否可达)与是否被 CRUSH 分配数据是两件事;
- 无响应的 OSD 先标记为 down,它在各 PG 里的 primary 职责(更新串行化、复制)临时传给下一个 OSD;
- 若没有很快恢复,才标记为 out of data distribution,此时另一个 OSD 加入各 PG 去重新复制内容。
这么分的理由:网络异常种类很多,可能造成间歇性连通中断,所以由一小簇 monitor 集中收集故障报告、滤掉瞬态或系统性问题(如网络分区)。monitor 之间用选举、主动对等监测、短期租约与两阶段提交,协同提供对 cluster map 的一致且可用的访问(原型里 monitor 只有部分实现)。map 更新后受影响的 OSD 收到增量更新,增量再搭在既有 OSD 间通信上扩散开。
整体效果可以总结成一句:分布式检测给出快速检测而不给 monitor 过重负担,集中仲裁解决不一致的出现。而最重要的一条是 —— RADOS 靠"标记 down 但不标记 out"避免系统性故障(例如半数 OSD 掉电)触发大范围重复制。
恢复
OSD 集群 map 会因 OSD 故障、恢复以及显式集群变更(部署新存储)而变化,Ceph 用同一套流程处理所有这些变化。
机制基础是每个对象一个版本号 + 每个 PG 一份近期变更日志(记录被更新或被删除对象的名字与版本),类似 Harp 里的复制日志。收到新 map 的 OSD 遍历本地存的 PG、重算 CRUSH 映射,判断自己是否(以 primary 或副本身份)负责它们。若某个 PG 的成员变了、或 OSD 刚启动,就必须与该 PG 的其他 OSD 做 peering:
- 副本 OSD 把自己的当前 PG 版本号给 primary;
- primary(若自己是)收集当前与前任副本的 PG 版本号;
- 若 primary 缺最新的 PG 状态,就从当前或此前在 PG 中的 OSD 取近期 PG 变更日志(必要时取完整内容摘要)以确定正确的(最近的)PG 内容;
- primary 再给每个副本发增量日志更新(必要时发完整内容摘要),使各方都知道 PG 内容应当是什么,即使本地存的对象集可能并不匹配。
一条硬约束:只有在 primary 确定了正确的 PG 状态并分享给副本之后,才允许对该 PG 里对象做 I/O。此后各 OSD 自行负责从对端取回缺失或过期的对象;若某 OSD 收到对陈旧或缺失对象的请求,它推迟处理并把该对象移到恢复队列最前面。
一个例子把 up/down 的时序讲全了:osd1 崩溃被标 down,osd2 接手成为 pgA 的 primary;osd1 恢复后启动时请求最新 map,monitor 把它标为 up;osd2 收到由此产生的 map 更新,发现自己不再是 pgA 的 primary,于是把 pgA 版本号发给 osd1;osd1 从 osd2 取近期 pgA 日志条目、告知 osd2 自己内容已最新,然后在后台恢复更新对象的同时开始处理请求。
因为故障恢复完全由各个 OSD 驱动,受某个失败 OSD 影响的每个 PG 会并行地向(很可能各不相同的)替换 OSD 恢复。这套思路来自 FaRM(Fast Recovery Mechanism),效果是缩短恢复时间、提升整体数据安全。
恢复这一节里还有一处与 CRUSH 直接呼应的性质值得点出。 “故障恢复完全由各个 OSD 驱动”这句话之所以成立,前提是每个 OSD 都能独立算出“哪些 PG 现在归我”。于是恢复不需要一个中央调度器来分配任务 —— 受某个失败 OSD 影响的每个 PG 会并行地向(很可能各不相同的)替换 OSD 恢复。
这与“用生成函数替代分配表”是同一件事的两面:因为位置算得出来,所以职责也推得出去。 前面九篇里,恢复通常要由 master 或协调者驱动(GFS 由 master 决定重新复制、WAS 由 pitchfork 决定标记);Ceph 把这一步也变成了本地判断。
代价是“谁来判断恢复已完成”这件事没有单一视角。 每个 PG 各自认为自己修好了,而“整个集群是否健康”只能由 monitor 汇总各 OSD 上报的状态得出 —— 这就是 monitor 那一簇存在的位置。
EBOFS:为什么不能用 ext3
一堆分布式文件系统用本地文件系统(如 ext3)管底层存储,Ceph 发现它们的接口与性能不适配对象负载,三条理由:
- 现有内核接口限制了我们判断对象更新何时安全落盘的能力;同步写或 journaling 能给出所需安全语义,但延迟与性能代价很重;
- POSIX 接口不支持"数据 + 元数据(如属性)"的原子更新事务,而这对维持 RADOS 一致性很重要;
- 用户态实现能绕开 Linux VFS 与页缓存 —— 这两者是为另一种接口与负载设计的。
EBOFS 的设计要点
于是每个 OSD 用 EBOFS 管本地对象存储 —— Extent and B-tree based Object File System。要点:
- 完全跑在用户态、直接对裸块设备操作,从而自定义底层对象存储接口与更新语义,把更新串行化(为同步)与落盘提交(为安全)分开;
- 支持原子事务(例如多个对象上的写与属性更新);
- 更新函数在内存缓存更新后即返回,提交则是异步通知;
- 激进地调度磁盘写,并在后续更新使先前的 I/O 变得多余时直接取消它 —— 这给底层磁盘调度器更长的 I/O 队列与相应的调度效率提升;用户态调度器也让将来给不同负载(客户端 I/O 与恢复)排优先级或提供 QoS 保证更容易;
- 核心是一套完整的 B-tree 服务,同时用于定位对象、管理块分配、为集合(即 PG)建索引;
- 块分配以 extent(起始 + 长度对)为单位而不是块链表,让元数据保持紧凑;空闲 extent 按大小分桶、按位置排序,从而快速在写位置附近或磁盘上相关数据附近找到空闲空间,同时限制长期碎片;
- 除 per-object 块分配信息外,所有元数据都在内存(即使对大卷它也很小);
- 激进地做写时复制:除 superblock 更新外,数据总是写到磁盘上未分配的区域。
一次写走完的路径与两次通知
原稿把复制机制讲清了(primary-copy 的三步),但没有把时间轴画出来 —— 而 RADOS 最有讲究的一处恰恰在时间轴上:它把一个写拆成两条通知(ack 与 commit),让“可见”与“安全”分别到达。
三处设计决定要一起看:
- ack 只在“更新已完整复制”之后才发。 这抬高了客户端延迟,换来的是无缝容忍任一单个 OSD 故障 —— 一发 ack 就意味着它在所有副本的内存里都存在过。
- ack 到 commit 之间隔着一个未落盘的窗口(可能几秒)。客户端默认会缓冲写直到 commit,为的是防“PG 里所有 OSD 同时断电”这种情形。
- 恢复那种情形时允许重放。 RADOS 允许在固定区间内重放此前已确认(因而有序)的更新,之后才接受新更新 —— 这条正是“版本号 + 每 PG 近期变更日志”那套机制存在的理由。
两条通知对应两个不同的问题,不要混。 ack 回答的是“别的地方能不能看到这次更新”(可见性);commit 回答的是“它是不是真的躺在盘上”(持久性)。把两者合成一条通知,就必然要在“延迟”和“单点故障耐受”之间选一个牺牲 —— 而拆开之后两个都要得到,代价只是多一次通知。
这套拆法与 WAS 的 journal 那一手在同一个位置上发力:那里是“写只保证进了顺序日志就算成功、落 data disk 是异步的”;这里是“写只保证进了所有副本的内存缓冲就算可见、落盘是异步的”。两者都在把“同步等待的那一段”从关键路径上摘下来,而摘下来的方式都是“先给它一个更弱的成功定义,再补一条通知”。
还有一处与 GFS 的对照值得记:GFS 的写成功以“数据到主副本(或 pipeline 上最近的那个)”为准;RADOS 要所有副本都 ack。这解释了为什么 RADOS 的 ack 一旦发出就能容忍单个 OSD 故障 —— 它把“多副本”这件事从后台补账变成了前台确认。
这条路径上还有一处与“谁来判断完成”有关的性质。 一次写要等所有副本都 ack 才回客户端,但commit 是异步的 —— 于是“写已完成”这件事在客户端侧有两个不同的含义(可见、已落盘),而系统侧没有单一的完成点。这与“每个 OSD 各自判断自己该负责哪些 PG”是同一种取向:把判断分散到各参与者,用版本号与日志让它们最终对齐。
把“两次通知”与“读也走 primary”合起来看,能读出一个总的形状:RADOS 把顺序与可见性都交给了 primary,把持久性交给了异步落盘,把一致性恢复交给了版本号与日志。 四件事各有归属,没有一件事需要所有 OSD 同时在场协商 —— 这正是它能容忍“任何时刻都有若干 OSD 不可用”的原因。
代价是每条路径都有一个“等最慢的”环节:写要等所有副本 ack(决定 ack 延迟),落盘要等最慢的副本确认(决定 commit 延迟)。这与 WAS 那篇“三副本都成功才确认”是同一种选择,只是那里把它当成强一致的代价,这里把它当成单 OSD 故障无缝容忍的代价。
两条通知之间还有一个工程上的后果:客户端缓冲写直到 commit,是一段可观测的窗口。 这段时间里客户端持有的是“已知可见但尚未安全”的更新;而恢复那种极端情形(PG 里所有 OSD 同时断电)时,RADOS 允许在固定区间内重放此前已确认的更新 —— 这条重放之所以安全,正是因为已确认的更新是有序的(版本号由 primary 分配、按版本号提交)。顺序在这里是重放能够成立的前提,而不只是一条好看的附带性质。
down 与 out:一次崩溃的完整时序
原稿讲了“两个维度分开”,但那条时序值得单独走一遍,因为它是整套自愈机制里唯一需要跨组件协作的地方:
osd1 崩溃
│
▼
与 osd1 共享 PG 的对端在一段时间内收不到消息
│ (多数情况下“现有的复制流量”就是被动存活确认,
│ 只有沉默超过阈值才发显式 ping)
▼
osd1 被标记 down ──► 它在各 PG 里的 primary 职责
(更新串行化、复制)
临时传给下一个 OSD(如 osd2)
│
│ ▶ 此时 osd1 仍然在 CRUSH 的分布里 —— 数据不动
▼
若没有很快恢复 ⇒ 才标记 out of data distribution
│ └─ 另一个 OSD 加入各 PG 去重新复制内容
▼
osd1 恢复,启动时向 monitor 请求最新 map
│
▼
monitor 把它标为 up
│
▼
osd2 收到因此产生的 map 更新,
发现自己不再是 pgA 的 primary
│
▼
osd2 把 pgA 的版本号发给 osd1
│
▼
osd1 从 osd2 取近期 pgA 日志条目、告知 osd2 自己内容已最新
│
▼
osd1 在后台恢复更新对象的同时开始处理请求这条时序里最值钱的是那个 ▶:down 与 out 之间隔着一整段判断。 无响应只意味着“暂时联系不上”,它不该触发数据搬迁 —— 因为网络异常的种类里,间歇性连通中断很常见。先 down 让职责转移(几秒级),只有确实不回来才 out 触发重复制(可能搬很多数据)。
这套分工的收益是具体的:如果只有 down,系统性故障(例如半数 OSD 掉电)不会触发大范围重复制 —— 否则一次机房级抖动会启动一场全集群的数据搬迁,而搬迁本身又会压垮剩下的 OSD。把“暂时不可达”与“退出分布”分成两个状态,实际是在给运维留一个可以干预的中间态。
这条判据可以迁移:任何自愈系统都要把“感知到异常”与“依据异常做破坏性动作”分成两步,中间隔一段可观测的等待。 这里的两步是 down / out,等待是“没有很快恢复”。
EBOFS 这一节的三条理由值得与后面的版本演进并读,因为它们后来被反过来印证了。 这篇设计否掉通用文件系统的三条是:内核接口限制了判断“对象更新何时安全落盘”的能力;POSIX 接口不支持“数据 + 属性”的原子更新;用户态能绕开为别的接口设计的 VFS 与页缓存。
这三条的每一条,后来都在 BlueStore 的设计里以另一种形式回来了(见 ## 版本演进:同一场争论的三次来回)。而当年被否掉的“用 ext3 那类通用文件系统”这条路,在实现里其实先走了一段时间 —— 因为“自己管裸设备”的工程量很大。这类“设计里的结论先被工程现实推迟、多年后才落地”的情形,在存储系统里并不少见。
评测
环境:双处理器 Linux 集群,SCSI 盘,TCP 通信;一般每个 OSD / MDS 独占一台主机,而几十到上百个客户端实例可能共享同一台主机。
数据侧
- 单 OSD 吞吐最终受裸盘带宽限制,约 58 MB/sec(图里的水平线);复制会让磁盘 I/O 翻倍或三倍,固定 OSD 数时客户端数据率相应下降;
- EBOFS 对大于 32 KB 的写几乎打满磁盘带宽;读显著优于 ext3 / ReiserFS / XFS,因为数据按写入时的大 extent 落盘。小读写的表现受原型里粗粒度的线程与锁拖累;老化文件系统上的表现尚未评测;
- 写延迟:因为 primary 同时向所有副本重传,小写上"超过两个副本"几乎不增加延迟;大写上重传代价占主导 —— 1 MB 写一个副本 13 ms,三个副本 33 ms(约 2.5 倍)。客户端对超过 128 KB 的同步写会先取独占锁、再异步刷盘来部分掩盖这个延迟;另一种选择是应用使用
O_LAZY,一致性放宽后客户端可以攒小写、只提交大的异步写; - PG 数与利用率方差的关系是可定量的:每 OSD 100 个 PG 时标准差 10%,1000 个时 3%;
- 扩展性:OSD 写吞吐随集群规模近线性增长,直到 24 个 OSD 时交换机饱和。对照实验里 CRUSH、简单哈希、线性条带三种方式对比 —— 线性条带能完美均衡但和简单哈希一样应付不了设备故障;CRUSH 还能在扩容时最小化数据迁移,并且可以给 map 里特别标记的 OSD 卸载一部分分配量来纠正个别设备被填爆或过载。
元数据侧
- 有 本地盘 的 MDS 比纯 diskless 的 写延迟更低,因为它避开了第一次网络往返(MDS → 本地 primary OSD);两种配置下超过两个副本几乎不增加延迟,因为副本并行更新;
- 读侧:inode 内容内嵌在目录里,所以整个目录内容可以用一次 OSD 访问取进 MDS 缓存;预热过的 MDS 缓存缩短
readdir时间;后续stat不受缓存影响(因为 inode 已在目录里)。原本大目录的累计stat时间会占主导,两条路可以消掉这部分 MDS 交互:readdirplus(把 stat 与 readdir 结果打包成一次操作),或者放宽 POSIX、允许readdir紧随其后的stat由客户端缓存应答(原型默认如此); - 扩展性(在 LLNL 的 alc 集群 430 节点分区上测):
| 指标 | 数字 |
|---|---|
| MDS 吞吐从(小集群) | 2000 ops/MDS/秒 |
| 降到(128 个 MDS) | 约 1000 ops/MDS/秒,即 50% 效率,总量超过 100,000 ops/秒 |
| 128 节点 MDS 集群整体 | 超过 25 万次元数据操作/秒 |
openshared 与 openssh+include 这两个读共享最重的负载扩展性最差,原因可能是客户端选副本的策略不好;网络争用与消息层线程也会进一步压低大规模 MDS 集群的表现,但独占大集群的时间有限,未能深入查证。
一个 HPC 场景把差距量化了:LLNL 的 Bluegene/L 上,6.4 万个节点、每节点两个处理器各写同一目录里的独立文件(即 makefiles 那类负载)—— 现有存储系统峰值 6,000 次元数据操作/秒、完成一次 checkpoint 要几分钟,而 128 节点的 Ceph MDS 集群两秒能做完。顺着算:若每个文件只有 10 MB(按 HPC 标准算很小)、OSD 持续 50 MB/sec,则能写 1.25 TB/sec、打满至少 25,000 个 OSD(带复制 50,000 个);若 OSD 是 250 GB,则系统规模超过 6 PB。
实现侧的经验
FUSE 的几个坑值得记:DIRECT_IO 绕开内核页缓存但不支持 mmap(为此改了 FUSE 去失效干净页)、FUSE 坚持自己做安全检查导致连简单调用都产生大量 getattr(stat)、内核与用户态之间基于页的 I/O 限制了整体 I/O 速率;而直接链接客户端又会带来"用户态里重载系统调用"的一堆新问题,所以内核内客户端模块不可避免。
还有一条经验值得单独记:MDS 负载均衡器对整体扩展性的重要性,以及"挑什么元数据、往哪迁、什么时候迁"的复杂度,是这套设计里最大的难点。MDS 性能有很宽的性能边界(CPU、内存与缓存效率、网络或 I/O 限制,任一项都可能是当下的瓶颈),而且总吞吐与公平性之间的权衡很难定量刻画 —— 某些情况下不均衡的元数据分布反而提升总吞吐。
底层依赖:三类守护进程,以及 CRUSH 依赖 map 的什么
这套设计对外部世界的依赖比前几篇少,但它对自己的内部契约依赖得很深。
依赖一:三类守护进程各占一个位置。
| 守护进程 | 职责 | 规模量级 |
|---|---|---|
| OSD | 存绝大部分数据;同时承担复制、故障检测、恢复 | 数量由数据量、单盘容量、冗余级别共同决定 |
| Monitor | 管关键集群状态:成员信息与认证信息 | 小集群几 GB,大集群可达几十到几百 GB |
| Manager | 与 monitor 并行运行,提供额外监控与对外接口 | —— |
| MDS | 管命名空间(只在本篇的文件系统形态下需要) | 几十到上百个 |
Monitor 那一簇的规模数字值得单独看:它的数据库在小集群只占几 GB,在大集群能涨到几十甚至几百 GB。这个量级的来源是“它记的是集群成员与认证的完整历史”,而不是瞬时状态—— 而它必须保持强一致(靠选举、主动对等监测、短期租约与两阶段提交)。一个必须强一致、又存几十 GB 状态的集群,本身就是一个不小的系统 —— 这是“无中心定位”换来的一处集中点。
依赖二:CRUSH 依赖 map 里的四样东西。
cluster map 是CRUSH 的输入,而不是一份描述文档。它必须提供:
- 分层结构(例如 root → row → rack → host → osd)—— 决定故障域约束能表达多细;
- 每个 OSD 的 weight —— 决定它被分配到多少数据;
- down / inactive 设备清单 —— 决定哪些设备暂时不参与;
- 一个 epoch 号,每次 map 变化即递增 —— 让各方能判断自己的 map 是否过期。
缺任何一样,CRUSH 都无法给出“确定且各方一致”的答案。 而 map 本身的维护者是 monitor,它的更新以增量形式在协作的 OSD 之间共享,客户端 map 过期时搭在 OSD 应答上捎回去。
依赖三:一条“故障是常态”的运营假设。
这条是 RADOS 自己管复制的前提:在 PB 到 EB 级的系统里,任何时刻都有若干个 OSD 不可用。对照 Lustre 那条路 —— 它假设可以用 RAID 或 SAN 上的 failover 造出足够可靠的 OSD。两种假设导致两种架构:那种假设把可靠性外包给硬件,这种假设把可靠性做进软件。假设换掉,整套复制机制的存在理由也就换了。
依赖四:客户端与内核要支持同一组 CRUSH 语义。
这是一条容易被忽略的硬依赖:tunables 与 bucket 算法都有客户端与内核版本要求。官方给的对应关系是 —— CRUSH_TUNABLES2 需要客户端 v0.55+ / 内核 v3.9+;TUNABLES3 需要 v0.78(Firefly)/ 内核 v3.15+;CRUSH_V4 需要 v0.94(Hammer)/ 内核 v4.1+;TUNABLES5 需要 v10.0.2(Jewel)/ 内核 v4.5+。
这条依赖的含义是:CRUSH 的“算”必须由所有参与者用同一套规则算。 一旦不支持新值的旧客户端还在跑,升级 tunables 就会让它们的映射与集群其他方不一致 —— 官方对此有一条明确警告:若集群此前用过非 legacy 的 CRUSH 值,不要再运行旧版本的 ceph-osd。
把四处依赖排成一张表:
| 依赖 | 承担什么 | 换得掉吗 |
|---|---|---|
| OSD / Monitor / Manager(+ MDS) | 数据、集群状态、监控、命名空间 | 换不掉 —— 这是系统的组成部分 |
| cluster map 的四样内容 | CRUSH 的全部输入 | 换不掉;改它的结构等于改整套分布的语义 |
| “故障是常态”的假设 | RADOS 自己管复制的理由 | 换得掉(换成 Lustre 那种假设),代价是丢掉软件侧的自愈 |
| 客户端与内核的 CRUSH 支持 | 各方算出同一答案 | 换不掉 —— 它是“算而不是查”这条路线的前提 |
这张表里最值得记的是最后一行。 前几篇的依赖大多是“外部设施”(时钟、协调服务、对象存储);这里最硬的一条依赖是“所有参与者的实现必须同步” —— 因为“算出来”这件事没有仲裁者。把一张表换成一段代码,省掉了查询,换来的是版本一致性成了正确性的一部分。
参数与可调项
这套系统的可调空间分三层:crush map 的结构、CRUSH 的算法参数、以及池与设备的属性。
第一层:crush map 与 placement rules
规则的语法是 take / chooseleaf / emit 三步式的:
rule <名称> {
id <编号>
type replicated # 或 erasure
step take <root> [class <类别>] # 从哪个层级节点开始取
step chooseleaf firstn <N> type <故障域类型>
step emit
}一条 rule 同时表达了三件事:从哪棵子树取(take)、取几个(firstn N)、以及副本之间要隔开哪一层(type host / type rack / type row)。官方给的示例把这一条讲得最清楚:一个 PG 复制到 3 个 OSD,都放在同一行(限制跨行复制流量)但分在不同机柜(降低对上电回路或边缘交换机故障的暴露)。
| 命令 | 用途 |
|---|---|
ceph osd crush rule ls / dump | 查看已有规则与其内容 |
ceph osd crush rule create-replicated {名称} {root} {故障域类型} [{class}] | 为副本池建规则 |
ceph osd crush rule create-erasure {名称} {profile} | 为纠删码池建规则 |
ceph osd crush rule rm {名称} | 删除未被池使用的规则 |
ceph osd pool set <池> crush_rule <规则> | 把规则应用到池 |
bucket 的层级类型是固定的词表:osd(或 device)、host、chassis、rack、row、pdu、pod、room、datacenter、zone、region、root。这份词表就是“故障域能表达多细”的上界 —— 想按“同一路电”分隔,就得在 map 里先有 pdu 这一层。
第二层:CRUSH 的算法参数
第二层:CRUSH 的算法参数(tunables)
官方提供 8 个 profile:legacy、argonaut、bobtail、firefly、hammer、jewel、optimal、default。default 的含义值得准确理解:它是从零安装的新集群的硬编码值,通常是 optimal 与 legacy 的混合,对应“上一代 LTS 或当前版本的 optimal”。
tunables 的取值表
| 参数 | legacy | optimal | 它改的是什么 |
|---|---|---|---|
choose_local_tries | 2 | 0 | 局部重试次数 |
choose_local_fallback_tries | 5 | 0 | 局部失败后的回退重试 |
choose_total_tries | 19 | 50(典型集群更合适) | 总重试上限 |
chooseleaf_descend_once | 0 | 1 | 叶子选择是否只下降一次 |
chooseleaf_vary_r | 0 | 1 | 叶子选择的起始位置是否随重试变化 |
straw_calc_version | 0(保留旧的有缺陷计算) | 1(修复) | bucket 内部权重计算 |
chooseleaf_stable | 0 | 1 | 叶子选择的稳定性 |
命令是 ceph osd crush tunables {profile}。非最优 tunables 会触发一条 HEALTH_WARN(“crush map has non-optimal tunables”),可通过对 monitor 设 mon_warn_on_legacy_crush_tunables = false 静音。
bucket 算法的代际
bucket 的算法也有代际:straw2 是新建 bucket 的默认类型(Hammer 引入)。它修的是 straw 的一个具体缺陷 —— 旧 straw 在被调整权重的项之外也会改变一些本不该变的映射,而 straw2 做到“只对被改权重的那一项相关的映射发生变化”。从 straw 切到 straw2 会触发少量数据迁移,量取决于各项权重差异有多大(权重全相同则不动)。
第三层:设备类别与池的冗余
- device class(Luminous 起):OSD 启动时按底层设备类型自动设为
hdd/ssd/nvme,也可以用ceph osd crush set-device-class <class> <osd>显式设置;改 class 前必须先rm-device-class。实现方式是 shadow 层级 —— 每个在用类别有一份只含该类设备的影子层级,规则在影子上分发数据,对旧客户端完全向后兼容(查看:ceph osd crush tree --show-shadow)。 - 纠删码 profile 里控制 CRUSH 的键:
crush-root默认default、crush-failure-domain默认host、crush-osds-per-failure-domain默认 1、crush-device-class默认 none(即所有设备都用)、以及k/m(和某些插件的l)决定分片数。 primary-affinity:ceph osd primary-affinity <osd-id> <weight>,范围[0-1],默认 1。它调的是“这个 OSD 有多情愿当 primary” —— 因为读也走 primary,这是一处把读负载从弱设备上挪开的旋钮。- **权重集合(weight sets)**有两种:
compat与 per-pool(推荐后者),并推荐启用ceph-mgr的balancer模块。
四条读这张表的纪律:
- 改 tunables 会触发数据迁移,而且有时量很大。
chooseleaf_vary_r从 0 改成 1 就会引起大量迁移(官方提示值 4 或 5 可减少移动量)。这是“改算法参数”与“改资源参数”最本质的差别 —— 前者动的是映射本身。 - tunables 不是纯本地参数,它要求所有客户端同步。 每个 profile 都绑定了客户端与内核的最低版本(见上文那条硬依赖)。混合版本的集群先升客户端,再升 tunables。
default不等于optimal。 新装集群拿到的是硬编码的混合值,想要“当前版本最优”要显式设optimal—— 而这一步又会带来迁移。- 设备类别解决的是“同一池里混用不同介质”,用的是影子层级而不是新语法。 这条实现方式的好处是对旧客户端零感知,代价是多出一套需要同步维护的隐藏层级。
一处与前面那个数字相呼应的配置项:PG 的数量。给出的取值依据是“让每个 OSD 上大约 100 个 PG”,用来平衡利用率方差与每个 OSD 维护的复制元数据量;对应的实测是每 OSD 100 个 PG 时标准差 10%、1000 个时 3%。这是一处“参数直接由一段可量的取舍推出”的例子 —— 不是拍的,也不是越多越好。
一处新增的规则类型:当纠删码 profile 的 crush-osds-per-failure-domain 大于 1 时,会生成 MSR 类型规则(Squid 新增)。普通规则在遇到 out 的 OSD 时无法重试先前步骤(它依赖 CHOOSELEAF,而 CHOOSELEAF 每个故障域只支持一个 OSD);MSR 支持每故障域多个 OSD,且遇到 out 时会重试所有先前步骤。使用它要求 OSD 与客户端都支持 CRUSH_MSR 特性位。
还有一处细节与“两次通知”配合得很紧:读也走 primary。 这让客户端完全不用管副本间的同步与串行化,也把“谁来决定顺序”收敛到了一个点上 —— 版本号由 primary 分配、更新按版本号提交。两条通知的分工因此有了一个明确的执行者:ack 与 commit 都由 primary 发出,副本只负责落盘并回确认。
代价是 primary 成了单 PG 的热点 —— 这也是 primary-affinity 这个旋钮存在的原因:既然读也走 primary,能不能选出一个“不那么弱的设备”来当 primary 就是一件值得调的事(取值范围 [0-1],默认 1)。
版本演进:同一场争论的三次来回
这项设计的后续最有意思:它否掉的那条路,实现里先走了回去,多年后又按它的思路重做了。
2004/2006 Ceph / CRUSH 的原始设计
│ · OSD 本地存储用 EBOFS:用户态、直接对裸块设备、
│ B-tree + extent 分配、原子事务
│ · 明确否掉“用 ext3 这类通用文件系统”,三条理由:
│ 内核接口限制判断更新何时安全落盘、POSIX 不支持
│ “数据+属性”原子更新、VFS 与页缓存为别的负载设计
▼
中间期 FileStore —— 回到了通用文件系统
│ · 依赖标准文件系统(通常是 XFS)+ 一个 KV 数据库
│ (先 LevelDB,后来 RocksDB)存部分元数据
│ · 官方评价:经过充分测试、生产广泛使用,
│ 但因整体设计与“依赖传统文件系统存对象数据”而
│ 存在大量性能缺陷
▼
12.2.z BlueStore —— 按原始设计的思路重做
(Luminous) · 成为「默认且推荐」的后端
│ · 直接消费裸块设备/分区,避开 XFS 这类中间抽象层
│ · 用内嵌的 RocksDB 管内部元数据
│ (含“对象名到磁盘块位置”的映射)
▼
Reef FileStore 在这一代被弃用、不再支持
│
Squid 新增 MSR 规则类型这条线的形状比“后来改进了”更有信息量:它是一次完整的往返。 这项设计否掉通用文件系统 → 工程上先退回去(FileStore + XFS)→ 多年后重新按“自己管裸设备”做(BlueStore)。这不能读成“设计对了、实现走了弯路”,因为中间那一步换来了十年生产经验 —— 官方对 BlueStore 的定位原话是“它的设计基于十年支持和运营 FileStore OSD 的经验”。
BlueStore 的六条特性,逐条都能对上当年那三条理由:
| 当年否掉通用文件系统的理由 | BlueStore 的处理 |
|---|---|
| 内核接口限制“判断对象更新何时安全落盘”的能力 | 直接管裸设备,自己定义更新与提交语义;全数据与元数据默认带校验和,任何读取都要验证后才返回 |
| POSIX 不支持“数据 + 属性”的原子更新 | 用内嵌 RocksDB 管内部元数据(含对象名到块位置的映射),元数据与数据在同一套自管理结构里 |
| VFS 与页缓存是为别的接口与负载设计的 | 不创建也不挂载常规文件系统,绕开这一整层;数据目录是 tmpfs |
另外两条特性是 FileStore 时代做不出来的:多设备元数据分层(把内部 journal/WAL 与 DB 放到更快的设备上 —— 只有当那块设备比主设备快时才划算,否则元数据会回落到主设备);开销更低的写时复制(RBD 与 CephFS 快照依赖它,而纠删码池的两阶段提交也依赖克隆)。
版本演进这一节还有一处与“分层”直接相关的观察。 BlueStore 把元数据交给内嵌的 RocksDB 管,而 FileStore 是把一部分元数据交给独立的 KV 数据库管 —— 两者都在用 LSM 系的结构(见 HBase 与 LSM-Tree)。差别在于 BlueStore 的那套结构是进程内的、要自己管设备,因此它能承诺“全数据与元数据默认带校验和、任何读取都要先验证”这种更强的性质。把 KV 引擎内嵌进来,换来的是对“什么时候算写完”这件事的完全控制 —— 而这正是当年否掉内核接口的那条理由。
同一件事在 CRUSH 那一侧也成立:device class 用“影子层级”实现,而没给 CRUSH 加新语法 —— 换来的是对旧客户端的完全兼容。两处都在用“把新机制藏进既有语义里”的办法换取平滑迁移,代价是多一层需要同步维护的隐藏结构(一份影子层级、一个进程内的 KV 库)。
这条往返还解释了一件容易被误读的事:FileStore 那一代更像把设计里被推迟的那部分暂时外包了出去,而不是走了弯路。 它用“只用 XFS”这条硬约束、加上一个独立的 KV 数据库,把当年三条理由的痛处各自压住;等到自管理裸设备的工程量可承受、且已经积累了十年该管哪些元数据的经验,那三条理由就重新成了主线。
CRUSH 那一侧:持续修细节,不改架构
CRUSH 这一侧的演进则是一条“持续修细节”的线,不改架构:
| 代际 | 引入什么 | 解决的问题 |
|---|---|---|
| bobtail(CRUSH_TUNABLES2) | choose_local_tries 2→0、choose_local_fallback_tries 5→0、choose_total_tries 19→50、chooseleaf_descend_once 0→1 | 重试策略的取值不再合适 |
| firefly(TUNABLES3) | chooseleaf_vary_r 0→1、straw_calc_version 0→1 | straw 在 bucket weight 为 0 或内部权重各异时会不按比例地错误分布数据 |
| hammer(CRUSH_V4) | straw2 | 旧 straw 在被调权重的项之外也改变映射 |
| jewel(TUNABLES5) | chooseleaf_stable 0→1 | 叶子选择的稳定性 |
| Luminous | device class + shadow 层级 | 同一池里混用 HDD/SSD/NVMe 而不破坏旧客户端 |
| Squid | MSR 规则 | 纠删码下“每个故障域放多个 OSD”且遇到 out 能重试 |
这张表最值得记的规律:CRUSH 的每一次演进都在修“分布的正确性或稳定性”,而不是在换思路。 它从头到尾都是“伪随机 + 分层 map + 规则”,变的只是映射在被扰动时该动多少、以及权重该怎么解释。这正是一个“把表换成函数”的设计该有的演进形态 —— 函数的形式一旦确定,剩下的工作就是把它的边界条件补完。
一处方法上的对照:它的 OSD 本地存储经历了“自己管裸设备 → 用通用文件系统 → 又自己管裸设备”的往返,而 CRUSH 几乎没有往返。差别在于一个有状态要迁移、一个只是算得对不对 —— 换 CRUSH 实现只影响计算,换本地后端要重做数据布局。这也是“把复杂度放在无状态的那一侧”在长期演进上的具体收益。
这条往返还带出一处方法论上的对照:CRUSH 几乎没有经历过类似的往返。 它从最初的设计到今天的形态一直是“伪随机 + 分层 map + 规则”,历次版本改的都是映射在被扰动时该动多少、权重该怎么解释这类细节。差别在于一个有状态要迁移、一个只是算得对不对 —— 换 CRUSH 实现只影响计算,换本地后端要重做数据布局。这也是“把复杂度放在无状态的那一侧”在长期演进上的具体收益:无状态的那一半可以反复重写,有状态的那一半只能一次做对。
这条返还在方法论上还有一层:它区分了“设计上被否掉”与“工程上被推迟”。 当年否掉通用文件系统的三条理由,在实现里并没有被推翻 —— FileStore 那一代是靠一个更强的约束(只推荐 XFS)与一套 KV 数据库把三条理由各自的痛处压住,而不是解决了它们。等到自管理裸设备的工程量变得可承受、且经验足够判断该管哪些元数据,那三条理由就又成了主线。
这条区分可以推广:读一份设计时,把“它否掉了什么”与“它为什么当时做不到”分开记 —— 前者是判断,后者是约束;而约束会随时间变化。
不适用于什么:算得出位置这件前提的边界
成立前提
- 所有参与者能算出同一答案。 这是最硬的一条 —— 客户端与内核必须支持同一代 CRUSH 语义。混合版本集群里,升级 tunables 会让旧客户端算出不同的位置。
- 故障是常态。 整套自愈机制建立在“任何时刻都有若干 OSD 不可用”这个假设上;如果硬件可靠性高到故障罕见,那套机制的复杂度就白付了。
- 元数据能有一个唯一权威。 动态子树分区的前提是“对任一块元数据,只有唯一一个 MDS 负责缓存连贯性与更新串行化”。
- 负载形态偏 HPC 型。 目标场景是几万到几十万台主机同时读写同一个文件、或在同一个目录里建文件。
- 能接受 near-POSIX。 扩展接口、选择性放宽一致性语义,是刻意的选择。
前提被违反时的后果
| 违反的前提 | 后果 |
|---|---|
| 客户端版本混杂 | 不能升 tunables —— 升了会让旧客户端映射错位;官方警告“曾用过非 legacy 值的集群不要再跑旧 ceph-osd” |
| 需要严格 POSIX 语义 | 超出设计目标;一致性是被主动放宽的,某些语义要客户端缓存来补 |
| 元数据没有稳定的权威边界 | 动态子树分区的迁移与连贯性都会变复杂 —— 官方把“挑什么元数据、往哪迁、什么时候迁”列为这套设计里最大的难点 |
| 极小规模或极低故障率 | 三类守护进程 + monitor 集群 + CRUSH 规则体系的运维面一点没少,收益却拿不到 |
| 大量小对象、高频元数据操作 | CRUSH 的 |
| 元数据出现强热点 | 分区机制应付不了热点或突发流量,要靠读复制或按文件名哈希这类特殊处理 |
与相邻系统的对照
| Ceph | GFS | Dynamo | Haystack | |
|---|---|---|---|---|
| 数据位置怎么定 | 算(CRUSH + 分层 map + 规则) | 查 master 内存表 | 算(哈希环 + 虚拟节点) | 查内存映射 |
| 位置算式的表达能力 | 分层的故障域约束 + 设备权重 | 无(master 决策) | 扁平环 | 无(Directory 查表) |
| 元数据在哪 | MDS 集群(有唯一权威) | master(单点) | 每个节点 | 全在内存 |
| 恢复由谁驱动 | 每个 OSD 独立判断 | master | 反熵 + hinted handoff | pitchfork 标记 + bulk sync |
| 故障假设 | 常态 | 例外 | 例外 | 例外 |
这张表里最值得注意的一行是“位置算式的表达能力”。 三者都在“算”,但只有 Ceph 的算式里带了故障域这一维 —— Dynamo 的哈希环是扁平的,要靠虚拟节点缓解倾斜;Ceph 的 map 是分层的,于是“同排不同柜”这类约束可以直接用规则写出来。这一维的代价是 map 必须被维护、且必须被所有参与者同步解释(见上文那条硬依赖)。
代价落在谁身上
| 代价 | 落在谁身上 |
|---|---|
| 设计 CRUSH 规则(取几个、隔开哪一层) | 运维 |
| tunables 与客户端版本的联动 | 运维(升级顺序) |
| MDS 的元数据分布与迁移策略 | 运维 —— 官方称这是设计里最大的难点 |
| 位置计算的开销 | 客户端与服务端的 CPU |
| 热点目录的额外处理 | 应用与运维 |
| monitor 那一簇的强一致开销与存储量 | 运维 |
这张表的落点与前一篇形成了对照:Haystack 把复杂度推进数据布局里,运行期几乎没有旋钮;Ceph 把复杂度推进“map 与规则”里,运行期有一整套 map/规则/tunables/类别可以调 —— 而这也是它的运维负担所在。 两条路的取舍点是同一个问题:你更愿意在建的时候想清楚,还是更愿意在跑的时候能调。
与 Haystack 的对照在这里最清楚:两者都在减少“每个对象要付多少字节”,但减的位置不同。 Haystack 把元数据压进内存(10 字节/照片、40 字节/图),代价是“全放内存”成了设计的前提;Ceph 让元数据本身不需要那么多字段**(80 字节的 inode,且不含任何分配元数据),代价是位置必须算得出来。一个动的是“存在哪”,一个动的是“存什么”。
这也解释了两者在故障假设上的分野:Haystack 的 Directory 是“复制的数据库 + memcache”,它的正确性靠这一层保证;Ceph 把位置交给函数之后,需要同步的状态只剩 cluster map,而 map 的更新频率远低于对象的归属变化。 换句话说:前者的集中点管“每个对象在哪”,后者的集中点管“集群长什么样”。
排查:从症状到判据
症状
│
├─ HEALTH_WARN: crush map has non-optimal tunables
│ ──▶ tunables 停在 legacy 档
│ └─ 升之前先确认所有客户端与内核达标
│
├─ 某个 OSD 显示 down ────▶ 先别动数据
│ └─ down 只转移 primary 职责;
│ 等待是否恢复,再决定要不要 out
│
├─ PG 卡住(长时间不完成)▶ 看 peering 是否卡在版本号协商
│ └─ primary 必须先确定正确的 PG 状态
│ 并分享给副本,才允许 I/O
│
├─ OSD 之间利用率不均 ────▶ 依次看三样:PG 数够不够(每 OSD ~100)、
│ weight 对不对、要不要开 mgr 的 balancer
│ └─ primary-affinity 可把读负载从弱设备挪开
│
├─ 改过 map 之后大量迁移 ─▶ 属预期,但要分清是哪一类改动
│ ├─ 改 weight / 加层级 ⇒ 迁移范围可控
│ ├─ 改 bucket 算法(straw → straw2)⇒ 取决于权重差异
│ └─ 改 tunables(尤其 chooseleaf_vary_r)⇒ 量可能很大
│
└─ 大量小对象时 CPU 高 ──▶ 看是不是位置计算被请求数放大了
└─ 单次 $O(\log n)$、几十微秒,
但乘以高 IOPS 就是实打实的开销三条判读原则
- 先分清
down与out,因为它们的处置相反。down是“联系不上”,预期会自己好;out是“退出分布”,数据已经开始搬。看到 down 就着急去 out,会把一次网络抖动放大成一场全集群的数据搬迁。 - PG 卡住先怀疑 peering,不要先怀疑网络。 硬约束是“primary 确定并分享正确的 PG 状态之后才允许 I/O” —— 所以卡住的位置通常在版本号协商这一步,而不是在数据路径上。
- “迁移量大”要归因到具体是哪一类改动。 改 weight、换 bucket 算法、升 tunables 三者都会触发迁移,但量与可控性完全不同:weight 可以一点点调,tunables 一旦改了就是全局的。
还有一条与“该不该开 balancer”有关的判据。 官方提供了两种权重集合(compat 与 per-pool),并推荐用 per-pool 加 ceph-mgr 的 balancer 模块。这两者的差别是“人工算权重”与“让系统自己平衡” —— 而它成立的前提正是 CRUSH 的映射是函数:既然位置能算,就能反复算、反复优化。这是“用生成函数替代分配表”在运维侧顺带拿到的一样东西。
还有一条边界与“算得出位置”这件事本身的成本有关:它要求 map 是小的。 位置算得出来,前提是每个参与者都持有完整 map —— 而 map 的规模随 OSD 数量线性增长,它的每次变化又要广播给所有参与者。当 OSD 数量到几十万时,“让所有人保持一致”本身就成了一项工作量。 这是“算而不是查”在极大规模下的边界:它省掉了查询,但没有省掉共识 —— 只是把共识压到了极少更新的 map 上。
由此能推出一条判断:这套设计把“频繁变化的东西”(对象的归属)做成了无状态的计算,把“极少变化的东西”(集群的形状)留成了需要同步的状态。 凡是能这样切分的系统,都能把大部分请求变成纯本地运算;切不动的系统就只能在每次请求上付一次协调。
最后一条与“该调哪个旋钮”有关。 这套系统里能调的东西分两类,性质完全不同:一类改映射(weight、bucket 算法、tunables),一类只改谁承担负载(device class、primary-affinity、balancer)。前者会触发数据迁移,后者不会 —— 分清这两类,能避免一类代价很高的误操作:把“想均衡一下负载”做成了“改一条 tunables”。
相关
- 一致性哈希算法 —— 最直接的对照:一致性哈希用哈希环 + 虚拟节点把数据摊到节点上、目标是节点增减时少搬数据;CRUSH 用分层的 cluster map + placement rules,在同一个"算而不是查"的思路下多管了故障域约束与设备权重两件事
- Dynamo —— 同为"位置算得出来",但 Dynamo 的环是扁平的(靠虚拟节点缓解倾斜),Ceph 的 map 是分层的(靠规则表达"同排不同柜"这类约束);Dynamo 为写可用性放弃强一致,Ceph 的元数据侧反而有唯一权威 MDS + 主副本缓存
- Haystack —— 正面对照:Haystack 把元数据整体搬进内存去查(10 字节/照片、40 字节/图),Ceph 让元数据本身小到 80 字节的 inode、且不需要分配元数据
- GFS / Bigtable —— 两篇都靠查表定位(master 内存表 / METADATA 表),且 master 是单点或需额外协调;Ceph 无中心元数据定位
- HBase 与 LSM-Tree —— 局部存储都选了"绕过通用文件系统":HBase 走 HFile 多层结构,Ceph 走用户态裸设备的 EBOFS;两篇都强调更新串行化与落盘提交应当分开
参考
- S. A. Weil, S. A. Brandt, E. L. Miller, D. D. E. Long, C. Maltzahn. Ceph: A Scalable, High-Performance Distributed File System. OSDI 2006.
- S. A. Weil, K. T. Pollack, S. A. Brandt, E. L. Miller. Dynamic Metadata Management for Petabyte-Scale File Systems. SC 2004.
- Ceph Documentation. CRUSH Maps. https://docs.ceph.com/en/latest/rados/operations/crush-map/
- Ceph Documentation. Storage Devices. https://docs.ceph.com/en/latest/rados/configuration/storage-devices/
- Ceph Documentation. BlueStore Configuration Reference. https://docs.ceph.com/en/latest/rados/configuration/bluestore-config-ref/
YJ