前面专门介绍过几种常见的数据分片(Sharding),主要包括范围分片(Range)和哈希(Hash)分片两种大的策略,其中哈希分片最简单的Round Robin方法(直接按照机器数取模)存在一个明显的缺点,当机器数增加或者减少的时候,所有的数据都要进行重新的哈希分配。这个问题的本质原因是因为机器和数据哈希分布之间强耦合,因此针对这个缺陷主要有两个解决方案一种是引入虚拟桶的概念,分两步映射(key-partition和partition-machine),另一种方案就是一致性哈希,引入哈希空间(将key-partition和partition-machine映射到同一个哈希空间),解除了机器和数据分布的耦合关系。这两天看了《大数据日知录》第一章的数据分片与路由,这里主要讲一下一致性哈希的实现原理。
整体架构
一致性哈希是分布式哈希表的一种实现,主要有Chord(和弦)系统提出,其变种也广泛应用于诸如Dynamo和Cassandra等分布式存储系统。与虚拟桶的初衷相同,都是为了解决机器和数据分布的耦合关系,但是一致性哈希则对key-partition和partition-machine使用同一个哈希函数,这样就可以将机器和数据分片都在同一个空间,每一个机器负责规定范围的数据分片。
一致性哈希算法将哈希空间抽象成一个环状结构,比如一个长度为5的二进制空间(可以表示0-31的空间),在空间结构上首尾相连,组成一个环状序列。对于数据根据Key值哈希到这个空间,对于机器同样根据根据一定的key(比如ip+port)映射到同一个空间,这样机器就相当于将整个空间截成了几段,每个机器节点负责一段,同时每个节点记录其前驱节点和后继节点。下图就是一个长度为5的哈希空间,有五个节点,五个节点的哈希值分别是5,14,20,25,29,每个节点负责(
Np
,
Nn
],其中
Np
是前一个节点的哈希值,
Nn
是当前节点的哈希值。比如对于节点
N5
其哈希值是5,其前一个节点是
N_29
,其哈希值是29,所以节点
N5
负责的哈希空间是(29,5],也就是30-5,所有哈希值在这个范围的key都归节点
N5
管理。由于节点的哈希值比较随机,可能映射到哈希空间排列比较密集,导致负载不均衡,后面会讨论解决方法。
查找key
上一节可以看到一致性哈希算法没有统一的控制节点,也就是说对于客户端来说并不知道需要请求那个节点去获得某个key的值,也不能通过一个主控节点去获得某个key的存储节点。这也正是一致性哈希广泛用于分布式系统的重要原因,不需要主控节点,也就不存在单点问题,同时又有良好的可扩展性。
虽然没有主控节点,但是客户端知道所有节点,可以向任何一台节点发送请求,前面我们说过每个节点都知道其前驱节点和后继节点,所以任何一个收到请求的节点会首先通过哈希函数获得请求key的哈希值,假设为j,判断是否在其负责的范围如果没有则将其转发给后继节点查找,如此循环直到找到一个节点,其负责范围包括j(即上图中节点下角大于等于j的最小编号节点)。
如果按照这样的方法查找显然效率很低,一个请求如果查找这么久才返回,延迟也太高了。所以为了加快速度,一致性哈希算法在每个节点配置了一个路由表,存储了m条(前面提到的二进制哈希空间长度,比如前面的5)路由信息。分别是比当前节点多 2i ( 0≤i≤(m−1) )的五个哈希值所在的节点编号,还是前面例子为例说明,下面是节点 N14 存储的路由表:
距离 | 1( 20 ) | 2( 21 ) | 4( 22 ) | 8( 23 ) | 16( 24 ) |
---|---|---|---|---|---|
节点编号 | N20 | N20 | N20 | N25 | N5 |
有了路由表,如果客户端要请求的key不在改节点,怎么可以直接根据该表尽可能快的找到目标节点。
一致性哈希路由算法
假设要查找key的哈希值为hash(key)=j,初始请求的节点为
Nc
,其后继节点为
Ns
,
Nc
节点负责的哈希空间范围是(i,c],按照下面的算法查找:
1. 首先
Nc
判断是否
jϵ(i,c]
,如果为真,说明key确实存在
Nc
节点,查询key的value,直接返回
2. 如果key不在
Nc
节点,则判断j是否属于(c,s],如果存在,则说明key在
Nc
的后继节点
Ns
上,
Nc
向
Ns
发送消息,查找key的值,然后返回给
Nc
(每个消息都包含
Nc
的相关信息)。
3. 如果key不在后继节点,这时就需要查找路由表(这里的路由表示距离为1开始所以存储了后继节点的信息,和第二步有点重复,个人感觉也许可以不用存储后继节点的路由信息进行优化),找到小于j的最大编号节点(如果所有路由表都大于j,则选择最后一项路由信息作为下一个查询节点)作为查询节点,
Nc
向其发送消息查询key的值,该节点成为新的
Nc
节点,继续按照上述步骤查找。
还是以上面的例子为例,假设初始查询的节点是
N5
,想要查询的key的哈希值为27,按照步骤一
N5
的负责哈希空间为[30,5],27不在这个范围;进入步骤二,查看其后继节点N14的范围位[6,14],也不在这个范围;进入步骤三查询路由表,找到小于27的最大编号节点是
N25
(14+8=22<27<14+16=30),于是发送该请求到
N25
,
N25
进入步骤一,发现不在其负责范围,然后进入步骤二发现在其后继节点
N29
的负责范围[26,29],所以将请求再次转发到
N29
,
N29
查询结果返回给
N5
.
节点变更
一致性哈希算法一般应用于分布式的环境,避免不了新增加节点,以及节点宕机的故障。一致性哈希算法也同样设计了一套算法用于增加,减少节点。
增加节点
如果现有的哈希空间中新增加一个节点,假设为
Nnew
,则
Nnew
必须能够被安插到合适的位置,并且能够和其他节点建立联系。通过上一节介绍的路由查找算法,拿着新节点的哈希值new, 可以很容易地找到新节点哈希值所在的节点,假设为
Ns
, 其前驱节点为
Np
, 则新节点就是要插入到节点
Np
和
Ns
之间,
Ns
的前驱节点重新指向
Nnew
,
Nnew
的后继节点指向
Ns
,
Np
的后继节点指向
Nnew
,并完成数据片的转移,这里就是将
Ns
多余的数据转移到
Nnew
上。
为了保证多个节点加入的时候不出现问题,一致性哈希提供了一套稳定性检测的算法去保证新节点的加入和离开。每个节点都会周期性的执行稳定性检测算法,保证目前节点的连接关系的正确性。
稳定性检测算法
在新加入节点的时候,首先安装前面说的找到新节点的后继节点,也就是当前新节点哈希值所在的节点
Ns
,然后将新节点的后继节点指向
Ns
。然后在执行稳定性检测算法的时候自动更新节点的关系,新加入的节点也就进入了新的网络结构中。
稳定性检测算法的具体流程如下:
1. 假设
Nc
的后继节点为
Ns
,
Nc
向
Ns
询问其前驱节点,假设收到的回复是
Np
2. 如果
Np
位于
Nc
和
Ns
之间,
Nc
记录下
Np
为其后继节点
3. 假设
Nc
的后继节点为
Nx
,这里
Nx
可能是
Ns
和
Np
。如果
Nx
的前驱节点为空,或者
Nc
位于
Nx
和它的前驱节点之间,那么
Nc
给
Nx
发送消息告诉
Nx
说
Nc
就是它的前驱节点,
Nx
将其前驱节点置为
Nc
。
4.
Nx
把部分数据迁移到
Nc
,即将
Nx
上哈希值小于c的记录迁移到
Nc
上。
还是以前面的例子为例,加入一个新的节点
N8
,其哈希值是8,在当前的哈希空间中落到了节点
N14
,则此时将节点
N8
的后继节点指向
N14
,前驱节点置位空,这个时候是下面的状态:
之后节点
N8
进行稳定性检测的时候,这个时候
Nc=N8,Ns=N14,Np=N5
,此时
N8
向
N14
询问其前驱节点,此处为
N5
,不满足步骤二,因此
Nx
为
N14
,按照步骤三
N8
位于
N5
和
N14
之间,因此将
N14
的前驱节点设置为
N8
,并将[[6,8]的数据迁移到
N8
。自此
N8
稳定性检测完成,此时的状态如下:
之后当节点
N5
进行稳定检测的时候,发现其后继节点
N14
的前驱节点为
N8
,满足步骤二位于节点
N5
和
N14
之间。因此将
N5
的后继节点指向
N8
,到步骤三的时候
Nx
此时为
N8
,其前驱节点为空,因此
N5
给
N8
发消息告诉其
N8
其前驱节点为
N5
,这时
N8
的前驱节点指向
N5
。这个时候
N8
上的数据全都大于5,所以不需要进行数据迁移。这个时候已经完成了节点的加入工作,最后的状态如下:
当然这个时候还不算完成,加入节点后,部分节点的路由表已经不再适用,除了稳定性检测,每个节点也会周期性进行路由表的更新检测,用来更新节点的路由表。
节点的离开
节点的离开分为两种,正常离开和异常离开,对于正常离开的节点,可以自动更新前驱和后继节点,并转移数据。对于异常离开的节点,通常都是由于机器的故障,这时就会导致数据的丢失,因此通常会在节点的后继节点中保留多份副本保证数据的安全。
虚拟节点
为了解决刚开始提到哈希的随机导致的节点负载不均衡,可以使用虚拟节点的方法解决,具体稍微复杂一点可以参考 Cassandra的一致性哈希(Consistent Hashing)和虚拟节点(Virtual Nodes)的关系