分布式:一致性Hash算法
什么是一致性Hash
Hash
Hash,一般翻译做散列、杂凑,或音译为哈希,是把任意长度的输入(又叫做预映射pre-image)通过散列算法变换成固定长度的输出,该输出就是散列值。这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,
hash 算法就是将字符串转换为数字的算法
一致性哈希
一致性哈希算法在1997年由麻省理工学院提出,是一种特殊的哈希算法,
在移除或者添加一个服务器时,能够尽可能小地改变已存在的服务请求与处理请求服务器之间的映射关系。一致性哈希解决了简单哈希算法在分布式哈希表( Distributed Hash Table,DHT) 中存在的动态伸缩等问题 [2]。
简单地说在移除或者添加一个服务器时,此算法能够尽可能小地改变已存在的服务请求与处理请求服务器之间的映射关系,尽可能满足单调性的要求。
主要解决什么问题
目的是解决分布式缓存的问题。
存在的问题
绑定映射关系-单机模式
普通分布式集群中,服务请求与处理请求服务器之间可以一一对应,也就是说固定服务请求与处理服务器之间的映射关系,某个请求由固定的服务器去处理。这种方式无法对整个系统进行负载均衡,可能会造成某些服务器过于繁忙以至于无法处理新来的请求。而另一些服务器则过于空闲,整体系统的资源利用率低,并且当分布式集群中的某个服务器宕机,会直接导致某些服务请求无法处理
这种类似于宇哥主机服务只处理指定的用户集群。如果该主机服务宕机了。这些所有用户都不能访问服务了。
普通hash算法-解决单机问题
进一步的改进可以利用hash算法对服务请求与处理服务器之间的关系进行映射,以达到动态分配的目的。通过hash算法对服务请求进行转换,转换后的结果对服务器节点值进行取模运算,取模后的值就是服务请求对应的请求处理服务器。这种方法可以应对节点失效的情况,当某个分布式集群节点宕机,服务请求可以通过hash算法重新分配到其他可用的服务器上。避免了无法处理请求的状况出现
场景: 我们部署了三台服务器 A,B,C 提供给客户。我们采用了计算 客户id的(hashcode % 3) 的方式的路由策略作为我们负载均衡服务器的路由算法。现在我们的客户总共由 10个 id = [ 1, 2,3,4,5,6,7,8,9,10]。
那么根据我们 (hashcode(id) %3) 的算法去计算用户实际分配到那个服务器上。我们可知 用户分配到服务器上
-
A= 1 : [1,4,7,10]
-
B=2 :[2,5,8]
-
C=0 :[3,6,9]
客户请求数据时,都能合理落到对应的服务器上 。
一台机器如果挂掉了,可以实现动态切换路由到其他可用的服务器上。
-
服务器挂掉(移除)
假如A服务器突然挂掉了。假如负载均衡服务器发现了,并把 路由算法的除数改成可用的服务器数量(hashcode % 2) 了。那么。现在客户的实际的分配点重新计算
-
B = 0 : 【2,4,6,8,10】
-
C = 1 : [1,3,5,7,9]
-
因为在我们服务器动态伸缩时, (hascode % num) 中num 是可变的。一旦变化,其分配结果会同时变化。导致数据访问出错。这在分布式系统中是风险较大的。
普通hash存在的问题
但这种方法的缺陷也很明显,如果服务器中保存有服务请求对应的数据,那么如果重新计算请求的hash值,会造成大量的请求被重定位到不同的服务器而造成请求所要使用的数据失效,这种情况在分布式系统中是非常糟糕的。一个设计良好的分布式系统应该具有良好的单调性,即服务器的添加与移除不会造成大量的哈希重定位,而一致性哈希恰好可以解决这个问题
如何理解
一致性hash算法比喻
我们想象成 水车就比较好理解。
在一个圆形的轴承上安装多个 水槽 ,水槽由上木板和下木板组成了容量空间。水落到槽中,经过旋转,经由下木板,滑落到指定水渠中。
这里我们就可以对应到服务器系统中:
所有的木板 比作服务器。水 代表了 请求。当请求 落到槽中,只会从顺时针的第一个下木板流出。木板的数量可以任意多个。
图中:紫色代表 水(请求)进行一致性hash后的落点后,找到最近的木板(服务器)后,被木板(服务器)流出(处理)的过程
如果我们给这个水车的轴上画上刻度线。总共 223-1 。 那么 223-1 和 0 的刻度相邻,组成一个圆环。那么水和木板的落点就可以通过刻度表述。
如果我们在上面的木板中间加入一个新的木板,那么之前的木板区间,水流就会分为两块。一块新增木板上半部将会由新木板拦截流出。新木板下面的部分,还是由之前的木板处理。如下图。
唯一变动影响的部分就是 绿色木板和前一个木板区间,即绿色水流区间。
如果删除呢?
我们可以看到,如果删除黄色木板下面的木板,那么 黄色~灰色木板间的水流,会由 灰色木板的下一个木板处理。而受到影响的部分,只有 黄木板~灰色木板的数据。
虚拟节点
如上图:为什么引入虚拟节点,因为当我们服务器节点hash算法映射后,挤在一起,导致红色木板(服务器)处理大批量的 水流(请求)。如果水量巨大(请求),木板有可能被冲断(缓存击穿-服务宕机)。这种情况可不是我们看到的
核心问题: 服务器节点分布不均
那么如何理解(虚拟木板)虚拟节点这个概念。它是一个木板(服务器),是虚拟(假的)出来的 > 分身
通过标记真身木板(服务器ip或名称)加上【分身编号##1或其他标记】去hash算出木板 的刻度。所有被 分身木板处理的水流,默默的连个水管导入到了 真身木板处理了。分身只是当了老六不干活。
增加了虚拟木板(虚拟节点)我们发现,红色木板的压力降低了。许多给到其他节点上了。
原理讲解
一致性hash算法
一致性哈希算法将整个哈希值空间映射成一个虚拟的圆环,整个哈希空间的取值范围为0~223-1。整个空间按顺时针方向组织。0~223-1在零点中方向重合。接下来使用如下算法对服务请求进行映射,将服务请求使用哈希算法算出对应的hash值,然后根据hash值的位置沿圆环顺时针查找,第一台遇到的服务器就是所对应的处理请求服务器。当增加一台新的服务器,受影响的数据仅仅是新添加的服务器到其环空间中前一台的服务器(也就是顺着逆时针方向遇到的第一台服务器)之间的数据,其他都不会受到影响。综上所述,一致性哈希算法对于节点的增减都只需重定位环空间中的一小部分数据,具有较好的容错性和可扩展性
一致性hash定义了一个 0 ~ 2^32-1 hash环,hash函数并不是按照服务器节点的数量取模,而是按照 2^32 取模(hashcode % 2^32),这样请求的数据就会落在环上某个固定的位置。
服务器节点按照IP或域名进行hash,分配到hash环上
请求数据先通过hash函数(hashcode % 2^32)确定了在环上的位置,再沿着环顺时针查找,遇到的第一个节点就是命中的服务器节点。
一致性hash算法,节点的增删都只会影响了系统中的一小部分数据,容错性非常好
按照常用的hash算法来将对应的key哈希到一个具有232次方个桶的空间中,即0~(232)-1的数字空间中。现在我们可以将这些数字头尾相连,想象成一个闭合的环形。
改进【虚拟节点】
模型还是有个问题,如果服务器节点太少或者出现热点数据,就会导致服务器节点上之间的数据分布不均匀;并且还可能出现缓存雪崩的问题
虚拟节点解决了这个问题,每个实际节点映射多个虚拟节点,数据按照规则找到虚拟节点后,再储存到映射的实际节点上;因为虚拟节点可以在hash环上均匀分布,这意味着当一个真实节点失效退出后,它原来所承载的压力将会均匀地分散到其他节点上去,解决缓存雪崩问题
如何计算虚拟节点的hash和映射到环上?
即对每一个服务节点计算多个哈希(可以用原节点key+"##xxxk"作为每个虚拟节点的key,然后求hashcode),每个计算结果位置都放置一个此服务节点,称为虚拟节点。 具体做法可以在服务器ip或主机名的后面增加编号来实现。
代码实现
package com.kx.utils.hash;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.SortedMap;
import java.util.TreeMap;
/**
* 一致性hash环
*
* @author 孔翔
* @since 2023-10-19
* copyright for author : 孔翔 at 2023-10-19
* spring-utils
*/
public class ConsistentHashRouter<T> {
/**
* 服务节点
*/
private final TreeMap<Long, T> nodes = new TreeMap<>();
/**
* 虚拟节点数量
*/
private long virtualHashNode;
public ConsistentHashRouter() {
this.virtualHashNode = 10L;
}
public ConsistentHashRouter(long virtualHashNode) {
this.virtualHashNode = virtualHashNode;
}
/**
* 注册一个服务节点,创建指定的虚拟节点
*
* @param key
* @param node
*/
public void registerNode(String key, T node) {
// 虚拟节点
for (int i = 0; i < this.virtualHashNode; i++) {
long hash = Fnv132Hash.hash(key + "##VIR" + i);
nodes.put(hash, node);
}
// 真实节点
nodes.put(Fnv132Hash.hash(key), node);
}
/**
* 请求落点:获取最近服务节点
*
* @param key
*/
public T getNode(String key) {
long hash = Fnv132Hash.hash(key);
SortedMap<Long, T> longTSortedMap = nodes.tailMap(hash);
T t;
if (longTSortedMap.isEmpty()) {
// 获取第一个
t = nodes.firstEntry().getValue();
} else {
// 获取最近的一个
t = nodes.get(longTSortedMap.firstKey());
}
return t;
}
/**
* 删除指定hash的服务节点(包括真实、虚拟节点)
* @param hash 实际hash(可以是虚拟hash)
* @return
*/
public List<T> remove(long hash) {
List<T> removes = new ArrayList<>();
T t = nodes.get(hash);
Iterator<Map.Entry<Long, T>> iterator = nodes.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<Long, T> next = iterator.next();
if (t == next.getValue()) {
iterator.remove();
removes.add(next.getValue());
}
}
return removes;
}
}
总结
优点
-
可扩展性。一致性哈希算法保证了增加或减少服务器时,数据存储的改变最少,相比传统哈希算法大大节省了数据移动的开销
-
更好地适应数据的快速增长。采用一致性哈希算法分布数据,当数据不断增长时,部分虚拟节点中可能包含很多数据、造成数据在虚拟节点上分布不均衡,此时可以将包含数据多的虚拟节点分裂,这种分裂仅仅是将原有的虚拟节点一分为二、不需要对全部的数据进行重新哈希和划分。虚拟节点分裂后,如果物理服务器的负载仍然不均衡,只需在服务器之间调整部分虚拟节点的存储分布。这样可以随数据的增长而动态的扩展物理服务器的数量,且代价远比传统哈希算法重新分布所有数据要小很多
简单地说在移除或者添加一个服务器时,此算法能够尽可能小地改变已存在的服务请求与处理请求服务器之间的映射关系,尽可能满足单调性的要求。