负载均衡器在 Web 架构中扮演着非常重要的角色,被用于为多个后端分发流量负载,提升服务的伸缩性。负载均衡器后面配置了多个服务,在某个服务发生故障时,负载均衡器可以很快地选择另一个可用的服务,所以整体的服务可用性得到了提升。
我们使用go语言实现一些负载均衡算法【哈希、一致性哈希、随机、加权随机、轮询、加权轮询、平滑加权轮询】
其中,有一些是动态算法,较为复杂,这里没有实现
哈希
package load_balance
import "hash/crc32"
type Server struct {
Name string
IP string
}
type LoadBalancer struct {
Servers []Server
}
func (lb *LoadBalancer) ChooseServer(key string) Server {
hash := crc32.ChecksumIEEE([]byte(key))
index := int(hash) % len(lb.Servers)
return lb.Servers[index]
}
- 该方法首先使用
hash/crc32
包中的ChecksumIEEE
函数计算输入key
的哈希值。 - 然后通过对服务器数量取模的方式,计算出一个索引值,该索引值指向
Servers
切片中的某个服务器。 - 最后,根据计算出的索引值,返回对应的服务器。
func TestHashLoadBalancer(t *testing.T) {
server1 := Server{Name: "Server1", IP: "192.168.0.1"}
server2 := Server{Name: "Server2", IP: "192.168.0.2"}
server3 := Server{Name: "Server3", IP: "192.168.0.3"}
lb := LoadBalancer{
Servers: []Server{server1, server2, server3},
}
server := lb.ChooseServer("2")
assert.Equal(t, server.Name, "Server1")
server = lb.ChooseServer("2")
assert.Equal(t, server.Name, "Server2")
server = lb.ChooseServer("1")
assert.Equal(t, server.Name, "Server3")
}
一致性哈希
package load_balance
import (
"fmt"
"sort"
)
type Node struct {
Key uint64
Name string
IP string
}
type ConsistentHasher struct {
Nodes []Node
replicas int
hash func(data []byte) uint64
keys []uint64
nodeToKeys map[uint64]Node
}
func NewConsistentHasher(replicas int, hash func(data []byte) uint64) *ConsistentHasher {
return &ConsistentHasher{
Nodes: make([]Node, 0),
replicas: replicas,
hash: hash,
keys: make([]uint64, 0),
nodeToKeys: make(map[uint64]Node),
}
}
func (ch *ConsistentHasher) AddNode(node Node) {
for i := 0; i < ch.replicas; i++ {
key := ch.hash([]byte(fmt.Sprintf("%s-%d", node.Name, i)))
ch.keys = append(ch.keys, key)
ch.nodeToKeys[key] = node
}
sort.Slice(ch.keys, func(i, j int) bool {
return ch.keys[i] < ch.keys[j]
})
}
func (ch *ConsistentHasher) ChooseServer(key string) Node {
if len(ch.Nodes) == 0 {
return Node{}
}
hashedKey := ch.hash([]byte(key))
index := sort.Search(len(ch.keys), func(i int) bool {
return ch.keys[i] >= hashedKey
})
if index == len(ch.keys) {
index = 0
}
return ch.nodeToKeys[ch.keys[index]]
}
定义了 ConsistentHasher
结构体:
ConsistentHasher
结构体表示一致性哈希器,其中包含了一些字段和方法用于实现一致性哈希负载均衡。Nodes
用于存储所有的节点信息。replicas
表示每个节点在哈希环中的复制数量。hash
是一个函数类型的字段,用于计算哈希值。keys
存储了哈希环上的所有节点的哈希值。nodeToKeys
是一个映射,用于将哈希值映射为对应的节点
实现了 NewConsistentHasher
函数:
NewConsistentHasher
是一个构造函数,用于创建一个一致性哈希器实例。- 它接收
replicas
参数表示每个节点的复制数量,以及一个哈希函数。 - 创建并返回一个初始化后的
ConsistentHasher
实例。
实现了 AddNode
方法:
AddNode
用于向一致性哈希器中添加一个节点。- 对于每个节点,根据复制数量使用节点名称和索引的组合生成复制的哈希键。
- 将生成的哈希键添加到
keys
切片中,并在nodeToKeys
映射中将哈希键映射到节点。 - 最后,对切片
keys
进行排序以便进行二分搜索。
实现了 ChooseServer
方法:
ChooseServer
方法用于根据输入的key
选择一个节点。- 首先,如果没有可用的节点,则返回一个空的
Node
。 - 然后,计算输入
key
的哈希键,并使用二分搜索在keys
切片中找到第一个大于或等于哈希键的索引。 - 如果索引等于
len(ch.keys)
,则说明哈希键超出了哈希环的范围,将索引重新设置为 0。 - 最后,根据哈希键找到对应的节点,并返回该节点。
func TestConsistentHasher(t *testing.T) {
node1 := Node{Key: 1, Name: "Node1", IP: "192.168.0.1"}
node2 := Node{Key: 2, Name: "Node2", IP: "192.168.0.2"}
node3 := Node{Key: 3, Name: "Node3", IP: "192.168.0.3"}
ch := NewConsistentHasher(100, xxhash.Sum64)
ch.AddNode(node1)
ch.AddNode(node2)
ch.AddNode(node3)
server := ch.ChooseServer("Key1")
assert.Equal(t, server.Name, "Node1")
server = ch.ChooseServer("Key2")
assert.Equal(t, server.Name, "Node2")
server = ch.ChooseServer("Key3")
assert.Equal(t, server.Name, "Node3")
}
随机
import (
"math/rand"
"time"
)
type ServerWeight struct {
Name string
IP string
Weight int
}
type LoadBalancerWeight struct {
Servers []ServerWeight
}
func (lb *LoadBalancerWeight) ChooseServer() ServerWeight {
rand.Seed(time.Now().UnixNano())
index := rand.Intn(len(lb.Servers))
return lb.Servers[index]
}
实现了 ChooseServer
方法:
ChooseServer
方法是LoadBalancerWeight
结构体的方法,用于根据服务器的权重随机选择一个服务器。- 首先,使用当前时间作为种子来初始化随机数生成器。
- 然后,生成一个随机索引,该索引落在
Servers
切片的范围内。 - 最后,根据生成的随机索引,返回对应的服务器。
将请求随机分配到各个节点。由概率统计理论得知,随着客户端调用服务端的次数增多,其实际效果越来越接近于平均分配,也就是轮询的结果。 优缺点和轮询相似。
加权随机
type LoadBalancerTotalWeight struct {
Servers []ServerWeight
}
func (lb *LoadBalancerTotalWeight) ChooseServer() ServerWeight {
totalWeight := 0
for _, server := range lb.Servers {
totalWeight += server.Weight
}
rand.Seed(time.Now().UnixNano())
randomWeight := rand.Intn(totalWeight)
currentWeight := 0
for _, server := range lb.Servers {
currentWeight += server.Weight
if currentWeight >= randomWeight {
return server
}
}
// Fallback, should not reach here
return lb.Servers[0]
}
-
ChooseServer
方法用于选择一个服务器。在选择之前,首先计算所有服务器的总权重,以便后续的随机选择。 -
使用随机数种子为当前时间戳,以确保每次调用时获得不同的随机数。随机数
randomWeight
范围在总权重之内。 -
初始化
currentWeight
为 0,用于跟踪当前累积的权重值。 -
遍历服务器列表中的每个服务器,依次累加它们的权重值到
currentWeight
。 -
当
currentWeight
超过或等于randomWeight
时,表示找到了符合条件的服务器。然后,将该服务器作为选择结果返回。 -
如果遍历完所有服务器后,仍未找到符合条件的服务器,会执行到注释中的
Fallback
分支,该分支表示一个备选方案。代码中返回服务器列表中的第一个服务器作为备选方案。
与加权轮询法一样,加权随机法也根据后端机器的配置,系统的负载分配不同的权重。不同的是,它是按照权重随机请求后端服务器,而非顺序。
轮询
// 轮询算法
type RoundRobin struct {
servers []string
index int
}
func NewRoundRobin(servers []string) *RoundRobin {
return &RoundRobin{
servers: servers,
index: -1,
}
}
func (rr *RoundRobin) Next() string {
rr.index = (rr.index + 1) % len(rr.servers)
return rr.servers[rr.index]
}
实现了 Next
方法:
Next
方法用于选择下一个服务器。- 首先,将
index
增加 1,并使用取模运算符%
来确保索引在服务器范围内循环。 - 然后,根据计算出的索引返回相应的服务器。
优点:服务器请求数目相同;实现简单、高效;易水平扩展。
缺点:服务器压力不一样,不适合服务器配置不同的情况;请求到目的结点的不确定,造成其无法适用于有写操作的场景。
应用场景:数据库或应用服务层中只有读的场景。
带权重最大公约数调度加权轮询
package load_balance
// 加权轮询算法
type WeightedRoundRobin struct {
servers []string
weights []int
index int
currWight int
}
func NewWeightedRoundRobin(servers []string, weights []int) *WeightedRoundRobin {
return &WeightedRoundRobin{
servers: servers,
weights: weights,
index: -1,
currWight: 0,
}
}
func (wrr *WeightedRoundRobin) Next() string {
for {
wrr.index = (wrr.index + 1) % len(wrr.servers)
if wrr.index == 0 {
wrr.currWight = wrr.currWight - wrr.gcd(wrr.weights) // 减去最大公约数
if wrr.currWight <= 0 {
wrr.currWight = wrr.maxWeight(wrr.weights) // 重新获取最大权重
if wrr.currWight == 0 {
return "" // 所有服务器权重为0,无法提供服务
}
}
}
if wrr.weights[wrr.index] >= wrr.currWight {
return wrr.servers[wrr.index]
}
}
}
// 计算最大公约数
func (wrr *WeightedRoundRobin) gcd(weights []int) int {
size := len(weights)
if size == 0 {
return 0
}
gcd := weights[0]
for i := 1; i < size; i++ {
if weights[i] > 0 {
gcd = wrr.getGcd(gcd, weights[i])
}
}
return gcd
}
// 计算最大公约数
func (wrr *WeightedRoundRobin) getGcd(a, b int) int {
if b == 0 {
return a
}
return wrr.getGcd(b, a%b)
}
// 获取最大权重
func (wrr *WeightedRoundRobin) maxWeight(weights []int) int {
max := 0
for _, weight := range weights {
if weight > max {
max = weight
}
}
return max
}
定义了一个名为WeightedRoundRobin
的结构体,该结构体包含了服务器列表 servers
、权重列表 weights
、当前索引 index
和当前权重 currWeight
。索引和当前权重用于跟踪下一个要选择的服务器。
NewWeightedRoundRobin
函数是一个工厂函数,用于创建 WeightedRoundRobin
结构体的实例。它接受服务器列表和权重列表作为参数,并初始化结构体的字段。
Next
方法用于选择下一个服务器。它使用循环遍历服务器列表,并根据权重选择合适的服务器。具体的算法如下:
-
每次调用
Next
方法时,索引index
递增,并通过取模运算将其限制在服务器列表的范围内。 -
如果索引
index
等于0,则表示已经遍历完一轮服务器列表。在此时需要进行一些额外的处理:- 将当前权重
currWeight
减去服务器列表中所有权重的最大公约数,目的是去除已选中的服务器的权重。 - 如果当前权重
currWeight
小于等于0,则表示所有服务器的权重都为0,无法提供服务,直接返回空字符串。 - 否则,将当前权重
currWeight
重新设置为服务器列表中所有权重的最大值,以重新开始下一轮选择。
- 将当前权重
-
检查当前选中的服务器的权重是否大于等于当前权重
currWeight
。如果是,则返回该服务器作为下一个选择。 -
如果服务器权重小于当前权重,则继续循环,选择下一个服务器。
gcd
方法用于计算权重列表中所有权重的最大公约数。它通过遍历权重列表,使用辗转相除法(欧几里德算法)逐步计算最大公约数。
-
getGcd
方法是递归实现最大公约数计算的辅助函数。 -
maxWeight
方法用于获取权重列表中的最大权重。它通过遍历权重列表,找到最大的权重值并返回。
代码中有一个条件判断,在当前权重 currWeight
小于等于 0 时,会直接返回空字符串,表示无法提供服务。这个条件是为了处理一种特殊情况,即所有服务器的权重都为 0。
最大公约数调度算法,为什么需要减去最大公约数
在最大公约数调度算法(GCD调度算法)中,服务器的权重取值通常是不确定的,为了确保计算出的权重是整数,需要找到所有服务器权重的最大公约数,并将其作为一个调整参数,以确保服务器的调度权重是整数。
假设有两台服务器A和B,它们的权重分别是6和8,它们的最大公约数是2。如果不进行调整,按照权重比例,A被选中的概率是6/(6+8)=6/14,而B被选中的概率是8/14。但是在实际调度中,我们需要整数权重值,所以将最大公约数2作为调整参数,将A和B的调度权重分别计算为6/2=3和8/2=4。这样一来,按照调整后的权重值,A被选中的概率是3/(3+4)=3/7,B被选中的概率是4/7,保证了按照权重进行调度后的均衡性。
因此,在最大公约数调度算法中,需要减去最大公约数是为了确保最终的调度权重是整数,从而保持调度的准确性和均衡性
加权轮询算法要生成一个服务器序列,该序列中包含n个服务器。n是所有服务器的权重之和。在该序列中,每个服务器的出现的次数,等于其权重值。并且,生成的序列中,服务器的分布应该尽可能的均匀。比如序列{a, a, a, a, a, b, c}中,前五个请求都会分配给服务器a,这就是一种不均匀的分配方法,更好的序列应该是:{a, a, b, a, c, a, a}。
优点:可以将不同机器的性能问题纳入到考量范围,集群性能最优最大化;
缺点:生产环境复杂多变,服务器抗压能力也无法精确估算,静态算法导致无法实时动态调整节点权重,只能粗糙优化。
平滑加权轮训--时间
package load_balance
import "time"
// 平滑加权轮询算法
type SmoothWeightedRoundRobin struct {
servers []string // 服务器名字列表
weights []int // 对应服务器的权重列表
currentWeight []int // 每台服务器的当前权重
maxWeight int // 权重列表中的最大权重
lastUpdateTime int64 // 上次更新权重的时间戳
}
// NewSmoothWeightedRoundRobin 创建平滑加权轮询算法的负载均衡器
func NewSmoothWeightedRoundRobin(servers []string, weights []int) *SmoothWeightedRoundRobin {
swr := &SmoothWeightedRoundRobin{
servers: servers,
weights: weights,
currentWeight: make([]int, len(weights)),
maxWeight: maxWeight(weights),
lastUpdateTime: time.Now().Unix(),
}
swr.updateCurrentWeights() // 初始化当前权重
return swr
}
// Next 选择下一个服务器
func (swr *SmoothWeightedRoundRobin) Next() string {
swr.updateCurrentWeights() // 更新当前权重
index := getMaximumCurrentWeightIndex(swr.currentWeight) // 获取最大当前权重的服务器索引
// 更新当前权重
swr.currentWeight[index] -= swr.sumWeights()
return swr.servers[index] // 返回选中的服务器名字
}
// updateCurrentWeights 根据时间间隔调整当前权重
func (swr *SmoothWeightedRoundRobin) updateCurrentWeights() {
currentTime := time.Now().Unix()
timeInterval := currentTime - swr.lastUpdateTime // 计算时间间隔
if timeInterval <= 0 {
return
}
// 根据时间间隔调整当前权重
for i := range swr.currentWeight {
diff := int(float64(timeInterval) / float64(1*time.Second) * float64(swr.weights[i]))
swr.currentWeight[i] += diff
}
swr.lastUpdateTime = currentTime // 更新上次更新时间
}
// sumWeights 计算所有权重的和
func (swr *SmoothWeightedRoundRobin) sumWeights() int {
sum := 0
for _, weight := range swr.weights {
sum += weight
}
return sum
}
// getMaximumCurrentWeightIndex 获取当前权重最大的服务器索引
func getMaximumCurrentWeightIndex(weights []int) int {
max := 0
index := -1
for i, weight := range weights {
if weight > max {
max = weight
index = i
}
}
return index
}
// maxWeight 获取权重的最大值
func maxWeight(weights []int) int {
max := 0
for _, weight := range weights {
if weight > max {
max = weight
}
}
return max
}
时间间隔调整当前权重是通过以下方式进行计算的:
- 首先获取当前时间戳currentTime。
- 计算时间间隔timeInterval,等于currentTime减去上次更新权重的时间戳(lastUpdateTime)。
- 如果时间间隔小于等于0,表示没有需要调整的权重,直接返回。
- 对于每个服务器的当前权重,根据时间间隔和该服务器的权重,计算出应该增加的权重diff。计算公式为:diff = int(float64(timeInterval) / float64(1*time.Second) * float64(swr.weights[i])),其中weights[i]是每个服务器的权重。
- 将服务器的当前权重加上diff,以使当前权重反映出经过了适当的时间间隔。
- 更新上次更新时间为currentTime。
通过这个算法,权重平滑地根据时间间隔进行调整,以适应不同服务器的负载情况。较长的时间间隔会导致服务器的当前权重增加较多,使其更有机会被选中,而较短的时间间隔则会导致服务器的当前权重增加较少,使其相对被选中的机会减少。
平滑加权轮训--权重
package main
import "fmt"
type Server struct {
Name string
Weight int
CurrentWeight int
}
func nextServer(servers []*Server) *Server {
total := 0
maxWeightServer := servers[0]
for _, server := range servers {
server.CurrentWeight += server.Weight
total += server.Weight
if server.CurrentWeight > maxWeightServer.CurrentWeight {
maxWeightServer = server
}
}
maxWeightServer.CurrentWeight -= total
return maxWeightServer
}
func main() {
servers := []*Server{
{"Server1", 5, 0},
{"Server2", 3, 0},
{"Server3", 2, 0},
}
for i := 0; i < 10; i++ {
server := nextServer(servers)
fmt.Println("Selected server:", server.Name)
}
}
平滑加权轮询 会把轮询的请求打散 让每个服务器都比较均衡的获得请求 避免了同一个时刻大量请求打入,更加平衡
- 假设有 N 台服务器 S = {S0, S1, S2, …, Sn},默认权重为 W = {W0, W1, W2, …, Wn},当前权重为 CW = {CW0, CW1, CW2, …, CWn}。在该算法中有两个权重,默认权重表示服务器的原始权重,当前权重表示每次访问后重新计算的权重,当前权重的出初始值为默认权重值,当前权重值最大的服务器为 maxWeightServer,所有默认权重之和为 weightSum,服务器列表为 serverList,算法可以描述为:
1、找出当前权重值最大的服务器 maxWeightServer;
2、计算 {W0, W1, W2, …, Wn} 之和 weightSum;
3、将 maxWeightServer.CW = maxWeightServer.CW - weightSum;
4、重新计算 {S0, S1, S2, …, Sn} 的当前权重 CW,计算公式为 Sn.CW = Sn.CW + Sn.Wn
5、返回 maxWeightServer
-
固定权重为 2,3,5 动态权重第一次设置为 0,0,0
我们是这样使用这个公式的 , 举个例子 2,3,5 当前的最大权重 max(currWeight) 是5, 5 定位到C服务器 就返回一个C服务器 ,此时我们把C的权重 减去 总权重 得到 -5;其他2个权重不变,我们就得到了动态变化的权重 -
第二次 ,我们把上一次的动态权重加上我们的固定权重 得到新的权重 得到新的权重 4,6,0 当前最大权重是6 权重变化后 对应的服务器是B 返回一个B服务器,重复上一步骤,B的权重 减去总权重 得到新的动态权重 4,-4,0 以此类推10次 便会发现 动态权重又变回了 0,0,0