第36章 HTTP/2 帧、流控与 HPACK:h2 代理的内部机制

学习目标

  • 看清 HTTP/2 的帧结构与关键帧类型,理解"一切都是帧"
  • 吃透 h2 的两级流控窗口——它就是 第34章 背压在多路复用下的解法
  • 理解 HPACK 为何有状态、不能透传,代理必须维护独立编解码上下文
  • 搞懂 h2↔h1 的阻抗失配翻译——h2 代理真正的难点

前置知识


原理

第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(或反之)。两个协议模型不对等,翻译处处是坑:

维度h2h1代理怎么翻
多路复用一连接 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),并讲透熔断、重试预算、亚稳态失败。