记录一致性hash算法,以及采用一致性hash进行消息对应关系分发
一致性Hash概述
为了能直观的理解一致性hash原理,结合一个简单的例子来,假设有4台服务器,地址为ip1,ip2,ip3,ip4。
一致性hash是首先计算四个ip地址对应的hash值
hash(ip1),hash(ip2),hash(ip3),hash(ip3),计算出来的hash值是0~最大正整数直接的一个值,这四个值在一致性hash环上呈现如下图:
hash环上顺时针从整数0开始,一直到最大正整数,我们根据四个ip计算的hash值肯定会落到这个hash环上的某一个点,至此我们把服务器的四个ip映射到了一致性hash环。
当用户在客户端进行请求时候,首先根据hash(用户id)计算路由规则(hash值),然后看hash值落到了hash环的那个地方,根据hash值在hash环上的位置顺时针找距离最近的ip作为路由ip。
如上图可知user1,user2的请求会落到服务器ip2进行处理,User3的请求会落到服务器ip3进行处理,user4的请求会落到服务器ip4进行处理,user5,user6的请求会落到服务器ip1进行处理。
当ip2的服务器挂了的时候,一致性hash环大致如下图:
根据顺时针规则可知user1,user2的请求会被服务器ip3进行处理,而其它用户的请求对应的处理服务器不变,也就是只有之前被ip2处理的一部分用户的映射关系被破坏了,并且其负责处理的请求被顺时针下一个节点委托处理。
当新增一个ip5的服务器后,一致性hash环大致如下图:
根据顺时针规则可知之前user5的请求应该被ip5服务器处理,现在被新增的ip5服务器处理,其他用户的请求处理服务器不变,也就是新增的服务器顺时针最近的服务器的一部分请求会被新增的服务器所替代。
一致性hash的特性单调性(Monotonicity),单调性是指如果已经有一些请求通过哈希分派到了相应的服务器进行处理,又有新的服务器加入到系统中时候,应保证原有的请求可以被映射到原有的或者新的服务器中去,而不会被映射到原来的其它服务器上去。 这个通过上面新增服务器ip5可以证明,新增ip5后,原来被ip1处理的user6现在还是被ip1处理,原来被ip1处理的user5现在被新增的ip5处理。
分散性(Spread):分布式环境中,客户端请求时候可能不知道所有服务器的存在,可能只知道其中一部分服务器,在客户端看来他看到的部分服务器会形成一个完整的hash环。如果多个客户端都把部分服务器作为一个完整hash环,那么可能会导致,同一个用户的请求被路由到不同的服务器进行处理。这种情况显然是应该避免的,因为它不能保证同一个用户的请求落到同一个服务器。所谓分散性是指上述情况发生的严重程度。好的哈希算法应尽量避免尽量降低分散性。 一致性hash具有很低的分散性。
平衡性(Balance):平衡性也就是说负载均衡,是指客户端hash后的请求应该能够分散到不同的服务器上去。一致性hash可以做到每个服务器都进行处理请求,但是不能保证每个服务器处理的请求的数量大致相同,如下图
服务器ip1,ip2,ip3经过hash后落到了一致性hash环上,从图中hash值分布可知ip1会负责处理大概80%的请求,而ip2和ip3则只会负责处理大概20%的请求,虽然三个机器都在处理请求,但是明显每个机器的负载不均衡,这样称为一致性hash的倾斜,虚拟节点的出现就是为了解决这个问题。
虚拟节点
当服务器节点比较少的时候会出现上节所说的一致性hash倾斜的问题,一个解决方法是多加机器,但是加机器是有成本的,那么就加虚拟节点,比如上面三个机器,每个机器引入1个虚拟节点后的一致性hash环的图如下:
其中ip1-1是ip1的虚拟节点,ip2-1是ip2的虚拟节点,ip3-1是ip3的虚拟节点。
可知当物理机器数目为M,虚拟节点为N的时候,实际hash环上节点个数为M*N。比如当客户端计算的hash值处于ip2和ip3或者处于ip2-1和ip3-1之间时候使用ip3服务器进行处理。
采用一致性hash进行消息对应关系分发
背景
类似kafka等传统的消息分发策略,为了实现负载均衡,会根据传入的key的hash值,通过取模的方法,尽可能保证消息能够相对均匀的分摊到每个可用的消费者分区上。
该算法虽然实现了消息的负载均衡,但是无法保证消息的对应关系,同一个类型(key)的消息可能会被分散在多个消费者分区上。在某些应用场景中,既希望多种类型的消息能够相对均匀的分摊给各个消费者,同时又希望同一个类型的消息能够被集中在某个消费者上被集中消费。例如:当有N个消费服务,有N条同类型的消息需要进行数据库操作时,传统的消息分发,每个消费服务会分配到一条消息,然后创建数据库连接,进行数据写入,当消息量多时,会使服务频繁创建和占用数据库连接,造成不必要的系统开销和负载。
为了解决上述情况,在rabbitmq为消息中间件的基础上,采用基于一致性hash的分布式消息分发方法,能够将同一类型的消息集中在一个消费者服务上处理,这样就能够复用数据库连接。当有另一类消息到达时又能够根据负载分发到其他消费者上。既能保证对需要复用资源的消息进行集中处理,又能实现其他消息的负载均衡。
实现步骤首先当消费者服务上线时,需要将自己的节点ID(consumer1)在zookeeper上进行注册,并且通过监听zookeeper的节点变化来感知是否有新的消费者服务上线,或者某个消费者服务被移除。
进行一致性Hash算法,定义当前消费者服务节点的hash值,通过hash算法计算节点consumer1的hash值为hash(consumer1)。本文采用MD5进行hash算法:
1
2
3
4
5
6
7
8
9
10
11
12md5 = MessageDigest.getInstance("MD5");
md5.reset();
md5.update(key.getBytes());
byte[] bKey = md5.digest();
//具体的哈希函数实现
long result = ((long) (bKey[3] & 0XFF) << 24)
| ((long) (bKey[2] & 0XFF) << 16)
| ((long) (bKey[1] & 0XFF) << 8)
| (long) (bKey[0] & 0XFF);
return result & 0xffffffffL;
既要保存hash值,又要保存对应的节点地址,比较直接的就是用map,在Java中没有什么map可以满足是个环状。那就找一个排序的,0开头,2^32-1做尾。查找时查到尾没有结果,再返回头找这样可以理解为是个环状了。
在java中ConcurrentSkipListMap和TreeMap虽然都是有序哈希表,此处需要使用线程安全的有序map结构,TreeMap线程不安全,所以使用跳表,调表查询复杂度与红黑树差不多,插入/删除不需要而外的平衡动作,区间查找性能优于红黑树。详见《SortedMap》
1
2SortedMap circle = new ConcurrentSkipListMap<>(); //由实际节点和虚拟节点构成的环
SortedMap nodeMap = new ConcurrentSkipListMap<>();//由实际节点构成的环
为了解决当消费者服务节点比较少的时候会出现一致性hash倾斜的问题,需要为每个消费者服务节点定义n个虚拟节点,既虚拟节点consumer1#(1…n)的hash值为hash(consumer1#(1…n))。将消费者节点和虚拟节点的hash值配置到值为0~最大正整数的环上。如图1所示。
1
2
3
4
5
6
7
8
9
10
11public void add(T node){
if (!nodeMap.containsKey(node.toString())) {
nodeMap.put(node.toString(), node);
for (int i = 0; i < numberOfReplicas; i++) {
String key = node.toString() + "#" + i;
Long hash = hashFunction.hash(key);
circle.putIfAbsent(hash, node);
}
}
}
当通过1步骤监听到有其他消费者服务上线时,获取上线节点的ID(consumerN),重复步骤2,3,计算出consumerN和consumerN虚拟节点的hash值,配置到环中。若监听到有其他消费者服务下线时,则将该下线节点及虚拟节点的hash值从环上移除。如图2所示。
当生产者产生一条需要执行数据库操作消息时,通过该消息的类型(DBUri),算出消息的hash值,通过该hash值在环上的位置,到环上顺时针找距离该值最近的消费者节点的hash值,向该消费者发送消息,消费者收到消息后建立则建立连接,执行操作。
1
2
3
4
5
6
7
8
9
10public T get(String key){
long hash = hashFunction.hash(key);
T node = circle.get(hash);
if (node == null) {
SortedMap tailMap = circle.tailMap(hash);
hash = tailMap.isEmpty() ? circle.firstKey() : tailMap.firstKey();
node = circle.get(hash);
}
return node;
}
若其他生产者又生产一条需要执行该数据库操作的消息,由于计算hash值的(DBUri)是相同的,所以能够保证该类型的消息往同一个消费者发,消费者收到消息后,由于原先连接已建立,直接复用连接进行数据库操作。这样,针对需要复用数据库资源的场景来说,该类型的消息只需要在该消费者上创建一次数据库连接,后续的消息就能够复用该数据库连接进行数据库操作,避免同一类型的消息被多个消费者消费而建立多个相同的数据库连接,减少资源和服务压力开销。如图3所示。
若其他生产者又生产一条需要执行另外一个数据库操作的消息,由于计算hash值的(DBUri2)是不相同的,新的hash值又会落到环上某个位置,到环上顺时针找距离该值最近的消费者节点的hash值,向另一个消费者发送消息,另一个消费者收到消息后,建立数据库连接执行操作。