《Netty 高性能网络编程》11.1 Http2FrameCodec 与多路复用

HTTP/2 帧分连接级与 stream 级两层。本节用 linkd-v2 的 9091 端口拆开 Http2FrameCodec 与 Http2MultiplexHandler 的分工,说明 preface、SETTINGS、HEADERS、DATA、WINDOW_UPDATE、GOAWAY 归谁处理,对比 h2c prior-knowledge 与 ALPN,并给出多路复用与连接池的取舍。

本节目标:分清 HTTP/2 的连接级帧与 stream 级帧,知道 Http2FrameCodec 和 Http2MultiplexHandler 各管什么,并为 9091 端口画出正确的 pipeline。
适用版本:JDK 25 + Netty 4.2.19.Final(netty-codec-http2 模块)。

承接的问题

linkd-v2 的 9090 端口是 HTTP/1.1:HttpServerCodec、HttpObjectAggregator、LinkdHttpHandler 三层,一个 TCP 连接在同一时刻只处理一个请求。要并发一百个请求,客户端连接池就开一百条连接,每条连接各做一次 TCP 握手;如果套 TLS,还要各做一次 TLS 握手。10.3 的 WebSocket 广播把连接变成了长生命周期,但每条连接仍然只承载一个会话。

9091 要解决的是另一种模型:一条 TCP 连接上并发多个请求。HTTP/2 把「请求」从连接里拆出来,变成 stream,每个 stream 有自己的编号、头部、数据帧和流量控制窗口。浏览器访问一个站点只开一条连接就能同时拉 HTML、CSS、图片;gRPC 的 9092 也是在这个模型上再加 protobuf。

但 HTTP/2 的 API 比 HTTP/1.1 难一层,因为 Netty 没有把帧直接映射成 FullHttpRequest。连接上跑的是 Http2HeadersFrame、Http2DataFrame 这类帧,一个帧属于哪条 stream 由帧头里的 stream id 决定。11.1 只讲这层结构:帧由谁解析、stream 由谁创建、h2c 和 ALPN 怎么区分、什么时候该用多路复用而不是连接池。11.2 再把完整服务端拼起来。

本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,Netty 4.2.19.Final。本节不贴性能数字。

核心机制一:连接级帧与 stream 级帧

HTTP/2 的所有通信单位都是帧,帧头固定 9 字节:24 位长度、8 位类型、8 位标志、1 位保留位加 31 位 stream id。stream id 为 0 的帧作用于整条连接,非 0 的帧属于某条 stream。这个字段决定了 Netty 的两层结构。

帧stream id作用Netty 中的落点
preface无客户端发送 PRI * HTTP/2.0\r\n\r\nSM\r\n\r\nHttp2FrameCodec 内部校验
SETTINGS0交换头部表大小、初始窗口、最大并发流等参数连接级,Http2SettingsFrame
HEADERS非 0请求或响应头,可带 END_STREAMstream 级,Http2HeadersFrame
DATA非 0请求或响应体,受流量控制stream 级,Http2DataFrame
WINDOW_UPDATE0 或非 0增加连接或 stream 的发送窗口连接级由 codec 处理,stream 级由子 channel 处理
GOAWAY0告知对端「不再新建 stream」连接级,Http2GoAwayFrame
RST_STREAM非 0单条 stream 异常终止子 channel 关闭

preface 是固定的 24 字节,只在连接建立时出现一次。服务端收到它之后才能开始解析帧;如果对端发来的是 GET / HTTP/1.1,Http2FrameCodec 会认为 preface 缺失。SETTINGS 是 preface 之后的第一帧,双方各发一次并互相 ACK;但发送 HEADERS 不必等对方 ACK,客户端发完自己的 SETTINGS 就可以发请求。Http2FrameCodec 默认会自动 ACK SETTINGS,也会在应用读取 DATA 后按比例补发 WINDOW_UPDATE,这两件事不需要业务 handler 手工做。

Http2FrameCodec 是连接级 handler:一条连接一个实例,不能标 @Sharable。它负责 preface、SETTINGS 握手、PING、GOAWAY、连接级窗口,以及把 stream 级帧按 stream id 路由出去。它本身不创建子 channel,创建子 channel 的是 Http2MultiplexHandler。

核心机制二:Http2MultiplexHandler 与子 channel

Http2MultiplexHandler 是连接 pipeline 里的第二个关键 handler。它监听 Http2FrameCodec 发出的 stream 帧,为每个新的 stream id 创建一个 Http2StreamChannel,然后把帧送进这个子 channel 的 pipeline。子 channel 有自己的 ChannelPipeline、ChannelConfig、closeFuture 和写缓冲,看起来像一条独立连接,实际共享底层 TCP 连接。

public final class Http2ConnectionObserver extends ChannelInboundHandlerAdapter {

    private static final System.Logger LOG =
            System.getLogger(Http2ConnectionObserver.class.getName());

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        if (msg instanceof Http2SettingsFrame settings) {
            LOG.log(System.Logger.Level.INFO, "SETTINGS received, ack pending");
            ctx.fireChannelRead(settings);
        } else if (msg instanceof Http2GoAwayFrame goAway) {
            LOG.log(System.Logger.Level.WARNING,
                    "GOAWAY lastStreamId={0} errorCode={1}",
                    goAway.lastStreamId(), goAway.errorCode());
            ctx.fireChannelRead(goAway);
        } else {
            ctx.fireChannelRead(msg);
        }
    }
}

这段是连接级观察器,放在 Http2FrameCodec 之后、Http2MultiplexHandler 之前。它只记录 SETTINGS 和 GOAWAY,不消费消息,fireChannelRead 把同一个对象继续往后传。SETTINGS 帧不是引用计数对象,转发不需要 retain;GOAWAY 可能携带调试内容,同样由后面的 handler 按约定处理。不要在这里 release 后再转发,否则下游拿到的是已释放对象。

lastStreamId 是理解优雅关闭的关键:GOAWAY 表示「这条连接上编号大于 lastStreamId 的 stream 不会再被处理」。客户端收到后,应当把落在这些 id 上的请求重试到新连接;已经小于等于 lastStreamId 的 stream 可以继续等响应。linkd-v2 的 GatewayHandler 在转发上游请求时,需要把「是否可重试」和 HTTP 方法、幂等性一起判断,不能看到 GOAWAY 就把所有请求重发。

Http2MultiplexHandler 的构造器接收一个子 channel 的 ChannelHandler。常见写法是传一个 ChannelInitializer<Http2StreamChannel>:

ChannelHandler childHandler = new ChannelInitializer<Http2StreamChannel>() {
    @Override
    protected void initChannel(Http2StreamChannel ch) {
        ch.pipeline().addLast(new Http2StreamFrameToHttpObjectCodec(true));
        ch.pipeline().addLast(new HttpObjectAggregator(1 << 20));
        ch.pipeline().addLast(new LinkdHttpHandler());
    }
};

三个 handler 的分工正好对应三层转换:Http2StreamFrameToHttpObjectCodec(true) 把 Http2HeadersFrame/Http2DataFrame 翻成 HttpRequest/HttpContent,参数 true 表示服务端方向;HttpObjectAggregator 把分片的头和体聚合成 FullHttpRequest;LinkdHttpHandler 只认 FullHttpRequest,因此 9090 的 /healthz 分支可以原样复用。客户端方向的参数是 false,把 HttpResponse 翻回 Http2HeadersFrame 和 Http2DataFrame。这里的 HttpRequest、HttpContent、LastHttpContent、HttpResponse 都在 netty-codec-http 模块里,和 netty-codec-http2 一起由 netty-all 聚合引入。

子 channel 的出站方向同样经过这个 codec。LinkdHttpHandler 写出的 DefaultFullHttpResponse 先被 codec 转成 HEADERS 加 DATA,再由 Http2MultiplexHandler 交回连接级 Http2FrameCodec 编码成字节。如果子 channel 里没有这个 codec 就写 HTTP 对象,写操作走到 Http2MultiplexHandler 时会因为类型不匹配失败。

服务端的子 channel 由 Http2MultiplexHandler 创建,客户端则要自己开 stream。Http2StreamChannelBootstrap 接收已建立的连接 channel,open() 返回一个 Future<Http2StreamChannel>:

Http2StreamChannelBootstrap bootstrap =
        new Http2StreamChannelBootstrap(connectionChannel);
bootstrap.handler(new ChannelInitializer<Channel>() {
    @Override
    protected void initChannel(Channel ch) {
        ch.pipeline().addLast(new Http2StreamFrameToHttpObjectCodec(false));
        ch.pipeline().addLast(new HttpObjectAggregator(1 << 20));
        ch.pipeline().addLast(new SimpleChannelInboundHandler<FullHttpResponse>() {
            @Override
            protected void channelRead0(ChannelHandlerContext ctx, FullHttpResponse response) {
                LOG.log(System.Logger.Level.INFO, "stream response status={0}",
                        response.status());
            }
        });
    }
});
Http2StreamChannel stream = bootstrap.open().sync().getNow();

open() 本身是异步的,sync() 只适合在非 EventLoop 线程调用;如果已经在 EventLoop 里,用 addListener 拿结果,否则会阻塞该 EventLoop。同一个 connectionChannel 可以反复 open(),每次得到一个独立 stream id 和独立子 channel,这就是客户端侧的多路复用。服务端不能用这个类去「接受」stream,stream 只能由对端的 HEADERS 触发。

核心机制三:9091 的 pipeline 全图

把上面的组件按入站顺序摆好,9091 的结构是:

NioServerSocketChannel
└── NioSocketChannel(每条 TCP 连接)
    ├── Http2FrameCodec                     连接级:preface、SETTINGS、PING、GOAWAY、连接窗口
    ├── Http2ConnectionObserver             只观察 SETTINGS / GOAWAY,不消费
    └── Http2MultiplexHandler               按 streamId 创建并分发子 channel
        ├── Http2StreamChannel(id=1)
        │   ├── Http2StreamFrameToHttpObjectCodec(true)
        │   ├── HttpObjectAggregator(1 MiB)
        │   └── LinkdHttpHandler
        └── Http2StreamChannel(id=3)
            ├── Http2StreamFrameToHttpObjectCodec(true)
            ├── HttpObjectAggregator(1 MiB)
            └── LinkdHttpHandler

入站顺序不能颠倒。Http2MultiplexHandler 必须排在 Http2FrameCodec 后面,因为它收到的参数是 Http2StreamFrame,不是 ByteBuf。把 Http2MultiplexHandler 放到 codec 前面,它看到的是原始字节,既不认识帧也不会创建子 channel,表现是连接建立后没有任何请求被处理,也没有明显异常。

连接级 handler 里不要放 HttpObjectAggregator 或 HttpServerCodec。前者只处理 HttpObject,对 Http2HeadersFrame 直接放行;后者会在连接开头按 HTTP/1.1 解析,把 preface 当成非法请求行。两个层级各管各的对象类型,混放不会报「配置错误」,只会让消息在 pipeline 尾部被丢弃。

同一个 Http2FrameCodec 和 Http2MultiplexHandler 实例只能服务一条连接。它们内部持有连接状态和子 channel 表,标成 @Sharable 会被 Netty 拒绝:

io.netty.channel.ChannelPipelineException:
  io.netty.handler.codec.http2.Http2FrameCodec is not a @Sharable handler,
  so can't be added or removed multiple times.

ChannelInitializer 每次 initChannel 都新建实例,这个错误通常来自把 codec 提成 static final 字段。

核心机制四:h2c prior-knowledge 与 ALPN

HTTP/2 可以跑在明文上,叫 h2c;也可以跑在 TLS 上,靠 ALPN 协商。两者在协议开头做的事不同:

  • h2c prior-knowledge:客户端连上 TCP 后直接发送 preface,再发 SETTINGS。服务端从第一个字节起就按 HTTP/2 解析,不需要任何 HTTP/1.1 请求做引导。curl --http2-prior-knowledge 就是这个模式。
  • TLS + ALPN:先完成 TLS 握手,客户端在 ClientHello 的 ALPN 扩展里带 h2 和 http/1.1,服务端选一个并在握手结果里返回。选到 h2 之后才发送 preface。ApplicationProtocolNegotiationHandler 根据协商结果决定往 pipeline 里装 HTTP/2 还是 HTTP/1.1 的 handler。

还有第三种是 HTTP/1.1 的 Upgrade: h2c,需要先发一个带 HTTP2-Settings 头的 HTTP/1.1 请求,服务端回 101 Switching Protocols 再切协议。它需要额外的升级 handler,且只适合从 HTTP/1.1 渐进迁移;linkd-v2 不做这条路,9091 只接受 prior-knowledge。

两者的边界很硬:对 prior-knowledge 端口发普通 HTTP/1.1 请求,Http2FrameCodec 会在连接开头报 preface 错误,典型日志是:

HTTP/2 client preface string missing or corrupt. Hex dump for received bytes: 474554202f...

474554202f 就是 GET / 的 ASCII。反过来,对 TLS 端口发明文 preface,会在 TLS 握手阶段失败,根本到不了 HTTP/2 层。11.2 的服务端只做 h2c,TLS 和 ALPN 的完整配置留到需要证书的部署环境,不在本节展开。

在 linkd-v2 里落地:9090 与 9091 并存

LinkdV2Main 启动两个 HTTP 入口:9090 走 HTTP/1.1,9091 走 h2c。两个入口复用同一个 LinkdHttpHandler 的 /healthz 分支,但 pipeline 不同。9090 的 LinkdHttpHandler 直接接在 HttpObjectAggregator 后面;9091 的同一个类接在子 channel 的聚合器后面。对 LinkdHttpHandler 来说,输入都是 FullHttpRequest,差别只在上游是谁把字节拼成了这个对象。/books/{id} 的代理仍留在 9090 的 GatewayHandler,本节不把它搬到 h2 路径,避免把「帧转换」和「下游代理」两件事混在一起。

public final class LinkdHttp2Pipeline {

    public static ChannelHandler connectionHandler() {
        return new ChannelInitializer<SocketChannel>() {
            @Override
            protected void initChannel(SocketChannel ch) {
                ch.pipeline().addLast(Http2FrameCodecBuilder.forServer().build());
                ch.pipeline().addLast(new Http2ConnectionObserver());
                ch.pipeline().addLast(new Http2MultiplexHandler(streamInitializer()));
            }
        };
    }

    private static ChannelHandler streamInitializer() {
        return new ChannelInitializer<Http2StreamChannel>() {
            @Override
            protected void initChannel(Http2StreamChannel ch) {
                ch.pipeline().addLast(new Http2StreamFrameToHttpObjectCodec(true));
                ch.pipeline().addLast(new HttpObjectAggregator(1 << 20));
                ch.pipeline().addLast(new LinkdHttpHandler());
            }
        };
    }
}

这段只负责 pipeline,不负责 bind。LinkdHttp2Server 在 11.2 里把 ServerBootstrap 补全。注意 HttpObjectAggregator 的上限是 1 MiB,和 ProtocolConstants.MAX_PAYLOAD 同量级;超过上限的请求体会被聚合成 413 或直接失败,不会无限堆在堆外内存里。

ConnectionRegistry 只在连接级 channelActive 里登记 TCP 连接,不能在子 channel 的 channelActive 里登记。一条 HTTP/2 连接上会创建很多 Http2StreamChannel,每个子 channel 都会触发自己的 channelActive;如果照抄 9093 的写法,ConnectionRegistry.size() 会把「请求数」当成「连接数」,10.3 的广播和容量指标全部失真。

单连接多路复用与 HTTP/1.1 连接池的取舍

维度HTTP/1.1 + 连接池HTTP/2 单连接多路复用
并发单位一条连接一个在途请求一条连接多条 stream
连接数并发越高连接越多一条连接可承载大量 stream
握手成本每条连接一次 TCP(加 TLS)一次握手,后续 stream 复用
队头阻塞连接池内每条连接各自阻塞应用层无队头阻塞,但 TCP 丢包仍影响整条连接
流量控制靠 TCP 窗口和连接池大小连接级与 stream 级双窗口
故障影响一条连接断开只影响该连接上的请求连接断开影响全部在途 stream;GOAWAY 影响未处理的 stream
运维观测连接数、池命中率stream 并发数、连接窗口、GOAWAY 次数

多路复用不是无条件更好。它把并发集中到一条 TCP 连接上,一旦这条连接出现丢包,所有 stream 一起等重传;HTTP/1.1 的连接池至少把请求分散到多条连接,单条连接的网络抖动只影响一部分请求。连接级流量控制窗口也会成为共享瓶颈:某条 stream 大量下载,会把连接窗口吃满,其他 stream 的 DATA 发不出去。

linkd-v2 的选择是并存:9090 保留 HTTP/1.1,照顾只支持 HTTP/1.1 的客户端和现有 GatewayHandler 代理逻辑;9091 提供 h2c,给内部服务和高并发读取用;9092 的 gRPC 单独走 Netty 自带的 HTTP/2 传输。不要为了「统一」把 9090 直接改成 HTTP/2,客户端、代理、日志和压测口径都要跟着变,收益却只在高并发短请求上明显。

常见错误与边界

错误一:把 Http2HeadersFrame 当 FullHttpRequest。 在连接 pipeline 里直接写 SimpleChannelInboundHandler<FullHttpRequest>,收到的却是 Http2HeadersFrame,类型不匹配,消息被一路传到 tail,Netty 打出:

Discarded inbound message Http2HeadersFrame that reached at the tail of the pipeline.

正确做法是先经过 Http2StreamFrameToHttpObjectCodec 和 HttpObjectAggregator,再交给业务 handler。帧不是 HTTP 对象,聚合器也不会替你做这层转换。

错误二:在连接级 pipeline 加 HttpServerCodec。 HttpServerCodec 按 HTTP/1.1 的请求行解析,preface 的 PRI * HTTP/2.0 会被当成非法方法或非法版本,连接在第一个包就失败。HTTP/2 的连接级解码器只有 Http2FrameCodec,两者不能叠在同一个位置。

错误三:共享 codec 实例。 把 Http2FrameCodec 或 Http2MultiplexHandler 写成 static final 并复用到所有连接,第二条连接注册时抛 ChannelPipelineException。这两个 handler 都是有状态的非共享 handler,必须每条连接新建。

错误四:以为一条连接只能有一个请求在飞。 在子 channel 的 handler 里对共享状态做「请求开始置位、响应结束清位」,并发 stream 会互相覆盖。同一条连接上的子 channel 共用父连接的 EventLoop 线程,不会真正并行,但会交错执行;共享状态要么用 ConnectionRegistry 这类线程安全结构,要么按 stream 隔离。

边界:h2c 不做 TLS。 9091 只接受明文 prior-knowledge。真要让浏览器用 HTTP/2,必须有 TLS 和 ALPN,浏览器不会对明文端口走 h2c。TLS 配置涉及证书、SslContextBuilder 和 ALPN 选择,属于部署层,本节只划边界。

边界:curl 必须带 HTTP/2 支持。 用 curl -V 看 Features 里有没有 HTTP2;没有的话 --http2-prior-knowledge 会直接报 unknown option。macOS 自带 curl 与 Homebrew curl 的能力不同,验证前先确认。

小结

  • HTTP/2 的帧分两层:stream id 为 0 的连接级帧由 Http2FrameCodec 处理,非 0 的 stream 级帧由子 channel 处理。
  • Http2MultiplexHandler 按 stream id 创建 Http2StreamChannel,每个子 channel 有自己的 pipeline 和生命周期。
  • 子 channel 里先用 Http2StreamFrameToHttpObjectCodec 转成 HTTP 对象,再用 HttpObjectAggregator 聚合,业务 handler 才能复用 HTTP/1.1 的代码。
  • h2c prior-knowledge 从第一个字节就发 preface;TLS + ALPN 先握手再选协议。两者端口不能混用。
  • 多路复用省连接和握手,但 TCP 丢包会同时影响所有 stream,连接级窗口也是共享瓶颈。
  • ConnectionRegistry 只登记连接级 channel,不能把 Http2StreamChannel 算成连接。

连接级结构清楚之后,下一步是把 9091 真正跑起来:ServerBootstrap 怎么配、流什么时候关闭、GOAWAY 和窗口怎么处理、curl 怎么验证。这些是 11.2 的内容。

阅读导航:上一节:10.3 会话表、心跳与广播 · 下一节:11.2 HTTP/2 服务端与流生命周期 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发