连接管理:连接池、keepalive 与负载均衡
gRPC 不需要传统意义上的连接池:一个 ClientConn 就是一条 HTTP/2 长连接,并发 RPC 在它上面多路复用成不同的流。真正要管的是三件事——这条连接怎么建、假死与空闲怎么发现、请求怎么在多个后端之间分。任何一件搞错,表现都是"平时挺好,扩容或滚动发布时集中出问题"。
一个 ClientConn 就是一条复用连接
进程启动时建一次,全局复用:
conn, err := grpc.NewClient(
"dns:///logic-svc.default.svc.cluster.local:9000", // scheme 决定用哪个 resolver
grpc.WithTransportCredentials(insecure.NewCredentials()), // 内网明文;跨网用 credentials.NewTLS
grpc.WithDefaultServiceConfig(lbRoundRobin), // 负载均衡策略,见下
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: 30 * time.Second, // 无活动时每 30s 发一次 PING
Timeout: 10 * time.Second, // PING 无响应即判定连接坏,触发重连
PermitWithoutStream: true, // 空闲连接也保活,需与服务端 MinTime 匹配
}),
)
if err != nil {
return err // 参数/凭证错误在这里返回,此时还没有建连
}
defer conn.Close() // 长驻进程退出前必须关,否则 goroutine 泄漏
grpc.NewClient只做参数校验与 resolver 注册,不建连也不阻塞,连接在第一次 RPC(或显式conn.Connect())时才建立。想"启动即建连、失败即失败",就显式conn.Connect(),再用conn.WaitForStateChange/conn.GetState()判断就绪;- 不要每次请求
NewClient:它不阻塞也不报错,泄漏起来悄无声息——每条 ClientConn 都带 resolver、子连接与健康检查 goroutine; target的 scheme 决定寻址方式:dns:///(K8s 里配 headless Service)、passthrough:///(直连)、unix:///(本机 socket)、xds:///(xDS 控制面)。
keepalive:发现"看起来还活着"的坏连接
NAT、四层 LB 会静默回收空闲连接,客户端以为连接还在,直到下次发请求才发现超时。keepalive 两侧都要配,而且必须匹配:客户端 Time 小于服务端 EnforcementPolicy.MinTime 时,服务端会回 GOAWAY 并带 too_many_pings,连接被踢。
srv := grpc.NewServer(
grpc.KeepaliveParams(keepalive.ServerParameters{
MaxConnectionIdle: 5 * time.Minute, // 回收空闲僵尸连接
MaxConnectionAge: 30 * time.Minute, // 强制换连接,避免长连接把负载钉死在旧 Pod
MaxConnectionAgeGrace: 30 * time.Second, // 给在途 RPC 收尾时间
Time: 60 * time.Second, // 反向 PING,探测客户端是否还活
Timeout: 20 * time.Second,
}),
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
MinTime: 10 * time.Second, // 客户端 PING 间隔小于它视为滥用
PermitWithoutStream: true, // 允许空闲时 PING,否则空闲连接会被判违规
}),
)
MaxConnectionAge 与 K8s 的组合尤其重要:长连接加 ClusterIP 会让流量长期压在少数旧 Pod 上,滚动发布后新 Pod 分不到流量;服务端定期断连(30 分钟 + 30s 宽限)能把连接重新洗牌。
客户端负载均衡:headless Service + round_robin
默认策略是 pick_first:只用解析到的第一个地址。它会把所有请求钉死在一个 Pod 上,这是"扩了容但 QPS 没变"的最常见原因。
// 策略名必须放在 loadBalancingConfig 里,写成字符串 "round_robin" 是旧式写法,别混用
const lbRoundRobin = `{"loadBalancingConfig": [{"round_robin": {}}]}`
round_robin只在 resolver 返回多个地址时才有意义。K8s 里普通 Service(ClusterIP)的 DNS 只给一个 VIP,轮询一个虚拟 IP 等于没轮询;- 正确组合:
headless Service(clusterIP: None)+dns:///svc.ns.svc.cluster.local:9000+round_robin,DNS 返回全部就绪 Pod 的 IP,由客户端自己挑; - headless 配合
readinessProbe:未就绪 Pod 不进 endpoints,客户端名单随之更新;dnsresolver 有最短重解析间隔(grpc-go 约 30s),摘除 Pod 后最多 30s 内停止打流量; - 客户端 LB 省掉一跳 kube-proxy 转发,代价是每个调用方进程都要解析 DNS、维护子连接。后端几十个实例时这笔账划算;上千实例时改用
xds/服务网格更省心。
什么时候还需要连接池
单连接多路复用不是万能的:
| 场景 | 现象 | 处理 |
|---|---|---|
| 流数打满 | 服务端 MaxConcurrentStreams 用尽,新流排队等 | 见第 12 章;也可对同一 target 建 2~4 条 ClientConn 分摊 |
| 单 TCP 吞吐到顶 | 大消息场景受连接拥塞窗口限制 | 每个后端建 2~8 条连接 |
| 多集群/多机房 | 需要按机房分流、故障时切换 | 不同 target 各建一条 ClientConn,按机房路由 |
// 同一个 target 建 N 条 ClientConn,轮询取用(仅在大消息/高吞吐场景需要)
type Pool struct {
conns []*grpc.ClientConn
next atomic.Uint64
}
func (p *Pool) Get() *grpc.ClientConn {
return p.conns[p.next.Add(1)%uint64(len(p.conns))]
}
池大小按"并发流数 ÷ MaxConcurrentStreams"估算:目标 4000 并发流、服务端 1000 并发流,则至少 4 条。普通的元数据类小请求一条连接足够——HTTP/2 多路复用本来就是为了这个,盲目多连接只增加 FD、内存与 TLS 开销。
参数建议值
| 参数 | 建议值 | 说明 |
|---|---|---|
ClientParameters.Time | 30s | 必须 ≥ 服务端 MinTime(10s) |
ClientParameters.Timeout | 10s | PING 无响应即判定坏连接 |
ClientParameters.PermitWithoutStream | true | 空闲也保活,防 NAT 静默回收 |
EnforcementPolicy.MinTime | 10s | 小于它的客户端会被 GOAWAY |
ServerParameters.Time / Timeout | 60s / 20s | 服务端反向探测客户端 |
ServerParameters.MaxConnectionIdle | 5m | 回收僵尸连接 |
MaxConnectionAge / MaxConnectionAgeGrace | 30m / 30s | 连接重新洗牌,让 LB 真正生效 |
| 负载均衡策略 | round_robin(配 headless) | 单实例或直连场景才用 pick_first |
| 连接池大小 | 2~8 条/后端 | 以并发流数与 MaxConcurrentStreams 为准 |
MaxRecvMsgSize | 4~16MB | 与消息规格一致,见第 12 章 |
常见坑
pick_first+ ClusterIP 当成负载均衡:解析出一个 VIP,请求全落在同一个 Pod。排查时看 resolver 返回的地址数,只有dns+ headless 才会返回多个。- keepalive 两侧打架:客户端 10s PING、服务端
MinTime30s,会被判too_many_pings断开,日志里能看到 GOAWAY/ENHANCE_YOUR_CALM。参数必须成套改。 PermitWithoutStream不一致:客户端 true、服务端不允许,空闲连接被踢,表现为"夜里没流量,早上第一批请求大面积失败"。- 每次请求新建 ClientConn:
NewClient不阻塞也不报错,泄漏的 goroutine 与文件描述符要几小时才显形,压测时才暴露。 - 不
Close():进程退出前不关连接,优雅下线时在途 RPC 被硬断;第 10 章的重试能兜住一部分,但不要依赖它。 - 忽视 DNS 重解析延迟:容器里 DNS 变化不会立刻生效;要求秒级摘除的场景要靠更短的
MaxConnectionAge或改用 xDS。 - 跨机房建连:别让
dns把跨机房 IP 混进地址名单,否则一半流量跨机房往返,延迟与带宽双输。
小结:一个 ClientConn 就是一条 HTTP/2 多路复用长连接,用 grpc.NewClient 在启动时建好并全局复用;keepalive 客户端 30s/10s、服务端 MinTime 10s 加 MaxConnectionAge 30m+30s,两侧成套配置;负载均衡用 round_robin 必须配 headless Service 与 dns:/// 多地址,pick_first 只适合单实例;只有大消息、高吞吐或并发流打满时才按后端建 2~8 条连接的池子。