连接管理:连接池、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,客户端名单随之更新;dns resolver 有最短重解析间隔(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.Time30s必须 ≥ 服务端 MinTime(10s)
ClientParameters.Timeout10sPING 无响应即判定坏连接
ClientParameters.PermitWithoutStreamtrue空闲也保活,防 NAT 静默回收
EnforcementPolicy.MinTime10s小于它的客户端会被 GOAWAY
ServerParameters.Time / Timeout60s / 20s服务端反向探测客户端
ServerParameters.MaxConnectionIdle5m回收僵尸连接
MaxConnectionAge / MaxConnectionAgeGrace30m / 30s连接重新洗牌,让 LB 真正生效
负载均衡策略round_robin(配 headless)单实例或直连场景才用 pick_first
连接池大小2~8 条/后端以并发流数与 MaxConcurrentStreams 为准
MaxRecvMsgSize4~16MB与消息规格一致,见第 12 章

常见坑

  • pick_first + ClusterIP 当成负载均衡:解析出一个 VIP,请求全落在同一个 Pod。排查时看 resolver 返回的地址数,只有 dns + headless 才会返回多个。
  • keepalive 两侧打架:客户端 10s PING、服务端 MinTime 30s,会被判 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 条连接的池子。

笔记加载中…