2.4 负载均衡:客户端 LB 策略
注册中心把位置给到了调用方,但 136 个实例怎么分流量是另一件事。服务端 LB 在 gRPC 里不常用,我们全部走客户端 LB:每个调用方从本地实例表选目标。这一篇给策略选型、探活机制、与灰度路由的衔接方式,以及 3 次流量不均事故的复盘。选策略的底层原则只有一条:先问流量形态,再谈算法,把长连接、会话型、突发型分开对待,别用一个轮询打天下。
场景:流量不均的三次事故
| 事故 | 现象 | 根因 |
|---|---|---|
| 随机 LB 长尾 | 高峰最大节点比最小高 61% | 随机采样在小实例数下偏斜 |
| 加权不均 | 扩容后新实例权重 0 被冷落 2 小时 | 权重靠人工改 etcd |
| 长连接饿死 | 1 个慢实例占满连接后流量锁死 | 无探活摘除机制 |
三起共性:策略选错加摘除机制缺位。负载策略要匹配流量形态,探活是策略生效的前提。
第一起事故把「随机」判了刑:4 个实例时随机选中的理论偏差小,但 gRPC 的多路复用连接让「选中」与「建连」分离,连接数远小于实例数时,个别实例的连接池被少数客户端垄断,61% 的偏差就是这么来的。换成加权轮询后偏差压到 8% 以内,这个数字从此进 LB 的验收清单。
方案:加权轮询加一致性退让
| 流量形态 | 策略 | 理由 |
|---|---|---|
| 高频短请求(订单) | 加权轮询 | 分布均匀,权重可动态 |
| 长连接(网关流式) | 随机加连接复用 | 轮询会频繁换连接 |
| 重状态(会话型) | 一致性 hash 退让为随机 | 服务无状态后 hash 无意义 |
最终全服务统一加权轮询,权重直接读 etcd 实例表 value 里的 weight 字段,扩容新实例默认权重 1,与老实例等价接入,不用人工补。会话型服务当初真配过一致性 hash,后来库存与订单都改成无状态(会话进 Redis)后,hash 只剩成本没有收益,全部退让回加权轮询——策略要跟着服务的状态属性变,别把「高级策略」当默认。
轮询器实现要点:
// 加权轮询:平滑版,避免同权重实例连续命中
func (r *smoothRR) Next() *Instance {
r.cur = (r.cur + instance.Weight) % total
return instance // 平滑轮询的经典写法
}
平滑版解决老式加权轮询的「1、1、1、2、2」分布问题,四个实例权重 1/1/1/2 时序列变成均匀的交错。total 用所有实例权重之和的原子计数,权重变更(watch 事件)时重建轮询器实例,不热改内部状态,避免并发读写的半更新。
策略与探活的分工也要说清:轮询管「选谁」,探活管「谁还有资格被选」。被本地摘除的实例临时移出轮询池,权重与实例表的其他字段不变,恢复探测通过后放回池子。两套数据分离,出问题时改策略不碰探活、调探活不动策略,排障半径小一半。
配置与代码:探活是策略的地基
客户端探活定三参数:间隔 5s、连续 2 次失败摘除、被摘实例 30s 后重试。摘除是本地行为,不写回 etcd——本地视角的健康抖动不该影响全集群。
| 参数 | 取值 | 说明 |
|---|---|---|
| 探活间隔 | 5s | 配合 R1 的 10s 摘除口径 |
| 失败阈值 | 2 次 | 容忍单次抖动 |
| 摘除恢复 | 30s 后重试 | 防反复抖动 |
| 权重来源 | etcd value | 扩容自动生效 |
探活走轻量 HTTP 的 /health(200 即健康),不走 gRPC 心跳,理由是 gRPC 心跳一次探活就占一条连接槽,8 实例 × 17 调用方时心跳连接数超过业务连接,反而挤占。/health 只读内存里的健康标记(数据写盘成功即置位),不做重活,响应时间控制在 5ms 内。健康标记的粒度是「最近一次数据操作成功」,服务进程活着但数据库全挂时探活仍返回 200,这种情况靠熔断(3.1)接住,探活不替熔断背锅。
摘除期间该实例的 in-flight 请求不主动断(让其自然完成),新请求不再选它。这比硬断安全,硬断会让订单在途支付被打断。被摘实例的恢复也分两档:普通实例 30 秒后重试一次 /health,通过即放回轮询池;数据库相关实例加一次慢查询检查再放回,避免「探活 200 但查询全挂」的假恢复反复摘除。
灰度路由的衔接点
加权轮询只在全版本内匀流量,灰度分流(4.1)是叠加在轮询之上的一层:先看请求头里的灰度标记与 gov/gray/{svc} 的比例,命中灰度桶只在 v=2 实例里轮询,否则在 v=1 里轮询。两层解耦,改灰度比例不动 LB 代码。灰度桶的实现与放量节奏在 4.1 展开,go-micro-cps-guide 里也有按版本放量的对照做法。
灰度桶的分配用稳定 hash:按 userId 取 hash 值与 100 取模,同一用户始终落同一桶,避免放量过程中同一用户跨版本跳变。这个 hash 也进了 4.1 篇的放量公式。
踩坑:四个实战教训
第一,连接数要跟上。加权轮询把流量打匀后,每实例并发连接数翻倍,gRPC 默认 1 条连接不够,按 实例数 × 2 配连接池,订单服务 8 实例配 16,TCP 握手风暴集中在扩容窗口,加随机延迟 0~200ms 打散。连接池配置在 govkit 里随实例数自动算,服务侧不用手填。
第二,权重写反的事故。一次 etcd 里权重字段写成字符串「1」,客户端解析失败全实例权重归零,流量 3s 内清空。之后注册写入侧加了类型校验,读侧解析失败保留旧权重不覆盖。这条经验直接进 3.1 的熔断设计:权重类字段解析失败宁可旧值,不可归零。当时的现象值得记:流量清空后 3 秒内网关 503 率 100%,值班第一反应是 etcd 挂了,查了 20 分钟才定位到是权重解析——「全实例权重 0」必须单独一条告警,不能混在健康度里。
第三,长连接服务别用轮询。网关的流式连接被轮询切走时新连接建连 200ms,P99 出现周期性尖峰,改回随机加连接复用后消失。判断长连接还是短连接看接口的流式属性,不要按服务名猜。
第四,摘除要可观测。被摘实例的摘除次数进 4.3 看板,某服务摘除次数日增 50 次以上才定位到是实例自身 GC 停顿,不是网络问题。摘除事件同步打 CLS,格式 lb=evict inst=xx reason=2fail,4.4 篇检索时直接按 reason 聚合。
还有一个边界情况:实例数等于 1 时轮询退化成单点,探活一失败流量直接清零。我们对核心服务加了一条硬规则——实例数必须 ≥ 2 且跨可用区(1.3 的规则一),少于 2 个实例时发布系统拒绝放量,LB 层只是最后一道兜底,不是第一道。另有个常见误解要澄清:客户端 LB 不是「更高级」所以一定好,它把选择逻辑分散到每个调用方,策略 bug 的影响面是全部调用方的总和。选客户端 LB 的真正理由是零中间节点、灰度路由好叠,代价是策略一致性靠 watch 传播,这一点要在团队里提前讲清楚。
验收口径:流量均匀度怎么测
策略上线后必须量化验收,否则「匀开了」只是感觉。我们的均匀度脚本:
# 每实例请求计数采样 60s,算最大最小比
curl -s :9001/metrics | grep lb_requests | sort > snap1
sleep 60
# 再采一次,两快照差值 / 实例数 = 均值
# 验收线:(max-min)/mean < 0.1
| 阶段 | 最大最小比 | 验收 |
|---|---|---|
| 随机 LB | 61% | 不达标 |
| 加权轮询 | 7.9% | 达标 |
| 扩容窗口内 | 15% | 可接受(打散握手后回落) |
均匀度数字同时进 4.3 看板做长期监控,某服务漂移回 30% 以上触发告警,防策略退化(比如有人把轮询改回随机没发现)。客户端 LB 与网关 LB 的分工边界与 tencent-cloud-practice 的网关章节一致:入口层用网关,服务间全客户端。
权重字段本身也要有运维通道:紧急降某实例权重不用发版,etcd 里直接改 value 的 weight,watch 传播 300ms 内全网生效。这条通道的权限只给值班与 SRE,改动必须留工单号进日志,4.4 篇检索时按工单号可回溯到具体操作人。权重改动与灰度放量共用这条通道,操作口径统一。
小结
客户端 LB 的落点就三条:加权平滑轮询做默认、本地探活做地基(5s/2/30s)、灰度在轮询之上叠一层。权重自动生效、摘除只影响本地、连接池按实例数翻倍,这三点防住了 3 类历史事故。验收口径盯住均匀度 10% 红线与摘除次数日增两条监控,策略退化能被自动发现,权重变更留工单号可回溯。本篇只负责把流量匀开,下游慢了怎么办是第三篇保命层的事。下一篇进入熔断与降级,先做状态机。
延伸阅读
- 负载均衡策略的更多场景与对照,见 go-micro-cps-guide。
- 客户端 LB 与云网关的分工,见 tencent-cloud-practice。
- 摘除与探活事件进日志检索,见 rust-logpipe-guide。
