概要
- 本篇博客从哈希算法的介绍和基本的哈希思想开始,介绍了哈希函数是啥,常见的哈希冲突的解决办法,然后说明了哈希算法在分布式集群架构中的应用。
- 哈希算法在分布式集群架构中一般用作负载均衡,和数据分片。
- 同时也介绍了负载均衡中,一般的哈希算法的局限性,并以此介绍了一致性哈希算法的原理并且用java语言实现了简单的案例。还列举了nginx配置一致性哈希负载均衡的例子。帮助大家理解各个分布式框架的哈希原理
一、哈希算法介绍
1.1 哈希思想
哈希思想
将需要被查找的数据中的,可以表示某个具体数据对象的关键字,转化为数组下标的映射,利用数组按照下标访问元素时事件复杂度为O(1)的特性,实现快速访问。这个将关键字转换为数组下标的方法,就称为哈希函数。通过哈希函数计算得到的值,就是哈希值。
Hash算法常见应用
- 加密算法 MD5、SHA等
- 数据存储方面的Hash表
为什么使用Hash算法
- Hash算法较多应用在存储和查找领域,例如Hash表
- Hash表查询效率非常高,较好的Hash算法的事件复杂度接近于O(1)
1.2 Hash函数
1.2.1 Hash函数的要求
- 哈希函数算出来的哈希值,是一个非负整数(要用做数组下标)
- 如果用来计算的键(key)相等,那么哈希值一定相等
- 如果用来计算的键不相等,那么hash值也不相等。(没有Hash冲突)
前面两点都可以理解,但是第三个要求,实现起来非常有难度,要找一个没有冲突的Hash函数几乎是不可能。即使时业界知名的MD5、SHA、CRC等Hash算法也没办法解决Hash冲突的问题,因为数组的存储空间总归是有限的。好的Hash函数,应该是计算过程简单,并且数据Hash后分布均匀。
1.2.2 常见Hash函数的设计方法
- 直接寻址法:计算出数据中的非负整数的键,直接将建当作数组的下标进行存储。
- 优点:速度快,一次查找,不会hash冲突。
- 缺点:浪费空间,数组大小需要大于数据范围
- 除数留余法:和直接寻址法相比,此方法会将数据对数组大小求模。取得的结果作为数组下标
- 其他Hash算法:平方取中法、折叠法、随机数法、线性构造hash算法
1.3 解决哈希冲突的办法
1.3.1 开放寻址法
开放寻址法的核心思想:一旦出现哈希冲突,就通过重新探测新位置来解决冲突。
重新探测位置的方法:线性探测法、二次探测法、双重哈希法
线性探测法
- 原理 如果发现哈希冲突,就顺着这个位置往下查找(到尾部没找到则从头再找),直到找到空闲的位置进行保存。查找时,首先判断哈希函数计算出来的位置是否存在数据,如果存在则依次向下遍历数组依次和查找的对象进行比较。如果查找到空闲位置还没找到目标对象。则认为目标对象不存在于哈希表中。
- 查找过程中,如果查找路径中元素被删除,那么可能导致遍历提前停止得出错误的结果。所以线性探测法,引入了delete标记,查找过程中删除的数据会标记为delete。遍历时遇到delete标记的空间,仍会继续向下查找比较
- **效率:**最坏情况下遍历整个数组,时间复杂度为O(n) 最好时间复杂度为O(1)
二次探测法
- 二次探测法和线性探测法非常相似,只是线性探测法是依次向下探测,而二次探测法是每次探测的步长是探测次数的平方。
- 探测序列是:
hash(key)+0²,hash(key)+1²,hash(key)+2²,......
双重哈希法
- 双重哈希法使用多个哈希函数
- 探测序列是:
hash1(key)、hash2(key)、hash3key)
1.3.2 链表法
链表法比开放寻址法要更简单
- 经过哈希算法得出对应元素,对应的 桶/槽
- 每个桶/槽都会对应着一个链表
- 插入数据时,只要得出元素的桶/槽的位置,再将数据插入到链表中即可
- 查找数据时,查找耗时跟链表长度有关,如果n表示数据的个数,m表示哈希表桶的个数,那么链表长度k=n/m 如果K是一个不大的常量,可以粗略的认为 查找和删除的时间复杂度是 O(1)
- K可以认为是装载因子。装载因子=元素个数/槽的个数,装载因子越大,说明链表越长,查找性能越低。
二、哈希算法在分布式集群架构中的应用
Hash算法在很多分布式集群产品中都有应用,例如分布式集群架构的Redis、Hadoop、ElasticSearch、Mysql分库分表、Nginx负载均衡等。
主要的应用场景:
- 请求的负载均衡(nginx的ip_hash策略:保证客户端ip不变时,请求被分配到同一个目标放服务器,实现会话粘滞,避免处理Session共享问题)
- 分布式存储
2.1 负载均衡
会话粘滞:属于同一会话的请求,都会被路由到同一个服务器上。
2.1.1 维护路由表实现负载均衡
如果不使用哈希算法,要实现一个带会话粘滞的负载均衡我们一般会维护一个会话ID/IP和机器的映射关系。每次收到一个请求,都根据ip或者会话ID去查询这个路由表。但是这种策略会存在一些弊端
- 客户端一多,路由表就会巨大无比浪费内存
- 客户端的上下线、服务器的扩容都会更新路由表,维护成本巨大。
2.1.2 哈希算法用于负载均衡
- 对会话id或者客户端IP进行哈希,得到对应服务器的下标
- 这样只要相同的会话ID或者ip就会被转发到同一台机器上。nginx的ip_hash负载均衡机制就是这么做的。这种方式天然的实现了,会话粘滞,并且不需要维护路由映射表。
- 但是普通的hash算法(简单的取模),在负载均衡服务器增多时,所有的会话对应的服务地址都会变动,如果涉及会话,或者缓存。这种情况就会出现问题。
- 引入一致性哈希算法可以将Session丢失和缓存穿透的影响降到最小。
- Session丢失可以参考下面介绍的解决办法。
2.1.3 补充:Session丢失问题一般解决办法
哈希算法实现的负载均衡,虽然能实现会话粘滞,但是在新增服务器时,同一个会话,可能会被分配到新增的服务器上,这时候客户端保存的Cookie在新加的服务器上就是失效的,用户可能就会掉出登录状态。这就是Session丢失。
解决方法:Session保持、Session复制、Session共享
方案 | 说明 | 优略 |
---|---|---|
Session保持(原来在哪儿还去哪儿) | 用nginx举例,nginx对Session保存的支持,需要引入nginx-sticky-module模块。nginx会在新的客户端加入为其分配一个节点的同时,会将节点的唯一标识进行md5后,作为一个cookie一并返回客户端,如果再次收到这个客户端的请求,就根据这个特殊的cookie来将其转发到对应的节点。 | 缺点1. 如果浏览器关闭了cookie就凉了2. 破坏了负载均衡的初衷,如果新增了服务器节点,只要没有新的客户端加入,那么这个节点永远收不到请求。3. 减少节点,仍然会丢失会话。 优点1. 简单 |
Session复制(随便你哪儿都数据都一样) | cookie在每个服务器节点间复制同步,这样部分节点宕机都没事。适用于会话数据小的场景。 | 优点:天然高可用,宕机一部分都不会session丢失。增加节点也不会。 缺点:1. 需要多个节点间来回复制同步数据,带来了数据一致性的问题。2. 会话数据的增多,节点的增多,都会使得数据同步、延迟、等性能的消耗增多。 |
Session共享(随便去哪儿咱都是一份数据) | 中心化思想,将多个节点的会话数据同一保存,这样不论节点增多还是减小都没有影响。100%保持了会话。 | 优点:会话100%保持 缺点:1. 单独的共享介质,增加了额外的成本2. 数据保存的介质需要解决单点问题。同样引入了系统性风险。 |
2.2 分布式存储
- 原理:把数据存储到不同的机器上的时候,利用哈希算法确定数据保存的机器。
- 举例:以分布式内存数据库Redis为例,集群中有redis1,redis2,redis3 三台Redis服务器那么,在进⾏数据存储时,<key1,value1>数据存储到哪个服务器当中呢?这时可以用哈希算法 针对key进⾏hash处理,hash(key1)%3=index, 使⽤余数index锁定存储的具体服务器节点。
三、一致性哈希算法
3.1 普通哈希算法存在的问题
在分布式缓存的场景中,缓存的数据原本按照普通的哈希算法,通过取模确定好了每个数据保存的服务器。此时如果增加/减少服务器,那么原有的缓存数据,和新的数据保存规则就无法对应了。
举例:查询缓存数据时,由于规则的改变,旧数据可能存在于服务器1,但是按照新的查询规则查询后,由于新增了服务器可能去服务器2查询缓存数据,这时候就无法查询到缓存,出现了缓存穿透问题。对于分布式系统中的节点数量变化时,就需要用到一致性哈希算法。
3.2 一致性哈希算法
3.2.1 基本思想
- 同样采用哈希的思想,但是会把请求的某些参数例如 客户端id、会话id、等映射到一个一个很大的数字空间里,比如 2^32让他们自然溢出。
- 将2^32看作0 然后整个空间看起来就是一个首位相接的环,其中每个值称之为“项”(item)
- 将每个节点,按照标识(ip或者编号),映射到这个环上 每个节点称之为 桶(bucket)
- 按照某个方向,寻找距离最近的桶,桶对应的位置就是一致性哈希的位置。在负载均衡场景下,就是对应的节点了。
- 这样在增加/J减少
3.2.2 虚拟节点策略
接着上一段的思路,看似没有问题。但是如果节点数量太少,那么在环上就可能分布的不够均匀,会导致
各个节点收到的请求数量不够均衡也就偏离了负载均衡的本意。于是就引入了虚拟节点的概念。就是确定好某个bucket的哈希标识后,给每个bucket再额外分配一定数量的副本,并且将其也映射到环上。tiem找到了某个副本的话,请求也会被指向对应的bucket。
3.3 java中实现一致性哈希算法示例
3.3.1 不包含虚拟节点举例
思路
- 使用Object的hashCode()作为哈希算法,最大值不会超过int,分别对服务器和客户端进行哈希
- 利用SortedMap 保存服务器节点(bucket) 的hashcode。其中哈希值作为key,ip作为value。SortedMap保存时会自动按照key值排序,比较省事儿
- 得到客户端ip的hashCode,利用SortedMapa的tailMap方法,可以方便得到最近的节点,实现一致性哈希算法
public class ConsistenHashNoVitureNode {
public static void main(String[] args) {
//1.将节点映射到hash环 bucket(桶)
//定义服务器ip
String[] servers=new String[]{"127.222.1.1","127.153.1.2","127.168.1.3","127.168.1.0"};
SortedMap<Integer,String> serverHashMap=new TreeMap<>();
for (String server:servers){
//求出ip地址与其hash值,并保存映射。此处简单用hashCode,仅作演示
int serverHash = Math.abs(server.hashCode());
//存储映射关系,并且自动按照了Key排序
serverHashMap.put(serverHash,server);
}
//2.针对客户端的ip求出对应的hash值 --项(item)
//这里定义了相同的客户端IP,可以看结果是否被路由到同样的server
String[] clients=new String[]{"127.165.1.1","127.153.1.2","127.222.1.3","127.333.1.4","127.153.1.2","127.333.1.4"};
for (String client : clients) {
Integer clientHash = Math.abs(client.hashCode());
//按照传入key大小,截取比key打的部分
SortedMap<Integer, String> tailMap = serverHashMap.tailMap(clientHash);
//3.针对客户端找到其对应的bucket节点
Integer bucketServerKey=tailMap.isEmpty()?serverHashMap.firstKey():tailMap.firstKey();
String bucketServerIp=serverHashMap.get(bucketServerKey);
System.out.println("客户端ip:"+client+" ===>>> 服务端:"+bucketServerIp);
}
}
}
3.3.2 包含虚拟节点举例
增加虚拟节点的不同之处就在于,略微修改了真实server的ip后再生成hashCode 作为虚拟节点,这些节点的value仍对应之前的serverip
public class ConsistenHashWithNoVitureNode {
public static void main(String[] args) {
//1.将节点映射到hash环 bucket(桶)
//定义服务器ip
String[] servers = new String[]{"127.222.1.1", "127.153.1.2", "127.168.1.3", "127.168.1.0"};
//定义服务器的hashcode和真实ip的映射
SortedMap<Integer, String> serverHashMap = new TreeMap<>();
//定义虚拟节点的个数
int vitureBucketNum = 3;
for (String server : servers) {
//求出ip地址与其hash值,并保存映射。此处简单用hashCode,仅作演示
int serverHash = Math.abs(server.hashCode());
//存储映射关系,并且自动按照了Key排序
serverHashMap.put(serverHash, server);
//增加虚拟节点
for (int i = 0; i < vitureBucketNum; i++) {
int vitureServerHashCode = new String(server + "#" + i).hashCode();
serverHashMap.put(vitureServerHashCode, "虚拟节点映射:" + server);
}
}
//2.针对客户端的ip求出对应的hash值 --项(item)
String[] clients = new String[]{"127.165.1.1", "127.153.1.2", "127.222.1.3", "127.333.1.4", "127.153.1.2", "127.333.1.4"};
for (String client : clients) {
Integer clientHash = Math.abs(client.hashCode());
//按照传入key大小,截取比key打的部分
SortedMap<Integer, String> tailMap = serverHashMap.tailMap(clientHash);
//3.针对客户端找到其对应的bucket节点
Integer bucketServerKey = tailMap.isEmpty() ? serverHashMap.firstKey() : tailMap.firstKey();
String bucketServerIp = serverHashMap.get(bucketServerKey);
System.out.println("客户端ip:" + client + " ===>>> 服务端:" + bucketServerIp);
}
}
}
四、案例:nginx中的一致性哈希算法模块
nginx是一个模块化的程序,初始安装时是不支持一致性哈希算法的,需要引入额外的模块。
- nginx的一致性哈希算法的负载均衡器的模块(这是一个第三方模块,用的很多且很老旧):ngx_http_upstream_consistent_hash
- ngx_http_upstream_consistent_hash支持根据配置参数来选择不同的方式将请求均衡的映射到负载均衡中的服务
- consistent_hash $remote_addr 根据客户端ip映射
- consistent_hash $request_uri 根据客户端请求uir映射
- consistent_hash $args 根据客户端携带的参数进行映射
- 安装ngx_http_upstream_consistent_hash
- 从github上下载模块 https://github.com/replay/ngx_http_consistent_hash
- 进入已经安装的nginx的源码目录,编译安装插件
./configure —add-module=/root/ngx_http_consistent_hash-master
make
make install
- 配置一致性哈希负载均衡