42.4 服务发现与客户端负载均衡
一句话
客户端负载均衡(client-side LB)的本质不是“选哪个算法”,而是它只看得见自己手里那份实例视图:列表是否新鲜、连接是否复用到同一个实例、慢节点能不能被摘掉——这三件事决定它比“算法本身”重要得多。
三种放置方式
| 方式 | 谁做负载均衡 | 调用路径 | 优点 | 缺点 |
|---|---|---|---|---|
| 服务端 LB(集中式) | 独立代理(LVS/Nginx/ELB) | 客户端 → LB → 实例 | 客户端简单,全局视角 | 多一跳,LB 成瓶颈/单点 |
| 客户端 LB | 调用方进程内的 SDK | 客户端直连实例 | 少一跳、低延迟、可做 P2C/局部性 | 每个语言一套 SDK,视图可能过期 |
| Sidecar LB | 本 Pod 的代理 | 客户端 → 本地代理 → 实例 | 语言无关、治理统一 | 每跳两次转发(30.5) |
服务发现的注册、健康检查、一致性、推送风暴,主场在 48.2 服务发现;四层/七层负载均衡的分层在 8.9 四层与七层负载均衡。本节只讲客户端 LB 的算法与它独有的坑。
五种算法,五种失效方式
| 算法 | 怎么选 | 适合 | 失效方式 |
|---|---|---|---|
| 轮询 RR | 依次 | 实例同构、请求耗时均匀 | 慢节点照样分到 1/N,队列越积越长 |
| 加权轮询 | 按权重比例 | 实例规格不同 | 权重靠人工配置,扩容后不同步 |
| 随机 | 随机 | 实例多、请求大 | 短期抖动,慢节点概率性拖慢 |
| 最少连接/最少在途 | 选当前在途最少的 | 请求耗时差异大 | 需要准确的“在途”计数;新实例瞬间被灌满 |
| P2C(两次随机取优) | 随机挑 2 个,选在途少的 | 大规模、去中心化 | 近似最优,不是最优;实现极简单 |
| 一致性哈希 | 按请求 key 固定实例 | 需要缓存亲和 | 热点 key 全部压一个实例(41.6) |
代码:虚拟时间模拟,五种策略在同一场景下的差异
package main
import (
"fmt"
"math/rand"
"sort"
)
const (
nReqs = 20000
rateMs = 1.0 // 每 1ms 一个请求 = 1000 QPS(约为总容量的 60%)
degrade = 10000 // 第 10000 个请求时,0 号实例变慢 5 倍
)
type instance struct {
busy float64 // 何时空闲(ms)
count int
cost float64 // 单次服务耗时(ms)
}
func newInstances() []*instance {
costs := []float64{2, 2, 3, 5, 8}
out := make([]*instance, len(costs))
for i, c := range costs {
out[i] = &instance{cost: c}
}
return out
}
type picker func(insts []*instance, req int, r *rand.Rand) int
func rr(insts []*instance, req int, _ *rand.Rand) int { return req % len(insts) }
func random(insts []*instance, _ int, r *rand.Rand) int { return r.Intn(len(insts)) }
func leastBusy(insts []*instance, _ int, _ *rand.Rand) int {
best := 0
for i := 1; i < len(insts); i++ {
if insts[i].busy < insts[best].busy {
best = i
}
}
return best
}
// P2C:随机挑两个,选更闲的(大规模下近似最优,成本 O(1))
func p2c(insts []*instance, _ int, r *rand.Rand) int {
a := r.Intn(len(insts))
b := r.Intn(len(insts))
if insts[b].busy < insts[a].busy {
return b
}
return a
}
// 一致性哈希(简化为取模):同一个 key 永远落同一个实例
func hashPick(insts []*instance, req int, _ *rand.Rand) int {
key := req % 100 // 模拟只有 100 个不同 key
return key % len(insts)
}
func run(name string, pick picker, seed int64) {
insts := newInstances()
r := rand.New(rand.NewSource(seed))
lat := make([]float64, 0, nReqs)
var p99Before, p99After []float64
for i := 0; i < nReqs; i++ {
if i == degrade {
insts[0].cost *= 5 // 0 号实例变慢(GC/邻居干扰/磁盘抖动)
}
idx := pick(insts, i, r)
it := insts[idx]
now := float64(i) * rateMs
start := now
if it.busy > start {
start = it.busy
}
l := start - now + it.cost
it.busy = start + it.cost
it.count++
lat = append(lat, l)
if i < degrade {
p99Before = append(p99Before, l)
} else {
p99After = append(p99After, l)
}
}
sort.Float64s(lat)
sort.Float64s(p99Before)
sort.Float64s(p99After)
minC, maxC, sum := insts[0].count, insts[0].count, 0
for _, it := range insts {
if it.count < minC {
minC = it.count
}
if it.count > maxC {
maxC = it.count
}
sum += it.count
}
avg := float64(sum) / float64(len(insts))
fmt.Printf("%-12s p50=%6.1fms p99=%7.1fms 变慢后 p99=%7.1fms 负载 max/avg=%.2f\n",
name,
lat[len(lat)/2],
lat[len(lat)*99/100],
p99After[len(p99After)*99/100],
float64(maxC)/avg)
}
func main() {
fmt.Println("=== 5 个实例(服务耗时 2/2/3/5/8ms),20000 个请求,1000 QPS ===")
fmt.Println("=== 第 10000 个请求起,0 号实例变慢 5 倍 ===")
run("轮询 RR", rr, 1)
run("随机", random, 2)
run("最少在途", leastBusy, 3)
run("P2C", p2c, 4)
run("哈希取模", hashPick, 5)
}
本机实测(macOS,Apple M1,Go 1.27):
=== 5 个实例(服务耗时 2/2/3/5/8ms),20000 个请求,1000 QPS ===
=== 第 10000 个请求起,0 号实例变慢 5 倍 ===
轮询 RR p50= 5.0ms p99=11408.0ms 变慢后 p99=11708.0ms 负载 max/avg=1.00
随机 p50= 45.0ms p99=11367.0ms 变慢后 p99=11731.0ms 负载 max/avg=1.02
最少在途 p50= 3.0ms p99= 10.0ms 变慢后 p99= 11.0ms 负载 max/avg=1.65
P2C p50= 3.0ms p99= 33.0ms 变慢后 p99= 39.0ms 负载 max/avg=1.45
哈希取模 p50= 5.0ms p99=11408.0ms 变慢后 p99=11708.0ms 负载 max/avg=1.00
读数:
| 策略 | p50 | p99 | 变慢后 p99 | 负载 max/avg |
|---|---|---|---|---|
| 轮询 RR | 5.0 ms | 11,408 ms | 11,708 ms | 1.00 |
| 随机 | 45.0 ms | 11,367 ms | 11,731 ms | 1.02 |
| 最少在途 | 3.0 ms | 10 ms | 11 ms | 1.65 |
| P2C | 3.0 ms | 33 ms | 39 ms | 1.45 |
| 哈希取模 | 5.0 ms | 11,408 ms | 11,708 ms | 1.00 |
三条读数:
- 开环 vs 闭环差三个数量级:轮询把 1/N 的流量固定给每个实例,最慢的实例(8 ms)容量只有
1000/8 = 125 QPS,却被分了1000/5 = 200 QPS——队列无界增长,p99 冲到 11 秒;最少在途与 P2C 会看“谁闲”,把流量挪走,p99 掉到 10–33 ms。 - “负载均衡”不等于“请求数均匀”:最少在途的 max/avg 是 1.65(流量明显偏向快节点),P2C 是 1.45——它们追求的是延迟最优,不是分配均匀。想要均匀就回到轮询,代价是慢节点拖死 p99。
- 变慢之后:闭环算法能把变慢的实例“饿着”(P2C 的 p99 从 33 ms 到 39 ms),开环算法没有任何反应(因为它根本不看反馈)。
🔑 结论:算法只决定“平均分得好不好”,能不能应对慢节点取决于“是否有在途/延迟反馈”。轮询是开环控制(不看结果),最少在途与 P2C 是闭环控制(看当前状态)。生产上通常是“P2C + 异常点摘除(outlier detection)”组合,而不是纯轮询。
慢节点怎么处理:摘除,而不是等
| 机制 | 判据 | 动作 | 风险 |
|---|---|---|---|
| 被动健康检查 | 健康检查失败 N 次 | 从列表摘除 | 检查间隔内的请求仍会打到它 |
| 异常点摘除 | 成功率 < 阈值 或 P99 > 基线 × K | 临时摘除(如 30 秒) | 误摘好节点(指标滞后) |
| 熔断 | 连续失败率超阈值 | 快速失败(35.6) | 恢复探测不当会反复抖动 |
| 权重下调 | 延迟 EWMA 升高 | 降低权重而非摘除 | 权重收敛慢 |
客户端 LB 的三个独有坑
| 坑 | 现象 | 解法 |
|---|---|---|
| 长连接绑定 | 连接池里的一条连接绑定一个实例,LB 只影响“建连”,不影响“复用” | 限制单连接请求数、定期重建连接、用 L7 代理 |
| 实例列表过期 | 扩容后新实例没人用(客户端还没刷新),缩容后仍打旧 IP | 订阅推送 + 本地缓存 + 兜底直连(48.2) |
| 雪崩式重连 | 一个实例挂掉,所有客户端同时重连其他实例,瞬间打满 | 重连退避 + 抖动 + 预热(新实例慢启动,逐渐加权重) |
⚠️ “长连接绑定”是最容易被忽略的一条:你配了 P2C,但客户端用 gRPC 长连接,一个 stream 会一直待在同一个实例上——LB 只在建连时生效。所以 gRPC 这类长连接协议要配合“子通道(subchannel)+ 定期重连/最大连接年龄”,否则负载会随着时间越来越偏。
反模式
| 反模式 | 后果 |
|---|---|
| 只做轮询,不做慢节点摘除 | 一个慢实例拖住整个 p99 |
| 客户端 LB 缓存实例列表且永不刷新 | 扩容无效、缩容后打空 IP |
| 一致性哈希做通用负载均衡 | 热点 key 全压一个实例,其他实例空闲 |
| 新实例上线立刻满权重 | 冷启动(JIT/缓存未预热)导致超时,被误判为坏节点 |
| 重连不抖避 | 实例故障引发重连风暴,把剩余实例打挂 |
| 把 LB 权重写死在配置里 | 扩容/缩容后权重与实际规格不符 |
自检
Q1 为什么“轮询”在实例同构、请求耗时均匀时是最优解,但在实例异构时是最差的之一?
▸ 答案:轮询是开环的:它不看实例当前是否忙,只按顺序分配。同构且请求均匀时,每个实例的队列长度自然接近,轮询足够且开销最小。异构或耗时差异大时,慢实例的队列会持续增长(同样的请求数、更长的处理时间),而轮询仍按 1/N 给它分流量,最终它的排队延迟主导 p99。闭环算法(最少在途/P2C)会把流量抽走,代价是需要维护状态。
Q2 P2C 只比“随机”多看一个实例,为什么效果接近“最少在途”?
▸ 答案:因为在实例较多时,“随机两个里较闲的那个”已经能显著偏离“随机一个”的分布:假设当前负载分布不均,随机两个里更闲的那个大概率不是最忙的节点。数学上 P2C 的负载上界是 O(log log n) 量级(而随机是 O(log n / log log n)),已经非常接近最优,但只需要 O(1) 状态(不必全局扫描所有实例)。这就是它在 Envoy/gRPC 等项目里成为默认的原因。
Q3 一个服务用 gRPC 长连接 + P2C,上线后发现流量分布越来越偏。最可能的原因是什么?
▸ 答案:长连接绑定。P2C 只在建立子通道(连接)时做选择;一旦连接建立,后续所有 stream 都复用这条连接、固定打到同一个实例,LB 不再参与。随着时间推移,早期的连接分布决定了流量分布,扩容的新实例可能一条连接都没有。解法:设置“最大连接年龄/最大请求数”强制重建、或在 L7 代理层做 per-request 负载均衡(sidecar 模式)。
Q4 新实例刚上线就被打满,随后因超时被摘除,反复循环。该怎么改?
▸ 答案:这是冷启动 + 满权重的经典问题。三个改动:① 慢启动:新实例权重从很小开始,在 N 秒内线性升到满权重(让 JIT/缓存/连接池预热);② 预热期不参与异常点摘除:否则冷启动的首次超时会被误判为坏节点;③ 摘除后指数退避恢复(30s → 60s → 120s),避免“摘除-恢复-再摘除”的抖动循环。
候选集合从哪来(按机房/版本/黑白名单过滤)见 30.8 服务路由 (条件 / 标签 / 脚本):路由在前,负载均衡在后。