现在的位置: 首页 > 综合 > 正文

构建一致性哈希ring Part1

2013年07月07日 ⁄ 综合 ⁄ 共 9210字 ⁄ 字号 评论关闭
文章目录

******************转载请注明出处!**********

最后更新:2011年8月22日22:29:01

写在前面。

原打算只从代码角度做些swift ring的分析工作。但无意中看到了gholt的文章,收益良多,便动了翻译的念头。

“翻译向来是一件费力不讨好的事情。”这是郑烨在某本书序言中的如是说。自己曾零散的翻译过一些,深有同感,因此不打算只是原样翻译。

虽然本系列文章源自gholt,但不尽相同。

原文链接:http://tlohg.com/building-a-consistent-hashing-ring-part-1(这只是part 1,其余part请自行查找)

本系列文章的代码下载:http://code.google.com/p/swift-cn-doc/downloads/list

原文结构索引

part1:介绍普通hash的方法,引入“平衡”的度量标准

part2:线性hash存在的问题:增加一个bucket会导致hash结果剧烈变动;分区域以减少变动影响范围;进一步减少变动;去除冗余代码

part3:代码转型到swift中的类ring形式

part4:增加replica和zone的概念,并添加进类ring原型

part5:代码改进成module, 类ring原型增加weight概念

 

本系列文在gholt的基础上加上了自己的理解,以飨读者。

系列文章结构索引

part1:普通hash及其问题,对应原文part1&part2

part2:swift实际中的情况介绍和部分特性的引入,提出问题。相应问题的解决,并附“ring已废弃方案”。对应原文part3&part4。

part3:对应原文part5。引入weight属性,并封装成module。

part4:对swift中的几个文件(ringdata.py;ringbuilder.py的简单分析)

0 准备工作

为了便于之后python代码的说明,这里需要做一些准备工作。

0.1 /和%的关系

一个除法求商(/),一个除法求余(%),有什么区别?

其实没什么区别。只是映射的关系不一样。如图1.1所示

总结如下:

M%N:把max(M)个区映射到N个区,每个区至少有个元素

M/N:把max(M)个区映射到个区,每个区不会多于N个元素

以上的基本前提是:应保证M个区的样本数量是足够多的。

 

0.2数据分区和hash结果“抖动”的关系

这个小节名称很拗口。但确实找不到相对合适的名字...-_-!

普通hash函数会有剧烈抖动情况。如图1.2所示。

例如:hash函数为:x->x/N,N取2、3,x取值[1,10]

N由2变到3过程中,hash结果80%发生了变化(数字1、3除外)。这种抖动是需要避免的。

 

对数据分区之后,抖动情况依然存在。如图1.3所示。

例如:hash函数为:x->x/N,N取5、6、7,x取值为[1,7][11,17][21,27][31,37]

N由6->7过程中,红色和绿色标识的hash结果是不变的;

N由5->6过程中,蓝色和绿色标识的hash结果是不变的。

数据分区后,情况比图1.2好一些,但仍不理想,最好时有50%的结果发生了变化。

0.3还是一致性哈希

一致性哈希来源于[1]论文,起初是为了解决因特网中的热点(Hot spot)问题,被广泛应用于路由算法中。Dynamo中使用一致性哈希思想对数据进行分区,同时引入虚节点概念,以实现系统的增量扩展[2]。

一致性哈希很多博文都有深刻的介绍。比如[3]、[4],这里不赘述,只介绍思路。

一致性哈希的两种思路:

1.迁移为主要特点([1]论文中的思路,swift初期采用,后废弃)

2引入虚结点,减少移动为特点(swift现采用)

 

如图2.1,为第1种思路。

每个key都映射到hash区间里某方向(例如顺时针)最近的那个bucket。当有新bucket加入时,key的映射更改到新的bucket。如图3.1中,key2原映射到Bucket C;当Bucket D加入时,key2映射到Bucket D。

 

如图2.2,为第2种思路

每个Bucket对应着若干个虚结点(图中标识的为5个)。key映射的是虚结点。当有新的Bucket加入时,需要从原有的Bucket中把虚结点分给新的Bucket,原有的key映射并不变化。如图2.2中,Bucket D加入时,原来归属于Bucket B的虚结点被分配给Bucket D。无论虚节点是否有key与其映射,改变的都是虚结点的归属,即虚结点和Bucket之间的关系。

这里需要说明的是:图2.2为了简化,在hash环空间上只标注了15个虚结点,考虑到replica,实际上只有5个真正存储用的虚结点(这个数可以定制,这里用5只是为了画图方便,实际情况下,swift中默认会有2^18个虚结点。)。且每个Bucket分到5个(5*3/3)。当Bucket D加入时,虚节点重新分配,每个(现在有4个) Bucket约分到4(5*3/4约为4)个。Bucket D只分到3个虚结点,总数为15个不变。

 

0.4二分查找bisect_left

bisect.bisect_left(list, item[,lo[, hi]])

实际上要求list是有序的,然后在[lo,hi]之间找到item应该在的位置。如果item已经存在于list中,那么插入到原位置的左边。

如果list不是有序的,也能找到位置。当然,找到的位置是没有意义的。

 

python文档中对bisect_left的解释有些让人郁闷。例子刚开始也没看明白。经指点后去找的源代码。这里贴出来,留着以后查看。

Python-2.6.7 Modules\_bisectmodule.c: line109

static Py_ssize_t
internal_bisect_left(PyObject *list, PyObject *item, Py_ssize_t lo, Py_ssize_t hi)
{
    PyObject *litem;
    Py_ssize_t mid, res;

    if (lo < 0) {
        PyErr_SetString(PyExc_ValueError, "lo must be non-negative");
        return -1;
    }
    if (hi == -1) {
        hi = PySequence_Size(list);
        if (hi < 0)
            return -1;
    }
    while (lo < hi) {
        mid = (lo + hi) / 2;
        litem = PySequence_GetItem(list, mid);
        if (litem == NULL)
            return -1;
        res = PyObject_RichCompareBool(litem, item, Py_LT);
        Py_DECREF(litem);
        if (res < 0)
            return -1;
        if (res)
            lo = mid + 1;
        else
            hi = mid;
    }
    return lo;
}

OK,准备工作做完。之后就可以开始ring的演进之旅!

构建一致性哈希ring  Part1

注意:以代码原始博客中的顺序命名。下同。

ring1.py

from hashlib import md5
from struct import unpack_from
from time import time

NODE_COUNT = 100
DATA_ID_COUNT = 10000000

begin = time()
node_counts = [0] * NODE_COUNT
for data_id in xrange(DATA_ID_COUNT):
    data_id = str(data_id)
    hsh = unpack_from('>I', md5(data_id).digest())[0]
    node_id = hsh % NODE_COUNT
    node_counts[node_id] += 1
    
desired_count = DATA_ID_COUNT / NODE_COUNT
print '%d: Desired data ids per node' % desired_count
max_count = max(node_counts)
over = 100.0 * (max_count - desired_count) / desired_count
print '%d: Most data ids on one node, %.02f%% over' % (max_count, over)

min_count = min(node_counts)
under = 100.0 * (desired_count - min_count) / desired_count
print '%d: Least data ids on one node,%.02f%% under' % (min_count, under)
print '%d seconds pass...' % (time() - begin)

结果:

100000: Desired data ids per node
100695: Most data ids on one node, 0.69% over
99073: Least data ids on one node,0.93% under
20 seconds pass...

说明:

ring1.py不用过多解释,把10^7个数hash到100个bucket中。值得注意的是,hash结果比较均匀, bucket在平均值上下1%之间。

ring2_0.py

from hashlib import md5
from struct import unpack_from
from time import time

NODE_COUNT = 100
NEW_NODE_COUNT = 101
DATA_ID_COUNT = 10000000

begin = time()
moved_ids = 0
for data_id in xrange(DATA_ID_COUNT):
    data_id = str(data_id)
    hsh = unpack_from('>I', md5(data_id).digest())[0]
    node_id = hsh % NODE_COUNT
    new_node_id = hsh % NEW_NODE_COUNT
    if node_id != new_node_id:
        moved_ids += 1
percent_moved = 100.0 * moved_ids / DATA_ID_COUNT
print '%d ids moved, %.02f%%' % (moved_ids, percent_moved)
print '%d seconds pass...' % (time() - begin)

结果:

9900989 ids moved, 99.01%
21 seconds pass...

说明:

如0.2小节所述。当增加1一个Bucket时,造成hash结果剧烈抖动(99%的结果和原来不同)。这很不理想。(以下Bucket统称为node)

ring2_1.py

from bisect import bisect_left
from hashlib import md5
from struct import unpack_from
from time import time

NODE_COUNT = 100
NEW_NODE_COUNT = 101
DATA_ID_COUNT = 10000000

begin = time()

node_range_starts = []
for node_id in xrange(DATA_ID_COUNT):#(1)
    node_range_starts.append(DATA_ID_COUNT / NODE_COUNT * node_id)
    
new_node_range_starts = []
for new_node_id in xrange(DATA_ID_COUNT): #(2)
    new_node_range_starts.append(DATA_ID_COUNT / NEW_NODE_COUNT * new_node_id)
    
moved_ids = 0
for data_id in xrange(DATA_ID_COUNT):
    data_id = str(data_id)
    hsh = unpack_from('>I', md5(str(data_id)).digest())[0]
    node_id = bisect_left(node_range_starts, hsh % DATA_ID_COUNT) % NODE_COUNT #(3)
    new_node_id = bisect_left(new_node_range_starts, hsh % DATA_ID_COUNT) % NEW_NODE_COUNT #(4)
    if node_id != new_node_id:
        moved_ids += 1
percent_moved = 100.0 * moved_ids / DATA_ID_COUNT
print '%d ids moved, %.02f%%' % (moved_ids, percent_moved)
print '%d seconds pass ... ' % (time() - begin)

结果:

4901817 ids moved, 49.02%
49 seconds pass ...

说明:

#(1)中把[1,100]的node编号扩展到[1,10^7]空间中去。即原空间步长为1,新空间步长为10^5。

#(2)中类似,是把[1,101]的node扩展到[1,10^7]空间。因此步长比#(1)的小了一点。正因为小了这么一点点(将近900),累加效应后,直接导致后面将近50%的映射发生变化。

#(3)在node_range_starts空间中,找到hsh % DATA_ID_COUNT位置。hsh已经是打散的均匀值,取模是为了让结果均匀落在[1,10^7]空间。bisect_left函数在这里比较重要,当然也非常耗时。bisect_left忽略了具体的hsh % DATA_ID_COUNT的值,只是按照一定规格分拣出落在的node范围。比如落在[1,10^5]内的就算是node0,[10^5,2*10^5]就是node1,类推。因此前面两次hash运算失去了意义(相比改进后的ring2_3.py而言)。

#(4)和#(3)类同,不赘述。只是步长小了900左右。

50%的变化比起ring2_0.py会好一些,但仍然不能接受。

ring2_2.py

from bisect import bisect_left
from hashlib import md5
from struct import unpack_from
from time import time

NODE_COUNT = 100
DATA_ID_COUNT = 10000000
VNODE_COUNT = 1000

begin = time()
vnode_range_starts = []
vnode2node = []
for vnode_id in xrange(VNODE_COUNT):
    vnode_range_starts.append(DATA_ID_COUNT / 
                              VNODE_COUNT * vnode_id)#(1)
    vnode2node.append(vnode_id % NODE_COUNT) #(2)
new_vnode2node = list(vnode2node)
new_node_id = NODE_COUNT
NEW_NODE_COUNT = NODE_COUNT + 1
vnodes_to_reassign = VNODE_COUNT / NEW_NODE_COUNT#(3)
print 'vnodes_to_reassign is %d' % vnodes_to_reassign

while vnodes_to_reassign > 0:  #(3)
    for node_to_take_from in xrange(NODE_COUNT):
        for vnode_id, node_id in enumerate(new_vnode2node):
            if node_id == node_to_take_from:
                new_vnode2node[vnode_id] = new_node_id
                vnodes_to_reassign -= 1
                if vnodes_to_reassign <= 0:
                    break
        if vnodes_to_reassign <= 0:
            break

moved_ids = 0
for data_id in xrange(DATA_ID_COUNT):
    data_id = str(data_id)
    hsh = unpack_from('>I', md5(str(data_id)).digest())[0]
    vnode_id = bisect_left(vnode_range_starts, hsh % DATA_ID_COUNT) % VNODE_COUNT#(4)
    node_id = vnode2node[vnode_id]
    new_node_id = new_vnode2node[vnode_id]
    if node_id != new_node_id:
        moved_ids += 1
percent_moved = 100.0 * moved_ids / DATA_ID_COUNT
print '%d ids moved, %.02f%%' % (moved_ids, percent_moved)
print '%d seconds pass ...' % (time() - begin)

结果:

vnodes_to_reassign is 9
90108 ids moved, 0.90%
30 seconds pass ...

说明:

正如ring2_1.py对[1,10^7]空间分为100份。ring2_2.py引入虚结点的概念进一步细分,即分为1000份。如果这里只是类似ring2_1.py,变动区别于1000和1001,那就不用再细表了。然而不仅仅如此。

先说明一个问题。[1,10^7]空间未来会扩充到[1,2^128](准确的上限不确定,默认是2^114)。1000个虚结点未来会扩充到2^18。这样来看,暂不提效率,依靠变化虚结点数目减少映射变动是非常有限的。

ring2_2.py中解耦了对node的依赖,即不以node数目的变动而影响映射关系。映射关系更依赖于vnode数目,但是vnode相对稳定。vnode会对应到不同的node。(参照0.3小节)

#(1) vnode_range_starts对[1,10^7]空间划分为1000份。

#(2) vnode2node是vnode和node之间的对应关系。注意这个list初始化时比较有规律,之后变更“对应”时会用到。

#(3) vnodes_to_reassign反映node变动。根据node的变动调整vnode和node的对应关系。vnode只变动有限个,这个数目才真正影响到结果中0.90%的映射变动

#(4)注意这里被查询的list为vnode_range_starts。

ring2_3.py

from struct import unpack_from
from hashlib import md5
from time import time

NODE_COUNT = 100
DATA_ID_COUNT = 10000000
VNODE_COUNT = 1000

begin = time()
vnode2node = []
for vnode_id in xrange(VNODE_COUNT):
    vnode2node.append(vnode_id % NODE_COUNT)
new_vnode2node = list(vnode2node)
new_node_id = NODE_COUNT
vnodes_to_assign = VNODE_COUNT / (NODE_COUNT + 1)
while vnodes_to_assign > 0:
    for node_to_take_from in xrange(NODE_COUNT):
        for vnode_id, node_id in enumerate(vnode2node):
            if node_id == node_to_take_from:
                vnode2node[vnode_id] = new_node_id
                vnodes_to_assign -= 1
                if vnodes_to_assign <= 0:
                    break
        if vnodes_to_assign <= 0:
            break
moved_id = 0
for data_id in xrange(DATA_ID_COUNT):
    data_id = str(data_id)
    hsh = unpack_from('>I', md5(str(data_id)).digest())[0]
    vnode_id = hsh % VNODE_COUNT#(1)
    node_id = vnode2node[vnode_id]
    new_node_id = new_vnode2node[vnode_id]
    if node_id != new_node_id:
        moved_id += 1
percent_moved = 100.0 * moved_id / DATA_ID_COUNT
print '%d ids moved, %.02f%%' % (moved_id, percent_moved)
print '%d seconds pass ...' % (time() - begin)

结果:

90369 ids moved, 0.90%
22 seconds pass ...

说明:

因为变为“手动”重分配vnode和node对应关系。因此不再需要像ring1.py#(3)那样使用数学的方法归类。这样节省了很多比较时间(显然二分查找还是很快的)。

#(1)需要注意这里的变动。和前几例比较后会发现会清爽很多。

 

到此,Part1已经构建好了一致性哈希ring的原型。但似乎和swift中的实际代码有些差距。不要着急。之后的Part会不断添加特性。我们会越来越接近真相。


[1] D. Darger, E. Lehman, T. Leighton, M.Levine, D. Lewin and R. Panigrahy. Consistent Hashing and RandomTrees:Distributed Caching Protocols for Relieving Hot Spots On the World WideWeb. ACM Symposium on Theory of Computing, 1997. 1997:654-663.

[2] G. Decandia, D. Hastorun, M. Jampani,G. Kakulapati, A. Lakshman, A. Pilchin, S. Sivasubramanian, P. Vosshall and W.Vogels. Dynamo:Amazon's Highly Available Key-Value Store.SOSP'07. 2007:205-220.

[3]http://blog.csdn.net/sparkliang/article/details/5279393  sparkliang的博客,做了深入总结

[4]http://blog.sina.com.cn/s/blog_4e6b346e0100eokj.html  一直对其中“黑帮分地盘”的例子记忆犹新

抱歉!评论已关闭.