Skip to content

Commit

Permalink
update consistent-hash.md
Browse files Browse the repository at this point in the history
  • Loading branch information
xiaobinqt committed Dec 2, 2022
1 parent f702e0b commit f5586fa
Showing 1 changed file with 187 additions and 9 deletions.
196 changes: 187 additions & 9 deletions content/posts/algorithm/consistent-hash.md
@@ -1,5 +1,5 @@
---
title: "一致性 hash"
title: "一致性哈希算法"

date: 2022-01-15

Expand All @@ -11,32 +11,210 @@ author: "xiaobinqt"
description: "一致性Hash,hash算法,数据倾斜"
resources:

- name: ""
src: ""
featuredImage: ""

tags: ["算法"]
featuredImagePreview: "https://cdn.xiaobinqt.cn/xiaobinqt.io/20221202/ecb1b15e9bef4dd4bc7ae3bcf5dcec0a.png"

tags: ["算法","hash"]
categories: ["算法与数学"]
lightgallery: true

reproduce: true

series: ["reproduce"]

toc: true

math:
enable: true
---

## 存在的意义
<!-- author: xiaobinqt -->
<!-- email: xiaobinqt@163.com -->
<!-- https://xiaobinqt.github.io -->
<!-- https://www.xiaobinqt.cn -->

## 如何分配请求

大多数网站背后肯定不是只有一台服务器提供服务,因为单机的并发量和数据量都是有限的,所以都会用多台服务器构成集群来对外提供服务。但是这样的话,问题就来了,现在有那么多个节点(后文统称服务器为节点),要如何分配客户端的请求呢:question:

![大量请求](https://cdn.xiaobinqt.cn/xiaobinqt.io/20221202/2c1d93e9e28d48b1af656fb08cb5ffd3.png '大量请求')

其实这个问题就是「负载均衡问题」。解决负载均衡问题的算法很多,不同的负载均衡算法,对应的就是不同的分配策略,适应的业务场景也不同。

最简单的方式,引入一个中间的负载均衡层,让它将外界的请求「轮流」的转发给内部的集群。比如集群有 3 个节点,外界请求有 3 个,那么每个节点都会处理 1 个请求,达到了分配请求的目的。

![负载均衡层](https://cdn.xiaobinqt.cn/xiaobinqt.io/20221202/5c1c50a1b91b484c907f20d423e126c1.png '负载均衡层')

考虑到每个节点的硬件配置有所区别,我们可以引入**权重值**,将硬件配置更好的节点的权重值设高,然后根据各个节点的权重值,按照一定比重分配在不同的节点上,让硬件配置更好的节点承担更多的请求,这种算法叫做**加权轮询**

**加权轮询算法使用场景是建立在每个节点存储的数据都是相同的前提**。所以,每次读数据的请求,访问任意一个节点都能得到结果。但是,加权轮询算法是无法应对「分布式系统」的,因为**分布式系统中,每个节点存储的数据是不同的**

当我们想提高系统的容量,就会将数据水平切分到不同的节点来存储,也就是将数据分布到了不同的节点。比如一个分布式 KV(key-valu) 缓存系统,某个 key 应该到哪个或者哪些节点上获得,应该是确定的,不是说任意访问一个节点都可以得到缓存结果。

因此,我们要想一个能应对分布式系统的负载均衡算法。

## 使用哈希算法有什么问题

有的同学可能很快就想到了:哈希算法。因为对同一个关键字进行哈希计算,每次计算都是相同的值,这样就可以将某个 key 确定到一个节点了,可以满足分布式系统的负载均衡需求。

哈希算法最简单的做法就是进行取模运算,比如分布式系统中有 3 个节点,基于`hash(key) % 3`公式对数据进行了映射。如果客户端要获取指定 key 的数据,通过下面的公式可以定位节点:

```shell
hash(key) % 3
```

如果经过上面这个公式计算后得到的值是 0,就说明该 key 需要去第一个节点获取。

但是有一个很致命的问题,**如果节点数量发生了变化,也就是在对系统做扩容或者缩容时,必须迁移改变了映射关系的数据**,否则会出现查询不到数据的问题。

举个例子,假设我们有一个由 A、B、C 三个节点组成分布式 KV 缓存系统,基于计算公式`hash(key) % 3`将数据进行了映射,每个节点存储了不同的数据:

![hash算法](https://cdn.xiaobinqt.cn/xiaobinqt.io/20221202/7fe2de0ff60940d6bfbb10f160256e06.png ' ')

现在有 3 个查询 key 的请求,分别查询`key-01``key-02``key-03` 的数据,这三个 key 分别经过`hash()`函数计算后的值为`hash( key-01) = 6``hash( key-02) = 7``hash(key-03) = 8`,然后再对这些值进行取模运算。

通过这样的哈希算法,每个 key 都可以定位到对应的节点。

![正常节点](https://cdn.xiaobinqt.cn/xiaobinqt.io/20221202/9db86d41bd62409784aeb3cc2f62e93f.png ' ')

当 3 个节点不能满足业务需求了,这时我们**增加**了一个节点,节点的数量从 3 变化为 4,意味取模哈希函数中**基数的变化**,这样会导致大部分映射关系改变,如下图:point_down:

![节点增加](https://cdn.xiaobinqt.cn/xiaobinqt.io/20221202/722345cfe27c47fb9f8a7a56ccced288.png ' ')

比如,之前的`hash(key-01) % 3 = 0`,就变成了`hash(key-01) % 4 = 2`,查询`key-01`数据时,寻址到了节点 C,而`key-01`的数据是存储在节点 A 上的,不是在节点 C,所以会查询不到数据。

同样的道理,如果我们对分布式系统进行缩容,比如移除一个节点,也会因为取模哈希函数中基数的变化,可能出现查询不到数据的问题。

要解决这个问题的办法,就需要我们进行**迁移数据**,比如节点的数量从 3 变化为 4 时,要基于新的计算公式`hash(key) % 4`,重新对数据和节点做映射。

假设总数据条数为 M,哈希算法在面对节点数量变化时,**最坏情况下所有数据都需要迁移,所以它的数据迁移规模是`O(M)`,这样数据的迁移成本太高了**

所以,我们应该要重新想一个新的算法,来避免分布式系统在扩容或者缩容时,发生过多的数据迁移。

## 使用一致性哈希算法有什么问题

一致性哈希算法就很好地解决了分布式系统在扩容或者缩容时,发生过多的数据迁移的问题。

一致哈希算法也用了取模运算,但与哈希算法不同的是,哈希算法是对节点的数量进行取模运算,而一致哈希算法是对`2^32`进行取模运算,是一个固定的值。

我们可以把一致哈希算法是对`2^32`进行取模运算的结果值组织成一个圆环,就像钟表一样。钟表的圆可以理解成由 60 个点组成的圆,而此处我们把这个圆想象成由`2^32`个点组成的圆,这个圆环被称为哈希环,如下图:point_down:

![哈希环](https://cdn.xiaobinqt.cn/xiaobinqt.io/20221202/0f6d1adc1fa346f29fa1ddcd1ba7a271.png '哈希环')

一致性哈希要进行两步哈希:

+ 第一步:对存储节点进行哈希计算,也就是对存储节点做哈希映射,比如根据节点的 IP 地址进行哈希;
+ 第二步:当对数据进行存储或访问时,对数据进行哈希映射;

所以,**一致性哈希是指将「存储节点」和「数据」都映射到一个首尾相连的哈希环上**

问题来了,对「数据」进行哈希映射得到一个结果要怎么找到存储该数据的节点呢:question:

答案是,映射的结果值往**顺时针的方向找到第一个节点**,就是存储该数据的节点。

举个例子,有 3 个节点经过哈希计算,映射到了如下图的位置:

一致性哈希算法解决了普通余数 Hash 算法伸缩性差的问题,可以保证在上线、下线服务器的情况下尽量有多的请求命中原来路由到的服务器。
![示例01](https://cdn.xiaobinqt.cn/xiaobinqt.io/20221202/58005e781a0f427d9e955da50d493d6f.png ' ')

## 优化
接着,对要查询的`key-01`进行哈希计算,确定此`key-01`映射在哈希环的位置,然后从这个位置往顺时针的方向找到第一个节点,就是存储该`key-01`数据的节点。

一致性哈希算法在服务节点太少时,容易因为节点分部不均匀而造成数据倾斜问题。可以通过通过增加虚拟节点来解决数据倾斜问题
比如,下图中的`key-01`映射的位置,往顺时针的方向找到第一个节点就是节点 A

如果存在大量的虚拟节点,节点的查找性能就成为必须考虑的因数。可以使用[红黑树](https://xiaozhuanlan.com/topic/1248367905) 来加快查找速度,
![示例02](https://cdn.xiaobinqt.cn/xiaobinqt.io/20221202/de39de77b55c47b985f6702ae4eb99a7.png ' ')

所以,当需要对指定 key 的值进行读写的时候,要通过下面 2 步进行寻址:

+ 首先,对 key 进行哈希计算,确定此 key 在环上的位置;
+ 然后,从这个位置沿着顺时针方向走,遇到的第一节点就是存储 key 的节点。

知道了一致哈希寻址的方式,我们来看看,如果增加一个节点或者减少一个节点会发生大量的数据迁移吗?

假设节点数量从 3 增加到了 4,新的节点 D 经过哈希计算后映射到了下图中的位置:point_down:

![示例03](https://cdn.xiaobinqt.cn/xiaobinqt.io/20221202/a896ece188c54e22a2073563e3b6c69a.png ' ')

你可以看到`key-01``key-03`都不受影响,只有`key-02`需要被迁移节点 D。

假设节点数量从 3 减少到了 2,比如将节点 A 移除:point_down:

![示例04](https://cdn.xiaobinqt.cn/xiaobinqt.io/20221202/972ee2a16273443c9beae2279aae1137.png ' ')

可以看到`key-02``key-03`不会受到影响,只有`key-01`需要被迁移节点 B。

因此,在一致哈希算法中,如果增加或者移除一个节点,**仅影响该节点在哈希环上顺时针相邻的后继节点,其它数据也不会受到影响**

上面这些图中 3 个节点映射在哈希环还是比较分散的,所以看起来请求都会「均衡」到每个节点。

但是一致性哈希算法并不保证节点能够在哈希环上分布均匀,这样就会带来一个问题,会有大量的请求集中在一个节点上。

比如,下图中 3 个节点的映射位置都在哈希环的右半边:point_down:

![示例05](https://cdn.xiaobinqt.cn/xiaobinqt.io/20221202/1c1070a9a49e4ccf99092142cc5e33d1.png ' ')

这时候有一半以上的数据的寻址都会找节点 A,也就是访问请求主要集中的节点 A 上,这是肯定不行的,说好的负载均衡呢,这种情况一点都不均衡。

另外,在这种节点分布不均匀的情况下,进行容灾与扩容时,哈希环上的相邻节点容易受到过大影响,容易发生雪崩式的连锁反应。

比如,上图中如果节点 A 被移除了,当节点 A 宕机后,根据一致性哈希算法的规则,其上数据应该全部迁移到相邻的节点 B 上,这样,节点 B 的数据量、访问量都会迅速增加很多倍,一旦新增的压力超过了节点 B 的处理能力上限,就会导致节点 B 崩溃,进而形成雪崩式的连锁反应。

所以,**一致性哈希算法虽然减少了数据迁移量,但是存在节点分布不均匀的问题**

## 如何通过虚拟节点提高均衡度

要想解决节点能在哈希环上分配不均匀的问题,就是要有大量的节点,节点数越多,哈希环上的节点分布的就越均匀。

但问题是,实际中我们没有那么多节点。所以这个时候我们就加入虚拟节点,也就是对一个真实节点做多个副本。

具体做法是,**不再将真实节点映射到哈希环上,而是将虚拟节点映射到哈希环上,并将虚拟节点映射到实际节点**,所以这里有「两层」映射关系。

比如对每个节点分别设置 3 个虚拟节点:

+ 对节点 A 加上编号来作为虚拟节点:A-01、A-02、A-03
+ 对节点 B 加上编号来作为虚拟节点:B-01、B-02、B-03
+ 对节点 C 加上编号来作为虚拟节点:C-01、C-02、C-03

引入虚拟节点后,原本哈希环上只有 3 个节点的情况,就会变成有 9 个虚拟节点映射到哈希环上,哈希环上的节点数量多了 3 倍。

![虚拟节点](https://cdn.xiaobinqt.cn/xiaobinqt.io/20221202/81659d664abb431d87f8d910bde47d65.png '虚拟节点')

我们可以看到,**节点数量多了后,节点在哈希环上的分布就相对均匀了**。这时候,如果有访问请求寻址到「A-01」这个虚拟节点,接着再通过「A-01」虚拟节点找到真实节点 A,这样请求就能访问到真实节点 A 了。

上面为了方便理解,每个真实节点仅包含 3 个虚拟节点,这样能起到的均衡效果其实很有限。而在实际的工程中,虚拟节点的数量会大很多,比如 Nginx 的一致性哈希算法,每个权重为 1 的真实节点就含有160 个虚拟节点。

另外,虚拟节点除了会提高节点的均衡度,还会提高系统的稳定性。**当节点变化时,会有不同的节点共同分担系统的变化,因此稳定性更高**

比如,当某个节点被移除时,对应该节点的多个虚拟节点均会移除,而这些虚拟节点按顺时针方向的下一个虚拟节点,可能会对应不同的真实节点,即这些不同的真实节点共同分担了节点变化导致的压力。

而且,有了虚拟节点后,还可以为硬件配置更好的节点增加权重,比如对权重更高的节点增加更多的虚拟机节点即可。

因此,**带虚拟节点的一致性哈希方法不仅适合硬件配置不同的节点的场景,而且适合节点规模会发生变化的场景**

## 总结

不同的负载均衡算法适用的业务场景不同。

轮训这类的策略只能适用于每个节点的数据都是相同的场景,访问任意节点都能请求到数据。但是不适用分布式系统,因为分布式系统意味着数据水平切分到了不同的节点上,访问数据的时候,一定要寻址存储该数据的节点。

哈希算法虽然能建立数据和节点的映射关系,但是每次在节点数量发生变化的时候,最坏情况下所有数据都需要迁移,这样太麻烦了,所以不适用节点数量变化的场景。

为了减少迁移的数据量,就出现了一致性哈希算法。

一致性哈希是指将「存储节点」和「数据」都映射到一个首尾相连的哈希环上,如果增加或者移除一个节点,仅影响该节点在哈希环上顺时针相邻的后继节点,其它数据也不会受到影响。

但是一致性哈希算法不能够均匀的分布节点,会出现大量请求都集中在一个节点的情况,在这种情况下进行容灾与扩容时,容易出现雪崩的连锁反应。

为了解决一致性哈希算法不能够均匀的分布节点的问题,就需要引入虚拟节点,对一个真实节点做多个副本。不再将真实节点映射到哈希环上,而是将虚拟节点映射到哈希环上,并将虚拟节点映射到实际节点,所以这里有「两层」映射关系。

引入虚拟节点后,可以提高节点的均衡度,还会提高系统的稳定性。所以,带虚拟节点的一致性哈希方法不仅适合硬件配置不同的节点的场景,而且适合节点规模会发生变化的场景。

## 参考

+ [16 张图解 | 一致性哈希算法](https://bbs.huaweicloud.com/blogs/333158?utm_source=luntan&utm_medium=bbs-ex&utm_campaign=other&utm_content=content)
+ [程序员算法-心中有树](https://xiaozhuanlan.com/learningalgorithm)
+ [一致性Hash(Consistent Hashing)原理剖析及Java实现](https://blog.csdn.net/suifeng629/article/details/81567777)
+ [图解一致性哈希算法](https://segmentfault.com/a/1190000021199728)
+ [golang实现一致性hash环及优化方法](http://jintang.zone/2018/08/20/golang%E5%AE%9E%E7%8E%B0%E4%B8%80%E8%87%B4%E6%80%A7hash%E7%8E%AF%E5%8F%8A%E4%BC%98%E5%8C%96%E6%96%B9%E6%B3%95.html)
+ [一致性哈希](https://geektutu.com/post/geecache-day4.html)
+ [一致性Hash算法详解](https://zhuanlan.zhihu.com/p/98030096)

0 comments on commit f5586fa

Please sign in to comment.