我不会起名字322· 后端 / 算法 / 数据库
📘 技术栈 | 🧩 力扣 Hot100 | 🐹 Go 项目 | 🟥 Redis | 🐬 MySQL
文章目录
- gRPC 连接池打满:HTTP/2 多路复用下的 3 类瓶颈
- 一、先纠正一个直觉
- 二、那"连接池打满"到底是什么满了
- 瓶颈 1:`MAX_CONCURRENT_STREAMS`(最常见)
- 瓶颈 2:单条连接的带宽 / 队头阻塞
- 瓶颈 3:客户端侧的资源限制
- 三、什么时候该加连接
- 四、一份推荐的客户端配置
- 五、带走物
gRPC 连接池打满:HTTP/2 多路复用下的 3 类瓶颈
一、先纠正一个直觉
从 HTTP/1.1 过来的人会本能地觉得:“并发不够就加连接数”。
这个直觉在 gRPC/HTTP-2 下是错的,而且会帮倒忙。
HTTP/2 的核心特性是多路复用(multiplexing):一条 TCP 连接上可以同时跑多个请求,用 Stream ID 区分。所以:
HTTP/1.1:一条连接同时只能处理 1 个请求 → 并发 = 连接数 HTTP/2 :一条连接可以同时处理 N 个请求 → 并发 ≠ 连接数gRPC 官方推荐的做法是:一个目标地址复用一条连接(或者少数几条),而不是每个请求建一条。
grpc.Dial返回的ClientConn本身就是"连接池 + 负载均衡 + 重连"的封装,它是线程安全的,应该全局复用:
// ✅ 正确:全局一个 ClientConn,到处用varorderConn*grpc.ClientConnfuncinit(){varerrerrororderConn,err=grpc.Dial("dns:///order-service:8080",...)}funcGetOrder(ctx context.Context,idstring)(*Order,error){returnpb.NewOrderClient(orderConn).GetOrder(ctx,&pb.GetReq{Id:id})}// ❌ 错误:每次调用都 DialfuncGetOrder(ctx context.Context,idstring)(*Order,error){conn,_:=grpc.Dial(addr,...)// 每次建连接!deferconn.Close()// 建完就关,全是 TIME_WAITreturnpb.NewOrderClient(conn).GetOrder(ctx,...)}后者会造成:连接数暴涨、TIME_WAIT 堆积、TLS 握手开销重复、端口耗尽。
二、那"连接池打满"到底是什么满了
既然一条连接就能复用,为什么还会"打满"?因为单条连接上的并发是有上限的。
瓶颈 1:MAX_CONCURRENT_STREAMS(最常见)
HTTP/2 有一个设置项SETTINGS_MAX_CONCURRENT_STREAMS,表示对端允许本端在这条连接上同时开多少个 stream。
服务端设置:我最多同时处理 100 个 stream 客户端在这条连接上开了 100 个请求 → 第 101 个要排队gRPC-Go 的默认行为:
- 服务端默认限制
MaxConcurrentStreams(如果不显式设置,用的是math.MaxUint32,即不限制); - 但很多中间设备(Envoy、Nginx、云厂商的 LB)会设一个比较小的值,常见的是 100 或 128。
超过限制会怎样:客户端不会报错,它会排队等待,表现为延迟升高而不是报错。这是最难发现的一类问题——你的 P99 突然变高,但没有任何错误日志。
怎么确诊:
// 客户端打点:看等待可用 stream 的时间// gRPC 有相应的统计,但更实用的方法是直接看延迟分布或者服务端显式设置并观察:
server:=grpc.NewServer(grpc.MaxConcurrentStreams(1000),// 显式放大)如果调大之后延迟下降,就是它。
瓶颈 2:单条连接的带宽 / 队头阻塞
HTTP/2 的多路复用在TCP 层仍然是串行的:所有 stream 共享同一个 TCP 连接,共享同一个拥塞控制窗口。
后果:
- 大响应会挤占小响应:一个 10MB 的响应会把整条连接的带宽吃掉,其他小请求排队;
- 丢包会阻塞所有 stream:TCP 丢一个包,整条连接上的所有 stream 都要等重传(这是 HTTP/2 相对 HTTP/3 的固有缺陷)。
症状:请求延迟的方差很大,偶尔出现"整体卡一下"。
解法:
- 大响应走流式或者分片,不要在一个 unary 调用里返回 10MB;
- 建多条连接分担(这时加连接才有用):
// 用多个 ClientConn 分担带宽(注意:要用不同的 ClientConn,不是加 pool size)varconns[]*grpc.ClientConn// 比如 4 条funcpick()*grpc.ClientConn{returnconns[atomic.AddUint64(&idx,1)%uint64(len(conns))]}注意:gRPC 的ClientConn内部已经是"一个地址一条连接",没有"pool size"这种参数。要分担带宽,只能建多个ClientConn。
瓶颈 3:客户端侧的资源限制
这一类不是协议问题,是 Go 程序自己的配置问题:
(a) 没有设置消息大小上限
grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(64*1024*1024),// 默认只有 4MB!)默认MaxCallRecvMsgSize是 4MB。超过会报:
rpc error: code = ResourceExhausted desc = grpc: received message larger than max(b) 没有 keepalive,连接被中间设备悄悄断开
grpc.WithKeepaliveParams(keepalive.ClientParameters{Time:30*time.Second,Timeout:5*time.Second,PermitWithoutStream:true,// 没有活跃流时也发 ping})PermitWithoutStream: true很关键:默认false,意味着空闲时不发 ping,长连接会被 NAT/防火墙清理,下次用到时才发现是死连接。
© 没有连接超时
grpc.Dial(addr,grpc.WithBlock(),// 阻塞等待连接建立grpc.WithTimeout(5*time.Second),// 已废弃)// 新写法ctx,cancel:=context.WithTimeout(ctx,5*time.Second)defercancel()conn,err:=grpc.DialContext(ctx,addr,grpc.WithBlock(),...)gRPC 默认是懒连接(不阻塞),第一次调用时才真的建连。如果地址不通,第一次调用会失败。用WithBlock()+DialContext可以在启动时就发现"连不上"。
三、什么时候该加连接
结论:默认一条就够,只有两种情况需要加:
| 场景 | 要不要加连接 |
|---|---|
| 普通 RPC(小请求小响应) | ❌ 一条足够 |
| 大响应(>1MB)占带宽 | ✅ 建 2~4 条分担 |
| 单连接的 stream 上限被打满 | ✅ 或者调大MaxConcurrentStreams |
| 跨可用区,单连接带宽受限于 RTT | ✅ 可以加 |
盲目加连接的代价:
- 每个
ClientConn都要一次 TCP + TLS 握手; - 服务端要维护更多连接(内存、fd);
- 负载均衡重新洗牌:连接多了,"每个连接轮询"又会变成偏斜(详见负载均衡那篇)。
四、一份推荐的客户端配置
conn,err:=grpc.DialContext(ctx,"dns:///order-service.default.svc.cluster.local:8080",grpc.WithTransportCredentials(insecure.NewCredentials()),// 1. 负载均衡:请求级grpc.WithDefaultServiceConfig(`{ "loadBalancingPolicy": "round_robin", "healthCheckConfig": {"serviceName": ""} }`),// 2. keepalive:保活 + 探测死连接grpc.WithKeepaliveParams(keepalive.ClientParameters{Time:30*time.Second,Timeout:5*time.Second,PermitWithoutStream:true,}),// 3. 消息大小grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(32*1024*1024),grpc.MaxCallSendMsgSize(32*1024*1024),),// 4. 启动时就验证连通性grpc.WithBlock(),// 5. 拦截器链grpc.WithChainUnaryInterceptor(logInterceptor,timeoutInterceptor),)五、带走物
排查"gRPC 慢/连接池满"的顺序:
1. 是不是每次调用都 Dial? └─ 是 → 改成全局复用一个 ClientConn(最常见也最致命) 2. P99 高但没有错误? └─ 大概率是 MAX_CONCURRENT_STREAMS 排队 → 调大服务端的 MaxConcurrentStreams 3. 延迟方差大、偶尔整体卡? └─ 大响应挤占带宽 或 丢阻塞 → 大响应改流式/分片,或建多条 ClientConn 分担 4. 报 received message larger than max? └─ 调 MaxCallRecvMsgSize(默认只有 4MB) 5. 空闲一段时间后第一次调用失败? └─ keepalive 没配或 PermitWithoutStream 没开一句话结论:gRPC 里"连接池打满"多半不是连接不够,而是单条连接上的 stream 上限被打满或带宽被大响应挤占;默认应该复用一个 ClientConn,只有大响应和高吞吐场景才需要建多条。