Ceph Stable_mod 与 CRUSH算法心得
最近在研究Ceph 关于 pg 和 osd 的一些算法。在参考了徐小胖大神的文章后 link. 以及《Ceph之RADOS设计原理与实现》后得到一些总结,本文部分采用了《大话Ceph–CRUSH那点事儿》的文章架构及内容,再此基础上进行了一定的补充。
引言
那么问题来了,把一份数据存到集群中分几步?
Ceph的答案是:两步。
- 计算PG
- 计算OSD
首先,要明确Ceph的一个规定:在Ceph中,一切皆对象。
不论是视频,文本,照片等一切格式的数据,Ceph统一将其看作是对象,因为追其根源,所有的数据都是二进制数据保存于磁盘上,所以每一份二进制数据都看成一个对象,不以它们的格式来区分他们。
那么用什么来区分两个对象呢?对象名。也就是说,每个不同的对象都有不一样的对象名。于是,开篇的问题就变成了:
把一个对象存到集群中分几步?
Ceph组织成一个集群,这个集群由若干的磁盘组成,也就是由若干的OSD组成。于是,继续简化问题:
把一个对象存到一堆OSD中分几步?
计算PG
假设一个存储池有100个pg。那么pg在存储池中编号为0、1、2、3…99
首先有Ceph客户端负责生成一个字符串形式的对象名,然后基于对象名(对象名+命名空间)计算得到一个32位的哈希值。
我们很容易想到拿PG数取模的方式 :
Hash(Object)mod pg_num
这样Object必然计算出一个[0,99]之间的PG编号。那么计算PG采用 mod 算法可行吗?
当集群规模增大,pg数量必然增加。也就是pg_num已经发生了变化, Hash(Object)mod pg_num 的值将会完全不同,那么几乎所有的对象都会在pg中进行大量的无规则转移。这将带来长时间的业务停顿已经性能的消耗。显然,为了避免造成长时间的业务停顿,我们应该尽可能的减少对象移动。
解决思路:如果将PG看成一个集合,那么容易理解我们总是期望集合中的元素有尽可能多的共同只处,以便为之指定一些通用的运算规则。
x mod 2^n = x&(2^n-1)
我们对pg数量总是设定为2^n
假定存储池中的某个对象,经过计算后32位哈希值为0x4979FA12,并且该存储池的pg_num为256 ,则由:
0x4979FA12 mod 256 = 18
并且我们很容易验证该存储池内哈希值如下的其他对象,通过模运算同意会被映射至PGID为18的PG之上。
0x4979FB12 mod 256 =18
0x4979FC12 mod 256 =18
0x4979FD12 mod 256 =18
…
可以发现被映射到18的Hash(Object)的后8位是相同的。
可见我们仅仅使用了这个“全精度“32位 哈希值的后8位。因此,如果pg_num可以写成2n的形式(例如这里的256可以写成28,即是2的整数次幂),则每个对象执行PG映射时,其32位哈希值中仅有低8位是有意义的。所以归属于同一个PG的对象,其32位哈希值中低n位都是相同的。基于此,我们将2^n-1 成为pg_num的掩码,其中n位掩码的位数。
但是现实总不会如你愿,假设pg_num不是2的整数次幂,就像上面举例pg=100,即无法写成2^n的形式 。例如,假定pg_num=12,此时n=4,容易验证如下输入经过模运算后结果一致,但是它们只有低2位是相同的。
可能有人会问这个n是怎么来的,其实n就是PG数二进制表示中1最高位的1。ceph中如果设置PG数目为a,那么程序会得到一个最小的n,使 a <= 2^n,例如:(a=12, n=4), (a=16, n=4), (a=17, n=5),以此类推。
0x00 mod 12 = 0
0x0C mod 12 = 0
0x18 mod 12 = 0
0x24 mod 12 = 0
因此需要针对普通的取模操作加以改进,以保证针对不同输入,当计算结果相等时,维持”输入具有尽可能多的、相同的低位“这个相对有规律的特性。
引入stable_mod算法
一种改进的方案是使用掩码的方式来代替取模操作。例如PG数目对应的最高比特位为n,则其掩码为2^n -1, 将某个对象映射到PG时,直接使用 object & (2^n -1)即可。这种直接与的方式其实就是取object二进制的后 n 位作为pg number,这种方式不仅高效,而且运算速度快。但是 这种方案存在一个潜在的问题,如果运维人员设置的PG数是2的幂次方的话,那么这种方案比较完美。如果PG数目不是2的幂次方的话这种映射会产生空穴,即将某些对象映射到一些实际上不存在的PG上。如下图所示:
上图中如果如果PG数目为12,但是n=4,直接hash的话会产生 0~15 共计16中不同的结果,但是12 ~ 15这4个PG是不存在的,为了解决这个问题ceph做了降级方案。因为 n 为PG数的最高位,因此可以得到如下的方程式。
2^(n-1) < PG数 <= 2^n
由此可得 [0,2^(n-1)]内PG一定是存在的,可以通过 object & 2^(n-1) 来将那些实际上不存在的PG重新映射到这个小的区间内,如下图所示:
如上图所示,ceph会将映射出不存在的PG重新映射到前面存在的区间中。这样,退而求其次,如果pg_num不是2的整数次幂,我们只能保证相对每个PG而言,映射至该PG的所有对象,其哈希值低N-1位都是相同的。
所以如果ceph集群设置的PG数目不是2的幂次方的话,可能会造成中间某一段的PG所承载的数据高于其它PG,例如:PG数为12时会造成4,5,6,7四个PG所承载的对象数是其它PG的两倍,所以集群在稳定时PG数最好的2的幂次方。
改进后的映射方法称为stable_mod,其逻辑代码如下:
/*
x: 对象key值
b: pg 数
bmask: 掩码
*/
static inline int ceph_stable_mod(int x, int b, int bmask)
{
if ((x & bmask) < b)
return x & bmask;
else
return x & (bmask >> 1);
}
任然已pg_num等于12为例,可以验证:如果采用stable_mod方式,在保证结果相等的前提下,如下输入的低n-1=3位都是相同的。
仍然以pg_num=12为例,可以验证:如果采用stable_mod方式,在保证结果相等的前提下,如下输入的低n-1=3位都有相同的。
0x05 stable_mod 12 = 5
0x0D stable_mod 12 =5
0x15 stable_mod 12 =5
0x1D stable_mod 12 =5
综上,无论pg_num是否为2的整数次幂,采用stable_mod都可以产生一个相对有规律的对象到PG的映射结果,这是PG分裂的一个重要理论。
好,当我们采用了stable_mod算法来计算pg,我们再来考虑pg数量增加这个问题。假定某个存储池老的pg_num为24,新的pg_num为26。
之前我们已经验证了pg内所以对象的哈希值后四位都相同(假定其PGID=Y.X,其中X=0bX3X2X1X0)为例,容易验证其中所有对象的哈希值都可以分布如图1-6所示的4中类型(按前面的分析,分裂前后,对象哈希值中只有低6位有效,图中只展示这6位)
依次针对这4种类型的对象PG(Y.X)使用新的pg_num(2^4 -> 2^6)再次执行stable_mod,结果如表1-2所示
为此,我们需要创建3个新的PG来分别转移部分来自老PG中的对象,参考表1-2,容易验证这3个新的PG在存储池中的编号可以通过(m*16)+X (m=1,2,3)得到,同时由于概率上每种类型的对象数量相等,因此完成对象转移后每个PG承载的数据任然可以保持均衡。
针对所有老的PG重复上诉过程,最终可以将存储池中的PG数量调整为原来的4倍。又因为在调整PG数量的过程中,我们综上基于老PG(称为祖先PG)产生新的孩子PG,并且新的孩子PG中最初的对象全部来源于老PG,所有这个过程形象的称为PG分裂。
计算OSD
在讨论CRUSH算法之前,我们来做一点思考,可以发现,上面两个计算公式有点类似,为何我们不把
CRUSH(PG_ID) ===> OSD
改为HASH(PG_ID) %OSD_num ===> OSD
我可以如下几个由此假设带来的副作用:
- 如果挂掉一个OSD,
OSD_num-1
,于是所有的PG % OSD_num
的余数都会变化,也就是说这个PG保存的磁盘发生了变化,对这最简单的解释就是,这个PG上的数据要从一个磁盘全部迁移到另一个磁盘上去,一个优秀的存储架构应当在磁盘损坏时使得数据迁移量降到最低,CRUSH可以做到。 - 如果保存多个副本,我们希望得到多个OSD结果的输出,HASH只能获得一个,但是CRUSH可以获得任意多个。
- 如果增加OSD的数量,OSD_num增大了,同样会导致PG在OSD之间的胡乱迁移,但是CRUSH可以保证数据向新增机器均匀的扩散。
所以HASH只适用于一对一的映射关系计算,并且两个映射组合(对象名和PG总数)不能变化,因此这里的假设不适用于PG->OSD的映射计算。因此,这里开始引入CRUSH算法。
引入CRUSH算法
千呼万唤始出来,终于开始讲CRUSH算法了,如果直接讲Sage的博士论文或者crush.c
的代码的话,可能会十分苦涩难懂,所以我决定尝试大话一把CRUSH,希望能让没有接触过CRUSH的同学也能对其有所理解。
首先来看我们要做什么:
- 把已有的PG_ID映射到OSD上,有了映射关系就可以把一个PG保存到一个磁盘上。
- 如果我们想保存三个副本,可以把一个PG映射到三个不同的OSD上,这三个OSD上保存着一模一样的PG内容。
再来看我们有了什么:
- 互不相同的PG_ID。
- 如果给OSD也编个号,那么就有了互不相同的OSD_ID。
- 每个OSD最大的不同的就是它们的容量,即4T还是800G的容量,我们将每个OSD的容量又称为OSD的权重(weight),规定4T权重为4,800G为0.8,也就是以T为单位的值。
现在问题转化为:如何将PG_ID映射到有各自权重的OSD上。这里我直接使用CRUSH里面采取的Straw
算法,翻译过来就是抽签,说白了就是挑个最长的签,这里的签指的是OSD的权重。
那么问题就来了,总不至于每次都挑容量最大的OSD吧,这不分分钟都把数据存满那个最大的 OSD了吗?是的,所以在挑之前先把这些OSD搓一搓
,这里直接介绍CRUSH的方法,如下图(可忽视代码直接看文字):
- CRUSH_HASH( PG_ID, OSD_ID, r ) ===> draw
- ( draw &0xffff ) * osd_weight ===> osd_straw
- pick up high_osd_straw
第一行,我们姑且把r当做一个常数,第一行实际上就做了搓一搓的事情:将PG_ID, OSD_ID和r一起当做CRUSH_HASH的输入,求出一个十六进制输出,这和HASH(对象名)完全类似,只是多了两个输入。所以需要强调的是,对于相同的三个输入,计算得出的draw
的值是一定相同的。
这个draw
到底有啥用?其实,CRUSH希望得到一个随机数,也就是这里的draw
,然后拿这个随机数去乘以OSD的权重,这样把随机数和OSD的权重搓在一起,就得到了每个OSD的实际签长,而且每个签都不一样长(极大概率),就很容易从中挑一个最长的。
说白了,CRUSH希望随机
挑一个OSD出来,但是还要满足权重越大的OSD被挑中的概率越大,为了达到随机的目的,它在挑之前让每个OSD都拿着自己的权重乘以一个随机数,再取乘积最大的那个。那么这里我们再定个小目标:挑个一亿次!从宏观来看,同样是乘以一个随机数,在样本容量足够大之后,这个随机数对挑中的结果不再有影响,起决定性影响的是OSD的权重,也就是说,OSD的权重越大,宏观来看被挑中的概率越大。
这里再说明下CRUSH造出来的随机数draw
,前文可知,对于常量输入,一定会得到一样的输出,所以这并不是真正的随机,所以说,CRUSH是一个伪随机
算法。下图是CRUSH_HASH
的代码段,我喜欢叫它搅拌搅拌再搅拌得出一个随机数:
如果看到这里你已经被搅晕了,那让我再简单梳理下PG选择一个OSD时做的事情:
- 给出一个PG_ID,作为CRUSH_HASH的输入。
- CRUSH_HASH(PG_ID, OSD_ID, r) 得出一个随机数(重点是随机数,不是HASH)。
- 对于所有的OSD用他们的权重乘以每个OSD_ID对应的随机数,得到乘积。
- 选出乘积最大的OSD。
- 这个PG就会保存到这个OSD上。
现在趁热打铁,解决一个PG映射到多个OSD的问题,还记得那个常量r
吗?我们把r+1
,再求一遍随机数,再去乘以每个OSD的权重,再去选出乘积最大的OSD,如果和之前的OSD编号不一样,那么就选中它,如果和之前的OSD编号一样的话,那么再把r+2
,再次选一次,直到选出我们需要的三个不一样编号的OSD为止!
当然实际选择过程还要稍微复杂一点,我这里只是用最简单的方法来解释CRUSH在选择OSD的时候所做的事情。
下面我们来举个例子,假定我们有6个OSD,需要从中选出三个副本:
osd_id | weight | CRUSH_HASH | (CRUSH_HASH & 0xffff)* weight |
---|---|---|---|
osd.0 | 4 | 0xC35E90CB | 0x2432C |
osd.1 | 4 | 0xA67DE680 | 0x39A00 |
osd.2 | 4 | 0xF9B1B224 | 0x2C890 |
osd.3 | 4 | 0x42454470 | 0x111C0 |
osd.4 | 4 | 0xE950E2F9 | 0x38BE4 |
osd.5 | 4 | 0x8A844538 | 0x114E0 |
这是r = 0
的情况,这时候,我们选出(CRUSH_HASH & 0xFFFF) * weight
的值最大的一个,也就是osd.1
的0x39A00
,这就是我们选出的第一个OSD。
然后,我们再让r = 1
,再生成一组CRUSH_HASH
的随机值,乘以OSD的weight,再取一个最大的得到第二个OSD,依此得到第三个OSD,如果在此过程中,选中了相同的OSD,那么将r
再加一,生成一组随机值,再选一次,直到选中三个OSD为止。
看似完美,但引入PG分裂及之后,如果仍然直接使用PGID作为CRUSH输入,据此计算新增的孩子PG在OSD之间的映射结果,由于此时每个PG的PGID都不相同,必然触发大量新增孩子PG在OSD之间的迁移。考虑到分裂之前PG与其保持相同的副本分布,这样当分裂完成之后,整个集群的PG分布任然是均衡的。为此,每个存储池除了记录当前的PG数量之外看,为了应对PG分裂,还需要额外记录分裂之前祖先PG的数量,后者称为PGP 。
最终,利用CRUSH执行PG映射时,我们不是直接使用PGID,而是转而使用其在存储池内的唯一编号先针对pgp_num执行stable_mod,再与存储池标识一起哈希之后作为CRUSH的特征输入,即可保证每个孩子PG与其祖先产生相同的CRUSH计算结果。(因为此时祖先和孩子PG产生的CRUSH特征输入都相同),进而保证两者产生相同的副本分布。
- CRUSH_HASH( PG_ID stable_mod pgp_num, OSD_ID, r ) ===> draw
- ( draw &0xffff ) * osd_weight ===> osd_straw
- pick up high_osd_straw