核心问题#

注:文章由 codex 整理。

HTTP/2 的一个核心收益是 multiplexing:多个 request/response 可以复用同一条 TCP connection,并且不同 stream 的 frame 可以交错发送。这个能力解决了 HTTP/1.x 依赖多连接并发的问题,但也引入了一个新问题:如果某个接收方、某个 stream 或某个应用层消费者处理不过来,发送方应该怎样被限制,而不至于把整个连接的内存打爆?

HTTP/2 flow control 解决的就是这个问题。它不是拥塞控制(congestion control),也不是优先级调度(prioritization),而是一个接收方驱动的背压机制:

  • TCP 拥塞控制关心网络路径能承受多少数据。
  • HTTP/2 flow control 关心接收端应用和协议栈愿意接收多少 DATA payload。
  • HTTP/2 priority 关心多个 stream 竞争发送机会时先发谁。

可以把它理解为:TCP 管路决定“路上能跑多少车”,HTTP/2 flow control 决定“每个收货口和总仓库还能接多少货”。

基本模型#

HTTP/2 的流量控制基于 credit/window:

  1. 接收方声明自己还能接收多少字节。
  2. 发送方发送 DATA frame 时消耗窗口。
  3. 接收方消费掉数据、释放缓冲后,通过 WINDOW_UPDATE 把窗口加回来。
  4. 如果窗口耗尽,发送方必须暂停发送受流控约束的数据。

这里有两个层级的窗口:

窗口 作用范围 用来限制什么
stream flow-control window 单个 stream 某个 request/response body 的发送量
connection flow-control window 整条 HTTP/2 connection 所有 stream 的 DATA frame 总发送量

发送一个 DATA frame 之前,发送方必须同时检查两个窗口:frame payload 长度不能超过 stream window,也不能超过 connection window。发送后,这两个窗口都会按 payload 长度扣减。注意,HTTP/2 frame header 的 9 个字节不计入流控窗口。

为什么需要两层窗口#

只做 connection 级别窗口是不够的。一个大 response 或上传 stream 可能吃光整条连接的窗口,导致其他小请求也被拖住。

只做 stream 级别窗口也不够。假设每个 stream 都允许 64 KiB,而一个连接上同时打开很多 stream,总缓冲需求会随并发数线性增长,接收方仍然可能被压垮。

所以 HTTP/2 同时维护两层约束:

  • stream window 保护单个 stream 的接收缓冲。
  • connection window 保护整条连接上的总体内存和处理能力。

这也是 HTTP/2 multiplexing 的代价之一:协议不再只面对“一个连接一个消息”的简单队列,而要在同一条连接里对多个独立 stream 做资源隔离。

WINDOW_UPDATE 的工作方式#

WINDOW_UPDATE frame 是 HTTP/2 flow control 的核心控制消息。它携带一个 31-bit 的 Window Size Increment,表示“你可以在现有窗口基础上再多发送多少字节”。

它可以作用在两个对象上:

  • Stream Identifier = 0:更新 connection flow-control window。
  • Stream Identifier != 0:更新对应 stream 的 flow-control window。

一个接收方通常会在应用层读走 body、协议栈释放 buffer 后发送 WINDOW_UPDATE。这点很重要:窗口不是“收到数据就马上归还”,而应当和实际消费能力绑定。否则 flow control 就会退化成形式上的 ACK,无法形成背压。

一个简化的数据流如下:

Sender                                      Receiver
  |                                            |
  | DATA stream=1 len=16KB                     |
  |------------------------------------------->|
  | stream window -= 16KB                      | buffer += 16KB
  | connection window -= 16KB                  |
  |                                            | app reads 16KB
  |                                            | buffer -= 16KB
  | WINDOW_UPDATE stream=1 +16KB               |
  |<-------------------------------------------|
  | WINDOW_UPDATE connection +16KB             |
  |<-------------------------------------------|

WINDOW_UPDATE 本身不受 flow control 约束,否则就会出现窗口用尽后连恢复窗口的控制消息都发不出去的死锁。

初始窗口和动态调整#

HTTP/2 连接建立时,新 stream 的默认初始窗口是 65,535 bytes,connection flow-control window 也是 65,535 bytes。

stream 初始窗口可以通过 SETTINGS_INITIAL_WINDOW_SIZE 调整。这个设置不仅影响新 stream,也会影响处于 openhalf-closed (remote) 状态的已有 stream:窗口会按新旧值的差值整体调整。

这里有一个容易忽略的细节:如果把初始窗口调小,已有 stream 的可用窗口可能变成负数。负窗口不是协议错误,而是表示发送方已经“超前发送”了新限制下不该继续发送的数据。发送方必须记住这个负值,并等待后续 WINDOW_UPDATE 把窗口恢复为正数后,才能继续发送新的 DATA frame。

connection window 不能通过 SETTINGS_INITIAL_WINDOW_SIZE 修改,只能通过 WINDOW_UPDATE 增加。

哪些 frame 受流控影响#

在 RFC 9113 定义的 frame 类型中,只有 DATA frame 受 flow control 影响。HEADERSSETTINGSWINDOW_UPDATEPINGRST_STREAMGOAWAY 等控制 frame 不消耗流控窗口。

这有几个直接后果:

  • 大 body 会被 flow control 限制,header 不会。
  • header 太大要靠 SETTINGS_MAX_FIELD_SECTION_SIZE、实现侧限制或 431/stream reset 等机制处理,不是靠 flow control。
  • 即使某个 stream 的发送方向被 flow control 卡住,连接仍然应该能处理控制 frame,例如 RST_STREAMWINDOW_UPDATE

与 TCP flow control / congestion control 的区别#

HTTP/2 flow control 很容易和 TCP 的窗口机制混在一起。它们都叫 window,但含义不同。

机制 层次 控制目标 由谁驱动
TCP receive window 传输层 接收端 TCP buffer 能否继续接收字节流 TCP 接收端
TCP congestion window (cwnd) 传输层 网络路径是否拥塞 TCP 发送端根据 ACK/loss/ECN 估算
HTTP/2 flow-control window 应用层协议 接收端 HTTP/2 stream/connection buffer 与应用消费能力 HTTP/2 接收端

HTTP/2 运行在 TCP 之上。即使 TCP 认为还能继续发送,HTTP/2 也可能因为某个 stream window 或 connection window 为 0 而停止发送 DATA frame。反过来,即使 HTTP/2 window 很大,TCP 也可能因为网络拥塞、receive window 小、丢包或重传而发不出去。

排查吞吐问题时,这个分层很关键:

  • cwnd 小、重传多、RTT 抖动大,更像 TCP/网络路径问题。
  • HTTP/2 连接上 WINDOW_UPDATE 回得慢,或者 connection window 长时间归零,更像应用层消费或协议层流控问题。
  • 某个 stream 卡住但其他 stream 正常,优先看 stream window、应用读取、stream 状态。
  • 所有 stream 都卡住,优先看 connection window、TCP 发送状态、对端整体读能力。

性能影响#

HTTP/2 flow control 的窗口大小直接影响高带宽、高延迟链路上的吞吐上限。

如果窗口太小,发送方会频繁停下来等待 WINDOW_UPDATE,实际吞吐可能被限制在近似:

throughput <= flow_control_window / RTT

例如默认窗口 65,535 bytes,在 100 ms RTT 上,单个 stream 即使底层网络没有问题,理论上也只能达到大约:

65,535 bytes / 0.1 s ~= 655 KB/s ~= 5.2 Mbps

这只是粗略估算,因为实现可能提前批量更新窗口,多个 stream 也会共享 connection window,但它能解释一个常见现象:HTTP/2 默认窗口在长肥管道(long fat network)上可能明显偏小

性能调优通常要同时考虑:

  • 单 stream 大对象下载/上传:增大 stream initial window。
  • 多 stream 并发传输:增大 connection window,避免总窗口成为瓶颈。
  • 内存占用:窗口越大,接收方可能需要预留或承受的缓冲越大。
  • 更新策略:WINDOW_UPDATE 太碎会增加控制 frame 和调度开销,太晚又会造成发送停顿。

实现和网关中的常见坑#

1. 只更新 stream window,忘了 connection window#

发送 DATA 会同时消耗 stream window 和 connection window。接收方消费数据后,也通常需要分别归还 stream 级和 connection 级 credit。只更新其中一个,发送方仍可能因为另一个窗口耗尽而停住。

2. 代理没有把背压正确传递到上下游#

HTTP/2 flow control 是 hop-by-hop 的。中间代理不会把下游连接收到的 WINDOW_UPDATE 原样转发给上游连接,而是在两个连接之间重新实现背压。

一个代理如果从上游读得太快、向下游写得太慢,就会在本地积压 body。健康的代理应该让下游消费能力影响上游读取节奏,而不是无上限缓存。

3. 把收到数据等同于消费数据#

如果协议栈在收到 DATA 后立刻发送 WINDOW_UPDATE,但应用层还没读走数据,窗口就不能真实反映应用消费能力。高并发或慢消费者场景下,这会扩大内存压力。

4. 初始窗口调得过大#

增大窗口可以提升吞吐,但也放大了单连接、单 stream 可积压的数据量。对公网网关、API Gateway、服务网格 sidecar 这类多租户入口来说,窗口配置还要和最大并发 stream 数、最大请求体、连接数上限一起看。

5. 忽略负窗口状态#

SETTINGS_INITIAL_WINDOW_SIZE 被调小,已有 stream window 可能变成负数。实现如果没有正确处理负窗口,可能出现多发数据、误报 flow-control error,或者永久停顿。

排查思路#

遇到 HTTP/2 大 body 慢、上传卡住、gRPC stream 堵住时,可以按这个顺序判断:

  1. 确认是不是 HTTP/2 层面卡住:抓包或日志中观察 DATA 是否停止、WINDOW_UPDATE 是否延迟或缺失。
  2. 区分 stream 级和 connection 级:单个 stream 停住看 stream window;所有 stream 停住看 connection window。
  3. 看应用消费速度:服务端 handler 是否及时读取 request body;客户端是否及时读取 response body;gRPC stream 是否有用户态队列堆积。
  4. 看 TCP 层状态:如果 HTTP/2 window 足够但仍发不出去,再看 cwnd、重传、send queue、receive window、RTT。
  5. 看代理链路:如果经过 gateway/sidecar/load balancer,需要分别看每一跳的 HTTP/2 window,而不是只看端到端。

可观察信号包括:

  • WINDOW_UPDATE frame 的频率和增量。
  • DATA frame 发送停顿的位置。
  • 每个 stream 的 pending send bytes / pending receive bytes。
  • 连接级 write buffer 和 read buffer。
  • 应用层 body read / write 延迟。
  • TCP send-qrecv-q、RTT、重传、拥塞窗口。

和 gRPC 的关系#

gRPC over HTTP/2 的 request message 和 response message 最终都承载在 HTTP/2 DATA frame 中,所以同样受 HTTP/2 flow control 影响。

这对 streaming RPC 尤其明显:

  • server streaming:客户端读得慢,会通过 flow control 反压服务端发送。
  • client streaming:服务端读 request stream 慢,会反压客户端上传。
  • bidi streaming:两个方向各自都有独立的发送与接收压力,但共享同一条 HTTP/2 connection 的连接级窗口。

因此,gRPC 的“发送慢”不一定是业务处理慢,也可能是对端没有及时读流,导致 HTTP/2 window 没有恢复。

关键结论#

HTTP/2 flow control 的本质是接收方驱动的应用层背压:

  • 它只约束 DATA frame,不约束 header 和控制 frame。
  • 它同时有 stream 级和 connection 级窗口。
  • 发送方必须同时满足两个窗口才能发送 body 数据。
  • WINDOW_UPDATE 是 credit 归还机制,不是 TCP ACK。
  • 窗口太小会限制高 RTT 链路吞吐,窗口太大又会增加内存风险。
  • 在代理和 gRPC 场景中,flow control 是定位“看起来像网络慢,实际是对端不读”的关键线索。

参考资料: