学习目标
学完本章,你应该能够:
- 用"目的而非手段"的视角说清负载均衡在解决什么——它要选出"最适合"处理请求的节点,而不是机械地把流量均分。
- 讲清静态算法(轮询 / 加权轮询 / 随机 / 哈希 / 一致性哈希)的原理、Go 实现与适用边界,能手写最经典的平滑加权轮询。
- 讲清动态算法(最少连接 / 最少活跃 / 最快响应)的思路,以及"客户端只能看到自己这一侧指标"的固有局限。
- 区分客户端负载均衡与服务端负载均衡,并在 gRPC 里用内置的
round_robin/weightedroundrobin,还能自己实现PickerBuilder+Picker。 - 把"重试 + 负载均衡"的 Failover 讲成一段可落地的面试亮点方案,并结合熔断/限流做增强。
前置知识(如果下面任意一点生疏,先回看对应章):
- 第02章 Gin + GORM:知道一个 HTTP 接口怎么写、DAO 怎么分层。
- 第07章 服务注册与发现(etcd):知道"有哪些可用实例"是怎么来的。
- Go 并发基础:原子操作
sync/atomic、sync.Mutex、goroutine 调度。
本章你会动手做的事:
- 手写一个平滑加权轮询,验证 A:B:C = 5:1:1 的序列不再连续命中同一节点。
- 用 gRPC 的
WithDefaultServiceConfig把默认pick_first换成round_robin,看请求是否打散到多个节点。 - 在自定义 Picker 的
Done回调里,根据调用错误动态调低节点权重,体会 Failover。
一、核心概念:负载均衡到底在解决什么问题
1.1 从"目的"而非"手段"理解负载均衡
服务注册与发现解决了"有哪些可用实例"的问题,但没有解决"我该把请求发给谁"的问题。直觉上我们会想到"负载均衡"这个词,但要清楚:负载均衡只是手段,不是目的。
真正的目的是:把请求转发给"最适合"处理它的节点。例如:
- 内存密集型请求 → 转发给内存多的节点
- CPU 密集型请求 → 转发给 CPU 空闲的节点
- 大多数时候 → 转发给"能最快返回响应"的节点
类比:你开了一家餐厅,门口有个迎宾。客人进门,迎宾不是无脑地"1 号桌、2 号桌、3 号桌"轮流安排——而是看哪桌空着、哪桌服务员闲,把客人领到"最合适"的那桌。负载均衡就是这位迎宾,它的 KPI 是"让每桌都别太累、也别太闲",而不是"必须每桌轮到一个客人"。
1.2 负载均衡是一个"简化模型"
要选出"最适合"的节点,必须设立一个标准。这个标准被简化为"负载"——我们认为负载最轻的就是最适合的。又因为所有节点提供同等服务,每次都选负载最轻的,最终所有节点负载会大致均衡。
flowchart LR
R[用户请求] --> LB[负载均衡器]
LB -->|选负载最轻| N1[节点 A 内存空闲]
LB -->|选负载最轻| N2[节点 B 内存吃紧]
LB -->|选负载最轻| N3[节点 C CPU 空闲]工程上重要:不要为了"均衡"而均衡。如果某个节点性能更好,让多打几个请求过去反而更合理——这就是"加权"的来源。
1.3 客户端 LB vs 服务端 LB
| 维度 | 服务端负载均衡(如 Nginx、LVS) | 客户端负载均衡(如 gRPC、Dubbo 内置 LB) |
|---|---|---|
| 决策位置 | 独立的 LB 节点 | 调用方进程内 |
| 性能瓶颈 | LB 节点本身可能成为瓶颈 | 无独立瓶颈,每个客户端自己决策 |
| 实例感知 | LB 需要维护实例列表 | 客户端从注册中心拉取实例列表 |
| 算法灵活性 | 受 LB 软件限制 | 可在客户端代码中自定义 |
| 典型代表 | Nginx、LVS、F5、SLB | gRPC Picker、Ribbon、Dubbo LoadBalance |
flowchart LR
subgraph 服务端LB
C[客户端] --> SLB[Nginx/LVS 独立节点]
SLB --> X1[实例 1]
SLB --> X2[实例 2]
end
subgraph 客户端LB
CL[调用方进程内] -->|自己计算选点| Y1[实例 1]
CL -->|自己计算选点| Y2[实例 2]
end微服务架构下,客户端负载均衡是主流,因为它没有独立瓶颈,并且可以和服务注册发现紧密配合。本章后续代码都以客户端 LB(gRPC)为例。
二、静态负载均衡算法
静态算法的核心思路是:完全不管节点的实际负载,依靠统计学来保证均衡。也就是说,只要请求量足够大,最终各节点负载大概率是均衡的。常见算法有:轮询、加权轮询、随机、加权随机、哈希、一致性哈希。
2.1 轮询(Round Robin)
思路极简:候选节点列表 [A, B, C],请求依次落到 A → B → C → A → …
两个隐含假设:
- 所有服务器处理能力相同
- 所有请求消耗的资源相同
实践中第二个假设经常不成立(比如某个节点恰好收到一个超大请求,直接被打爆),所以轮询偶发不均衡。
flowchart LR
Q[请求流] --> RR[轮询计数器自增]
RR -->|第 1 个| A[节点 A]
RR -->|第 2 个| B[节点 B]
RR -->|第 3 个| C[节点 C]
RR -->|第 4 个| A// RoundRobin 最简实现:原子自增取模
type RoundRobin struct {
nodes []string
counter uint64
}
func (r *RoundRobin) Pick() string {
// 步骤 1:原子加 1,避免并发场景下的竞态
n := atomic.AddUint64(&r.counter, 1)
// 步骤 2:减 1 后取模,映射到节点下标(从 0 开始)
idx := (n - 1) % uint64(len(r.nodes))
// 步骤 3:返回对应节点
return r.nodes[idx]
}
2.2 加权轮询(Weighted Round Robin)
权重通常代表节点处理能力(或重要性、优先级)。权重信息一般通过注册中心传递:服务端启动时把自己的权重注册进去,客户端服务发现时拿到。
普通加权轮询的缺陷:权重高的节点会连续收到多个请求。例如 A:B:C = 1:2:3,序列就是 A B B C C C,B 和 C 都被打成连续命中,瞬时压力大。
2.3 平滑加权轮询(Smooth WRR)—— 高频面试题
引入 currentWeight(当前权重)概念,每个节点有两个权重:初始权重 weight 和当前权重 currentWeight。算法步骤:
- 计算所有节点初始权重的和
totalWeight - 每个节点的
currentWeight += weight - 选
currentWeight最大的节点 - 选中节点的
currentWeight -= totalWeight
这个算法就是 Nginx 的默认 LB 算法,gRPC、Kratos 都有实现。面试要求手写。
flowchart TD
S[开始 Pick] --> T[每个节点 currentWeight += weight]
T --> M[选 currentWeight 最大的节点]
M --> D[选中节点 currentWeight -= totalWeight]
D --> R[返回该节点]package wrr
import "sync"
// WeightNode 是平滑加权轮询中的一个节点
type WeightNode struct {
Node string // 节点标识(如 addr)
Weight int64 // 初始权重(也叫有效权重),不会变
CurrentWeight int64 // 当前权重,每次 Pick 都会变化
}
// SmoothWRR 平滑加权轮询
type SmoothWRR struct {
mu sync.Mutex
nodes []*WeightNode
}
func NewSmoothWRR(nodes []*WeightNode) *SmoothWRR {
return &SmoothWRR{nodes: nodes}
}
// Pick 挑选一个节点
func (w *SmoothWRR) Pick() string {
w.mu.Lock()
defer w.mu.Unlock()
// 步骤 1:计算总权重,同时让每个节点的 currentWeight += weight
var total int64
for _, n := range w.nodes {
total += n.Weight
n.CurrentWeight += n.Weight
}
// 步骤 2:找到 currentWeight 最大的节点
max := w.nodes[0]
for _, n := range w.nodes[1:] {
if n.CurrentWeight > max.CurrentWeight {
max = n
}
}
// 步骤 3:选中节点 currentWeight -= total
max.CurrentWeight -= total
return max.Node
}
举例 A:B:C = 5:1:1,连续 7 次 Pick 的序列是 A A B A C A A,分散均匀,不会出现连续命中同一节点的尖刺。
2.4 随机与加权随机
随机算法比轮询还简单:生成随机数取模即可。同样假设节点能力相同、请求资源相同。
加权随机则把权重换算成"区间":
// WeightedRandom 加权随机
type WeightedRandom struct {
nodes []string
weights []int64 // 对应权重
total int64 // 总权重
}
func (w *WeightRandom) Pick() string {
// 步骤 1:生成 [0, total) 的随机数
r := rand.Int63n(w.total)
// 步骤 2:累加权重,落到哪个区间就选哪个节点
var sum int64
for i, weight := range w.weights {
sum += weight
if r < sum {
return w.nodes[i]
}
}
// 步骤 3:理论上不会走到这里,兜底返回最后一个
return w.nodes[len(w.nodes)-1]
}
加权随机没有"平滑"版本——因为本身随机,连续命中的概率本来就低,没必要平滑。
2.5 哈希与一致性哈希
哈希
把业务特征(业务 ID、外键、唯一索引等)算一个哈希值,对节点数取模得到目标节点。可以理解为"用业务特征替代随机数"。
// Hash 简单哈希负载均衡
type Hash struct {
nodes []string
}
func (h *Hash) Pick(key string) string {
// 步骤 1:用 crc32 把 key 算成哈希值
hash := crc32.ChecksumIEEE([]byte(key))
// 步骤 2:对节点数取模,落到固定节点
return h.nodes[hash%uint32(len(h.nodes))]
}
一致性哈希(Consistent Hash)
普通哈希的痛点:节点数变化时,几乎所有请求的落点都会变,缓存大面积失效。
一致性哈希的思路:把哈希值空间组织成一个环(0 ~ 2^32-1),每个节点也计算一个哈希值落在环上。请求 key 算哈希后,顺时针找到的第一个节点就是目标。当节点增删时,只有环上一段区间的请求需要迁移。
flowchart TD
Key[请求 key] --> Hash[计算 crc32 哈希值]
Hash --> Pos[在 0 到 2^32 环上定位]
Pos --> Find[顺时针找到第一个节点]
Find --> Node[目标节点]package consistenthash
import (
"hash/crc32"
"sort"
"strconv"
"sync"
)
// Ring 一致性哈希环
type Ring struct {
mu sync.RWMutex
hashFunc func(data []byte) uint32
replicas int // 虚拟节点数,解决数据倾斜
keys []uint32 // 排好序的哈希值
hashMap map[uint32]string // 哈希值 -> 真实节点
}
func New(replicas int, fn func(data []byte) uint32) *Ring {
if fn == nil {
fn = crc32.ChecksumIEEE
}
return &Ring{
replicas: replicas,
hashFunc: fn,
hashMap: make(map[uint32]string),
}
}
// Add 增加节点,每个真实节点会生成 replicas 个虚拟节点
func (r *Ring) Add(nodes ...string) {
r.mu.Lock()
defer r.mu.Unlock()
// 步骤 1:为每个真实节点生成 replicas 个虚拟节点
for _, node := range nodes {
for i := 0; i < r.replicas; i++ {
// 用 编号 拼接生成虚拟节点哈希
vNode := strconv.Itoa(i) + node
h := r.hashFunc([]byte(vNode))
r.keys = append(r.keys, h)
r.hashMap[h] = node
}
}
// 步骤 2:排序,保证二分查找可用
sort.Slice(r.keys, func(i, j int) bool { return r.keys[i] < r.keys[j] })
}
// Get 根据 key 顺时针找到第一个节点
func (r *Ring) Get(key string) string {
r.mu.RLock()
defer r.mu.RUnlock()
if len(r.keys) == 0 {
return ""
}
// 步骤 1:计算 key 的哈希值
h := r.hashFunc([]byte(key))
// 步骤 2:二分找到第一个 >= h 的哈希值(顺时针)
idx := sort.Search(len(r.keys), func(i int) bool { return r.keys[i] >= h })
// 步骤 3:越界则环绕回环首
if idx == len(r.keys) {
idx = 0
}
return r.hashMap[r.keys[idx]]
}
虚拟节点的作用:节点很少时(比如 3 个),数据倾斜严重。引入虚拟节点(每个真实节点对应 100~200 个虚拟节点)可以让 key 均匀分布。
一致性哈希 + 本地缓存 + Redis 多级缓存是面试初中级岗位的"绝杀"组合,详见 6.2 节。
三、动态负载均衡算法
动态算法的核心是:尝试实时计算节点负载。但实践中"负载"难以定量计算,只能选取一些指标近似:
3.1 最少连接数(Least Connections)
挑选当前连接数最少的节点。缺陷:在连接多路复用(HTTP/2、gRPC)下,连接数少不代表负载低——A 节点 10 个连接复用 100 个请求,B 节点 12 个连接只复用 50 个请求。
3.2 最少活跃数(Least Active Requests)
“活跃数” = 客户端发出去但还没收到响应的请求数。客户端为每个节点维持一个计数器,发请求 +1,收到响应 -1,每次选活跃数最低的节点。比连接数更准确。
3.3 最快响应时间(Response Time)
挑选历史响应最快的节点,可以是平均响应时间或 P99、P999。推荐用平均或 P99。
3.4 微服务框架的局限
上面三种算法都是客户端选择,但客户端只知道自己这边的情况:自己的连接数、自己未收到的响应数、自己测出来的响应时间。客户端无法直接知道服务端的 CPU、内存。要拿到这些指标可以:
- 服务端把指标写入注册中心,注册中心通知客户端
- 服务端在响应中附带自己的指标(如 CPU 利用率)
- 从可观测性平台(Prometheus)拉取
flowchart LR
C[客户端] -->|只知自己侧指标| L[本地计算: 活跃数/响应时间]
S[服务端] -->|上报 CPU/内存| R[注册中心/Prometheus]
R -->|通知| C四、在 gRPC 中接入负载均衡
4.1 gRPC 默认行为:pick_first
gRPC 默认没有负载均衡——它永远选 Resolver 解析出来的第一个节点。即使后端启动了 8090、8091 两个节点,请求也只会打到 8090。
flowchart TD
D1[默认 pick_first] --> P1[永远选 Resolver 第一个节点 8090]
D2[配置 round_robin] --> P2[请求轮流打到 8090 与 8091]4.2 使用内置的 round_robin
通过 WithDefaultServiceConfig 传入一段 JSON 配置即可。注意这是 gRPC 的"垃圾设计"——容易写错:
import (
"google.golang.org/grpc"
"google.golang.org/grpc/balancer/roundrobin"
)
// 注意:JSON 串很容易写错,配置名为 round_robin(下划线)
// roundrobin.Name 的值就是 "round_robin"
conn, err := grpc.Dial(
"etcd:///service.user",
grpc.WithDefaultServiceConfig(`{"loadBalancingConfig":[{"`+roundrobin.Name+`":{}}]}`),
)
round_robin 的实现核心是利用原子操作更新候选节点下标。balancer.SubConn 虽然叫 Conn,实际代表"一个节点"——也就是连到这个节点的连接池。
4.3 使用内置的 weightedroundrobin
需要两步:
- 匿名引入包
google.golang.org/grpc/balancer/weightedroundrobin,触发其init()完成注册 - 客户端启动时指定使用
weightedroundrobin
import (
_ "google.golang.org/grpc/balancer/weightedroundrobin" // 匿名引入,触发 init
"google.golang.org/grpc"
)
conn, err := grpc.Dial(
"etcd:///service.user",
grpc.WithDefaultServiceConfig(`{"loadBalancingConfig":[{"weightedroundrobin":{}}]}`),
)
关键:gRPC 的 weightedroundrobin 并不是从服务端读权重,而是客户端主动计算——根据每个节点的 QPS、错误比率算出权重,再用 EDF(Earliest Deadline First)算法挑节点。QPS 越高权重越大,错误率越高权重越低。
4.4 gRPC 内置 balancer 一览
在 google.golang.org/grpc/balancer 子包下:
| 名称 | 说明 | 建议 |
|---|---|---|
roundrobin | 轮询 | 推荐 |
weightedroundrobin | 加权轮询(客户端自适应算权重) | 推荐 |
grpclb | 用于集成外部 LB 的实现 | 不用 |
leastrequest | 最少请求数,实验特性 | 不要用 |
rls | 集成限流的 LB,实验特性 | 不要用 |
总结:除了
roundrobin其他基本都不用。Kratos、go-zero 等框架都自带更好的实现。
4.5 自定义负载均衡算法
实现一个自定义算法要实现两个接口:
balancer.PickerBuilder:根据可用连接构造 Pickerbalancer.Picker:每次请求Pick一个节点
下面是基于权重的平滑加权轮询完整示例(服务端通过 metadata 把权重注册进注册中心)。
flowchart TD
Req[请求到达 gRPC] --> Build[PickerBuilder 用可用连接构造 Picker]
Build --> Pick[Pick: 选 currentWeight 最高节点]
Pick --> Handle[handler 处理请求]
Handle --> Done[Done 回调: 成功则加权重 / 失败则降权重]Step 1:服务端注册时带上权重
// 服务端启动时把权重塞到 metadata
// 这里 weight=5 表示这个节点权重为 5
r, _ := etcd.New(client)
server := etcd.NewServer(
r,
service,
etcd.WithAddress(":8090"),
etcd.WithWeight(5), // 自定义选项,写入 metadata["weight"] = "5"
)
Step 2:定义节点结构体和 Picker
package mywrr
import (
"context"
"sync"
"google.golang.org/grpc/balancer"
"google.golang.org/grpc/resolver"
)
// weightConn 把 balancer.SubConn 和权重绑在一起
type weightConn struct {
SubConn balancer.SubConn
Weight int64 // 初始权重
CurrentWeight int64 // 当前权重
}
// Picker 实现 balancer.Picker
type Picker struct {
mu sync.Mutex
conns []*weightConn
}
// Pick 每次调用选一个节点
func (p *Picker) Pick(info balancer.PickInfo) (balancer.PickResult, error) {
p.mu.Lock()
defer p.mu.Unlock()
// 步骤 1:无可用连接直接报错
if len(p.conns) == 0 {
return balancer.PickResult{}, balancer.ErrNoSubConnAvailable
}
// 步骤 2:每个节点 currentWeight += weight,并累加 total
var total int64
for _, c := range p.conns {
total += c.Weight
c.CurrentWeight += c.Weight
}
// 步骤 3:选 currentWeight 最大的节点
max := p.conns[0]
for _, c := range p.conns[1:] {
if c.CurrentWeight > max.CurrentWeight {
max = c
}
}
// 步骤 4:选中节点扣减 total
max.CurrentWeight -= total
// 返回结果时挂上 Done 回调,可以在这里做 failover / 动态调整权重
return balancer.PickResult{
SubConn: max.SubConn,
Done: func(ctx context.Context, info balancer.PickDoneInfo) {
// 作业:根据 info.Err 动态调整 max.Weight
// 例如:err != nil → 降低权重;err == nil → 提高权重
// 注意设置上下限,避免权重变成 0 或极大值
},
}, nil
}
Step 3:实现 PickerBuilder
// PickerBuilder 在可用连接变化时被调用,重新构造 Picker
type PickerBuilder struct{}
func (b *PickerBuilder) Build(info PickerBuildInfo) balancer.Picker {
if len(info.ReadySCs) == 0 {
return base.NewErrPicker(balancer.ErrNoSubConnAvailable)
}
// 步骤 1:遍历所有就绪连接,取出权重
var conns []*weightConn
for sc, sci := range info.ReadySCs {
// 注意:Metadata 字段已被弃用,新版本应从 Address.Attributes 取
// 不同注册中心实现可能不一样,要确认拿到的类型
w, _ := sci.Address.Metadata.(map[string]any)["weight"].(int64)
if w <= 0 {
w = 1 // 默认权重
}
conns = append(conns, &weightConn{
SubConn: sc,
Weight: w,
})
}
// 步骤 2:用连接列表构造 Picker
return &Picker{conns: conns}
}
Step 4:注册到 gRPC
package mywrr
import (
"google.golang.org/grpc/balancer"
"google.golang.org/grpc/balancer/base"
)
const Name = "my_smooth_wrr"
func init() {
// 把 PickerBuilder 包成 balancer.Builder 注册进去
// 这种"通过 init 注册"是 Go 常见设计模式,本人不太喜欢,更偏爱 WithResolver 形态
balancer.Register(base.NewBalancerBuilder(Name, &PickerBuilder{}, base.Config{HealthCheck: true}))
}
使用时匿名引入该包,并在 WithDefaultServiceConfig 指定 my_smooth_wrr 即可。
五、Failover:调用失败怎么办
5.1 Failover 的本质:重试 + 负载均衡
当下游某节点崩溃、网络异常、或服务下线还没收到注册中心通知时,要做 failover。gRPC 中最简单的 failover 思路:重试 + 负载均衡——重试时再次走 LB,自然就换了一个节点。
sequenceDiagram
participant C as 客户端
participant LB as 负载均衡
participant N1 as 节点1 故障
participant N2 as 节点2 正常
C->>LB: 发起请求
LB->>N1: 第 1 次转发
N1-->>C: 失败
C->>LB: 重试(再次走 LB)
LB->>N2: 第 2 次转发(换节点)
N2-->>C: 成功5.2 通过 methodConfig 配置重试
// 重试策略通过 JSON 配置,手写极易出错,仔细检查
svcCfg := `{
"methodConfig": [{
"name": [{"service": "user.v1.UserService"}],
"retryPolicy": {
"maxAttempts": 4,
"initialBackoff": "0.1s",
"maxBackoff": "1s",
"backoffMultiplier": 2.0,
"retryableStatusCodes": ["UNAVAILABLE", "RESOURCE_EXHAUSTED"]
}
}]
}`
conn, _ := grpc.Dial(
"etcd:///service.user",
grpc.WithDefaultServiceConfig(svcCfg),
grpc.WithDefaultServiceConfig(`{"loadBalancingConfig":[{"round_robin":{}}]}`),
)
字段含义:
name:指定方法(这里覆盖整个 UserService),service 值要去 proto 编译后的 .pb.go 找maxAttempts:最大尝试次数(含首次)initialBackoff/maxBackoff/backoffMultiplier:重试间隔指数退避
⚠️ 新手必踩的坑:重试放大效应。一个请求最多重试 N 次,链路纵深时层层重试会把流量放大 N 倍。比如 A→B→C 三层每层重试 3 次,最坏流量放大 27 倍,直接把下游打挂。必须配合链路超时控制(见第14章)。
5.3 进阶 Failover:在 Picker 的 Done 里调整权重
复杂场景下需要自己实现 LB,在 PickResult.Done 回调里嵌入 failover 逻辑。例如收到错误时把 currentWeight 调到极低,或直接把节点踢出可用列表。面试很好用,生产较少用。
六、主流框架的 LB 方案与面试亮点
6.1 Kratos / go-zero 一览
| 框架 | 默认算法 | 说明 |
|---|---|---|
| Kratos | 平滑加权轮询 | LB 被抽象为 Selector,支持 filter 做分组/路由 |
| go-zero | P2C | 框架内部写死,要改得用 WithDefaultServiceConfig 覆盖 |
Kratos 支持的算法:WRR(默认)、P2C(Pick of 2 choices:随机选 2 个,再挑负载低的)、Random。
不管哪个框架,只要用 gRPC,都绕不开
PickerBuilder+Picker两个接口,也都借助WithDefaultServiceConfig指定算法。
6.2 适合面试的亮点方案
方案一:哈希 LB + 本地缓存 + Redis 多级缓存
如果直接用轮询,本地缓存会出现在多个节点上重复加载的问题。改用一致性哈希:同一个 key 的请求必然命中同一个节点,本地缓存命中率提升、内存消耗下降。适合面试初中级岗位,“有点高级但不至于太高级”。
flowchart LR
K[请求 key] --> H[一致性哈希]
H -->|固定落点| N1[节点1 本地缓存命中]
H -->|固定落点| N2[节点2 本地缓存命中]方案二:结合熔断/限流/降级的 Failover
客户端发现服务端触发了熔断/限流/降级时,一段时间内不选这个节点。可以在 Done 回调里把权重调到极低(结合第14章内容)。
方案三:动态判定负载
客户端维持每个节点的响应时间、错误率等指标,选负载最低的节点。负载指标来源:服务端上报注册中心 / 响应附带 / Prometheus 采集。
七、工程实践要点
- 遇事不决用轮询。出了问题再换,不要过度设计。
- 权重要有上下限。动态调整权重时务必防止权重变成 0(永不选中)、变成极大值(一直选中)。0 和极值会让 LB 完全失效。
- Metadata 字段已弃用。自定义 Picker 时确认数据是从
Address.Metadata拿还是从Address.Attributes拿,并确认拿出来的类型。 - 重试要小心放大效应。一个请求最多重试 N 次,链路纵深时层层重试会把流量放大 N 倍,必须配合链路超时控制。
- 不要为了"均衡"而均衡。如果节点性能差异大,加权才是正确选择。
- gRPC 内置 LB 已能解决绝大多数问题,除非业务有强特征需求(如本地缓存命中),否则没必要自己实现。
八、自测题与动手练习
自测题(合上书能答出来,才算懂):
- 为什么说"负载均衡是手段不是目的"?如果某节点性能明显更好,正确的做法是什么?
- 普通加权轮询 A:B:C = 1:2:3 的序列是什么?平滑加权轮询是怎么解决"连续命中"尖刺的?
- 一致性哈希相比普通哈希解决了什么问题?虚拟节点是干嘛用的?
- 动态算法(最少连接/最少活跃/最快响应)为什么"客户端只能看到自己这一侧"?要拿服务端 CPU/内存怎么办?
- gRPC 默认
pick_first会发生什么?Failover 的本质是什么?重试为什么必须配超时?
动手练习(建议真做一遍):
- 手写平滑加权轮询:实现
SmoothWRR,验证 A:B:C = 5:1:1 连续 7 次 Pick 的序列是A A B A C A A,且不会出现连续命中同一节点。 - 亲手换 LB:用
WithDefaultServiceConfig把默认pick_first换成round_robin,起两个后端节点,打印每次请求落点,确认请求被打散。 - Failover 小实验:在自定义 Picker 的
Done回调里,调用失败时把该节点Weight减半、成功时加 1(设上下限),观察高错误率节点是否逐渐被"冷落"。
九、本章小结
- 负载均衡是"简化模型",目的是选出最适合的节点,而不是机械地追求均衡
- 静态算法(轮询 / 随机 / 哈希)依靠统计学,动态算法(最少连接 / 最少活跃 / 最快响应)依靠实时指标,但客户端看不到服务端真实负载
- 平滑加权轮询是工程最常用的算法,面试要求手写,注意
currentWeight的更新逻辑 - 一致性哈希解决节点增删导致的大面积缓存失效,配合虚拟节点解决数据倾斜
- gRPC 中 LB 通过
PickerBuilder+Picker接口接入,可用WithDefaultServiceConfig指定算法 - Failover = 重试 + 负载均衡,复杂场景可在
Done回调里调整权重或踢出节点 - 面试亮点:哈希 + 本地缓存 + Redis、结合熔断限流的 Failover、动态判定负载
下一章(第14章)我们进入服务治理,把熔断 / 限流 / 降级这套"防雪崩"的组合拳打明白。