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% 红线与摘除次数日增两条监控,策略退化能被自动发现,权重变更留工单号可回溯。本篇只负责把流量匀开,下游慢了怎么办是第三篇保命层的事。下一篇进入熔断与降级,先做状态机。

延伸阅读