前言

在了解一致性哈希算法之前,最好先了解一下缓存中的一个应用场景,了解了这个应用场景之后,再来理解一致性哈希算法就容易多了,也更能体现出一致性哈希算法的优点。那么,我们先来描述一下这个经典的分布式缓存的应用场景。

应用场景

假设,我们有三台缓存服务器,用于缓存图片,我们为这三台缓存服务器编号为0号、1号、2号,现在有三万张图片需要缓存,我们希望这些图片被均匀地缓存到这三台服务器上,以便它们能够分摊缓存的压力。也就是说,我们希望每台服务器能够缓存一万张左右的图片,那么,我们应该怎样做呢?如果我们没有任何规律的将三万张图片平均地缓存在三台服务器上,可以满足我们的要求吗?可以!但是如果这样做,当我们需要访问某个缓存项时,则需要遍历三台缓存服务器,从三万个缓存项中找到我们需要访问的缓存,遍历的过程效率太低,时间太长,当我们找到需要访问的缓存项时,时长可能是不能被接收的,也就失去了缓存的意义,缓存的目的就是提高速度,改善用户体验,减轻后端服务器压力,如果每次访问一个缓存项都需要遍历所有缓存服务器的所有缓存项,想想就觉得很累。那么,我们该怎么办呢?原始的做法是对缓存项的键进行哈希,将hash后的结果对缓存服务器的数量进行取模操作,通过取模后的结果,决定缓存项将会缓存在哪一台服务器上,这样说可能不太容易理解,我们举例说明,仍然以刚才描述的场景为例,假设我们使用图片名称作为访问图片的key,假设图片名称是不重复的,那么,我们可以使用如下公式,计算出图片应该存放在哪台服务器上。

HASH(图片名称)%N

因为图片的名称是不重复的,所以当我们对同一个图片名称做相同的哈希计算时,得出的结果应该是不变的。如果我们有三台服务器,使用哈希后的结果对三求余,那么余数一定是0、1或者2,没错,正好与我们之前的服务器编号相同,如果求余的结果为0, 我们就把当前图片名称对应的图片缓存在0号服务器上,如果余数为1,就把当前图片名对应的图片缓存在1号服务器上,如果余数为2,同理。那么,当我们访问任意一个图片的时候,只要再次对图片名称进行上述运算,即可得出对应的图片应该存放在哪一台缓存服务器上,我们只要在这一台服务器上查找图片即可,如果图片在对应的服务器上不存在,则证明对应的图片没有被缓存,也不用再去遍历其他缓存服务器了,通过这样的方法,即可将三万张图片随机的分布到三台缓存服务器上了,而且下次访问某张图片时,直接能够判断出该图片应该存在于哪台缓存服务器上,这样就能满足我们的需求了。我们暂时称上述算法为HASH算法或者取模算法,取模算法的过程可以用下图表示。

但是,使用上述HASH算法进行缓存时,会出现一些缺陷,试想一下,如果三台缓存服务器已经不能满足我们的缓存需求,那么我们应该怎么做呢?没错,很简单,多增加两台缓存服务器不就行了。假设,我们增加了一台缓存服务器,那么缓存服务器的数量就由三台变成了四台,此时,如果仍然使用上述方法对同一张图片进行缓存,那么这张图片所在的服务器编号必定与原来三台服务器时所在的服务器编号不同,因为除数由三变为了四,被除数不变的情况下,余数肯定不同,这种情况带来的结果就是当服务器数量变动时,所有缓存的位置都要发生改变。换句话说,当服务器数量发生改变时,所有缓存在一定时间内是失效的,当应用无法从缓存中获取数据时,则会向后端服务器请求数据。同理,假设三台缓存中突然有一台缓存服务器出现了故障,无法进行缓存,那么我们则需要将故障机器移除,但是如果移除了一台缓存服务器,那么缓存服务器数量从三台变为两台,如果想要访问一张图片,这张图片的缓存位置必定会发生改变,以前缓存的图片也会失去缓存的作用与意义,由于大量缓存在同一时间失效,造成了缓存的雪崩,此时前端缓存已经无法起到承担部分压力的作用,后端服务器将会承受巨大的压力,整个系统很有可能被压垮。所以,我们应该想办法不让这种情况发生,但是由于上述HASH算法本身的缘故,使用取模法进行缓存时,这种情况是无法避免的,为了解决这些问题,一致性哈希算法诞生了。

一致性哈希算法的基本概念

其实,一致性哈希算法也是使用取模的方法,只是刚才描述的取模法是对服务器的数量进行取模,而一致性哈希算法是对2^32取模,什么意思呢?我们慢慢聊。首先,我们把二的三十二次方想象成一个圆,就像钟表一样,钟表的圆可以理解成由60个点组成的圆,而此处我们把这个圆想象成由2^32个点组成的圆,示意图如下:

圆环的正上方的点代表0,0点右侧的第一个点代表1,以此类推,2、3、4、5、6……直到2^32-1,也就是说0点左侧的第一个点代表2^32-1 。我们把这个由2的32次方个点组成的圆环称为Hash环。

那么,一致性哈希算法与上图中的圆环有什么关系呢?我们继续聊,仍然以之前描述的场景为例,假设我们有三台缓存服务器,服务器A、服务器B、服务器C,那么,在生产环境中,这三台服务器肯定有自己的IP地址,我们使用它们各自的IP地址进行哈希计算,使用哈希后的结果对2^32取模,可以使用如下公式示意。

HASH(服务器A的IP地址) %  2^32

通过上述公式算出的结果一定是一个0到2^32-1之间的一个整数,我们就用算出的这个整数,代表服务器A,既然这个整数肯定处于0到2^32-1之间,那么,上图中的hash环上必定有一个点与这个整数对应,而我们刚才已经说明,使用这个整数代表服务器A,那么,服务器A就可以映射到这个环上,用下图示意:

同理,服务器B与服务器C也可以通过相同的方法映射到上图中的hash环中,公式如下:

HASH(服务器B的IP地址) %  2^32

HASH(服务器C的IP地址) %  2^32

通过上述方法,可以将服务器B与服务器C映射到上图中的hash环上,示意图如下:

假设三台服务器映射到hash环上以后如上图所示(这里分布的相对均匀,属于理想情况)。好了,到目前为止,我们已经把缓存服务器与hash环联系在了一起。我们通过上述方法,把缓存服务器映射到了hash环上,那么使用同样的方法,我们也可以将需要缓存的对象映射到hash环上。假设,我们需要使用缓存服务器缓存图片,而且我们仍然使用图片的名称作为找到图片的key,那么我们使用如下公式可以将图片映射到上图中的hash环上。

HASH(图片名称) %  2^32

映射后的示意图如下,下图中的橘黄色圆形表示图片:

好了,现在服务器与图片都被映射到了hash环上,那么上图中的这个图片到底应该被缓存到哪一台服务器上呢?上图中的图片将会被缓存到服务器A上,为什么呢?因为从图片的位置开始,沿顺时针方向遇到的第一个服务器就是A服务器,所以,上图中的图片将会被缓存到服务器A上,如下图所示。

 

没错,一致性哈希算法就是通过这种方法,判断一个对象应该被缓存到哪台服务器上的,将缓存服务器与被缓存对象都映射到hash环上以后,从被缓存对象的位置出发,沿顺时针方向遇到的第一个服务器,就是当前对象将要缓存于的服务器,由于被缓存对象与服务器hash后的值是固定的,所以,在服务器不变的情况下,一张图片必定会被缓存到固定的服务器上,那么,当下次想要访问这张图片时,只要再次使用相同的算法进行计算,即可算出这个图片被缓存在哪个服务器上,直接去对应的服务器查找对应的图片即可。刚才的示例只使用了一张图片进行演示,假设有四张图片需要缓存,示意图如下:

1号、2号图片将会被缓存到服务器A上,3号图片将会被缓存到服务器B上,4号图片将会被缓存到服务器C上。

一致性哈希算法的优点

经过上述描述,我想你应该已经明白了一致性哈希算法的原理了,但是话说回来,一致性哈希算法能够解决之前出现的问题吗?我们说过,如果简单的对服务器数量进行取模,那么当服务器数量发生变化时,会产生缓存的雪崩,从而很有可能导致系统崩溃,那么使用一致性哈希算法,能够避免这个问题吗?我们来模拟一遍,即可得到答案。假设,服务器B出现了故障,我们现在需要将服务器B移除,那么,我们将上图中的服务器B从hash环上移除即可,移除服务器B以后示意图如下:

在服务器B未移除时,图片3应该被缓存到服务器B中,可是当服务器B移除以后,按照之前描述的一致性哈希算法的规则,图片3应该被缓存到服务器C中,因为从图片3的位置出发,沿顺时针方向遇到的第一个缓存服务器节点就是服务器C,也就是说,如果服务器B出现故障被移除时,图片3的缓存位置会发生改变,如下图:

但是,图片4仍然会被缓存到服务器C中,图片1与图片2仍然会被缓存到服务器A中,这与服务器B移除之前并没有任何区别。如果使用之前的hash算法,服务器数量发生改变时,所有服务器的所有缓存在同一时间失效了,而使用一致性哈希算法时,服务器的数量如果发生改变,并不是所有缓存都会失效,而是只有部分缓存会失效,前端的缓存仍然能分担整个系统的压力,而不至于所有压力都在同一时间集中到后端服务器上。这就是一致性哈希算法所体现出的优点。

Hash环的偏斜

 在介绍一致性哈希的概念时,我们理想化的将三台服务器均匀地映射到了hash环上,如下图所示:

但是,我们想象的与实际情况往往不一样,不总是会出现均匀分布的情况,在实际的映射中,服务器可能会被映射成如下模样:

是的,如果服务器被映射成上图中的模样,那么被缓存的对象很有可能大部分集中缓存在某一台服务器上,如下图所示:

上图中,1号、2号、3号、4号、6号图片均被缓存在了服务器A上,只有5号图片被缓存在了服务器B上,服务器C上甚至没有缓存任何图片。如果出现上图中的情况,A、B、C三台服务器并没有被合理地平均地充分利用,缓存分布极度不均匀,而且,如果此时服务器A出现故障,那么失效缓存的数量也将达到最大值,在极端情况下,仍然有可能引起系统的崩溃,上图中的情况则被称之为hash环的偏斜。那么,我们应该怎样防止hash环的偏斜呢?一致性hash算法中使用"虚拟节点"解决了这个问题。

虚拟节点

由于我们只有三台服务器,当我们把服务器映射到hash环上的时候,很有可能出现hash环偏斜的情况,当hash环偏斜以后,缓存往往会极度不均衡的分布在各服务器上。如果想要均衡地将缓存分布到三台服务器上,最好能让这三台服务器尽量多的、均匀的出现在hash环上。但是,真实的服务器资源只有三台,我们怎样凭空的让它们多起来呢?没错,就是凭空的让服务器节点多起来,既然没有多余的真正的物理服务器节点,我们就只能将现有的物理节点通过虚拟的方法复制出来,这些由实际节点虚拟复制而来的节点被称为"虚拟节点"。加入虚拟节点以后的hash环如下:

"虚拟节点"是"实际节点"(实际的物理服务器)在hash环上的复制品,一个实际节点可以对应多个虚拟节点。从上图可以看出,A、B、C三台服务器分别虚拟出了一个虚拟节点。当然,如果你需要,也可以虚拟出更多的虚拟节点。引入虚拟节点的概念后,缓存的分布就均衡多了。上图中,1号、3号图片被缓存在服务器A中,5号、4号图片被缓存在服务器B中,6号、2号图片被缓存在服务器C中,如果你还不放心,可以虚拟出更多的虚拟节点,以便减小hash环偏斜所带来的影响,虚拟节点越多,hash环上的节点就越多,缓存被均匀分布的概率就越大。
算法实现:(虚拟节点)

1.  package test;
    

3.  import java.util.SortedMap;
    
4.  import java.util.TreeMap;
    

6.  /\*\*
    
7.  \* Created by louyuting on 17/1/6.
    
8.  \*/
    
9.  public class  ConsistentHashingWithoutVirtualNode  {
    

11.  private static String\[\] servers = {"192.168.0.0:8011","192.168.0.1:8011","192.168.0.2:8011","192.168.0.3:8011","192.168.0.4:8011"};
    

13.  private static SortedMap<Integer, String> sortedMap = new TreeMap<>();
    

15.  static {
    
16.  for(int i=0; i<servers.length; i++){
    
17.  int hash = getHash(servers\[i\]);
    
18.  System.out.println("\[" + servers\[i\] + "\]加入集合中, 其Hash值为" + hash);
    
19.  sortedMap.put(hash, servers\[i\]);
    
20.  }
    
21.  System.out.println();
    
22.  }
    

24.  private  static  int  getHash(String str)
    
25.  {
    
26.  final int p = 16777619;
    
27.  int hash = (int)2166136261L;
    
28.  for (int i = 0; i < str.length(); i++)
    
29.  hash = (hash ^ str.charAt(i)) \* p;
    
30.  hash += hash << 13;
    
31.  hash ^= hash >> 7;
    
32.  hash += hash << 3;
    
33.  hash ^= hash >> 17;
    
34.  hash += hash << 5;
    

36.  // 如果算出来的值为负数则取其绝对值
    
37.  if (hash < 0)
    
38.  hash = Math.abs(hash);
    
39.  return hash;
    
40.  }
    

42.  private  static String getServer(String node){
    
43.  //d得到hash值
    
44.  int hash = getHash(node);
    
45.  //得到大于该hash的map
    
46.  SortedMap<Integer, String> subMap = sortedMap.tailMap(hash);
    
47.  //取第一个元素
    
48.  int i =subMap.firstKey();
    
49.  return subMap.get(i);
    
50.  }
    

52.  public  static  void  main(String\[\] args) {
    
53.  String\[\] nodes = {"127.122.0.0:8011","11.168.22.1:8011","11.22.0.2:8011"};
    

55.  for (int i=0; i<nodes.length; i++){
    
56.  System.out.println("\[" + nodes\[i\] + "\]的hash值为" + getHash(nodes\[i\]) + ", 被路由到结点\[" + getServer(nodes\[i\]) + "\]");
    
57.  }
    

59.  }
    
60.  }
    

62.  //运行结果
    
63.  /\*\*\*
    
64.  \[192.168.0.0:8011\]加入集合中, 其Hash值为1818534674
    
65.  \[192.168.0.1:8011\]加入集合中, 其Hash值为170444199
    
66.  \[192.168.0.2:8011\]加入集合中, 其Hash值为1044402306
    
67.  \[192.168.0.3:8011\]加入集合中, 其Hash值为1967926791
    
68.  \[192.168.0.4:8011\]加入集合中, 其Hash值为461565183
    

70.  \[127.122.0.0:8011\]的hash值为1767943803, 被路由到结点\[192.168.0.0:8011\]
    
71.  \[11.168.22.1:8011\]的hash值为486973912, 被路由到结点\[192.168.0.2:8011\]
    
72.  \[11.22.0.2:8011\]的hash值为768129259, 被路由到结点\[192.168.0.2:8011\]
    
73.  \*/

算法实现(有虚拟节点)

1.    
    package test;
    

3.  import java.util.LinkedList;
    
4.  import java.util.List;
    
5.  import java.util.SortedMap;
    
6.  import java.util.TreeMap;
    

8.  /\*\*
    
9.  \* Created by louyuting on 17/1/6.
    
10.  \*/
    
11.  public class  ConsistentHashwithVirNode  {
    
12.  /\*\*
    
13.  \* 待添加入Hash环的服务器列表
    
14.  \*/
    
15.  private static String\[\] servers = {"192.168.0.0:8011", "192.168.0.1:8011", "192.168.0.2:8011",
    
16.  "192.168.0.3:8011", "192.168.0.4:8011"};
    

18.  /\*\*
    
19.  \* 真实结点列表,考虑到服务器上线、下线的场景,即添加、删除的场景会比较频繁,这里使用LinkedList会更好
    
20.  \*/
    
21.  private static List<String> realNodes = new LinkedList<String>();
    

23.  /\*\*
    
24.  \* 虚拟节点,key表示虚拟节点的hash值,value表示虚拟节点的名称
    
25.  \*/
    
26.  private static SortedMap<Integer, String> virtualNodes =
    
27.  new TreeMap<Integer, String>();
    

29.  /\*\*
    
30.  \* 虚拟节点的数目,这里写死,为了演示需要,一个真实结点对应5个虚拟节点
    
31.  \*/
    
32.  private static final int VIRTUAL\_NODES = 5;
    

34.  static
    
35.  {
    
36.  // 先把原始的服务器添加到真实结点列表中
    
37.  for (int i = 0; i < servers.length; i++)
    
38.  realNodes.add(servers\[i\]);
    

40.  // 再添加虚拟节点,遍历LinkedList使用foreach循环效率会比较高
    
41.  for (String str : realNodes)
    
42.  {
    
43.  for (int i = 0; i < VIRTUAL\_NODES; i++)
    
44.  {
    
45.  String virtualNodeName = str + "&&VN" + String.valueOf(i);
    
46.  int hash = getHash(virtualNodeName);
    
47.  System.out.println("虚拟节点\[" + virtualNodeName + "\]被添加, hash值为" + hash);
    
48.  virtualNodes.put(hash, virtualNodeName);
    
49.  }
    
50.  }
    
51.  System.out.println();
    
52.  }
    

54.  /\*\*
    
55.  \* 使用FNV1\_32\_HASH算法计算服务器的Hash值,这里不使用重写hashCode的方法,最终效果没区别
    
56.  \*/
    
57.  private  static  int  getHash(String str)
    
58.  {
    
59.  final int p = 16777619;
    
60.  int hash = (int)2166136261L;
    
61.  for (int i = 0; i < str.length(); i++)
    
62.  hash = (hash ^ str.charAt(i)) \* p;
    
63.  hash += hash << 13;
    
64.  hash ^= hash >> 7;
    
65.  hash += hash << 3;
    
66.  hash ^= hash >> 17;
    
67.  hash += hash << 5;
    

69.  // 如果算出来的值为负数则取其绝对值
    
70.  if (hash < 0)
    
71.  hash = Math.abs(hash);
    
72.  return hash;
    
73.  }
    

75.  /\*\*
    
76.  \* 得到应当路由到的结点
    
77.  \*/
    
78.  private  static String getServer(String node)
    
79.  {
    
80.  // 得到带路由的结点的Hash值
    
81.  int hash = getHash(node);
    
82.  // 得到大于该Hash值的所有Map
    
83.  SortedMap<Integer, String> subMap =
    
84.  virtualNodes.tailMap(hash);
    
85.  // 第一个Key就是顺时针过去离node最近的那个结点
    
86.  Integer i = subMap.firstKey();
    
87.  // 返回对应的虚拟节点名称,这里字符串稍微截取一下
    
88.  String virtualNode = subMap.get(i);
    
89.  return virtualNode.substring(0, virtualNode.indexOf("&&"));
    
90.  }
    

92.  public  static  void  main(String\[\] args)
    
93.  {
    
94.  String\[\] nodes = {"127.0.0.1:1111", "221.226.0.1:2222", "10.211.0.1:3333"};
    
95.  for (int i = 0; i < nodes.length; i++)
    
96.  System.out.println("\[" + nodes\[i\] + "\]的hash值为" +
    
97.  getHash(nodes\[i\]) + ", 被路由到结点\[" + getServer(nodes\[i\]) + "\]");
    
98.  }
    
99.  }
    
100.  //运行结果:
    
101.  /\*\*
    
102.  虚拟节点\[192.168.0.0:8011&&VN0\]被添加, hash值为637334537
    
103.  虚拟节点\[192.168.0.0:8011&&VN1\]被添加, hash值为1942673682
    
104.  虚拟节点\[192.168.0.0:8011&&VN2\]被添加, hash值为1111653162
    
105.  虚拟节点\[192.168.0.0:8011&&VN3\]被添加, hash值为749027645
    
106.  虚拟节点\[192.168.0.0:8011&&VN4\]被添加, hash值为752063515
    
107.  虚拟节点\[192.168.0.1:8011&&VN0\]被添加, hash值为653786264
    
108.  虚拟节点\[192.168.0.1:8011&&VN1\]被添加, hash值为132412064
    
109.  虚拟节点\[192.168.0.1:8011&&VN2\]被添加, hash值为811025279
    
110.  虚拟节点\[192.168.0.1:8011&&VN3\]被添加, hash值为326692669
    
111.  虚拟节点\[192.168.0.1:8011&&VN4\]被添加, hash值为374169458
    
112.  虚拟节点\[192.168.0.2:8011&&VN0\]被添加, hash值为1321894695
    
113.  虚拟节点\[192.168.0.2:8011&&VN1\]被添加, hash值为1051614494
    
114.  虚拟节点\[192.168.0.2:8011&&VN2\]被添加, hash值为1087571079
    
115.  虚拟节点\[192.168.0.2:8011&&VN3\]被添加, hash值为781884308
    
116.  虚拟节点\[192.168.0.2:8011&&VN4\]被添加, hash值为1623760690
    
117.  虚拟节点\[192.168.0.3:8011&&VN0\]被添加, hash值为367036244
    
118.  虚拟节点\[192.168.0.3:8011&&VN1\]被添加, hash值为1370453265
    
119.  虚拟节点\[192.168.0.3:8011&&VN2\]被添加, hash值为458430883
    
120.  虚拟节点\[192.168.0.3:8011&&VN3\]被添加, hash值为1845319771
    
121.  虚拟节点\[192.168.0.3:8011&&VN4\]被添加, hash值为2139636740
    
122.  虚拟节点\[192.168.0.4:8011&&VN0\]被添加, hash值为1842286794
    
123.  虚拟节点\[192.168.0.4:8011&&VN1\]被添加, hash值为460849631
    
124.  虚拟节点\[192.168.0.4:8011&&VN2\]被添加, hash值为2130990870
    
125.  虚拟节点\[192.168.0.4:8011&&VN3\]被添加, hash值为573019492
    
126.  虚拟节点\[192.168.0.4:8011&&VN4\]被添加, hash值为1063403512
    

128.  \[127.0.0.1:1111\]的hash值为380278925, 被路由到结点\[192.168.0.3:8011\]
    
129.  \[221.226.0.1:2222\]的hash值为1493545632, 被路由到结点\[192.168.0.2:8011\]
    
130.  \[10.211.0.1:3333\]的hash值为1393836017, 被路由到结点\[192.168.0.2:8011\]

Smile3k
197 声望22 粉丝