第36章 HTTP/2 帧、流控与 HPACK:h2 代理的内部机制
学习目标
- 看清 HTTP/2 的帧结构与关键帧类型,理解"一切都是帧"
- 吃透 h2 的两级流控窗口——它就是 第34章 背压在多路复用下的解法
- 理解 HPACK 为何有状态、不能透传,代理必须维护独立编解码上下文
- 搞懂 h2↔h1 的阻抗失配翻译——h2 代理真正的难点
前置知识
- 第07章 HTTP2/gRPC 代理挑战(本章是其机制级展开)
- 第34章 背压、第35章 状态机
原理
第07章 说"h2 多路复用所以不能 splice、很难代理"。本章把"为什么难"拆到机制级。
一切都是帧
HTTP/2 把一条 TCP 连接上的通信切成帧(frame)。每帧 9 字节定长头 + 变长 payload:
+-----------------------------------------------+
| Length (24) | payload 长度
+---------------+---------------+---------------+
| Type (8) | Flags (8) | | 类型 + 标志
+-+-------------+---------------+---------------+
|R| Stream Id (31) | 属于哪个 stream
+=+=============================================+
| Payload ... |
Stream Id 是关键:多个 stream(逻辑请求/响应)的帧交错跑在一条连接上,靠这个 id 区分。代理必须解析帧头、按 stream id 重组——这就是不能盲转 splice 的根本(第02章)。
关键帧类型
| 帧 | 作用 | 代理要关心 |
|---|---|---|
HEADERS | 携带 HPACK 压缩的头,开启 stream | 解压、改写、重压 |
DATA | 请求/响应体 | 受流控约束(见下) |
SETTINGS | 连接参数协商(窗口、最大并发流、最大帧) | 两侧参数可不同,要桥接 |
WINDOW_UPDATE | 流控:补充窗口额度 | 必须正确转发,否则卡死 |
RST_STREAM | 取消单个 stream(不关连接) | h1 无对应,难翻译 |
GOAWAY | 优雅关闭连接(告知最后处理的 stream id) | 关闭语义传递 |
PING | 保活 / 测 RTT | 一般本地应答 |
CONTINUATION | 头太大时续帧 | HPACK 炸弹防护点 |
PRIORITY/PUSH_PROMISE | 优先级 / 服务端推送 | 基本已废弃 |
每个 stream 还有自己的小状态机:idle → open → half-closed(half-closed local/remote) → closed(呼应 第35章 的半关闭,h2 在应用层重现了它)。
两级流控窗口:h2 的背压(呼应第34章)
第34章 结尾埋了个伏笔:h2 一条连接上多个 stream,一个慢 stream 不能阻塞别的 stream,所以不能"停止读整条连接"。h2 的答案是应用层的两级流控窗口:
连接级窗口(stream 0):约束整条连接的在途 DATA 总量
每流级窗口(每个 stream):约束单个 stream 的在途 DATA 量
发送方:只能在【连接窗口 ∩ 该流窗口】的额度内发 DATA
接收方:消费了数据后,发 WINDOW_UPDATE 补充额度(分别补连接级和流级)
- 只有
DATA帧受流控(HEADERS 等控制帧不受限) - 默认初始窗口 65535 字节(可由 SETTINGS 调大,吞吐关键)
- 为什么要两级? 流级窗口让背压精确到单个 stream:一个慢消费的 stream 耗尽自己的流窗口、发送方对它停发,但其它 stream 的窗口还在,照常传输——背压被隔离在 stream 内。这正是 第34章 说的"按 stream 背压",是 h2 相对 h1 的核心进步(h1 只能整条连接停)。
代理的责任:把客户端侧的
WINDOW_UPDATE/窗口状态,正确地映射到后端侧。漏传或算错WINDOW_UPDATE,stream 的窗口耗尽后就永远等不到补充 → 卡死。 这就是 第07章 "gRPC 流式调用挂起"的底层机制之一。
HPACK:为什么不能透传压缩字节
HTTP/2 用 HPACK 压缩头部。它由三部分组成:
静态表:61 项预定义常见头(:method GET、:status 200、content-type 等),固定
动态表:连接内【动态学习】的头,双方各维护一份,随通信增长
编码:① 引用表中某项的索引(1~2 字节搞定一个头)
② 字面值(可选 Huffman 编码)+ 决定是否加入动态表
致命关键:HPACK 是有状态的,编解码上下文绑定一条连接、一个方向。 第 N 个请求的头压缩,依赖前面请求建立的动态表状态。
这意味着:代理不能把客户端侧压缩好的头字节,原样转发给后端。 因为后端的 HPACK 动态表状态和客户端侧不同步。代理必须:用客户端侧上下文解压 → 得到明文头 → (改写)→ 用后端侧自己的 HPACK 上下文重新压缩。 两侧各维护一套独立的 HPACK 编解码器。这是 h2 代理比 h1 重得多的原因之一。
安全:恶意构造的头可撑爆动态表或用 CONTINUATION 洪泛(HPACK Bomb / CONTINUATION flood,近年的 CVE),代理要限制动态表大小与头总量。
h2 ↔ h1 阻抗失配翻译:真正的难点
现实里代理常常前端 h2、后端 h1(或反之)。两个协议模型不对等,翻译处处是坑:
| 维度 | h2 | h1 | 代理怎么翻 |
|---|---|---|---|
| 多路复用 | 一连接 N stream | 一连接一请求 | N 个 stream → 后端 h1 连接池(第31章)的 N 条连接 |
| 流控 | 两级窗口 WINDOW_UPDATE | 无应用层流控(靠 TCP,第34章) | 把 h2 窗口 ↔ 后端 TCP 背压互转 |
| 取消 | RST_STREAM 取消单 stream | 无"取消单请求" | RST_STREAM → 中止/复位整条 h1 连接(无法只取消一个) |
| 头 | 伪头 :method/:path/:scheme/:authority、全小写、禁逐跳头 | 请求行 + 普通头 | 伪头 ↔ 请求行/Host 互转,删逐跳头(第04章) |
| trailers | 原生支持 | 需 chunked trailer | 转发 trailer(gRPC grpc-status,第07章) |
这张表就是"代理 gRPC 为什么要选 Envoy"的全部理由:Envoy 原生在 stream 粒度处理帧、流控、HPACK、RST、trailer 的双向翻译,而拼凑的 h1 代理在每一行都可能翻车。
️ 实现 / 命令
实验一:用 nghttp 看真实帧流
# nghttp -v 打印每一帧(来自 nghttp2 工具集)
nghttp -v https://www.google.com/ 2>&1 | grep -E "recv|send" | head -30
# send SETTINGS frame ← 连接参数协商
# send HEADERS frame (stream 13) ← 发请求头(HPACK 压缩)
# recv SETTINGS frame
# recv HEADERS frame (stream 13) ← 响应头
# recv DATA frame (stream 13) ← 响应体
# recv WINDOW_UPDATE frame ← 流控补充额度
亲眼看到 HEADERS/DATA/SETTINGS/WINDOW_UPDATE,第07章 的抽象就具象了。
实验二:观察 SETTINGS 协商的窗口/并发参数
nghttp -v https://example.com/ 2>&1 | grep -A6 "SETTINGS frame"
# [SETTINGS_MAX_CONCURRENT_STREAMS(0x03):100] ← 最大并发 stream
# [SETTINGS_INITIAL_WINDOW_SIZE(0x04):65535] ← 初始流控窗口
# [SETTINGS_MAX_FRAME_SIZE(0x05):16384]
实验三:h2 多路复用压测
# h2load:一条连接上跑大量并发 stream,看多路复用吞吐
h2load -n10000 -c1 -m100 https://example.com/ # 1 条连接、100 并发流
# -c1 -m100 = 单连接 100 路复用,对比 h1 需要 100 条连接
实验四:验证代理是否正确转发 WINDOW_UPDATE(gRPC 流式)
# 大流式响应经代理,若 WINDOW_UPDATE 没传,收到 ~64KB(初始窗口)后卡死
grpcurl -plaintext -proxy http://代理:8080 server:443 stream.BigStream/Download
# 正常:持续收流;卡在 ~64KB 不动 = 代理没正确传 WINDOW_UPDATE(窗口耗尽)
排错
| 现象 | 根因 | 解决 |
|---|---|---|
| gRPC/流式收到约 64KB 后卡死 | 代理没正确传 WINDOW_UPDATE,流窗口耗尽 | 用原生 h2 代理(Envoy),核对流控转发 |
| 一个慢请求拖垮整条 h2 连接 | 按连接而非按 stream 背压 | 正确实现两级流控(流级隔离) |
| 头解析错乱/连接被重置 | HPACK 上下文不同步(透传了压缩字节) | 两侧各维护独立 HPACK 编解码 |
| 代理被畸形头打挂 | HPACK Bomb / CONTINUATION 洪泛 | 限制动态表大小与头总量,打补丁 |
| h2 转 h1 后 gRPC 状态丢失 | trailer 没转发 | 转发 trailer(grpc-status,第07章) |
| 客户端取消请求后端仍在跑 | RST_STREAM 没映射到后端中止 | h2 代理需把 RST_STREAM 传导为后端 abort |
本章小结
- HTTP/2 一切皆帧(9 字节头 + payload),靠
Stream Id多路复用——这是不能 splice 的根因。 - 两级流控窗口(连接级 + 流级
WINDOW_UPDATE)是 h2 的背压,流级隔离让一个慢 stream 不拖累其它——代理漏传WINDOW_UPDATE就卡死。 - HPACK 有状态、绑定连接:代理必须解压→改写→用自己这侧上下文重压,不能透传压缩字节。
- h2↔h1 阻抗失配(多路复用/流控/RST_STREAM/伪头/trailer)是 h2 代理的真正难点,也是 Envoy 成为标杆的理由。
下一章 第37章 负载均衡算法推导与韧性状态机,把 第09章 列的算法名补上推导(一致性哈希/Maglev/P2C),并讲透熔断、重试预算、亚稳态失败。