写在最前面:这一版和普通讲义有什么不同
这是《内容社区》课程第 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 固定路由到同一实例,扩缩容时只移动一小段数据。两条路线最后都要过「健康检查」这道闸:通过才「发往目标实例」;失败则落入右下角的兜底分支——把坏节点从候选里剔除,请求换到别的实例重发。
二、本章知识点大纲
先把这张地图看一遍,知道这一章要往哪走、走到哪算够。
必会(学完必须能独立写出来)
- 手写一个平滑加权轮询(Smooth WRR),并能手推出 A:B:C = 5:1:1 时连续 7 次的序列是
A A B A C A A。 - 手写一个一致性哈希环(含虚拟节点 + 二分查找),并写单测验证「同一个 key 永远落在同一个节点」。
- 用
WithDefaultServiceConfig把 gRPC 默认的pick_first换成round_robin,并解释为什么要这么配。 - 实现 gRPC 的
PickerBuilder+Picker两个接口,把自定义算法注册成一个新的负载均衡策略。 - 写出一个「连续 3 次失败剔除、10 秒后半开放一个试探请求」的健康检查状态机。
理解(能讲清原理和取舍,不要求背代码)
- 为什么说「负载均衡是手段不是目的」——真正的目的是选出"最适合"处理这个请求的节点。
- 客户端负载均衡与服务端负载均衡的差异,以及为什么微服务时代客户端 LB 成了主流。
- 静态算法(轮询/随机/哈希)与动态算法(最少连接/最少活跃/最快响应)的分界:前者靠统计学,后者靠指标。
- 动态算法的固有局限:客户端只能看到自己这一侧的指标,看不到服务端真实 CPU / 内存。
- Failover 的本质是「重试 + 负载均衡」,以及为什么重试必须配超时和退避(重试放大效应)。
了解(知道有这回事,面试能说出名字和一句话定位)
- gRPC 内置 balancer 一览:
roundrobin/weightedroundrobin/grpclb/leastrequest/rls,以及"除了前两个其他基本不用"的结论。 - 主流框架的 LB 方案:Kratos 的
Selector(默认平滑 WRR)、go-zero 的P2C(随机挑两个再选负载低的)。 - 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 道面试问答收口。每条边就是「读完上一节再读下一节」的线性顺序,没有分支。
怎么读这一章:每一节都按同一个节奏推进——先抛问题 → 给个直觉(类比或图)→ 最小可运行代码 → 逐行讲解 → 常见坑 → 小收口。如果你只想快速过一遍,可以只读每节的"抛问题"和"小收口";如果要真学会,就把第四节「跟着写」的 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 负载均衡是一个"简化模型”
现在问题来了:“最适合"这个词太主观了,怎么让机器判断?
工程上的做法是建立一个简化模型:
我们认为:负载最轻的节点,就是最适合的节点。
这个简化带来两个推论:
- 既然所有节点提供的服务是等价的,那么每次都选负载最轻的,长此以往所有节点的负载会大致均衡——“均衡"是结果,不是目标。
- 于是"选最合适的节点"这个复杂问题,被简化成了”量化负载,选最小的那个“这个可计算的问题。
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、云厂商 SLB | gRPC 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 成了主流? 三个理由:
- 少一跳网络。微服务之间调用量是天文数字(一个外部请求可能扇出几十次内部调用),每一次都绕一层 Nginx,延迟和带宽成本都扛不住。
- 没有单点瓶颈。服务端 LB 要处理所有流量,必须堆机器、做高可用;客户端 LB 的计算分散在每个调用方进程里,加机器就自动加算力。
- 和注册中心天然配合。微服务实例变化频繁(扩缩容、发布、故障重启),客户端 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-12 | counter uint64 | 计数器。用 uint64 是因为 atomic 包对它有原生的 AddUint64 支持,且不会溢出成负数 |
| 16 | atomic.AddUint64(&r.counter, 1) | 原子自增。如果写成 r.counter++,多个 goroutine 并发调用时会丢计数(不是线程安全),导致分布不均 |
| 18 | (n - 1) % len | 为什么要减 1?因为 AddUint64 返回的是加完之后的值(第一次返回 1),不减 1 的话第一次会拿到下标 1(节点 B),序列变成 B C A B C A |
| 19 | return 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(相当于"攒分"),然后谁的分最高谁上。上完之后,这个赢家要扣掉所有人的总分(相当于"还债")。因为它扣了一大笔(总分),接下来几轮它的分追不上别人,别人就有机会上场。等它慢慢把债还清,分重新涨回最高,它又能上场了。
这个"先攒分、谁高谁上、上了就还债"的循环,天然就把高权重节点的请求打散了。
算法四步
严谨表述(这四步就是面试手写的骨架,背下来):
- 计算所有节点初始权重的和
totalWeight - 每个节点的
currentWeight += weight(攒分) - 选出
currentWeight最大的节点 - 选中节点的
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 | — | — |
| 1 | 5, 1, 1 | A(5 最大) | -2, 1, 1 |
| 2 | 3, 2, 2 | A(3 最大) | -4, 2, 2 |
| 3 | 1, 3, 3 | B(B、C 都是 3,取先遍历到的 B) | 1, -4, 3 |
| 4 | 6, -3, 4 | A(6 最大) | -1, -3, 4 |
| 5 | 4, -2, 5 | C(5 最大) | 4, -2, -2 |
| 6 | 9, -1, -1 | A(9 最大) | 2, -1, -1 |
| 7 | 7, 0, 0 | A(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
}
逐行讲解
| 行 | 代码 | 为什么这么写 |
|---|---|---|
| 8 | Weight int64 | 初始权重,构造后不变。用 int64 是为了和后面可能的"动态调整权重"统一(用 int 在 32 位平台上可能溢出) |
| 9 | CurrentWeight int64 | 当前权重,每次 Pick 都变。它可以是负数(还债后欠账),所以必须用有符号类型 |
| 14 | mu sync.Mutex | 这里用 Mutex 而不是 atomic,因为 Pick 内部要修改多个节点的 CurrentWeight,这是一个不可分割的整体操作,原子操作搞不定 |
| 28-32 | 一个循环同时做两件事 | 累加 total 和 currentWeight += weight 合并成一个循环,减少一次遍历。注意 total 必须在扣减之前算完 |
| 36 | if n.CurrentWeight > max.CurrentWeight | 严格大于是关键。如果写成 >=,打平时会选后遍历到的节点,序列会变成 A A C A B A A——也对,但要和实现保持一致 |
| 43 | max.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]
}
逐行讲解
| 行 | 代码 | 为什么这么写 |
|---|---|---|
| 11 | rand.Int63n(w.total) | 生成 [0, total) 的随机整数。注意是左闭右开,所以 r 最大是 total-1,必然小于 sum 的终值 |
| 15-17 | sum += weight; if r < sum | 前缀和技巧:累加到某个节点时,sum 就是"从 0 到这个节点的区间右端点"。r < sum 说明 r 落在这个区间内 |
| 21 | 兜底 return | 防御性编程。理论上不可达,但如果 total 和 weights 不一致(有人改了权重忘了更新 total),这里能避免越界 panic |
⚠️ 坑 · rand 的全局锁与随机种子
两个必踩的点:
Go 1.20 之前必须手动播种,否则每次程序启动的随机序列一模一样:
rand.Seed(time.Now().UnixNano()) // Go 1.20 之前需要Go 1.20 起,全局
rand会在启动时自动随机播种,这行不再需要(写了会报 deprecated 警告)。如果你用的是 Go 1.20+,直接rand.Intn(...)即可。高并发下全局
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)想象成一个首尾相接的圆环:
- 节点落环:每个节点也计算一个哈希值(通常用它的 IP 或实例 ID),落在环上的某个位置。
- key 落环:请求的 key 也算哈希,落在环上的某个位置。
- 顺时针找节点:从 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]]
}
逐行讲解
| 行 | 代码 | 为什么这么写 |
|---|---|---|
| 11 | sync.RWMutex | 读(Get)远多于写(Add),用读写锁让多个 Get 可以并发执行 |
| 14 | keys []uint32 | 排序后的哈希环。这是整个算法的"数据结构核心"——有了它才能二分查找 |
| 36 | strconv.Itoa(i) + node | 虚拟节点的命名。用"编号+节点名"而不是"节点名+编号",是为了让不同编号的哈希值差异更大(前缀不同影响哈希的高位) |
| 43 | sort.Slice(...) | 必须排序。sort.Search 要求输入有序,否则结果不可预期 |
| 57 | sort.Search(len, func(i) bool { return r.keys[i] >= h }) | 标准库二分查找,返回第一个使条件为真的下标,即第一个 >= h 的位置。这正是"顺时针第一个节点" |
| 61-62 | if 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 动态负载均衡算法
先抛问题
静态算法"不算负载",那么动态算法就要回答一个问题:
我能不能真的去测量每个节点当前的负载,然后选最轻的那个?
听起来很美好,但马上会遇到两个现实问题:
- “负载"到底是什么?怎么量化? CPU?内存?队列长度?响应时间?没有一个单一指标能完美代表负载。
- 我在客户端,我怎么知道服务端那台机器的 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% 的请求比它快 | ✅ 推荐,能反映长尾 |
| P999 | 99.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 拉取 | 客户端定期去监控系统拉各节点的指标 | 不侵入业务;指标最丰富 | 引入对监控系统的强依赖;拉取有延迟 |
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() 拿到的那个对象,代表"到某个服务名的一条逻辑连接" | 你手机里存的联系人"客服热线" |
SubConn | ClientConn 之下、到某一个具体实例的连接(实际是一个连接池) | 客服热线的某一条具体线路 |
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
需要两步:
- 匿名引入包
google.golang.org/grpc/balancer/weightedroundrobin,触发它的init()完成注册 - 在 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),其他基本都不用。
如果这两个满足不了需求,有两个更好的选择:
- 用框架自带的:Kratos、go-zero、Dubbo-go 都自带了更丰富、更好用的 LB 实现(见 3.6 节)。
- 自己实现 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.NewBalancerBuilder | 0 行(调官方的) | 提供连接管理的通用骨架:创建 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 条:权重要有上下限)
- 权重下限不能是 0:权重为 0 意味着"永不选中",这个节点就永久下线了——即使它已经恢复。兜底到 1 就好。
- 权重上限不能无限大:如果成功一次就 +1,跑几天后某个节点的权重会变成 999999,其他节点永远分不到流量。必须设上限(如 100)。
- 权重要能"恢复":只降不升的话,一次网络抖动就会让节点永久降级。成功时缓慢回升,才能让系统自愈。
面试很好用,生产较少用——因为这套逻辑和"熔断器"(第 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 | 实现轮询 RoundRobin | pkg/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
🔍 如果测试失败了,按这个顺序排查:
- 序列是
A A C A B A A→ 你把>写成了>= - 序列是
B C A B C A B→ 你忘了减 1,或者第一轮的currentWeight初始值不是 0 - 序列是
A A A A A B C→ 你写成了朴素加权轮询(展开列表法),没有实现 currentWeight 机制 - 序列是
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))
}
}
逐行讲解
| 行 | 代码 | 为什么这么写 |
|---|---|---|
| 18 | unhealthyUntil time.Time | 用"截止时间"而不是"倒计时/布尔标记"。好处是判断时只需 time.Now().After(...),不需要额外的定时器 goroutine 去翻转状态 |
| 19 | probing bool | 半开状态的节流阀。见下方坑 |
| 34-38 | Allow 的三个分支 | ① 无记录 → 放行;② 冷却期未过 → 拒绝;③ 冷却期过了但已放过试探 → 继续拒绝(等试探结果) |
| 45-49 | delete(h.states, id) | 成功后直接删除记录,而不是把计数清零。这样既不占内存,语义也最干净:“没有记录 = 完全健康” |
| 60-63 | 剔除时同时重置 probing 和 consecutiveFails | probing=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 步骤 1 | if !p.health.Allow(...) { continue } | 健康检查与加权算法的联动点。被剔除的节点不进 idxs,自然也不进 total——这一点非常重要:如果 total 包含了被剔除节点的权重,还债金额就偏大,剩余节点的分布会失真(对应 3.2.3 节的坑 3) |
| chPicker.Pick 步骤 2 | for 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 次。
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 时,务必确认三件事:
- 数据是从
Address.Metadata拿还是从Address.Attributes拿(新版本用后者) - 拿出来的类型是什么(
int/int64/string?) - key 的大小写是否一致(
weightvsWeight)
最稳妥的做法是先打印出来看(见 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 步的验证清单里专门列了这一条,请一定要做。
六、自测题与动手练习
自测题(合上书能答出来,才算懂。答案要点在第七节,先别急着翻):
- 为什么说"负载均衡是手段不是目的"?如果某个节点性能明显更好,正确的做法是什么?
- gRPC 默认的负载均衡策略叫什么?它会造成什么现象?怎么改成真正的负载均衡?
- 普通加权轮询 A:B:C = 1:2:3 的序列是什么?它有什么缺陷?平滑加权轮询是怎么解决的?请手写出核心的四步。
- A:B:C = 5:1:1 时,平滑加权轮询连续 7 次的序列是什么?请自己推一遍,不要看答案。
- 一致性哈希相比普通哈希(取模)解决了什么问题?虚拟节点是干嘛用的?为什么通常取 100~200 个?
- 动态算法(最少连接 / 最少活跃 / 最快响应)为什么说"客户端只能看到自己这一侧"?要拿到服务端真实 CPU / 内存有哪三条路?
- 在 gRPC 里自定义负载均衡要实现哪两个接口?它们各自的职责是什么?为什么需要两个而不是一个?
- 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 默认是 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(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 的本质是「重试 + 负载均衡」——重试时再走一次 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 次放大多少倍"这个具体数字。
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 章会把它扩展成完整的状态机。