本节目标:知道一条 gRPC 连接上「并发」的单位是流而不是连接,能读出
MAX_CONCURRENT_STREAMS和流控窗口的默认值,并用在途请求数估算需要几条连接。
适用版本:JDK 25 + gRPC-Java 1.76.0(grpc-netty-shaded)。
承接的问题
5.3 解决了「发给哪个实例」。现在每个实例只有一条 channel,问题变成:这条连接能同时承载多少请求?如果并发是 500,是不是要建 500 条连接?如果只建一条,会不会排队排到超时?
HTTP/2 的答案是「一条连接上可以有多条流」。每个 gRPC 调用对应一个 stream,unary 调用的请求和响应各占一个方向,stream 结束后关闭;server streaming 和 bidi streaming 则是一条长期存在的流,期间可以发多条消息。连接是 TCP 层的,流是 HTTP/2 层的,两者的数量不是一回事。
但「多路复用」不等于「无限并发」。服务端通过 SETTINGS_MAX_CONCURRENT_STREAMS 告诉客户端一条连接上最多同时有多少条流;每条流还有自己的流控窗口,连接整体也有窗口。超过上限时,新流要么排队,要么在 deadline 到期时失败。连接数、流上限、流控窗口和服务端并发限制共同决定了实际吞吐。
这一节先拆 HTTP/2 的流模型,再读 gRPC 1.76.0 的默认值,最后给出连接数估算和 linkd-v3 的 9093 配置。连接池的设计在 6.2,关闭与 drain 在 6.3;本节只回答「一条连接能扛多少,什么时候需要第二条」。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,gRPC-Java 1.76.0。文中的默认值来自本机反射读取,吞吐数字都是算式示例,不是压测结论。
核心机制一:连接、流与消息
一次 gRPC unary 调用在 HTTP/2 上的生命周期:
客户端 服务端
| -- HEADERS (POST /plume.linkd.v3.Library/GetBook) --> |
| -- DATA (5 字节前缀 + 请求消息) -------------------> |
| <-- HEADERS (:status 200, content-type) ------------ |
| <-- DATA (5 字节前缀 + 响应消息) -------------------- |
| <-- HEADERS (trailers: grpc-status, grpc-message) --- |
这四个帧共享同一条 TCP 连接,但属于不同的 stream。HTTP/2 用 31 位的 stream ID 标识,客户端发起的流是奇数,服务端发起的是偶数。stream 之间互不阻塞(在应用层),所以一条连接可以同时跑多个 GetBook。
三种调用模式的流数量不同:
| 模式 | 流数量 | 流生命周期 |
|---|---|---|
| unary | 1 条 | 请求 + 响应 + trailers,结束即关 |
| server streaming | 1 条 | 整个响应序列期间保持 |
| client streaming | 1 条 | 整个请求序列期间保持 |
| bidi streaming | 1 条 | 双向都结束时关闭 |
SearchBooks 是 server streaming:客户端发一个请求,服务端在一条流上连续 onNext 多本 Book,最后 onCompleted。这条流在传输期间一直占着并发额度,即使某段时间没有数据;WatchStock 更极端,一条流可能开几十分钟。计算连接数时,streaming 调用的流要按「同时打开的流」算,不能按「每秒请求数」算。
核心机制二:MAX_CONCURRENT_STREAMS 与服务端并发上限
HTTP/2 的 SETTINGS_MAX_CONCURRENT_STREAMS 是服务端对客户端的约束:一条连接上最多同时存在多少条由客户端发起的流。gRPC 的 Netty 服务端用 maxConcurrentCallsPerConnection 配置这个值。
本机用反射读取 1.76.0 的 NettyServerBuilder 字段,输出:
server.maxConcurrentCallsPerConnection=2147483647
server.flowControlWindow=1048576
server.maxMessageSize=4194304
2147483647 是 Integer.MAX_VALUE,表示默认不限制单连接并发。这个默认值很激进:一条连接可以同时跑任意多条流,真正的上限落在服务端的线程池、数据库连接池和内存上。linkd-v3 不依赖这个默认值,而是在 9093 上显式配置 64:
Server server = NettyServerBuilder.forPort(9093)
.maxConcurrentCallsPerConnection(64)
.flowControlWindow(1024 * 1024)
.maxInboundMessageSize(4 * 1024 * 1024)
.addService(new LibraryServiceImpl())
.build()
.start();
maxConcurrentCallsPerConnection(64) 会通过 HTTP/2 的 SETTINGS 帧告知客户端:这条连接最多 64 条活跃流。第 65 条流不会被服务端接受,客户端会等前面的流结束,或者在 deadline 到期时失败。它不是「每秒 64 个请求」的限流,而是「同时在途 64 个」的并发上限;一个流跑 10 秒,这 10 秒内它一直占一个名额。限流和并发限制的区别在 8.1、8.2。
这个上限是每连接的,不是每服务端的。一个客户端建了两条 channel,服务端看到的是两条连接,各自可以跑 64 条流。如果希望全服务端最多 64 个并发,要用应用层的 Bulkhead 或令牌桶(7.3、8.1),不能靠这个配置。
客户端侧没有对应的「每连接并发」API。客户端能控制的是建几条 channel;每条 channel 的并发额度由服务端 SETTINGS 决定。ManagedChannelBuilder 也没有 maxConcurrentStreams 方法,不要去找。
核心机制三:流控窗口与内存
HTTP/2 的流控是「信用制」:接收方告诉发送方「你还能发多少字节」,发送方每发一段就扣减,接收方处理完再通过 WINDOW_UPDATE 补回。窗口分两层:
- stream 级窗口:每条流独立,防止单条大流饿死其他流。
- connection 级窗口:整条连接共享,防止所有流一起把接收方内存打满。
HTTP/2 协议默认的初始窗口是 65535 字节;gRPC 的 Netty 传输把默认值提高到 1 MiB。上面的反射输出里 server.flowControlWindow=1048576 就是证据,autoFlowControl=true 表示 gRPC 会根据消费速度自动补窗口,不需要业务代码手工 request(n) 来维护字节信用(gRPC 的 request(n) 管的是消息条数,不是字节窗口)。
窗口越大,单条连接的吞吐越高,但接收方要预留的缓冲也越多。一个 1 MiB 的连接窗口乘以 8 条连接就是 8 MiB 的潜在缓冲;stream 级窗口还会叠加。把窗口调到几十 MB 不会让网络变快,只会让 OOM 更早发生。linkd-v3 保持 1 MiB,只有在明确观测到「窗口不足导致吞吐上不去」时才调整:
ManagedChannel channel = NettyChannelBuilder
.forAddress("libraryd.internal", 9093, InsecureChannelCredentials.create())
.flowControlWindow(1024 * 1024)
.build();
flowControlWindow 在客户端和服务端都要匹配。只调客户端,服务端仍然按自己的窗口接收;只调服务端,客户端的发送窗口仍然受限。调之前先看 netstat 的接收队列、gRPC 的流控指标和服务端的 GC,不要凭感觉。
核心机制四:连接数估算与 TCP 队头阻塞
连接数不是「QPS」,也不是「线程数」。用 Little 定律算在途请求数,再除以单连接并发额度:
在途请求数 L = 到达率 λ × 平均处理时间 W
连接数 N = ceil(L / 单连接并发上限) + 余量
| 场景 | λ | W | L | 单连接上限 | 建议连接数 |
|---|---|---|---|---|---|
| 网关查图书详情 | 200 req/s | 30 ms | 6 | 64 | 1 |
| 批量导入 | 1000 req/s | 50 ms | 50 | 64 | 1–2 |
| 大促读流量 | 5000 req/s | 100 ms | 500 | 64 | 9–10 |
表格里的 W 是服务端处理时间,不是客户端观测到的 p99;把排队时间算进 W 会高估所需连接。余量用于吸收突发和实例故障,通常留 20%–50%,不是精确值。
如果服务端没有配置 maxConcurrentCallsPerConnection(默认 Integer.MAX_VALUE),公式里的「单连接上限」就不存在,一条连接可以承载所有在途流。这时连接数由另外三个因素决定:
- TCP 队头阻塞。 一个丢包会拖住这条连接上的所有流,因为 TCP 按序交付。多条连接可以把丢包的影响限制在其中一条上,代价是更多握手和内存。
- 单连接的 CPU 分布。 一条连接的所有读写通常落在同一个 EventLoop 线程上;连接数太少,单核先饱和。
- 故障隔离。 一条连接被网络设备重置,上面的所有流一起失败;多条连接可以缩小爆炸半径。
反过来,连接太多也有代价:每条连接都要 TCP 三次握手、TLS 握手(如果启用)、HTTP/2 设置交换和独立的流控缓冲;服务端的 fd、内存和负载均衡状态都会随连接数上涨。默认应该是一条连接,只有在「并发额度不够」「需要隔离」「单 EventLoop 饱和」这三个理由之一成立时,才增加到多条。
MAX_CONCURRENT_STREAMS 和 TCP 队头阻塞是两件事。前者限制流的数量,后者影响所有流的延迟;把 maxConcurrentCallsPerConnection 调大不会消除丢包导致的停顿,只会让更多流一起停顿。
核心机制五:观测在途流与调参顺序
连接数估算的输入是「在途请求数」,但 gRPC 没有直接暴露「当前这条 channel 上有多少条活跃流」的公开 API。可行的做法是在应用层自己计数:网关每进入一个下游调用加一,调用结束减一,用 LongAdder 避免热点竞争。
package com.plume.linkd.v3.gateway;
import java.util.concurrent.Callable;
import java.util.concurrent.atomic.LongAdder;
public final class InFlightGauge {
private final LongAdder current = new LongAdder();
private final LongAdder rejected = new LongAdder();
public long current() {
return current.sum();
}
public long rejected() {
return rejected.sum();
}
public <T> T track(Callable<T> task) throws Exception {
current.increment();
try {
return task.call();
} finally {
current.decrement();
}
}
public void reject() {
rejected.increment();
}
}
这个计数不是 HTTP/2 的流计数:一个 unary 调用对应一条流,一个 server streaming 调用也只对应一条流,但可能持续几分钟。用它估算连接数时,streaming 要单独按「同时打开的流」统计,不能和 unary 混在一个平均值里。
调参顺序应该是:
- 先看服务端的在途请求数和处理时间,确认瓶颈在服务端还是客户端。
- 如果单连接并发额度经常打满,而服务端 CPU、数据库都没饱和,再调大
maxConcurrentCallsPerConnection。 - 如果单连接的 EventLoop 先饱和,或者需要隔离丢包/故障,再增加连接。
- 最后才考虑流控窗口;窗口通常不是第一瓶颈,调大它之前要有明确的流控等待证据。
| 观察项 | 来源 | 用途 |
|---|---|---|
| 应用在途请求数 | InFlightGauge.current() | 估算 L,判断连接数 |
| 服务端处理时间 p99 | 11.1 的 histogram | 计算 W |
| 单连接并发上限 | 服务端 SETTINGS / 配置 | 计算 N |
| 流控等待 | gRPC 指标或 tracing | 判断窗口是否太小 |
| 连接状态 | channel.getState(true) | 判断是否 TRANSIENT_FAILURE |
不要把 InFlightGauge 的当前值直接当成「需要多少条连接」;它是瞬时值,连接数是按峰值和余量规划的。压测时记录峰值,再按上面的公式算,而不是看某一秒的平均数。
在 linkd-v3 里落地:9093 的连接配置
linkd-v3 到 libraryd 的 9093 是一条长连接,服务端配置 64 条并发流、1 MiB 窗口、4 MiB 消息上限;客户端配置同样的窗口和消息上限,不额外建连接。配置集中在两个类里:
package com.plume.linkd.v3.server;
import io.grpc.Server;
import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
public final class MultiplexedServer {
public static Server start(int port, LibraryServiceImpl service) {
return NettyServerBuilder.forPort(port)
.maxConcurrentCallsPerConnection(64)
.flowControlWindow(1024 * 1024)
.maxInboundMessageSize(4 * 1024 * 1024)
.addService(service)
.build();
}
}
客户端:
package com.plume.linkd.v3.core.client;
import io.grpc.InsecureChannelCredentials;
import io.grpc.ManagedChannel;
import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder;
public final class MultiplexedClient {
public static ManagedChannel channel(String host, int port) {
return NettyChannelBuilder
.forAddress(host, port, InsecureChannelCredentials.create())
.flowControlWindow(1024 * 1024)
.maxInboundMessageSize(4 * 1024 * 1024)
.build();
}
}
本机用 4.1 的自包含探针验证过这条调用链,输出 unary=book:42;它证明的是 API 可用,不是并发能力。要验证 64 条流的上限,需要并发压测并观察服务端的在途请求计数;本节不伪造这组数字。
如果压测发现 64 条流不够,先确认是「服务端处理慢导致流占用时间长」还是「客户端并发真的高」。前者应该优化服务端,后者才考虑调大上限或加连接。把 maxConcurrentCallsPerConnection 直接改成 Integer.MAX_VALUE,等于把保护去掉,慢请求会把服务端内存和线程池吃满。
常见错误与边界
错误一:把 maxConcurrentCallsPerConnection 当成 QPS 限流。 它是同时在途的流数,不是每秒请求数。一个 10 秒的流占一个名额 10 秒,QPS 可能很低但并发额度已满。限流用 8.1 的令牌桶。
错误二:为了「提高并发」无限加连接。 连接数增加会带来握手、fd、内存和 LB 状态的成本;在服务端没有流上限时,一条连接通常就够。先看瓶颈在 CPU、数据库还是网络,再决定加不加连接。
错误三:只调客户端流控窗口。 流控是双向协商,客户端和服务端的窗口都要匹配;只调一端,另一端仍然是瓶颈。
错误四:用 getState() == READY 判断连接能承载多少。 READY 只说明连接可用,不说明还有多少流额度;额度由服务端 SETTINGS 和服务端当前在途流决定。
错误五:把 streaming 的流按请求数计算。 WatchStock 一条流可能开几分钟,按 QPS 算连接数会严重低估并发占用。
边界:HTTP/2 多路复用不消除队头阻塞。 它消除的是 HTTP/1.1 的应用层队头阻塞;TCP 层的丢包仍然会拖住整条连接。需要隔离丢包影响时,才考虑多条连接。
边界:连接数不是越多越「高可用」。 每条连接都可能失败,连接越多,故障组合越多;drain 和重连的逻辑也越复杂。默认一条,按证据增加。
小结
- 一条 HTTP/2 连接可以同时承载多条流;unary 一条流,streaming 一条长流,流数量不等于请求数。
- 服务端用
maxConcurrentCallsPerConnection设置MAX_CONCURRENT_STREAMS;1.76.0 默认Integer.MAX_VALUE,linkd-v3显式设为 64。 - 流控窗口分 stream 级和 connection 级;gRPC Netty 默认 1 MiB,调大窗口会增加内存而不是免费提速。
- 连接数按
L = λW估算在途请求,再除以单连接并发上限;默认一条连接,只有在额度、隔离或 EventLoop 饱和时才增加。 - TCP 队头阻塞仍然存在,多路复用不能消除丢包对整条连接的影响。
- 本机探针输出
unary=book:42,只证明调用链可用;并发上限需要压测验证,不能编造数字。
下一节讨论「什么时候真的需要多条 channel」:按租户隔离、按用途拆分、池的键与上限,以及如何避免把连接池写成每请求一条连接。
阅读导航:上一节 5.3 round_robin、pick_first 与健康检查 | 下一节 6.2 客户端连接池设计
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。