《Java 服务端通信工程》6.1 HTTP/2 多路复用与连接数

一条 HTTP/2 连接能同时跑多少请求,取决于流上限、流控窗口和服务端并发配置。本节用 1.76.0 的实测默认值拆开 MAX_CONCURRENT_STREAMS 与 1 MiB 窗口,给出连接数估算表和 linkd-v3 的 9093 配置。

本节目标:知道一条 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。

三种调用模式的流数量不同:

模式流数量流生命周期
unary1 条请求 + 响应 + trailers,结束即关
server streaming1 条整个响应序列期间保持
client streaming1 条整个请求序列期间保持
bidi streaming1 条双向都结束时关闭

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 / 单连接并发上限) + 余量
场景λWL单连接上限建议连接数
网关查图书详情200 req/s30 ms6641
批量导入1000 req/s50 ms50641–2
大促读流量5000 req/s100 ms500649–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 混在一个平均值里。

调参顺序应该是:

  1. 先看服务端的在途请求数和处理时间,确认瓶颈在服务端还是客户端。
  2. 如果单连接并发额度经常打满,而服务端 CPU、数据库都没饱和,再调大 maxConcurrentCallsPerConnection。
  3. 如果单连接的 EventLoop 先饱和,或者需要隔离丢包/故障,再增加连接。
  4. 最后才考虑流控窗口;窗口通常不是第一瓶颈,调大它之前要有明确的流控等待证据。
观察项来源用途
应用在途请求数InFlightGauge.current()估算 L,判断连接数
服务端处理时间 p9911.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 客户端连接池设计

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收