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

三条读数:

  1. 开环 vs 闭环差三个数量级:轮询把 1/N 的流量固定给每个实例,最慢的实例(8 ms)容量只有 1000/8 = 125 QPS,却被分了 1000/5 = 200 QPS——队列无界增长,p99 冲到 11 秒;最少在途与 P2C 会看“谁闲”,把流量挪走,p99 掉到 10–33 ms。
  2. “负载均衡”不等于“请求数均匀”:最少在途的 max/avg 是 1.65(流量明显偏向快节点),P2C 是 1.45——它们追求的是延迟最优,不是分配均匀。想要均匀就回到轮询,代价是慢节点拖死 p99。
  3. 变慢之后:闭环算法能把变慢的实例“饿着”(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 服务路由 (条件 / 标签 / 脚本):路由在前,负载均衡在后。

关联