探究 Go 的高级特性之 【实现负载均衡】

负载均衡器在 Web 架构中扮演着非常重要的角色,被用于为多个后端分发流量负载,提升服务的伸缩性。负载均衡器后面配置了多个服务,在某个服务发生故障时,负载均衡器可以很快地选择另一个可用的服务,所以整体的服务可用性得到了提升。

我们使用go语言实现一些负载均衡算法【哈希、一致性哈希、随机、加权随机、轮询、加权轮询、平滑加权轮询

其中,有一些是动态算法,较为复杂,这里没有实现

哈希

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 切片中的某个服务器。
  • 最后,根据计算出的索引值,返回对应的服务器。
go 复制代码
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")
}

一致性哈希

go 复制代码
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。
  • 最后,根据哈希键找到对应的节点,并返回该节点。
go 复制代码
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")
}

随机

go 复制代码
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 切片的范围内。
  • 最后,根据生成的随机索引,返回对应的服务器。

将请求随机分配到各个节点。由概率统计理论得知,随着客户端调用服务端的次数增多,其实际效果越来越接近于平均分配,也就是轮询的结果。 优缺点和轮询相似。

加权随机

go 复制代码
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]
}
  1. ChooseServer 方法用于选择一个服务器。在选择之前,首先计算所有服务器的总权重,以便后续的随机选择。

  2. 使用随机数种子为当前时间戳,以确保每次调用时获得不同的随机数。随机数 randomWeight 范围在总权重之内。

  3. 初始化 currentWeight 为 0,用于跟踪当前累积的权重值。

  4. 遍历服务器列表中的每个服务器,依次累加它们的权重值到 currentWeight

  5. currentWeight 超过或等于 randomWeight 时,表示找到了符合条件的服务器。然后,将该服务器作为选择结果返回。

  6. 如果遍历完所有服务器后,仍未找到符合条件的服务器,会执行到注释中的 Fallback 分支,该分支表示一个备选方案。代码中返回服务器列表中的第一个服务器作为备选方案。

与加权轮询法一样,加权随机法也根据后端机器的配置,系统的负载分配不同的权重。不同的是,它是按照权重随机请求后端服务器,而非顺序。

轮询

go 复制代码
// 轮询算法
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,并使用取模运算符 % 来确保索引在服务器范围内循环。
  • 然后,根据计算出的索引返回相应的服务器。

优点:服务器请求数目相同;实现简单、高效;易水平扩展。

缺点:服务器压力不一样,不适合服务器配置不同的情况;请求到目的结点的不确定,造成其无法适用于有写操作的场景。

应用场景:数据库或应用服务层中只有读的场景。

带权重最大公约数调度加权轮询

go 复制代码
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方法用于计算权重列表中所有权重的最大公约数。它通过遍历权重列表,使用辗转相除法(欧几里德算法)逐步计算最大公约数。

  1. getGcd方法是递归实现最大公约数计算的辅助函数。

  2. 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}。

优点:可以将不同机器的性能问题纳入到考量范围,集群性能最优最大化;

缺点:生产环境复杂多变,服务器抗压能力也无法精确估算,静态算法导致无法实时动态调整节点权重,只能粗糙优化。

平滑加权轮训--时间

go 复制代码
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
}

时间间隔调整当前权重是通过以下方式进行计算的:

  1. 首先获取当前时间戳currentTime。
  2. 计算时间间隔timeInterval,等于currentTime减去上次更新权重的时间戳(lastUpdateTime)。
  3. 如果时间间隔小于等于0,表示没有需要调整的权重,直接返回。
  4. 对于每个服务器的当前权重,根据时间间隔和该服务器的权重,计算出应该增加的权重diff。计算公式为:diff = int(float64(timeInterval) / float64(1*time.Second) * float64(swr.weightsi)),其中weightsi是每个服务器的权重。
  5. 将服务器的当前权重加上diff,以使当前权重反映出经过了适当的时间间隔。
  6. 更新上次更新时间为currentTime。

通过这个算法,权重平滑地根据时间间隔进行调整,以适应不同服务器的负载情况。较长的时间间隔会导致服务器的当前权重增加较多,使其更有机会被选中,而较短的时间间隔则会导致服务器的当前权重增加较少,使其相对被选中的机会减少。

平滑加权轮训--权重

go 复制代码
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

相关推荐
指令集梦境12 小时前
Cursor + Spring Boot实战:从零写一个RESTful API
spring boot·后端·restful
码云之上13 小时前
聊聊如何设计一个高效、稳定的 Node.js 接入层
前端·后端·node.js
IT_陈寒14 小时前
Vite项目build后路由404了?你可能漏了这个小配置
前端·人工智能·后端
宸津-代码粉碎机14 小时前
Spring AI企业级实战|从RAG优化到Agent多工具调度
java·大数据·人工智能·后端·python·spring
吴佳浩14 小时前
AI Infra 的真相:Go 没输,rust也不是取代
后端·rust·go
喵个咪15 小时前
实时游戏网络协议深度对比:KCP vs WebRTC vs WebSocket
后端·websocket·webrtc
普通网友15 小时前
springboot之集成Elasticsearch
spring boot·后端·elasticsearch
QuZero15 小时前
Guava Cache Deep Dive
java·后端·算法·guava
leeyi15 小时前
SSE 实时推流 —— Token 怎么一个个蹦出来
后端·agent
leeyi15 小时前
ReAct 循环的 50 行 Go 实现,逐行拆解
后端·agent