![be872557f25e5250520058cf9a9e0ef8.png](https://i-blog.csdnimg.cn/blog_migrate/1d74fb81446c8ad4e27217c6a398b61d.jpeg)
一致性Hash算法
普通余数Hash算法与一致性Hash算法的区别,一致性Hash算法解决了什么问题?
普通余数Hash算法
我们前面研究Memcache(mc)与Redis的区别时,分析到mc是通过客户端来实现服务集群高可用的,客户端会有服务集群的节点列表信息,客户端通过一个负载均衡算法来实现服务的均衡调用。我们先来梳理下常用的负载均衡(将外部发送来的请求均匀分配到对称结构中的某一台服务器上)算法有哪些:
1.轮询(Round Robin)法
2.随机(Random)法
3.源地址哈希(Hash)法
4.加权轮询(Weight Round Robin)法
5.加权随机(Weight Random)法
6.最小连接数(Least Connections)法
我们今天分析下哈希法,根据请求Ip求出hash值,假如mc集群的节点数量为3,则将hash值与3进行求余操作,求出的余数分别对应3个节点服务器(1:节点1,2:节点2,3:节点3),当算出来的hash值随机性比较强的情况,请求分配到各个服务器的数量会大致相同(求hash值的散列函数设计非常重要)。
优点:
1.算法实现足够简单,服务器节点数量设置为2n次方,可以使用&求出余数(通过位运算进一步优化cpu计算性能)。
2.散列函数计算出的Hash值足够随机的话,请求可以均匀地分摊到各个服务节点。
缺点:
该算法的伸缩性不好,当新增节点和下线节点时,大量的请求不能命中mc缓存。
假设mc服务器集群由3台变为4台吧,更改服务器列表,仍然使用余数Hash,50对4的余数是2,对应Node2,但是str原来是存在Node1上的,这就导致了缓存没有命中。如果这么说不够明白,那么不妨举个例子,原来有HashCode为0~19的20个数据,那么:
![75ecd85b6ff9b67455640812005cdb8c.png](https://i-blog.csdnimg.cn/blog_migrate/57408fd06063caac57b02d5e2cbf3bd6.png)
如果我扩容到20+的台数,只有前三个HashCode对应的Key是命中的,也就是15%。通过上述例子我们可以知道当新增和下线节点时,原数据库中的数据会大量无法命中,而且是节点数越高,命中率越低,往往我们的集群节点数量会比较多,所以这个结果是我们无法接受的。
这个问题有什么解决办法呢?
(1)在网站访问量低谷,通常是深夜,技术团队加班,扩容、重启服务器
(2)通过模拟请求的方式逐渐预热缓存,使缓存服务器中的数据重新分布
(3)一致性Hash算法
一致性Hash算法
一致性Hash算法通过一个一致性Hash环的数据结构实现key到缓存服务器的Hash映射。
![1db865b3cec15f7ac01dd2db07baca51.png](https://i-blog.csdnimg.cn/blog_migrate/e9323d61b750d66282cae280bbac0400.png)
具体算法过程为:先构造一个长度为2的32次方的整数环(这个环被称为一致性Hash环),根据节点名称的Hash值(其分布为[0, 2的32次方-1])将缓存服务器节点放置在这个Hash环上,然后根据需要缓存的数据的Key值计算得到其Hash值(其分布也为[0, 2的32次方-1]),然后在Hash环上顺时针查找距离这个Key值的Hash值最近的服务器节点,完成Key到服务器的映射查找。
一致性Hash算法是如何解决余数Hash算法节点新增/删除时大量原请求无法命中原目标服务器?
![872c1100d63b0a40f71580139fb2543c.png](https://i-blog.csdnimg.cn/blog_migrate/27c53d23944d2a8514a07e0236d79f50.png)
加了一个Node4节点后,只影响到了一个Key值的数据,本来这个Key值应该是在Node1服务器上的,现在要去Node4了。采用一致性Hash算法,的确也会影响到整个集群,但是影响的只是加粗的那一段而已,相比余数Hash算法影响了远超一半的影响率,这种影响要小得多。更重要的是,集群中缓存服务器节点越多,增加节点带来的影响越小,很好理解。换句话说,随着集群规模的增大,继续命中原有缓存数据的概率会越来越大,虽然仍然有小部分数据缓存在服务器中不能被读到,但是这个比例足够小,即使访问数据库,也不会对数据库造成致命的负载压力。
总结:
1.一致性Hash算法并没有完全解决节点数量变化后,部分数据无法命中的问题。只是解决了伸缩性差的问题,一致性Hash算法只会影响变化节点(新增或删除节点)顺时针方向的第一个节点,而且环上的节点数量越多,影响的数据范围越小。反之余数Hash算法的伸缩性非常差,节点变化之后影响的数据范围非常大(3个节点拓展到4个节点,缓存命中率只有15%),绝大部分数据都受到了影响。
一致性Hash算法的实现
实现思路:
1.如何设计这个一致性hash环的数据结构,并且把节点的Hash值放到环上?
2.如何设计散列函数保证算出来的Hash值足够随机?
3.怎么分配节点的虚拟节点,并且把虚拟节点放置到Hash环中?
4.如何根据请求key的Hash值找到正确的节点?
解决方案一:排序+List
我想到的第一种思路是:算出所有待加入数据结构的节点名称的Hash值放入一个数组中,然后使用某种排序算法将其从小到大进行排序,最后将排序后的数据放入List中,采用List而不是数组是为了结点的扩展考虑。
之后,待路由的结点,只需要在List中找到第一个Hash值比它大的服务器节点就可以了,比如服务器节点的Hash值是[0,2,4,6,8,10],带路由的结点是7,只需要找到第一个比7大的整数,也就是8,就是我们最终需要路由过去的服务器节点。这个方案的主要缺点是:需要对数组的数据进行排序操作,数组的数据比较多的话排序这个操作会比较耗时。
解决方案二:遍历+List
既然排序操作比较耗性能,那么能不能不排序?可以的,所以进一步的,有了第二种解决方案。
解决方案使用List不变,不过可以采用遍历的方式:
- 服务器节点不排序,其Hash值全部直接放入一个List中
- 带路由的节点,算出其Hash值,由于指明了"顺时针",因此遍历List,比待路由的节点Hash值大的算出差值并记录,比待路由节点Hash值小的忽略
- 算出所有的差值之后,最小的那个,就是最终需要路由过去的节点
解决方案三:二叉查找树
抛开List这种数据结构,另一种数据结构则是使用二叉查找树。对于树不是很清楚的朋友可以简单看一下这篇文章树形结构。
当然我们不能简单地使用二叉查找树,因为可能出现不平衡的情况。平衡二叉查找树有AVL树、红黑树等,这里使用红黑树,选用红黑树的原因有两点:
- 红黑树主要的作用是用于存储有序的数据,这其实和第一种解决方案的思路又不谋而合了,但是它的效率非常高
- JDK里面提供了红黑树的代码实现TreeMap和TreeSet
另外,以TreeMap为例,TreeMap本身提供了一个tailMap(K fromKey)方法,支持从红黑树中查找比fromKey大的值的集合,但并不需要遍历整个数据结构。
使用红黑树,可以使得查找的时间复杂度降低为O(logN),比上面两种解决方案,效率大大提升。
为了验证这个说法,我做了一次测试,从大量数据中查找第一个大于其中间值的那个数据,比如10000数据就找第一个大于5000的数据(模拟平均的情况)。看一下O(N)时间复杂度和O(logN)时间复杂度运行效率的对比:
![ced391757e35430284ba41b855e6029d.png](https://i-blog.csdnimg.cn/blog_migrate/4d76c3bfc0695a219dfab4fd008c1137.png)
因为再大就内存溢出了,所以只测试到4000000数据。可以看到,数据查找的效率,TreeMap是完胜的,其实再增大数据测试也是一样的,红黑树的数据结构决定了任何一个大于N的最小数据,它都只需要几次至几十次查找就可以查到。
当然,明确一点,有利必有弊,根据我另外一次测试得到的结论是,为了维护红黑树,数据插入效率TreeMap在三种数据结构里面是最差的,且插入要慢上5~10倍。
一致性Hash算法实现版本1:不带虚拟节点
/**
使用虚拟节点来改善一致性Hash算法
Hash环上有A、B、C三个服务器节点,分别有100个请求会被路由到相应服务器上。现在在A与B之间增加了一个节点D,这导致了原来会路由到B上的部分节点被路由到了D上,这样A、C上被路由到的请求明显多于B、D上的,原来三个服务器节点上均衡的负载被打破了。某种程度上来说,这失去了负载均衡的意义,因为负载均衡的目的本身就是为了使得目标服务器均分所有的请求。
解决这个问题的办法是引入虚拟节点,其工作原理是:将一个物理节点拆分为多个虚拟节点,并且同一个物理节点的虚拟节点尽量均匀分布在Hash环上。采取这样的方式,就可以有效地解决增加或减少节点时候的负载不均衡的问题。
一致性Hash算法实现版本2:带虚拟节点
在理解了使用虚拟节点来改善一致性Hash算法的理论基础之后,就可以尝试开发代码了。编程方面需要考虑的问题是:
- 一个真实结点如何对应成为多个虚拟节点?
- 虚拟节点找到后如何还原为真实结点?
这两个问题其实有很多解决办法,我这里使用了一种简单的办法,给每个真实结点后面根据虚拟节点加上后缀再取Hash值,比如"192.168.0.0:111"就把它变成"192.168.0.0:111&&VN0"到"192.168.0.0:111&&VN4",VN就是Virtual Node的缩写,还原的时候只需要从头截取字符串到"&&"的位置就可以了。
下面来看一下带虚拟节点的一致性Hash算法的Java代码实现:
/**