我不会起名字322 · 后端 / 算法 / 数据库
文章目录
- 负载均衡选错:轮询在长连接下的偏斜问题
-
- 一、现象:明明配了轮询,流量却集中在某一台
- 二、两种负载均衡的区别
- 三、为什么"连接级"是默认
- [四、解法 1:用请求级负载均衡](#四、解法 1:用请求级负载均衡)
- [五、解法 2:用 least_request(最少请求)](#五、解法 2:用 least_request(最少请求))
- [六、解法 3:定期重建连接(有争议的兜底)](#六、解法 3:定期重建连接(有争议的兜底))
- [七、还有一个坑:K8s Service 的"负载均衡"](#七、还有一个坑:K8s Service 的"负载均衡")
- 八、怎么验证流量是不是偏的
- 九、带走物
负载均衡选错:轮询在长连接下的偏斜问题
一、现象:明明配了轮询,流量却集中在某一台
典型场景:
- 服务有 4 个实例,配了
round_robin; - 但监控显示其中 1~2 个实例的 CPU 是其他的 2~3 倍;
- 扩容之后没有好转,新的实例几乎收不到流量;
- 重启调用方之后分布变均匀,跑一段时间又偏了。
根因只有一个:你在长连接上做"每条连接轮询",而不是"每个请求轮询"。
二、两种负载均衡的区别
这是理解问题的关键。gRPC(以及很多 RPC 框架)区分两种 LB:
| 连接级 LB | 请求级 LB | |
|---|---|---|
| 决策时机 | 建连时决定连哪个后端 | 每次请求决定发哪个后端 |
| 实现位置 | 客户端建连接池时 | 客户端每次调用时 |
| 连接数 | 每个后端一条连接 | 每个后端一条连接 |
| 轮询含义 | "新建的第 N 条连接连第 N 个后端" | "第 N 个请求发给第 N 个后端" |
长连接 + 连接级 LB = 灾难:连接一旦建立就长期复用,"轮询"只在建连那一刻生效,之后所有请求都走那条已经选定的连接。
具体会偏成什么样
假设调用方有 3 个实例,被调用方有 4 个实例:
text
调用方 A 建了 2 条连接 → 都连到了 后端 1
调用方 B 建了 2 条连接 → 都连到了 后端 2
调用方 C 建了 2 条连接 → 都连到了 后端 3
后端 4:一个都没连上
结果:后端 4 完全空闲,后端 1/2/3 各承担 1/3。 这还算好的情况。更常见的是某个调用方连了 5 条连接全落到一个后端上。
HTTP/2 让这个问题更严重 :gRPC 用 HTTP/2,多个请求可以在同一条连接上多路复用 。所以一条连接就能承载全部流量------"连接级轮询"退化成了"随机选一个后端然后一直用"。
三、为什么"连接级"是默认
这不是设计缺陷,是历史原因:
连接级 LB 是传输层做的(在 TCP 建连时选一个 IP)。它不需要理解应用协议,性能好、实现简单。
请求级 LB 需要框架在每次调用时做决策,成本高一点(要维护每个后端的连接、要统计、要选)。
问题在于:HTTP/1.1 的时代,连接级 LB 勉强够用 (因为一条连接同时只能处理一个请求,所以会建很多连接,分布还算均匀)。而 HTTP/2 的多路复用让"一条连接承载所有请求"成为常态,连接级 LB 就彻底失效了。
四、解法 1:用请求级负载均衡
gRPC 里显式指定:
go
conn, err := grpc.Dial(
"dns:///order-service.default.svc.cluster.local:8080",
grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`),
)
注意 dns:/// 这个 scheme:
text
"order-service:8080" → 默认 pick_first(连接级!)
"dns:///order-service:8080" → 解析出多个 IP,配合 round_robin 做请求级轮询
只写 round_robin 但地址是 passthrough 模式,是没有效果的------因为 passthrough 只解析出一个地址(DNS 返回的那个),或者把整个名字当成一个后端。
完整的正确写法:
go
conn, err := grpc.Dial(
"dns:///order-service.default.svc.cluster.local:8080",
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithDefaultServiceConfig(`{
"loadBalancingPolicy": "round_robin",
"healthCheckConfig": {"serviceName": ""}
}`),
)
加上 health check 更重要:没有健康检查时,gRPC 会往已经下线的后端继续发请求,直到连接失败才发现。
五、解法 2:用 least_request(最少请求)
round_robin 还有一个问题:它假设每个请求代价相同。如果请求耗时差异很大(有的 10ms,有的 2 秒),轮询会让"分到慢请求的实例"堆积更多在途请求。
least_request 会选当前在途请求最少的后端:
go
grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"least_request"}`)
对比:
| 策略 | 适用 | 不适用 |
|---|---|---|
pick_first |
单后端 / 只有一个实例 | 多实例(默认就是这个,很容易踩坑) |
round_robin |
请求耗时均匀 | 耗时差异大 |
least_request |
耗时差异大 | 需要额外的统计开销 |
我的建议 :默认就用 least_request,它的开销很小,但对"长尾请求"的容忍度高得多。
六、解法 3:定期重建连接(有争议的兜底)
有些框架没有请求级 LB,只能在连接层面缓解------定期断掉连接重建,让"轮询"重新发生。
go
// 给连接设一个最大存活时间,到期强制重建
config := sarama.NewConfig()
// Kafka 的 Consumer 或者 HTTP transport:
tr := &http.Transport{
MaxIdleConnsPerHost: 100,
IdleConnTimeout: 60 * time.Second, // 空闲 60s 就关掉重建
}
这是治标不是治本,而且有副作用:
- 频繁重建连接会带来 DNS + TCP + TLS 的开销;
- 重建瞬间可能出现"所有连接同时失效";
- 如果所有调用方的重建周期一致,会造成周期性抖动(要加随机抖动)。
只有在框架不支持请求级 LB 时才用它。
七、还有一个坑:K8s Service 的"负载均衡"
K8s 的 Service(ClusterIP)做的是连接级负载均衡,不是请求级。
这意味着:
text
Pod A 通过 ClusterIP 访问 Service
→ iptables/ipvs 在**建连时**选一个后端 Pod
→ 这条连接之后的所有请求都发给同一个后端 Pod
所以 gRPC 通过 K8s Service 访问,流量必然是偏的,而且扩容 Service 后面的 Pod 也不会改善------因为长连接早就建好了。
四种解法:
| 方案 | 做法 | 代价 |
|---|---|---|
| 客户端服务发现 | 不用 Service,客户端直接拿 Endpoint 列表做请求级 LB | 需要服务发现组件 |
| Service Mesh | 用 Istio/Linkerd,sidecar 做请求级 LB | 架构复杂度和资源开销 |
| headless Service | clusterIP: None,DNS 返回所有 Pod IP,客户端自己做 LB |
客户端要支持 |
| 定期断连 | 缩短 IdleConnTimeout |
治标,有开销 |
headless Service + gRPC 的 dns:/// 是最轻量的组合:
yaml
apiVersion: v1
kind: Service
spec:
clusterIP: None # headless
selector:
app: order
go
// 客户端拿到所有 Pod IP,自己做请求级轮询
conn, _ := grpc.Dial("dns:///order.default.svc.cluster.local:8080",
grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`))
八、怎么验证流量是不是偏的
不要只看调用方的指标 ,要看每个后端实例收到的请求数:
promql
# 按 Pod 聚合 gRPC 请求数,看分布
sum by (pod) (rate(grpc_server_started_total[5m]))
健康的分布应该是各实例接近平均值。如果最大值 / 最小值 > 1.5,就是明显偏斜。
也可以在客户端 dump 一下连接池状态(很多框架支持):
bash
curl http://localhost:8080/debug/lb # 看每个后端分配了多少请求
九、带走物
检查清单:
text
1. 负载均衡策略是不是默认的 pick_first?
└─ 是 → 改成 round_robin 或 least_request
2. gRPC 的地址 scheme 对不对?
└─ 必须是 dns:///,passthrough 模式下 round_robin 无效
3. 是不是通过 K8s Service 访问 gRPC?
└─ 是 → Service 只做连接级 LB,要换 headless Service 或上 Mesh
4. 有没有开健康检查?
└─ 没有 → 加上,否则会往已下线的实例发请求
5. 验证:sum by (pod) (rate(grpc_server_started_total[5m]))
└─ 最大值/最小值 > 1.5 就是偏斜
一句话结论:长连接上的"轮询"如果不做在请求级,就等于没做------建连时选一次,之后所有请求都走那条连接;HTTP/2 的多路复用会把这个问题放大到"一个后端扛全部"。