一. 简述一致性哈希算法
这里不详细介绍一致性哈希算法的起源了,网上能方便地搜到许多介绍一致性哈希算法的好文章。本文主要想动手实现一致性哈希算法,并搭建一个环境进行实战测试。
在开始之前先整理一下算法的思路:
一致性哈希算法通过把每台服务器的哈希值打在哈希环上,把哈希环分成不同的段,然后对到来的请求计算哈希值从而得知该请求所归属的服务器。这个办法解决了传统服务器增减机器时需要重新计算哈希的麻烦。
但如果服务器的数量较少,可能导致计算出的哈希值相差较小,在哈希环上分布不均匀,导致某台服务器过载。为了解决负载均衡问题,我们引入虚拟节点技术,为每台服务器分配一定数量的节点,通过节点的哈希值在哈希环上进行划分。这样一来,我们就可以根据机器的性能为其分配节点,性能好就多分配一点,差就少一点,从而达到负载均衡。
二. 实现一致性哈希算法
奠定了整体思路后我们开始考虑实现的细节
- 哈希算法的选择
选择能散列出32位整数的 FNV 算法, 由于该哈希函数可能产生负数, 需要作取绝对值处理.
- 请求节点在哈希环上寻找对应服务器的策略
策略为:新节点寻找最近比且它大的节点, 比如说现在已经有环[0, 5, 7, 10], 来了个哈希值为6的节点, 那么它应该由哈希值为7对应的服务器处理. 如果请求节点所计算的哈希值大于环上的所有节点, 那么就取第一个节点. 比如来了个11, 将分配到0所对应的节点.
- 哈希环的组织结构
开始的时候想过用顺序存储的结构存放,但是在一致性哈希中,最频繁的操作是在集合中查找最近且比目标大的数. 如果用顺序存储结构的话,时间复杂度是收敛于O(N)的,而树形结构则为更优的O(logN)。
但凡事有两面,采用树形结构存储的代价是数据初始化的效率较低,而且运行期间如果有节点插入删除的话效率也比较低。但是在现实中,服务器在一开始注册后基本上就不怎么变了,期间增减机器,宕机,机器修复等事件的频率相比起节点的查询简直是微不足道。所以本案例决定使用使用树形结构存储。
贴合上述要求,并且提供有序存储的,首先想到的是红黑树,而且Java中提供了红黑树的实现 TreeMap
。
- 虚拟节点与真实节点的映射关系
如何确定一个虚拟节点对应的真实节点也是个问题。理论上应该维护一张表记录真实节点与虚拟节点的映射关系。本案例为了演示,采用简单的字符串处理。
比方说服务器 192.168.0.1:8888
分配了 1000 个虚拟节点, 那么它的虚拟节点名称从 192.168.0.1:8888@1
一直到 192.168.0.1:8888@1000
。通过这样的处理,我们在通过虚拟节点找真实节点时只需要裁剪字符串即可。
计划定制好后, 下面是具体代码:
publicclassConsistentHashTest{
/**
* 服务器列表,一共有3台服务器提供服务, 将根据性能分配虚拟节点
*/
publicstaticString[] servers ={
"192.168.0.1#100",//服务器1: 性能指数100, 将获得1000个虚拟节点
"192.168.0.2#100",//服务器2: 性能指数100, 将获得1000个虚拟节点
"192.168.0.3#30" //服务器3: 性能指数30, 将获得300个虚拟节点
};
/**
* 真实服务器列表, 由于增加与删除的频率比遍历高, 用链表存储比较划算
*/
privatestaticList<String> realNodes =newLinkedList<>();
/**
* 虚拟节点列表
*/
privatestaticTreeMap<Integer,String> virtualNodes =newTreeMap<>();
static{
for(String s : servers){
//把服务器加入真实服务器列表中
realNodes.add(s);
String[] strs = s.split("#");
//服务器名称, 省略端口号
String name = strs[0];
//根据服务器性能给每台真实服务器分配虚拟节点, 并把虚拟节点放到虚拟节点列表中.
int virtualNodeNum =Integer.parseInt(strs[1])*10;
for(int i =1; i <= virtualNodeNum; i++){
virtualNodes.put(FVNHash(name +"@"+ i), name +"@"+ i);
}
}
}
publicstaticvoid main(String[] args){
newThread(newRequestProcess()).start();
}
staticclassRequestProcessimplementsRunnable{
@Override
publicvoid run(){
String client =null;
while(true){
//模拟产生一个请求
client = getN()+"."+ getN()+"."+ getN()+"."+ getN()+":"+(1000+(int)(Math.random()*9000));
//计算请求的哈希值
int hash =FVNHash(client);
//判断请求将由哪台服务器处理
System.out.println(client +" 的请求将由 "+ getServer(client)+" 处理");
try{
Thread.sleep(500);
}catch(InterruptedException e){
e.printStackTrace();
}
}
}
}
privatestaticString getServer(String client){
//计算客户端请求的哈希值
int hash =FVNHash(client);
//得到大于该哈希值的所有map集合
SortedMap<Integer,String> subMap = virtualNodes.tailMap(hash);
//找到比该值大的第一个虚拟节点, 如果没有比它大的虚拟节点, 根据哈希环, 则返回第一个节点.
Integer targetKey = subMap.size()==0? virtualNodes.firstKey(): subMap.firstKey();
//通过该虚拟节点获得真实节点的名称
String virtualNodeName = virtualNodes.get(targetKey);
String realNodeName = virtualNodeName.split("@")[0];
return realNodeName;
}
publicstaticint getN(){
return(int)(Math.random()*128);
}
publicstaticintFVNHash(String data){
finalint p =16777619;
int hash =(int)2166136261L;
for(int i =0; i < data.length(); i++)
hash =(hash ^ data.charAt(i))* p;
hash += hash <<13;
hash ^= hash >>7;
hash += hash <<3;
hash ^= hash >>17;
hash += hash <<5;
return hash <0?Math.abs(hash): hash;
}
}
/* 运行结果片段
55.1.13.47:6240 的请求将由 192.168.0.1 处理
5.49.56.126:1105 的请求将由 192.168.0.1 处理
90.41.8.88:6884 的请求将由 192.168.0.2 处理
26.107.104.81:2989 的请求将由 192.168.0.2 处理
114.66.6.56:8233 的请求将由 192.168.0.1 处理
123.74.52.94:5523 的请求将由 192.168.0.1 处理
104.59.60.2:7502 的请求将由 192.168.0.2 处理
4.94.30.79:1299 的请求将由 192.168.0.1 处理
10.44.37.73:9332 的请求将由 192.168.0.2 处理
115.93.93.82:6333 的请求将由 192.168.0.2 处理
15.24.97.66:9177 的请求将由 192.168.0.2 处理
100.39.98.10:1023 的请求将由 192.168.0.2 处理
61.118.87.26:5108 的请求将由 192.168.0.2 处理
17.79.104.35:3901 的请求将由 192.168.0.1 处理
95.36.5.25:8020 的请求将由 192.168.0.2 处理
126.74.56.71:7792 的请求将由 192.168.0.2 处理
14.63.56.45:8275 的请求将由 192.168.0.1 处理
58.53.44.71:2089 的请求将由 192.168.0.3 处理
80.64.57.43:6144 的请求将由 192.168.0.2 处理
46.65.4.18:7649 的请求将由 192.168.0.2 处理
57.35.27.62:9607 的请求将由 192.168.0.2 处理
81.114.72.3:3444 的请求将由 192.168.0.1 处理
38.18.61.26:6295 的请求将由 192.168.0.2 处理
71.75.18.82:9686 的请求将由 192.168.0.2 处理
26.11.98.111:3781 的请求将由 192.168.0.1 处理
62.86.23.37:8570 的请求将由 192.168.0.3 处理
*/
经过上面的测试我们可以看到性能较好的服务器1和服务器2分担了大部分的请求,只有少部分请求落到了性能较差的服务器3上,已经初步实现了负载均衡。
下面我们将结合zookeeper,搭建一个更加逼真的服务器集群,看看在部分服务器上线下线的过程中,一致性哈希算法是否仍能够实现负载均衡。
三. 结合zookeeper搭建环境
环境介绍
首先会通过启动多台虚拟机模拟服务器集群,各台服务器都提供一个相同的接口供消费者消费。
同时会有一个消费者线程不断地向服务器集群发起请求,这些请求会经过一致性哈希算法均衡负载到各个服务器。
为了能够模拟上述场景, 我们必须在客户端维护一个服务器列表, 使得客户端能够通过一致性哈希算法选择服务器发送。(现实中可能会把一致性哈希算法实现在前端服务器, 客户先访问前端服务器, 再路由到后端服务器集群)。
但是我们的重点是模拟服务器的宕机和上线,看看一致性哈希算法是否仍能实现负载均衡。所以客户端必须能够感知服务器端的变化并动态地调整它的服务器列表。
为了完成这项工作, 我们引入 zookeeper
, zookeeper
的数据一致性算法保证数据实时, 准确, 客户端能够通过 zookeeper
得知实时的服务器情况。
具体操作是这样的: 服务器集群先以临时节点的方式连接到 zookeeper
, 并在 zookeeper
上注册自己的接口服务(注册节点). 客户端连接上 zookeeper
后, 把已注册的节点(服务器)添加到自己的服务器列表中。
如果有服务器宕机的话, 由于当初注册的是瞬时节点的原因, 该台服务器节点会从 zookeeper
中注销。客户端监听到服务器节点有变时, 也会动态调整自己的服务器列表, 把当宕机的服务器从服务器列表中删除, 因此不会再向该服务器发送请求, 负载均衡的任务将交到剩余的机器身上。
当有服务器从新连接上集群后, 客户端的服务器列表也会更新, 哈希环也将做出相应的变化以提供负载均衡。
具体操作:
I. 搭建 zookeeper
集群环境:
- 创建3个
zookeeper
服务, 构成集群. 在各自的data
文件夹中添加一个myid
文件, 各个id分别为1,2,3
.
2.
重新复制一份配置文件, 在配置文件中配置各个 zookeeper
的端口号. 本案例中三台 zookeeper
分别在 2181,2182,2183
端口
- 启动
zookeeper
集群
由于zookeeper不是本案例的重点, 细节暂不展开讲了.