3. HTTP/2二进制分帧层实战解析

HTTP/1.1 的报文是“文本行 + 换行”的形态,抓包时肉眼就能读懂请求行和首部;HTTP/2 则把一切通信内容切成二进制帧,文本行不复存在。要真正理解 HTTP/2 的性能行为,必须把视角从“请求—响应”下沉到“帧”这一层:一条请求或响应被拆成若干帧,帧在一条流上按序传输,多条流在同一 TCP 连接上交错复用。本节用抓包与帧日志的方式,把 HEADERS、DATA、SETTINGS 三类帧的结构和它们与流、消息的映射关系看清楚。

一、三个层次:连接、流、消息、帧

HTTP/2 的层次关系可以这样理解:一条 TCP 连接上可以承载多条流(Stream),每条流有唯一的整数标识(Stream ID);一条流上承载一次完整的消息(Message)交换,即一个请求或一个响应;而一条消息由若干帧(Frame)组成。帧是 HTTP/2 的最小传输单位,所有帧共享同一个 9 字节固定帧头:

+-----------------------------------------------+
|                 Length (24)                   |  帧载荷长度,不含帧头
+---------------+---------------+---------------+
|   Type (8)    |   Flags (8)   |               |  帧类型 + 标志位
+-+-------------+---------------+               |
|R|                 Stream Identifier (31)      |  流 ID,R 为保留位
+=+=============================================+
|                   Frame Payload ...           |  载荷,长度由 Length 决定
+-----------------------------------------------+

Length 是 24 位,因此单帧载荷最大 16 777 215 字节,但实际受 SETTINGS 中 MAX_FRAME_SIZE 限制,默认 16 384 字节。Type 决定帧的语义,常见取值:0x0 为 DATA、0x1 为 HEADERS、0x4 为 SETTINGS、0x6 为 PING、0x9 为 CONTINUATION、0x3 为 RST_STREAM、0x7 为 GOAWAY。Flags 是 8 位标志,例如 HEADERS 帧的 END_HEADERS(0x4)表示首部块结束,END_STREAM(0x1)表示该流不再发送数据。Stream Identifier 指明该帧属于哪条流,0 号流专用于连接级控制帧(如 SETTINGS、PING、GOAWAY)。

HTTP/2 连接 → 流 → 消息 → 帧 映射关系 TCP 连接(一条) 承载所有流,连接级控制帧走 Stream 0 流 Stream 1 请求消息(客户端→服务端) HEADERS (END_HEADERS) DATA (END_STREAM) 流 Stream 3 响应消息(服务端→客户端) HEADERS (END_HEADERS) DATA (END_STREAM) 流 Stream 5 另一请求,与 1/3 交错复用 HEADERS (END_HEADERS) DATA (END_STREAM) 帧结构(9 字节帧头 + 载荷) Length (24) Type (8) Flags (8) Stream ID (31) + R HEADERS (0x1) 携带 HPACK 压缩首部块 END_HEADERS 结束首部 END_STREAM 表示无 body DATA (0x0) 承载消息体字节 受 MAX_FRAME_SIZE 限制 END_STREAM 结束消息 SETTINGS (0x4) Stream 0,连接级参数 MAX_FRAME_SIZE 等 ACK 标志确认生效

二、抓包准备与帧日志的获取

要观察帧,最直接的方式是用 Wireshark 抓取明文 HTTP/2 流量。由于浏览器普遍使用 TLS,抓包前需要让 HTTP/2 走明文:以 Chrome 为例,启动时加 --origin-to-force-quic-on 不适用于 h2,更常用的做法是使用支持 h2c 的测试服务端,例如 nghttpd 或 h2o,并让客户端以明文 h2c 连接。也可以使用 nghttp 命令行客户端,它自带帧日志输出,非常适合教学观察:

# 启动明文 HTTP/2 服务端(nghttpd),监听 8080
nghttpd 8080 server.key server.crt --no-tls

# 用 nghttp 客户端发起请求,并打印帧日志
nghttp -v http://127.0.0.1:8080/index.html

在 Wireshark 中,过滤器写 http2 即可只显示 HTTP/2 帧。若流量是 TLS 加密的,需要配置 SSLKEYLOGFILE 环境变量导出密钥,再在 Wireshark 的 TLS 协议首选项里指定该密钥日志文件,才能解密看到帧。抓包时建议同时打开“帧详情”面板,逐字段核对 Length、Type、Flags、Stream ID。

三、SETTINGS 帧:连接建立后的第一件事

TCP 连接建立后,客户端和服务端各自立即发送一个 SETTINGS 帧,Stream ID 为 0。SETTINGS 帧的载荷是一串 6 字节的参数项:2 字节标识符 + 4 字节值。常见参数包括:

标识符名称默认值作用
0x1HEADER_TABLE_SIZE4096HPACK 动态表大小
0x2ENABLE_PUSH1是否允许服务端推送
0x3MAX_CONCURRENT_STREAMS无限制对端可并发流数上限
0x4INITIAL_WINDOW_SIZE65535流级初始流量窗口
0x5MAX_FRAME_SIZE16384单帧最大载荷
0x6MAX_HEADER_LIST_SIZE无限制首部列表大小上限

收到 SETTINGS 后,对端必须回一个带 ACK 标志(Flags=0x1)的空 SETTINGS 帧确认。抓包时你会看到:客户端 SETTINGS → 服务端 SETTINGS → 客户端 SETTINGS ACK → 服务端 SETTINGS ACK。这四个帧都在 Stream 0 上,是连接级协商,与具体请求无关。如果服务端把 MAX_FRAME_SIZE 调大,后续 DATA 帧的单帧载荷就可以更大,减少帧数量,这是调优的一个切入点。

四、HEADERS 与 DATA 帧:一条消息如何被拆开

以请求 GET /index.html 为例,客户端在 Stream 1 上发送 HEADERS 帧,载荷是 HPACK 压缩后的首部块。若首部块超过 MAX_FRAME_SIZE,会拆成 HEADERS + 若干 CONTINUATION 帧,最后一个带 END_HEADERS。若请求没有 body,HEADERS 帧同时带 END_STREAM,表示该流客户端方向结束。服务端响应时,在 Stream 1 上先发 HEADERS(状态行和响应头),再发一个或多个 DATA 帧承载 HTML 字节,最后一个 DATA 帧带 END_STREAM。

这里的关键映射关系是:一条流 = 一次消息交换,一条消息 = 一个 HEADERS 帧(可能加 CONTINUATION)+ 零到多个 DATA 帧。抓包时按 Stream ID 过滤,就能把属于同一条消息的帧串起来。例如在 Wireshark 中右键某个帧选择“Follow HTTP/2 Stream”,可以只显示该流的帧序列,HEADERS 在前、DATA 在后,END_STREAM 收尾,顺序一目了然。

五、多路复用:帧在连接上如何交错

HTTP/2 的性能优势来自多路复用。假设客户端同时请求 /a.css(Stream 1)、/b.js(Stream 3)、/c.png(Stream 5),抓包看到的帧顺序可能是:Stream 1 HEADERS、Stream 3 HEADERS、Stream 5 HEADERS、Stream 1 DATA、Stream 3 DATA、Stream 1 DATA、Stream 5 DATA……不同流的帧交错出现在同一条 TCP 连接上,接收端按 Stream ID 归位重组。这正是 HTTP/1.1 队头阻塞的解法:一个流的 DATA 帧被延迟,不会阻塞其他流的 HEADERS 或 DATA。

但要注意,TCP 层仍是单一有序字节流,若某个 TCP 段丢失,所有流都会等待重传,这就是 TCP 层队头阻塞,也是 HTTP/3 改用 QUIC 的动机。观察帧日志时,可以统计同一连接上并发流的数量、各流 DATA 帧的分布,判断多路复用是否真正生效。

抓包观察建议:先用 nghttp -v 看帧日志建立整体印象,再用 Wireshark 看字节级结构。重点核对三处:SETTINGS 的参数值、HEADERS 的 END_HEADERS/END_STREAM 标志、DATA 帧的 Stream ID 与长度。把这三处看熟,流、消息、帧的映射关系就清楚了。

六、从帧日志判断常见问题

帧日志不仅能看结构,还能辅助排障。若看到大量 CONTINUATION 帧,说明首部块偏大,可能是 Cookie 过多或未启用 HPACK 动态表,应考虑精简首部。若 DATA 帧数量异常多且每帧都很小,可能是发送端未按 MAX_FRAME_SIZE 聚合,或流量窗口过小导致频繁等待 WINDOW_UPDATE。若看到 RST_STREAM,说明某条流被主动取消,常见于客户端超时或服务端拒绝。若长时间没有 SETTINGS ACK,说明对端未正确响应,连接可能异常。把这些帧类型与标志位和实际现象对应起来,二进制分帧层就不再是黑盒。

最后强调一点:帧是 HTTP/2 的“原子”,流是“通道”,消息是“业务单元”。抓包时先看 Stream ID 分组,再看帧类型和标志位,最后看载荷长度和内容,这个顺序能帮你快速还原一次完整的 HTTP/2 交互。