分布式一致性hash算法详解及实现
1.一致性hash算法应用领域
1.1 哪些地方有用到hash算法?
以缓存为例:
使用缓存的目的:提升数据访问性能,缓解数据库压力。
互联网公司的分布式高并发系统有什么特点?
(1)高并发 - 应用集群(负载均衡)处理
(2)海量数据 - 缓存(Redis\Memcache)
1.分布式数据存储场景下,为什么这种场景需要用到一致性hash算法呢?
近年来B2C、O2O等商业概念的提出和移动端的发展,使得分布式系统流行了起来。分布式系统相对于单系统,解决了流量大、系统高可用和高容错等问题。功能强大也意味着实现起来需要更多技术的支持。例如系统访问层的负载均衡,缓存层的多实例主从复制备份,数据层的分库分表等。
我们以负载均衡为例,常见的负载均衡方法有很多,但是它们的优缺点也都很明显:
1.2 一致性哈希算法基本原理
一致性哈希算法在1997年由麻省理工学院的Karger等人在解决分布式Cache中提出的,设计目标是为了解决因特网中的热点(Hot spot)问题,初衷和CARP十分类似。一致性哈希修正了CARP使用的简单哈希算法带来的问题,使得DHT可以在P2P环境中真正得到应用。
简单来说,一致性Hash算法将整个哈希值空间组织成一个虚拟的圆环,如假设某哈希函数 H 的值空间为 0 ~ 2^32-1(即哈希值是一个32位无符号整形),整个哈希环如下:
整个空间圆按顺时针方向布局,圆环的正上方的点代表0,0点右侧的第一个点代表1。以此类推2、3、4、5、6……直到2^32-1,
我们把这个由2^32个点组成的圆环称为 Hash环。
那么,一致性哈希算法与上图中的圆环有什么关系呢?仍然以之前描述的场景为例,假设我们有4台服务器,服务器0、服务器1、服务器2,服务器3,那么,在生产环境中,这4台服务器肯定有自己的 IP 地址或主机名,我们使用它们各自的 IP 地址或主机名作为关键字进行哈希计算,最后会得到一个 [0, 2^32-1]之间的一个无符号整形数,这个整数就代表服务器的编号。同时这个整数肯定处于[0, 2^32-1]之间,那么,上图中的 hash 环上必定有一个点与这个整数对应。那么这个服务器就可以映射到这个环上。
多个服务器都通过这种方式进行计算,最后都会各自映射到圆环上的某个点,这样每台机器就能确定其在哈希环上的位置,如下图所示。
1.3 如何提高容错性和扩展性的
那么用户访问,如何分配访问的服务器呢?我们根据用户的 IP 使用上面相同的函数 Hash 计算出哈希值,并确定此数据在环上的位置,从此位置沿环 顺时针行走,遇到的第一台服务器就是其应该定位到的服务器。
1.3.1 新增服务器节点
如果这时需要新增一台服务器节点,一致性哈希策略是如何应对的呢?
如下图所示,我们新增了一台服务器4,通过上述一致性哈希算法计算后得出它在哈希环的位置。可以发现,原来访问服务器3的用户1现在访问的对象是服务器4,用户能正常访问且服务不需要停机就可以自动切换。
1.3.2 删除服务器节点
如果这时某台服务器异常宕机或者运维撤销了一台服务器,那么这时会发生什么情况呢?如下图所示,假设我们撤销了服务器2。
可以看出,我们服务仍然能正常提供服务,只不过这时用户2会被分配到服务1上了而已。
通过一致性哈希的方式,我们提高了我们系统的容错性和可扩展性,分布式节点的变动不会影响整个系统的运行且不需要我们做一些人为的调整策略。
1.3.3 Hash环的数据倾斜问题
一致性哈希虽然为我们提供了稳定的切换策略,但是它也有一些小缺陷。因为 hash取模算法得到的结果是随机的,我们并不能保证各个服务节点能均匀的分配到哈希环上。
例如当有4个服务节点时,我们把哈希环认为是一个圆盘时钟,我们并不能保证4个服务节点刚好均匀的落在时钟的 12、3、6、9点上。
分布不均匀就会产生一个问题,用户的请求访问就会不均匀,同时4个服务承受的压力就会不均匀。这种问题现象我们称之为,Hash环的数据倾斜问题。
如果想要均衡的将缓存分布到每台服务器上,最好能让这每台服务器尽量多的、均匀的出现在hash环上,但是如上图中所示,真实的服务器资源只有4台,我们怎样凭空的让它们多起来呢?
既然没有多余的真正的物理服务器节点,我们就只能将现有的物理节点通过虚拟的方法复制出来。
这些由实际节点虚拟复制而来的节点被称为 “虚拟节点”,即对每一个服务节点计算多个哈希,每个计算结果位置都放置一个此服务节点,称为虚拟节点。具体做法可以在服务器IP或主机名的后面增加编号来实现。
同时数据定位算法不变,只是多了一步虚拟节点到实际节点的映射,例如定位到 “Server1-A”、“Server1-B” 两个虚拟节点的数据均定位到 服务器1上。这样就解决了服务节点少时数据倾斜的问题。
在实际应用中,通常将虚拟节点数设置为32甚至更大,因此即使很少的服务节点也能做到相对均匀的数据分布。由于虚拟节点数量较多,与虚拟节点的映射关系也变得相对均衡了。
2.一致性hash算法实现
2.1 实现步骤
1.hash值是一个非负整数,把非负整数的值范围做成一个圆环;
2.对集群的节点的某个属性求hash值(如节点名称),根据hash值把节点放到环上;
3.对数据的key求hash值,一样的把数据也放到环上,按顺时针方向,找离他最近的节点,就存储到这个节点上。(这个圆环相当于有序的集合,且为了方便查找,存储结构使用treeMap)
2.2 实现
2.2.1 节点类
class Node{
String name;
String id;
public Node(){}
public Node(String id,String name){
this.id = id;
this.name = name;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
}
2.2.2 实现过程
/**
* 一致性hash算法过程:
* 1.物理节点
* 2.虚拟节点
* 3.hash算法
* 4.虚拟节点放到环上
* 5.数据找到对应的环上
* @author rmling
*/
public class ConsistencyHash {
private List<Node> realNodes = new ArrayList<Node>();//物理节点
private int virtualNodeNum = 100;//虚拟节点个数
private Map<Node,List<Integer>> entity2VirtualNodeIdMap = new HashMap<>();//物理节点与虚拟节点hash值的对应关系map
private SortedMap<Integer, Node> sortedMap = new TreeMap<>();//定义一个环集合
/**
* 添加一个服务器:
* 1.将服务器节点虚拟出多个虚拟节点
* 2.将虚拟节点放到环上
* @param node
*/
public void addServer(Node node){
realNodes.add(node);
//生成虚拟节点
List<Integer> virtualNodes = new ArrayList<Integer>();
for(int i = 0;i < virtualNodeNum; i++){
Node v = new Node(node.getId()+"-"+i, node.getName()+"-"+i);
//计算虚拟节点的hash值
int hashValue = FNVHash1(v.getId());
//将物理放到环上
this.sortedMap.put(hashValue, node);
virtualNodes.add(hashValue);
}
entity2VirtualNodeIdMap.put(node, virtualNodes);
}
/**
* 删除一台服务器
*/
public void removeServer(Node node){
//删除物理节点
realNodes.remove(node);
//删除环上的该节点
List<Integer> value = entity2VirtualNodeIdMap.get(node);
for(Integer v:value){
sortedMap.remove(v, node);
}
//删除物理节点与虚拟节点的对应关系
entity2VirtualNodeIdMap.remove(node,value);
}
/**
* 找到数据的存放节点
* @param key
* @return
*/
public Node getServer(String key){
int hashValue = FNVHash1(key);
//从下到大有序map : tailMap取大于等于hashValue的部分
SortedMap<Integer, Node> subMap = sortedMap.tailMap(hashValue);
if(subMap.isEmpty()){
return sortedMap.get(sortedMap.firstKey());
}else{
return subMap.get(subMap.firstKey());
}
}
2.2.3 hash算法选择
1.java中String类型自带hashCode()算法,该算法不够散列,会有负值(取绝对值)
2.其他hash算法:
(1) CRC32_HASH
(2) FNV1_32_HASH
(3) KETAMA_HASH (默认的MemCache推荐的一致性hash算法)
这里我选用的是FNV1_32_HASH算法,实现如下:
/**
* 改进的32位FNV算法1,计算hash值
* @param data 字符串
* @return int值
*/
public int FNVHash1(String data) {
final int p = 16777619;
int hash = (int) 2166136261L;
for (int i = 0; i < data.length(); i++)
hash = (hash ^ data.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;
}
}
2.3 测试
public static void main(String[] args) {
ConsistencyHash sh = new ConsistencyHash();
Node node1 = new Node("192.168.0.11", "明的服务器-1");
//添加服务器
sh.addServer(node1);
sh.addServer(new Node("192.168.0.12", "明的服务器-2"));
sh.addServer(new Node("192.168.0.13", "明的服务器-3"));
//查询数据对应的服务器
for(int i = 0; i < 10 ;i++){
String data = "test数据 "+i;
Node n = sh.getServer(data);
System.out.println(data+" 对应的服务器 :"+n.getId()+" "+n.getName());
}
//删除服务器1
sh.removeServer(node1);
System.out.println("删除服务器1后的数据对应情况输出:");
for(int i = 0; i < 10 ;i++){
String data = "test数据 "+i;
Node n = sh.getServer(data);
System.out.println(data+" 对应的服务器 :"+n.getId()+" "+n.getName());
}
}
测试结果输出:
test数据 0 对应的服务器 :192.168.0.11 明的服务器-1
test数据 1 对应的服务器 :192.168.0.13 明的服务器-3
test数据 2 对应的服务器 :192.168.0.12 明的服务器-2
test数据 3 对应的服务器 :192.168.0.11 明的服务器-1
test数据 4 对应的服务器 :192.168.0.13 明的服务器-3
test数据 5 对应的服务器 :192.168.0.12 明的服务器-2
test数据 6 对应的服务器 :192.168.0.13 明的服务器-3
test数据 7 对应的服务器 :192.168.0.12 明的服务器-2
test数据 8 对应的服务器 :192.168.0.11 明的服务器-1
test数据 9 对应的服务器 :192.168.0.13 明的服务器-3
删除服务器1后的数据对应情况输出:
test数据 0 对应的服务器 :192.168.0.13 明的服务器-3
test数据 1 对应的服务器 :192.168.0.13 明的服务器-3
test数据 2 对应的服务器 :192.168.0.12 明的服务器-2
test数据 3 对应的服务器 :192.168.0.13 明的服务器-3
test数据 4 对应的服务器 :192.168.0.13 明的服务器-3
test数据 5 对应的服务器 :192.168.0.12 明的服务器-2
test数据 6 对应的服务器 :192.168.0.13 明的服务器-3
test数据 7 对应的服务器 :192.168.0.12 明的服务器-2
test数据 8 对应的服务器 :192.168.0.13 明的服务器-3
test数据 9 对应的服务器 :192.168.0.13 明的服务器-3
3.总结
在分布式存储和分布式缓存中,当服务节点发生变化时(新增或减少),一致性哈希算法并不能杜绝数据迁移的问题,但是可以有效避免数据的全量迁移,需要迁移的只是更改的节点和它的上游节点它们两个节点之间的那部分数据。
另外,我们都知道 hash算法 有一个避免不了的问题,就是哈希冲突。对于用户请求IP的哈希冲突,其实只是不同用户被分配到了同一台服务器上,这个没什么影响。但是如果是服务节点有哈希冲突呢?这会导致两个服务节点在哈希环上对应同一个点,其实我感觉这个问题也不大,因为一方面哈希冲突的概率比较低,另一方面我们可以通过虚拟节点也可减少这种情况。