《Netty 高性能网络编程》11.2 HTTP/2 服务端与流生命周期

把 11.1 的 pipeline 变成 9091 上可运行的 h2c 服务端。本节给出 ServerBootstrap、Http2FrameCodec、Http2MultiplexHandler 与子 channel 的完整装配,讲 HEADERS/DATA 如何创建和关闭 stream、GOAWAY 与流量控制窗口怎么处理,并用 curl --http2-prior-knowledge 验证。

本节目标:在 9091 上跑起一个只支持 h2c prior-knowledge 的 HTTP/2 服务端,能解释每条 stream 从 HEADERS 到关闭的完整路径。
适用版本:JDK 25 + Netty 4.2.19.Final(netty-codec-http2 模块)。

承接的问题

11.1 把 9091 的 pipeline 画清楚了:Http2FrameCodec 管连接,Http2MultiplexHandler 管 stream,子 channel 里再把帧翻成 HTTP 对象。但那张图没有 ServerBootstrap,也没有回答几个运行时问题:stream 是什么时候创建的、Http2HeadersFrame 和 Http2DataFrame 分别对应什么、响应写完以后子 channel 会不会关、服务端停机时在途请求怎么办。

这些问题在 HTTP/1.1 里都不存在。9090 的连接就是请求边界,FullHttpRequest 处理完写回响应,连接继续留着等下一条请求。HTTP/2 里连接和请求解耦了:一条连接上可能同时有 100 条 stream,每条 stream 有自己的头和体、自己的关闭时机;某条 stream 被 RST_STREAM 取消,连接本身还在。把 9090 的 LinkdHttpHandler 直接搬过来只能解决「拿到 FullHttpRequest 之后」这一段,前后两段都要重写。

本节先给一个能 bind(9091) 的完整服务端,再把 stream 生命周期拆成创建、传输、关闭三个阶段,最后讲 GOAWAY 和流量控制这两个只在 HTTP/2 里出现的边界。本节不做 TLS ALPN,9091 只接受明文 prior-knowledge;TLS 与 ALPN 的边界 11.1 已经说明,证书和部署配置不在这里展开。

本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,Netty 4.2.19.Final。下面的 curl 输出是示意,字段顺序随 curl 版本变化。

核心机制一:ServerBootstrap 与连接级装配

LinkdHttp2Server 负责生命周期:启动时建 boss/worker 两个 NioEventLoopGroup,绑定 9091;停止时先关 server channel,再关 EventLoopGroup。连接级 pipeline 只放三个 handler,顺序和 11.1 的图一致。

package com.plume.linkd.v2.server;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.Channel;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http2.Http2FrameCodecBuilder;
import io.netty.handler.codec.http2.Http2MultiplexHandler;
import io.netty.handler.codec.http2.Http2StreamChannel;
import io.netty.handler.codec.http2.Http2StreamFrameToHttpObjectCodec;

public final class LinkdHttp2Server {

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

    private final ConnectionRegistry connectionRegistry;
    private final EventLoopGroup boss = new NioEventLoopGroup(1);
    private final EventLoopGroup workers = new NioEventLoopGroup();
    private Channel serverChannel;

    public LinkdHttp2Server(ConnectionRegistry connectionRegistry) {
        this.connectionRegistry = connectionRegistry;
    }

    public Channel start(int port) throws InterruptedException {
        ServerBootstrap bootstrap = new ServerBootstrap();
        bootstrap.group(boss, workers)
                .channel(NioServerSocketChannel.class)
                .option(ChannelOption.SO_BACKLOG, 128)
                .childOption(ChannelOption.TCP_NODELAY, true)
                .childHandler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel ch) {
                        ch.pipeline().addLast(Http2FrameCodecBuilder.forServer()
                                .gracefulShutdownTimeoutMillis(1000)
                                .build());
                        ch.pipeline().addLast(new Http2ConnectionObserver());
                        ch.pipeline().addLast(new Http2MultiplexHandler(
                                new ChannelInitializer<Http2StreamChannel>() {
                                    @Override
                                    protected void initChannel(Http2StreamChannel stream) {
                                        stream.pipeline().addLast(new Http2StreamObserver());
                                        stream.pipeline().addLast(
                                                new Http2StreamFrameToHttpObjectCodec(true));
                                        stream.pipeline().addLast(
                                                new HttpObjectAggregator(1 << 20));
                                        stream.pipeline().addLast(
                                                new LinkdHttpHandler());
                                    }
                                }));
                    }
                });
        serverChannel = bootstrap.bind(port).sync().channel();
        LOG.log(System.Logger.Level.INFO, "h2c listening on {0}", serverChannel.localAddress());
        return serverChannel;
    }

    public void stop() throws InterruptedException {
        if (serverChannel != null) {
            serverChannel.close().sync();
        }
        boss.shutdownGracefully().sync();
        workers.shutdownGracefully().sync();
    }
}

Http2FrameCodecBuilder.forServer() 建的是服务端方向的 codec:它等待客户端 preface,发送自己的 SETTINGS,校验帧序。gracefulShutdownTimeoutMillis(1000) 是关闭连接时等待在途 stream 的上限,超过之后强制关闭。这个值不能当「请求超时」用,它只影响 GOAWAY 之后的收尾。

Http2MultiplexHandler 的子 handler 是每条 stream 新建的 ChannelInitializer<Http2StreamChannel>。四条 handler 的顺序是:观察器、帧转对象、聚合、业务。观察器放在最前面,所以它看到的是原始 Http2HeadersFrame 和 Http2DataFrame;如果放到 codec 后面,看到的就变成 HttpRequest 和 HttpContent,不再是帧。

ServerBootstrap 上不要加 HttpServerCodec 或 HttpObjectAggregator。连接级 pipeline 的第一个入站 handler 必须是 Http2FrameCodec,否则 preface 会被当成 HTTP/1.1 请求行。SO_BACKLOG 和 TCP_NODELAY 与 9090 保持一致即可,HTTP/2 没有额外的 socket 选项必须打开。

核心机制二:HEADERS、DATA 与 stream 的创建

stream 不是服务端主动创建的,第一条带新 stream id 的 HEADERS 到达时才创建。Http2MultiplexHandler 收到这个帧,建一个 Http2StreamChannel,把帧送进子 channel;子 channel 的 channelActive 先触发,然后才是 channelRead。

public final class Http2StreamObserver extends ChannelInboundHandlerAdapter {

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

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        if (msg instanceof Http2HeadersFrame headers) {
            LOG.log(System.Logger.Level.INFO,
                    "stream {0} HEADERS endStream={1} path={2}",
                    headers.stream().id(), headers.isEndStream(),
                    headers.headers().get(":path"));
            ctx.fireChannelRead(msg);
        } else if (msg instanceof Http2DataFrame data) {
            LOG.log(System.Logger.Level.INFO,
                    "stream {0} DATA flowControlledBytes={1} endStream={2}",
                    data.stream().id(), data.initialFlowControlledBytes(),
                    data.isEndStream());
            ctx.fireChannelRead(msg);
        } else {
            ctx.fireChannelRead(msg);
        }
    }
}

观察器用 ChannelInboundHandlerAdapter 而不是 SimpleChannelInboundHandler,因为它只记录、不消费,必须把原对象继续传下去。Http2DataFrame.content() 是 ByteBuf,如果在这里 readBytes 复制内容,要自己处理 readerIndex;如果 retain 了,必须 release。最省事的做法是什么都不动,直接 fireChannelRead,让下游 codec 按自己的规则消费和释放。

GET /healthz 的 HEADERS 带 END_STREAM,isEndStream() 为 true,后面没有 DATA。POST /books 的 HEADERS 不带 END_STREAM,后面跟一个或多个 DATA,最后一个 DATA 带 END_STREAM。Http2StreamFrameToHttpObjectCodec(true) 把 HEADERS 翻成 HttpRequest,把 DATA 翻成 HttpContent,遇到 END_STREAM 再补一个 LastHttpContent;HttpObjectAggregator 把这些拼成 FullHttpRequest,LinkdHttpHandler 只看到最终对象,不需要知道 stream id。

这里有一个容易忽略的顺序:聚合器只有收到 LastHttpContent 才会输出 FullHttpRequest。如果客户端发了 HEADERS 和一部分 DATA 就断开,聚合器不会输出半个请求,子 channel 直接关闭;业务 handler 的 channelRead0 一次都不会被调用。日志里能看到 Http2StreamObserver 打印了 HEADERS,但没有对应的 LinkdHttpHandler 记录,这不是业务丢请求,而是请求没发完。

核心机制三:流的关闭与 GOAWAY

一条 stream 的关闭由两个方向决定:客户端发完 END_STREAM 是半关闭,服务端写完带 END_STREAM 的响应是另一个半关闭,两边都完成后子 channel 关闭。LinkdHttpHandler 写回 DefaultFullHttpResponse 时,Http2StreamFrameToHttpObjectCodec 会把它编码成 HEADERS(可能还有 DATA),并自动带上 END_STREAM。业务代码不需要手工写结束标记。

子 channel 关闭会触发它自己的 channelInactive。这个事件只代表某条 stream 结束,不代表 TCP 连接断开。10.3 的 WebSocketHub 在连接关闭时清理会话,这套逻辑不能直接套到 stream 上:一条连接上几十条 stream 轮流关闭,如果每条都去 ConnectionRegistry.remove(channel),连接计数会被减到负数。正确的分工是连接级 channel 负责 ConnectionRegistry,stream 级 channel 只清理自己的请求上下文。

客户端提前取消请求时,会发 RST_STREAM。Netty 把这条 stream 的子 channel 关闭,channelInactive 触发,但 TCP 连接和同连接上的其他 stream 不受影响。业务 handler 如果在响应前做了耗时操作,应当检查 ctx.channel().isActive() 再写回;对一个已经关闭的子 channel 写响应不会抛到调用栈上,而是让 ChannelFuture 失败。

GOAWAY 是连接级事件,由 11.1 的 Http2ConnectionObserver 观察。服务端主动停机的顺序是:

  1. 关闭 server channel,停止接受新 TCP 连接。
  2. 遍历 ConnectionRegistry 里的连接 channel,逐条关闭。关闭时 Http2FrameCodec 会先写 GOAWAY,再按 gracefulShutdownTimeoutMillis 等待在途 stream。
  3. 关闭 boss 和 worker 两个 EventLoopGroup,释放线程和堆外内存。
public void stop() throws InterruptedException {
    if (serverChannel != null) {
        serverChannel.close().sync();
    }
    for (Channel connection : connectionRegistry.snapshot()) {
        connection.close();
    }
    boss.shutdownGracefully().sync();
    workers.shutdownGracefully().sync();
}

ConnectionRegistry 目前只有 add、remove、size、broadcast,停机遍历需要一个返回快照的方法,或者直接在 registry 内部提供 closeAll()。不要在遍历 ChannelGroup 的同时让回调修改集合,先取快照再关闭。connection.close() 是异步的,想让停机等待所有 GOAWAY 写完,需要收集 closeFuture() 再统一等待;shutdownGracefully() 本身会处理 EventLoop 上剩余任务,但不会替业务等响应写完。

客户端收到 GOAWAY 后,编号大于 lastStreamId 的请求没有被执行,可以换新连接重试;小于等于的请求可能已经处理,是否重试取决于方法是否幂等。GatewayHandler 代理的 GET /books/{id} 可以重试,POST 不行。这个判断必须在业务层做,不能由 Http2ConnectionObserver 一刀切。

核心机制四:流量控制窗口与可写性

HTTP/2 有两级流量控制:连接级窗口和 stream 级窗口。接收方通过 WINDOW_UPDATE 告诉发送方「我还能收多少字节」。Http2FrameCodec 默认会在 DATA 被消费后自动补窗口,业务代码通常看不到 WINDOW_UPDATE 帧;但这不代表窗口不存在。

HttpObjectAggregator 会把整个请求体聚合成一个对象,聚合期间占用的堆外内存等于请求体大小。上限设成 1 MiB,意味着单条 stream 最多占 1 MiB;100 条并发上传理论上可以同时占 100 MiB。这个数字不是跑出来的,而是上限相乘的结果,容量规划时要按并发上传数估算,而不是按平均请求大小估算。

响应方向同理。LinkdHttpHandler 写一个大响应时,如果客户端接收慢,DATA 会堆在子 channel 的写队列里;队列涨到高水位后 isWritable() 变成 false。第 9 章的水位线机制在这里仍然适用,只是水位挂在 Http2StreamChannel 上,不是 TCP 连接上。正确的做法是写完后监听 ChannelFuture,不要在 EventLoop 里等它完成:

ChannelFuture write = ctx.writeAndFlush(response);
write.addListener(f -> {
    if (!f.isSuccess()) {
        LOG.log(System.Logger.Level.WARNING,
                "stream write failed: {0}", f.cause());
    }
});

如果 isWritable() 已经是 false,说明写队列到了高水位,业务应当暂停继续产生响应,等 channelWritabilityChanged 触发后再恢复;不要在 false 时循环 writeAndFlush,那只会把队列堆得更高。关键点是:窗口耗尽时 writeAndFlush 不会阻塞,它返回一个可能很久以后才完成的 ChannelFuture;在 EventLoop 里等这个 future 会卡住整条连接。

子 channel 的 AUTO_READ 只影响这条 stream 的读取节奏,连接级读取仍在继续。不要为了让某条 stream 慢下来去关连接级 AUTO_READ,那会把同连接上其他 stream 的数据一起停掉。反过来,如果业务确实需要按 stream 背压,可以在子 channel 上 config().setAutoRead(false),处理完一批再打开;连接上的其他 stream 不受影响。

在 linkd-v2 里落地:9091 的健康检查

9091 只演示 /healthz,复用 9090 LinkdHttpHandler 的健康检查分支;/books/{id} 的代理仍留在 9090 的 GatewayHandler,不搬到 h2 路径。区别只在 FullHttpRequest 的来源:9090 由 HttpServerCodec 加聚合器产生,9091 由 Http2StreamFrameToHttpObjectCodec 加聚合器产生。业务 handler 里的 HttpUtil、DefaultFullHttpResponse 都不用改。

public final class LinkdHttpHandler extends SimpleChannelInboundHandler<FullHttpRequest> {

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest request) {
        if (!request.decoderResult().isSuccess()) {
            write(ctx, HttpResponseStatus.BAD_REQUEST, "bad request");
            return;
        }
        if ("/healthz".equals(request.uri())) {
            write(ctx, HttpResponseStatus.OK, "ok");
        } else {
            write(ctx, HttpResponseStatus.NOT_FOUND, "not found");
        }
    }

    private static void write(ChannelHandlerContext ctx,
                              HttpResponseStatus status, String body) {
        ByteBuf content = ctx.alloc().buffer();
        content.writeCharSequence(body, CharsetUtil.UTF_8);
        FullHttpResponse response = new DefaultFullHttpResponse(
                HttpVersion.HTTP_1_1, status, content);
        response.headers().set(HttpHeaderNames.CONTENT_TYPE,
                "text/plain; charset=UTF-8");
        HttpUtil.setContentLength(response, content.readableBytes());
        ctx.writeAndFlush(response);
    }
}

这里只保留 9091 用到的路由,9090 的完整 LinkdHttpHandler 还会有代理和别的分支。LinkdHttpHandler 继承 SimpleChannelInboundHandler,channelRead0 返回后会自动释放 request;不要把 request 或它的 ByteBuf 存进字段或交给异步线程,需要异步处理时先复制 URI 等字符串,或者 retain 并明确由谁 release。

这段里唯一需要为 HTTP/2 注意的是响应头。Http2StreamFrameToHttpObjectCodec 会把 HttpVersion.HTTP_1_1 的响应转成 HTTP/2 帧,版本号不会原样出现在线上;但 Connection: keep-alive、Transfer-Encoding: chunked 这类连接级头在 HTTP/2 里没有意义,业务 handler 在 9091 路径上不要设置。HttpUtil.setContentLength 设的 content-length 是合法的,HTTP/2 允许它。

LinkdV2Main 在启动时按顺序拉起 9090、9091、9092、9093。9091 失败不应该拖垮 9090:两个 bind 各自捕获异常,记录端口和原因,再决定是退出还是只关掉失败的入口。stop() 要按启动的逆序关闭,先停 gRPC 和 h2c,再停 HTTP/1.1 和自定义协议。

用 curl 验证 h2c

先确认 curl 支持 HTTP/2:

curl -V
curl 8.7.1 (x86_64-apple-darwin25.0) libcurl/8.7.1 (SecureTransport) LibreSSL/3.3.6 zlib/1.2.12 nghttp2/1.68.0
Release-Date: 2024-03-27
Features: alt-svc AsynchDNS GSS-API HSTS HTTP2 HTTPS-proxy IPv6 Kerberos Largefile libz MultiSSL NTLM SPNEGO SSL threadsafe UnixSockets

Features 里要有 HTTP2。没有的话,--http2-prior-knowledge 会报 unknown option,先换一个带 HTTP/2 的 curl,不要怀疑服务端。接着直接发请求:

curl -v --http2-prior-knowledge http://127.0.0.1:9091/healthz

示意输出:

* Connected to 127.0.0.1 (127.0.0.1) port 9091
* using HTTP/2
> GET /healthz HTTP/2
> Host: 127.0.0.1:9091
>
< HTTP/2 200
< content-type: text/plain; charset=UTF-8
< content-length: 2
<
ok

< HTTP/2 200 说明响应走的是 HTTP/2,不是 HTTP/1.1。/books/{id} 不在 9091 演示,仍由 9090 的 GatewayHandler 走 HTTP/1.1 连接池;这一节只验证 h2c 的帧路径。最后验证错误用法:

curl -v http://127.0.0.1:9091/healthz

普通 curl 对明文 URL 走 HTTP/1.1,9091 会在连接开头报 preface 缺失,连接被关闭。这个失败是预期的,说明服务端只接受 prior-knowledge。要发 HTTP/2 必须显式加 --http2-prior-knowledge;--http2 对明文 URL 走的是 Upgrade,9091 同样不支持。

常见错误与边界

错误一:子 channel 里漏了 Http2StreamFrameToHttpObjectCodec。 聚合器收到 Http2HeadersFrame 不认识,直接放行;LinkdHttpHandler 只处理 FullHttpRequest,也不认;消息最后被丢弃,日志是:

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

入站丢请求的同时,出站也会出问题:LinkdHttpHandler 写 DefaultFullHttpResponse,子 channel 没有 codec 转换,写操作到达 Http2MultiplexHandler 时类型不符,抛 UnsupportedMessageTypeException。

错误二:把 HttpObjectAggregator 放连接级。 聚合器只接受 HttpObject,对 Http2HeadersFrame 和 Http2DataFrame 都放行,等于没有聚合。业务 handler 拿不到 FullHttpRequest,每个请求都表现为「没有反应」。聚合器必须放在子 channel 的 codec 之后。

错误三:把 Http2StreamChannel 登记进 ConnectionRegistry。 一条 TCP 连接加上 N 条 stream,size() 会变成 1 + N,10.3 的广播会往子 channel 写 WebSocket 帧,类型和协议都不对。连接级计数只在 SocketChannel 的 channelActive/channelInactive 里做。

错误四:在 h2 响应里写 Connection: keep-alive。 HTTP/2 没有连接级 keep-alive 头,连接复用由协议自身承担。这个头在 HTTP/1.1 里合法,在 h2 路径上轻则被忽略,重则被对端判为协议错误。9090 和 9091 共用 LinkdHttpHandler 时,响应头要么两边都兼容,要么按入口分开设置。

错误五:在 EventLoop 线程里调 stop()。 stop() 里有 serverChannel.close().sync() 和 shutdownGracefully().sync(),在 EventLoop 线程里调用会等待自己,可能死锁。关闭钩子、main 线程、管理端点都可以调,handler 里不行;handler 里只提交关闭任务。

边界:不做 TLS ALPN。 9091 只接受明文 prior-knowledge,浏览器不会用这种模式访问,https:// 和 ALPN 都不在本书这一节的范围。需要浏览器直连时,前面加 TLS 终止代理,或者按 11.1 的说明另配 SslContext 和 ApplicationProtocolNegotiationHandler。

边界:gracefulShutdownTimeoutMillis 不是请求超时。 它只决定 GOAWAY 之后等多久强制关闭连接。业务请求的超时要在 LinkdHttpHandler 或 GatewayHandler 里自己做,不能指望这个参数。

小结

  • 9091 的连接级 pipeline 是 Http2FrameCodec、观察器、Http2MultiplexHandler,顺序不能换。
  • 每条 stream 的子 channel 里依次放观察器、Http2StreamFrameToHttpObjectCodec(true)、HttpObjectAggregator、LinkdHttpHandler。
  • stream 由第一条 HEADERS 创建,END_STREAM 决定请求体是否结束,两个方向都结束后子 channel 关闭。
  • 服务端停机先关 server channel,再逐条关闭连接触发 GOAWAY,最后关 EventLoopGroup。
  • 流量控制有连接级和 stream 级两层,窗口耗尽时写队列增长,用 isWritable() 和 channelWritabilityChanged 做背压。
  • ConnectionRegistry 只记连接级 channel,stream 级 channel 不能算连接。

9091 证明 Netty 可以直接承载 HTTP/2,但业务如果只需要一个强类型的 RPC,手写帧和头仍然繁琐。11.3 看 gRPC 如何把 HTTP/2、protobuf 和生成代码叠在一起,以及 NettyServerBuilder 在这套结构里到底负责哪一层。

阅读导航:上一节:11.1 Http2FrameCodec 与多路复用 · 下一节:11.3 gRPC on Netty 的结构 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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