负载均衡:算法原理与 gRPC 接入实战

2023-02-24T14:11:02+08:00 | 16分钟阅读 | 更新于 2026-02-24T14:11:02+08:00

@

学习目标

学完本章,你应该能够:

  1. 用"目的而非手段"的视角说清负载均衡在解决什么——它要选出"最适合"处理请求的节点,而不是机械地把流量均分。
  2. 讲清静态算法(轮询 / 加权轮询 / 随机 / 哈希 / 一致性哈希)的原理、Go 实现与适用边界,能手写最经典的平滑加权轮询。
  3. 讲清动态算法(最少连接 / 最少活跃 / 最快响应)的思路,以及"客户端只能看到自己这一侧指标"的固有局限。
  4. 区分客户端负载均衡与服务端负载均衡,并在 gRPC 里用内置的 round_robin / weightedroundrobin,还能自己实现 PickerBuilder + Picker
  5. 把"重试 + 负载均衡"的 Failover 讲成一段可落地的面试亮点方案,并结合熔断/限流做增强。

前置知识(如果下面任意一点生疏,先回看对应章):

  • 第02章 Gin + GORM:知道一个 HTTP 接口怎么写、DAO 怎么分层。
  • 第07章 服务注册与发现(etcd):知道"有哪些可用实例"是怎么来的。
  • Go 并发基础:原子操作 sync/atomicsync.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、SLBgRPC 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 → …

两个隐含假设:

  1. 所有服务器处理能力相同
  2. 所有请求消耗的资源相同

实践中第二个假设经常不成立(比如某个节点恰好收到一个超大请求,直接被打爆),所以轮询偶发不均衡。

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。算法步骤:

  1. 计算所有节点初始权重的和 totalWeight
  2. 每个节点的 currentWeight += weight
  3. currentWeight 最大的节点
  4. 选中节点的 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

需要两步:

  1. 匿名引入google.golang.org/grpc/balancer/weightedroundrobin,触发其 init() 完成注册
  2. 客户端启动时指定使用 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:根据可用连接构造 Picker
  • balancer.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-zeroP2C框架内部写死,要改得用 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 采集。


七、工程实践要点

  1. 遇事不决用轮询。出了问题再换,不要过度设计。
  2. 权重要有上下限。动态调整权重时务必防止权重变成 0(永不选中)、变成极大值(一直选中)。0 和极值会让 LB 完全失效。
  3. Metadata 字段已弃用。自定义 Picker 时确认数据是从 Address.Metadata 拿还是从 Address.Attributes 拿,并确认拿出来的类型。
  4. 重试要小心放大效应。一个请求最多重试 N 次,链路纵深时层层重试会把流量放大 N 倍,必须配合链路超时控制。
  5. 不要为了"均衡"而均衡。如果节点性能差异大,加权才是正确选择。
  6. gRPC 内置 LB 已能解决绝大多数问题,除非业务有强特征需求(如本地缓存命中),否则没必要自己实现。

八、自测题与动手练习

自测题(合上书能答出来,才算懂)

  1. 为什么说"负载均衡是手段不是目的"?如果某节点性能明显更好,正确的做法是什么?
  2. 普通加权轮询 A:B:C = 1:2:3 的序列是什么?平滑加权轮询是怎么解决"连续命中"尖刺的?
  3. 一致性哈希相比普通哈希解决了什么问题?虚拟节点是干嘛用的?
  4. 动态算法(最少连接/最少活跃/最快响应)为什么"客户端只能看到自己这一侧"?要拿服务端 CPU/内存怎么办?
  5. gRPC 默认 pick_first 会发生什么?Failover 的本质是什么?重试为什么必须配超时?

动手练习(建议真做一遍)

  1. 手写平滑加权轮询:实现 SmoothWRR,验证 A:B:C = 5:1:1 连续 7 次 Pick 的序列是 A A B A C A A,且不会出现连续命中同一节点。
  2. 亲手换 LB:用 WithDefaultServiceConfig 把默认 pick_first 换成 round_robin,起两个后端节点,打印每次请求落点,确认请求被打散。
  3. Failover 小实验:在自定义 Picker 的 Done 回调里,调用失败时把该节点 Weight 减半、成功时加 1(设上下限),观察高错误率节点是否逐渐被"冷落"。

九、本章小结

  • 负载均衡是"简化模型",目的是选出最适合的节点,而不是机械地追求均衡
  • 静态算法(轮询 / 随机 / 哈希)依靠统计学,动态算法(最少连接 / 最少活跃 / 最快响应)依靠实时指标,但客户端看不到服务端真实负载
  • 平滑加权轮询是工程最常用的算法,面试要求手写,注意 currentWeight 的更新逻辑
  • 一致性哈希解决节点增删导致的大面积缓存失效,配合虚拟节点解决数据倾斜
  • gRPC 中 LB 通过 PickerBuilder + Picker 接口接入,可用 WithDefaultServiceConfig 指定算法
  • Failover = 重试 + 负载均衡,复杂场景可在 Done 回调里调整权重或踢出节点
  • 面试亮点:哈希 + 本地缓存 + Redis、结合熔断限流的 Failover、动态判定负载

下一章(第14章)我们进入服务治理,把熔断 / 限流 / 降级这套"防雪崩"的组合拳打明白。

About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!