第13章 · 负载均衡:算法原理与 gRPC 接入实战(正式版)

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

@

写在最前面:这一版和普通讲义有什么不同

这是《内容社区》课程第 13 章,假设你已经跟着前 12 章做到了「微服务能互相找到对方」,但没系统学过负载均衡。

这一版做了四件事:① 先讲"为什么"再讲"怎么做"(负载均衡真正的目的是"把请求发给最合适的节点");② 所有新名词当场解释(gRPC、SubConn、Picker、哈希环、虚拟节点、半开恢复……每个都给一句大白话 + 一个生活类比);③ 每一行代码都能跑(第三节讲原理,第四节从零写出 5 种算法 + 健康检查 + gRPC 接入,每步都有文件路径、运行命令、预期输出);④ 章末 12 道面试问答,按「基础 4 / 进阶 4 / 追问 4」三档排列。

想要高密度速查版可以看原版第 13 章 ;两版知识点完全一致,只是讲法不同。


一、本章导读

上一章(第 12 章 · 服务注册与发现 )我们解决了「服务之间怎么找到对方」:服务启动时把地址写进 etcd(一种分布式键值存储,可以理解为微服务们的"公告栏"),调用方从 etcd 拿到一份可用实例名单。

但拿到名单之后,一个更现实的问题立刻冒出来:

名单上有 5 个实例,我这一次请求该发给哪一个?

这就是负载均衡(Load Balance,简称 LB)要解决的问题。

很多人对负载均衡的印象停留在"轮询一下就行",但真实生产环境远比这复杂:

  • 有的机器是 16 核,有的是 4 核——平均分流量会让 4 核的那台先挂
  • 有的请求希望每次都落到同一台机器上(因为那台机器的本地缓存里有这个用户的数据)
  • 有的机器正在慢慢变慢甚至已经半死不活——继续把请求发给它就是雪上加霜
  • 节点随时会上线、下线、扩缩容——你的分发规则不能一改机器就乱套

本章的路线是:先建立"目的感"(第一节),再讲静态算法(轮询 → 加权轮询 → 平滑加权轮询 → 随机 → 哈希 → 一致性哈希),再讲动态算法(最少连接 / 最少活跃 / 最快响应),然后落地到 gRPC(第四节接入实战),最后补上"调用失败怎么办"的 Failover。

其中有两个知识点是本章的硬骨头,也是面试高频:

  • 平滑加权轮询:Nginx 的默认算法,面试要求手写。它解决的是"权重高的节点被连续打爆"这个扎堆问题。
  • 一致性哈希:缓存、分库分表、消息队列分区背后共用的同一套算法。它解决的是"增加一台机器,导致 90% 的缓存全部失效"这个灾难。

另外还有一个反直觉的事实必须提前剧透:gRPC 默认根本不做负载均衡。它的默认策略叫 pick_first——永远选名单里的第一个节点,后端起了 10 个实例,请求也只会打到第 1 个。无数新手(包括很多工作两三年的工程师)在这里踩过坑:明明起了 3 个实例,监控上却只有 1 个有流量。这个点我们会在 3.4 节重点讲。

学完本章,你不仅能手写这几种算法,还能把它们真正接进 gRPC,让项目里的 post-service 调用 user-service 时实现真正的流量分发。

下一章(第 14 章)我们进入服务治理,把熔断 / 限流 / 降级这套「防雪崩」的组合拳打明白——本章第 6 步写的健康检查,其实就是熔断器的雏形。

开读前先看一眼全局:注册发现解决了"服务在哪",本章解决"发给谁"。核心是一张策略选择图:随机 / 轮询追求均匀,一致性哈希追求"同一个 key 总落到同一台",而健康检查负责把坏节点从候选名单里踢出去。

flowchart LR
    A["调用方拿到实例列表"] --> B{"选择负载均衡策略"}
    B -->|"随机 / 轮询 / 加权"| C["请求均匀分散"]
    B -->|"一致性哈希"| D["同一 key 固定路由
扩缩容只移动一小段"] C --> E{"健康检查"} D --> E E -->|"通过"| F["发往目标实例"] E -->|"失败"| G["剔除坏节点
请求换到别的实例"]

这张图怎么读:请求从「调用方拿到实例列表」进入「选择负载均衡策略」菱形,分两条路线——走「随机 / 轮询 / 加权」这条边,请求被均匀分散到各实例;走「一致性哈希」这条边,同一个 key 固定路由到同一实例,扩缩容时只移动一小段数据。两条路线最后都要过「健康检查」这道闸:通过才「发往目标实例」;失败则落入右下角的兜底分支——把坏节点从候选里剔除,请求换到别的实例重发。


二、本章知识点大纲

先把这张地图看一遍,知道这一章要往哪走、走到哪算够。

必会(学完必须能独立写出来)

  1. 手写一个平滑加权轮询(Smooth WRR),并能手推出 A:B:C = 5:1:1 时连续 7 次的序列是 A A B A C A A。
  2. 手写一个一致性哈希环(含虚拟节点 + 二分查找),并写单测验证「同一个 key 永远落在同一个节点」。
  3. 用 WithDefaultServiceConfig 把 gRPC 默认的 pick_first 换成 round_robin,并解释为什么要这么配。
  4. 实现 gRPC 的 PickerBuilder + Picker 两个接口,把自定义算法注册成一个新的负载均衡策略。
  5. 写出一个「连续 3 次失败剔除、10 秒后半开放一个试探请求」的健康检查状态机。

理解(能讲清原理和取舍,不要求背代码)

  1. 为什么说「负载均衡是手段不是目的」——真正的目的是选出"最适合"处理这个请求的节点。
  2. 客户端负载均衡与服务端负载均衡的差异,以及为什么微服务时代客户端 LB 成了主流。
  3. 静态算法(轮询/随机/哈希)与动态算法(最少连接/最少活跃/最快响应)的分界:前者靠统计学,后者靠指标。
  4. 动态算法的固有局限:客户端只能看到自己这一侧的指标,看不到服务端真实 CPU / 内存。
  5. Failover 的本质是「重试 + 负载均衡」,以及为什么重试必须配超时和退避(重试放大效应)。

了解(知道有这回事,面试能说出名字和一句话定位)

  1. gRPC 内置 balancer 一览:roundrobin / weightedroundrobin / grpclb / leastrequest / rls,以及"除了前两个其他基本不用"的结论。
  2. 主流框架的 LB 方案:Kratos 的 Selector(默认平滑 WRR)、go-zero 的 P2C(随机挑两个再选负载低的)。
  3. gRPC 更顶级的 balancer.Balancer 接口与 Picker 的分工——99% 的场景只需要写 Picker。

前置依赖与补课清单

需要你已掌握本章会不会补补在哪一节
Go 基础语法:结构体、方法、interface❌ 不补,这是真前提(见第 01 章)—
sync.Mutex / sync.RWMutex / sync/atomic✅ 会补,用到时当场讲3.2.1、3.2.3
sort.Search 二分查找✅ 会补3.2.5
服务注册与发现(etcd / Resolver)✅ 会补一个胶囊3.0 节
gRPC 基本概念(Stub / Channel / SubConn)✅ 会补一个胶囊3.4 节
Go 的 init() 与匿名 import _ "xxx"✅ 会补一个胶囊3.4.3 节
哈希函数(crc32)✅ 会补一个胶囊3.2.5

不需要你提前懂的:Nginx 配置、K8s Service、Istio / Service Mesh、Envoy。本章只讲「代码层面怎么把请求分发出去」。

预计时长与学习成果

  • 预计时长:4 小时(精读 + 跟着敲完全部代码);如果只求原理主干,1.5 小时可过一遍。

  • 学完你能跑出来的东西(这是本章的验收标准,做到才算过关):

    一个 pkg/balancer 包:内含轮询、平滑加权轮询、随机、最少连接、一致性哈希 5 种算法 + 一个健康检查器,全部实现统一的 Balancer 接口;其中平滑加权轮询和一致性哈希各有单测;最后注册成 gRPC 的 weighted_round_robin 和 consistent_hash 两个策略,起两个实例实测「权重 5:1 时 7 次请求分布为 5/2 且不连续命中」与「同一个 user_id 永远落同一台」。跑 go test ./pkg/balancer/... -v 全绿。

flowchart LR
    A["3.1 目的与客户端/服务端 LB"] --> B["3.2 静态算法
轮询→加权→平滑WRR→随机→哈希"] B --> C["3.3 动态算法
最少连接/活跃/最快响应"] C --> D["3.4 gRPC 接入
pick_first→round_robin→自定义 Picker"] D --> E["3.5 Failover
重试+LB+Done 调权重"] E --> F["3.6 框架方案与面试亮点"] F --> G["第四节 跟着写
6 步落地 pkg/balancer"] G --> H["第七节 面试问答 12 题"]

这张图怎么读:主线从「3.1 目的与客户端/服务端 LB」出发,先过静态算法(3.2:轮询→加权→平滑 WRR→随机→哈希),再过动态算法(3.3),然后到本章落点——gRPC 接入(3.4:pick_first→round_robin→自定义 Picker),接着补上失败兜底 Failover(3.5)与框架方案(3.6);之后转入「第四节 跟着写」的 6 步落地,最后以 12 道面试问答收口。每条边就是「读完上一节再读下一节」的线性顺序,没有分支。

负载均衡:选最合适的节点目的与模型LB 是手段非目的负载最轻=最合适客户端 vs 服务端静态算法轮询 / 加权轮询平滑 WRR(手写)哈希 / 一致性哈希动态算法最少连接 / 活跃最快响应时间只看自己这一侧gRPC 接入默认 pick_firstround_robin / WRRPickerBuilder+PickerFailover重试 + 负载均衡重试放大效应坑Done 里调权重贯穿本章的一条主线:名单从哪来(第 12 章)→发给名单里的谁(本章)→ 发出去失败了怎么办(第 14 章)
复习提示:

怎么读这一章:每一节都按同一个节奏推进——先抛问题 → 给个直觉(类比或图)→ 最小可运行代码 → 逐行讲解 → 常见坑 → 小收口。如果你只想快速过一遍,可以只读每节的"抛问题"和"小收口";如果要真学会,就把第四节「跟着写」的 6 步全部敲一遍。

难度提示:3.2.3(平滑加权轮询)和 3.2.5(一致性哈希)是本章的两个高峰,也是面试最爱问的两块。如果时间紧张,这两节务必一次啃透,其余小节可以先泛读再回看。


三、正文

3.0 补课:上一章我们做到哪了

在进入新内容之前,先用两分钟对齐一下"你现在手上有什么",否则后面的代码会像凭空冒出来。

补课胶囊 · 服务注册与发现(etcd + Resolver) 一句话:服务启动时把"我是谁、我在哪"写到公告栏(etcd)上,调用方去公告栏抄一份名单回来。 类比:开分店。每家新店开张就去工商局的公告栏贴一张纸条「xx 分店,地址 A,电话 1」;总店要找分店时,去看公告栏抄一份完整名单;分店关门了,纸条会自动过期消失(靠"租约/心跳"机制——纸条有有效期,分店要不停地续期,续不上就自动撕掉)。 最小示例:第 12 章结束时,你的项目里已经有这样一个结构(本章会直接复用它):

// pkg/registry/registry.go(第 12 章已存在,本章直接复用)
package registry

// ServiceInstance 是一个服务实例的完整信息
type ServiceInstance struct {
    InstanceID string // 实例唯一 ID,如 "user-service-7f3a"
    Name       string // 服务名,如 "user-service"
    Address    string // 地址,如 "127.0.0.1"
    Port       int    // 端口,如 9090
    Weight     int    // 权重,本章的主角之一
}

// Addr 返回 "ip:port" 形式的地址
func (s *ServiceInstance) Addr() string {
    return net.JoinHostPort(s.Address, strconv.Itoa(s.Port))
}

回到主线:注意那个 Weight 字段——它就是为本章准备的。上一章我们注册时顺手把它写进了 etcd,但一直没用到。本章的加权算法会把它真正用起来。

上一章我们做的 Resolver(解析器)是 gRPC 的一个扩展点,它的职责只有一件事:把服务名(如 user-service)翻译成一串地址(如 ["127.0.0.1:9090", "127.0.0.1:9091"]),并在地址变化时通知 gRPC。

复习提示:

⚠️ 一个非常容易混淆的分工,请务必记牢:

组件回答的问题类比
服务注册我是谁,我在哪(服务端主动上报)分店去公告栏贴纸条
服务发现 / Resolver现在有哪些可用实例?(拿到名单)总店去公告栏抄名单
负载均衡 / Balancer这一次请求发给名单里的哪一个?(做选择)迎宾决定客人坐哪一桌

很多人把"服务发现"和"负载均衡"混为一谈,是因为在 gRPC 里它们都跟 grpc.Dial 那一行代码有关。但它们的职责完全不同:Resolver 管名单,Balancer 管选择。本章只讲后者。


3.1 核心概念:负载均衡到底在解决什么问题

先抛问题

假设你的 user-service 起了 3 个实例,名单拿到了:

127.0.0.1:9090  (16 核 32 G,刚启动,闲得发慌)
127.0.0.1:9091  (4 核 8 G,已经跑了三天,内存快满了)
127.0.0.1:9092  (16 核 32 G,但磁盘 IO 正在打满)

现在来了 100 个请求。如果随便发,会发生什么?

  • 全发给 9091 → 4 核机器先挂,剩下的流量再压垮另外两台,最后全站雪崩(这就是级联故障)
  • 平均分给三台 → 9091 还是先挂,因为它只有 4 核
  • 全都发给 9090 → 9090 迟早也挂,另外两台在旁边看戏

所以核心问题不是"怎么平均分",而是:

怎么把每一个请求,都送到"当下最合适处理它"的那台机器上?

给个直觉:超市收银台

补课胶囊 · 负载均衡(Load Balance,LB) 一句话:从一堆能干同样活的节点里,挑一个最合适的来干活。 类比:超市的收银台排队。周末超市开了 5 个收银台,你推着满满一车东西走进去——

  • 如果由超市广播安排:“下一位顾客请到 3 号台”,顾客只需要听话照做 → 这就是服务端负载均衡(有个集中的调度者)
  • 如果每个顾客自己看“哪个队最短、哪个收银员手脚最快"然后自己走过去 → 这就是客户端负载均衡(每个调用方自己算)

一个好的人工引导员不会机械地喊"1 号、2 号、3 号、4 号、5 号"轮着来。他会看:3 号台那位顾客买了一整车(大请求),先别排给他;5 号台的收银员是新来的动作慢,少排点;2 号台刚有个顾客在闹退货(这台机器在报错),暂时别排。

回到主线:这个"引导员"就是负载均衡器。它的 KPI 是让整个超市的结账效率最高,而不是"必须每个台轮到一样多的顾客”。

3.1.1 负载均衡是手段,不是目的

这是本章第一句要背下来的话:

负载均衡只是手段,真正的目的是把请求转发给"最适合"处理它的节点。

什么叫"最适合"?举几个例子你就懂了:

场景“最适合"的节点为什么
这是个内存密集型请求(比如要加载一个大榜单)内存剩余最多的节点内存不够会 OOM(内存溢出,进程被系统杀掉)
这是个 CPU 密集型请求(比如要算一堆推荐分数)CPU 最空闲的节点CPU 打满会让其他请求排队
这是个读多写少的请求,而且节点有本地缓存缓存里正好有这个 key 的节点缓存命中就不用查库,快几十倍
大多数普通请求能最快返回响应的节点响应时间是最通用的"综合负载"指标
跨机房调用同机房 / 同城的节点网络延迟低一个数量级
复习提示:

⚠️ 新手最容易犯的思维错误:把"均衡"当成了 KPI 本身。

如果 A 机器是 16 核、B 机器是 4 核,那么让 A 接 4 倍于 B 的流量,才是均衡的——因为两台机器的"相对压力"是一样的。强行 1:1 平均分配,反而会让 B 先挂。

这就是为什么几乎所有生产级负载均衡器都支持权重:权重就是你对"这台机器能力有多强"的声明。

3.1.2 负载均衡是一个"简化模型”

现在问题来了:“最适合"这个词太主观了,怎么让机器判断?

工程上的做法是建立一个简化模型:

我们认为:负载最轻的节点,就是最适合的节点。

这个简化带来两个推论:

  1. 既然所有节点提供的服务是等价的,那么每次都选负载最轻的,长此以往所有节点的负载会大致均衡——“均衡"是结果,不是目标。
  2. 于是"选最合适的节点"这个复杂问题,被简化成了”量化负载,选最小的那个“这个可计算的问题。
flowchart LR
    R[用户请求] --> LB{负载均衡器}
    LB -->|选负载最轻| N1[节点 A
内存空闲 负载 20] LB -->|选负载最轻| N2[节点 B
内存吃紧 负载 85] LB -->|选负载最轻| N3[节点 C
CPU 空闲 负载 15] N3 -.->|"√ 选中"| OK[返回响应]

用一句话总结这个模型:

目的:选最适合处理请求的节点
  ↓ 简化
标准:选负载最轻的节点
  ↓ 副作用(不是目标)
结果:所有节点负载大致均衡
复习提示:

🔑 这个"简化"的连锁反应,是理解后面所有算法的钥匙:

  • 静态算法(轮询、随机、哈希)说:“负载算不准,那我不算了,靠统计学——请求量足够大时自然就均衡了。"(有点"摆烂”,但真的好用)
  • 动态算法(最少连接、最快响应)说:“我偏要算,我用一个近似的指标代表负载。”
  • 加权算法说:“节点能力不一样,我用一个权重来表达能力差异。”

三种思路没有优劣,只有适用场景不同。3.2 和 3.3 节就沿着这三条路走一遍。

3.1.3 客户端负载均衡 vs 服务端负载均衡

这是面试必考的对比题。先给结论,再讲为什么。

维度服务端负载均衡(Nginx / LVS / F5 / 云 SLB)客户端负载均衡(gRPC / Dubbo / Ribbon)
决策位置一个独立的 LB 节点(或集群)调用方自己的进程里
流量路径客户端 → LB → 服务端(多一跳)客户端 → 服务端(直连,少一跳)
性能瓶颈LB 本身可能成为瓶颈,要堆机器 / 上 LVS无独立瓶颈,每个调用方自己算
实例名单从哪来LB 上写死一份配置,或 LB 自己去注册中心拉调用方从注册中心拉(天然和注册中心打通)
算法灵活性受 LB 软件限制(Nginx 就那几种)想怎么写怎么写,代码在你手里
多语言支持天然支持,LB 不关心你后端用什么语言每种语言都要实现一遍
运维复杂度要专门运维 LB(高可用、扩容)无额外组件,但 SDK 升级要推动所有业务方
典型代表Nginx、LVS、F5、云厂商 SLBgRPC Picker、Netflix Ribbon、Dubbo LoadBalance、Kratos Selector
flowchart TB
    subgraph S1["服务端 LB:多一跳,LB 是集中决策点"]
        C1[客户端 A] --> SLB[Nginx / LVS
独立部署] C2[客户端 B] --> SLB SLB --> X1[实例 1] SLB --> X2[实例 2] SLB --> X3[实例 3] end subgraph S2["客户端 LB:直连,每个客户端自己决策"] CL1[客户端 A
内置 LB] --> Y1[实例 1] CL2[客户端 B
内置 LB] --> Y2[实例 2] CL1 --> Y2 CL2 --> Y1 CL1 --> Y3[实例 3] end

为什么微服务时代客户端 LB 成了主流? 三个理由:

  1. 少一跳网络。微服务之间调用量是天文数字(一个外部请求可能扇出几十次内部调用),每一次都绕一层 Nginx,延迟和带宽成本都扛不住。
  2. 没有单点瓶颈。服务端 LB 要处理所有流量,必须堆机器、做高可用;客户端 LB 的计算分散在每个调用方进程里,加机器就自动加算力。
  3. 和注册中心天然配合。微服务实例变化频繁(扩缩容、发布、故障重启),客户端 LB 直接用注册中心推过来的实时名单,服务端 LB 还得自己再对接一遍注册中心。
复习提示:

⚠️ 别走极端。客户端 LB 不是"服务端 LB 的替代品",两者常常同时存在:

用户浏览器
   ↓
云 SLB / Nginx(服务端 LB:把外部流量分给网关集群)
   ↓
API Gateway
   ↓  ← 从这里开始进入内网微服务世界
gRPC 客户端 LB(客户端 LB:网关直接连到后端的 N 个实例)
   ↓
user-service 实例 1 / 2 / 3

外部流量用服务端 LB(因为客户端是浏览器,你没法在浏览器里装 SDK);内部服务间调用用客户端 LB(因为调用方是你自己的服务,想装什么装什么)。这在业内叫「南北流量 vs 东西流量」——南北(外部进来的)用服务端 LB,东西(内部横穿的)用客户端 LB。

小收口:这一节我们建立了一个认知框架——负载均衡目的是选最合适的节点,手段是量化"负载"并选最轻的,落地方式分服务端 LB 和客户端 LB 两种。本章后续全部代码都是客户端 LB(跑在 gRPC 调用方进程里)。


3.2 静态负载均衡算法

先抛问题

上一节说了,要想选出"负载最轻"的节点,就得先量化负载。但现实很骨感:

  • 你在客户端,你怎么知道服务端那台机器的 CPU 是多少?内存还剩多少?
  • 就算服务端愿意告诉你,那也要一次额外的网络交互——为了选节点先问一圈,这开销比请求本身还大。

于是就有了静态算法的思路:

既然算不准真实负载,那我就不算了。我用一套固定的规则分发,请求量足够大时,按统计学规律自然就均衡了。

用大白话说就是"摆烂”——但这个摆烂在 90% 的场景下都够用,而且实现简单、无状态、性能极好。

静态算法全家福(本章会逐一实现):

算法核心思路考虑节点能力差异?是否有"会话保持”本节位置
轮询 RoundRobin依次轮流❌❌3.2.1
加权轮询 WRR按权重比例轮流✅❌3.2.2
平滑加权轮询 Smooth WRR按权重比例且分散地轮流✅❌3.2.3
随机 Random随机挑一个❌❌3.2.4
加权随机 Weighted Random按权重划区间,随机落区间✅❌3.2.4
哈希 Hash对 key 取模❌✅3.2.5
加权哈希 Weighted Hash按权重划区间,key 的哈希落区间✅✅3.2.5
一致性哈希 Consistent Hash哈希环 + 顺时针找节点⚠️ 靠虚拟节点✅3.2.5

补课胶囊 · 会话保持(Session Stickiness / Session Affinity) 一句话:同一个用户的请求,尽量每次都落到同一台机器上。 类比:你常去的那家理发店,每次都找同一个 Tony 老师——他记得你的发型偏好(本地缓存),不用每次重新沟通。如果每次去都换个陌生老师,体验会差很多。 为什么需要:节点上可能有本地缓存(进程内缓存,如 Go 的 sync.Map 或 bigcache)。如果同一个用户的请求每次落到不同节点,每个节点都要各自缓存一份同样的数据,缓存命中率暴跌、内存翻倍。 回到主线:轮询和随机没有会话保持;哈希类算法有。这是选型时的重要分水岭。

3.2.1 轮询(Round Robin)

先抛问题

最简单也最常用的算法。候选节点列表 [A, B, C],请求依次落到 A → B → C → A → B → C → …

它成立依赖于两个隐含假设:

假设内容现实中成立吗
假设 1所有服务器的处理能力相同⚠️ 经常不成立(机器配置不同、混部了别的进程)
假设 2所有请求消耗的资源相同❌ 基本不成立(有的请求查 1 条数据,有的查 10 万条)

假设 2 是轮询最大的软肋:某个节点恰好收到一个大请求(比如导出全表),它就被打爆了,而另外两台闲着。所以它只能保证"请求数均衡",保证不了"负载均衡"。

给个直觉
flowchart LR
    Q[请求流] --> RR["轮询计数器
counter++"] RR -->|第 1 个| A[节点 A] RR -->|第 2 个| B[节点 B] RR -->|第 3 个| C[节点 C] RR -->|第 4 个| A RR -->|第 5 个| B

就像打牌轮流发牌:牌(请求)一张一张按顺序发给每个人(节点),发完一轮从头再来。

最小可运行示例

新建一个临时目录试跑(不影响项目):

// 文件路径: ~/code/lb-demo/main.go
package main

import (
	"fmt"
	"sync/atomic"
)

// RoundRobin 最简轮询实现:原子自增 + 取模
type RoundRobin struct {
	nodes   []string
	counter uint64
}

// Pick 挑出一个节点
func (r *RoundRobin) Pick() string {
	// 步骤 1:原子加 1,避免多个 goroutine 同时 Pick 时计数错乱
	n := atomic.AddUint64(&r.counter, 1)
	// 步骤 2:减 1 后取模,映射到节点下标(这样第一次拿到的是下标 0)
	idx := (n - 1) % uint64(len(r.nodes))
	// 步骤 3:返回对应节点
	return r.nodes[idx]
}

func main() {
	r := &RoundRobin{nodes: []string{"A", "B", "C"}}
	for i := 0; i < 7; i++ {
		fmt.Printf("第 %d 个请求 -> %s\n", i+1, r.Pick())
	}
}

运行:

cd ~/code/lb-demo && go run main.go

预期输出:

第 1 个请求 -> A
第 2 个请求 -> B
第 3 个请求 -> C
第 4 个请求 -> A
第 5 个请求 -> B
第 6 个请求 -> C
第 7 个请求 -> A
逐行讲解
行代码为什么这么写
9-12counter uint64计数器。用 uint64 是因为 atomic 包对它有原生的 AddUint64 支持,且不会溢出成负数
16atomic.AddUint64(&r.counter, 1)原子自增。如果写成 r.counter++,多个 goroutine 并发调用时会丢计数(不是线程安全),导致分布不均
18(n - 1) % len为什么要减 1?因为 AddUint64 返回的是加完之后的值(第一次返回 1),不减 1 的话第一次会拿到下标 1(节点 B),序列变成 B C A B C A
19return r.nodes[idx]取模保证下标永远落在 [0, len) 范围内

补课胶囊 · 原子操作(sync/atomic) 一句话:一条 CPU 指令就能完成、不会被其他 goroutine 打断的操作。 类比:超市的取号机。你按一下,号码必然加一,绝不会出现两个人拿到同一个号——因为"读号码 + 加一 + 写回"这三步在硬件层面是一个不可分割的整体。而手写 counter++ 就像两个人同时伸手去翻纸质号码本,可能都看到"38"、都写成"39"。 最小示例:

var n int64
atomic.AddInt64(&n, 1)      // 原子 +1,返回新值
atomic.LoadInt64(&n)        // 原子读取
atomic.CompareAndSwapInt64(&n, 1, 2) // CAS:如果当前是 1 就改成 2

回到主线:轮询只需要一个"安全的自增",用 atomic 比用 sync.Mutex 加锁快一个数量级(无锁),这是 LB 这种高频调用场景的标准做法。

常见坑
复习提示:

⚠️ 坑 1 · 忘了减 1,第一个请求打到第二个节点

n := atomic.AddUint64(&r.counter, 1)
idx := n % uint64(len(r.nodes)) // ← 忘了减 1

序列会变成 B C A B C A。在"第一台机器配置最好"的场景下(很多人把权重最高的放第一个),这会导致流量全被打到配置差的机器上。这类 bug 很难发现——不报错,只是分布"怪怪的"。

⚠️ 坑 2 · 节点列表被并发替换,取模时下标越界

Resolver 推送新名单时直接 r.nodes = newNodes,而此刻另一个 goroutine 正在 Pick(),可能出现"刚取完 len 得到 5,下一瞬列表被换成 3 个元素,然后取下标 4"→ 数组越界 panic。第四节的工程版会用 sync.RWMutex 保护整个 Pick 过程。

⚠️ 坑 3 · 以为轮询能保证"负载"均衡

轮询只保证请求数均衡,一个大请求可能消耗 1000 倍于小请求的资源。存在明显"大小请求"差异的业务(导出、批量导入、大 V 粉丝列表)会出现"某台机器偶发被打爆"——要么换动态算法,要么对大请求单独限流。

小收口:轮询是最简单、性能最好的静态算法,“遇事不决用轮询"是业界公认的经验。但它的两个隐含假设(节点同能力、请求同消耗)在现实中经常不成立——这正是后面"加权"和"动态"两条路线的出发点。

3.2.2 加权轮询(Weighted Round Robin)

先抛问题

轮询的第一个假设"所有服务器处理能力相同"不成立时怎么办?

答:给节点加权重。 权重通常代表节点的处理能力(CPU 核数、内存大小、压测出来的 QPS 上限),也有可能代表重要性或优先级。

补课胶囊 · 权重(Weight) 一句话:一个数字,表示"这个节点应该分到多少比例的流量”。 类比:切蛋糕。三个人的食量比是 5:1:1,那就把蛋糕切成 7 份,第一个人拿 5 份,后两个各拿 1 份——按比例分,而不是平均分成三份。 权重从哪来:通常是服务端自己声明的。服务启动时把自己的权重写进注册中心(etcd),客户端做服务发现时从注册信息里读出来。第 12 章我们已经在 ServiceInstance 里留好了 Weight 字段,就是为了这一刻。 另一种用法(较少见但很实用):在多机房 / 异地多活部署时,给同机房、同城的节点设置高权重,跨机房节点设置低权重——这样请求会优先"就近"访问低延迟节点,跨机房节点只做兜底(比如同城机房全挂时才顶上)。这招叫就近访问 / 区域感知路由。

普通加权轮询怎么做,以及它的致命缺陷

最朴素的做法:把权重展开成一个更长的列表,然后对这个长列表做普通轮询。

比如 A:B:C = 1:2:3,展开成 [A, B, B, C, C, C],然后对这个 6 元素列表轮询:

请求序列:A → B → B → C → C → C → A → B → B → C → C → C → …

流量比例是对的(A 占 1/6,B 占 2/6,C 占 3/6),但你看出来了问题吗?

复习提示:

⚠️ 缺陷:权重高的节点会被"连续"命中,造成瞬时压力尖刺。

上面的序列里,C 会连续收到 3 个请求。如果 C 的权重是 10,那它会连续收到 10 个请求才轮到别人。

在真实流量下这意味着:C 会在某个瞬间被一梭子打过来 10 个请求,队列瞬间堆满、响应时间飙升,而 A 和 B 在旁边闲着。这就是所谓的扎堆问题(Bursting Problem)。

想象一下超市广播喊"1 号台、1 号台、1 号台、1 号台……“连喊十声,1 号台前面瞬间排起长队,而 2 号 5 号台空无一人——这个引导员显然不合格。

平滑加权轮询就是为了解决这个扎堆问题而生的。

小收口

加权轮询解决了"节点能力不同"的问题,但流量比例对了,分布节奏不对——高权重节点会被连续打爆。下一节的平滑加权轮询,就是要做到「比例不变,但高权重的请求被打散开」。

3.2.3 平滑加权轮询(Smooth WRR)—— 本章第一个硬骨头

这是 Nginx 的默认负载均衡算法,gRPC、Kratos、go-zero 都有实现。面试要求手写。如果本章你只记住一个算法,请记住这个。

先抛问题

我们希望:A:B:C = 5:1:1 时,7 个请求里 A 拿 5 个、B 拿 1 个、C 拿 1 个——但 A 的这 5 个不能连在一起,要像撒芝麻一样均匀撒开。

朴素加权轮询给出的是:A A A A A B C(A 连着 5 个,扎堆) 平滑加权轮询给出的是:A A B A C A A(A 的 5 个被打散了)

看出来了吗?比例完全一样,但节奏完全不同。

给个直觉:欠账与还债

算法的想法非常朴素,可以用"欠账"来理解:

每个节点有一个"当前权重"(currentWeight)。每次挑选时,所有节点的 currentWeight 先各自加上自己的 weight(相当于"攒分"),然后谁的分最高谁上。上完之后,这个赢家要扣掉所有人的总分(相当于"还债")。

因为它扣了一大笔(总分),接下来几轮它的分追不上别人,别人就有机会上场。等它慢慢把债还清,分重新涨回最高,它又能上场了。

这个"先攒分、谁高谁上、上了就还债"的循环,天然就把高权重节点的请求打散了。

算法四步

严谨表述(这四步就是面试手写的骨架,背下来):

  1. 计算所有节点初始权重的和 totalWeight
  2. 每个节点的 currentWeight += weight(攒分)
  3. 选出 currentWeight 最大的节点
  4. 选中节点的 currentWeight -= totalWeight(还债),返回它
flowchart TD
    S[开始 Pick] --> T["① 每个节点 currentWeight += weight
同时累加 totalWeight"] T --> M["② 选 currentWeight 最大的节点"] M --> D["③ 选中节点 currentWeight -= totalWeight"] D --> R["④ 返回该节点"] R --> S
手推一遍:A:B:C = 5:1:1,连续 7 次

这是本节最重要的一张表。请拿出纸笔跟着算一遍——算完你就能默写了。totalWeight = 5+1+1 = 7,初始 currentWeight 全为 0。

第几次① 攒分后(A, B, C)② 选中③ 还债后(A, B, C)
0(初始)0, 0, 0——
15, 1, 1A(5 最大)-2, 1, 1
23, 2, 2A(3 最大)-4, 2, 2
31, 3, 3B(B、C 都是 3,取先遍历到的 B)1, -4, 3
46, -3, 4A(6 最大)-1, -3, 4
54, -2, 5C(5 最大)4, -2, -2
69, -1, -1A(9 最大)2, -1, -1
77, 0, 0A(7 最大)0, 0, 0

7 次结果:A A B A C A A —— A 恰好 5 次、B 1 次、C 1 次,而且 A 的 5 次被打散了,最长连续只有 2 次(开头和结尾)。

注意第 7 次结束后状态回到 0, 0, 0,与初始状态完全一致——这说明算法以 7 次为一个周期循环。这不是巧合:只要权重是整数,算法必然在 totalWeight 次调用后回到初始状态。这是一个可证明的数学性质,也是它能被形式化验证的原因。

复习提示:

🔑 手推技巧 · 盯住"欠账"

不要死记每一步的数字,而是记住一句话:谁被选中,谁就被扣掉 7 分(总权重),接下来它得慢慢"攒"回来。

比如第 1 次 A 被选中后变成 -2,它要连攒 2 轮(+5、+5)才能回到正数——所以第 2 次它还能赢(-4+5+5=6?不对,注意第 2 次攒完是 3,第 3 次攒完是 1,B 和 C 已经攒到 3 了,所以第 3 次 A 输了)。

另一个必须知道细节:第 3 次 B 和 C 打平(都是 3)。示例代码用的是严格大于 >,所以保留先遍历到的那个(B)。打平时选谁取决于遍历顺序——这是面试手写时最容易被追问的细节,标准答案是:“打平时行为取决于实现(严格大于则保留先遍历到的),只要保持一致即可,不影响整体分布的正确性。”

代码实现
package wrr

import "sync"

// WeightNode 是平滑加权轮询中的一个节点
type WeightNode struct {
	Node          string // 节点标识(如 "127.0.0.1:9090")
	Weight        int64  // 初始权重(也叫有效权重),构造后不再变化
	CurrentWeight int64  // 当前权重,每次 Pick 都会变化
}

// SmoothWRR 平滑加权轮询(Nginx 同款算法)
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:选中节点"还债",扣掉总权重
	max.CurrentWeight -= total
	return max.Node
}
逐行讲解
行代码为什么这么写
8Weight int64初始权重,构造后不变。用 int64 是为了和后面可能的"动态调整权重"统一(用 int 在 32 位平台上可能溢出)
9CurrentWeight int64当前权重,每次 Pick 都变。它可以是负数(还债后欠账),所以必须用有符号类型
14mu sync.Mutex这里用 Mutex 而不是 atomic,因为 Pick 内部要修改多个节点的 CurrentWeight,这是一个不可分割的整体操作,原子操作搞不定
28-32一个循环同时做两件事累加 total 和 currentWeight += weight 合并成一个循环,减少一次遍历。注意 total 必须在扣减之前算完
36if n.CurrentWeight > max.CurrentWeight严格大于是关键。如果写成 >=,打平时会选后遍历到的节点,序列会变成 A A C A B A A——也对,但要和实现保持一致
43max.CurrentWeight -= total核心的"还债"动作。这一步保证了算法周期性,也保证了分布平滑
复习提示:

🧪 可运行验证:把上面 SmoothWRR 的代码抄进 ~/code/lb-demo/smoothwrr/main.go,循环 7 次 Pick() 并打印每次选中后三个节点的 CurrentWeight,你会依次看到 A=-2 B=1 C=1 → A=-4 B=2 C=2 → A=1 B=-4 C=3 → A=-1 B=-3 C=4 → A=4 B=-2 C=-2 → A=2 B=-1 C=-1 → A=0 B=0 C=0——与上面手推表的第三列完全一致,且第 7 次后回到全 0(周期性)。

如果打印结果对不上,按第四节第 3 步末尾的排查清单逐条检查。

常见坑
复习提示:

⚠️ 坑 1 · 权重配成 0 或负数,节点永远选不中

如果某节点的 Weight = 0,它每轮 += 0,CurrentWeight 永远是 0,永远选不中(除非所有节点都是 0)。负权重更离谱,它会越欠越多。

工程做法:构造时兜底 if w <= 0 { w = 1 }。第四节的工程版代码就是这么做的。

⚠️ 坑 2 · 更新节点列表后没有重置 currentWeight,分布立刻失真

这是生产环境最常见的 bug。服务发现推送新名单后,如果你只替换了节点数组、却保留了旧的 CurrentWeight 数组,那么"旧节点 A 欠的债"会被算到"新节点 D"(同一下标)头上,导致新节点一上线就被冷落,或者旧节点的债被凭空抹掉导致分布突变。

工程做法:UpdateInstances 时重建整个节点列表和 CurrentWeight(第四节第 3 步)。

⚠️ 坑 3 · 面试手写时把 total 算错

有人会把 total 写成"上一次的 total"或者"当前存活节点的权重和(不含被健康检查剔除的)"。标准实现里,total 必须是参与本轮选择的所有节点的权重之和——如果你做了健康检查过滤,total 要跟过滤后的集合保持一致,否则算法周期性会被破坏。第四节的 gRPC Picker 里你会看到这个细节。

小收口:平滑加权轮询 =「攒分 → 选最大 → 还债」三步循环。它用 O(n) 的遍历换来了"比例精确 + 分布平滑",是工程上最常用的加权算法。至此,静态算法里"轮询家族"讲完了,接下来是"随机家族"和"哈希家族"。

3.2.4 随机与加权随机

先抛问题

轮询需要维护一个计数器,还要处理并发安全。有没有更简单的办法,同样能达到"请求量大时自然均衡"的效果?

有——随机。每次请求随机挑一个节点,请求量足够大时,按大数定律(样本量越大,实际频率越接近理论概率),分布自然趋于均匀。

给个直觉

轮询 vs 随机的类比:

  • 轮询 = 老师点名回答问题,按座位号依次数下去,保证每个人被点到的次数精确相等。
  • 随机 = 抽奖箱摸球,摸完放回去。短期内可能连着摸到同一个人,但摸一万次之后,每个人被摸到的次数几乎相等。

随机牺牲了"短期精确性",换来了零状态(不需要计数器,也就没有并发问题)。

代码实现:随机
// Random 随机负载均衡
type Random struct {
	nodes []string
}

func (r *Random) Pick() string {
	// 随机数对节点数取模,得到 [0, len) 的下标
	return r.nodes[rand.Intn(len(r.nodes))]
}
加权随机:把权重画成"区间"

普通随机假设所有节点能力相同。加权随机怎么做?

思路:把权重换成一条数轴上的区间长度,然后随机往数轴上扔一个点,落在谁的区间就选谁。

比如 A:B:C = 5:1:1,总权重 7,那么:

数轴:0        5    6    7
      |--------|----|----|
        A 区间    B     C
       (长度5) (1)  (1)

随机生成一个 [0, 7) 的数:落在 [0,5) 选 A,[5,6) 选 B,[6,7) 选 C。因为 A 的区间占了 5/7 的长度,所以 A 被选中的概率就是 5/7。

flowchart LR
    R["随机数 r ∈ [0, 7)"] --> C1{"r < 5 ?"}
    C1 -->|是| A["选 A(概率 5/7)"]
    C1 -->|否| C2{"r < 6 ?"}
    C2 -->|是| B["选 B(概率 1/7)"]
    C2 -->|否| C["选 C(概率 1/7)"]
// WeightedRandom 加权随机
type WeightedRandom struct {
	nodes   []string
	weights []int64 // 与 nodes 一一对应的权重
	total   int64   // 总权重
}

func (w *WeightedRandom) Pick() string {
	// 步骤 1:生成 [0, total) 的随机数
	r := rand.Int63n(w.total)
	// 步骤 2:累加权重,看 r 落进谁的区间
	var sum int64
	for i, weight := range w.weights {
		sum += weight
		if r < sum { // 首次满足说明 r 落在第 i 个区间内
			return w.nodes[i]
		}
	}
	// 步骤 3:理论上不会走到这里(r < total 必然落在某个区间),兜底返回最后一个
	return w.nodes[len(w.nodes)-1]
}
逐行讲解
行代码为什么这么写
11rand.Int63n(w.total)生成 [0, total) 的随机整数。注意是左闭右开,所以 r 最大是 total-1,必然小于 sum 的终值
15-17sum += weight; if r < sum前缀和技巧:累加到某个节点时,sum 就是"从 0 到这个节点的区间右端点"。r < sum 说明 r 落在这个区间内
21兜底 return防御性编程。理论上不可达,但如果 total 和 weights 不一致(有人改了权重忘了更新 total),这里能避免越界 panic
复习提示:

⚠️ 坑 · rand 的全局锁与随机种子

两个必踩的点:

  1. Go 1.20 之前必须手动播种,否则每次程序启动的随机序列一模一样:

    rand.Seed(time.Now().UnixNano()) // Go 1.20 之前需要
    

    Go 1.20 起,全局 rand 会在启动时自动随机播种,这行不再需要(写了会报 deprecated 警告)。如果你用的是 Go 1.20+,直接 rand.Intn(...) 即可。

  2. 高并发下全局 rand 有锁竞争。标准库的全局随机源是加锁保护的,在 LB 这种每秒几十万次调用的场景下会成为瓶颈。工程做法:用 rand.New(rand.NewSource(seed)) 创建一个per-goroutine 或 per-instance 的独立随机源,避开全局锁。

为什么加权随机没有"平滑版"?

因为它本身就是随机的。 平滑加权轮询要解决的问题是"高权重节点被连续命中"——而随机算法里,连续命中同一节点的概率本来就只有 (5/7)² ≈ 51%(两次)、(5/7)⁵ ≈ 18.6%(五次),天然就是散的,没必要再平滑。

复习提示:

📊 轮询 vs 随机,怎么选?

场景推荐理由
请求量小(每秒几十次)轮询随机在样本少时分布很差,可能连打同一台
请求量大(每秒几千+)都行大数定律生效,两者趋同
极高性能要求随机无计数器、无锁竞争
需要精确控制比例轮询 / 平滑 WRR随机只能保证概率,不保证次数
节点频繁上下线随机轮询的计数器在列表变化时需要重置,随机无状态更省心

工程上"遇事不决用轮询",但加权随机在很多框架里反而是默认(比如早期 Dubbo 的 RandomLoadBalance),因为它简单且无状态。

小收口:随机家族(随机、加权随机)用"零状态"换来了简单和高性能,代价是短期内分布不精确。接下来是静态算法里最有价值的"哈希家族"。

3.2.5 哈希与一致性哈希 —— 本章第二个硬骨头

先抛问题

前面所有算法都有一个共同点:同一个用户的两次请求,可能落到不同节点。

这在大多数场景没问题,但有两种场景会出事:

场景一:节点上有本地缓存

请求 1(user_id=42)→ 节点 A → 缓存未命中,查库,把 user 42 写进 A 的本地缓存
请求 2(user_id=42)→ 节点 B → 缓存未命中,查库,把 user 42 写进 B 的本地缓存
请求 3(user_id=42)→ 节点 C → 缓存未命中,查库,把 user 42 写进 C 的本地缓存

3 个节点各缓存一份完全相同的数据:缓存命中率只有 1/3,内存却花了 3 倍。

场景二:有状态的业务逻辑

比如一个"分步提交"的流程(第一步填基本信息、第二步上传材料、第三步确认),中间状态暂存在节点的内存里。如果第二步跳到了另一个节点,它会说"你谁啊?没见过你的第一步数据"。

解决办法:让同一个 key 的请求,永远落到同一个节点。这就是哈希类算法。

给个直觉:班级分学习小组

类比:老师要分学习小组,规则是"按学号的尾号分组"——学号尾号 0-2 去第一组,3-5 去第二组,6-9 去第三组。

这个规则的好处是:只要学号不变,你永远在同一个组。不用问老师,自己算一下就知道去哪。

但如果突然从 3 个组变成 4 个组,规则就变成"尾号 0-1 第一组、2-4 第二组……"——几乎所有人的组都变了,大家刚和组员混熟,全打散了。

这就是普通哈希的痛点。一致性哈希要解决的就是:组数变化时,只让尽可能少的人换组。

补课:哈希函数是什么

补课胶囊 · 哈希函数(Hash Function) 一句话:把任意长度的输入,变成固定长度的数字(哈希值)。同样的输入永远得到同样的输出。 类比:榨汁机。不管你放进去的是苹果、西瓜还是榴莲(任意输入),出来都是一杯固定容量的果汁(固定长度输出)。而且同一个水果每次榨出的汁完全一样(确定性),但你没法从果汁反推出原来是什么水果(不可逆)。 最小示例:

import "hash/crc32"

h := crc32.ChecksumIEEE([]byte("user:42"))
fmt.Println(h)          // 输出一个 uint32,如 3735928559
fmt.Println(h % 3)      // 对 3 取模,得到 0/1/2 —— 这就是节点下标

回到主线:本章用 crc32.ChecksumIEEE,因为它快且够用。生产环境一致性哈希也常用 FNV、MurmurHash3(分布更均匀)。哈希函数的选择只影响"均匀程度",不影响算法原理。

普通哈希:取模

最简单粗暴的哈希负载均衡:把 key 算成哈希值,对节点数取模。

// Hash 简单哈希负载均衡
type Hash struct {
	nodes []string
}

func (h *Hash) Pick(key string) string {
	// 步骤 1:用 crc32 把 key 算成哈希值(uint32)
	hash := crc32.ChecksumIEEE([]byte(key))
	// 步骤 2:对节点数取模,落到固定节点
	return h.nodes[hash%uint32(len(h.nodes))]
}

它完美解决了会话保持:同一个 key 的哈希值永远一样,模 3 的结果也永远一样,所以永远落到同一个节点。

但它的致命缺陷在扩缩容时暴露:节点数从 3 变成 4,hash % 3 变成 hash % 4——几乎所有 key 的落点都会变。

我们来量化一下这个灾难有多严重。假设有 100 万个 key、3 个节点:

变化需要迁移的 key 比例后果
3 节点 → 4 节点约 75%(只有 hash % 12 落在特定区间的才不变)75 万条缓存全部失效,请求瞬间打到数据库 → 缓存雪崩
4 节点 → 5 节点约 80%同上
复习提示:

⚠️ 缓存雪崩(Cache Avalanche)是什么意思? 大批缓存在同一时刻集体失效,导致所有请求瞬间涌向数据库,数据库被压垮,进而导致整个系统不可用。

类比:商场搞促销,本来大家都在各个柜台慢慢逛(缓存命中),突然广播说"所有柜台的商品位置全换了"(缓存全部失效),几千人同时涌向总服务台问路(打数据库),服务台当场被挤塌。

这就是为什么普通哈希在"节点会频繁变化"的微服务环境里基本不可用。

加权哈希:用"区间"替代"取模"

把加权随机(3.2.4 节)里的"随机数"换成"key 的哈希值",就得到了加权哈希:

同一个 key 的哈希值是固定的 → 它永远落在数轴上同一个点 → 永远命中同一个节点。既保留了哈希的"固定落点",又让权重高的节点分到更大的区间。

所以它的代码和 WeightedRandom 几乎一模一样,唯一的区别在第 1 行:

func (w *WeightedHash) Pick(key string) string {
	// 与 WeightedRandom 的唯一区别:把 rand.Int63n(total) 换成 key 的哈希值
	r := int64(crc32.ChecksumIEEE([]byte(key))) % w.total
	// 下面完全一致:累加权重,找 r 落在哪个区间
	var sum int64
	for i, weight := range w.weights {
		sum += weight
		if r < sum {
			return w.nodes[i]
		}
	}
	return w.nodes[len(w.nodes)-1]
}

“点从哪来"这一行的差别,就是"随机"与"会话保持"的分界线——一个用随机数(每次不同),一个用 key 的哈希值(永远相同)。这个对照非常有助于理解。

适用场景和一致性哈希类似(本地缓存命中、会话保持),只是用"区间"替代了"环”,实现更简单。它的缺点和普通哈希一样:节点数变化时落点全变。

一致性哈希:把哈希空间弯成一个环

核心思路:不要对"节点数"取模,而是把整个哈希值空间(0 ~ 2³²-1)想象成一个首尾相接的圆环:

  1. 节点落环:每个节点也计算一个哈希值(通常用它的 IP 或实例 ID),落在环上的某个位置。
  2. key 落环:请求的 key 也算哈希,落在环上的某个位置。
  3. 顺时针找节点:从 key 的位置出发,沿顺时针方向遇到的第一个节点,就是目标节点。
flowchart TD
    Key["请求 key = user:42"] --> Hash["计算 crc32 哈希值 h"]
    Hash --> Pos["在 0 ~ 2³²-1 的环上定位"]
    Pos --> Find["从 h 出发顺时针找第一个节点"]
    Find --> Node["目标节点(如 127.0.0.1:9090)"]

为什么这样就能减少迁移? 关键洞察:

  • 增加一个节点:新节点落在环上的某个位置,它只"抢走"它逆时针方向到前一个节点之间的那段区间。其他区间的归属完全不变。
  • 删除一个节点:只有这个节点负责的那段区间需要转移给它的下一个节点。其余全部不变。
flowchart LR
    subgraph Before["3 个节点时"]
        direction TB
        B1["环上依次是:A → B → C → 回到 A"]
    end
    subgraph After["插入节点 D(落在 B 和 C 之间)"]
        direction TB
        A2["只有 B→D 这一段的 key 从 C 改投 D"]
        B2["A→B 段、C→A 段完全不受影响"]
    end
    Before --> After

定量结论:N 个节点时增加 1 个节点,只有约 1/(N+1) 的 key 需要迁移(删除 1 个节点才是 1/N)。3 个节点加 1 个 = 迁移 1/4 = 25%,而普通哈希是 75%。节点越多,优势越大。

虚拟节点:解决"数据倾斜"

理想很丰满,现实很骨感。一致性哈希有个严重问题:节点很少时,它们在环上的位置是随机的,很可能挤在一起。

理想情况(均匀分布):        实际情况(随机落点,3 个节点):
    A ---- B ---- C              A--B-------C
  每段各占 1/3                  A 只管 10%,C 管了 60%!

3 个节点时,运气不好可能一个节点管了 70% 的 key,另两个各管 15%——这就是数据倾斜。

解决方案:虚拟节点(Virtual Node / VNode)。

不要让一个真实节点只在环上占 1 个位置,而是让它占 100~200 个位置。具体做法是给真实节点编号,生成 0#nodeA、1#nodeA、2#nodeA…… 199#nodeA 这 200 个"虚拟节点",每个都算一个哈希落在环上,全部映射到真实节点 A。

节点多了,环上的点就密了,分布自然趋于均匀(还是大数定律)。

只有真实节点(3 个点):           有虚拟节点(每个真实节点 100 个):
  A--B-------------C                A B C A C B A B C C A B ...(300 个点交错)
  ↑ 分布极不均匀                    ↑ 每个真实节点约占 1/3

虚拟节点数怎么选? 通常 100~200。太少分布不均,太多则内存和构建哈希环的开销变大(每次节点变化要重建环)。经验值:节点数 × 虚拟节点数 ≥ 1000 时分布就比较均匀了。

代码实现(完整可运行)
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()
	for _, node := range nodes {
		for i := 0; i < r.replicas; i++ {
			// 虚拟节点名 = 编号 + 真实节点名,保证每个虚拟节点哈希不同
			h := r.hashFunc([]byte(strconv.Itoa(i) + node))
			r.keys = append(r.keys, h)
			r.hashMap[h] = node
		}
	}
	// 排序!只有排好序才能用二分查找
	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 ""
	}
	h := r.hashFunc([]byte(key))
	// 二分找第一个 >= h 的哈希值,即顺时针方向的下一个节点
	idx := sort.Search(len(r.keys), func(i int) bool { return r.keys[i] >= h })
	if idx == len(r.keys) {
		idx = 0 // 走到环尾则环绕回环首(首尾相接的关键)
	}
	return r.hashMap[r.keys[idx]]
}
逐行讲解
行代码为什么这么写
11sync.RWMutex读(Get)远多于写(Add),用读写锁让多个 Get 可以并发执行
14keys []uint32排序后的哈希环。这是整个算法的"数据结构核心"——有了它才能二分查找
36strconv.Itoa(i) + node虚拟节点的命名。用"编号+节点名"而不是"节点名+编号",是为了让不同编号的哈希值差异更大(前缀不同影响哈希的高位)
43sort.Slice(...)必须排序。sort.Search 要求输入有序,否则结果不可预期
57sort.Search(len, func(i) bool { return r.keys[i] >= h })标准库二分查找,返回第一个使条件为真的下标,即第一个 >= h 的位置。这正是"顺时针第一个节点"
61-62if idx == len(r.keys) { idx = 0 }环的关键。key 的哈希值比环上所有节点都大时,绕回开头,形成闭环

补课胶囊 · sort.Search(标准库二分查找) 一句话:在有序序列里,找到第一个让 f(i) 返回 true 的下标;找不到就返回 n(序列长度)。 类比:查字典。你要找第一个拼音排在 “zh” 之后(含)的字——不会一页一页翻,而是从中间翻开,看这一页在 “zh” 前面还是后面,然后扔掉一半,继续对半砍。100 万页的字典只需要约 20 次翻找。 最小示例:

a := []int{1, 3, 5, 7, 9}
i := sort.Search(len(a), func(i int) bool { return a[i] >= 6 })
fmt.Println(i) // 输出 3(a[3]=7 是第一个 >= 6 的)

j := sort.Search(len(a), func(i int) bool { return a[i] >= 100 })
fmt.Println(j) // 输出 5(等于 len(a),表示"没找到",需要环绕)

复杂度:O(log n)。300 个虚拟节点只需约 9 次比较;如果线性扫描要 300 次。 回到主线:sort.Search 是 Go 里写哈希环的标准姿势。记住它的语义——找第一个满足条件的下标,找不到返回 n。

常见坑
复习提示:

⚠️ 坑 1 · 忘了排序,二分查找结果完全错误

sort.Search 的前提是有序。Add 之后忘了 sort.Slice,Get 不会报错,但会返回一个"看起来随机"的节点——同一个 key 有时落 A 有时落 B,会话保持彻底失效。这类 bug 极其隐蔽,唯一的检测方法是写单测验证"同一个 key 调 100 次必须落在同一节点"(第四节第 5 步就是这么做的)。

⚠️ 坑 2 · 虚拟节点哈希冲突,覆盖 map 导致节点丢失

两个虚拟节点哈希值相同时,r.hashMap[h] = node 会覆盖前一个。工程上更稳妥的做法是用 map[uint32][]string(一个哈希值挂多个节点)。crc32 是 32 位,冲突概率约 1/40 亿,实际影响可忽略——面试时能提到这一点是加分项。

⚠️ 坑 3 · 以为一致性哈希能"绝对均匀"

它只保证节点变化时迁移量小,不保证任何时刻绝对均匀。即使有 100 个虚拟节点,3 个真实节点的负载比也可能是 38% : 32% : 30%——比轮询差,但比没有虚拟节点时(可能 70% : 15% : 15%)好太多。若既要有会话保持又要尽量均匀,业界用带边界的一致性哈希(Consistent Hashing with Bounded Loads):按环找到目标节点后,若其负载超过平均值的一定倍数,就顺时针继续找下一个。面试提到这个非常有亮点。

一致性哈希 vs 普通哈希 vs 加权哈希
维度普通哈希(取模)加权哈希(区间)一致性哈希(环)
会话保持✅✅✅
支持权重❌✅⚠️ 靠虚拟节点数调
节点数变化时迁移量约 (N-1)/N,全变约 (N-1)/N,全变约 1/N
分布均匀度均匀按权重均匀靠虚拟节点,略有偏差
实现复杂度极低(1 行)低中(环 + 排序 + 二分 + 虚拟节点)
典型用途分库分表(节点数固定)加权会话保持缓存集群、微服务 LB、分布式存储
复习提示:

🔑 什么时候用取模,什么时候用一致性哈希?

关键判据是:节点数会不会变?

  • 分库分表:一旦定好 32 张表,几乎永远不会变(扩容要停服迁移数据)→ 直接取模,简单高效。
  • 微服务集群:实例随时扩缩容、随时发布重启、随时故障下线 → 必须一致性哈希。

很多同学背了"一致性哈希更高级",结果在分库分表场景也硬上一致性哈希,反而给自己找麻烦(不好定位数据在哪个分片)。技术选型没有高级低级,只有合适不合适。

小收口:静态算法三大家族讲完了——轮询家族(精确但需计状态)、随机家族(无状态但短期不准)、哈希家族(有会话保持,一致性哈希还能把节点变化的迁移量压到 1/N)。它们共同的思路是"不算真实负载,靠统计学保证均衡"。下一节的动态算法,走的是完全相反的路。


3.3 动态负载均衡算法

先抛问题

静态算法"不算负载",那么动态算法就要回答一个问题:

我能不能真的去测量每个节点当前的负载,然后选最轻的那个?

听起来很美好,但马上会遇到两个现实问题:

  1. “负载"到底是什么?怎么量化? CPU?内存?队列长度?响应时间?没有一个单一指标能完美代表负载。
  2. 我在客户端,我怎么知道服务端那台机器的 CPU 是多少?

动态算法就是围绕这两个问题展开的一系列"近似方案”。

给个直觉:看病挂号

静态算法 = 医院叫号,按顺序轮到你(不管你是感冒还是要做手术)。 动态算法 = 分诊台护士看一眼每个诊室的情况:3 号诊室门口排了 20 个人,1 号诊室门口空着——“你去 1 号”。

但护士只能看到候诊区(客户端侧的情况),她看不到诊室里面医生是正在处理一个疑难杂症还是闲着刷手机(服务端真实情况)。这就是动态算法的根本局限。

3.3.1 最少连接数(Least Connections)

思路:挑选当前连接数最少的节点。逻辑很直白:连接数少 = 手头活儿少 = 应该给它派活。

实现就是"遍历找计数最小的 → 计数 +1 → 连接关闭时 -1",与下方 3.3.2 节的最少活跃完全同构,只是把"活跃请求数"换成"连接数"。完整的工程实现见第四节第 4 步的 LeastConn(那里用的是 inflight 在途请求数)。

复习提示:

⚠️ 缺陷 · 连接多路复用下,“连接数"不等于"负载”

这是最少连接算法在 gRPC / HTTP/2 时代的致命伤。

多路复用(Multiplexing):一条 TCP 连接上可以同时跑成百上千个请求(不用等前一个返回就能发下一个)。所以:

节点 A:10 条连接,但每条上面挂着 10 个请求  →  实际在途 100 个请求
节点 B:12 条连接,但每条只挂 4 个请求       →  实际在途 48 个请求

按"最少连接",会选 A(10 < 12)——但 A 实际更忙!选错了。

结论:在 HTTP/1.1(一连接一请求)时代最少连接很好用;在 gRPC(HTTP/2 多路复用)时代基本不准。

3.3.2 最少活跃数(Least Active Requests)

为了解决上面的问题,把指标从"连接数"换成"活跃请求数":

活跃数 = 客户端发出去、但还没收到响应的请求数。

做法:客户端为每个节点维持一个计数器,发请求时 +1,收到响应时 -1,每次选活跃数最低的节点。

sequenceDiagram
    participant P as Pick()
    participant C as 计数器
    participant N as 节点
    P->>C: 查各节点活跃数 A=3, B=7, C=2
    P->>C: 选 C,C 活跃数 +1 → 3
    P->>N: 向 C 发请求
    N-->>C: 收到响应,C 活跃数 -1 → 2

比连接数准确得多,因为它直接衡量"这个节点欠我多少响应"。gRPC 的 leastrequest balancer 就是这么做的。

复习提示:

⚠️ 缺陷:活跃数只反映请求数量,不反映请求大小。节点 A 有 3 个活跃请求但都是"查用户昵称",节点 B 有 3 个活跃请求但都是"导出全站数据"——活跃数一样,实际负载天差地别。

另外它要求必须有回调机制:每个请求结束时都要通知 LB 减一。如果某个分支忘了调(比如 error 分支提前 return 了),计数器只增不减,这个节点会看起来"永远很忙",流量全部歪走。第四节的工程代码会专门处理这个坑。

3.3.3 最快响应时间(Response Time)

思路:挑选历史响应最快的节点。

可以用的统计量:

统计量含义推荐度
平均响应时间所有请求耗时的算术平均✅ 推荐,简单稳定
P99(99 分位)99% 的请求比它快✅ 推荐,能反映长尾
P99999.9% 的请求比它快⚠️ 样本少时抖动大
最近一次响应时间上一次调用的耗时❌ 太容易受偶然抖动影响

补课胶囊 · P99 / 分位数(Percentile) 一句话:把所有请求的耗时从小到大排序,第 99% 位置的那个值就叫 P99,意思是"99% 的请求都比它快(或等)"。 类比:高考的一分一段表。平均分会被"超级学霸"拉高,也不能反映"垫底那批人"有多惨。而 P99 相当于"排名前 1% 的分数线"——它专门盯最慢的那 1%,也就是用户体验最差的那部分请求。 为什么推荐平均或 P99 而不是 P999:P999 意味着 1000 个请求里只取最慢的那 1 个,样本量不足时这个值会剧烈抖动(一个偶然的 GC 停顿就能让 P999 翻十倍),用它做选点会导致流量在两个节点之间来回横跳。 回到主线:响应时间是一个"综合指标"——它同时包含了 CPU、内存、网络、数据库慢查询等所有因素的影响,所以在动态算法里它往往是最实用的那个。

实现时通常配合滑动窗口或**指数移动平均(EWMA)**来平滑历史值,避免被单次抖动带偏。

3.3.4 微服务框架的固有局限

上面三种算法都是客户端在选择,而客户端有一个无法回避的事实:

客户端只知道自己这一侧的情况,看不到服务端的真实负载。

指标客户端能测到吗说明
客户端到该节点的连接数✅ 能我建的连接我当然知道
客户端发往该节点的活跃请求数✅ 能我发的请求我当然知道
客户端测到的响应时间✅ 能我掐的表
服务端的 CPU 使用率❌ 不能除非服务端告诉我
服务端的内存使用率❌ 不能同上
其他客户端发给该节点的请求量❌ 不能这是最大的盲区

最后一条是致命的:你只知道你自己给节点 B 发了 5 个请求,但另外 20 个服务实例可能同时给 B 发了 500 个请求。 你以为 B 很闲,其实 B 已经快炸了。这在业内叫**“局部视角问题”(Partial View Problem)**。

要拿到服务端真实指标,有三条路:

flowchart LR
    C[客户端] -->|只知自己侧指标| L["本地计算
活跃数 / 响应时间"] S[服务端] -->|"① 上报 CPU/内存"| R[注册中心] S -->|"② 响应头附带指标"| C S -->|"③ 被采集"| P["Prometheus
可观测性平台"] R -->|通知| C P -->|拉取| C
方案做法优点缺点
① 服务端上报注册中心服务端定期把 CPU/内存写进 etcd,注册中心推给客户端客户端拿现成的,实时性较好增加注册中心写入压力;指标精度低(不可能每秒上报)
② 响应中附带指标服务端在 gRPC 的响应 metadata 里带上自己当前的 CPU最准确(就是这个请求当时的状态)每个响应都多带数据;需要改所有服务端的拦截器
③ 从 Prometheus 拉取客户端定期去监控系统拉各节点的指标不侵入业务;指标最丰富引入对监控系统的强依赖;拉取有延迟
复习提示:💡 面试加分点:gRPC 官方的 weightedroundrobin 走的是方案 ② 的思路——服务端通过 ORCA(Open Request Cost Aggregation,开放的"请求成本聚合"协议)在响应里回传自己的 CPU 利用率、QPS、错误率,客户端据此算权重。你在 3.4.3 节会看到它。

3.3.5 设计自己的负载均衡算法:先回答四个问题

不管是评价别人的算法,还是要自己设计一个,都可以用这四个问题过一遍。这是本章最有迁移价值的方法论——面试官问"你设计一个负载均衡算法"时,就按这四问答:

问题 1 · 要不要考虑服务器处理能力?

  • 答"不考虑" → 轮询、随机、哈希、最少连接、最少活跃这些无权重版本
  • 答"要考虑" → 对应的加权版本,用权重表达处理能力

问题 2 · 选什么指标表达当前负载?

  • 答"不选,靠统计" → 轮询、随机、哈希(静态算法)
  • 答"选一个指标" → 连接数、活跃请求数、响应时间、错误率、CPU……
  • 答"选多个指标组合" → 这就是你自己的算法了!比如 得分 = 0.5 × 归一化响应时间 + 0.3 × 错误率 + 0.2 × (1 - 活跃数/总活跃数),选得分最高的

问题 3 · 所有请求消耗的资源一样吗?

显然不一样——大 V 的粉丝列表有 1000 万条,普通用户只有 20 条。不考虑请求消耗差异的 LB,容易出现"偶发性打爆某一台实例"。

可能的应对:对大请求做单独限流 / 单独路由到"大请求专用节点池" / 用请求预估成本(cost)作为权重。

问题 4 · 实践中选什么?

遇事不决用轮询,出了问题再换。

这是非常实在的工程建议。轮询简单、可预测、无隐藏状态,绝大多数业务用它就够了。不要一上来就上"自适应 + 多指标 + 机器学习"的复杂算法——复杂算法带来的收益往往抵不上它的调试成本和不可预测性。

复习提示:

🎯 面试导向的三级能力标准:

级别表现面试评价
及格能把静态算法(轮询、加权、平滑 WRR、一致性哈希)的原理讲清楚并手写基础扎实
有竞争力还知道动态算法(最少连接/活跃/最快响应),能说出"客户端只看得到自己这一侧"的局限有工程思考
亮点能结合具体业务特征设计出带指标的专属算法,比如"哈希 + 本地缓存"、“错误率加权 + 健康检查”、“响应时间 + 熔断联动”有架构潜力

注意第三档的关键词是"结合业务特征"——不是背更多算法,而是能说清"在我的这个业务里,什么指标最能代表负载,为什么"。

小收口:动态算法用"实时指标"替代"统计学",思路更先进,但受限于"客户端只能看到自己这一侧",实践中反而不如静态算法用得多。业界主流做法是"静态算法打底 + 健康检查纠偏"(第四节就是这么落地的)——用轮询/平滑 WRR 保证基本分布,用连续失败剔除来兜底故障节点。


3.4 在 gRPC 中接入负载均衡

前面讲了这么多算法,现在要把它们真正接进项目。在动手前,先补一下 gRPC 的基础概念。

3.4.1 补课:gRPC 的几个关键概念

补课胶囊 · gRPC 一句话:一种高性能的 RPC 框架——让你像调用本地函数一样调用另一台机器上的函数。 类比:打电话。你拨号(调用 client.GetUser(ctx, req)),对方接起来处理(服务端执行真实的 GetUser),然后回答你(返回 resp)。中间的拨号、编码、传输、解码全由 gRPC 帮你做了,你感觉就像直接跟对方说话。 为什么微服务爱用它:用 Protobuf(一种紧凑的二进制编码格式)传输,比 JSON 小 3-10 倍、快 5-10 倍;基于 HTTP/2,天生支持多路复用和流式传输。 回到主线:第 12 章我们已经用 gRPC + etcd 让服务互相调通了,本章只关注"调用时选哪个节点"这一件事。

gRPC 客户端有三个核心抽象,必须分清:

概念是什么类比
ClientConn你 grpc.Dial() 拿到的那个对象,代表"到某个服务名的一条逻辑连接"你手机里存的联系人"客服热线"
SubConnClientConn 之下、到某一个具体实例的连接(实际是一个连接池)客服热线的某一条具体线路
Address一个实例的地址(ip:port)+ 附带的元数据(如权重)那条线路的号码和备注
复习提示:

⚠️ 最容易混淆的一点:SubConn 名字里带 “Conn”,但它不是一条 TCP 连接,而是"到某个节点的一个连接池"。

为什么叫 SubConn 而不是 Node? 因为 gRPC 的抽象是"一个 ClientConn 下面挂若干 SubConn",强调的是层级关系(Sub = 子级)。一个 SubConn 对应一个后端节点——你在写 Picker 时可以把它直接理解成"一个节点"。

这个命名坑了无数新手:有人以为 len(SubConns) 是连接数,然后基于它实现了"最少连接"算法——完全错了(它其实是"最少节点",而节点数是固定的,所以算法会退化成轮询)。

3.4.2 gRPC 的默认行为:pick_first(本章最重要的反直觉点)

复习提示:

🚨 重要事实:gRPC 默认根本不做负载均衡!

默认策略叫 pick_first:永远选 Resolver 解析出来的第一个节点,并且一直用它,直到这个节点彻底不可用。

即使你在后端起了 3 个实例(8090、8091、8092),所有请求都只会打到 8090。另外两个实例的监控曲线是一条平躺的直线。

这是本章最实用的一条知识——很多人"配了服务发现却没生效",问题就在这里。

flowchart TD
    D1["默认 pick_first"] --> P1["永远选名单第一个节点
127.0.0.1:8090"] P1 --> R1["8091、8092:一条平躺的直线 😴"] D2["配置 round_robin"] --> P2["请求轮流打到
8090 → 8091 → 8092"] P2 --> R2["三条曲线均匀分布 ✅"]

为什么会这么设计? 不是 gRPC 偷懒,而是因为 pick_first 有一个重要优点:它保证同一个 ClientConn 上的所有请求都打到同一个后端,这在需要连接亲和性(比如后端是有状态服务、或者你想减少连接数)的场景下反而是对的。另外 pick_first 可以省掉"为每个节点都建连"的开销。

怎么验证自己踩了这个坑? 两个方法:

# 方法一:看连接数(只连了 1 个节点,说明是 pick_first)
netstat -an | grep ESTABLISHED | grep 909

# 方法二:看各实例的请求日志/监控 QPS 曲线
# 只有一个实例有流量 → 就是 pick_first 没换

3.4.3 使用内置的 round_robin

改法只有一行:通过 WithDefaultServiceConfig 传入一段 JSON 配置。

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+`":{}}]}`),
)
逐行讲解
行代码为什么这么写
9{"loadBalancingConfig":[...]}gRPC 的 ServiceConfig 格式。外层 key 固定叫 loadBalancingConfig,值是一个数组(按优先级排列,第一个可用的生效)
9{"round_robin":{}}数组元素:key 是 balancer 的名字,value 是该 balancer 的配置(轮询没有配置项,所以是空对象 {})
9`...` 反引号Go 的原始字符串字面量,里面可以直接写双引号,不用转义。如果没有它,你得写成 "{\"loadBalancingConfig\":...}",可读性极差
9+roundrobin.Name+用常量拼接而不是硬编码字符串,写错了编译期就报错。(roundrobin.Name 的值就是 "round_robin")
复习提示:

⚠️ gRPC 这个 JSON 配置被很多人吐槽为"垃圾设计",原因有三:① 配置是 JSON 字符串而非 Go 结构体,写错了编译不报错、运行时才失败,且常常静默回退到 pick_first;② 名字是下划线(round_robin)而包名是连写(roundrobin),极易写混;③ 多次调用 WithDefaultServiceConfig 只有最后一次生效——它是"覆盖"不是"追加"。

第三点最坑:原文 5.2 节的示例连着写了两次 WithDefaultServiceConfig(一次配重试、一次配 LB),实际上只有第二个生效,重试配置被丢弃。正确做法是把两段 JSON 合并成一个对象:

// ❌ 错误:第二次调用覆盖第一次,retryPolicy 丢失
grpc.WithDefaultServiceConfig(retryCfg),
grpc.WithDefaultServiceConfig(lbCfg),

// ✅ 正确:合并成一个 JSON 对象
grpc.WithDefaultServiceConfig(`{
    "loadBalancingConfig":[{"round_robin":{}}],
    "methodConfig":[{"name":[{"service":"user.v1.UserService"}],"retryPolicy":{...}}]
}`)

面试时指出这一点是加分项。

round_robin 内部怎么实现的? 核心就是利用原子操作更新候选节点下标——和我们 3.2.1 节写的那个 RoundRobin 是一回事,只是它操作的对象是 balancer.SubConn 数组。

3.4.4 使用内置的 weightedroundrobin

需要两步:

  1. 匿名引入包 google.golang.org/grpc/balancer/weightedroundrobin,触发它的 init() 完成注册
  2. 在 ServiceConfig 里指定使用 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、错误率、CPU 利用率)是服务端通过 ORCA 协议在响应里回传的(见 3.3.4 的方案 ②),客户端据此算出权重,再用 EDF 算法(Earliest Deadline First,最早截止时间优先)挑节点。

  • QPS 越高 → 权重越大(因为它扛得住更多流量)
  • 错误率越高 → 权重越低(因为它可能有问题)

这本质上是"动态算法"和"加权算法"的结合体,属于前面说的"结合业务特征设计算法"的实例。

补课胶囊 · 匿名 import(_ "xxx")与 init 注册模式 一句话:_ 表示"我只需要这个包的副作用(执行它的 init()),不用它的任何函数"。 类比:你去驾校报名,不是要用他们的车,而是要让他们在系统里给你登记一条"此人已报名"的记录。登记这个动作(副作用)才是目的。

为什么 Go 需要这套机制? 因为 Go 语言**没有"插件注册中心"**这种运行时机制。于是很多库用 init() + 全局注册表来模拟:包一被 import,它的 init() 就执行,把实现按名字登记进一张全局表;后续使用方按名字去表里查。

最小示例:

// mypkg/mypkg.go
package mypkg

import "fmt"

func init() {
    fmt.Println("init 被调用了") // import 时自动执行
}

// main.go
import _ "example.com/mypkg" // 只用副作用
// 运行后输出:init 被调用了

哪些地方用了这套模式?

  • 数据库驱动:_ "github.com/go-sql-driver/mysql" → 注册 mysql 驱动,你才能 sql.Open("mysql", ...)
  • gRPC 的 balancer / resolver / compressor
  • 各种序列化框架、插件系统

回到主线:忘了匿名引入 weightedroundrobin 包,配置里写 weightedroundrobin 就会报 unknown balancer "weightedroundrobin"。这是最高频的踩坑点之一。

3.4.5 gRPC 内置 balancer 一览

在 google.golang.org/grpc/balancer 子包下,官方提供了这些实现:

名称说明建议
roundrobin轮询✅ 推荐,生产可用
weightedroundrobin加权轮询(客户端根据 QPS/错误率自适应算权重)✅ 推荐,生产可用(较新版本)
grpclb用于集成外部负载均衡器的实现(gRPC 自己的 LB 协议)❌ 不用(需要额外部署 grpclb 服务器,基本被 Service Mesh 取代)
leastrequest最少请求数⚠️ 实验特性,不要用于生产
rls集成限流的 LB(Route Lookup Service)⚠️ 实验特性,不要用于生产
pick_first默认策略,永远选第一个⚠️ 知道它,别无意中用它
复习提示:

💡 一句话总结选型:除了 roundrobin(和较新版本的 weightedroundrobin),其他基本都不用。

如果这两个满足不了需求,有两个更好的选择:

  1. 用框架自带的:Kratos、go-zero、Dubbo-go 都自带了更丰富、更好用的 LB 实现(见 3.6 节)。
  2. 自己实现 Picker(3.4.6 节)——这也是本章第四节的实战内容。

3.4.6 自定义负载均衡算法:PickerBuilder + Picker

要在 gRPC 里塞进自己的算法,需要实现两个接口:

接口职责什么时候被调用
balancer.PickerBuilder根据当前可用连接,构造一个 Picker可用连接集合发生变化时(实例上下线、健康状态变化)
balancer.Picker每次 RPC 请求时,挑一个连接每一次 RPC 调用
flowchart TD
    A["grpc.Dial 建立 ClientConn"] --> B["Resolver 解析出地址列表"]
    B --> C["Balancer 为每个地址创建 SubConn 并建连"]
    C --> D["连接就绪 → 调用 PickerBuilder.Build()"]
    D --> E["生成 Picker(内含我们的算法状态)"]
    E --> F["每次 RPC → 调用 Picker.Pick()"]
    F --> G["返回 PickResult{SubConn, Done}"]
    G --> H["gRPC 用这个 SubConn 发请求"]
    H --> I["请求结束 → 回调 Done(info)"]
    I -->|"info.Err != nil"| J["上报健康检查:失败"]
    I -->|"info.Err == nil"| K["上报健康检查:成功"]
    J --> D
    K --> D
完整示例:自定义平滑加权轮询

Step 1 · 定义节点结构体和 Picker

package mywrr

import (
	"context"
	"sync"

	"google.golang.org/grpc/balancer"
)

// 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 每次调用选一个节点(算法与 3.2.3 节完全一致)
func (p *Picker) Pick(info balancer.PickInfo) (balancer.PickResult, error) {
	p.mu.Lock()
	defer p.mu.Unlock()
	if len(p.conns) == 0 {
		return balancer.PickResult{}, balancer.ErrNoSubConnAvailable
	}
	// ① 攒分 + 累加总权重
	var total int64
	for _, c := range p.conns {
		total += c.Weight
		c.CurrentWeight += c.Weight
	}
	// ② 选 currentWeight 最大的
	max := p.conns[0]
	for _, c := range p.conns[1:] {
		if c.CurrentWeight > max.CurrentWeight {
			max = c
		}
	}
	// ③ 还债
	max.CurrentWeight -= total

	// 挂上 Done 回调:可在这里做 failover / 动态调整权重(见 3.5.3 节)
	return balancer.PickResult{
		SubConn: max.SubConn,
		Done: func(ctx context.Context, info balancer.PickDoneInfo) {
			// 作业:根据 info.Err 动态调整 max.Weight
			// 失败→降低权重;成功→提高权重;务必设上下限
		},
	}, nil
}

Step 2 · 实现 PickerBuilder

// PickerBuilder 在可用连接变化时被调用,重新构造 Picker
type PickerBuilder struct{}

func (b *PickerBuilder) Build(info base.PickerBuildInfo) balancer.Picker {
	if len(info.ReadySCs) == 0 {
		return base.NewErrPicker(balancer.ErrNoSubConnAvailable)
	}
	// 遍历所有就绪连接,取出权重
	var conns []*weightConn
	for sc, sci := range info.ReadySCs {
		// 注意:Metadata 字段已被弃用,应从 Address.Attributes 取
		// resolver 侧 attributes.New("weight", ins.Weight) 存的是 int,
		// 这里按 int 断言,再转成平滑 WRR 需要的 int64(按 int64 断言会静默失败、权重退化为 1)
		w, _ := sci.Address.Attributes.Value("weight").(int)
		if w <= 0 {
			w = 1 // 默认权重,兜底
		}
		conns = append(conns, &weightConn{SubConn: sc, Weight: int64(w)})
	}
	return &Picker{conns: conns}
}
复习提示:

⚠️ 坑 · Address.Metadata 已被弃用,类型和取值方式都要确认

gRPC 版本演进中,地址附带的元数据从 Address.Metadata(interface{},各注册中心随便塞)迁移到了 Address.Attributes(类型安全的 attributes.Attributes)。写自定义 Picker 前,先打印出来看里面到底是什么:

for sc, sci := range info.ReadySCs {
    fmt.Printf("addr=%s attributes=%+v\n", sci.Address.Addr, sci.Address.Attributes)
}

三种常见坑:① 权重被写成 string("5")而非 int64 → 类型断言失败,永远走兜底 1;② key 写成 "Weight" → 取到 nil;③ Attributes 是 nil(注册中心没写属性)→ 要在 Resolver 里主动写入(第四节第 7 步会做)。

Step 3 · 注册到 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 注册进去(见 3.4.4 的补课胶囊)
	balancer.Register(base.NewBalancerBuilder(Name, &PickerBuilder{}, base.Config{HealthCheck: true}))
}

使用时:匿名引入 mywrr 包触发 init(),然后在 WithDefaultServiceConfig 里指定 "my_smooth_wrr" 即可。(服务端的权重要先通过 etcd.WithWeight(5) 之类的选项注册进注册中心,再由 Resolver 写进 Address.Attributes——第四节第 7 步会完整实现这条链路。)

逐行讲解:这三个东西到底是什么关系?
组件你需要写的代码量作用
base.NewBalancerBuilder0 行(调官方的)提供连接管理的通用骨架:创建 SubConn、连接、断开、重连、健康检查
PickerBuilder~15 行在连接就绪时,把连接列表 + 权重打包成一个 Picker
Picker~30 行每次请求时跑你的算法,返回选中的 SubConn

为什么要拆成 Builder 和 Picker 两个? 因为连接列表变化是低频事件、选节点是高频操作。Builder 在低频事件时做一次性的准备工作(解析权重、建哈希环、排序),Picker 只做高频的选点计算。这个拆分让你可以把耗时的准备工作只在必要时做一次。

3.4.7 更顶级的抽象:Balancer 接口

在 Picker / PickerBuilder 之上,gRPC 还有一个更顶级的抽象 balancer.Balancer。两者的分工:

  • Picker:只回答"这个请求发给哪个节点"
  • Balancer:还要管理所有节点的连接与连接池状态——SubConn 的创建、连接、断开、重连、健康检查都归它管

我们前面用的 base.NewBalancerBuilder,本质就是「默认的连接管理逻辑 + 你的 PickerBuilder」打包成一个完整的 Balancer。

复习提示:

🔑 类比:

  • Picker = 迎宾(决定客人坐哪一桌)
  • Balancer = 餐厅运营经理(管每张桌子的开台、收台、维修、翻台)

99% 的场景你只需要换迎宾,不需要换经理。 直接实现 Balancer 接口意味着连连接管理(重连退避、健康检查、SubConn 生命周期)都要自己写,工作量大不说,还极容易写出难以复现的 bug。

什么时候才需要实现 Balancer? 只有要做非常复杂的事情——比如完全自定义的连接生命周期管理(按业务分组建连、灰度流量用独立连接池等)。这种需求几乎不会遇到,遇到了也应该先考虑用 Service Mesh(Istio / Envoy)解决。

小收口:gRPC 的 LB 扩展点就是两个接口——PickerBuilder 管"准备",Picker 管"选点",连接管理交给官方的 base 骨架。记住三件事:默认是 pick_first(不均衡!)、换策略靠 WithDefaultServiceConfig 的 JSON、自定义包必须匿名引入触发 init()。


3.5 Failover:调用失败怎么办

先抛问题

即使做了负载均衡,还是会遇到失败:

  • 某个节点进程崩了(Panic、OOM)
  • 某个节点还在,但已经不健康(死锁、GC 卡死、磁盘打满,能连上但永远不返回)
  • 某个节点刚下线,注册中心的通知还没到(租约还没过期,Resolver 还把它列在名单里)
  • 网络抖动(这一秒不通,下一秒就好了)

这时候要做 Failover(故障转移):把请求转移到别的节点上。

3.5.1 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: 失败(UNAVAILABLE)
    C->>LB: 重试(再次走 LB)
    LB->>N2: 第 2 次转发(换了个节点)
    N2-->>C: 成功

注意这里的精妙之处:你没有写任何"换节点"的代码——因为重试会重新调用 Pick(),而平滑 WRR 的"还债"机制会让第二次大概率选中另一个节点。重试和负载均衡天然地组合在了一起。

3.5.2 通过 methodConfig 配置重试

gRPC 的重试策略通过 ServiceConfig 里的 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),
)
逐行讲解
字段含义为什么这么配
name这个策略作用于哪些方法[{"service": "user.v1.UserService"}] 表示覆盖整个 UserService 的所有方法。这个值要去 proto 编译后的 .pb.go 里找,通常在 FullMethod 常量里,形如 /user.v1.UserService/GetById(这里只写 service 部分)
maxAttempts最大尝试次数(含首次)配 4 表示"最多试 4 次",即 1 次正常 + 最多 3 次重试。注意不是"重试 4 次"
initialBackoff首次重试前等多久0.1s
maxBackoff退避上限1s,防止等待时间无限增长
backoffMultiplier退避倍数2.0 → 等待序列是 0.1s → 0.2s → 0.4s → 0.8s(超过 1s 后封顶)
retryableStatusCodes哪些错误码才重试这里是关键:只重试 UNAVAILABLE(连不上/节点不可用)和 RESOURCE_EXHAUSTED(限流)。绝不能无脑重试所有错误
复习提示:

⚠️ 重试的三大铁律

① 只对"可重试的错误"重试——✅ 可以重试:UNAVAILABLE(连接失败/节点挂了)、RESOURCE_EXHAUSTED(限流,换节点可能就行);❌ 绝对不能重试:INVALID_ARGUMENT、NOT_FOUND、PERMISSION_DENIED、ALREADY_EXISTS。因为重试不是幂等的:扣款请求第一次其实成功了、只是响应丢了,重试就扣两次钱。

② 重试放大效应(Retry Storm)——一个请求最多重试 N 次,链路纵深时层层重试会把流量放大 N 的层数次方:A→B→C 三层每层重试 3 次 → 最坏放大 27 倍。C 压力过大开始超时 → B 重试 → C 压力更大 → 更多超时……这就是重试风暴,会把一个"只是有点慢"的服务直接打死。防护:只对链路最后一跳配置重试、必须配合链路共享 deadline 的超时控制(第 14 章)、设置重试预算(重试请求占比上限,如 10%)。

③ 重试必须配退避——固定间隔重试会让所有客户端在同一时刻一起重试,形成惊群效应。要用指数退避 + 抖动,gRPC 的 backoffMultiplier 就是干这个的。

3.5.3 进阶 Failover:在 Picker 的 Done 里调整权重

配置化的重试只能处理"这次请求失败"这一件事。更复杂的场景——比如"某个节点最近一直在报错,我想让它暂时少接流量"——就需要自己实现 LB,在 PickResult.Done 回调里嵌入逻辑。

Done 回调是什么? gRPC 在每次 RPC 结束(成功或失败)时,会调用你返回的 PickResult.Done,并把这次调用的结果放进 balancer.PickDoneInfo(里面有一个 Err 字段)。

于是你可以在这里做两件事:

Done: func(ctx context.Context, info balancer.PickDoneInfo) {
    if info.Err != nil {
        // 失败:降低这个节点的权重(甚至直接踢出可用列表)
        max.Weight = maxWeight(max.Weight/2, 1) // 减半,但不能低于 1
    } else {
        // 成功:慢慢把权重加回来
        max.Weight = minWeight(max.Weight+1, 100) // +1,但不能超过 100
    }
}

效果:一个连续报错的节点,权重会指数级衰减(100 → 50 → 25 → 12 → 6 → 3 → 1),流量自然越来越少;等它恢复了,权重又会缓慢爬升回来。这叫**“基于反馈的自适应负载均衡”**。

复习提示:

⚠️ 三个必守的边界(对应第五节工程要点第 2 条:权重要有上下限)

  1. 权重下限不能是 0:权重为 0 意味着"永不选中",这个节点就永久下线了——即使它已经恢复。兜底到 1 就好。
  2. 权重上限不能无限大:如果成功一次就 +1,跑几天后某个节点的权重会变成 999999,其他节点永远分不到流量。必须设上限(如 100)。
  3. 权重要能"恢复":只降不升的话,一次网络抖动就会让节点永久降级。成功时缓慢回升,才能让系统自愈。

面试很好用,生产较少用——因为这套逻辑和"熔断器"(第 14 章)高度重合,生产上更推荐用成熟的熔断组件。但面试时能说出"在 Done 回调里根据错误率动态调整权重",并且能指出上下限的坑,是很明显的加分项。

小收口:Failover = 重试 + 负载均衡。简单场景用 methodConfig 配重试策略(只对幂等的、可重试的错误码重试,必须配退避);复杂场景在 Done 回调里根据错误动态调整权重(记得设上下限)。下一节看看主流框架是怎么做的。


3.6 主流框架的 LB 方案与面试亮点

3.6.1 Kratos / go-zero 一览

框架默认算法说明
Kratos(B 站开源)平滑加权轮询(WRR)LB 被抽象成 Selector 接口,支持 filter 做分组 / 路由 / 灰度
go-zero(好未来开源)P2C框架内部写死,要改得用 WithDefaultServiceConfig 覆盖

Kratos 支持的算法:

  • WRR(默认):平滑加权轮询,就是我们 3.2.3 节手写的那个
  • P2C(Pick of 2 Choices):随机挑 2 个节点,再从中选负载低的那个(后面细说)
  • Random:随机
复习提示:

⚠️ Kratos 一个反直觉的设计:指定算法的方式不是在建立连接时按服务指定,而是通过 selector 包设置一个全局的负载均衡实现:

// Kratos:设置全局默认(注意是全局的,不是 per-service 的)
selector.SetGlobalSelector(wrr.NewBuilder())

这意味着你所有服务都得用同一个算法。想给不同服务配不同算法,得自己封装。这是很多从 gRPC 原生转过来的人会困惑的地方。

补课胶囊 · P2C(Pick of 2 Choices) 一句话:别一个一个挑,随机抓两个出来比一比,选更好的那个。 类比:相亲。与其把 100 个人全部面试一遍(开销太大),不如随机抽 2 个人见一见,选更合适的那个。虽然不是最优解,但比随机选 1 个好很多,而且开销极小。 为什么有效:这是个有数学证明的结论——从 N 个里"随机选 1 个"的期望负载是平均负载,而"随机选 2 个取更优"能把最坏情况的负载从 O(log N / log log N) 降到 O(log log N),提升极其显著。这就是所谓的"二选一的力量(The Power of Two Choices)"。 回到主线:P2C 通常配合"活跃数"或"响应时间"作为比较依据,是 go-zero 的默认算法。它比"全局扫描找最小的"快(O(1) vs O(n)),比纯随机准,是个很漂亮的折中。

复习提示:

💡 一句话打通所有框架:不管用哪个框架,只要底层是 gRPC,就绕不开 PickerBuilder + Picker 两个接口,也都借助 WithDefaultServiceConfig 指定算法。

框架只是把这层封装得更好看了。理解了原生 gRPC 的这两个接口,所有框架的 LB 你都能看懂。这也是本章花大力气讲原生接口的原因——学底层,一通百通。

3.6.2 三个适合面试的亮点方案

方案一 · 一致性哈希 LB + 本地缓存 + Redis 多级缓存

问题:如果直接用轮询,本地缓存会在多个节点上重复加载——3 个节点各缓存一份相同的数据,命中率 1/3、内存 3 倍。

方案:改用一致性哈希,让同一个 key 的请求必然命中同一个节点。

flowchart LR
    K["请求 key = user:42"] --> H["一致性哈希"]
    H -->|固定落点| N1["节点1
本地缓存命中 ✅"] H -.->|不会落到这里| N2["节点2
本地缓存命中 ✅"] N1 --> M["未命中才查 Redis → 再查 DB"]

效果:

  • 本地缓存命中率从 1/N 提升到接近 100%(N = 节点数)
  • 内存消耗降到 1/N
  • 节点变化时只有 1/N 的缓存需要重建

面试定位:这是"有点高级但不至于太高级"的方案,特别适合初中级岗位——它证明你理解了一致性哈希的价值,并且能把它和缓存体系串起来讲。

方案二 · 结合熔断 / 限流 / 降级的 Failover

思路:客户端发现某个服务端触发了熔断 / 限流 / 降级时,一段时间内不选这个节点。

实现:在 Done 回调里识别特定错误码(比如服务端限流会返回 RESOURCE_EXHAUSTED),收到就把权重调到极低或直接踢出(详见第 14 章)。

效果:避免把请求继续发给一个"明确说了它扛不住"的节点,给对方喘息时间。

方案三 · 动态判定负载

思路:客户端维持每个节点的响应时间、错误率、活跃数等指标,选负载最低的节点。

关键是要能说清指标来源(3.3.4 节的三条路):服务端上报注册中心 / 响应中附带 / 从 Prometheus 拉取。

面试话术示例:

“我们的 LB 用的是平滑加权轮询打底,权重由实例规格决定;同时客户端在 Done 回调里维护每个节点的滑动窗口错误率和 P99 响应时间,错误率超过阈值就指数降低权重。另外我加了一层健康检查,连续 3 次失败会剔除节点 10 秒,10 秒后半开放一个试探请求。这样既保证了基本分布,又能对故障节点快速反应。”

小收口:框架的 LB 万变不离其宗。面试准备的优先级是:① 手撕平滑 WRR > ② 讲清一致性哈希 > ③ 能结合业务说出一个带指标的专属方案。


四、跟着写:分步实现

上一章我们把实例注册进了 etcd,客户端也通过自定义 Resolver 拿到了"有哪些可用实例"。但 Resolver 只回答了"名单",没回答"这一次调用发给谁"。

本章就在项目里补上这块拼图:新建 pkg/balancer/ 包,落地 5 种负载均衡算法和统一的 Balancer 接口,加上"连续失败剔除、半开恢复"的健康检查,最后注册成 gRPC 的 weighted_round_robin 与 consistent_hash 两个策略,让 post-service 调用 user-service 时实现真正的客户端负载均衡。

整体分 8 步:

步骤内容产出文件
1定义 Balancer 统一接口pkg/balancer/balancer.go
2实现轮询 RoundRobinpkg/balancer/roundrobin.go
3实现平滑加权轮询 SmoothWRR(本章核心)pkg/balancer/smoothwrr.go + 单测
4实现随机与最少连接pkg/balancer/random.go、leastconn.go
5实现一致性哈希 ConsistentHash(100 虚拟节点)pkg/balancer/consistenthash.go + 单测
6实现健康检查:连续 3 次失败剔除、10 秒后半开试探pkg/balancer/health.go
7注册为 gRPC 负载均衡策略(Picker + 健康检查联动)pkg/balancer/picker.go
8起两个实例实测权重分布、会话保持与故障转移配置文件 + 实测脚本
复习提示:

前置准备:确保你处于第 12 章结束时的项目状态,且 pkg/registry 包里有 ServiceInstance 结构体(含 InstanceID、Addr()、Weight 字段)。如果手头没有完整项目,也可以新建一个空目录跟着写——所有算法都是自包含的,只有第 7 步依赖 gRPC。

# 确认项目可编译
cd ~/code/content-community && go build ./...
# 预期:无任何输出(编译通过)

第 1 步:定义 Balancer 统一接口

目标:5 种算法实现同一个接口,方便在运行时按配置切换。

节点直接复用第 12 章 pkg/registry 的 ServiceInstance——它的 Weight 字段就是为这一章准备的。Resolver 监听到实例变化时调用 UpdateInstances 全量刷新,各算法内部自己重建状态。

// pkg/balancer/balancer.go
package balancer

import (
	"errors"

	"github.com/yourname/content-community/pkg/registry"
)

// ErrNoInstance 当前没有可用实例时返回,调用方应快速失败或走降级
var ErrNoInstance = errors.New("balancer: no available instance")

// Balancer 负载均衡统一接口,本章 5 种算法都实现它
type Balancer interface {
	// Pick 选出一个实例;key 给哈希类算法用(如 user_id),轮询/随机忽略它
	Pick(key string) (*registry.ServiceInstance, error)
	// UpdateInstances 服务发现推送新列表时调用,全量替换
	UpdateInstances(instances []*registry.ServiceInstance)
}

三个设计要点:① ErrNoInstance 是统一哨兵错误,调用方用 errors.Is 判断后决定"快速失败"还是"走降级";② Pick 为什么给轮询和随机也带 key 参数?为了接口统一,上层代码不用关心用的是哪种算法,非哈希算法忽略它即可;③ UpdateInstances 用全量替换而非"增量增删",因为全量替换幂等且无状态泄漏,不会出现"删除消息丢了导致脏节点残留"——注册中心的 watch 本来也是推全量。

验证:

go build ./pkg/balancer/...
# 预期:无任何输出(编译通过)

第 2 步:实现轮询 RoundRobin

目标:最基础的算法,验证接口设计是否合理。

与上文 3.2.1 节的演示版相比,工程版有两点不同:节点是 ServiceInstance 而非字符串;实例列表会被 Resolver 并发刷新,要用读写锁保护切片,计数器依旧走原子操作。

// pkg/balancer/roundrobin.go
package balancer

import (
	"sync"
	"sync/atomic"

	"github.com/yourname/content-community/pkg/registry"
)

// RoundRobin 轮询负载均衡
type RoundRobin struct {
	mu        sync.RWMutex                // 实例列表会被并发更新,读多写少用 RWMutex
	instances []*registry.ServiceInstance // 候选实例
	counter   uint64                      // 原子计数器,Pick 时无锁自增
}

// Pick 按顺序依次返回实例:A → B → C → A …
func (r *RoundRobin) Pick(key string) (*registry.ServiceInstance, error) {
	r.mu.RLock()
	defer r.mu.RUnlock()
	if len(r.instances) == 0 {
		return nil, ErrNoInstance
	}
	// 原子 +1 后先减 1 再取模,保证第一次请求拿到下标 0
	n := atomic.AddUint64(&r.counter, 1)
	return r.instances[(n-1)%uint64(len(r.instances))], nil
}

// UpdateInstances 全量替换实例列表
func (r *RoundRobin) UpdateInstances(instances []*registry.ServiceInstance) {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.instances = instances
}

三个关键点:① sync.RWMutex 因为实例列表读多写少,允许多个读并发;② 计数器 counter 不放在锁的保护范围内,它用自己的原子操作(原因见下方 hint);③ defer r.mu.RUnlock() 必须用 defer,否则 22 行那个提前 return 的分支会漏掉解锁造成死锁。

复习提示:

⚠️ 为什么 counter 不放进 RWMutex 保护的范围?

有人会问:“既然都加了 RLock 了,为什么不直接 r.counter++?”

因为 RLock 允许多个读操作并发执行!如果两个 goroutine 同时拿到 RLock 并同时执行 r.counter++,这个自增操作不是原子的(读-改-写三步),依然会丢计数。

读锁只保护"不被写操作干扰",不提供"多个读者之间互斥"。 所以计数器必须自己保证原子性。这是一个非常经典的新手误区。

验证:

go build ./pkg/balancer/...
# 预期:无输出即通过(请求分布留到第 8 步实测)

第 3 步:实现平滑加权轮询 SmoothWRR(本章核心)

目标:写出 Nginx 同款算法,并用单测把 3.2.3 节手推的 7 次序列锁进代码里。

算法步骤与 7 次推演表见 3.2.3 节,这里聚焦工程化差异:UpdateInstances 之后要重建权重状态,权重非法时要兜底。

复习提示:

⚠️ 坑 1 · UpdateInstances 之后必须重建 currentWeight

直接换节点列表的话,旧节点留下的"欠账"会算到同下标的新节点头上,分布立刻失真。

举个真实的故障场景:A(weight=5) 跑到 currentWeight=-2 时,B 实例下线了。Resolver 推送新名单 [A, C]——如果你只更新了节点数组,C 会继承 B 的位置,但没人继承 B 的 currentWeight,于是 C 从 0 开始,而 A 从 -2 开始,前两次请求都会打到 C。这完全违背了 5:1 的配比。

⚠️ 坑 2 · 权重为 0 或负数时必须兜底

权重为 0 的节点永远选不中(见 3.2.3 坑 1)。兜底成 1,保证实例还能被选中——“偶尔分到一点流量"总比"永远分不到"好,而且要防止有人注册服务时忘了填权重。

// pkg/balancer/smoothwrr.go
package balancer

import (
	"sync"

	"github.com/yourname/content-community/pkg/registry"
)

// wrrNode 平滑加权轮询的内部节点
type wrrNode struct {
	instance      *registry.ServiceInstance
	weight        int // 初始权重,构造后不变
	currentWeight int // 当前权重,每次 Pick 都会变(可能是负数)
}

// SmoothWRR 平滑加权轮询(Nginx 同款算法)
type SmoothWRR struct {
	mu    sync.Mutex
	nodes []*wrrNode
}

// Pick 每次选 currentWeight 最大的节点
func (s *SmoothWRR) Pick(key string) (*registry.ServiceInstance, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if len(s.nodes) == 0 {
		return nil, ErrNoInstance
	}
	// 步骤 1:所有节点 currentWeight += weight,顺便累加总权重
	total := 0
	for _, n := range s.nodes {
		total += n.weight
		n.currentWeight += n.weight
	}
	// 步骤 2:找 currentWeight 最大的节点(打平时保留先遍历到的,见 3.2.3 节)
	best := s.nodes[0]
	for _, n := range s.nodes[1:] {
		if n.currentWeight > best.currentWeight {
			best = n
		}
	}
	// 步骤 3:选中节点"还债",扣掉总权重
	best.currentWeight -= total
	return best.instance, nil
}

// UpdateInstances 全量替换实例,并重建权重状态(见上方坑 1)
func (s *SmoothWRR) UpdateInstances(instances []*registry.ServiceInstance) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.nodes = s.nodes[:0] // 复用底层数组,减少 GC
	for _, ins := range instances {
		w := ins.Weight
		if w <= 0 {
			w = 1 // 权重缺失/非法兜底为 1,保证实例还能被选中
		}
		s.nodes = append(s.nodes, &wrrNode{instance: ins, weight: w})
	}
}

三个关键点:① weight 与 currentWeight 必须分开存(前者是"应该分多少"的声明且不变,后者每次 Pick 都变);② 用 sync.Mutex 而非 RWMutex,因为 Pick 会修改 currentWeight,是写操作;③ 第 39 行是严格大于,改成 >= 会让打平时选后一个节点,序列变成 A A C A B A A(也对,但要和单测保持一致)。另外 s.nodes = s.nodes[:0] 是"长度归零但保留底层数组容量"的常用优化,避免每次更新都重新分配内存。

写单测:把手推的 7 次序列锁进代码
// pkg/balancer/smoothwrr_test.go
package balancer

import (
	"strings"
	"testing"

	"github.com/yourname/content-community/pkg/registry"
)

// 验证 A:B:C = 5:1:1 时,连续 7 次 Pick 的序列是 A A B A C A A
func TestSmoothWRR(t *testing.T) {
	s := &SmoothWRR{}
	s.UpdateInstances([]*registry.ServiceInstance{
		{InstanceID: "A", Weight: 5},
		{InstanceID: "B", Weight: 1},
		{InstanceID: "C", Weight: 1},
	})
	var seq []string
	for i := 0; i < 7; i++ {
		ins, err := s.Pick("")
		if err != nil {
			t.Fatal(err)
		}
		seq = append(seq, ins.InstanceID)
	}
	if got, want := strings.Join(seq, " "), "A A B A C A A"; got != want {
		t.Fatalf("序列 = %q,期望 %q", got, want)
	}
}

// 验证周期性:跑满 14 次(两个周期),A 应该恰好出现 10 次
func TestSmoothWRRPeriod(t *testing.T) {
	s := &SmoothWRR{}
	s.UpdateInstances([]*registry.ServiceInstance{
		{InstanceID: "A", Weight: 5},
		{InstanceID: "B", Weight: 1},
		{InstanceID: "C", Weight: 1},
	})
	count := map[string]int{}
	for i := 0; i < 14; i++ {
		ins, _ := s.Pick("")
		count[ins.InstanceID]++
	}
	if count["A"] != 10 || count["B"] != 2 || count["C"] != 2 {
		t.Fatalf("14 次分布 = A:%d B:%d C:%d,期望 A:10 B:2 C:2",
			count["A"], count["B"], count["C"])
	}
}

验证:

go test ./pkg/balancer/ -run TestSmoothWRR -v
# 预期:
# === RUN   TestSmoothWRR
# --- PASS: TestSmoothWRR (0.00s)
# === RUN   TestSmoothWRRPeriod
# --- PASS: TestSmoothWRRPeriod (0.00s)
# PASS
# ok      github.com/yourname/content-community/pkg/balancer
复习提示:

🔍 如果测试失败了,按这个顺序排查:

  1. 序列是 A A C A B A A → 你把 > 写成了 >=
  2. 序列是 B C A B C A B → 你忘了减 1,或者第一轮的 currentWeight 初始值不是 0
  3. 序列是 A A A A A B C → 你写成了朴素加权轮询(展开列表法),没有实现 currentWeight 机制
  4. 序列是 A A B A C A A 但 TestSmoothWRRPeriod 失败 → 检查 UpdateInstances 是否正确重建了节点

第 4 步:实现随机与最少连接

目标:补齐随机与最小连接两种算法。随机几行就够;最少连接的关键是维护每个实例的"在途请求数":Pick 选最小的并 +1,请求结束后调用 Done 减回去。

// pkg/balancer/random.go
package balancer

import (
	"math/rand"
	"sync"

	"github.com/yourname/content-community/pkg/registry"
)

// Random 随机负载均衡
type Random struct {
	mu        sync.RWMutex
	instances []*registry.ServiceInstance
}

func (r *Random) Pick(key string) (*registry.ServiceInstance, error) {
	r.mu.RLock()
	defer r.mu.RUnlock()
	if len(r.instances) == 0 {
		return nil, ErrNoInstance
	}
	// 随机数对实例数取模,流量越大分布越均匀
	return r.instances[rand.Intn(len(r.instances))], nil
}

func (r *Random) UpdateInstances(instances []*registry.ServiceInstance) {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.instances = instances
}
// pkg/balancer/leastconn.go
package balancer

import (
	"sync"

	"github.com/yourname/content-community/pkg/registry"
)

// LeastConn 最少连接:选在途请求数最少的实例
type LeastConn struct {
	mu        sync.Mutex
	instances []*registry.ServiceInstance
	inflight  map[string]int // InstanceID -> 在途请求数
}

func NewLeastConn() *LeastConn {
	return &LeastConn{inflight: make(map[string]int)}
}

func (l *LeastConn) Pick(key string) (*registry.ServiceInstance, error) {
	l.mu.Lock()
	defer l.mu.Unlock()
	if len(l.instances) == 0 {
		return nil, ErrNoInstance
	}
	// 找在途数最小的(新实例没有记录相当于 0,天然优先承接请求)
	best := l.instances[0]
	for _, ins := range l.instances[1:] {
		if l.inflight[ins.InstanceID] < l.inflight[best.InstanceID] {
			best = ins
		}
	}
	l.inflight[best.InstanceID]++ // 选中即在途 +1
	return best, nil
}

// Done 一次请求结束后必须调用,在途数 -1
func (l *LeastConn) Done(instanceID string) {
	l.mu.Lock()
	defer l.mu.Unlock()
	l.inflight[instanceID]--
}

// UpdateInstances 全量替换并重建计数,避免残留已下线实例
func (l *LeastConn) UpdateInstances(instances []*registry.ServiceInstance) {
	l.mu.Lock()
	defer l.mu.Unlock()
	l.instances = instances
	l.inflight = make(map[string]int) // 重建:旧实例的计数会永久泄漏
}

三个关键点:① Go 1.20+ 全局 rand 自动播种,Go 1.19 及更早必须在 init() 里 rand.Seed(time.Now().UnixNano());② inflight map 必须 make 初始化,否则第一次 ++ 会 panic(nil map 不可写);③ 读不存在的 key 返回零值 0,所以新实例天然优先被选中;UpdateInstances 里重建 inflight 是为了避免已下线实例的计数永久留在 map 里造成内存泄漏。

复习提示:

⚠️ 坑 · LeastConn 的 Done 忘了调用(或只在成功时调用)

现象:在途数只增不减 → 老实例看起来越来越"忙" → 流量全歪到新上线的实例 → 老实例闲死、新实例被打爆。这是本节最容易犯的错,它不报错,只是分布"慢慢变歪",等发现时往往已过去几小时。

正确写法(二选一):

// 写法 1(最稳):defer 调用,任何分支都不会漏
ins, _ := lb.Pick(key)
defer lb.Done(ins.InstanceID)

// 写法 2:挂在 gRPC 的 Done 回调里(第 7 步就是这么做的)
return balancer.PickResult{
    SubConn: c,
    Done: func(ctx context.Context, di balancer.PickDoneInfo) {
        lb.Done(id) // 成功和失败都会走到这里
    },
}, nil

// ❌ 错误写法:只在成功分支调用 → 失败时计数器泄漏
if err == nil {
    lb.Done(id)
}

验证:

go build ./pkg/balancer/...
# 预期:无输出即通过

第 5 步:实现一致性哈希 ConsistentHash

目标:实现哈希环 + 虚拟节点,并写单测验证"会话保持"。

哈希环、二分查找与虚拟节点原理见 3.2.5 节。工程版直接持有 *registry.ServiceInstance,UpdateInstances 全量重建环——实例增删只影响相邻区间的 key,这正是它能做会话保持的原因。

// pkg/balancer/consistenthash.go
package balancer

import (
	"hash/crc32"
	"sort"
	"strconv"
	"sync"

	"github.com/yourname/content-community/pkg/registry"
)

// ConsistentHash 一致性哈希,默认每个真实节点 100 个虚拟节点
type ConsistentHash struct {
	mu       sync.RWMutex
	replicas int                                  // 每个真实节点的虚拟节点数
	keys     []uint32                             // 排好序的虚拟节点哈希,供二分查找
	hashMap  map[uint32]*registry.ServiceInstance // 虚拟节点哈希 -> 真实实例
}

// NewConsistentHash replicas 传 0 或负数时用默认值 100
func NewConsistentHash(replicas int) *ConsistentHash {
	if replicas <= 0 {
		replicas = 100
	}
	return &ConsistentHash{
		replicas: replicas,
		hashMap:  make(map[uint32]*registry.ServiceInstance),
	}
}

// Pick 根据 key(如 user_id)顺时针找到第一个虚拟节点对应的真实实例
func (c *ConsistentHash) Pick(key string) (*registry.ServiceInstance, error) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	if len(c.keys) == 0 {
		return nil, ErrNoInstance
	}
	h := crc32.ChecksumIEEE([]byte(key))
	// 二分找第一个 >= h 的虚拟节点(顺时针方向)
	idx := sort.Search(len(c.keys), func(i int) bool { return c.keys[i] >= h })
	if idx == len(c.keys) {
		idx = 0 // 走到环尾则绕回环首
	}
	return c.hashMap[c.keys[idx]], nil
}

// UpdateInstances 全量重建哈希环(实例增删后调用)
func (c *ConsistentHash) UpdateInstances(instances []*registry.ServiceInstance) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.keys = c.keys[:0]
	c.hashMap = make(map[uint32]*registry.ServiceInstance)
	for _, ins := range instances {
		for i := 0; i < c.replicas; i++ {
			// 虚拟节点名 = 编号 + 实例ID,确保每个虚拟节点哈希不同
			h := crc32.ChecksumIEEE([]byte(strconv.Itoa(i) + ins.InstanceID))
			c.keys = append(c.keys, h)
			c.hashMap[h] = ins
		}
	}
	sort.Slice(c.keys, func(i, j int) bool { return c.keys[i] < c.keys[j] }) // 排序后才能二分
}
写单测:验证会话保持
// pkg/balancer/consistenthash_test.go
package balancer

import (
	"testing"

	"github.com/yourname/content-community/pkg/registry"
)

// 验证同一个 key 始终落到同一个实例(会话保持)
func TestConsistentHashStickiness(t *testing.T) {
	c := NewConsistentHash(100)
	c.UpdateInstances([]*registry.ServiceInstance{
		{InstanceID: "user-service-1"},
		{InstanceID: "user-service-2"},
		{InstanceID: "user-service-3"},
	})
	first, err := c.Pick("user:42")
	if err != nil {
		t.Fatal(err)
	}
	for i := 0; i < 100; i++ {
		got, err := c.Pick("user:42")
		if err != nil {
			t.Fatal(err)
		}
		if got.InstanceID != first.InstanceID {
			t.Fatalf("同一个 key 落到了不同实例:%s != %s", got.InstanceID, first.InstanceID)
		}
	}
}

// 验证分布均匀度:1000 个 key 应该比较均匀地散在 3 个实例上
func TestConsistentHashBalance(t *testing.T) {
	c := NewConsistentHash(100)
	c.UpdateInstances([]*registry.ServiceInstance{
		{InstanceID: "n1"},
		{InstanceID: "n2"},
		{InstanceID: "n3"},
	})
	count := map[string]int{}
	for i := 0; i < 3000; i++ {
		ins, _ := c.Pick("user:" + strconv.Itoa(i))
		count[ins.InstanceID]++
	}
	t.Logf("分布:n1=%d n2=%d n3=%d", count["n1"], count["n2"], count["n3"])
	// 每个实例至少分到 300 个(理想是 1000 个,一致性哈希允许偏差,但不能太离谱)
	for _, n := range []string{"n1", "n2", "n3"} {
		if count[n] < 300 {
			t.Fatalf("实例 %s 只分到 %d 个请求,数据倾斜严重", n, count[n])
		}
	}
}

验证:

go test ./pkg/balancer/ -run TestConsistentHash -v
# 预期:
# === RUN   TestConsistentHashStickiness
# --- PASS: TestConsistentHashStickiness (0.00s)
# === RUN   TestConsistentHashBalance
#     consistenthash_test.go:xx: 分布:n1=1032 n2=978 n3=990
# --- PASS: TestConsistentHashBalance (0.00s)
# PASS
复习提示:

📊 关于"分布:n1=1032 n2=978 n3=990"这个输出:

这是 100 个虚拟节点时的典型结果——偏差在 ±5% 以内,比没有虚拟节点时(可能出现 2000 / 500 / 500)好太多了。

注意:具体数字会因 Go 版本和 ServiceInstance.InstanceID 的命名而不同,不要把它当成固定期望值去断言。如果你想写严格的断言,应该用"每个节点占比在 [25%, 42%] 之间"这种范围断言,而不是写死数字。这也是原文反复强调"预期输出不确定时宁可不写具体数值,也不要编造“的原因——上面这个数字是实测值,你在自己机器上跑会得到不同的结果,但量级和分布形态应该一致。

动手试试:把 NewConsistentHash(100) 改成 NewConsistentHash(1)(相当于没有虚拟节点),再跑一次 TestConsistentHashBalance,你会看到明显的倾斜——这就是虚拟节点存在的意义。

第 6 步:健康检查:连续失败剔除 + 半开恢复

目标:实现一个状态机——连续 3 次调用失败(超时或 5xx)就剔除该实例,10 秒后进入"半开"状态只放一个试探请求,成功即恢复。

它是第 14 章熔断器的雏形,因此单独封装成 HealthChecker,供第 7 步的 Picker 复用。

补课胶囊 · 健康检查与"半开"状态 一句话:自动发现"连得上但已经不工作"的节点,暂时把它踢出名单,过一会儿再试探它恢复没有。 类比:你给一个朋友打电话,连着 3 次都没人接。你会想"他可能在忙,我过 10 分钟再试"——而不是每秒重拨一次,也不是从此永远不打了。10 分钟后你拨了一次,通了,说明他没事了,恢复正常联系频率。

三个状态:

状态行为类比
健康(Closed)正常接流量正常联系
剔除(Open)完全不接流量,持续 10 秒决定"他现在不方便,别打了"
半开(Half-Open)只放 1 个试探请求,成功则恢复健康,失败则重新进入剔除“我打一次试试,通了就恢复正常”

回到主线:半开状态是熔断器的精髓。第 14 章会把它扩展成完整的熔断 / 限流 / 降级体系。

// pkg/balancer/health.go
package balancer

import (
	"sync"
	"time"

	"go.uber.org/zap"
)

const (
	failThreshold = 3                // 连续失败 3 次判定不健康
	coolDown      = 10 * time.Second // 剔除 10 秒后进入半开
)

// nodeHealth 单个实例的健康状态
type nodeHealth struct {
	consecutiveFails int       // 连续失败次数
	unhealthyUntil   time.Time // 非零值表示被剔除,过了该时间点进入半开
	probing          bool      // 半开状态下是否已放出试探请求
}

// HealthChecker 实例健康状态机:正常 →(连续 3 次失败)→ 剔除 →(10 秒)→ 半开 → 成功则恢复
type HealthChecker struct {
	mu     sync.Mutex
	states map[string]*nodeHealth // key 用 InstanceID 或节点地址
}

func NewHealthChecker() *HealthChecker {
	return &HealthChecker{states: make(map[string]*nodeHealth)}
}

// Allow 判断实例当前能否被选中;半开时只放行一个试探请求
func (h *HealthChecker) Allow(id string) bool {
	h.mu.Lock()
	defer h.mu.Unlock()
	st, ok := h.states[id]
	if !ok || st.unhealthyUntil.IsZero() {
		return true // 没有记录或处于健康状态
	}
	// 冷却期已过且还没放试探请求:放一个进去
	if time.Now().After(st.unhealthyUntil) && !st.probing {
		st.probing = true
		return true
	}
	return false
}

// ReportSuccess 调用成功:清空状态,实例完全恢复
func (h *HealthChecker) ReportSuccess(id string) {
	h.mu.Lock()
	defer h.mu.Unlock()
	delete(h.states, id)
}

// ReportFailure 调用失败:连续 3 次则剔除 10 秒
func (h *HealthChecker) ReportFailure(id string) {
	h.mu.Lock()
	defer h.mu.Unlock()
	st := h.states[id]
	if st == nil {
		st = &nodeHealth{}
		h.states[id] = st
	}
	st.consecutiveFails++
	if st.consecutiveFails >= failThreshold {
		st.unhealthyUntil = time.Now().Add(coolDown)
		st.probing = false       // 重置试探标志,准备下一轮半开
		st.consecutiveFails = 0  // 重置计数,避免下次失败立刻又剔除
		zap.L().Warn("节点被暂时剔除",
			zap.String("instance", id),
			zap.Duration("retry_after", coolDown))
	} else if st.probing {
		// 半开试探请求失败:立即重新剔除。否则 probing 卡在 true,
		// Allow 永远拒绝、consecutiveFails 永远攒不到 3,节点永久"假死"
		st.unhealthyUntil = time.Now().Add(coolDown)
		st.probing = false
		st.consecutiveFails = 0
		zap.L().Warn("节点半开试探失败,重新剔除",
			zap.String("instance", id),
			zap.Duration("retry_after", coolDown))
	}
}
逐行讲解
行代码为什么这么写
18unhealthyUntil time.Time用"截止时间"而不是"倒计时/布尔标记"。好处是判断时只需 time.Now().After(...),不需要额外的定时器 goroutine 去翻转状态
19probing bool半开状态的节流阀。见下方坑
34-38Allow 的三个分支① 无记录 → 放行;② 冷却期未过 → 拒绝;③ 冷却期过了但已放过试探 → 继续拒绝(等试探结果)
45-49delete(h.states, id)成功后直接删除记录,而不是把计数清零。这样既不占内存,语义也最干净:“没有记录 = 完全健康”
60-63剔除时同时重置 probing 和 consecutiveFailsprobing=false 是为了让下一轮冷却期结束后能再放试探;consecutiveFails=0 是为了避免"刚剔除就又凑满 3 次"导致冷却期无限延长
复习提示:

⚠️ 坑 · 半开状态务必只放"一个"试探请求(probing 标志的作用)

如果放开全部流量会怎样? 一个刚刚因为过载而被剔除的节点,冷却 10 秒后如果立刻收到全量流量,会二次被打挂——然后又被剔除,10 秒后又被打挂,循环往复。这个节点就陷入了"死亡螺旋"。

probing 标志就是那个"只开一条缝"的阀门:

冷却期结束
   ↓
第 1 个请求进来 → probing 从 false 变 true,放行 ✅
第 2 个请求进来 → probing 已是 true,拒绝 ❌
第 3 个请求进来 → probing 已是 true,拒绝 ❌
   ↓
试探请求返回
   ├─ 成功 → ReportSuccess → 删除记录 → 完全恢复 ✅
   └─ 失败 → ReportFailure → 立即重新剔除(重新进入冷却期)

这正是第 14 章熔断器"半开"状态的经典设计。面试时被问到"熔断器的半开状态为什么要限制流量",答案就在这里。

验证:

go build ./pkg/balancer/...
# 预期:无输出即通过(剔除效果第 8 步实测)

第 7 步:注册为 gRPC 负载均衡策略

目标:把前面写的算法接进 gRPC,注册两个策略名,并让健康检查与 Picker 联动。

Picker 骨架与 3.4.6 节相同,这一步补齐工程细节:

  • 注册 weighted_round_robin 和 consistent_hash 两个策略名
  • 权重从 Address.Attributes 读(Address.Metadata 已弃用,见 3.4.6 的坑)
  • 一致性哈希的 key 从 metadata 的 x-user-id 取
  • Done 回调接健康检查

先给第 12 章的 Resolver 做一处增量修改,把权重随地址带给 Picker:

// pkg/registry/resolver.go —— resolve() 内的增量修改
// === 新增/修改开始 ===
// 原来是:addrs = append(addrs, resolver.Address{Addr: ins.Addr()})
addrs = append(addrs, resolver.Address{
	Addr:       ins.Addr(),
	Attributes: attributes.New("weight", ins.Weight), // 权重随地址带给 Picker
})
// === 新增/修改结束 ===
// import 区新增:"google.golang.org/grpc/attributes"
复习提示:

⚠️ 为什么必须改 Resolver?

gRPC 的 resolver.Address 是 Resolver 和 Balancer 之间唯一的数据通道。你注册时写进 etcd 的 Weight 字段,Resolver 不主动带上的话,Picker 那边根本拿不到。

这是很多人"配了权重却没效果"的根因——权重在注册中心里躺着,但没人把它传给 LB。

先看一眼权重的完整旅程:从 etcd 里躺着的 Weight 字段,到真正被挑中发请求的那条连接,要经过下图这几棒接力——正文的每一段代码,都在给图上的某条边补实现。

flowchart LR
    subgraph ETCD["注册中心 etcd"]
        E["/services/user/*
实例信息含 Weight 字段"] end subgraph RES["Resolver(第 12 章改造)"] RV["watch 实例变化"] --> RA["resolver.Address
Attributes 携带 weight(int)"] end subgraph BAL["Balancer(本章核心新文件)"] BC["buildConns
按 int 断言取出权重"] WRR["平滑加权轮询 Picker"] CH["一致性哈希 Picker
key = x-user-id"] HC["健康检查
Done 回调上报失败"] end SC["SubConn 发起真正的 RPC"] E -->|"变更推送"| RV RA -->|"PickerBuildInfo"| BC BC --> WRR BC --> CH WRR --> SC CH --> SC SC -.->|"请求结束"| HC HC -.->|"连续失败剔除节点"| BC

这张图怎么读:权重沿实线正向传递——etcd 里 /services/user/* 的 Weight 字段经「变更推送」到达 Resolver 的 watch,Resolver 把它塞进 resolver.Address 的 Attributes(int 类型),再随 PickerBuildInfo 交给 Balancer 侧的 buildConns 按 int 断言取出;随后分两路:平滑加权轮询 Picker 和一致性哈希 Picker(key 取自请求里的 x-user-id)各自选出节点,交给 SubConn 发起真正的 RPC。虚线是失败回路:请求结束后由 Done 回调向健康检查上报结果,连续失败的节点被剔除并反馈给 buildConns,下一轮构建 Picker 时它就不再参与选择。本步之后的代码,就是在逐条实现这些边。

然后是本章的核心新文件:

// pkg/balancer/picker.go
package balancer

import (
	"context"
	"hash/crc32"
	"sort"
	"strconv"
	"sync"

	"google.golang.org/grpc/balancer"
	"google.golang.org/grpc/balancer/base"
	"google.golang.org/grpc/metadata"
	"google.golang.org/grpc/resolver"
)

const (
	// WRRName 平滑加权轮询策略名,客户端 ServiceConfig 里填它
	WRRName = "weighted_round_robin"
	// ConsistentHashName 一致性哈希策略名
	ConsistentHashName = "consistent_hash"
)

func init() {
	// 连接管理交给 gRPC 官方 base 骨架,我们只提供 Picker(分工见 3.4.7 节)
	balancer.Register(base.NewBalancerBuilder(WRRName, &wrrPickerBuilder{}, base.Config{HealthCheck: true}))
	balancer.Register(base.NewBalancerBuilder(ConsistentHashName, &chPickerBuilder{}, base.Config{HealthCheck: true}))
}

// connInfo 把 SubConn、地址、权重绑在一起
type connInfo struct {
	conn   balancer.SubConn
	addr   resolver.Address
	weight int
}

// buildConns 从就绪连接里取权重(Resolver 已写入 Attributes)
func buildConns(info base.PickerBuildInfo) []*connInfo {
	conns := make([]*connInfo, 0, len(info.ReadySCs))
	for sc, sci := range info.ReadySCs {
		w := 1 // 默认权重
		if v, ok := sci.Address.Attributes.Value("weight").(int); ok && v > 0 {
			w = v
		}
		conns = append(conns, &connInfo{conn: sc, addr: sci.Address, weight: w})
	}
	return conns
}

// doneFunc 两个 Picker 共用的 RPC 结束回调:失败上报健康检查,成功则恢复
func doneFunc(h *HealthChecker, id string) func(context.Context, balancer.PickDoneInfo) {
	return func(ctx context.Context, di balancer.PickDoneInfo) {
		if di.Err != nil {
			h.ReportFailure(id)
			return
		}
		h.ReportSuccess(id)
	}
}
// pkg/balancer/picker.go(续)—— 策略一:平滑加权轮询
// ---------- 策略一:平滑加权轮询 ----------

type wrrPickerBuilder struct{}

// Build 在就绪连接变化时被调用,重建 Picker 的同时重置 currentWeight(见第 3 步的坑)
func (b *wrrPickerBuilder) Build(info base.PickerBuildInfo) balancer.Picker {
	conns := buildConns(info)
	if len(conns) == 0 {
		return base.NewErrPicker(balancer.ErrNoSubConnAvailable)
	}
	return &wrrPicker{
		conns:   conns,
		current: make([]int, len(conns)), // 与 conns 下标对齐的 currentWeight
		health:  NewHealthChecker(),
	}
}

type wrrPicker struct {
	mu      sync.Mutex
	conns   []*connInfo
	current []int
	health  *HealthChecker
}

func (p *wrrPicker) Pick(info balancer.PickInfo) (balancer.PickResult, error) {
	p.mu.Lock()
	defer p.mu.Unlock()
	// 步骤 1:先用健康检查过滤,活着的节点 currentWeight += weight
	idxs := make([]int, 0, len(p.conns))
	total := 0
	for i, c := range p.conns {
		if !p.health.Allow(c.addr.Addr) {
			continue // 被剔除的节点不参与选择
		}
		idxs = append(idxs, i)
		total += c.weight
		p.current[i] += c.weight
	}
	if len(idxs) == 0 {
		return balancer.PickResult{}, balancer.ErrNoSubConnAvailable
	}
	// 步骤 2:选 currentWeight 最大的节点
	best := idxs[0]
	for _, i := range idxs[1:] {
		if p.current[i] > p.current[best] {
			best = i
		}
	}
	// 步骤 3:选中节点扣掉总权重("还债")
	p.current[best] -= total
	id := p.conns[best].addr.Addr
	return balancer.PickResult{SubConn: p.conns[best].conn, Done: doneFunc(p.health, id)}, nil
}
// pkg/balancer/picker.go(续)—— 策略二:一致性哈希
// ---------- 策略二:一致性哈希 ----------

type chPickerBuilder struct{}

func (b *chPickerBuilder) Build(info base.PickerBuildInfo) balancer.Picker {
	conns := buildConns(info)
	if len(conns) == 0 {
		return base.NewErrPicker(balancer.ErrNoSubConnAvailable)
	}
	p := &chPicker{health: NewHealthChecker(), ring: make(map[uint32]*connInfo)}
	for _, c := range conns {
		for i := 0; i < 100; i++ { // 每个连接 100 个虚拟节点,解决数据倾斜
			h := crc32.ChecksumIEEE([]byte(strconv.Itoa(i) + c.addr.Addr))
			p.keys = append(p.keys, h)
			p.ring[h] = c
		}
	}
	sort.Slice(p.keys, func(i int, j int) bool { return p.keys[i] < p.keys[j] })
	return p
}

type chPicker struct {
	mu     sync.RWMutex
	keys   []uint32             // 排好序的虚拟节点哈希
	ring   map[uint32]*connInfo // 虚拟节点哈希 -> 连接
	health *HealthChecker
}

func (p *chPicker) Pick(info balancer.PickInfo) (balancer.PickResult, error) {
	// 一致性哈希的 key 从请求 metadata 取(业务侧提前 set 进 x-user-id)
	key := ""
	if md, ok := metadata.FromOutgoingContext(info.Ctx); ok {
		if v := md.Get("x-user-id"); len(v) > 0 {
			key = v[0]
		}
	}
	p.mu.RLock()
	defer p.mu.RUnlock()
	// 步骤 1:key 落环,二分找顺时针第一个虚拟节点
	h := crc32.ChecksumIEEE([]byte(key))
	idx := sort.Search(len(p.keys), func(i int) bool { return p.keys[i] >= h })
	// 步骤 2:从命中点顺时针扫描,跳过刚被健康检查剔除的节点
	for n := 0; n < len(p.keys); n++ {
		c := p.ring[p.keys[(idx+n)%len(p.keys)]]
		if p.health.Allow(c.addr.Addr) {
			return balancer.PickResult{SubConn: c.conn, Done: doneFunc(p.health, c.addr.Addr)}, nil
		}
	}
	return balancer.PickResult{}, balancer.ErrNoSubConnAvailable
}
逐行讲解(挑最关键的三处)
位置代码为什么这么写
wrrPicker.Pick 步骤 1if !p.health.Allow(...) { continue }健康检查与加权算法的联动点。被剔除的节点不进 idxs,自然也不进 total——这一点非常重要:如果 total 包含了被剔除节点的权重,还债金额就偏大,剩余节点的分布会失真(对应 3.2.3 节的坑 3)
chPicker.Pick 步骤 2for n := 0; n < len(p.keys); n++ 顺时针扫描一致性哈希遇到"目标节点不健康"时,不能直接返回错误——那样会丢请求。正确做法是顺时针继续找下一个可用节点(相当于把被剔除节点的区间临时交给它的后继节点)
两处 Done: doneFunc(...)统一的回调无论成功失败都会被调用(gRPC 保证),所以 Done 里做计数/健康上报绝不会泄漏(对应第 4 步的坑)
客户端接入(post-service)

只需匿名引入 + 指定策略名:

// cmd/post-service/main.go —— 修改:匿名引入 + 指定策略
import (
	_ "github.com/yourname/content-community/pkg/balancer" // 只为触发 init() 注册两个策略

	"google.golang.org/grpc"
)

conn, err := grpc.Dial(
	"etcd:///user-service",
	grpc.WithDefaultServiceConfig(`{"loadBalancingConfig":[{"weighted_round_robin":{}}]}`),
)
复习提示:

⚠️ 坑 · 忘了匿名引入 _ "github.com/yourname/content-community/pkg/balancer"

报错:unknown balancer "weighted_round_robin"(或类似提示),gRPC 会在 Dial 或第一次调用时失败。

为什么? 见 3.4.4 节的补课胶囊——Go 没有插件注册中心,全靠 import 触发 init() 完成注册。不 import,你的 init() 就永远不会执行,策略也就不存在。

排查技巧:如果报这个错,先检查 import 在不在;如果 import 在还报,检查 init() 里的 balancer.Register 有没有被条件分支跳过,或者策略名是否拼错(weighted_round_robin vs weightedroundrobin,前者是我们自己注册的,后者是 gRPC 官方的)。

验证:

go build ./...
# 预期:无输出即通过

第 8 步:起两个实例实测

目标:用真实流量验证权重分布、会话保持、故障转移三个特性。

策略名放进配置(viper 读取,换算法不用改代码):

# configs/post-service.yaml —— 新增配置项
grpc:
  balancer: weighted_round_robin # 想验证会话保持就换成 consistent_hash
// cmd/post-service/main.go —— 策略名从配置读,替换第 7 步的硬编码
import (
	"fmt"

	"github.com/spf13/viper"
)

policy := viper.GetString("grpc.balancer")
if policy == "" {
	policy = "weighted_round_robin" // 兜底默认值
}
svcCfg := fmt.Sprintf(`{"loadBalancingConfig":[{%q:{}}]}`, policy)
conn, err := grpc.Dial("etcd:///user-service",
	grpc.WithDefaultServiceConfig(svcCfg),
)
复习提示:

🔧 fmt.Sprintf 里的 %q 是什么?

%q 会把字符串转义成带双引号的 Go 字符串字面量。所以 fmt.Sprintf({%q:{}}, "weighted_round_robin") 得到的是:

{"weighted_round_robin":{}}

正好是 ServiceConfig 需要的格式——外层已经带上了双引号,不用你自己拼。%q 还会自动转义特殊字符,比手写字符串拼接安全得多。

实测流程
# 终端 1/2:起两个 user-service 实例,权重分别 5 和 1
# (weight 随注册进 etcd,见第 12 章)
go run ./cmd/user-service -port 9090 -weight 5
go run ./cmd/user-service -port 9091 -weight 1

# 终端 3:跑本章全部单测
go test ./pkg/balancer/... -v
# 预期:TestSmoothWRR、TestSmoothWRRPeriod、
#       TestConsistentHashStickiness、TestConsistentHashBalance 全部 PASS

# 终端 3:post-service 连发 7 次调用,观察两个实例各自打印的请求日志
go run ./cmd/post-service
# 预期:7 次请求约 5 次落在 9090、2 次落在 9091
#       且 9090 不会被连续打满(这就是"平滑"的意义)

# 把配置换成 consistent_hash 再跑:
# 预期:同一个 user_id 的请求只会出现在其中一个实例的日志里

# 故障演练:kill 掉 9090 的进程
# 预期:实例下线的场景,约 10 秒(etcd 租约到期 + Resolver 推送)后流量全到 9091
#       实例还在但连续报错的场景,3 次失败即被健康检查剔除,秒级转移
怎么观察"平滑"这个特性?

对照着看两个实例的日志时间戳/序号:

  • 没有平滑(朴素加权轮询):9090 会连续收到 5 个请求(1,2,3,4,5),9091 收到 1 个(6),9090 再收 1 个(7)——高权重节点被一梭子打过来 5 个,队列瞬间堆满。
  • 有平滑:9090 的请求被打散,中间会穿插 9091 的请求,9090 最长连续命中不超过 2 次。
复习提示:📝 关于具体序列:两个真实节点(权重 5:1)的实际顺序需要用代码验证(跑 go test -run TestSmoothWRRPeriod -v 看分布计数即可),请以你本地的实际输出为准,不要背固定答案。唯一稳定的可验证特征是:高权重节点不会被连续打满。

本章完成后的项目结构

.
├── configs
│   └── post-service.yaml          # 修改:新增 grpc.balancer 配置项
├── pkg
│   ├── balancer                   # 新增:本章的负载均衡包
│   │   ├── balancer.go            # Balancer 统一接口
│   │   ├── roundrobin.go          # 轮询
│   │   ├── smoothwrr.go           # 平滑加权轮询
│   │   ├── smoothwrr_test.go      # 5:1:1 序列单测 + 周期性单测
│   │   ├── random.go              # 随机
│   │   ├── leastconn.go           # 最少连接
│   │   ├── consistenthash.go      # 一致性哈希(100 虚拟节点)
│   │   ├── consistenthash_test.go # 会话保持 + 分布均匀度单测
│   │   ├── health.go              # 连续 3 次失败剔除 / 10 秒半开
│   │   └── picker.go              # gRPC 两个策略的注册与 Picker
│   └── registry
│       └── resolver.go            # 修改:Address.Attributes 带上 weight
└── cmd
    └── post-service
        └── main.go                # 修改:匿名引入 balancer + viper 读策略

验证清单

  • go build ./... 编译通过
  • go test ./pkg/balancer/... -v 全部 PASS,TestSmoothWRR 锁定序列 A A B A C A A
  • 同一个 user_id 连续 100 次请求始终落在同一实例(consistent_hash)
  • 权重 5:1 的两个实例,7 次请求分布为 5/2,且高权重节点不被连续命中
  • kill 一个实例后,请求在约 10 秒内全部转移到存活实例
  • 让某实例连续失败 3 次,它被立即剔除;10 秒后半开试探成功后自动恢复

五、工程实践要点

这一节是"上线前要记住的事",全部来自生产环境的真实教训。

要点 1 · 遇事不决用轮询,出了问题再换

不要一上来就设计"自适应 + 多指标 + 动态权重"的算法。轮询简单、可预测、无隐藏状态,绝大多数业务用它就够了。复杂算法带来的收益往往抵不上它的调试成本和不可预测性——一个"看起来更聪明"的 LB 在出问题时,排查难度是轮询的十倍。

正确的演进路径:

轮询 →(发现节点配置不一样)→ 加权轮询 / 平滑 WRR
     →(发现需要本地缓存命中)→ 一致性哈希
     →(发现节点经常半死不活)→ 平滑 WRR + 健康检查
     →(发现响应时间和负载强相关)→ 最快响应 / 多指标打分

每一步都应该由真实问题驱动,而不是由"我想用高级算法"驱动。

要点 2 · 权重要有上下限

动态调整权重时,务必防止两个极端:

极端后果防护
权重变成 0该节点永不选中,等于永久下线——即使它早就恢复了下限兜底到 1
权重变成极大值该节点一直被选中,其他节点永远分不到流量上限封顶(如 100)

第 6 步的 HealthChecker 和第 7 步的 Done 回调都要遵守这条。原文把它总结得很到位:0 和极值会让 LB 完全失效。

另外还有一条隐性要求:权重必须能恢复。只降不升的话,一次网络抖动就会让节点永久降级——一定要有"成功时缓慢回升"的路径。

要点 3 · Address.Metadata 已弃用,确认数据从哪来

自定义 Picker 时,务必确认三件事:

  1. 数据是从 Address.Metadata 拿还是从 Address.Attributes 拿(新版本用后者)
  2. 拿出来的类型是什么(int / int64 / string?)
  3. key 的大小写是否一致(weight vs Weight)

最稳妥的做法是先打印出来看(见 3.4.6 节的坑)。这三条里任意一条不对,你拿到的都是零值,然后静默地走兜底逻辑——不报错、不崩溃,但权重完全不生效。这是最难查的一类 bug。

要点 4 · 重试要小心放大效应

再强调一次,因为它是新手最容易踩且后果最严重的坑:

  • 一个请求最多重试 N 次,链路纵深时层层重试会把流量放大 N 的层数次方
  • 例:三层链路每层重试 3 次 → 最坏放大 27 倍
  • 必须配合链路超时控制(见第 14 章):整个链路共享一个 deadline
  • 补充手段:只对最后一跳重试、设置重试预算(重试请求占比上限)

要点 5 · 不要为了"均衡"而均衡

如果节点性能差异大(比如混部、不同代次的机器),加权才是正确选择。强行 1:1 平均分配会让弱机先挂,然后触发雪崩。

记住本章开头那句话:负载均衡的目的是"选最合适的节点",不是"让每个节点请求数一样多"。

要点 6 · gRPC 内置 LB 已能解决绝大多数问题

除非业务有强特征需求(如本地缓存命中、会话保持、灰度路由),否则没必要自己实现 Picker。roundrobin 能覆盖 80% 的场景,weightedroundrobin 再覆盖 15%。

自己实现的代价是:要自己维护健康检查、权重解析、连接变化处理,还要为它们写测试。在动手之前先问一句:“内置的真的不够用吗?” 另外别忘了给 LB 加可观测性——按节点维度的 QPS 打点、剔除事件打日志 + 告警、启动时打印生效的策略名。一个"你观察不到"的负载均衡器,等价于一个随机分发器。

复习提示:

⚠️ 一条压箱底的经验:上线前务必做一次"kill 一个实例"的演练。

很多 LB 的 bug 只在节点变化时才暴露:计数器没重置、权重没重建、哈希环没排序、Done 回调没触发……这些在正常运行时全都看不出来,只有扩缩容和故障时才爆发。

第 8 步的验证清单里专门列了这一条,请一定要做。


六、自测题与动手练习

自测题(合上书能答出来,才算懂。答案要点在第七节,先别急着翻):

  1. 为什么说"负载均衡是手段不是目的"?如果某个节点性能明显更好,正确的做法是什么?
  2. gRPC 默认的负载均衡策略叫什么?它会造成什么现象?怎么改成真正的负载均衡?
  3. 普通加权轮询 A:B:C = 1:2:3 的序列是什么?它有什么缺陷?平滑加权轮询是怎么解决的?请手写出核心的四步。
  4. A:B:C = 5:1:1 时,平滑加权轮询连续 7 次的序列是什么?请自己推一遍,不要看答案。
  5. 一致性哈希相比普通哈希(取模)解决了什么问题?虚拟节点是干嘛用的?为什么通常取 100~200 个?
  6. 动态算法(最少连接 / 最少活跃 / 最快响应)为什么说"客户端只能看到自己这一侧"?要拿到服务端真实 CPU / 内存有哪三条路?
  7. 在 gRPC 里自定义负载均衡要实现哪两个接口?它们各自的职责是什么?为什么需要两个而不是一个?
  8. Failover 的本质是什么?哪些 gRPC 错误码可以重试、哪些绝对不能?为什么重试必须配超时和退避?

动手练习(建议真做一遍,做到标注的程度算完成):

练习 1 · 手写平滑加权轮询(30 分钟)

从零实现一个 SmoothWRR(不抄第四节的工程版,先按 3.2.3 节的 40 行版本写),然后:

  • 验证 A:B:C = 5:1:1 连续 7 次 Pick 的序列是 A A B A C A A
  • 验证 14 次(两个周期)的分布是 A:10 B:2 C:2
  • 验证 3:2:1 时也有类似的周期性

完成标准:三个验证全部通过,且你能不看代码给别人讲清"为什么要减 totalWeight"。

练习 2 · 亲手换掉 gRPC 的默认策略(30 分钟)

用 WithDefaultServiceConfig 把默认 pick_first 换成 round_robin:

  • 起两个后端节点(两个端口,打印各自的请求日志)
  • 先用默认配置跑,观察:是不是只有一个节点有日志
  • 再改成 round_robin,观察:两个节点的日志是不是交替出现
  • 最后贴一个错误的策略名(如 roundrobin 不带下划线),观察报错信息

完成标准:你能说出"忘记匿名引入 balancer 包"和"策略名写错"两种情况下的报错分别长什么样。

练习 3 · Failover 小实验(40 分钟)

在自定义 Picker 的 Done 回调里实现动态权重:

  • 调用失败时把该节点 Weight 减半
  • 调用成功时 Weight 加 1
  • 设上下限:最小 1,最大 100

然后人为让某个节点返回错误(比如给它传一个非法参数),观察它是不是逐渐被"冷落"。

完成标准:高错误率节点的流量占比明显下降;停止注入错误后,它的流量能自动爬升回来(这一条最容易漏,考验你有没有设"回升"路径)。


七、模拟面试问答

这一节模拟真实面试场景。每题按问题 → 一句话结论 → 展开 → 面试官想听的关键词 → 常见失分点五段组织。三档难度:基础 4 题 / 进阶 4 题 / 追问 4 题。

面试官
【基础】 负载均衡解决什么问题?为什么说"负载均衡是手段而不是目的"?如果某节点性能明显更好,正确的做法是什么?
候选人

一句话结论:负载均衡解决的是"找到多个实例后,请求发给哪一个"的问题;它的目的是选出"最适合"处理该请求的节点,手段是量化负载并选最轻的——均衡只是结果,不是目标。某节点性能更好时,正确做法是给它更高权重(加权轮询 / 加权随机),让它多接请求,而不是机械地平均分配。

展开:服务注册与发现只回答"有哪些可用实例",不回答"发给谁"。工程上把"选最合适"这个主观判断简化成了一个可计算的模型:负载最轻的节点就是最适合的节点。既然所有节点提供的服务等价,那么每次都选负载最轻的,长此以往所有节点负载自然趋于均衡——所以"均衡"是副产品。当节点能力有差异时(16 核 vs 4 核),强行 1:1 平均会让弱机先挂、进而引发雪崩,这时必须用权重表达能力差异,让强机承担更多流量。

面试官想听的关键词:手段与目的、负载最轻即最适合、均衡是结果、权重表达处理能力、不要为了均衡而均衡。

常见失分点:把"让每个节点请求数一样多"当成负载均衡的目标(概念搞反);或者答"性能好的节点应该少接请求"(完全说反了——性能好就该多干)。

面试官
【基础】 gRPC 默认的负载均衡策略是什么?会造成什么现象?怎么改成真正的负载均衡?
候选人

一句话结论:gRPC 默认是 pick_first——永远选 Resolver 解析出的第一个节点,根本不做负载均衡。现象是:后端起了 N 个实例,但只有 1 个有流量,其余实例的监控是一条平躺的直线。改法是用 grpc.WithDefaultServiceConfig({“loadBalancingConfig”:[{“round_robin”:{}}]}) 指定 round_robin。

展开:这是本章最实用的反直觉知识点。很多人"配了服务发现却没生效",问题就在这里——服务发现是生效的(名单确实拿到了 N 个),但选节点的策略永远选第一个。pick_first 这样设计是有理由的:它保证同一 ClientConn 上的请求都打到同一后端,在需要连接亲和性或减少连接数的场景下反而是对的。排查方法有两个:看 ESTABLISHED 连接数(只连了 1 个就是 pick_first)、看各实例的 QPS 曲线。改策略时注意策略名是下划线(round_robin),而包名是连写(roundrobin),且 WithDefaultServiceConfig 多次调用会覆盖而不是追加。

面试官想听的关键词:pick_first、只有第一个节点有流量、WithDefaultServiceConfig、loadBalancingConfig、round_robin(下划线)、多次调用会覆盖。

常见失分点:以为 gRPC 默认就是轮询(最常见的错误认知);或者知道要改但把策略名写成 roundrobin;或者不知道"多次调用会覆盖"这个坑。

面试官
【基础】 普通加权轮询有什么缺陷?平滑加权轮询是怎么解决的?请说出它的核心步骤。
候选人

一句话结论:普通加权轮询(把权重展开成列表再轮询)会让高权重节点被连续命中,造成瞬时压力尖刺(如 1:2:3 → A B B C C C);平滑加权轮询通过给每个节点维护一个 currentWeight,让高权重的请求被打散(如 5:1:1 → A A B A C A A)。核心四步:① 算总权重 totalWeight;② 每个节点 currentWeight += weight;③ 选 currentWeight 最大的;④ 选中节点 currentWeight -= totalWeight。

展开:这是 Nginx 的默认算法,Kratos、gRPC、go-zero 都有实现,面试要求手写。理解的关键在于"欠账与还债":每个节点每轮攒分,谁分高谁上场,上场后要扣掉全场总分(还债),于是它接下来几轮追不上别人,别人就有机会上场。这天然地把高权重节点的请求打散了。数学上,只要权重是整数,算法必然在 totalWeight 次调用后回到初始状态——具有严格的周期性,这是它能被形式化验证的原因。工程上要注意两个坑:权重为 0 或负数必须兜底成 1;更新节点列表时必须重建 currentWeight,否则旧节点的欠账会算到新节点头上。

面试官想听的关键词:连续命中 / 扎堆、currentWeight、攒分-选最大-还债、周期性(totalWeight 次一轮回)、严格大于、更新列表要重建状态、Nginx 默认算法。

常见失分点:只讲得出"有个 currentWeight"却说不清四步顺序;忘记"选中后要减 totalWeight"这一步(这是算法的核心,漏了就退化成普通加权轮询);或者答不出"更新节点列表时要重置状态"这个工程坑。

面试官
【基础】 轮询和随机有什么区别?各自适用什么场景?加权随机是怎么实现的?
候选人

一句话结论:轮询靠计数器保证短期精确(N 个请求必然每个节点各若干个),随机靠大数定律保证长期趋近(短期可能连打同一台);请求量小时用轮询,请求量极大且追求性能时用随机。加权随机把权重画成数轴上的区间,随机扔一个点,落在谁的区间就选谁。

展开:轮询需要维护计数器,且并发下要用原子操作(还要注意"减 1"和"列表被并发替换"两个坑);随机零状态、无锁竞争,实现只要一行 nodes[rand.Intn(len(nodes))]。选择依据:请求量小(每秒几十次)时随机分布很差,必须用轮询;请求量极大时两者趋同,随机因为没有全局锁竞争反而更快。加权随机的实现是"前缀和 + 二分/线性扫描":先算总权重 total,生成 [0, total) 的随机数 r,然后累加权重找第一个 r < sum 的位置。加权随机没有平滑版本,因为随机本身就够散了。

面试官想听的关键词:计数器 vs 大数定律、短期精确 vs 长期趋近、原子操作、零状态、前缀和区间法、加权随机无平滑版。

常见失分点:说"随机比轮询更均匀"(错,随机只是长期趋近,短期波动比轮询大得多);或者不知道加权随机怎么实现(答"按权重排序"是错的,正确答案是区间法)。

面试官
【进阶】 一致性哈希相比普通哈希解决了什么问题?虚拟节点是干嘛用的?什么时候反而应该用普通取模?
候选人

一句话结论:普通哈希对节点数取模,节点增删时约 (N-1)/N 的 key 落点全变,导致缓存大面积失效甚至雪崩;一致性哈希把哈希空间组织成环,key 顺时针找第一个节点,增删节点只迁移约 1/N 的 key。虚拟节点(每个真实节点 100~200 个)解决节点少时的数据倾斜。但如果节点数固定不变(如分库分表),普通取模反而更简单高效。

展开:普通哈希在 3 节点变 4 节点时约 75% 的 key 要迁移,这些 key 的缓存全部失效、请求瞬间打向数据库——这就是缓存雪崩。一致性哈希的做法是:节点和 key 都算哈希落在 02³²-1 的环上,key 顺时针遇到的第一个节点就是目标;新增节点只"抢走"它逆时针到前一个节点之间的区间,其余完全不变。虚拟节点则是为了解决"3 个节点随机落点可能挤在一起,导致一个节点管 70% 的 key"——让每个真实节点占 100200 个位置,环上的点密了,分布自然均匀(还是大数定律)。实现上用排序数组 + sort.Search 二分,复杂度 O(log n)。选型判据很明确:节点会变 → 一致性哈希;节点固定 → 取模。

面试官想听的关键词:哈希环、顺时针找第一个、(N-1)/N vs 1/N、缓存雪崩、虚拟节点与数据倾斜、排序 + 二分、大数定律、分库分表用取模。

常见失分点:只会背"一致性哈希更高级"却在分库分表场景也硬上(分库分表节点固定,用取模更好定位数据);或者说不清虚拟节点到底解决什么问题(答"提高性能"是错的,它解决的是分布不均)。

面试官
【进阶】 最少连接、最少活跃、最快响应这三种动态算法各自有什么缺陷?为什么说"客户端只能看到自己这一侧"?
候选人

一句话结论:最少连接在多路复用(HTTP/2、gRPC)下不准——连接数少不代表请求少;最少活跃只衡量请求数量、不衡量请求大小,且要求必须有结束回调(漏调会计数泄漏);最快响应受长尾抖动影响,要选平均或 P99 而非 P999。三者共同的局限是客户端只知道自己这侧的连接数、未响应数、自己掐的响应时间,看不到服务端真实 CPU / 内存,更看不到其他客户端发给该节点的流量。

展开:多路复用下一条连接能同时跑成百上千个请求,所以"A 有 10 条连接跑 100 个请求、B 有 12 条连接跑 48 个请求"时,最少连接会错误地选 A。最快响应要注意统计量选择:P999 意味着 1000 个请求里只取最慢的 1 个,样本不足时这个值会剧烈抖动,用它选点会导致流量在两节点间来回横跳。最根本的局限是"局部视角问题"——你只知道你自己给 B 发了 5 个请求,但另外 20 个服务实例可能同时给 B 发了 500 个。要拿服务端真实指标有三条路:① 服务端上报注册中心再由注册中心推给客户端;② 服务端在响应 metadata 里附带自己的指标(gRPC 的 ORCA 协议走这条路);③ 从 Prometheus 等可观测平台拉取。

面试官想听的关键词:多路复用使连接数失真、活跃数不衡量请求大小、Done 回调不能漏、P99 vs P999、局部视角问题、三条指标获取路径。

常见失分点:只罗列三种算法却说不出各自的具体缺陷;或者把"客户端只看得到自己这一侧"简单理解成"看不到 CPU",漏掉了更关键的"看不到其他客户端的流量“这一点。

面试官
【进阶】 客户端负载均衡和服务端负载均衡有什么区别?为什么微服务时代客户端 LB 成了主流?
候选人

一句话结论:服务端 LB(Nginx / LVS / F5)是独立节点集中决策,客户端 LB 是调用方进程内自己算;微服务时代客户端 LB 主流是因为少一跳网络、无单点瓶颈、和注册中心天然打通。但两者不是替代关系,而是南北流量用服务端 LB、东西流量用客户端 LB。

展开:服务端 LB 的流量路径是"客户端 → LB → 服务端",多一跳,且 LB 本身要堆机器做高可用;它不关心后端语言,天然支持多语言,但算法受 LB 软件限制。客户端 LB 直连后端、无独立瓶颈、算法可以随便写,代价是每种语言都要实现一遍 SDK、SDK 升级要推动所有业务方。微服务时代选客户端 LB 的核心理由:内部调用量是天文数字(一个外部请求可能扇出几十次内部调用),每跳都绕 Nginx 扛不住;而客户端 LB 的计算分散在每个调用方进程里,加机器就自动加算力。实际架构里两者并存:外部流量(南北)经云 SLB / Nginx 进网关,内部服务间调用(东西)由 gRPC 客户端 LB 直连。

面试官想听的关键词:集中决策 vs 进程内决策、少一跳、无单点瓶颈、注册中心打通、多语言成本、南北流量 vs 东西流量、两者并存。

常见失分点:把两者说成"二选一"(实际是并存);或者只说"客户端 LB 性能好"却说不出"和注册中心天然配合"这个微服务场景下更关键的理由。

面试官
【进阶】 Failover 的本质是什么?哪些错误可以重试、哪些绝对不能?为什么重试必须配超时?
候选人

一句话结论:Failover 的本质是「重试 + 负载均衡」——重试时再走一次 LB,自然就换了个节点。可以重试的是 UNAVAILABLE、RESOURCE_EXHAUSTED 这类"换节点可能就好了"的错误;绝对不能重试 INVALID_ARGUMENT、NOT_FOUND、PERMISSION_DENIED、ALREADY_EXISTS(重试一万次也是错,且可能造成重复写入)。重试必须配超时是因为重试放大效应:N 层链路每层重试 M 次,最坏放大 Mᴺ 倍。

展开:这个组合的精妙之处在于你不需要写任何"换节点"的代码——重试会重新调用 Pick(),而平滑 WRR 的还债机制会让第二次大概率选中另一个节点。但重试有三个铁律:只对可重试错误码重试(因为重试不是幂等的,扣款请求第一次其实成功了、只是响应丢了,重试会扣两次);必须配退避(指数退避 + 抖动,否则所有客户端同时重试形成惊群);必须配链路超时(整个链路共享一个 deadline,见第 14 章)。放大效应举例:A→B→C 三层、每层重试 3 次,最坏放大 27 倍,会把一个"只是有点慢"的服务直接打死——这就是重试风暴。防护手段:只对最后一跳重试、设置重试预算(重试占比上限)、链路级超时。

面试官想听的关键词:重试 + LB、幂等性、可重试错误码白名单、指数退避与抖动、惊群、重试放大(Mᴺ)、重试预算、链路共享 deadline。

常见失分点:说"失败就重试"(没区分错误码,也没提幂等性);或者只知道重试要配超时,却算不出"三层各重试 3 次放大多少倍"这个具体数字。

面试官
【追问】 在 gRPC 里自定义负载均衡要实现哪两个接口?Balancer 和 Picker 的区别是什么?为什么需要拆成 Builder 和 Picker 两个?
候选人

一句话结论:要实现 balancer.PickerBuilder(根据可用连接构造 Picker)和 balancer.Picker(每次请求挑选一个节点)。Picker 只回答"这个请求发给哪个节点",Balancer 还要管 SubConn 的创建、连接、断开、重连、健康检查等全部连接生命周期——99% 的场景只需要写 Picker。拆成两个是因为连接列表变化是低频事件、选节点是高频操作。

展开:gRPC 用 base.NewBalancerBuilder(name, pickerBuilder, base.Config{HealthCheck: true}) 把"官方的连接管理骨架 + 你的 PickerBuilder"打包成一个完整的 Balancer,你只需要在 init() 里 balancer.Register 注册,然后匿名引入这个包触发 init()。拆分的意义是把耗时的准备工作只在必要时做一次:Builder 在连接集合变化时做一次性准备(解析权重、建哈希环、排序),Picker 只做高频的选点计算。另外要注意 SubConn 名字里带 Conn 但它不是一条 TCP 连接,而是到一个节点的连接池——有人误以为它是连接数并据此实现"最少连接",结果算法退化成了轮询。直接实现 Balancer 接口意味着连重连退避、健康检查都要自己写,几乎不会遇到这种需求,真遇到了也应该先考虑 Service Mesh。

面试官想听的关键词:PickerBuilder + Picker、连接生命周期管理、base 骨架、init 注册 + 匿名引入、低频准备 vs 高频选点、SubConn 是连接池而非连接、99% 只需写 Picker。

常见失分点:说不清 Builder 和 Picker 的触发时机(答"每次请求都调用 Builder"是错的,它只在连接集合变化时被调用);或者不知道"自定义包必须匿名引入才能触发 init 注册",导致运行时报 unknown balancer。

面试官
【追问】 平滑加权轮询里两个节点的 currentWeight 打平时选谁?为什么?另外,服务发现推送新名单时直接替换节点列表会有什么问题?
候选人

一句话结论:打平时取决于实现——用严格大于 > 就保留先遍历到的那个(遍历顺序决定),只要实现保持一致就不影响整体分布正确性。直接替换节点列表会出问题:旧节点留下的 currentWeight 欠账会被算到同一下标的新节点头上,分布立刻失真;正确做法是重建整个节点列表和 currentWeight 数组。

展开:以 5:1:1 为例,第 3 次 B 和 C 的 currentWeight 都是 3,用 > 保留先遍历到的 B(序列 A A B A C A A),改成 >= 则会选 C(序列 A A C A B A A)——两者都满足"7 次里 A 占 5 次、B 和 C 各 1 次"的比例要求,所以都算对。面试时把这个细节说出来是加分项。第二个问题更贴近生产:假设 A(weight=5) 跑到 currentWeight=-2 时 B 下线了,Resolver 推送新名单 [A, C],如果你只替换节点数组,C 会从 0 开始而 A 从 -2 开始,前两次请求都会打到 C——完全违背 5:1 的配比。所以工程实现的 UpdateInstances 必须全量重建节点和权重状态。另外还要注意:total 必须是本轮参与选择的节点的权重之和(被健康检查剔除的不算),否则还债金额偏大、剩余节点的分布会失真。

面试官想听的关键词:严格大于、遍历顺序、比例不变即正确、欠账串位、全量重建、total 与过滤后集合保持一致。

常见失分点:斩钉截铁地说"打平时选 B"(应该是"取决于实现,用严格大于则保留先遍历到的");或者完全没想到"更新列表要重置状态"这个坑(这是生产环境最常见的 LB bug 之一)。

面试官
【追问】 健康检查的"半开"状态为什么只放一个试探请求?如果放开全部流量会怎样?这个设计和熔断器有什么关系?
候选人

一句话结论:半开状态只放一个试探请求(用一个 probing 布尔标志做节流阀),是为了避免刚冷却的故障节点瞬间收到全量流量被二次打挂;如果放开全部流量,节点会陷入"剔除 → 冷却 → 被打挂 → 再剔除"的死亡螺旋。这正是第 14 章熔断器"半开(Half-Open)“状态的经典设计。

展开:健康检查的状态机是"健康 →(连续 3 次失败)→ 剔除(Open,持续 10 秒)→ 半开(Half-Open)→ 试探成功则恢复健康 / 失败则重新计数”。实现上有两个细节:用 unhealthyUntil time.Time(截止时间)而不是倒计时,判断时只需 time.Now().After(...),不需要额外的定时器 goroutine;成功时直接 delete(states, id) 删除记录,“没有记录 = 完全健康”,既不占内存语义也最干净。另外剔除时要同时重置 probing 和 consecutiveFails,否则下一轮要么放不出试探、要么冷却期被无限延长。这个模式再往前走一步就是完整的熔断器:把"连续失败次数"换成"滑动窗口错误率",把"固定 10 秒"换成"指数增长的冷却期",再加上限流和降级,就是第 14 章要讲的服务治理三板斧。

面试官想听的关键词:probing 节流阀、二次打挂、死亡螺旋、unhealthyUntil 时间戳、delete 即恢复、重置 probing 与计数、熔断器的雏形。

常见失分点:只知道"半开是放一部分流量"却说不清具体放多少(标准是一个);或者说不出"不放一个会怎样"(答不出"二次打挂 / 死亡螺旋"这个关键后果)。

面试官
【追问】 如果要你从零设计一个负载均衡算法,你会考虑哪四个问题?能举一个"结合业务特征"的亮点方案吗?
候选人

一句话结论:四个问题是——① 要不要考虑服务器处理能力?② 选什么指标表达当前负载?③ 所有请求消耗的资源一样吗?④ 实践中到底选什么? 亮点方案示例:一致性哈希 LB + 本地缓存 + Redis 多级缓存——让同一个 key 的请求必然命中同一节点,把本地缓存命中率从 1/N 提到接近 100%、内存降到 1/N。

展开:问题 ① 决定要不要加权(轮询/随机/哈希/最少连接/最少活跃 vs 它们的加权版本);问题 ② 决定是静态还是动态(不选指标靠统计学 = 轮询/随机/哈希,选一个指标 = 连接数/活跃数/响应时间,选多个指标组合打分就是你的专属算法);问题 ③ 提醒你"大 V 的粉丝列表有 1000 万条、普通用户 20 条",不考虑请求大小差异就会偶发打爆某台机器;问题 ④ 的答案是遇事不决用轮询,出了问题再换。亮点方案要说清价值:轮询下 3 个节点各缓存一份相同数据,命中率仅 1/3 而内存花 3 倍;改一致性哈希后命中率接近 100%,且节点变化时只有 1/N 的缓存需要重建。其他可讲的亮点还有"错误率加权 + 健康检查"、“响应时间 + 熔断联动”、“P2C(随机挑两个选更优)"。

面试官想听的关键词:四问框架、权重表达能力、指标组合打分、请求大小差异、遇事不决用轮询、哈希 + 本地缓存、1/N 缓存重建、P2C。

常见失分点:只会背算法名字却给不出"设计方法论”(四问框架就是答案);或者举的"亮点方案"和业务无关(比如"我用机器学习预测负载"——面试官想听的是结合具体业务特征的取舍,不是堆砌炫技名词)。


八、本章小结

学完这一章,下面这些应该能从脑子里调出来。建议逐条打勾,打不上勾的回去看对应小节。

复习提示:

可勾选清单(面试前 5 分钟扫一遍)

  • 目的与模型:负载均衡是手段不是目的;目的是"选最适合处理请求的节点",工程上简化为"选负载最轻的";均衡是结果不是目标;不要为了均衡而均衡,节点能力不同就该加权。
  • 客户端 vs 服务端 LB:服务端 LB(Nginx/LVS)集中决策、多一跳、有瓶颈;客户端 LB 进程内决策、少一跳、无瓶颈、和注册中心天然打通;南北流量用服务端 LB,东西流量用客户端 LB,两者并存。
  • 静态算法家族:轮询(计数器 + 原子自增 + 记得减 1)、随机(零状态、大数定律)、哈希(会话保持);共同点是"不算真实负载,靠统计学保证均衡"。
  • 平滑加权轮询(面试手写):① 算 totalWeight ② currentWeight += weight ③ 选最大 ④ 选中 -= totalWeight;5:1:1 的 7 次序列是 A A B A C A A;周期性(totalWeight 次回到初始状态);打平时用严格大于保留先遍历到的;更新列表必须重建状态;权重 ≤ 0 兜底为 1。
  • 加权随机 / 加权哈希:把权重画成数轴区间,随机数(或 key 的哈希)落在谁的区间就选谁;前缀和 + 线性/二分查找。
  • 一致性哈希:哈希环 + 顺时针找第一个节点 + 排序数组 + sort.Search 二分;节点增删只迁移约 1/N 的 key(普通取模是 (N-1)/N 全变,会引发缓存雪崩);虚拟节点(100~200)解决数据倾斜;节点固定的场景(分库分表)用取模更合适。
  • 动态算法与局限:最少连接(多路复用下失真)、最少活跃(不衡量请求大小,Done 回调不能漏)、最快响应(用平均或 P99,别用 P999);根本局限是客户端只看得到自己这一侧;获取真实指标三条路:上报注册中心 / 响应附带(ORCA)/ Prometheus 拉取。
  • gRPC 默认 pick_first:默认根本不做负载均衡,永远选第一个节点,只有 1 个实例有流量;改法 WithDefaultServiceConfig({“loadBalancingConfig”:[{“round_robin”:{}}]});策略名是下划线;多次调用会覆盖不是追加。
  • 自定义 Picker:PickerBuilder(连接变化时构造 Picker,做一次性准备)+ Picker(每次请求选点,高频);用 base.NewBalancerBuilder 复用官方连接管理骨架;init() + balancer.Register 注册;必须匿名引入包触发 init;权重从 Address.Attributes 读(Metadata 已弃用),先打印确认类型。
  • SubConn 是连接池不是连接:一个 SubConn 对应一个后端节点。有人误以为它是连接数并据此写"最少连接",结果退化成轮询。
  • Balancer vs Picker:Balancer 管连接生命周期(创建/连接/断开/重连/健康检查),Picker 只管选点;99% 的场景只需写 Picker。
  • Failover = 重试 + 负载均衡:重试会重新走 Pick,自然换节点;只重试 UNAVAILABLE / RESOURCE_EXHAUSTED(重试不是幂等的);必须配指数退避 + 抖动(防惊群)和链路超时;重试放大效应:N 层每层重试 M 次最坏放大 Mᴺ 倍。
  • 健康检查三态:健康 →(连续 3 次失败)→ 剔除(10 秒)→ 半开只放 1 个试探请求 → 成功恢复 / 失败重来;放开全部流量会导致二次打挂 / 死亡螺旋;这是第 14 章熔断器的雏形。
  • 工程要点:遇事不决用轮询;权重要有上下限且能回升;重试防放大;上线前务必做"kill 一个实例"的演练。
  • 面试亮点:一致性哈希 + 本地缓存 + Redis(命中率 1/N → 100%);错误率加权 + 健康检查;P2C(随机挑两个选更优)。

下一章我们将进入服务治理,把熔断 / 限流 / 降级这套「防雪崩」的组合拳打明白——本章第 6 步写的"连续 3 次失败剔除、10 秒后半开放一个试探请求",其实就是熔断器的雏形,第 14 章会把它扩展成完整的状态机。

复习提示:给下一章留个伏笔:本章我们解决了"请求发给谁",以及"发给的那个节点坏了怎么办"(Failover)。但你有没有发现一个问题——如果所有节点都在变慢呢? 这时候无论怎么换节点、怎么重试,都只会让情况更糟:重试会把流量放大,而下游已经快撑不住了。这时候需要的是主动放弃(熔断)、主动拒绝(限流)和主动降级(返回兜底数据)。带着这个问题进入第 14 章,会学得更扎实。
About Me

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

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

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

目标

学AI,加油!加油!