共计 4950 个字符,预计需要花费 13 分钟才能阅读完成。
最近有一位读者跟我交换,说除了算法题之外,零碎设计题是一大痛点。算法题起码有很多刷题平台能够入手实际,但零碎设计类的题目个别很难实际,所以看一些教程总结也只是只知其一; 不知其二,遇到让写代码实现零碎的就懵了。
比方他最近被问到一个大型爬虫零碎的设计题,让手写一致性哈希算法,加上一系列 follow up,就被难住了。
说实话这个算法的实现并不难,所以本文就联合一致性哈希算法在工程中的利用场景介绍一下这个算法算法,并给出代码实现,防止大家吃一堑; 长一智。
这个名词大家必定不生疏,应该也大略了解这个算法的逻辑,不过我这里还是要再介绍一下。
一致性哈希次要解决把数据平均分配到多个节点上的问题,并且某些节点上线 / 下线后仍然可能做到主动负载平衡。
其原理就是形象出一个哈希环,把服务器节点的 id 通过哈希函数映射到这个哈希环上:
同时,把须要解决的数据也通过哈希函数映射到这个哈希环上,而后顺时针找,遇到的第一个服务器节点来负责解决这个数据:
这样一来,咱们其实曾经提供了一种机制将若干数据分布在若干服务节点上了,无妨称它为 V1 版本的一致性哈希算法。
但 V1 版本的算法还有问题:负载散布很可能不平衡。因为哈希函数的后果是难以预测的,所以可能呈现这种状况:
即某些服务器节点要负责的哈希环很长,而其余服务器负责的哈希环很短。这就会导致某些服务器负载很高,而其余的服务器负载很低,很不平衡。
而且,当某个服务器节点下线后,它的负载会顺时针转移到下一个节点上,那么某些特定的节点下线程序很可能导致某些服务器节点负责的哈希环一直加长,负载一直减少。业余点说,这就是「数据歪斜」。
如何解决数据歪斜的问题呢?能够给每个服务器节点增加若干「虚构节点」散布在哈希环上,咱们无妨称其为 V2 版的一致性哈希算法:
这样一来,因为哈希函数的随机性,每个服务器节点的虚构节点可能均匀散布在哈希环上,那么数据就可能比拟平均地调配到所有服务器节点上。
如果某个服务器节点下线,那么该服务器节点的所有虚构节点都会从哈希环上摘除,它们的负载会迁徙到顺时针的下一个服务器节点上。
和 V1 版算法不同的是,因为虚构节点有多个,它们的下一位不太可能是同一台服务器的虚构节点,所以它们的负载大概率会均分到多台服务器的虚构节点上。
综上,V2 版算法通过虚构节点的形式完满解决了数据歪斜的问题,是不是很奇妙?不过俗话说,纸上得来终觉浅,绝知此事要躬行,咱们须要实际能力真正写出正确的一致性哈希算法。
比方说,应该用什么数据结构实现哈希环?如果哈希函数呈现哈希抵触怎么办?真正写代码的时候,这些细节问题都是要思考的。
上面咱们就联合代码和理论场景来看看一致性哈希算法的实在利用。
理论场景剖析
就以音讯队列的生产模型为例吧,我在前文 用音讯队列做一个联机游戏 介绍过 Apache Pulsar 的生产模型,Pulsar 通过 subscription 的形象提供多种订阅模式,其中 key_shared 模式比拟有意思:
每条音讯会有一个 key,Pulsar 能够依据这个 key 散发音讯,保障带有雷同 key 的音讯散发到同一个消费者上。
官网的这幅图比拟好了解,图中的 K
就是指音讯的 key,V
就是指音讯的数据:
通过某些算法,所有的音讯都会有消费者去解决,比方上图就是 consumerA
负责解决 key=K3
的音讯,consumerB
负责解决 key=K1
的音讯,consumerC
负责解决 key=K2
的音讯。
当然,如果有 consumer 退出或者来到,音讯的调配会重置。比方 consumerA
下线,那么 key=K3
的音讯会被调配给其余消费者生产;如果有新的消费者 consumerD
退出,那么以后的调配计划也可能会扭转。
简略总结就是:
1、在没有 consumer 退出或者来到的前提下,保障 key 雷同的音讯都会调配到固定的 consumer,不能一会儿调配到consumerA
,一会儿调配给consumerB
。
2、如果有 consumer 退出或者来到,能够从新进行调配每个 consumer 负责的 key,要求尽量把 key 平均分配给 consumer,避免出现某些 consumer 负责过多 key 的状况导致数据歪斜。
3、以上两个操作,尤其是给 consumer 重新分配 key 的操作,效率要尽可能高。
对于上述场景,你如何设计调配算法,把这些带有 key 的音讯高效地、平均地调配给所有 consumer 呢?
咱们来看看 Pulsar 是如何做的,官网对这部分的实现原理形容的比较清楚,参考链接如下:
https://pulsar.apache.org/doc…
联合我之前在 学习开源我的项目的套路 中介绍的查看源码背景信息的技巧,能够发现 Pulsar 的 key_shared 模式的消费者实现其实是经验了一些演进的。
最开始的默认实现形式叫做 Auto-split Hash Range,即形象进去一个 [0, 65535]
的哈希区间,让每个 consumer 负责这个区间的一部分。比方有C1~C4
4 个 consumer,那么它们会平分整个哈希区间:
0 16,384 32,768 49,152 65,536
|------- C3 ------|------- C2 ------|------- C4 ------|------- C1 ------|
而后咱们能够对每条音讯的 key 计算哈希值并求模映射到 [0, 65535]
的区间中,这样咱们就能够选出负责解决这条音讯的 consumer 了,而且 key 雷同的音讯总会调配到这个 consumer 上。
那么如果有 consumer 上线或者下线怎么解决呢?
如果有 consumer 下线,那么它负责的哈希区间会间接交给右侧的 consumer。比方上例中 C4
下线,那么哈希区间就会变成这样:
0 16,384 32,768 65,536
|------- C3 ------|------- C2 ------|---------------- C1 ---------------|
当然这里也有个非凡状况,就是下线的那个 consumer 左边没有其余 consumer 的状况,咱们能够让它右边的 consumer 顶上来。比方当初的 C1
下线,那么就让右边的 C2
负责 C1
的区间:
0 16,384 65,536
|------- C3 ------|-------------------------- C2 -----------------------|
如果有 consumer 上线,那么算法能够把最长的哈希区间平分,分一半给新来的 consumer。比方此时 C5
上线,咱们就能够把 C2
负责的一半哈希区间分给C5
:
0 16,384 40,960 65,536
|------- C3 ------|----------- C5 ----------- | ---------- C2 ----------|
这就是 Auto-split Hash Range 的计划,不算简单,具体的实现能够看 Pulsar 源码中 HashRangeAutoSplitStickyKeyConsumerSelector
这个类,我在这里就不列举了。
这个计划的问题次要还是数据歪斜,比方下面的例子呈现的这种状况,C2
的负载显然比 C3
多很多:
0 16,384 65,536
|------- C3 ------|-------------------------- C2 -----------------------|
依照这个算法逻辑,一些 consumer 下线后很容易产生这种数据歪斜的状况,所以这个解决方案并不能平均地把 key 调配给 consumer。
那么如何优化这个算法呢?就要用到一致性哈希算法了。
一致性哈希算法的实现
联合我在本文结尾对一致性哈希算法的介绍,应该很容易想到优化思路。其实当初 Pulsar 就是应用一致性哈希算法来实现的 key_shared 订阅。
首先形象出一个值在 [0, MAX_INT]
的哈希环,而后给每个 consumer 调配 100 个虚构节点映射到这个哈希环上。接下来,咱们把 key 的哈希值放在哈希环上,顺时针方向找到最近的 consumer 虚构节点,也就找到了负责解决这个 key 的 consumer。
哈希环咱们个别用 TreeMap 实现,间接看 Pulsar 源码中 ConsistentHashingStickyKeyConsumerSelector
的实现吧,我提取了其中的要害逻辑并增加了具体的正文:
class ConsistentHashingStickyKeyConsumerSelector {
// 哈希环,虚构节点的哈希值 -> consumer 列表
// 因为存在哈希抵触,多个虚构节点可能映射到同一个哈希值,所以值为 List 类型
NavigableMap<Integer, List<Consumer>> hashRing = new TreeMap<>();
// 每个 consumer 有 100 个虚构节点
int numberOfPoints = 100;
// 将该 consumer 的 100 个虚构节点增加到哈希环上
public void addConsumer(Consumer consumer) {for (int i = 0; i < numberOfPoints; i++) {
// 计算虚构节点在哈希环上的地位
String key = consumer.consumerName() + i;
int hash = Murmur3_32Hash.getInstance().makeHash(key.getBytes());
// 把虚构节点放到哈希环上
hashRing.putIfAbsent(hash, new ArrayList<>());
hashRing.get(hash).add(consumer);
}
}
// 在哈希环上删除该 consumer 的所有虚构节点
public void removeConsumer(Consumer consumer) {for (int i = 0; i < numberOfPoints; i++) {
// 计算虚构节点在哈希环上的地位
String key = consumer.consumerName() + i;
int hash = Murmur3_32Hash.getInstance().makeHash(key.getBytes());
// 删除虚构节点
if (hashRing.containsKey(hash)) {hashRing.get(hash).remove(consumer);
}
}
}
// 通过 key 的哈希值抉择 consumer
public Consumer select(int hash) {if (hashRing.isEmpty()) {return null;}
// 抉择顺时针方向的第一个 consumer
Map.Entry<Integer, List<Consumer>> ceilingEntry = hashRing.ceilingEntry(hash);
List<Consumer> consumerList;
if (ceilingEntry != null) {consumerList = ceilingEntry.getValue();
} else {
// 哈希环顺时针转一圈,回到结尾寻找第一个节点
consumerList = hashRing.firstEntry().getValue();
}
// 保障雷同的 key 都会调配到同一个 consumer
return consumerList.get(hash % consumerList.size());
}
}
当音讯被发送过去后,Pulsar 能够通过 select
办法抉择对应的 consumer 来解决数据;当新的 consumer 上线时,能够通过 addConsumer
将它的虚构节点放到哈希环上并开始接管音讯;当有 consumer 下线时,能够通过 removeConsumer
将它的虚构节点从哈希环上摘除,由其余 consumer 承当它的工作。
因为每个 consumer 有 100 个虚构节点,所以在 consumer 下线时,负载其实是平均地调配给了其余 consumer,因而一致性哈希算法可能解决之前 Auto-split Hash Range 计划数据歪斜的问题。
本文就到这里,其实很多零碎设计相干的面试题在理论我的项目中会常常应用,后续我遇到乏味的算法利用再写文分享给大家。
更多高质量干货文章,请关注我的微信公众号:labuladong