《Java 网络编程基础》9.3 多路复用、流量控制与优先级

从 linkd 一条连接上 6 个并发请求抢 65535 字节窗口出发,讲清 stream id 分配、WINDOW_UPDATE、连接级与流级窗口的区别,给出 Http2Connection 的写数据、窗口回收与轮转调度骨架,并说明 RFC 9113 弃用优先树后的取舍。

本节目标:能说清 HTTP/2 的两级窗口怎么扣、怎么补,并写出不会把连接写死的发送骨架。
适用版本:JDK 25(Temurin 25.0.5+8);只使用 java.nio 与 JDK 标准库。

承接的问题

9.2 结束时,linkd 已经能把 HEADERS 还原成 HttpRequest,也能用 HPACK 压缩响应头。但多路复用真正难的部分才刚开始:一条连接上 6 个 /books/{id} 同时在途,每个响应 200 KB,而连接级窗口默认只有 65535 字节。谁先发、发多少、发到窗口为 0 之后怎么恢复,决定了这条连接是并发还是排队。

更具体一点:客户端打开 6 条流,服务端按顺序写第 1 条流的 200 KB,写到 64 KB 时连接窗口耗尽,第 2 到第 6 条流一个有数据的都没有。客户端此时看到的是「连接还活着,但所有请求都卡住」,而 netstat 里连接状态正常,CPU 也不高。这不是死锁,是流量控制没有被正确回收。

本节把 stream id、两级窗口和调度补上。优先级单独留一段,因为 HTTP/2 的优先树在标准里已经被移除,照搬旧文档会写出没人实现的代码。

本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。

核心机制一:stream id 分配

客户端发起的流用奇数,从 1 开始;服务端发起的流用偶数,从 2 开始。每开一条新流,id 加 2,且必须大于此前用过的所有 id。id 0 保留给连接级帧,DATA、HEADERS、RST_STREAM 都不能出现在 0 号流上。

public int openStream() {
    int id = nextStreamId;
    if (id > MAX_WINDOW) {
        throw new Http2ProtocolException(Http2ProtocolException.PROTOCOL_ERROR,
                "stream id space exhausted");
    }
    nextStreamId += 2;
    Http2Stream stream = new Http2Stream(id, peerInitialWindow);
    stream.opened();
    streams.put(id, stream);
    ready.add(id);
    return id;
}

MAX_WINDOW 是 0x7FFF_FFFF,id 用尽时不能回绕到 1 重用,只能发 GOAWAY,让客户端换一条连接。单条连接同时存在的流受 MAX_CONCURRENT_STREAMS 限制,id 空间在正常寿命内不容易耗尽,但网关类服务如果一条连接反复建流,要有这个检查。

id 只增不减还有两个副作用。第一,客户端收到一个比已见 id 小的新流,是 PROTOCOL_ERROR;第二,服务端不能主动「复用」已关闭流的 id 来省内存,流对象关闭后可以释放,id 本身不能回收。

核心机制二:连接级与流级窗口

HTTP/2 有两层窗口,发送方每发一个 DATA 帧,两层都要扣:

  • 连接级窗口,初始 65535,所有流共享,WINDOW_UPDATE 的 stream id 为 0 时补充。
  • 流级窗口,初始值由对端 SETTINGS 的 INITIAL_WINDOW_SIZE 决定,默认也是 65535,每条流独立,补充时带自己的 stream id。

只有 DATA 受流量控制。HEADERS、SETTINGS、PING、GOAWAY 不占窗口;DATA 的 padding 和 pad length 字段占窗口,因为接收方确实要为它们分配缓冲区。

发送时取两层窗口的较小值,再受 SETTINGS_MAX_FRAME_SIZE 限制。下面的方法都在 com.plume.linkd.http.Http2Connection 里,三个窗口字段用 long,maxFrameSize 是 int:

private long connectionWindow = DEFAULT_WINDOW;
private long peerInitialWindow = DEFAULT_WINDOW;
private long localInitialWindow = DEFAULT_WINDOW;
private int maxFrameSize = Http2FrameCodec.DEFAULT_MAX_FRAME_SIZE;

public int writeData(int streamId, byte[] data, int offset, List<Http2Frame> out) {
    Http2Stream stream = require(streamId);
    int written = 0;
    while (offset + written < data.length) {
        long budget = Math.min(connectionWindow, stream.sendWindow());
        if (budget <= 0) {
            break;
        }
        int size = (int) Math.min(Math.min(budget, maxFrameSize), data.length - offset - written);
        byte[] chunk = new byte[size];
        System.arraycopy(data, offset + written, chunk, 0, size);
        boolean end = offset + written + size == data.length;
        int flags = end ? Http2Frame.FLAG_END_STREAM : 0;
        out.add(new Http2Frame(Http2Frame.DATA, flags, streamId, chunk));
        connectionWindow -= size;
        stream.addSendWindow(-size);
        written += size;
    }
    if (written > 0 && offset + written == data.length) {
        stream.localEnd();
    }
    return written;
}

返回值是「本次真正接受的字节数」,不是「写完了」。窗口不够时它小于 data.length,调用方必须把剩余字节留在自己的待发队列里,等 WINDOW_UPDATE 到了再传同一个 offset。把它写成 void 并假设全部发完,是背压丢失的常见起点。

窗口补充:

public void onWindowUpdate(int streamId, int increment) {
    if (increment <= 0) {
        throw new Http2ProtocolException(Http2ProtocolException.PROTOCOL_ERROR,
                "WINDOW_UPDATE increment " + increment);
    }
    if (streamId == 0) {
        long updated = connectionWindow + increment;
        if (updated > MAX_WINDOW) {
            throw new Http2ProtocolException(Http2ProtocolException.FLOW_CONTROL_ERROR,
                    "connection window overflow: " + updated);
        }
        connectionWindow = updated;
    } else {
        require(streamId).addSendWindow(increment);
    }
}

WINDOW_UPDATE 的载荷是一个 31 位无符号增量,0 非法:流级 0 是 PROTOCOL_ERROR,连接级 0 是连接错误。增量让窗口超过 2^31-1 是 FLOW_CONTROL_ERROR,不是简单截断。

对端还可以用 SETTINGS 调整初始窗口,这个调整要按差值应用到所有已存在的流,包括已经发了数据的流:

public void applyPeerInitialWindowSize(long value) {
    long delta = value - peerInitialWindow;
    peerInitialWindow = value;
    for (Http2Stream stream : streams.values()) {
        stream.addSendWindow(delta);
    }
}

把初始窗口从 65535 调到 20000,所有流的发送窗口减 45535,正在传输的流可能变成负数。负窗口不是错误,它表示「你已经透支了,等对端补回来之前一个字节都不能发」。Http2Stream.addSendWindow 因此只检查上溢,不拒绝负值。

本机用 20000 的流窗口和默认连接窗口发送 50000 字节:

peer initial window = 20000, stream 1 send window = 20000
window update frame = 00000408000000000000004e20
write 50000 -> accepted=20000 frames=[16384,3616] conn=45535 stream=0
after update -> accepted=40000 frames=[16384,3616] conn=25535 stream=0
final -> accepted=50000 frames=[10000] conn=15535 stream=10000

第一轮只能发 20000:先发满一个 16384 的帧,再用 3616 把流窗口清零,连接窗口还剩 45535 也没用。补 20000 之后重复同样的切分,第三轮剩 10000 一次发完。最后的 conn=15535 是 65535 减 50000,stream=10000 是第三轮补的 20000 减去实际用的 10000。

同一条连接换成 1 MiB 响应、窗口都用默认值,本机输出:

1 MiB -> accepted=65535 frames=4 first=16384 last=16383 conn=0

只发出 65535 字节就被连接窗口挡住,4 个帧里最后一个是 16383。剩下的 983041 字节一个都没发,必须等客户端回 WINDOW_UPDATE。如果服务端此时把「已接受」当成「已发送」并丢掉剩余数据,响应就会静默截断,客户端看到的是 Content-Length 不符或流被 RST。

核心机制三:优先级取舍

RFC 7540 定义了一套依赖树:每条流可以声明依赖哪条流、权重 1 到 256、是否独占。设计目标是让浏览器表达「先给我 CSS,再给我图片」。实际部署里这套机制没有被广泛采用:服务器要维护动态树,客户端表达的重点也经常变化,调参成本高于收益。RFC 9113 的回顾直接称其并不成功。

RFC 9113 因此移除了 RFC 7540 的优先方案:优先树的正文不再进入新规范,PRIORITY 帧被标记为 deprecated,但帧格式与最低处理要求保留,用于和旧实现互通。RFC 同时建议需要优先级的端点改用替代方案(例如 RFC 9218 的可扩展优先级),并提醒:在没有替代信息时直接丢弃 PRIORITY 信号并不会带来好处。linkd 的教学实现不解析 PRIORITY,用轮转保证公平,这是能力边界,不是规范要求。

public int nextWritableStream() {
    int candidates = ready.size();
    while (candidates-- > 0) {
        int id = ready.poll();
        ready.add(id);
        Http2Stream stream = streams.get(id);
        if (stream.state() != Http2Stream.State.CLOSED
                && stream.sendWindow() > 0
                && connectionWindow > 0) {
            return id;
        }
    }
    return -1;
}

本机三条流连续取四次,输出 1, 3, 5, 1。轮转保证没有流会永远排不到,但不保证延迟:/healthz 的 2 字节响应和大文件的下一个 16 KB 帧在同一个队列里,最坏要等前面所有流各发一帧。

如果业务真的需要区分,优先在应用层做,而不是恢复协议树:

  • 给响应按类型分队列,小响应(健康检查、错误页)走独立队列,每次连接轮转先取小队列。
  • 给单条流设更小的发送配额,避免大文件一次占满连接窗口。
  • 用 SETTINGS_MAX_CONCURRENT_STREAMS 限制并发,把压力前移到建流阶段,而不是发送阶段抢窗口。

这些手段都不改变协议兼容性,只改变本端出队顺序,调错了也容易回滚。反过来,自己实现一棵优先树再声称「支持 HTTP/2 优先级」,既没有对端配合,也没有可验证的收益。

在 linkd 里落地:接收侧窗口回收

发送侧只解决了一半。接收侧如果只扣不补,对端发到 65535 就会停,linkd 表现为「请求体读到一半没有后续帧」。回收要同时看连接级和流级,并且在消费掉一半时补,而不是等清零:

public void onData(Http2Frame frame) {
    Http2Stream stream = require(frame.streamId());
    int flowLength = frame.length();
    connectionWindow -= flowLength;
    stream.addRecvWindow(-flowLength);
    if (connectionWindow < 0 || stream.recvWindow() < 0) {
        throw new Http2ProtocolException(Http2ProtocolException.FLOW_CONTROL_ERROR,
                "peer exceeded window on stream " + stream.id());
    }
    if (connectionWindow <= DEFAULT_WINDOW / 2) {
        int increment = (int) (DEFAULT_WINDOW - connectionWindow);
        connectionWindow += increment;
        outbound.add(windowUpdate(0, increment));
    }
    if (stream.recvWindow() <= localInitialWindow / 2) {
        int increment = (int) (localInitialWindow - stream.recvWindow());
        stream.addRecvWindow(increment);
        outbound.add(windowUpdate(stream.id(), increment));
    }
    if (frame.flag(Http2Frame.FLAG_END_STREAM)) {
        stream.remoteEnd();
    }
}

这里有两个容易混的初始值:peerInitialWindow 是「对端允许我发多少」,用于发送侧;localInitialWindow 是「我允许对端发多少」,用于接收侧。两者都来自 SETTINGS,但方向相反,用一个字段兼用会让窗口越算越偏。

windowUpdate 只是构造 4 字节载荷:

public static Http2Frame windowUpdate(int streamId, int increment) {
    ByteBuffer payload = ByteBuffer.allocate(4);
    payload.putInt(increment & 0x7FFF_FFFF);
    return new Http2Frame(Http2Frame.WINDOW_UPDATE, 0, streamId, payload.array());
}

onData 里 flowLength 用的是整个 DATA 载荷长度,包含 padding。如果业务只拿到去掉 padding 的内容,却按业务长度补窗口,两端的账会越差越多,几十个请求后对端就会因为「窗口没恢复」而停住。

Http2Stream 只保存 id、状态和两个窗口,状态迁移在 9.1 的表里已经定死:

void addSendWindow(long delta) {
    long updated = sendWindow + delta;
    if (updated > Http2Connection.MAX_WINDOW) {
        throw new Http2ProtocolException(Http2ProtocolException.FLOW_CONTROL_ERROR,
                "stream " + id + " send window overflow: " + updated);
    }
    sendWindow = updated;
}

关闭流之后,streams 里的对象可以移除,但连接级窗口的账要继续记。移除流时把它的未用流窗口丢掉不会影响连接窗口,因为连接窗口是独立变量;反过来,把流窗口加回连接窗口是错的,那等于凭空造流量。

常见错误与边界

错误一:只扣流窗口,不扣连接窗口。 单条流看起来正常,第二条流开始时连接窗口还是 65535,但实际已经发出去几 MB,对端会以 FLOW_CONTROL_ERROR 断连。本机 1 MiB 的例子就是连接窗口先耗尽。

错误二:用 int 存窗口。 SETTINGS_INITIAL_WINDOW_SIZE 最大 2^31-1,一次大增量或多次累加就能越过 Integer.MAX_VALUE,加法溢出成负数后 budget <= 0 永远成立,连接卡死。窗口变量一律用 long,只在构造帧时掩成 31 位。

错误三:把 HEADERS 算进流量控制。 HEADERS 不占窗口。把头部块也扣一遍,会让对端的账比本端少,几百个请求后对端提前停发。HPACK 的头部膨胀由 MAX_HEADER_LIST_SIZE 管,不是流量控制。

错误四:WINDOW_UPDATE 增量为 0 或对空闲流发。 增量为 0 本机会直接报 PROTOCOL_ERROR;对 idle 状态的流发 WINDOW_UPDATE 同样是协议错误。反过来,half-closed 甚至刚 closed 的流上收到 WINDOW_UPDATE 不能当错误,因为客户端发完请求体(END_STREAM)后还要继续收响应,仍会为该流补窗口;本实现关闭后可以直接移除流,一旦移除,晚到的更新会报 unknown stream,生产实现应为刚关闭的流保留一小段宽限。

错误五:收到 SETTINGS 只改新流。 初始窗口变化必须按差值作用到所有已存在的流,否则新旧流的窗口基准不同,同一个连接上会出现「有的流能发、有的流永远发不动」。

边界:本实现不做优先树,也不做 PUSH_PROMISE 的流控。 轮转只保证公平,不保证延迟或带宽比例;真要控延迟,得在应用层排队。另外 WINDOW_UPDATE 的增量上限、MAX_FRAME_SIZE 的协商范围都只做了基本校验,生产实现还要限制单连接待发字节数,防止一个大响应把堆内存吃满。writeData 在 data.length == 0 时不会产生任何帧,空响应要单独发一个带 END_STREAM 的空 DATA 帧。

小结

  • stream id 客户端奇数、服务端偶数,只增不复用;用尽发 GOAWAY,不回绕。
  • 发送 DATA 同时扣连接窗口和流窗口,取较小值,再受 MAX_FRAME_SIZE 限制。
  • 只有 DATA 受流控,padding 计入;HEADERS、SETTINGS、PING 不计。
  • SETTINGS_INITIAL_WINDOW_SIZE 变化按差值作用到已有流,窗口可以为负。
  • 接收侧在一半时补窗口,连接级和流级分别补,不能只补一边。
  • RFC 9113 已移除优先树,linkd 用轮转保证公平,延迟需求放到应用层解决。

到这里,明文 h2c 的帧、头部和窗口都能跑通,但连接是裸的:没有加密,也没有 ALPN。下一节从 TLS 1.3 的握手与证书链开始,把 SSLEngine 接到同一条非阻塞读循环上。

阅读导航:上一节:9.2 HPACK 与头部压缩 · 下一节:10.1 TLS 1.3 握手与证书链 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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