负载均衡是个大话题,我们可以谈谈:
- slow start 给新加入的节点分配较低权重,避免过载
- priority 不同的可用区(AZ)有不同的优先级,非当前可用区的节点只有在当前可用区节点不可用时才作为备份加入
- subset 分组负载均衡,会先通过负载均衡算法选择一个组,再通过负载均衡算法在组里选择具体的节点
- retry 当负载均衡碰上重试时,需要考虑一些额外的情况。在重试时,通常我们需要选中另一个节点,而不是重新选中当前节点。此外,在重试完所有节点后,一般情况下我们不会再重试多一轮。
本文会关注以上各功能的基石部分 —— 负载均衡算法。
Random
随机负载均衡即随机选取一个节点。由于该方式是无状态的,所以是最容易实现的负载均衡。不过这是对于开发者的优点,不是使用者的。随机负载均衡只保证了数学期望上的均衡,并不保证微观尺度上是均衡的。有可能连续几个请求都命中同一个节点,正如运气不佳的人总会祸不单行一样。黑天鹅事件是随机负载均衡抹不去的阴影。我唯一推荐采用随机负载均衡的场景,就是只有随机负载均衡作为唯一的选项。
RoundRobin
RoundRobin 指每个节点都会轮流被选中。对于所有节点权重都一样的场景,实现 RoundRobin 并不难。只需记录当前被选中的节点,然后下次选中它的下个节点即可。
对于权重不一样的场景,则需要考虑如何让选中的节点足够均衡。假设有两个节点 A、B,权重分别是 5、2。如果只是使用权重一致时简单的 RoundRobin 实现,会得到下面的结果:
A B
A B
A
A
A
节点 A 最多会被连续选中 4 次(当前轮结尾时的 3 次加上下一轮时的 1 次)。考虑到 A、B 节点的权重比例是 2.5:1,这种连续选中 A 节点达 4 次的行为跟两节点的权重比是不相称的。
所以针对该场景,我们需要超越简单的逐节点轮询,让不同权重的节点间尽可能在微观层面上均衡。以前面的 A、B 节点为例,一个微观层面上的均衡分配会是这样的:
A
A B
A
A
A B
节点 A 最多被连续选中 3 次,跟权重比例 2.5:1 相差不大。
在实现带权重的 RoundRobin 算法的时候,请尽可能不要自己发明一套新算法。带权重的 RoundRobin 实现较为容易犯错。可能会出现这样的情况:在本地开发测试下没问题,在线上跑一段时间也 OK,直到业务方输入了一组特殊的值,然后不均衡就发生了。应当参考主流的实现,如果需要在主流实现上做调整,最好提供数学上的证明。
接下来让我们看下主流的实现 —— Nginx 和 Envoy 是如何做的。
Nginx 的实现大体上是这样子的:
- 每个节点有自己的一个当前得分。每次选择时遍历各个节点,给得分加上一个跟节点权重相关的值。
- 每次选择分数最高的节点。
- 节点被选中时分数减去所有权重的和。
权重越高的节点,在减去分数后恢复得越快,也就越有可能被继续选中。而且这里存在一个恢复过程,所有保证了在下一次不太可能选中同一个节点。
因为这块代码耦合了被动健康检查的功能(存在多个 weight;effect_weight 需要根据 max_fails 做调整),所以较为复杂。由于 Nginx 的具体实现代码并非本文重点,感兴趣的读者可以自行查阅。
Envoy 的实现相对清晰些。它是基于 EDF 算法 的简化版本来做节点选择。简单来说,它采用了优先队列来选择当前最佳节点。对于每个节点,我们会记录两个值:
- deadline 下一次需要取出节点的时机
- last_popped_time 上一次取出此节点的时机
(Envoy 的具体实现代码与此有些出入。这里采用 last_popped_time
而非 Envoy 中的 offset_order
是出于容易理解的目的)
再次以我们的 A、B 节点为例。
A、B 两节点以 1/权重 作为各自的得分。算法运行方式如下:
- 构造一个优先队列,排序方法为先比较 deadline,前者相同时比较 last_popped_time。每个节点的初始值为各自的得分。
- 每次选择,会从优先队列中 pop 最新的值。
- 每次选中一个节点后,更新它的 last_popped_time 为选中时的 deadline,并往 deadline 中增加对应的得分,重新插入到队列中。
每次选择如下:
round | A deadline | B deadline | A last_popped_time | B last_popped_time | Selected |
---|---|---|---|---|---|
1 | 1/5 | 1/2 | 0 | 0 | A |
2 | 2/5 | 1/2 | 1/5 | 0 | A |
3 | 3/5 | 1/2 | 2/5 | 0 | B |
4 | 3/5 | 1 | 2/5 | 1/2 | A |
5 | 4/5 | 1 | 3/5 | 1/2 | A |
6 | 1 | 1 | 4/5 | 1/2 | B |
7 | 6/5 | 1 | 4/5 | 1 | A |
可以看出,在 EDF 算法下,节点 A 最多被连续选中 3 次(当前循环结尾时的 1 次加上下一循环时的 2 次),跟权重比例 2.5:1 相差不大。另外与 Nginx 的算法相比,在 EDF 下,选择节点的时间复杂度主要是重新插入时的 O(logn)
,存在大量节点时会比逐个节点比较分数更快些。
Least Request
最少请求算法通常又称之为最小连接数算法,这一别名来源于早期每个请求往往对应一个连接,且该算法常常用于长连接的负载均衡当中。RoundRobin 算法能够保证发给各个节点的请求是均衡的,但是它并不保证当前节点上的请求数是均衡的,因为它不知道每个请求什么时候结束。如果服务的负载与当前请求数紧密相关,比如在推送服务中希望每个节点管理的连接数要均衡,那么一个理想的选择就是使用最少请求算法。另外如果请求耗时较长且长短不一,使用最少请求算法也能保证每个节点上要准备处理的请求数均衡,避免长时间排队。对于这种情况,也适合采用后文提到的 EWMA 算法。
要想实现最少请求算法,我们需要记录每个节点的当前请求数。一个请求进来时加一,请求结束时减一。对于所有节点权重都一样的情况,靠 O(n)
的遍历可以找出最少请求的节点。我们还可以再优化下。通过 P2C 算法,我们可以每次随机选择两个节点,以 O(1)
的时间复杂度来达到近似于 O(n)
的遍历的效果。事实上,满足下面条件的情况,都能用 P2C 算法来优化时间复杂度:
- 每个节点有一个分数
- 所有节点权重一致
所以有些框架会直接抽象出一个 p2c
的中间件作为通用能力。
涉及到各节点权重不一的情况,就没办法用 P2C 算法了。我们可以把权重根据当前请求数做一下调整,变成 weight / (1 + 请求数)。一个节点得到请求数越多,那么当前权重就会相对应地减少。比如一个权重为 2 的节点,当前有 3 个请求,那么调整之后的权重为 1/2。如果又来了一个新的请求,那么权重就变成了 2/5。通过动态调整权重,我们就能让带权重的最少请求变成带权重的 RoundRobin,进而使用遍历或优先队列来处理它。
Hash
有些时候,需要保证客户端访问到固定的服务端。比如要求代理同一个 Session 的客户端的请求到同一个节点,或者根据客户端 IP 路由到固定节点。这时候我们需要采用 Hash 算法来把客户端的特征映射到某个节点上来。不过简单的 Hash 会有一个问题,如果节点数改变了,会放大影响到的请求数目。
假设这个简单的 Hash 就是以节点数来取余,请求是 1 到 10 这几个数。节点数一开始为 4,随后变成 3。那么结果是:
1: 0 1 2 3 0 1 2 3 0 1
2: 0 1 2 0 1 2 0 1 2 0
我们可以看到,70% 的请求对应的节点都变化了,远大于 25% 的节点数变化。
所以实践中我们更多的是采用 Consistent Hash,如果没有才会考虑一般的 Hash 算法。
Consistent Hash
一致性 Hash 是专门为减少重新 Hash 时结果发生大幅改变而设计的算法。在前面的 Hash 算法中,由于 Hash 的结果与节点数强相关,所以一旦节点数发生改变,Hash 结果就会剧烈变化。那么我们能不能让 Hash 结果与节点数无关呢?一致性 Hash 为我们提供了新的思路。
最常见的一致性 Hash 算法是 ring hash。也即把整个 Hash 空间看作一个环,然后每个节点通过 Hash 算法映射到环上的一个点,每个请求会算出一个 Hash 值,根据 Hash 值找顺时针方向最近的一个节点。这样一来,请求的 Hash 值和节点的数量就没有关系了。环上节点变更时,请求的 Hash 值不会变,变的只是离它最近的节点可能不一样。
读者也许会提出这样的问题,如果节点的位置取决于 Hash 的值,那么如何保证它是均衡分配呢?我在之前的文章《漫谈非加密哈希算法》提到过,Hash 算法在设计时会考虑到降低碰撞的可能性。一个高质量的算法,应当尽可能分散 Hash 映射后的结果。当然,如果只是对有限的几个节点做 Hash,那么难免会出现结果分得不够开的情况。所以一致性 Hash 中引入了虚拟节点的概念。每个真实的节点会对应 N 个虚拟节点,比如说 100 个。每个虚拟节点的 Hash 值由类似于 Hash(node + "_" + virtual_node_id)
这样的算法得到。这样一个真实节点,就会对应 Hash 环上 N 个虚拟节点。从统计学的角度上看,我们可以认为只要 N 的值足够大,节点间距离的标准差就越小,节点在环上的分布就越均衡。
然而 N 并不能无限变大。即使是环上的虚拟节点,也需要真实的内存地址来记录其位置。N 取得越大,节点就越均衡,但消耗的内存就越多。Maglev 算法是另外一种一致性 Hash 算法,旨在优化内存占用。该算法由于采用了别的数据结构,能够在保证同样的均衡性时使用更少的内存。(抑或在使用同样的内存时提供更好的均衡性,取决于你固定那一个变量)。
EWMA
EWMA(Exponential Weighted Moving Average)算法是一种利用响应时间进行负载均衡的算法。正如其名,它的计算过程就是“指数加权移动平均”。
假设当前响应时间为 R,距离上次访问的时间为 delta_time,上次访问时的得分为 S1,那么当前得分 S2
为:S2 = S1 * weight + R * (1.0 - weight)
,其中 weight = e ^ -delta_time/k
。k 是算法中事先固定的常量。
它是指数加权的:上次访问距离现在的时间越长,对当前得分的影响越小。
它是移动的:当前得分从上次得分调整过来。
它是平均的:假如 delta_time 足够大,weight 就足够小,得分接近当前响应时间;假如 delta_time 足够小,weight 就足够大,得分接近上次得分。总体来说,得分是历次响应时间通过调整得来的。
细心的读者会问,既然 weight 是由 delta_time 算出来的,那么用户在配置时指定的权重该放到哪个位置呢?EWMA 是一种自适应的算法,能够按照上游的状态动态调整。如果你发现你需要配置权重,那么你的场景就不适用于使用 EWMA。事实上,由于 EWMA 算法不用操心权重,许多人会考虑把它作为缺乏 slow start 功能时的替代品。
但是 EWMA 并非万灵药。由于 EWMA 是基于响应时间的算法,如果上游响应时间和上游状态没多大关系时,就不适用 EWMA。比如前面介绍最少请求算法时提到的推送场景,响应时间取决于推送的策略,这时采用 EWMA 就不般配了。
另外 EWMA 算法有个固有缺陷 —— 响应时间不一定反映了问题的全貌。设想一个场景,上游有个节点不断快速抛出 500 错误。在 EWMA 算法看来,这个节点反而是个优秀节点,毕竟它有着无与伦比的响应时间。结果大部分流量就会打到这个节点上。所以当你采用 EWMA 时,务必同时开启健康检查,及时摘掉有问题的节点。不过有些时候,一个看上去不属于节点问题的状态码也可能导致流量不均衡。举个例子,某次灰度升级时,新版本增加了一个错误的校验,会把生产环境上部分正确的请求给拒绝掉(返回 400 状态码)。由于 EWMA 会倾向于响应更快的节点,会导致更多的请求落入这个有问题的版本上。
除了 EWMA 之外,也有其他基于响应时间的算法,比如 Dubbo 的加权最短响应优先。