概览
在分布式环境下,各个微服务都会有不同的实例,服务注册和服务发现解决了“有哪些可用实例”的问题,剩下面临的就是,“这么多可用实例,我该把请求发给谁?”。直觉来说,大部分人如果听过一些专业名词,此时会直接想到“负载均衡”。那负载均衡到底是什么呢?
负载均衡是在支持应用程序的资源池中平均分配网络流量的一种方法。现代应用程序必须同时处理数百万用户,并以快速、可靠的方式将正确的文本、视频、图像和其他数据返回给每个用户。为了处理如此高的流量,大多数应用程序都有许多资源服务器,它们之间包含很多重复数据。负载均衡器是位于用户与服务器组之间的设备,充当不可见的协调者,确保均等使用所有资源服务器。 ——- aws
事实上负载均衡是手段而不是目的。因此从目的上来说,我们其实不需要搞什么负载均衡,我们的目的就是把请求转发给“最适合”处理这个请求的节点。最适合意味着:
– 如果这个请求需要很多内存,那么将它转发给内存多的节点。
– 如果这个请求是 CPU 密集的,那么将它转发给 CPU 比较空闲的请求。
– ……
显然,在大多数时候,我们会希望将请求转发给“能够最快返回响应”的那个节点。
负载均衡的本质
在这种情况下:负载均衡本质上是一个简化模型。也就是说,如果我们的目标是挑选出那个最适合的节点,我们就得设立一个标准。而这个标准,被简化为“负载” 。换言之,我们认为负载最轻的就是最适合的。而考虑到所有的节点都提供同等的服务,那么你每次挑选负载最轻的节点,就造成所有的节点负载是大致均衡的。
举个例子,假设就像现在有一个餐厅,一共有五个服务员,当一些菜做好后,此时可能部分服务员正在给其他客人上菜,有的很空闲,此时如何将这些菜品分发给服务员以达到高效让所有客人都吃上菜的过程就可以看作负载均衡。在这个情景下,对于负载的概念其实就是每个服务员正在处理多少盘菜,多的叫高负载,少的叫低负载。
负载均衡算法
上面提到了负载均衡的概念和本质,那么如何实现负载均衡呢?我们把实现负载均衡的这个过程所使用的规则或者说方法叫做负载均衡算法。大致可以分为两类:静态负载均衡算法和动态负载均衡算法。
静态负载均衡算法:静态负载均衡算法遵循固定规则,与当前服务器状态无关。
主要有:轮询、加权轮询、随机、加权随机、哈希、一致性哈希。
轮询
轮询算法的效果很不错,而且应该是应用最广泛的负载均衡算法了。轮询的思路非常简单的,也就是一个个节点的轮流过去。虽然轮询的算法很简单(实现也很简单),但是它效果很不错。应该说,绝大多数情况下,直接使用轮询算法不会有任何问题。但是轮询本身有两个假设:
• 所有服务器的处理能力是一样的。
• 所有请求所需的资源也是一样的。
而在实践中,这两个假设,尤其是第二个假设,并不成立。因此轮询用多了,难免会遇到偶发性的负载不均衡问题。
典型例子就是某个节点收到了一个大请求,直接把自己打爆了。
加权轮询
加权轮询是对轮询的一个改进。算法大体上还是轮询,但是不再是一个请求一个节点这样轮询,而是按照权重来轮询。权重多的会多分到几个请求,权重小的就少分几个请求。权重大多数时候都是代表了服务端节点的处理能力(重要性、优先级等)。还有一些比较罕见的情况是在多活的场景下,同机房、同城的节点会有更高的权重。
那么如何获得权重?在注册中心模型下,权重基本上是通过注册中心来传递的。也就是每一个服务端节点在启动的时候会把自己的权重信息也注册进去。客户端在服务发现的过程中,就能获得对应的权重信息。但是普通的加权轮询,有一个缺陷:权重高的服务端节点会连续收到多个请求。如下图,B 会连续收到两个请求,C 会连续收到三个请求。
平滑的加权轮询
加权轮询算法是在加权算法的基础上,引入了一个 currentWeight
(当前权重)的概念。每次选中一个节点之后,就降低 currentWeight
。于是一个节点就有两个权重了:初始权重和当前权重。整个算法的步骤是:
• 计算所有的初始权重的和,也就是总权重。
• 每一个节点,都让自己的当前权重加上初始权重。
• 选出当前权重最大的节点,发送请求。
• 将选中的节点的当前权重减去总权重。
代码示例:
/*
平滑加权轮询算法的实现
*/
package loadbalance
import (
"fmt"
"sync"
"testing"
)
type Node struct {
name string
weight int
currWeight int
}
func (n *Node) Invoke() {}
type Balancer struct {
nodes []*Node
mux sync.Mutex
t *testing.T
}
func TestSmoothWRR(t *testing.T) {
// 模拟单个服务的三个节点
nodes := []*Node{
{
name: "A",
weight: 10,
currWeight: 10,
},
{
name: "B",
weight: 20,
currWeight: 20,
},
{
name: "C",
weight: 30,
currWeight: 30,
},
}
bl := &Balancer{
nodes: nodes,
t: t,
}
for i := 1; i <= 6; i++ {
t.Log(fmt.Sprintf("第 %d 个请求发送前,nodes %v", i, convert(nodes)))
target := bl.pick()
// 模拟 rpc 调用
target.Invoke()
t.Log(fmt.Sprintf("第 %d 个请求发送后,nodes %v", i, convert(nodes)))
}
}
func (b *Balancer) pick() *Node {
b.mux.Lock()
defer b.mux.Unlock()
total := 0
// 计算总权重
for _, n := range b.nodes {
total += n.weight
}
// 计算当前权重
for _, n := range b.nodes {
n.currWeight = n.currWeight + n.weight
}
// 挑选节点
var target *Node
for _, n := range b.nodes {
if target == nil {
target = n
} else {
if target.currWeight < n.currWeight {
target = n
}
}
}
b.t.Log("选中的节点的当前权重", target)
target.currWeight = target.currWeight - total
b.t.Log("选中的节点减去总权重后的权重", target)
return target
}
func convert(src []*Node) []Node {
dst := make([]Node, 0, len(src))
for _, n := range src {
dst = append(dst, Node{
name: n.name,
weight: n.weight,
currWeight: n.currWeight,
})
}
return dst
}
输出结果:
weighted_test.go:53: 第 1 个请求发送前,nodes [{A 10 10} {B 20 20} {C 30 30}]
weighted_test.go:86: 选中的节点的当前权重 &{C 30 60}
weighted_test.go:88: 选中的节点减去总权重后的权重 &{C 30 0}
weighted_test.go:57: 第 1 个请求发送后,nodes [{A 10 20} {B 20 40} {C 30 0}]
weighted_test.go:53: 第 2 个请求发送前,nodes [{A 10 20} {B 20 40} {C 30 0}]
weighted_test.go:86: 选中的节点的当前权重 &{B 20 60}
weighted_test.go:88: 选中的节点减去总权重后的权重 &{B 20 0}
weighted_test.go:57: 第 2 个请求发送后,nodes [{A 10 30} {B 20 0} {C 30 30}]
weighted_test.go:53: 第 3 个请求发送前,nodes [{A 10 30} {B 20 0} {C 30 30}]
weighted_test.go:86: 选中的节点的当前权重 &{C 30 60}
weighted_test.go:88: 选中的节点减去总权重后的权重 &{C 30 0}
weighted_test.go:57: 第 3 个请求发送后,nodes [{A 10 40} {B 20 20} {C 30 0}]
weighted_test.go:53: 第 4 个请求发送前,nodes [{A 10 40} {B 20 20} {C 30 0}]
weighted_test.go:86: 选中的节点的当前权重 &{A 10 50}
weighted_test.go:88: 选中的节点减去总权重后的权重 &{A 10 -10}
weighted_test.go:57: 第 4 个请求发送后,nodes [{A 10 -10} {B 20 40} {C 30 30}]
weighted_test.go:53: 第 5 个请求发送前,nodes [{A 10 -10} {B 20 40} {C 30 30}]
weighted_test.go:86: 选中的节点的当前权重 &{B 20 60}
weighted_test.go:88: 选中的节点减去总权重后的权重 &{B 20 0}
weighted_test.go:57: 第 5 个请求发送后,nodes [{A 10 0} {B 20 0} {C 30 60}]
weighted_test.go:53: 第 6 个请求发送前,nodes [{A 10 0} {B 20 0} {C 30 60}]
weighted_test.go:86: 选中的节点的当前权重 &{C 30 90}
weighted_test.go:88: 选中的节点减去总权重后的权重 &{C 30 30}
weighted_test.go:57: 第 6 个请求发送后,nodes [{A 10 10} {B 20 20} {C 30 30}]
随机
随机算法比轮询还要简单,就是随便挑一个。从实现的角度来说,就是生成一个随机数,利用随机数除以总节点数量,得到的余数就是节点的下标。随机算法有两个假设:
• 所有服务器的处理能力是一样的。
• 所有请求所需的资源也是一样的。
并且,随机算法还认为在请求数量足够多的情况下,节点负载是均衡的。当然,如果特别倒霉,也是会遇到负载不均衡的问题。
加权随机
加权随机类似于加权轮询,给每一个节点一个权重。如下图,就是加权随机的基本思路。
• 计算一个总权重 T。
• 生成一个随机数 r。
• r 落到总权重的哪个区间,就选择哪个节点。加权随机没有平滑的加权随机这个版本,因为用不上。
哈希
哈希算法核心就是根据业务特征来计算一个哈希值,而后除以节点总数的余数,就是目标节点。你可以将哈希和随机结合在一起看,那么随机算法就是用随机数,而哈希算法就是用业务特征来计算哈希值。所谓的业务特征,可以是:
• 业务 ID
• 外键
• 唯一索引
• ……
跟你的业务是密切相关的。
加权哈希
加权哈希算法也类似于加权随机算法。如下图,你用哈希值替代了随机数,就变成了加权哈希算法。
一致性哈希
一致性哈希算法是哈希算法的改进,引入了一个类似环。同样是计算哈希值,但是哈希值和节点的关系是:哈希值落在某一个区间内,会被特定的一个节点处理。因此优势就是当增加节点或者减少节点的时候,只有一部分请求命中的节点会发生变化。
动态负载均衡算法:尝试实时计算节点的负载。而在实践中,我们其实也不知道怎么定量计算一个节点的负载,所以动态负载均衡算法也只能根据各自的理解,选择一些指标。
常见的有:
• 最少连接数。
• 最少活跃数。
• 最快响应。
不过在实际中,这三个算法都不常使用。
最少连接数
基本的想法就是:如果一个节点上的负载很高,那么连到这个节点的连接数量肯定很多。每次挑选节点的时候,客户端看看自己这边所有的候选节点的连接有多少,然后挑选最少连接数的节点。但是这里面有缺陷:如果是在连接多路复用(偏应用的封装而非底层的多路复用)的情况下,也就是一个连接可以同时被多个请求复用的时候,那么连接数少的,可能负载反而更高。比如说服务端 A 上面的十个连接,可能被 100 个请求复用,但是服务端 C 上面的十二个节点,可能 只有 50 个请求复用。但现实是,请求多路复用的框架不多。grpc
是实现了连接多路复用的,但可惜的是,在使用 grpc 时无法采取这个算法,原因在于,无法获取到 grpc 底层到底有多少个连接。
最少活跃数
活跃数是指正在被处理的请求数量。很显然,活跃请求数还是比较准确的。客户端为每一个服务端节点维持一个活跃请求数计数,每次发请求的时候选择一个活跃数最低的节点。
最快响应时间
简而言之:每次挑选曾经最快返回响应的节点。这个响应时间可以是:
• 平均响应时间
• 99 线
• 999 线
正常推荐平均响应时间或者 99 线。
它也是这三个中最靠谱的一种算法。
它的问题在于,假如有以下情况,某一个大请求打到了 C 节点,将它的平均响应时间拉到了 1 秒,然后剩下的所有请求都只会在 A 和 B 直接来回跳,原因是客户端一直认为它的平均响应时间是 1 秒,那它的原因就在于,当它的平均响应时间为一秒后,客户端就再也不会把请求打向它了,根本就不会重新计算它的平均响应时间。造成一种恶性循环,死锁的状况。
总结
负载均衡总结
- 要不要考虑服务器处理能力?
- 轮询、随机、哈希、最小连接数、最少活跃数都没考虑。
- 对应的加权版本,就可以用权重来代表服务器的处理能力。
- 选择什么指标来表达服务器当前负载?
- (加权)轮询、(加权)随机、哈希什么都没选,依赖于统计。
- 选择连接数、请求数、响应时间、错误数……所以你可以随便选几个指标,然后设计自己的负载均衡算法。
- 是不是所有的请求所需的资源都是一样的?显然不是
- 大商家品类极多,大买家订单极多。
- 不考虑请求消耗资源的负载均衡,容易出现偶发性的打爆某一台实例的情况。
- 选什么负载均衡算法?实践中遇事不决用轮询,出了问题再说。
权重效果
大多数时候我们都是使用权重来表达服务器的处理能力,或者说重要性、优先级。
使用权重的算法都要考虑:
- 某个实例的权重特别大,可能连续几次都选中它,那么要考虑平滑效果。
- 结合实际调用结果来调整权重,例如实例如果返回了错误,那么就降低权重,反之就增加权重。
- 一个实例的权重如果是动态调整的,那么就要考虑上限和下限的问题,尤其要考虑调整权重的过程中会不会导致权重变成 0、最大值或者最小值。这三个值可能导致实例完全不被选中,或者一直被选中。
微服务框架的局限性
前面我们聊到的三个动态负载均衡算法:最少连接数、最少活跃数、最快响应时间算法都是客户端来选择的。但是客户端只知道自己这一边的情况,也就是:
- 客户端只知道自己和各个服务端节点的连接数。
- 客户端只知道自己发过去,还没收到的响应的请求数。
- 客户端只知道自己发过去的请求的响应时间。
对于客户端2,如果它要发请求,在它看来,它会选择服务端1,但实际上,服务端3才是连接数最少的,局限在于,客户端2它是不知道客户端1与其他服务端之间的连接的,它只知道自己与其他服务端的连接数。总结为微服务框架不具备全局信息。但网关是能够具备全局信息的,特别是单实例网关,多实例网关必须要进行数据同步才能做到。
设计自己的负载均衡算法
核心是根据自己的业务特征来选取一些指标,来表达服务实例的负载。
- 错误率等其它服务指标。
- CPU、IO、网络负载等硬件指标。
而想要知道这些指标,除了客户端统计(有些客户端没法统计,例如每个实例的 CPU),还有一些奇技淫巧:
- 服务端将指标的值写入注册中心,注册中心通知客户端。
- 服务端每次返回响应的时候,额外带上自己的指标,如 CPU 利用率。
- 利用我们的可观测性平台,从观测性平台获得数据。