Skip to content

第16章 多流调度与流控:backpressure 在 HTTP/2 的落地

16.1 HTTP/2 的复杂度都在这一章

上一章讲了 HTTP/2 的帧格式和 HPACK。如果我们停在那里——你会以为 HTTP/2 就是"HTTP/1 + 帧化 + 压缩"。这是严重的低估。

HTTP/2 真正的复杂度——以及上线一个 HTTP/2 服务最容易踩坑的地方——在多路复用和流控。它们是 HTTP/2 能在一条 TCP 连接上安全地承载 1000 个并发请求的物理基础,也是"你的服务在某个奇怪的时刻突然 0 吞吐量"的一般来源。

这一章我们读 hyper 的 proto/h2/server.rs 以及它在 http2::Builder 上暴露的一组看起来类似但作用完全不同的参数:

rust
// hyper/src/proto/h2/server.rs:32-41
const DEFAULT_CONN_WINDOW: u32 = 1024 * 1024;                      // 1mb
const DEFAULT_STREAM_WINDOW: u32 = 1024 * 1024;                    // 1mb
const DEFAULT_MAX_FRAME_SIZE: u32 = 1024 * 16;                     // 16kb
const DEFAULT_MAX_SEND_BUF_SIZE: usize = 1024 * 400;               // 400kb
const DEFAULT_SETTINGS_MAX_HEADER_LIST_SIZE: u32 = 1024 * 16;      // 16kb
const DEFAULT_MAX_LOCAL_ERROR_RESET_STREAMS: usize = 1024;

六个数字。每一个背后都是一个具体的协议机制 + 一次曾经上过头条的安全事故 + 一个你部署时必须做的判断。

16.2 多路复用:一条 TCP,多个 stream

HTTP/2 的一条 TCP 连接能承载多个 stream——每个 stream 是一个独立的请求-响应。stream 之间的 frame 在线路上可以任意交错:

时刻  T1    T2    T3    T4    T5    T6
流量  H/s1  D/s1  D/s3  H/s5  D/s1  D/s5
            ↑     ↑     ↑
            在发 s1 的 body 中间,插入了 s3 的 body 和 s5 的 header

对方根据 Stream ID 字段把每个 frame 路由到对应的请求 context。

16.2.1 stream 的生命周期

每个 stream 经历若干状态(RFC 9113 §5.1):

idle → open → half-closed (local / remote) → closed
  • idle:还没分配。
  • open:双方都在交换数据。
  • half-closed (remote):对端已发完(END_STREAM flag),本端还没发完。
  • half-closed (local):本端已发完,对端还在发。
  • closed:双方都发完。

注意:stream 可以从 open 直接 → closed(通过 RST_STREAM 取消),这是 CVE-2023-44487 的切入点(下文)。

16.2.2 stream ID 规则

  • 客户端发起 的 stream:奇数ID(1, 3, 5, ...)
  • 服务端发起 的 stream:偶数ID(2, 4, 6, ...,主要用于已弃用的 Server Push)

所以实际的 HTTP/2 server 基本只看到奇数 stream。ID 是严格递增的——连接上永远不会有小于已见最大 ID 的新 stream。老的 stream ID 不会被复用(31 bit 够用 ~21 亿个 stream)。

16.2.3 max_concurrent_streams:允许多少同时在跑

hyper 默认:

rust
max_concurrent_streams: Some(200),

最多 200 个 stream 同时打开。超过这个数,对端发的新 HEADERS frame 会被 hyper 拒绝(RST_STREAM with REFUSED_STREAM)。

200 是一个中等激进的默认值。HTTP/2 spec 建议至少 100SETTINGS_MAX_CONCURRENT_STREAMS)。Nginx 默认 128、Envoy 默认 1024、Chrome 允许对方开到 1000。

为什么不设更高?每个 in-flight stream 消耗内存——至少一个 header map(几 KB)+ 一个 flow-control window 状态(几十字节)+ user service 的 future(视业务而定)。200 个 stream 大约 1-2 MB 内存。如果你每秒来几千个连接,全都开 1024 并发 stream——内存占用会放大 5 倍。

实战建议:内部服务(可信客户端)设高(1000+),暴露到公网(可能被滥用)设低(100-500)。

16.3 两级流控:Connection 级 + Stream 级

HTTP/2 的流控是最容易让人头大的部分。它有两层

  • Connection-level window整条连接的总流控窗口。
  • Stream-level window:每个 stream 独立的窗口。

发送数据时必须同时满足两个窗口都 > 0。任何一个为 0,数据就发不出去。

16.3.1 为什么要两级

假设只有 Connection 级:

  • 某个 stream 发大量数据把 connection window 耗尽。
  • 其他 stream 即使 idle 也无法发数据。
  • 这和 HTTP/1 没有区别——一个慢 stream 阻塞整条连接

假设只有 Stream 级:

  • 每个 stream 独立窗口——没问题。
  • 但是 内存攻击:攻击者开 1000 个 stream,每个窗口 64KB,总共 64MB 内存——server 没法一起承担。

两级流控给细粒度控制:connection 级限制总容量,stream 级保证公平性。

16.3.2 初始窗口

HTTP/2 spec 默认每个 stream 的 initial window 是 65535 字节(2^16 - 1,历史原因)。hyper 的默认覆盖:

rust
initial_conn_window_size: 1024 * 1024,        // 1 MB
initial_stream_window_size: 1024 * 1024,      // 1 MB

hyper 默认 1 MB——spec 默认的 16 倍。为什么?

spec 注释给了答案:

// Our defaults are chosen for the "majority" case, which usually are not
// resource constrained, and so the spec default of 64kb can be too limiting
// for performance.

64KB 窗口在现代网络里太小了。考虑 BDP(bandwidth-delay product):

  • 100Mbps 带宽 + 50ms 往返延迟 → BDP = 100e6 / 8 × 0.05 = 625 KB
  • 10Gbps 带宽 + 1ms 往返延迟(数据中心内部)→ BDP = 10e9 / 8 × 0.001 = 1.25 MB

窗口小于 BDP 意味着 TCP pipe 填不满。64KB 窗口在 100Mbps/50ms 链路上只能跑 ~10Mbps(一直 stop-and-wait),95% 带宽浪费。

1 MB 是个"大多数场景能填满 pipe、内存不过分"的折中。每连接 2 MB(connection 窗口 + 一个 stream 的窗口),200 并发 stream 总 402 MB。你有 32 GB 内存的 server 可以放心用。

16.3.3 WINDOW_UPDATE

读取方通过WINDOW_UPDATE告诉发送方"我又处理掉了 N 字节,你可以多发 N 字节"。

WINDOW_UPDATE frame payload: increment (31 bit)
stream_id == 0  →  更新 connection window
stream_id >  0  →  更新该 stream 的 window

hyper 通过 h2 crate 自动管理 WINDOW_UPDATE——当 body 被用户 consumer 消费(调用 body.poll_frame 后数据被真正取走),h2 会根据"消费了多少"发送对应的 WINDOW_UPDATE。

这一层自动化很关键——你读 body 慢,h2 就晚发 WINDOW_UPDATE,对端就被 back-pressure 到发得慢。流控信号从业务层一路回到对端 TCP 发送缓冲——这就是 HTTP/2 "真正端到端背压" 的机制。

16.3.4 实测:流控信号的传导

想象一个场景:

  • Client 上传一个 100 MB 的 body。
  • Server 处理每个 chunk 需要 10ms(例如写磁盘)。

没有流控:client 按网络带宽全速发 → 100MB 全到 server 的 socket buffer → server 进程内存爆。

有流控

  1. Server 初始窗口 1 MB。
  2. Client 发 1 MB 后流控打到 0,不再发。
  3. Server 业务代码消费掉 100 KB(body.poll_frame → data 被复制出来)。
  4. h2 发 WINDOW_UPDATE(100_000)
  5. Client 收到,再发 100 KB。
  6. 循环。

Client 的发送速度被 server 的消费速度反向拉住——永远不会超速。在 tokio 层面,这个约束通过 h2::SendStream::poll_capacity 传递到 user 层——这就是上一章末尾讨论过的 PipeToSendStream 循环。

16.3.5 adaptive_window:动态调整

rust
adaptive_window: bool,  // 默认 false

设为 true 时,hyper 根据 RTT 和消费速度自动调整窗口大小。机制:

  1. 用 PING 测 RTT。
  2. 用 "BDP 估算" 算出合理的窗口大小:RTT × 带宽 的 2-4 倍。
  3. 通过 WINDOW_UPDATE 动态调整实际窗口。

这个启发式来自 BBR congestion control 的思路——让 flow-control window 跟上实际带宽。开启之后对吞吐通常有 20-50% 提升。

hyper 默认关闭——因为需要更多内存、在某些场景会"窗口放大攻击"(malicious client 故意延迟发 ACK 让你持续放大窗口)。生产代码如果内部服务可信、追求极致吞吐,建议开

16.4 max_frame_size vs max_send_buf_size

两个看起来类似的参数——容易混。

16.4.1 max_frame_size(16KB 默认)

这是 HTTP/2 协议层的 SETTINGS 参数——告诉对端 "我接收的单个 frame payload 最大多少"。hyper 默认 16KB(spec 允许的最小值),h2 spec 允许最大 16 MB。

16KB 是一个好的默认:

  • cache 友好:一个 frame 刚好放 L2 cache 的一小部分。
  • 响应迅速:对端发大数据时会被切成多 frame——我们可以在 frame 之间插入其他 stream 的 frame,实现交错。

设大(比如 1MB)的副作用:一个大 stream 的 DATA frame 独占线路几 ms,其他 stream 的 frame 要等——变相 head-of-line blocking。实操几乎没人调大这个。

16.4.2 max_send_buf_size(400KB 默认)

这是 hyper 内部的实现参数——"发送队列缓存最多多少字节"。发送方从 user body 拉 frame 装到这个 buffer,按流控节奏冲出去。

为什么 hyper 要一个发送缓冲?因为 user body 的 poll_frame 不能跟着网络节奏跑——业务代码可能一次吐 1MB、下次 1 秒才吐 1KB。hyper 需要平滑这个节奏,发送缓冲吸收突刺。

400 KB 是默认。设大→ 吞吐更稳、内存占用升。设小→ 内存省、突发 throughput 容易被截断。

16.5 max_concurrent_streams × stream_window 的内存数学

把前面的参数组合起来,单连接的内存上界大约:

per-connection-memory 
  = max_concurrent_streams × stream_window + conn_window + per-stream overhead
  = 200 × 1MB + 1MB + 200 × ~5KB
  ≈ 202 MB  (!!)

一个 HTTP/2 连接最坏情况 200 MB。假设你有 100 个并发连接——20 GB。

当然这是最坏情况——所有 200 个 stream 都 full buffer。实际运行时 stream 数和 body 大小都是动态的,典型占用是理论上界的 5-10%。但理论上界是你部署规划时必须考虑的数字

实操调参的几条经验:

  • 流量小(<1000 QPS):默认(1 MB × 200)就用,不担心。
  • 公网入口(可能被攻击):max_concurrent_streams(50) + stream_window(64 KB) —— 把攻击面缩小到一次 3MB / 连接。
  • 内网高吞吐adaptive_window(true) + 大 window(4-16 MB)—— 让 BDP 被填满。

16.6 max_pending_accept_reset_streams:CVE-2023-44487 防御

最刺激的一个参数。

16.6.1 HTTP/2 Rapid Reset 攻击

2023 年 10 月披露的 CVE——一种利用 HTTP/2 多路复用 + RST_STREAM 组合的 DoS 攻击。机制:

  1. 客户端发 HEADERS / stream_id=1
  2. 客户端立刻发 RST_STREAM / stream_id=1——取消请求。
  3. 重复上面两步——每对 frame 组合大约 20-30 字节。
  4. server 端收到 HEADERS 后需要:
    • 解析 HPACK 分配 header map。
    • 触发 service.call(req) —— 可能已经调 call 了 —— 分配了各种资源。
    • 然后收到 RST_STREAM —— 取消 service future、回收资源。
  5. 攻击者的发送 cost 小(几十 bytes/op),但 server 的处理 cost 大(分配 + service future + 取消)。

此前很多 HTTP/2 实现(包括 Nginx、Envoy、Go/Apache)不对这个组合做限流——攻击者可以在一条 TCP 连接上每秒发 数十万次 HEADERS+RST_STREAM 循环,把 server CPU 打到 100%。

CVE-2023-44487 攻击被 Google、Amazon、Cloudflare 同时观察到——峰值 3.98 亿 requests/second,比他们之前的 DDoS 记录大 7 倍。

16.6.2 hyper 的防御

hyper 在 CVE 披露后(2023 年 10 月 10 日)当天发布 0.14.x 补丁(后向 1.0 同步),加入两个参数:

rust
max_pending_accept_reset_streams: Option<usize>,
max_local_error_reset_streams: Option<usize>,  // 默认 1024
  • max_pending_accept_reset_streams:客户端可以 RST 而我们还没开始 accept 的 stream 最大数量。超过 hyper 发 GOAWAY 关连接。
  • max_local_error_reset_streams我们因为某种协议错误 reset 的 stream 数量(如收到非法 header)。攻击者可以故意发非法 header 让我们 reset,这个限流防止 "我们自己被放大"。

默认 max_local_error_reset_streams = 1024 —— 1024 次本地 reset 后关连接。合理——正常客户端很少触发非法 header;1024 次已是极大嫌疑

max_pending_accept_reset_streams 默认 None —— 因为"pending accept" 的判断依赖于 h2 crate 内部行为,hyper 把选择权留给用户。生产建议设成 Some(50) 或类似——"允许 50 个正常取消,超过就判为攻击"。

16.6.3 更深一层:协议层的固有问题

CVE-2023-44487 是 HTTP/2 协议设计的一个系统性问题——请求取消不需要发送方付出显著代价。这是 HTTP/1 不会有的问题(HTTP/1 取消=关连接,显著代价)。

后续 RFC 9113 更新和 HTTP/3(QUIC)引入了 "Stream Reset Ratio" 限流建议——每 stream 开启 / 取消比例超过一定值就触发 rate limiting。hyper 实现了 pending accept 和 local error 两个 counter,是这类防御的实现。

这是一个经典的 "协议、库、运维" 三层共同承担 DoS 的案例——协议修复要时间,库能发防御补丁,运维要主动配置防御参数。如果你的服务暴露在公网,必须 调这两个参数。

16.7 对照:HTTP/1 的 pipeline vs HTTP/2 的 multiplex

第 1 章里提过 HTTP/1 的 pipelining 实际上弃用了。这里对照一下为什么 HTTP/2 的多路复用"更好"。

HTTP/1 Pipelining

Client:  [Req A] [Req B] [Req C]   →
Server:  [Resp A] [Resp B] [Resp C]  ←

响应必须按请求顺序返回——这就是 HOL (head-of-line) blocking。如果 Resp A 慢,Resp B/C 都得等——哪怕 Resp B 已经在 server 内存里写好了。

HTTP/2 Multiplex

Client:  [HEADERS A] [HEADERS B] [HEADERS C]  →
Server:  [HEADERS B, DATA B=0-100B] [DATA A=0-500B] [HEADERS C, DATA C=0-50B] [DATA B=100-200B] ...  ←

响应可以乱序、分片、交错——Resp B 先到没关系,Resp A 同时慢慢发,所有 stream 独立。这才真正解决了 HOL。

但 HTTP/2 有自己的 HOL——TCP 级别。HTTP/2 所有 stream 跑在一条 TCP 连接上——如果 TCP 丢包,整条连接停等重传——所有 stream 都被影响。这是 HTTP/3/QUIC 解决的问题(每个 stream 独立 TCP-like 传输)。

按时序看:

  • HTTP/1.0:每请求一连接。
  • HTTP/1.1:keep-alive,但仍然一条 tcp 一次一请求。
  • HTTP/2:多路复用,消除应用层 HOL,保留 TCP HOL。
  • HTTP/3 (QUIC):每个 stream 独立 TCP-like 传输,消除 TCP HOL。

HTTP/2 是一个 "足够好、部署成熟" 的平衡点——过去十年的主流。HTTP/3 正在逐步铺开,但 hyper 目前不内建(有 h3 crate 但生态还在成长,本书不展开)。

16.8 与 Tokio Semaphore 的对照

回想第 4 章 Tower 的 ConcurrencyLimitSemaphore 做流控。HTTP/2 的 window 和 Semaphore 是同一个抽象的两种实现:

特征SemaphoreHTTP/2 Window
单位许可(discrete)字节(continuous)
获取acquire(1)poll_capacity(n)
释放permit.drop()send_data 后自动释放
范围进程内跨网络
分级Connection + Stream

都是 "counting + backpressure" 的 pattern——只是实现尺度不同。Semaphore 在进程内、Window 在跨网络。

读过卷四《Tokio 源码深度解析》第 12 章的 Semaphore 源码 后再读 h2 的 flow control——你会感到两者的状态机骨架一模一样

  • 当前可用容量(remaining)
  • 等待队列(waiters)
  • 唤醒信号(waker)

把流控这件事抽象到"counting primitive"层面之后,网络的 flow control 和进程内的并发控制可以用同一套心智模型理解。这是 async runtime 的一个巨大价值——让不同物理层次的资源管理共享统一抽象

16.9 生产推荐配置

基于上面的讨论,给一个生产级 hyper HTTP/2 server 的推荐配置:

rust
use std::time::Duration;
use hyper_util::rt::{TokioExecutor, TokioTimer};

let http = hyper::server::conn::http2::Builder::new(TokioExecutor::new());
http.timer(TokioTimer::new())
    
    // 流控 - 内网服务可调大到 4 MB
    .initial_connection_window_size(2 * 1024 * 1024)  // 2 MB
    .initial_stream_window_size(1 * 1024 * 1024)       // 1 MB
    .adaptive_window(true)                             // 自适应 BDP
    
    // 并发 - 公网低,内网高
    .max_concurrent_streams(200)
    
    // CVE-2023-44487 防御
    .max_pending_accept_reset_streams(Some(50))
    .max_local_error_reset_streams(Some(1024))
    
    // 保活(第 17 章详讲)
    .keep_alive_interval(Some(Duration::from_secs(10)))
    .keep_alive_timeout(Duration::from_secs(20))
    
    // 限制 header
    .max_header_list_size(16 * 1024)                   // 16 KB
    
    // 发送缓冲
    .max_send_buf_size(256 * 1024)                     // 256 KB
    
    .serve_connection(io, svc)
    .await

公网暴露场景加:

rust
.max_concurrent_streams(50)                // 降低并发 stream
.initial_stream_window_size(64 * 1024)     // 降低内存攻击面

每一条都有具体理由——每一条修改都回答"防御什么"或"优化什么"。

16.10 落到你键盘上

  • h2/src/proto/streams/flow_control.rs——真正的 flow control 算法在那里。它是 HTTP/2 flow control spec 的直接 Rust 实现,带 counter、waker、限制检测,不到 400 行。
  • wrk2 + HTTP/2 对你的服务做压力测试——观察不同 initial_window_size 下 throughput 的差别。你会看到 64 KB vs 1 MB 在高带宽链路上差 10 倍。
  • 模拟 rapid reset 攻击(在受控环境里)——用 Go/Rust 写一个 client,不停发 HEADERS + RST_STREAM。不配 max_pending_accept_reset_streams 的 server 很快 CPU 100%;配了的 server 会在 50 次后关连接。直观感受防御效果。

下一章讲 HTTP/2 的"连接生存机制"——PING、GOAWAY 和超时。这三样一起构成 HTTP/2 在长连接下的健康度管理。

基于 VitePress 构建