关于一致性Hash算法在实际生产中的应用
一.背景
公司原先所有业务的数据都存储在一个相同的Redis集群里面,导致数据量太大,后续扩容不好扩,所以现在需要做的是将不同业务进行拆分,不同业务的数据存储到不同的Redis集群中。所以有以下实际。
二.思考
我们都知道这个算法的由来是因为在大数据时代,一个节点存储的数据量是有限的,系统的访问也是有瓶颈的,像一般数据库都是支持sharding的,也就是分片,来提高数据库访问的承受能力elasticsearch中使用的是分片,mongoDB中使用的也是sharding的概念,Redis集群模式使用的是哈希槽的方式,但是万变不离其宗,采用的思想都是路由的思想。
三.思考过程
3.1 是否可以直接使用Hash取模的方式来实现该需求呢?
其实经过思考,在本场景是可以满足需求的,因为我们是以集群为单位的,一旦集群建立好了就不会再有变动,并且一个集群中的节点不可能全部死掉,所以就不存在拆分数据后找不到数据的原因。但是使用Hash取模的方式存在哪种缺陷呢,假设是以节点为单位来实现hash取模存储,如果一个集群中的节点死掉了或者添加一个节点,导致集群中的节点个数减少了,取模后定位到的节点也会发生变化,从而导致在高峰期,在缓存中找不到数据而直接打到DB上而造成缓存雪崩,从而打垮服务。
3.2为什么还要采用一致性Hash来实现
为了长远考虑,因为谁也不知道过个三年五年是否还会添加集群,到时候还要预热多个集群的数据,采用一致hash,可以减少需要预热的集群的数据。
3.3.一致性hash原理
哈希函数实际上将对象和缓存映射到一个数字范围,Object的hashCode方法返回一个int,它位于它位于 -2 31到 2 31 -1 的范围内,将这个范围映射成一个圆圈。如下图
上图的 1,2,3,4代表的是缓存的数据,而A,B,C代表的是缓存的节点,当我们沿着顺时针查找的第一个节点就是我们需要存储的节点,从上图我们可以知道1,4属于A节点,2,属于B节点,3属于C节点,如果C节点被删除,此时3也属于A节点,若添加一个节点D这此时3,4属于D节点,而1属于A节点,如下图
但是这样做有一个缺陷,因为我们的节点个数比较少,可能导致数据集中在某些节点上,而发生数据倾斜,所以就有了另外一个概念,虚拟节点,增加虚拟节点的目的主要是为了让数据分配更加的均匀。
四.代码实现
4.1定义一致性hash算法工具类
public class ConsistentHashUtil<T> {
//hash算法实现
private final HashFunction hashFunction;
//一个节点对应的虚拟节点个数
private final int numberOfReplicas;
//存储节点的容器
private final SortedMap<Integer,T> circle=new TreeMap<>();
public ConsistentHashUtil(HashFunction hashFunction, int numberOfReplicas, Collection<T> nodes) {
this.hashFunction = hashFunction;
this.numberOfReplicas = numberOfReplicas;
for (T node : nodes) {
add(node);
}
}
private void add(T node){
for (int i = 0; i < numberOfReplicas; i++) {
//使用hash值作为key,真实节点为node
circle.put(hashFunction.hash(node.toString()+i),node);
}
}
private void remove(T node){
for (int i = 0; i < numberOfReplicas; i++) {
circle.remove(hashFunction.hash(node.toString()+i));
}
}
public T get (Object key){
if(circle.isEmpty()){
return null;
}
int hash =hashFunction.hash(key);
//判断是否存在这个key
if(!circle.containsKey(hash)){
//获取大于这个hash值得所有节点
SortedMap<Integer, T> tailMap = circle.tailMap(hash);
//判断是否为空,若为空则那所有节点中的第一个hash值,否则拿获取到的第一个
hash = tailMap.isEmpty() ? circle.firstKey() : tailMap.firstKey();
}
//获取真实节点
return circle.get(hash);
}
}
4.2定义一个函数式接口用来满足不同的hash算法
@FunctionalInterface
public interface HashFunction<T> {
int hash(T value);
}
4.4测试
public class ConsistentHashTest {
private static String[] servers = {"cluster1","cluster2","cluster3"};
private static final int VIRTUAL_NODES=100;
public static void main(String[] args) {
ConsistentHashUtil<String> consistentHashUtil = new ConsistentHashUtil<>(T -> {
String temp = (String) T;
final int p = 16777618;
int hash = (int) 2166136261L;
for (int i = 0; i < temp.length(); i++) {
hash = hash ^ temp.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;
}, VIRTUAL_NODES, Arrays.asList(servers));
//重复获取十次判断是否拿到的集群是同一个
for (int i = 0; i < 10; i++) {
String result = consistentHashUtil.get("ljm");
System.out.println(result);
}
}
}
4.5测试结果,从结果中我们可以看出拿到的结果都是cluster3,算法有效
五.总结
本场景中将原先的以节点为单位适用的一致性hash函数,引入到以集群为单位的场景中同样适用,一致性hash的概念没有阐述太多,具体的hash算法MD5,RSA,sha-1等算法,若自己公司想要用自己的算法,可以实现HashFunction这个函数式接口,也可以直接使用lambada表达式直接使用。