一致性 Hash 算法
关于一致性 Hash 算法, 在我之前的博文中已经有多次提到了, MemCache 超详细解读一文中一致性 Hash 算法部分, 对于为什么要使用一致性 Hash 算法和一致性 Hash 算法的算法原理做了详细的解读
算法的具体原理这里再次贴上:
先构造一个长度为 2 32 的整数环 (这个环被称为一致性 Hash 环), 根据节点名称的 Hash 值(其分布为[0, 2 32 -1]) 将服务器节点放置在这个 Hash 环上, 然后根据数据的 Key 值计算得到其 Hash 值(其分布也为[0, 2 32 -1]), 接着在 Hash 环上顺时针查找距离这个 Key 值的 Hash 值最近的服务器节点, 完成 Key 到服务器的映射查找
这种算法解决了普通余数 Hash 算法伸缩性差的问题, 可以保证在上线下线服务器的情况下尽量有多的请求命中原来路由到的服务器
当然, 万事不可能十全十美, 一致性 Hash 算法比普通 Hash 算法更具有伸缩性, 但是同时其算法实现也更为复杂, 本文就来研究一下, 如何利用 Java 代码实现一致性 Hash 算法在开始之前, 先对一致性 Hash 算法中的几个核心问题进行一些探究
数据结构的选取
一致性 Hash 算法最先要考虑的一个问题是: 构造出一个长度为 2 32 的整数环, 根据节点名称的 Hash 值将服务器节点放置在这个 Hash 环上
那么, 整数环应该使用何种数据结构, 才能使得运行时的时间复杂度最低? 首先说明一点, 关于时间复杂度, 常见的时间复杂度与时间效率的关系有如下的经验规则:
O(1) <O(log 2 N) < O(n) < O(N * log 2 N) < O(N 2 ) < O(N 3 ) < 2N < 3N < N!
一般来说, 前四个效率比较高, 中间两个差强人意, 后三个比较差(只要 N 比较大, 这个算法就动不了了)OK, 继续前面的话题, 应该如何选取数据结构, 我认为有以下几种可行的解决方案
1 解决方案一: 排序 + List
我想到的第一种思路是: 算出所有待加入数据结构的节点名称的 Hash 值放入一个数组中, 然后使用某种排序算法将其从小到大进行排序, 最后将排序后的数据放入 List 中, 采用 List 而不是数组是为了结点的扩展考虑
之后, 待路由的结点, 只需要在 List 中找到第一个 Hash 值比它大的服务器节点就可以了 , 比如服务器节点的 Hash 值是[0,2,4,6,8,10], 带路由的结点是 7, 只需要找到第一个比 7 大的整数, 也就是 8, 就是我们最终需要路由过去的服务器节点
如果暂时不考虑前面的排序, 那么这种解决方案的时间复杂度:
(1)最好的情况是第一次就找到, 时间复杂度为 O(1)
(2)最坏的情况是最后一次才找到, 时间复杂度为 O(N)
平均下来时间复杂度为 O(0.5N+0.5), 忽略首项系数和常数, 时间复杂度为 O(N)
但是如果考虑到之前的排序, 我在网上找了张图, 提供了各种排序算法的时间复杂度:
看得出来, 排序算法要么稳定但是时间复杂度高要么时间复杂度低但不稳定, 看起来最好的归并排序法的时间复杂度仍然有 O(N * logN), 稍微耗费性能了一些
2 解决方案二: 遍历 + List
既然排序操作比较耗性能, 那么能不能不排序? 可以的, 所以进一步的, 有了第二种解决方案
解决方案使用 List 不变, 不过可以采用遍历的方式:
(1)服务器节点不排序, 其 Hash 值全部直接放入一个 List 中
(2)带路由的节点, 算出其 Hash 值, 由于指明了顺时针, 因此遍历 List, 比待路由的节点 Hash 值大的算出差值并记录, 比待路由节点 Hash 值小的忽略
(3)算出所有的差值之后, 最小的那个, 就是最终需要路由过去的节点
在这个算法中, 看一下时间复杂度:
1 最好情况是只有一个服务器节点的 Hash 值大于带路由结点的 Hash 值, 其时间复杂度是 O(N)+O(1)=O(N+1), 忽略常数项, 即 O(N)
2 最坏情况是所有服务器节点的 Hash 值都大于带路由结点的 Hash 值, 其时间复杂度是 O(N)+O(N)=O(2N), 忽略首项系数, 即 O(N)
所以, 总的时间复杂度就是 O(N)其实算法还能更改进一些: 给一个位置变量 X, 如果新的差值比原差值小, X 替换为新的位置, 否则 X 不变这样遍历就减少了一轮, 不过经过改进后的算法时间复杂度仍为 O(N)
总而言之, 这个解决方案和解决方案一相比, 总体来看, 似乎更好了一些
3 解决方案三: 二叉查找树
抛开 List 这种数据结构, 另一种数据结构则是使用 二叉查找树 对于树不是很清楚的朋友可以简单看一下这篇文章树形结构
当然我们不能简单地使用二叉查找树, 因为可能出现不平衡的情况平衡二叉查找树有 AVL 树红黑树等, 这里使用红黑树, 选用红黑树的原因有两点:
1 红黑树主要的作用是用于存储有序的数据, 这其实和第一种解决方案的思路又不谋而合了, 但是它的效率非常高
2JDK 里面提供了红黑树的代码实现 TreeMap 和 TreeSet
另外, 以 TreeMap 为例, TreeMap 本身提供了一个 tailMap(K fromKey)方法, 支持从红黑树中查找比 fromKey 大的值的集合, 但并不需要遍历整个数据结构
使用红黑树, 可以使得查找的时间复杂度降低为 O(logN), 比上面两种解决方案, 效率大大提升
为了验证这个说法, 我做了一次测试, 从大量数据中查找第一个大于其中间值的那个数据, 比如 10000 数据就找第一个大于 5000 的数据 (模拟平均的情况) 看一下 O(N)时间复杂度和 O(logN)时间复杂度运行效率的对比:
50000 | 100000 | 500000 | 1000000 | 4000000 | |
ArrayList | 1ms | 1ms | 4ms | 4ms | 5ms |
LinkedList | 4ms | 7ms | 11ms | 13ms | 17ms |
TreeMap | 0ms | 0ms | 0ms | 0ms | 0ms |
因为再大就内存溢出了, 所以只测试到 4000000 数据可以看到, 数据查找的效率, TreeMap 是完胜的, 其实再增大数据测试也是一样的, 红黑树的数据结构决定了任何一个大于 N 的最小数据, 它都只需要几次至几十次查找就可以查到
当然, 明确一点, 有利必有弊, 根据我另外一次测试得到的结论是, 为了维护红黑树, 数据插入效率 TreeMap 在三种数据结构里面是最差的, 且插入要慢上 5~10 倍
Hash 值重新计算
服务器节点我们肯定用字符串来表示, 比如 192.168.1.1192.168.1.2, 根据字符串得到其 Hash 值, 那么另外一个重要的问题就是 Hash 值要重新计算, 这个问题是我在测试 String 的 hashCode()方法的时候发现的, 不妨来看一下为什么要重新计算 Hash 值:
- /**
- * String 的 hashCode()方法运算结果查看
- * @author 五月的仓颉 http://www.cnblogs.com/xrq730/*
- */
- public class StringHashCodeTest
- {
- public static void main(String[] args)
- {
- System.out.println("192.168.0.0:111 的哈希值:" + "192.168.0.0:1111".hashCode());
- System.out.println("192.168.0.1:111 的哈希值:" + "192.168.0.1:1111".hashCode());
- System.out.println("192.168.0.2:111 的哈希值:" + "192.168.0.2:1111".hashCode());
- System.out.println("192.168.0.3:111 的哈希值:" + "192.168.0.3:1111".hashCode());
- System.out.println("192.168.0.4:111 的哈希值:" + "192.168.0.4:1111".hashCode());
- }
- }
我们在做集群的时候, 集群点的 IP 以这种连续的形式存在是很正常的看一下运行结果为:
192.168.0.0:111 的哈希值: 1845870087
192.168.0.1:111 的哈希值: 1874499238
192.168.0.2:111 的哈希值: 1903128389
192.168.0.3:111 的哈希值: 1931757540
192.168.0.4:111 的哈希值: 1960386691
这个就问题大了,[0,2 32 -1]的区间之中, 5 个 HashCode 值却只分布在这么小小的一个区间, 什么概念?[0,2 32 -1]中有 4294967296 个数字, 而我们的区间只有 122516605, 从概率学上讲这将导致 97% 待路由的服务器都被路由到 192.168.0.1 这个集群点上, 简直是糟糕透了!
另外还有一个不好的地方: 规定的区间是非负数, String 的 hashCode()方法却会产生负数 (不信用 192.168.1.0:1111 试试看就知道了) 不过这个问题好解决, 取绝对值就是一种解决的办法
综上, String 重写的 hashCode()方法在一致性 Hash 算法中没有任何实用价值, 得找个算法重新计算 HashCode 这种重新计算 Hash 值的算法有很多, 比如 CRC32_HASHFNV1_32_HASHKETAMA_HASH 等, 其中 KETAMA_HASH 是默认的 MemCache 推荐的一致性 Hash 算法, 用别的 Hash 算法也可以, 比如 FNV1_32_HASH 算法的计算效率就会高一些
一致性 Hash 算法实现版本 1: 不带虚拟节点
使用一致性 Hash 算法, 尽管增强了系统的伸缩性, 但是也有可能导致负载分布不均匀, 解决办法就是使用 虚拟节点代替真实节点 , 第一个代码版本, 先来个简单的, 不带虚拟节点
下面来看一下不带虚拟节点的一致性 Hash 算法的 Java 代码实现:
- /**
- * 不带虚拟节点的一致性 Hash 算法
- * @author 五月的仓颉 http://www.cnblogs.com/xrq730/*
- */
- public class ConsistentHashingWithoutVirtualNode
- {
- /**
- * 待添加入 Hash 环的服务器列表
- */
- private static String[] servers = {"192.168.0.0:111", "192.168.0.1:111", "192.168.0.2:111",
- "192.168.0.3:111", "192.168.0.4:111"};
- /**
- * key 表示服务器的 hash 值, value 表示服务器的名称
- */
- private static SortedMap<Integer, String> sortedMap =
- new TreeMap<Integer, String>();
- /**
- * 程序初始化, 将所有的服务器放入 sortedMap 中
- */
- static
- {
- for (int i = 0; i <servers.length; i++)
- {
- int hash = getHash(servers[i]);
- System.out.println("[" + servers[i] + "]加入集合中, 其 Hash 值为" + hash);
- sortedMap.put(hash, servers[i]);
- }
- System.out.println();
- }
- /**
- * 使用 FNV1_32_HASH 算法计算服务器的 Hash 值, 这里不使用重写 hashCode 的方法, 最终效果没区别
- */
- private static int getHash(String str)
- {
- final int p = 16777619;
- int hash = (int)2166136261L;
- for (int i = 0; i < str.length(); i++)
- hash = (hash ^ str.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;
- }
- /**
- * 得到应当路由到的结点
- */
- private static String getServer(String node)
- {
- // 得到带路由的结点的 Hash 值
- int hash = getHash(node);
- // 得到大于该 Hash 值的所有 Map
- SortedMap<Integer, String> subMap =
- sortedMap.tailMap(hash);
- // 第一个 Key 就是顺时针过去离 node 最近的那个结点
- Integer i = subMap.firstKey();
- // 返回对应的服务器名称
- return subMap.get(i);
- }
- public static void main(String[] args)
- {
- String[] nodes = {"127.0.0.1:1111", "221.226.0.1:2222", "10.211.0.1:3333"};
- for (int i = 0; i <nodes.length; i++)
- System.out.println("[" + nodes[i] + "]的 hash 值为" +
- getHash(nodes[i]) + ", 被路由到结点[" + getServer(nodes[i]) + "]");
- }
- }
可以运行一下看一下结果:
[192.168.0.0:111]加入集合中, 其 Hash 值为 575774686
[192.168.0.1:111]加入集合中, 其 Hash 值为 8518713
[192.168.0.2:111]加入集合中, 其 Hash 值为 1361847097
[192.168.0.3:111]加入集合中, 其 Hash 值为 1171828661
[192.168.0.4:111]加入集合中, 其 Hash 值为 1764547046
[127.0.0.1:1111]的 hash 值为 380278925, 被路由到结点[192.168.0.0:111]
[221.226.0.1:2222]的 hash 值为 1493545632, 被路由到结点[192.168.0.4:111]
[10.211.0.1:3333]的 hash 值为 1393836017, 被路由到结点[192.168.0.4:111]
看到经过 FNV1_32_HASH 算法重新计算过后的 Hash 值, 就比原来 String 的 hashCode()方法好多了从运行结果来看, 也没有问题, 三个点路由到的都是顺时针离他们 Hash 值最近的那台服务器上
使用虚拟节点来改善一致性 Hash 算法
上面的一致性 Hash 算法实现, 可以在很大程度上解决很多分布式环境下不好的路由算法导致系统伸缩性差的问题, 但是会带来另外一个问题: 负载不均
比如说有 Hash 环上有 ABC 三个服务器节点, 分别有 100 个请求会被路由到相应服务器上现在在 A 与 B 之间增加了一个节点 D, 这导致了原来会路由到 B 上的部分节点被路由到了 D 上, 这样 AC 上被路由到的请求明显多于 BD 上的, 原来三个服务器节点上均衡的负载被打破了 某种程度上来说, 这失去了负载均衡的意义, 因为负载均衡的目的本身就是为了使得目标服务器均分所有的请求
解决这个问题的办法是引入虚拟节点, 其工作原理是: 将一个物理节点拆分为多个虚拟节点, 并且同一个物理节点的虚拟节点尽量均匀分布在 Hash 环上 采取这样的方式, 就可以有效地解决增加或减少节点时候的负载不均衡的问题
至于一个物理节点应该拆分为多少虚拟节点, 下面可以先看一张图:
横轴表示需要为每台福利服务器扩展的虚拟节点倍数, 纵轴表示的是实际物理服务器数可以看出, 物理服务器很少, 需要更大的虚拟节点; 反之物理服务器比较多, 虚拟节点就可以少一些比如有 10 台物理服务器, 那么差不多需要为每台服务器增加 100~200 个虚拟节点才可以达到真正的负载均衡
一致性 Hash 算法实现版本 2: 带虚拟节点
在理解了使用虚拟节点来改善一致性 Hash 算法的理论基础之后, 就可以尝试开发代码了编程方面需要考虑的问题是:
1 一个真实结点如何对应成为多个虚拟节点?
2 虚拟节点找到后如何还原为真实结点?
这两个问题其实有很多解决办法, 我这里使用了一种简单的办法, 给每个真实结点后面根据虚拟节点加上后缀再取 Hash 值, 比如 192.168.0.0:111 就把它变成 192.168.0.0:111&&VN0 到 192.168.0.0:111&&VN4,VN 就是 Virtual Node 的缩写, 还原的时候只需要从头截取字符串到 && 的位置就可以了
下面来看一下带虚拟节点的一致性 Hash 算法的 Java 代码实现:
- /**
- * 带虚拟节点的一致性 Hash 算法
- * @author 五月的仓颉 http://www.cnblogs.com/xrq730/*/
- public class ConsistentHashingWithVirtualNode
- {
- /**
- * 待添加入 Hash 环的服务器列表
- */
- private static String[] servers = {"192.168.0.0:111", "192.168.0.1:111", "192.168.0.2:111",
- "192.168.0.3:111", "192.168.0.4:111"};
- /**
- * 真实结点列表, 考虑到服务器上线下线的场景, 即添加删除的场景会比较频繁, 这里使用 LinkedList 会更好
- */
- private static List<String> realNodes = new LinkedList<String>();
- /**
- * 虚拟节点, key 表示虚拟节点的 hash 值, value 表示虚拟节点的名称
- */
- private static SortedMap<Integer, String> virtualNodes =
- new TreeMap<Integer, String>();
- /**
- * 虚拟节点的数目, 这里写死, 为了演示需要, 一个真实结点对应 5 个虚拟节点
- */
- private static final int VIRTUAL_NODES = 5;
- static
- {
- // 先把原始的服务器添加到真实结点列表中
- for (int i = 0; i <servers.length; i++)
- realNodes.add(servers[i]);
- // 再添加虚拟节点, 遍历 LinkedList 使用 foreach 循环效率会比较高
- for (String str : realNodes)
- {
- for (int i = 0; i < VIRTUAL_NODES; i++)
- {
- String virtualNodeName = str + "&&VN" + String.valueOf(i);
- int hash = getHash(virtualNodeName);
- System.out.println("虚拟节点 [" + virtualNodeName + "] 被添加, hash 值为" + hash);
- virtualNodes.put(hash, virtualNodeName);
- }
- }
- System.out.println();
- }
- /**
- * 使用 FNV1_32_HASH 算法计算服务器的 Hash 值, 这里不使用重写 hashCode 的方法, 最终效果没区别
- */
- private static int getHash(String str)
- {
- final int p = 16777619;
- int hash = (int)2166136261L;
- for (int i = 0; i < str.length(); i++)
- hash = (hash ^ str.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;
- }
- /**
- * 得到应当路由到的结点
- */
- private static String getServer(String node)
- {
- // 得到带路由的结点的 Hash 值
- int hash = getHash(node);
- // 得到大于该 Hash 值的所有 Map
- SortedMap<Integer, String> subMap =
- virtualNodes.tailMap(hash);
- // 第一个 Key 就是顺时针过去离 node 最近的那个结点
- Integer i = subMap.firstKey();
- // 返回对应的虚拟节点名称, 这里字符串稍微截取一下
- String virtualNode = subMap.get(i);
- return virtualNode.substring(0, virtualNode.indexOf("&&"));
- }
- public static void main(String[] args)
- {
- String[] nodes = {"127.0.0.1:1111", "221.226.0.1:2222", "10.211.0.1:3333"};
- for (int i = 0; i < nodes.length; i++)
- System.out.println("[" + nodes[i] + "]的 hash 值为" +
- getHash(nodes[i]) + ", 被路由到结点[" + getServer(nodes[i]) + "]");
- }
- }
关注一下运行结果:
虚拟节点 [192.168.0.0 : 111 && VN0]被添加,
hash 值为 1686427075 虚拟节点 [192.168.0.0 : 111 && VN1]被添加,
hash 值为 354859081 虚拟节点 [192.168.0.0 : 111 && VN2]被添加,
hash 值为 1306497370 虚拟节点 [192.168.0.0 : 111 && VN3]被添加,
hash 值为 817889914 虚拟节点 [192.168.0.0 : 111 && VN4]被添加,
hash 值为 396663629 虚拟节点 [192.168.0.1 : 111 && VN0]被添加,
hash 值为 1032739288 虚拟节点 [192.168.0.1 : 111 && VN1]被添加,
hash 值为 707592309 虚拟节点 [192.168.0.1 : 111 && VN2]被添加,
hash 值为 302114528 虚拟节点 [192.168.0.1 : 111 && VN3]被添加,
hash 值为 36526861 虚拟节点 [192.168.0.1 : 111 && VN4]被添加,
hash 值为 848442551 虚拟节点 [192.168.0.2 : 111 && VN0]被添加,
hash 值为 1452694222 虚拟节点 [192.168.0.2 : 111 && VN1]被添加,
hash 值为 2023612840 虚拟节点 [192.168.0.2 : 111 && VN2]被添加,
hash 值为 697907480 虚拟节点 [192.168.0.2 : 111 && VN3]被添加,
hash 值为 790847074 虚拟节点 [192.168.0.2 : 111 && VN4]被添加,
hash 值为 2010506136 虚拟节点 [192.168.0.3 : 111 && VN0]被添加,
hash 值为 891084251 虚拟节点 [192.168.0.3 : 111 && VN1]被添加,
hash 值为 1725031739 虚拟节点 [192.168.0.3 : 111 && VN2]被添加,
hash 值为 1127720370 虚拟节点 [192.168.0.3 : 111 && VN3]被添加,
hash 值为 676720500 虚拟节点 [192.168.0.3 : 111 && VN4]被添加,
hash 值为 2050578780 虚拟节点 [192.168.0.4 : 111 && VN0]被添加,
hash 值为 586921010 虚拟节点 [192.168.0.4 : 111 && VN1]被添加,
hash 值为 184078390 虚拟节点 [192.168.0.4 : 111 && VN2]被添加,
hash 值为 1331645117 虚拟节点 [192.168.0.4 : 111 && VN3]被添加,
hash 值为 918790803 虚拟节点 [192.168.0.4 : 111 && VN4]被添加,
hash 值为 1232193678[127.0.0.1 : 1111]的 hash 值为 380278925,
被路由到结点 [192.168.0.0 : 111][221.226.0.1 : 2222]的 hash 值为 1493545632,
被路由到结点 [192.168.0.0 : 111][10.211.0.1 : 3333]的 hash 值为 1393836017,
被路由到结点 [192.168.0.2 : 111]
从代码运行结果看, 每个点路由到的服务器都是 Hash 值顺时针离它最近的那个服务器节点, 没有任何问题
通过采取虚拟节点的方法, 一个真实结点不再固定在 Hash 换上的某个点, 而是大量地分布在整个 Hash 环上, 这样即使上线下线服务器, 也不会造成整体的负载不均衡
后记
在写本文的时候, 很多知识我也是边写边学, 难免有很多写得不好理解得不透彻的地方, 而且代码整体也比较糙, 未有考虑到可能的各种情况抛砖引玉, 一方面, 写得不对的地方, 还望网友朋友们指正; 另一方面, 后续我也将通过自己的工作学习不断完善上面的代码
来源: http://www.codeceo.com/java-hash-consistency.html