HTTP/2

本页目录

HTTP/2 保留 HTTP 的方法、状态、缓存等语义,用帧和流表达一次连接中的多个交换。它允许不同请求的响应交错发送,解决 HTTP/1.1 响应必须依请求顺序排列的限制;底层 TCP 仍提供单一有序字节流。

现行基础规范是 RFC 9113,替代 RFC 7540。不要把它描述成重写 TCP 的传输协议,也不能把“单连接多流”理解成全站永久只开一条连接。RFC 9113

定位问题先检查
已协商 h2,为什么没看到 GET 文本请求头使用字段块与 HPACK
某个响应慢,其他流是否必须等它流调度、连接窗口和 TCP 缺口分别看
END_HEADERS 是否表示请求结束字段块结束与发送方向结束不同
窗口很大,为什么发送停住同时看流窗口和连接窗口
压缩表为何偶尔无法解码两个方向各有状态,抓包可能缺历史
GOAWAY 后哪些请求可以重试Last-Stream-ID 与业务执行不确定性

连接启动与字段表达

HTTPS 常用 TLS ALPN 协商 h2,之后客户端发送规定的连接前言及 SETTINGS;服务端也以 SETTINGS 开始其连接前言。客户端的24字节固定前言虽然看起来包含 HTTP 文本,但并不是向应用发送一次普通 HTTP/1.1 请求。

无 TLS 的 HTTP/2 可基于事先知识启动。历史 h2c Upgrade 不应当作全部 HTTP/2 的必经入口;最新基础规范已弃用该升级机制。

普通请求通过 :method、:scheme、:authority、:path 等伪字段表达目标;响应使用 :status。伪字段应位于普通字段之前,字段名使用小写;CONNECT 等特定方法有相应规则。不能把完整的 HTTP/1.1 起始行塞进 HEADERS。

Connection、Keep-Alive、Transfer-Encoding 等连接专用字段不能照搬;TE 有仅允许 trailers 的特例。Content-Length 仍可表达预期内容长度,但流由 HTTP/2 帧与 END_STREAM 定界,不能用 CL 替代流状态判断。

帧头 9 B:长度不是完整帧长度

字段长度含义
Length24 bit仅 payload 字节数
Type8 bitDATA、HEADERS、SETTINGS 等
Flags8 bit按帧类型解释
Reserved + Stream ID1 + 31 bit保留位与所属流,0 用于适用的连接控制

接收方默认最大帧 payload 为16,384 B,可通过 SETTINGS_MAX_FRAME_SIZE 在规定范围内调整,最大为16,777,215 B。这个上限不是一条响应或一个字段列表的总长度。

下面构造流1上携带5 B内容并结束本方向的 DATA 帧;完整帧14 B。代码只负责帧头和长度,不实现连接状态机。

def frame(kind, flags, stream, payload):
    assert 0 <= stream < 2**31 and len(payload) < 2**24
    return (len(payload).to_bytes(3, 'big') + bytes([kind, flags])
            + stream.to_bytes(4, 'big') + payload)

wire = frame(0, 1, 1, b'hello')
assert wire.hex() == '00000500010000000168656c6c6f'
assert len(wire) == 14
assert int.from_bytes(wire[:3], 'big') == len(wire) - 9
assert int.from_bytes(wire[5:9], 'big') & 0x7fffffff == 1
print('DATA payload / 完整帧:', len(wire) - 9, len(wire))

流状态与两种 END 标志

客户端创建的请求流使用奇数ID;服务端发起的推送流使用偶数ID,不是任意双向 RPC 都可以随便开一个偶数请求流。ID 在连接内单调使用,不会完成后回收复用;流0不是业务请求流。

END_HEADERS 表示当前压缩字段块完整,END_STREAM 表示该发送方向结束。只有请求头的 GET 可以在 HEADERS 上同时设置两者,但服务端仍能在同一流发送响应。带内容的 POST 则可能先发 HEADERS,再发多个 DATA,最后用 DATA 或 trailer HEADERS 结束。

若字段块拆到 CONTINUATION,直到 END_HEADERS 之前必须连续发送该流的对应 CONTINUATION,不能穿插其他流的 DATA。多路复用因此也有协议级连续区域,不能简单理解为任何帧都能任意插队。

多路复用:应用排队与 TCP 队头阻塞分开看

帧可以交错,TCP 缺口之后的字节仍需等待 S1 HEADERS 按 Stream ID 分派 S3 HEADERS 按 Stream ID 分派 S3 DATA 按 Stream ID 分派 S1 DATA 按 Stream ID 分派 缺失字节 后续帧即使属于其他流,也不能跳过 TCP 缺口 图中流顺序是示例;HEADERS 的未结束字段块有连续 CONTINUATION 要求。

流1的慢查询尚未准备好响应时,流3可以先发送自己的响应;但发出的所有帧最终都落入同一个 TCP 序号空间。某段 TCP 字节丢失后,接收端不能把缺口之后的字节提前作为连续流交给 HTTP/2,即使它们来自另一 HTTP 流。

这不表示丢一个包会撤销所有流已经交付的数据,也不意味着帧天然对应某个 TCP 包。连接级带宽、拥塞控制、服务器调度、应用锁竞争和 HPACK 状态,也可能形成跨流影响。

双层流控:只处理 DATA,不是拥塞控制

DATA 同时消耗连接窗口与自己的流窗口 连接发送余量:10,000 B 所有流共享此方向的额度 流 1:余量 8,000 B 先发送 6,000 B DATA 流 3:余量 8,000 B 随后最多发送 4,000 B DATA 示例无 padding、帧长上限足够;流窗口还剩很多,也可能被连接窗口卡住。

对一个发送方向,DATA 同时受连接与流额度限制。默认初始额度为65,535 B;SETTINGS_INITIAL_WINDOW_SIZE 修改流窗口,不能用它增大连接窗口,连接额度通过流0 WINDOW_UPDATE 调整。反向发送有自己的额度。

DATA 的整个 payload 计入流控,包括 padding 及其长度字段,但不包括9 B帧头;HEADERS、PING、SETTINGS 等不消耗 DATA 窗口。因此仅限 DATA 并不足以限制对端的字段解码或控制帧资源消耗。

connection = 10_000
stream = {1: 8_000, 3: 8_000}

def send(sid, size):
    global connection
    assert 0 <= size <= min(connection, stream[sid])
    connection -= size
    stream[sid] -= size

send(1, 6_000)
assert connection == 4_000 and stream[1] == 2_000
send(3, 4_000)
assert connection == 0 and stream[3] == 4_000
# 仅增加流窗口,仍不能突破连接余量。
stream[3] += 10_000
assert min(connection, stream[3]) == 0
connection += 2_000
assert min(connection, stream[3]) == 2_000
print('连接窗口更新后,流3可发送:', min(connection, stream[3]))

WINDOW_UPDATE 增量不能为零,窗口也有上限。调低 SETTINGS_INITIAL_WINDOW_SIZE 还可能使已有流窗口暂时为负,此时需要等待额度恢复,而不是把负数强制转成无符号大数。应用及时消费并释放接收预算,才能避免流控停顿。

只给一条流补额度,为什么还是发不出去?

把连接额度用尽后,只点击流3的WINDOW_UPDATE:金色条增长,发送按钮仍不可用。再补连接额度,发送才恢复。这个状态展示了两个“剩余量取最小值”的约束如何在历史操作中形成。

正在呈现知识画面
只给一条流补额度,为什么还是发不出去?

每个DATA同时扣两本账。流3仍有余额,连接归零也会暂停;HEADERS不在这两本DATA账里,但仍受其他资源约束。

HPACK:两个方向各自维护压缩状态

HPACK 使用61项静态表、动态表及可选的固定 Huffman 编码。请求方向的编码器与服务端解码器共享一份对应状态,响应方向是另一份;不是服务端把响应字段存进去,客户端下次请求就能引用。

动态表按编码指令插入条目,后插入项位于前面,旧项按容量淘汰;不是“每个 HEADERS 结束后自动存全部字段”,访问旧项也不把它提升成 LRU。条目计费是名称字节数 + 值字节数 + 32,与压缩后的线长不同。

例如 :authority: www.example.com 占10+15+32=57 B表容量;第一项动态索引为62。下一次插入新项,旧项索引就变成63。静态索引2对应 :method: GET,单字节 indexed 表达为0x82;索引62对应0xbe,但只有方向和历史状态匹配时才知道它代表什么。

Never Indexed 用于敏感字段的编码约束;它不加密字段,不隐藏总长度,也不消除所有压缩侧信道。字段表容量限制与解码后字段列表大小限制是不同的资源控制。RFC 7541:HPACK

索引62为什么下一次指向别的字段?

连续插入两项后,62指向后插入者,先前的字段变成63。再引用62并观察表顺序没有变化。缩小容量后回调滑块,已经淘汰的项不会复活,因为容量限制与实际历史状态是两个变量。

正在呈现知识画面
索引62为什么下一次指向别的字段?

索引是当前方向压缩状态中的位置,不是字段永久编号。插入使旧索引后移,容量按name+value+32计费,读取不执行LRU提升。

SETTINGS、优先级与资源边界

SETTINGS 描述发送端愿意接收的约束,对端据此调整自己的发送。例如客户端宣告最大帧长度,限制服务端向客户端发送的帧;不能反过来解释成客户端自己的发送许可。带 ACK 的 SETTINGS 不再携带设置项。

MAX_CONCURRENT_STREAMS 限制相应方向可并发的流,不是连接历史累计请求数。只控制瞬时并发仍可能承受快速建流/取消造成的工作量,应同时关注请求成本、排队、字段大小与取消传播。

旧的优先级依赖树已在 RFC 9113 中弃用;RFC 9218 定义可扩展优先级,urgency为0到7、数值越小越紧急,incremental提示是否适合渐进交付。它们是调度提示,不保证固定吞吐份额,也不能突破流控窗口。RFC 9218

取消与优雅退出:不要把 RST 当事务回滚

RST_STREAM 终止一个流,并不证明应用未执行请求,也不会自动回滚数据库事务。实现仍要维护涉及连接的压缩和流控状态,不能看到某个流被取消就随意跳过所有相关帧。

GOAWAY 用 Last-Stream-ID 告知对端哪些流可能已经被处理。假设服务端最后接受的客户端流ID为5,流7可以在新连接重试;流1、3、5中未收到完整响应者仍有执行不确定性,要按方法和业务去重处理。优雅退出可以先停止新流,再等待已有流完成,不能简单收到GOAWAY就把所有请求全部重发。RFC 9113:连接退出

Server Push:协议能力与浏览器支持分开

PUSH_PROMISE 曾让服务端提前承诺并发送客户端可能需要的资源;缓存命中未知、浪费传输和调度复杂性会削弱收益。Chrome 从106开始移除 HTTP/2 Server Push 支持,但不能由此写成 HTTP/2 规范已经删除了推送帧。Chrome 移除说明

103 Early Hints 或预加载提示让客户端自己决定是否发起请求,与服务端直接推送内容不是同一机制。部署选择应依据真实客户端支持和测量结果,不能把“开启 HTTP/2”与“应当开启 push”绑在一起。

继续查阅

  • HTTP/1.1:消息边界、缓存和方法语义。
  • HTTP/3:QUIC 流与 QPACK 的依赖权衡。
  • TCP:字节序号、窗口及拥塞控制。