Appearance
第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 建议至少 100(SETTINGS_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 MBhyper 默认 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 的 windowhyper 通过 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 进程内存爆。
有流控:
- Server 初始窗口 1 MB。
- Client 发 1 MB 后流控打到 0,不再发。
- Server 业务代码消费掉 100 KB(body.poll_frame → data 被复制出来)。
- h2 发
WINDOW_UPDATE(100_000)。 - Client 收到,再发 100 KB。
- 循环。
Client 的发送速度被 server 的消费速度反向拉住——永远不会超速。在 tokio 层面,这个约束通过 h2::SendStream::poll_capacity 传递到 user 层——这就是上一章末尾讨论过的 PipeToSendStream 循环。
16.3.5 adaptive_window:动态调整
rust
adaptive_window: bool, // 默认 false设为 true 时,hyper 根据 RTT 和消费速度自动调整窗口大小。机制:
- 用 PING 测 RTT。
- 用 "BDP 估算" 算出合理的窗口大小:
RTT × 带宽的 2-4 倍。 - 通过 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 攻击。机制:
- 客户端发 HEADERS /
stream_id=1。 - 客户端立刻发 RST_STREAM /
stream_id=1——取消请求。 - 重复上面两步——每对 frame 组合大约 20-30 字节。
- server 端收到 HEADERS 后需要:
- 解析 HPACK 分配 header map。
- 触发 service.call(req) —— 可能已经调 call 了 —— 分配了各种资源。
- 然后收到 RST_STREAM —— 取消 service future、回收资源。
- 攻击者的发送 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>, // 默认 1024max_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 的 ConcurrencyLimit 用 Semaphore 做流控。HTTP/2 的 window 和 Semaphore 是同一个抽象的两种实现:
| 特征 | Semaphore | HTTP/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 在长连接下的健康度管理。