1. 什么是一致性哈希
一致性哈希算法核心思想,就是通过构造一个长度为2^32的整数环,这个环也被称为一致性Hash环(只是逻辑上的环),将缓存服务器的节点名称的哈希值均匀的分布在[0,2^32-1]的Hash环上,然后根据需要缓存的Key值计算得到其Hash值,然后在Hash环上顺时针查找距离Key的Hash值最近的服务器节点,完成Key到服务器的映射查找;如下图所示
2. 一致性哈希解决了什么问题
Hash算法解决的是集群管理中请求访问的路由的问题,一般是根据一个请求的某个key值取余,路由到相应的服务器,比如,Key的hashCode是52,服务器的数目是3,取余之后为1,则该key对应的节点是Node1,由于HashCode随机性比较强,所以使用余数Hash路由算法就可以保证缓存数据在整个缓存服务器集群中有比较均衡的分布,这就是余数Hash的算法,但是这种算法会有一个问题就是集群扩容了或者服务器下线了,那么取余的方式,原来Key映射到节点可能不会是Node1,可能会导致某个节点的请求压力变大,一致性哈希解决了普通余数Hash算法伸缩性差的问题,可以保证在上线、下线服务器的情况下尽量有多的请求命中原来路由到的服务器。
3. 如何实现一致性哈希算法
一致性哈希算法的实现首先就是要考虑如何构造出一个长度为2^32的整数环,采用什么数据结构,才能使得运行的时间复杂度最低,常见的时间复杂度与时间效率的关系有如下的经验规则:
O(1) < O(log2N) < O(n) < O(N * log2N) < O(N2) < O(N3) < 2N < 3N < N!
一般来说,前四个效率比较高,中间两个差强人意,后三个比较差(只要N比较大,这个算法就动不了了
TreeMap底层使用了红黑树,时间复杂度为O(logN),效率比较高,可以作为实现哈希环的数据结构;Hash算法的选择上,首先我们考虑简单的String.HashCode()方法,这个算法的缺点是,相似的字符串如N1(10.0.0.0:91001),N2(10.0.0.0:91002),N3(10.0.0.0:91003),哈希值也很相近,造成的结果是节点在Hash环上分布很紧密,导致大部分Key值落到了N0上,节点资源分布不均。一般我们采用FNV1_32_HASH、KETAMA_HASH等算法,KETAMA_HASH是MemCache集群默认的实现方法,这些算法效果要好得多,会使N0,N1,N2的Hash值更均匀的分布在环上;
所以初步的一致性哈希算法的实现如下:
public class ConsistentHashLoadBalanceNoVirtualNode {
private TreeMap<Long, String> realNodes = new TreeMap<>();
private String[] nodes;
public ConsistentHashLoadBalanceNoVirtualNode(String[] nodes) {
this.nodes = Arrays.copyOf(nodes, nodes.length);
//初始化哈希环
initalization();
}
/**
* 初始化哈希环,循环计算每个node的哈希值,放入TreeMap中
*/
private void initalization() {
for (String nodeName : nodes) {
realNodes.put(calculateHash(nodeName, 0), nodeName);
}
}
/**
* 根据资源key选择返回相应的节点名称
*
* @param key
* @return
*/
public String selectNode(String key) {
Long hashofKey = calculateHash(key, 0);
if (!realNodes.containsKey(hashofKey)) {
//获得比hashofKey大的第一个Entry
Map.Entry<Long, String> entry = realNodes.ceilingEntry(hashofKey);
if (entry != null) {
return entry.getValue();
} else {
return nodes[0];
}
} else {
return realNodes.get(hashofKey);
}
}
/**
* 使用KETAMA_HASH算法重新计算哈希值
*
* @param nodeName
* @param number
* @return
*/
private Long calculateHash(String nodeName, int number) {
byte[] digest = this.md5(nodeName);
return ((long) (digest[3 + number * 4] & 0xFF) << 24
| ((long) (digest[2 + number * 4] & 0xFF) << 16)
| ((long) (digest[1 + number * 4] & 0xFF) << 8)
| (digest[number * 4] & 0xFF))
& 0xFFFFFFFFL;
}
private byte[] md5(String nodeName) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
md.reset();
md.update(nodeName.getBytes("UTF-8"));
return md.digest();
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
return null;
}
private void printTreeNode() {
if (realNodes != null && !realNodes.isEmpty()) {
realNodes.forEach((hashKey, node) -> {
System.out.println(new StringBuffer(node)
.append("==>")
.append(hashKey));
});
} else {
System.out.println("Cycle is Empty");
}
}
public static void main(String[] args) {
String[] nodes = new String[]{"192.168.2.1:8080", "192.168.2.2:8080", "192.168.2.3:8080", "192.168.2.4:8080"};
ConsistentHashLoadBalanceNoVirtualNode consistentHash = new ConsistentHashLoadBalanceNoVirtualNode(nodes);
consistentHash.printTreeNode();
}
}
执行结果:
192.168.2.3:8080==>1182102228
192.168.2.4:8080==>1563927337
192.168.2.1:8080==>2686712470
192.168.2.2:8080==>3540412423
KETAMA_HASH解决了hash值分布不均的问题,但还存在一个问题,如下图,在没有Node3节点时,资源相对均匀的分布在{Node0,Node1,Node2}上。增加了Node3节点后,Node1到Node3节点中间的所有资源从Node2迁移到了Node3上。这样,Node0,Node1存储的资源多,Node2,Node3存储的资源少,资源分布不均匀。
如何解决这个问题呢?可以引入虚拟节点概念,如将一个真实节点Node0映射成100个虚拟节点分布在Hash环上,与这100个虚拟节点根据KETAMA_HASH哈希环匹配的资源都存到真实节点Node0上。{Node0,Node1,Node2}以相同的方式拆分虚拟节点映射到Hash环上。当集群增加节点Node3时,在Hash环上增加Node3拆分的100个虚拟节点,这新增的100个虚拟节点更均匀的分布在了哈希环上,可能承担了{Node0,Node1,Node2}每个节点的部分资源,资源分布仍然保持均匀。
每个真实节点应该拆分成多少个虚拟节点?数量要合适才能保证负载分布的均匀,有一个大致的规律,如下图所示,Y轴表示真实节点的数目,X轴表示需拆分的虚拟节点数目:
真实节点越少,所需阐发的虚拟节点越多,比如有10个真实节点,每个节点所需拆分的虚拟节点个数可能是100~200个,才能达到真正的负载均衡,带有虚拟节点的实现如下:
public class ConsistentHashLoadBalance {
private TreeMap<Long, String> virtualNodes = new TreeMap<>();
private LinkedList<String> nodes;
//每个真实节点对应的虚拟节点数
private final int replicCnt;
public ConsistentHashLoadBalance(LinkedList<String> nodes, int replicCnt) {
this.nodes = nodes;
this.replicCnt = replicCnt;
//初始化哈希环
initalization();
}
/**
* 初始化哈希环
*/
private void initalization() {
for (String nodeName : nodes) {
for (int i = 0; i < replicCnt / 4; i++) {
String virtualNodeName = getNodeNameByIndex(nodeName, i);
for (int j = 0; j < 4; j++) {
virtualNodes.put(getHash(virtualNodeName, j), nodeName);
}
}
}
}
private String selectNode(String key) {
Long hashofKey = getHash(key, 0);
if (!virtualNodes.containsKey(hashofKey)) {
//获得比hashofKey大的第一个Entry
Map.Entry<Long, String> entry = virtualNodes.ceilingEntry(hashofKey);
if (entry != null) {
return entry.getValue();
} else {
return nodes.getFirst();
}
} else {
return virtualNodes.get(hashofKey);
}
}
private Long getHash(String nodeName, int number) {
byte[] digest = md5(nodeName);
return (((long) (digest[3 + number * 4] & 0xFF) << 24)
| ((long) (digest[2 + number * 4] & 0xFF) << 16)
| ((long) (digest[1 + number * 4] & 0xFF) << 8)
| (digest[number * 4] & 0xFF))
& 0xFFFFFFFFL;
}
/**
* 74 * md5加密
* 75 *
* 76 * @param str
* 77 * @return
* 78
*/
private byte[] md5(String nodeName) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
md.reset();
md.update(nodeName.getBytes("UTF-8"));
return md.digest();
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
return null;
}
/**
* 根据资源key选择返回相应的节点名称
*
* @param nodeName
* @param index
* @return
*/
private String getNodeNameByIndex(String nodeName, int index) {
return new StringBuffer(nodeName)
.append("&&")
.append(index)
.toString();
}
public void addNode(String node) {
nodes.add(node);
String virtualNodeName = getNodeNameByIndex(node, 0);
for (int i = 0; i < replicCnt / 4; i++) {
for (int j = 0; j < 4; j++) {
virtualNodes.put(getHash(virtualNodeName, j), node);
}
}
}
public void removeNode(String node) {
nodes.remove(node);
String virtualNodeName = getNodeNameByIndex(node, 0);
for (int i = 0; i < replicCnt / 4; i++) {
for (int j = 0; j < 4; j++) {
virtualNodes.remove(getHash(virtualNodeName, j), node);
}
}
}
private void printTreeNode() {
if (virtualNodes != null && !virtualNodes.isEmpty()) {
virtualNodes.forEach((hashKey, node) -> {
System.out.println(new StringBuffer(node)
.append("==>")
.append(hashKey));
});
} else {
System.out.println("Cycle is Empty");
}
}
public static void main(String[] args) {
LinkedList<String> nodes = new LinkedList<>();
nodes.add("192.168.2.1:8080");
nodes.add("192.168.2.2:8080");
nodes.add("192.168.2.3:8080");
nodes.add("192.168.2.4:8080");
ConsistentHashLoadBalance consistentHash = new ConsistentHashLoadBalance(nodes, 160);
consistentHash.printTreeNode();
}
}
执行结果(部分):
192.168.2.4:8080==>18075595
192.168.2.1:8080==>18286704
192.168.2.1:8080==>35659769
192.168.2.2:8080==>43448858
192.168.2.1:8080==>44075453
192.168.2.3:8080==>47625378
192.168.2.4:8080==>52449361
4. 引用资料