负载均衡选错:轮询在长连接下的偏斜问题

我不会起名字322 · 后端 / 算法 / 数据库

📘 技术栈 | 🧩 力扣 Hot100 | 🐹 Go 项目 | 🟥 Redis | 🐬 MySQL

文章目录

负载均衡选错:轮询在长连接下的偏斜问题

一、现象:明明配了轮询,流量却集中在某一台

典型场景:

  • 服务有 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 的多路复用会把这个问题放大到"一个后端扛全部"。

相关推荐
。19 小时前
【无标题】
linux·服务器·负载均衡·lvs
code_slave(码畜)19 小时前
微服务架构落地:OpenFeign 服务远程调用 + 线程池隔离架构实战 —— 超时控制、重试机制、线程池隔离,防雪崩
spring boot·spring cloud·微服务·云原生·架构·openfeign
涵美女程序猿20 小时前
ChatCut online 剪微服务熔断测试录屏:仪表盘告警、降级日志与恢复时刻怎样对应
程序人生·微服务·云原生·架构·电脑
hhb_61821 小时前
智联AI办公微服务技术全解析
人工智能·微服务·架构
Thomas.Sir1 天前
第31课:微服务消息驱动(RabbitMQ/RocketMQ)适配与解耦实战
微服务·rabbitmq·rocketmq
果霸大叔1 天前
AI网关第8篇:AI 代理与负载均衡--多模型实例如何调度?
人工智能·算法·自然语言处理·负载均衡·ai-native
Thomas.Sir2 天前
第29课:微服务异步调用、线程池优化 & 事件驱动开发
java·redis·微服务
CarIise2 天前
Maven私服(Nexus)
微服务
敲上瘾2 天前
分布式系统——Redis哨兵:自动故障转移与选举原理
linux·数据库·redis·分布式·缓存·微服务