数据结构的选取
一致性hash算法中最重要的就是那个2^32的hash环,,根据结点名称的hash值将 服务器结点放在hash环上。那么整数环应该取什么数据结构实现能使运行的时间复杂度最低呢?关于时间复杂度,常见的时间复杂度与时间效率的关系有如下的经验规则:
O(1) < O(log2N) < O(n) < O(N * log2N) < O(N2) < O(N3) < 2N < 3N < N!
前面四五个效率还能接受,后面的就基本不能接受了。 关于如何选取数据结构,有如下几种方案。
1. 排序+List
将所有节点名称的hash值放入数组中然后进行排序,将排序后的数据存入List中(这里使用List存储是考虑到扩展性)。之后,待路由的结点,只需要在List中找到第一个Hash值比它大的服务器节点就可以了,比如服务器节点的Hash值是[0,2,4,6,8,10],带路由的结点是7,只需要找到第一个比7大的整数,也就是8,就是我们最终需要路由过去的服务器节点。
如果暂时不考虑前面的排序,那么这种解决方案的时间复杂度:
(1)最好的情况是第一次就找到,时间复杂度为O(1)
(2)最坏的情况是最后一次才找到,时间复杂度为O(N)
平均下来时间复杂度为O(0.5N+0.5),忽略首项系数和常数,时间复杂度为O(N)。
但是如果考虑到之前的排序,我在网上找了张图,提供了各种排序算法的时间复杂度:

看得出来,排序算法要么稳定但是时间复杂度高、要么时间复杂度低但不稳定,看起来最好的归并排序法的时间复杂度仍然有O(N * logN),稍微耗费性能了一些。
2 遍历 + List
既然排序操作比较耗性能,那么能不能不排序?可以的,所以进一步的,有了第二种解决方案:
解决方案使用List不变,不过可以采用遍历的方式:
(1)服务器节点不排序,其Hash值全部直接放入一个List中
(2)带路由的节点,算出其Hash值,由于指明了”顺时针”,因此遍历List,比待路由的节点Hash值大的算出差值并记录,比待路由节点Hash值小的忽略
(3)算出所有的差值之后,最小的那个,就是最终需要路由过去的节点
在这个算法中,看一下时间复杂度:
1、最好情况是只有一个服务器节点的Hash值大于带路由结点的Hash值,其时间复杂度是O(N)+O(1)=O(N+1),忽略常数项,即O(N)
2、最坏情况是所有服务器节点的Hash值都大于带路由结点的Hash值,其时间复杂度是O(N)+O(N)=O(2N),忽略首项系数,即O(N)
所以,总的时间复杂度就是O(N)。其实算法还能更改进一些:给一个位置变量X,如果新的差值比原差值小,X替换为新的位置,否则X不变。这样遍历就减少了一轮,不过经过改进后的算法时间复杂度仍为O(N)。
总而言之,这个解决方案和解决方案一相比,总体来看,似乎更好了一些。
3 二叉查找树
抛开List这种数据结构,另一种数据结构则是使用二叉查找树。
当然我们不能简单地使用二叉查找树,因为可能出现不平衡的情况。平衡二叉查找树有AVL树、红黑树等,这里使用红黑树,选用红黑树的原因有两点:
1、红黑树主要的作用是用于存储有序的数据,这其实和第一种解决方案的思路又不谋而合了,但是它的效率非常高
2、JDK里面提供了红黑树的代码实现TreeMap和TreeSet
另外,以TreeMap为例,TreeMap本身提供了一个tailMap(K fromKey)方法,支持从红黑树中查找比fromKey大的值的集合,但并不需要遍历整个数据结构。
使用红黑树,可以使得查找的时间复杂度降低为O(logN),比上面两种解决方案,效率大大提升。
Hash值得重计算
对于服务器结点的字符串表示,比如”192.168.0.1:80”,一般来说多个结点的IP地址都是紧挨着的,我做了一个测试,计算相邻的5个结点的IP的字符串表示的hash值,得到结果如下:
public static void main(String[] args) {
System.out.println("192.168.0.0:8011".hashCode());
System.out.println("192.168.0.1:8011".hashCode());
System.out.println("192.168.0.2:8011".hashCode());
System.out.println("192.168.0.3:8011".hashCode());
System.out.println("192.168.0.4:8011".hashCode());
}
运行结果如下:
我们可以看到其hash值非常的接近,这时候问题就大了,[0,232-1]的区间之中,5个HashCode值却只分布在这么小小的一个区间,什么概念?[0,232-1]中有4294967296个数字,而我们的区间只有114516604,从概率学上讲这将导致97%待路由的服务器都被路由到”192.168.0.0”这个集群点上,简直是糟糕透了!负载完全不均衡。所以我们必须重新选择计算hash值的算法。
这里我使用FNV1_32_HASH算法计算服务器的Hash值,这里不使用重写hashCode的方法,最终效果没区别 。
private static int getHash(String str)
{
final int p = 16777619;
int hash = (int)2166136261L;
for (int i = 0; i < str.length(); i++)
hash = (hash ^ str.charAt(i)) * p;
hash += hash << 13;
hash ^= hash >> 7;
hash += hash << 3;
hash ^= hash >> 17;
hash += hash << 5;
if (hash < 0)
hash = Math.abs(hash);
return hash;
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
这时新的计算结果是:
这时候分布明显均匀很多。
不带虚拟结点的一致性hash算法的实现
代码如下:
package test;
import java.util.SortedMap;
import java.util.TreeMap;
/**
* Created by louyuting on 17/1/6.
*/
public class ConsistentHashingWithoutVirtualNode {
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"};
private static SortedMap<Integer, String> sortedMap = new TreeMap<>();
static {
for(int i=0; i<servers.length; i++){
int hash = getHash(servers[i]);
System.out.println("[" + servers[i] + "]加入集合中, 其Hash值为" + hash);
sortedMap.put(hash, servers[i]);
}
System.out.println();
}
private static int getHash(String str)
{
final int p = 16777619;
int hash = (int)2166136261L;
for (int i = 0; i < str.length(); i++)
hash = (hash ^ str.charAt(i)) * p;
hash += hash << 13;
hash ^= hash >> 7;
hash += hash << 3;
hash ^= hash >> 17;
hash += hash << 5;
if (hash < 0)
hash = Math.abs(hash);
return hash;
}
private static String getServer(String node){
int hash = getHash(node);
SortedMap<Integer, String> subMap = sortedMap.tailMap(hash);
int i =subMap.firstKey();
return subMap.get(i);
}
public static void main(String[] args) {
String[] nodes = {"127.122.0.0:8011","11.168.22.1:8011","11.22.0.2:8011"};
for (int i=0; i<nodes.length; i++){
System.out.println("[" + nodes[i] + "]的hash值为" + getHash(nodes[i]) + ", 被路由到结点[" + getServer(nodes[i]) + "]");
}
}
}
/***
[192.168.0.0:8011]加入集合中, 其Hash值为1818534674
[192.168.0.1:8011]加入集合中, 其Hash值为170444199
[192.168.0.2:8011]加入集合中, 其Hash值为1044402306
[192.168.0.3:8011]加入集合中, 其Hash值为1967926791
[192.168.0.4:8011]加入集合中, 其Hash值为461565183
[127.122.0.0:8011]的hash值为1767943803, 被路由到结点[192.168.0.0:8011]
[11.168.22.1:8011]的hash值为486973912, 被路由到结点[192.168.0.2:8011]
[11.22.0.2:8011]的hash值为768129259, 被路由到结点[192.168.0.2:8011]
*/
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
- 70
- 71
- 72
- 73
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
- 70
- 71
- 72
- 73
带虚拟结点的一致性hash算法的实现
首先我们考虑两个问题:
1、一个真实结点如何对应成为多个虚拟节点?
2、虚拟节点找到后如何还原为真实结点?
这两个问题其实有很多解决办法,我这里使用了一种简单的办法,给每个真实结点后面根据虚拟节点加上后缀再取Hash值,比如”192.168.0.0:8011”就把它变成”192.168.0.0:8011&&VN0”到”192.168.0.0:8011&&VN4”,VN就是Virtual Node的缩写,还原的时候只需要从头截取字符串到”&&”的位置就可以了。
package test;
import java.util.LinkedList;
import java.util.List;
import java.util.SortedMap;
import java.util.TreeMap;
/**
* Created by louyuting on 17/1/6.
*/
public class ConsistentHashwithVirNode {
/**
* 待添加入Hash环的服务器列表
*/
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"};
/**
* 真实结点列表,考虑到服务器上线、下线的场景,即添加、删除的场景会比较频繁,这里使用LinkedList会更好
*/
private static List<String> realNodes = new LinkedList<String>();
/**
* 虚拟节点,key表示虚拟节点的hash值,value表示虚拟节点的名称
*/
private static SortedMap<Integer, String> virtualNodes =
new TreeMap<Integer, String>();
/**
* 虚拟节点的数目,这里写死,为了演示需要,一个真实结点对应5个虚拟节点
*/
private static final int VIRTUAL_NODES = 5;
static
{
for (int i = 0; i < servers.length; i++)
realNodes.add(servers[i]);
for (String str : realNodes)
{
for (int i = 0; i < VIRTUAL_NODES; i++)
{
String virtualNodeName = str + "&&VN" + String.valueOf(i);
int hash = getHash(virtualNodeName);
System.out.println("虚拟节点[" + virtualNodeName + "]被添加, hash值为" + hash);
virtualNodes.put(hash, virtualNodeName);
}
}
System.out.println();
}
/**
* 使用FNV1_32_HASH算法计算服务器的Hash值,这里不使用重写hashCode的方法,最终效果没区别
*/
private static int getHash(String str)
{
final int p = 16777619;
int hash = (int)2166136261L;
for (int i = 0; i < str.length(); i++)
hash = (hash ^ str.charAt(i)) * p;
hash += hash << 13;
hash ^= hash >> 7;
hash += hash << 3;
hash ^= hash >> 17;
hash += hash << 5;
if (hash < 0)
hash = Math.abs(hash);
return hash;
}
/**
* 得到应当路由到的结点
*/
private static String getServer(String node)
{
int hash = getHash(node);
SortedMap<Integer, String> subMap =
virtualNodes.tailMap(hash);
Integer i = subMap.firstKey();
String virtualNode = subMap.get(i);
return virtualNode.substring(0, virtualNode.indexOf("&&"));
}
public static void main(String[] args)
{
String[] nodes = {"127.0.0.1:1111", "221.226.0.1:2222", "10.211.0.1:3333"};
for (int i = 0; i < nodes.length; i++)
System.out.println("[" + nodes[i] + "]的hash值为" +
getHash(nodes[i]) + ", 被路由到结点[" + getServer(nodes[i]) + "]");
}
}
/**
虚拟节点[192.168.0.0:8011&&VN0]被添加, hash值为637334537
虚拟节点[192.168.0.0:8011&&VN1]被添加, hash值为1942673682
虚拟节点[192.168.0.0:8011&&VN2]被添加, hash值为1111653162
虚拟节点[192.168.0.0:8011&&VN3]被添加, hash值为749027645
虚拟节点[192.168.0.0:8011&&VN4]被添加, hash值为752063515
虚拟节点[192.168.0.1:8011&&VN0]被添加, hash值为653786264
虚拟节点[192.168.0.1:8011&&VN1]被添加, hash值为132412064
虚拟节点[192.168.0.1:8011&&VN2]被添加, hash值为811025279
虚拟节点[192.168.0.1:8011&&VN3]被添加, hash值为326692669
虚拟节点[192.168.0.1:8011&&VN4]被添加, hash值为374169458
虚拟节点[192.168.0.2:8011&&VN0]被添加, hash值为1321894695
虚拟节点[192.168.0.2:8011&&VN1]被添加, hash值为1051614494
虚拟节点[192.168.0.2:8011&&VN2]被添加, hash值为1087571079
虚拟节点[192.168.0.2:8011&&VN3]被添加, hash值为781884308
虚拟节点[192.168.0.2:8011&&VN4]被添加, hash值为1623760690
虚拟节点[192.168.0.3:8011&&VN0]被添加, hash值为367036244
虚拟节点[192.168.0.3:8011&&VN1]被添加, hash值为1370453265
虚拟节点[192.168.0.3:8011&&VN2]被添加, hash值为458430883
虚拟节点[192.168.0.3:8011&&VN3]被添加, hash值为1845319771
虚拟节点[192.168.0.3:8011&&VN4]被添加, hash值为2139636740
虚拟节点[192.168.0.4:8011&&VN0]被添加, hash值为1842286794
虚拟节点[192.168.0.4:8011&&VN1]被添加, hash值为460849631
虚拟节点[192.168.0.4:8011&&VN2]被添加, hash值为2130990870
虚拟节点[192.168.0.4:8011&&VN3]被添加, hash值为573019492
虚拟节点[192.168.0.4:8011&&VN4]被添加, hash值为1063403512
[127.0.0.1:1111]的hash值为380278925, 被路由到结点[192.168.0.3:8011]
[221.226.0.1:2222]的hash值为1493545632, 被路由到结点[192.168.0.2:8011]
[10.211.0.1:3333]的hash值为1393836017, 被路由到结点[192.168.0.2:8011]
*/
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
- 70
- 71
- 72
- 73
- 74
- 75
- 76
- 77
- 78
- 79
- 80
- 81
- 82
- 83
- 84
- 85
- 86
- 87
- 88
- 89
- 90
- 91
- 92
- 93
- 94
- 95
- 96
- 97
- 98
- 99
- 100
- 101
- 102
- 103
- 104
- 105
- 106
- 107
- 108
- 109
- 110
- 111
- 112
- 113
- 114
- 115
- 116
- 117
- 118
- 119
- 120
- 121
- 122
- 123
- 124
- 125
- 126
- 127
- 128
- 129
- 130
- 131
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
- 70
- 71
- 72
- 73
- 74
- 75
- 76
- 77
- 78
- 79
- 80
- 81
- 82
- 83
- 84
- 85
- 86
- 87
- 88
- 89
- 90
- 91
- 92
- 93
- 94
- 95
- 96
- 97
- 98
- 99
- 100
- 101
- 102
- 103
- 104
- 105
- 106
- 107
- 108
- 109
- 110
- 111
- 112
- 113
- 114
- 115
- 116
- 117
- 118
- 119
- 120
- 121
- 122
- 123
- 124
- 125
- 126
- 127
- 128
- 129
- 130
- 131
从代码运行结果看,每个点路由到的服务器都是Hash值顺时针离它最近的那个服务器节点,没有任何问题。
通过采取虚拟节点的方法,一个真实结点不再固定在Hash换上的某个点,而是大量地分布在整个Hash环上,这样即使上线、下线服务器,也不会造成整体的负载不均衡。