从分布式看一致性哈希算法
最近一段时间忙着两个重大项目的发布,好久没有更新文章了。一致性哈希其实并不复杂,在各大面试中都能接触到。一般的套路就是从Hashmap开始问起其中的hashcode,到一致性哈希的思路,今天我们从实现的角度看一个简单的实现,也让大家有个更直观的印象。
在强一致性的分布式选举算法Raft中使用leader-follower机制实现了共识协商,但写请求只能限制在leader节点上处理,导致了集群的处理性能类似于单机,那么请求量的增加,集群的性能可能就扛不住了,会造成系统过载和服务不可用,这时可以考虑使用哈希算法。
1、哈希算法
关于哈希算法,我曾经在《》中介绍过哈希算法的原理,其最终根据计算得到一个int型的数据,这个数值就是hashcode。这个int型的hashcode也是下面我们说的一致性哈希为什么会在2的32次方减1这个圆环上分布节点和数据的原因。
哈希算法是计算数据的哈希值,然后根据节点的个数作为哈希桶,将该数据的哈希值进行取余操作,然后存放到该节点中。等查找该数据的时候,需要根据该数据的哈希值得到存放的节点,然后获取该节点下的数据。这里,可以把节点想象成分布式系统下的单台服务器。数据存放在某节点就代表存放在某台机器。
这里,我们以一个Key-Value数据为例,类似于Redis集群那样存放。这里假设定义的节点DataObj类如下:
public class DataObj {String key;String value;public DataObj(String key, String value) {this.key = key;this.value = value;}//此处省略hashcode方法和toString方法}
我们定义一下机器的节点Node类,用于存储上面的数据节点。简单起见,我们只实现一下存和取方法。
public class Node {HashMap<Integer,DataObj> hashmap;String NodeName;//NodeName的作用在实现虚拟节点有用public Node(String nodeName) {NodeName = nodeName;hashmap = new HashMap<>();}//存放数据public void putDataObj(DataObj obj){hashmap.put(obj.hashCode(),obj);return;}//获取数据public DataObj getData(DataObj obj){return hashmap.get(obj.hashCode());}}
接下来,我们实现一个类NodeArray存放所有节点,通过该类存放和获取数据,相当于访问集群的意思了。
public class NodeArray {public static final int MAX_NODE= 1024;Node[] nodes = new Node[MAX_NODE];int size = 0;public NodeArray() {}public void addNode(Node node){if(size>MAX_NODE) return;nodes[size++] = node;}DataObj get(DataObj obj) {int index = obj.hashCode() % size;return nodes[index].getData(obj);}void put(DataObj obj) {int index = obj.hashCode() % size;nodes[index].putDataObj(obj);}}
下面我们测试看看,假如我们创建了3个节点,并存放了5个数据。最后再逐一访问这些数据节点,这些数据可以正常查询到。
然后,我们新增一个节点或删除一个节点,此时已经无法获取到节点了。
2、一致性哈希算法
上面的问题出现在增加或减少节点的情况下,这就类似于某台机器故障或扩容了一台机器,此时数据访问基本全部失效,需要重新rehash全部数据。而一致性哈希算法则可以有效减少数据迁移工作。那么一致性哈希算法如何解决的呢?
一致性哈希首先将节点也映射到这个环上面,这个环就是hashcode的大小,当通过节点的key去查找节点的时候,先找到最近的Node,然后再获取值。
public class ConsistNodeArray {/** 按照键排序*/TreeMap<Integer, Node> nodes = new TreeMap<>();void addNode(Node node) {nodes.put(node.hashCode(), node);}void put(DataObj obj) {int objHashcode = obj.hashCode();Node node = nodes.get(objHashcode);if (node != null) {node.putDataObj(obj);return;}// 找到比给定 key 大的集合SortedMap<Integer, Node> tailMap = nodes.tailMap(objHashcode);// 找到最小的节点int nodeHashcode = tailMap.isEmpty() ?nodes.firstKey() : tailMap.firstKey();nodes.get(nodeHashcode).putDataObj(obj);}DataObj get(DataObj obj) {Node node = nodes.get(obj.hashCode());if (node != null) {return node.getData(obj);}// 找到比给定 key 大的集合SortedMap<Integer,Node> tailMap=nodes.tailMap(obj.hashCode());// 找到最小的节点int nodeHashcode = tailMap.isEmpty() ?nodes.firstKey():tailMap.firstKey();return nodes.get(nodeHashcode).getData(obj);}}
此时如果不新增节点的话,测试结果可以全部查询到数据。如果新增2个节点,也能获取大部分数据。
这里需要注意的是,新增或减少节点,肯定需要做部分数据迁移的,只是这个迁移量很小,有一些专业的测试结果显示:对于 1000 万 key 的 3 节点 KV 存储,如果我们增加 1 个节点,变为 4 节点集群,只需要迁移 24.3% 的数据。如果原有节点数量越多,其实迁移量更少。当然了,如果这里引入虚拟节点,那么迁移量更低。
至于虚拟节点的实现,可以在创建Node的时候指定名称为NodeName-A,NodeName-B之类的,实际节点是同一个Node即可,这里不再赘述。
3、总结
一致性哈希算法通过两次哈希计算,不直接寻找映射的节点,而是找到距离当前数据哈希值最近的节点Node,然后将数据存储到该节点上。当发生节点Node故障或新增节点时,也只是影响这部分区域的节点。
对于要是想数据更均匀的分布在一致性哈希算法上面,可以引入虚拟节点的方法解决。
