- 原文地址:How we implemented consistent hashing efficiently
- 原文做者:Srushtika Neelakantam
- 译文出自:掘金翻译计划
- 本文永久连接:github.com/xitu/gold-m…
- 译者:yqian1991
- 校对者:Starrier
在这篇文章中,咱们将会理解一致性哈希究竟是怎么回事,为何它是可伸缩的分布式系统架构中的一个重要工具。而后更进一步,咱们会介绍能够用来高效率规模化实现一致性哈希算法的数据结构。最后,咱们也会带你们看一看用这个算法实现的一个可工做实例。前端
还记得大学里学的那个古老而原始的哈希方法吗?经过使用哈希函数,咱们确保了计算机程序所须要的资源能够经过一种高效的方式存储在内存中,也确保了内存数据结构能被均匀加载。咱们也确保了这种资源存储策略使信息检索变得更高效,从而让程序运行得更快。android
经典的哈希方法用一个哈希函数来生成一个伪随机数,而后这个伪随机数被内存空间大小整除,从而将一个随机的数值标识转换成可用内存空间里的一个位置。就如同下面这个函数所示:ios
location = hash(key) mod size
git
在各类不一样的程序、计算机或者用户从多个服务器请求资源的场景里,咱们须要一种机制来将请求均匀地分布到可用的服务器上,从而保证负载均衡,而且保持稳定一致的性能。咱们能够将这些服务器节点看作是一个或多个请求能够被映射到的位置。github
如今让咱们先退一步。在传统的哈希方法中,咱们老是假设:算法
例如,在 Ably,咱们一成天里一般须要扩大或者缩减集群的大小,并且咱们也要处理一些意外的故障。可是,若是咱们考虑前面提到的这些场景的话,咱们就不能保证服务器数量是不变的。若是其中一个服务器发生意外故障了怎么办?若是继续使用最简单的哈希方法,结果就是咱们须要对每一个哈希键从新计算哈希值,由于新的映射彻底决定于服务器节点或者内存地址的数量,以下图所示:后端
节点变化以前数组
节点变化以后bash
在分布式系统中使用简单再哈希存在的问题 — 每一个哈希键的存放位置都会变化 — 就是由于每一个节点都存放了一个状态;哪怕只是集群数目的一个很是小的变化,均可能致使须要从新排列集群上的全部数据,从而产生巨大的工做量。随着集群的增加,从新哈希的方法是无法持续使用的,由于从新哈希所须要的工做量会随着集群的大小而线性地增加。这就是一致性哈希的概念被引入的场景。服务器
一致性哈希能够用下面的方式描述:
那么它究竟是如何决定请求被哪一个服务器所服务呢?若是咱们假设这个环是有序的,并且在环上进行顺时针遍历就对应着存储地址的增加顺序,每一个请求能够被顺时针遍历过程当中所遇到的第一个节点所服务;也就是说,第一个在环上的地址比请求的地址大的服务器会服务这个请求。若是请求的地址比节点中的最大地址还大,那它会反过来被拥有最小地址的那个服务器服务,由于在这个环上的遍历是以循环的方式进行的。方法用下图进行了阐明:
理论上,每一个服务器‘拥有’哈希环(hashring)上的一段区间范围,任何映射到这个范围里的请求都将被同一个服务器服务。如今好了,若是其中一个服务器出现故障了怎么办,就以节点 3 为例吧,这个时候下一个服务器节点在环上的地址范围就会扩大,而且映射到这个范围的任何请求会被分派给新的服务器。仅此而已。只有对应到故障节点的区间范围内的哈希须要被从新分配,而哈希环上其他的部分和请求 - 服务器的分配仍然不会受到影响。这跟传统的哈希技术正好是相反的,在传统的哈希中,哈希表大小的变化会影响 所有 的映射。由于有了 一致性哈希,只有一部分(这跟环的分布因子有关)请求会受已知的哈希环变化的影响。(节点增长或者删除会致使环的变化,从而引发一些请求 - 服务器之间的映射发生改变。)
如今咱们对什么是哈希环已经熟悉了...
咱们须要实现如下内容来让它工做:
要完成上述的第一个部分,咱们须要如下内容:
为了找到与特定请求相对应的节点,咱们能够用一种简单的数据结构来阐释,它由如下内容组成:
这实际上就是一个有序图的原始表示。
为了能在以上数据结构中找到能够服务于已知哈希值的节点,咱们须要:
在这篇文章的开头咱们已经看到了,当一个节点被添加,哈希环上的一部分区间范围,以及它所包括的各类请求,都必须被分配到这个新节点。反过来,当一个节点被删除,过去被分配到这个节点的请求都将须要被其余节点处理。
一种解决方法就是遍历分配到一个节点的全部请求。对每一个请求,咱们判断它是否处在环发生变化的区间范围内,若是有须要的话,把它转移到其余地方。
然而,这么作所须要的工做量会随着节点上请求数量的增长而增长。让状况变得更糟糕的是,随着节点数量的增长,环上发生变化的数量也可能会增长。最坏的状况是,因为环的变化一般与局部故障有关,与环变化相关联的瞬时负载也可能增长其余受影响节点发生故障的可能性,有可能致使整个系统发生级联故障。
考虑到这个因素,咱们但愿请求的重定位作到尽量高效。最理想的状况是,咱们能够将全部请求保存在一种数据结构里,这样咱们能找到环上任何地方发生哈希变化时受到影响的请求。
在集群上增长或者删除一个节点将改变环上一部分请求的分配,咱们称之为 受影响范围(affected range)。若是咱们知道受影响范围的边界,咱们就能够把请求转移到正确的位置。
为了寻找受影响范围的边界,咱们从增长或者删除掉的一个节点的哈希值 H 开始,从 H 开始绕着环向后移动(图中的逆时针方向),直到找到另一个节点。让咱们将这个节点的哈希值定义为 S(做为开始)。从这个节点开始逆时针方向上的请求会被指定给它(S),所以它们不会受到影响。
注意:这只是实际将发生的状况的一个简化描述;在实践中,数据结构和算法都更加复杂,由于咱们使用的复制因子(replication factors)数目大于 1,而且当任意给定的请求都只有一部分节点可用的状况下,咱们还会使用专门的复制策略。
那些哈希值在被找到的节点和增长(或者删除)的节点范围之间的请求就是须要被移动的。
一种解决方法就是简单的遍历对应于一个节点的全部请求,而且更新那些哈希值映射到此范围内的请求。
在 JavaScript 中相似这样:
for (const request of requests) {
if (contains(S, H, request.hash)) {
/* 这个请求受环变化的影响 */
request.relocate();
}
}
function contains(lowerBound, upperBound, hash) {
const wrapsOver = upperBound < lowerBound;
const aboveLower = hash >= lowerBound;
const belowUpper = upperBound >= hash;
if (wrapsOver) {
return aboveLower || belowUpper;
} else {
return aboveLower && belowUpper;
}
}
复制代码
因为哈希环是环状的,仅仅查找 S <= r < H 之间的请求是不够的,由于 S 可能比 H 大(代表这个区间范围包含了哈希环的最顶端的开始部分)。函数 contains()
能够处理这种状况。
只要请求数量相对较少,或者节点的增长或者删除的状况也相对较少出现,遍历一个给定节点的全部请求仍是可行的。
然而,随着节点上的请求数量的增长,所需的工做量也随之增长,更糟糕的是,随着节点的增长,环变化也可能发生得更频繁,不管是由于在自动节点伸缩(automated scaling)或者是故障转换(failover)的状况下为了从新均衡访问请求而触发的整个系统上的并发负载。
最糟的状况是,与这些变化相关的负载可能增长其它节点发生故障的可能性,有可能致使整个系统范围的级联故障。
为了减轻这种影响,咱们也能够将请求存储到相似于以前讨论过的一个单独的环状数据结构中,在这个环里,一个哈希值直接映射到这个哈希对应的请求。
这样咱们就能经过如下步骤来定位受影响范围内的全部请求:
当一个哈希更新时所须要遍历的请求数量平均是 R/N,R 是定位到这个节点范围内的请求数量,N 是环上哈希值的数量,这里咱们假设请求是均匀分布的。
让咱们经过一个可工做的例子将以上解释付诸实践:
假设咱们有一个包含节点 A 和 B 的集群。
让咱们随机的产生每一个节点的 ‘哈希分配’:(假设是32位的哈希),所以咱们获得了
A:0x5e6058e5
B:0xa2d65c0
在此咱们将节点放到一个虚拟的环上,数值 0x0
、0x1
和 0x2
... 是被连续放置到环上的直到 0xffffffff
,就这样在环上绕一个圈后 0xffffffff
的后面正好跟着的就是 0x0
。
因为节点 A 的哈希是 0x5e6058e5
,它负责的就是从 0xa2d65c0+1
到 0xffffffff
,以及从 0x0
到 0x5e6058e5
范围里的任何请求,以下图所示:
另外一方面,B 负责的是从 0x5e6058e5+1
到 0xa2d65c0
的范围。如此,整个哈希空间都被划分了。
从节点到它们的哈希之间的映射在整个集群上是共享的,这样保证了每次环计算的结果老是一致的。所以,任何节点在须要服务请求的时候均可以判断请求放在哪里。
好比咱们须要寻找 (或者建立)一个新的请求,这个请求的标识符是 ‘bobs.blog@example.com’。
0x89e04a0a
所以 B 是负责这个请求的节点。若是咱们再次须要这个请求,咱们将重复以上步骤而且又会获得一样的节点,它会包含咱们须要的的状态。
这个例子是过于简单了。在实际状况中,只给每一个节点一个哈希可能致使负载很是不均匀的分布。你可能已经注意到了,在这个例子中,B 负责环的 (0xa2d656c0-0x5e6058e5)/232 = 26.7%
,同时 A 负责剩下的部分。理想的状况是,每一个节点能够负责环上同等大小的一部分。
让分布更均衡合理的一种方法是为每一个节点产生多个随机哈希,像下面这样:
事实上,咱们发现这样作的结果照样使人不满意,所以咱们将环分红 64 个一样大小的片断而且确保每一个节点都会被放到每一个片断中的某个位置;这个的细节就不是那么重要了。反正目的就是确保每一个节点能负责环上同等大小的一部分,所以保证负载是均匀分布的。(为每一个节点产生多个哈希的另外一个优点就是哈希能够在环上逐渐的被增长或者删除,这样就避免了负载的忽然间的变化。)
假设咱们如今在环上增长一个新节点叫作 C,咱们为 C 产生一个随机哈希值。
A:0x5e6058e5
B:0xa2d65c0
C:0xe12f751c
如今,0xa2d65c0 + 1
和 0xe12f751c
(之前是属于A的部分)之间的环空间被分配给了 C。全部其余的请求像之前同样继续被哈希到一样的节点。为了处理节点职责的变化,这个范围内的已经分配给 A 的全部请求须要将它们的全部状态转移给 C。
如今你理解了为何在分布式系统中均衡负载是须要哈希的。然而咱们须要一致性哈希来确保在环发生任何变化的时候最小化集群上所须要的工做量。
另外,节点须要存在于环上的多个地方,这样能够从统计学的角度保证负载被均匀分布。每次环发生变化都遍历整个哈希环的效率是不高的,随着你的分布式系统的伸缩,有一种更高效的方法来决定什么发生了变化是很必要的,它能帮助你尽量的最小化环变化带来的性能上的影响。咱们须要新的索引和数据类型来解决这个问题。
构建分布式系统是很难的事情。可是咱们热爱它而且咱们喜欢谈论它。若是你须要依靠一种分布式系统的话,选择 Ably。若是你想跟咱们谈一谈的话,联系咱们!
在此特别感谢 Ably 的分布式系统工程师 John Diamond 对本文的贡献。
Srushtika 是 Ably Realtime的软件开发顾问
感谢 John Diamond 和 Matthew O'Riordan。
若是发现译文存在错误或其余须要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可得到相应奖励积分。文章开头的 本文永久连接 即为本文在 GitHub 上的 MarkDown 连接。
掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 Android、iOS、前端、后端、区块链、产品、设计、人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划、官方微博、知乎专栏。