前言
上一篇文章我们分析了BFE的负载均衡算法--平滑加权轮询算法的原理与实现, 在后端服务处理业务能力相对平稳且均衡的情况下, 此算法可以比较理想的实现轮询调度。但是如果服务对于请求的能力处理有较大的差异, 比如由于rpc超时, 业务处理错误直接返回等情况引起的请求处理时间不均衡, 也会导致集群在相同的权重值下有不同的负载表现。所以引申出可以感知到后端负载能力的调度算法, 而服务器连接数可以明确表示当前正在处理的任务量, 所以引申出最小连接数算法的实现。
前期回顾: 平滑加权轮询算法设计与实现
源码分析
1. 选择负载较小的实例列表
最小连接数的算法实现在bfe_balance/bal_slb/bal_rr.go:leastConnsBalance()
方法, 源码解析如下:
// 最小连接数负载均衡
// 返回当前最小负载的实例列表, 如果实例只有一个, 则BackendList元素只有一个
// 当选择过程中计算得的实例负载相同时则添加到待返回的候选实例列表中
func leastConnsBalance(backs BackendList) (BackendList, error) {
var best *BackendRR
candidates := make(BackendList, 0, len(backs))
singleBackend := true // 是否只有一个实例
for _, backendRR := range backs {
if !backendRR.backend.Avail() || backendRR.weight <= 0 {
continue
}
// 先选择出一个后端实例
if best == nil {
best = backendRR
singleBackend = true
continue
}
// 比较初始化选择的实例与当前循环得到的实例负载情况
ret := compLCWeight(best, backendRR)
if ret > 0 { // 当前循环实例负载小于best(最优或最小负载实例)
best = backendRR
singleBackend = true
} else if ret == 0 { // 负载相同
singleBackend = false
}
}
if best == nil {
return nil, fmt.Errorf("rr_bal:all backend is down")
}
// 如果只有一个实例情况, 直接返回
if singleBackend {
candidates = append(candidates, best)
return candidates, nil
}
// 有多个负载相同的实例, 则添加列表返回
for _, backendRR := range backs {
if !backendRR.backend.Avail() || backendRR.weight <= 0 {
continue
}
if ret := compLCWeight(best, backendRR); ret == 0 {
candidates = append(candidates, backendRR)
}
}
return candidates, nil
}
算法的主要步骤为:
- 初始化一个最优实例, 取后端列表的第一个;
- 循环比较列表中其他实例与最优实例的当前负载情况, 如果存在负载更小的实例, 则替换最优实例为当前负载较小的实例
- 如果最终选择的最优实例只有一个, 则返回, 否则重新执行上述计算过程, 返回多个负载相同的实例。
2. 平滑加权轮旋选择实例
经过第一步已经选择除了负载最小或者负载相同的且最小的某一批实例集合, 如果当前实例列表中只有一个backend元素, 则直接返回该实例。否则基于此列表根据平滑加权轮旋算法选择一个后端实例。
func (brr *BalanceRR) leastConnsSmoothBalance() (*backend.BfeBackend, error) {
brr.Lock()
defer brr.Unlock()
// 根据最小连接数算法选择后端集合
candidates, err := leastConnsBalance(brr.backends)
if err != nil {
return nil, err
}
// 如果只有一个元素, 则直接返回
if len(candidates) == 1 {
return candidates[0].backend, nil
}
// 根据平滑加权轮询算法选取一个元素
return smoothBalance(candidates)
}
最后的平滑加权算法选取元素过程我们前面已经做过分析与实现, 不了解的同学可重温: 平滑加权轮询算法设计与实现
负载的计算
在上述的选举过程中比较重要的就是一个负载的计算过程, 实现逻辑在compLCWeight
方法。其实在负载均衡的选择过程中并不能真实的感知到后端实例负载情况, 可以通过计算分配给后端的连接数与后端配置的一个权重情况, 可以相对的做出负载情况的比较值。举例说明, 当前我们有两个后端元素b1, b2, 配置的负载权重都为100, 即Wb1=100, Wb2=100。在程序运行的某一时刻b1处理了50个连接记为Cb1=50, b2处理了60个连接记为Cb2=60。此时相对负载情况可表示为:
- b1: Cb1 / Wb1 = 50 / 100 = 0.5
- b2: Cb2 / Wb2 = 60 / 100 = 0.6
通过比较计算: 连接数 / 权重, 可以模拟后端接收到的负载情况, 负载较小的实例被优先选择。算法中为了避免出现浮点数比较, 使用了:
Cb1 * Wb2 - Cb2 * Wb1
来比较, 是一种很讨巧的做法。
// 比较a, b负载情况,返回值:
// 0 : a = b
// 1: a > b
// -1: a < b
func compLCWeight(a, b *BackendRR) int {
ret := a.backend.ConnNum()*b.weight - b.backend.ConnNum()*a.weight
if ret > 0 {
return 1
}
if ret == 0 {
return 0
}
return -1
}
当返回的实例被选择后, 需要将当前连接数+1
// bfe_server/reverseproxy.go:clusterInvoke()
backend.IncConnNum()
结束请求后, 连接数-1
// bfe_server/reverseproxy.go:FinishReq()
backend.DecConnNum()
思考
在计算最小连接数的候选backendList时执行了两次相同的循环和比较操作, 是否可以一次返回呢?尝试修改做性能测试如下, 修改前:
goos: darwin
goarch: arm64
pkg: github.com/bfenetworks/bfe/bfe_balance/bal_slb
BenchmarkWlcBalance
BenchmarkWlcBalance-8 146413 8691 ns/op
PASS
修改后:
goos: darwin
goarch: arm64
pkg: github.com/bfenetworks/bfe/bfe_balance/bal_slb
BenchmarkWlcBalance
BenchmarkWlcBalance-8 246891 4948 ns/op
PASS
性能提升43%,对于前端接入层, 承担万亿流量转发的引擎来说, 是一个非常大的提升!
总结
负载均衡常见的除了加权轮询和最小连接数算法, 还有源地址哈希法, 思想是根据获取客户端的IP地址,通过哈希函数计算得到的一个数值,用该数值对服务器列表的大小进行取模运算,得到的结果便是客服端要访问服务器的序号, 理解也比较简单, BFE中也有实现stickyBalance()
, 不计划做详细分析。这些算法基本上可以满足大部分场景下的合理使用。