本节目标:能说清 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 握手与证书链 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。