土法炼钢兴趣小组的算法知识备份

【网络工程】HTTP/2 完整解剖:流、帧、HPACK 与 Server Push

文章导航

分类入口
network
标签入口
#http2#binary-framing#hpack#server-push#multiplexing

目录

HTTP/1.1 的队头阻塞(Head-of-Line Blocking)限制了 Web 性能,工程师们不得不发明域名分片、雪碧图、资源合并等”反模式”来绕过这个限制。HTTP/2(RFC 7540/9113)从根本上解决了 HTTP 层的多路复用问题,但也带来了新的工程挑战——TCP 层队头阻塞、流控复杂度、Server Push 的失败经验。

本文从工程师视角完整剖析 HTTP/2 的协议设计、内部机制和生产实践。

一、HTTP/2 的设计动机与演进

1.1 SPDY 到 HTTP/2

HTTP/2 的前身是 Google 的 SPDY 协议(2009-2015),SPDY 在 Chrome 中的成功证明了二进制帧、多路复用和头部压缩的工程价值。

时间线 事件
2009 Google 发布 SPDY 白皮书
2012 Chrome/Firefox/Nginx 支持 SPDY
2014 IETF 基于 SPDY/3.1 制定 HTTP/2 草案
2015.05 RFC 7540: HTTP/2 正式发布
2016 Chrome 移除 SPDY 支持
2022.06 RFC 9113: HTTP/2 修订版

1.2 HTTP/2 解决的核心问题

HTTP/2 针对 HTTP/1.1 的四个工程痛点:

  1. 队头阻塞:HTTP/1.1 在单个连接上必须串行处理请求-响应,一个慢响应阻塞所有后续请求。HTTP/2 通过流多路复用(Stream Multiplexing)解决。

  2. 头部冗余:HTTP/1.1 每次请求都携带完整的头部(Cookie、User-Agent 等),大量重复数据。HTTP/2 通过 HPACK 压缩解决。

  3. 仅客户端发起:HTTP/1.1 中服务端无法主动推送资源。HTTP/2 引入 Server Push(但最终失败)。

  4. 文本协议的解析开销:HTTP/1.1 的纯文本格式解析复杂且容易出错(HTTP Request Smuggling)。HTTP/2 使用二进制帧格式。

1.3 HTTP/2 的协议协商

HTTP/2 通过两种方式协商:

# 方式一:ALPN(Application-Layer Protocol Negotiation)
# 在 TLS 握手的 ClientHello 中携带协议列表
# 这是最常用的方式,要求 HTTPS

ClientHello:
  extension: ALPN
    protocol: h2        # HTTP/2
    protocol: http/1.1  # 降级选项

ServerHello:
  extension: ALPN
    protocol: h2        # 服务端选择 HTTP/2

# 方式二:HTTP Upgrade(明文 HTTP/2,简称 h2c)
# 在 HTTP/1.1 请求中通过 Upgrade 头协商
GET / HTTP/1.1
Host: example.com
Connection: Upgrade, HTTP2-Settings
Upgrade: h2c
HTTP2-Settings: <base64url-encoded SETTINGS>

HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Upgrade: h2c
# 此后切换到 HTTP/2 二进制帧

生产环境中几乎都使用 ALPN + TLS 方式。明文 HTTP/2(h2c)主要用于内网服务间通信和调试。

二、连接前言与 SETTINGS 帧

2.1 连接前言

HTTP/2 连接建立后,双方首先交换连接前言(Connection Preface):

# 客户端连接前言:24 字节的魔术字符串 + SETTINGS 帧
PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n    # 24 字节魔术字符串
[SETTINGS Frame]                     # 客户端设置

# 服务端连接前言:SETTINGS 帧
[SETTINGS Frame]                     # 服务端设置

客户端的 24 字节魔术字符串(Magic Octets)的设计目的是让不支持 HTTP/2 的服务端快速失败——这个字符串不是合法的 HTTP/1.1 请求。

2.2 SETTINGS 帧参数

SETTINGS 帧定义了连接级别的参数,双方独立发送、独立生效:

参数 默认值 含义
HEADER_TABLE_SIZE 4096 HPACK 动态表大小(字节)
ENABLE_PUSH 1 是否允许 Server Push
MAX_CONCURRENT_STREAMS 无限 单方向最大并行流数
INITIAL_WINDOW_SIZE 65535 流级别初始窗口大小(字节)
MAX_FRAME_SIZE 16384 最大帧载荷大小(字节)
MAX_HEADER_LIST_SIZE 无限 头部列表最大大小(字节)
# 用 nghttp2 的 nghttp 查看服务端 SETTINGS
nghttp -v https://example.com 2>&1 | grep -A 10 "SETTINGS"

# 用 curl 查看 HTTP/2 协商过程
curl -v --http2 https://example.com 2>&1 | grep -i "http/2"

2.3 SETTINGS 的工程影响

MAX_CONCURRENT_STREAMS 是最关键的参数。它决定了单个连接上可以同时处理多少个请求。

# Nginx: 设置 HTTP/2 并发流数
http2_max_concurrent_streams 128;  # 默认 128
# 太小:客户端需要排队等待,退化成类似 HTTP/1.1 的行为
# 太大:单个连接消耗过多服务端资源

# Nginx: HTTP/2 相关配置
http2_max_field_size 4k;           # 单个头字段最大值
http2_max_header_size 16k;         # 头部列表最大值
http2_chunk_size 8k;               # DATA 帧大小

三、二进制帧格式

3.1 帧结构

HTTP/2 的所有通信都通过帧(Frame)完成。每个帧包含固定的 9 字节头部和可变长度的载荷:

+-----------------------------------------------+
|                 Length (24)                     |
+-------+-+-------------------------------------+
|  Type |  Flags  |
| (8)   |  (8)    |
+-------+-+-------+-----------------------------+
|R|          Stream Identifier (31)              |
+-+---------------------------------------------+
|               Frame Payload (0...)              |
+-----------------------------------------------+

帧头部各字段:
- Length (24 bit): 帧载荷的字节数(不含 9 字节头部)
  - 默认最大 16384 字节,可通过 SETTINGS_MAX_FRAME_SIZE 调整
  - 绝对上限 2^24-1 = 16777215 字节
- Type  (8 bit):  帧类型(DATA, HEADERS, PRIORITY, RST_STREAM 等)
- Flags (8 bit):  类型相关的标志位(END_STREAM, END_HEADERS 等)
- R     (1 bit):  保留位,必须为 0
- Stream ID (31 bit): 流标识符,0 表示连接级别的帧

3.2 帧类型

HTTP/2 定义了 10 种帧类型:

类型 编码 用途 关键标志
DATA 0x0 传输请求/响应体 END_STREAM, PADDED
HEADERS 0x1 传输头部(含优先级) END_STREAM, END_HEADERS, PADDED, PRIORITY
PRIORITY 0x2 指定流优先级 (无)
RST_STREAM 0x3 终止流 (无)
SETTINGS 0x4 连接级别配置 ACK
PUSH_PROMISE 0x5 Server Push 预告 END_HEADERS, PADDED
PING 0x6 连接级别心跳/RTT 测量 ACK
GOAWAY 0x7 优雅关闭连接 (无)
WINDOW_UPDATE 0x8 流量控制窗口更新 (无)
CONTINUATION 0x9 头部续帧 END_HEADERS

3.3 DATA 帧与消息映射

HTTP/2 将 HTTP 消息映射为帧序列。一个 HTTP 请求由一个 HEADERS 帧(可能加 CONTINUATION 帧)和零个或多个 DATA 帧组成:

HTTP/1.1 请求:
POST /api/users HTTP/1.1
Host: api.example.com
Content-Type: application/json
Content-Length: 27

{"name":"alice","age":30}

HTTP/2 帧序列(Stream 1):
┌─────────────────────────────┐
│ HEADERS (Stream 1)          │
│   :method = POST            │  ← 伪头部(Pseudo-Headers)
│   :path = /api/users        │
│   :scheme = https           │
│   :authority = api.example  │
│   content-type = app/json   │  ← 普通头部
│   Flags: END_HEADERS        │
├─────────────────────────────┤
│ DATA (Stream 1)             │
│   {"name":"alice","age":30} │
│   Flags: END_STREAM         │  ← 标记请求结束
└─────────────────────────────┘

注意 HTTP/2 中没有 “Content-Length” 的硬性要求——END_STREAM 标志明确标记了消息的结束。但实现通常仍会发送 Content-Length 以支持进度显示。

四、流多路复用

4.1 流(Stream)的概念

流是 HTTP/2 连接中双向传输帧的逻辑通道。每个流有唯一的整数标识符。

多路复用意味着多个流的帧可以在同一个 TCP 连接上交错传输:

TCP 连接上的帧交错:

时间 →
┌──────┬──────┬──────┬──────┬──────┬──────┬──────┐
│ HDR  │ HDR  │ DATA │ DATA │ HDR  │ DATA │ DATA │
│ S:1  │ S:3  │ S:1  │ S:3  │ S:5  │ S:1  │ S:5  │
└──────┴──────┴──────┴──────┴──────┴──────┴──────┘

Stream 1: [HEADERS]─────[DATA]────────────[DATA]→
Stream 3: ────[HEADERS]────────[DATA]──────────→
Stream 5: ─────────────────────[HEADERS]──────[DATA]→

三个请求在同一个连接上并行传输,没有 HTTP 层队头阻塞

4.2 流状态机

每个流遵循以下状态机:

stateDiagram-v2
    [*] --> idle
    idle --> reserved_local : send PUSH_PROMISE
    idle --> reserved_remote : recv PUSH_PROMISE
    idle --> open : send/recv HEADERS
    reserved_local --> half_closed_remote : send HEADERS
    reserved_remote --> half_closed_local : recv HEADERS
    open --> half_closed_local : send END_STREAM
    open --> half_closed_remote : recv END_STREAM
    open --> closed : send/recv RST_STREAM
    half_closed_local --> closed : recv END_STREAM / send/recv RST_STREAM
    half_closed_remote --> closed : send END_STREAM / send/recv RST_STREAM
    reserved_local --> closed : send/recv RST_STREAM
    reserved_remote --> closed : send/recv RST_STREAM
    closed --> [*]

各状态的含义:

状态 发送 接收 典型场景
idle 仅 HEADERS / PRIORITY 仅 HEADERS / PRIORITY 流尚未开始
open 任何帧 任何帧 双向传输中
half-closed (local) 仅 WINDOW_UPDATE / PRIORITY / RST_STREAM 任何帧 本端发送完毕,等待对端响应
half-closed (remote) 任何帧 仅 WINDOW_UPDATE / PRIORITY / RST_STREAM 对端发送完毕,本端继续发送
closed 仅 PRIORITY 仅 PRIORITY 流已结束

4.3 多路复用的工程影响

多路复用改变了 HTTP/1.1 时代的许多最佳实践:

HTTP/1.1 最佳实践 HTTP/2 中的变化 原因
域名分片(6 个域名) 反模式:每个域名增加一次 DNS + TCP + TLS 开销 HTTP/2 单连接已支持多路复用
资源合并(CSS/JS 打包) 可选:细粒度资源更利于缓存 不再需要减少请求数
雪碧图(CSS Sprites) 反模式:一个图标变更导致整个雪碧图缓存失效 多个小图标的请求不再是问题
内联资源(Base64 in CSS) 反模式:增加主文件体积,破坏缓存 独立资源可以被缓存

但是,TCP 层的队头阻塞在 HTTP/2 中更加严重

HTTP/1.1 + 6 连接: 一个连接上的 TCP 丢包只影响该连接的请求
HTTP/2 + 1 连接:   TCP 丢包影响所有流(所有请求)

  丢包时的行为:
  Stream 1: [DATA]─[DATA]─[LOST]──────────[重传]─[DATA]→
  Stream 3: ──────────────[阻塞等待TCP重传]──────[DATA]→  ← 无辜受影响
  Stream 5: ──────────────[阻塞等待TCP重传]──────[DATA]→  ← 无辜受影响

在高丢包率网络(>2%)中,HTTP/2 可能比 HTTP/1.1 的 6 连接更慢。这正是 HTTP/3 + QUIC 要解决的根本问题。

五、HPACK 头部压缩

5.1 为什么需要头部压缩

HTTP 头部在实际场景中高度重复。一个典型的 HTTP 请求头部约 300-800 字节:

# 典型请求头部(~500 字节)
GET /api/users?page=1 HTTP/1.1
Host: api.example.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; ...) AppleWebKit/537.36
Accept: application/json, text/plain, */*
Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8
Accept-Encoding: gzip, deflate, br
Cookie: session=abc123def456...(可能数百字节)
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9...(JWT 可能上千字节)

在一个页面加载过程中,浏览器可能发送 100+ 个请求。如果每个请求都携带完整头部,头部流量可达 50-100 KB。HPACK 通过静态表、动态表和 Huffman 编码三重机制压缩头部。

5.2 HPACK 的三重压缩机制

静态表(Static Table):预定义 61 个常见头字段,用索引号引用:

索引 名称
1 :authority (空)
2 :method GET
3 :method POST
4 :path /
5 :path /index.html
6 :scheme http
7 :scheme https
8 :status 200
61 www-authenticate (空)

动态表(Dynamic Table):连接级别的 FIFO 缓存。发送过的头字段被加入动态表,后续请求只需发送索引号:

第一个请求: 发送完整头部
  :method: GET                    → 静态表索引 2(1 字节)
  :path: /api/users               → 字面值(~12 字节)
  :authority: api.example.com      → 字面值(~18 字节)
  cookie: session=abc123...        → 字面值(~30 字节)

第二个请求: 大部分头部用索引引用
  :method: POST                   → 静态表索引 3(1 字节)
  :path: /api/users               → 动态表索引 62(1 字节)← 命中!
  :authority: api.example.com      → 动态表索引 63(1 字节)← 命中!
  cookie: session=abc123...        → 动态表索引 64(1 字节)← 命中!
  content-type: application/json   → 字面值(~25 字节)

# 第二个请求的头部从 ~60 字节压缩到 ~29 字节

Huffman 编码:对字面值使用基于 HTTP 头部字符频率的静态 Huffman 编码,通常能压缩 25-30%。

5.3 HPACK 安全性与 CRIME 攻击

HPACK 的设计特别考虑了 CRIME 攻击的教训。CRIME(Compression Ratio Info-leak Made Easy)利用 TLS 压缩泄露 Cookie 等敏感信息——攻击者通过注入已知文本,观察压缩后大小的变化来逐字节猜出 Cookie。

HPACK 的安全设计:

  1. 不使用通用压缩算法(如 gzip/DEFLATE),避免 CRIME 类攻击
  2. 头字段独立压缩,不跨字段匹配
  3. 提供 never-indexed 标志,敏感头字段(如 Cookie、Authorization)可以标记为”从不索引”
# HPACK 编码中的 never-indexed 标志
# 前缀 0001xxxx 表示"字面值,从不索引"
# 即使中间代理也不得将这个头字段加入动态表

0001 0000  # Literal Header Field Never Indexed
[Name]     # 头字段名
[Value]    # 头字段值(不会被加入任何表)

5.4 HPACK 的工程限制

动态表大小由 SETTINGS_HEADER_TABLE_SIZE 控制(默认 4096 字节)。在连接复用程度高的场景中,增大动态表可以提升压缩率:

# Nginx 无法直接配置 HPACK 表大小
# 但可以通过减少不必要的头字段间接优化
proxy_hide_header X-Powered-By;
proxy_hide_header Server;

六、流优先级与依赖

6.1 优先级模型

HTTP/2 的原始优先级模型使用依赖树(Dependency Tree)和权重(Weight)。每个流可以声明对另一个流的依赖关系和权重(1-256)。

优先级依赖树示例(浏览器资源加载):

            Stream 0(根)
           ╱        ╲
    CSS (S:1)     JS (S:3)
    Weight: 256   Weight: 220
         │
    Image (S:5)
    Weight: 110

含义:
- CSS 和 JS 都依赖根节点(并行,但 CSS 权重更高)
- Image 依赖 CSS(CSS 加载完才加载图片)
- 服务端按权重比例分配带宽:CSS : JS ≈ 256 : 220

6.2 优先级模型的失败

HTTP/2 的优先级模型在工程上被广泛认为是失败的:

  1. 服务端实现差异巨大:Nginx 完全忽略优先级、H2O 严格执行、Apache 部分实现。客户端无法假设优先级会被尊重。

  2. 依赖树模型过于复杂:浏览器发送的优先级信号不一致。Chrome、Firefox、Safari 使用完全不同的优先级策略。

  3. 中间代理会丢弃优先级:CDN 和反向代理通常不转发优先级信息。

RFC 9218 引入了可扩展优先级方案(Extensible Priority Scheme)作为替代,使用简单的 urgency(0-7)和 incremental(布尔)两个参数:

# RFC 9218 优先级头字段
Priority: u=0           # urgency=0(最高优先级),如 HTML 文档
Priority: u=2           # urgency=2,如 CSS/JS
Priority: u=4, i        # urgency=4,增量传输,如图片
Priority: u=6           # urgency=6(低优先级),如预加载资源

七、HTTP/2 流量控制

7.1 双层流量控制

HTTP/2 实现了独立于 TCP 的应用层流量控制,分为两个层级:

两个层级使用独立的窗口,DATA 帧同时消耗两个窗口的额度。

初始状态:
  连接窗口: 65535 字节
  Stream 1 窗口: 65535 字节
  Stream 3 窗口: 65535 字节

Stream 1 发送 16384 字节 DATA:
  连接窗口: 65535 - 16384 = 49151 字节
  Stream 1 窗口: 65535 - 16384 = 49151 字节
  Stream 3 窗口: 65535 字节(不受影响)

Stream 3 发送 32768 字节 DATA:
  连接窗口: 49151 - 32768 = 16383 字节
  Stream 1 窗口: 49151 字节(不受影响)
  Stream 3 窗口: 65535 - 32768 = 32767 字节

此时连接窗口只剩 16383 字节,即使 Stream 1 和 Stream 3 各自还有窗口余量,
它们的发送总量也受连接窗口限制。

7.2 WINDOW_UPDATE 帧

接收方通过发送 WINDOW_UPDATE 帧来增加窗口大小:

# 流级别 WINDOW_UPDATE(Stream ID > 0)
WINDOW_UPDATE (Stream 1)
  Window Size Increment: 32768  # 给 Stream 1 增加 32768 字节窗口

# 连接级别 WINDOW_UPDATE(Stream ID = 0)
WINDOW_UPDATE (Stream 0)
  Window Size Increment: 65536  # 给整个连接增加 65536 字节窗口

窗口更新的时机直接影响吞吐量。如果接收方更新太慢,发送方会被阻塞在窗口耗尽状态。

7.3 初始窗口大小的工程调优

默认的 65535 字节初始窗口在高带宽-高延迟(BDP 大)的链路上会严重限制吞吐量:

带宽 100 Mbps, RTT 100 ms:
  BDP = 100 Mbps × 100 ms = 12.5 MB
  初始窗口 64 KB: 需要 ~196 次 WINDOW_UPDATE 才能填满管道
  利用率: 64 KB / 12.5 MB ≈ 0.5%

需要设置更大的初始窗口:
  INITIAL_WINDOW_SIZE = 1048576 (1 MB) 或更大
// Go: 配置 HTTP/2 初始窗口大小
import "golang.org/x/net/http2"

transport := &http2.Transport{
    // 默认 1 MB,对于高 BDP 链路可以增大
    InitialWindowSize: 4 * 1024 * 1024, // 4 MB
}

server := &http2.Server{
    // 最大并行流数
    MaxConcurrentStreams: 250,
}

八、Server Push

8.1 Server Push 的设计初衷

Server Push 允许服务端在客户端请求之前主动推送资源。其设计初衷是减少页面加载中的”瀑布效应”:

没有 Server Push:
  客户端 ─── GET /index.html ──→ 服务端
          ←── 200 + HTML ────
  (解析 HTML,发现需要 style.css 和 app.js)
  客户端 ─── GET /style.css ──→ 服务端
  客户端 ─── GET /app.js ────→ 服务端
          ←── 200 + CSS ─────
          ←── 200 + JS ──────
  总耗时:2 RTT

有 Server Push:
  客户端 ─── GET /index.html ──→ 服务端
          ←── PUSH_PROMISE (style.css)
          ←── PUSH_PROMISE (app.js)
          ←── 200 + HTML ──────
          ←── 200 + CSS ───────  (Push Stream)
          ←── 200 + JS ────────  (Push Stream)
  总耗时:1 RTT

8.2 PUSH_PROMISE 帧

PUSH_PROMISE 帧结构:
+-----------------------------------------------+
| Pad Length? (8)                                 |
+-+---------------------------------------------+
|R|         Promised Stream ID (31)              |
+-+---------------------------------------------+
|            Header Block Fragment               |
+-----------------------------------------------+
|            Padding                             |
+-----------------------------------------------+

# 服务端在 Stream 1 上发送 PUSH_PROMISE
PUSH_PROMISE (Stream 1)              # 关联到原始请求
  Promised Stream ID: 2              # 推送使用偶数 Stream ID
  :method: GET                       # 推送资源的"伪请求"
  :path: /style.css
  :scheme: https
  :authority: example.com

8.3 Server Push 的失败

Server Push 在 2022 年被 Chrome 移除支持(Chrome 106),标志着该特性的实质性死亡。失败原因:

  1. 缓存浪费:服务端不知道客户端是否已缓存该资源。推送已缓存的资源浪费带宽。

    # 客户端可以发送 RST_STREAM 取消推送
    RST_STREAM (Stream 2)
      Error Code: CANCEL (0x8)
    
    # 但此时部分数据可能已经在传输中,带宽已经浪费
  2. 与 CDN 缓存冲突:CDN 边缘节点缓存的 HTML 无法动态决定推送什么资源。

  3. 优先级混乱:推送的资源可能抢占用户真正需要的资源的带宽。

  4. 103 Early Hints 的替代:RFC 8297 提出了更简单的替代方案:

# 103 Early Hints(替代 Server Push 的推荐方案)
HTTP/2 103 Early Hints
Link: </style.css>; rel=preload; as=style
Link: </app.js>; rel=preload; as=script

HTTP/2 200 OK
Content-Type: text/html
...

103 Early Hints 只提示客户端”你可能需要这些资源”,由客户端决定是否下载,完全避免了缓存浪费问题。

8.4 Nginx Server Push 配置

虽然 Server Push 已被废弃,了解其配置有助于理解历史代码:

# Nginx: Server Push 配置(已不推荐使用)
location = /index.html {
    http2_push /style.css;
    http2_push /app.js;
}

# 或者基于 Link 头自动推送
http2_push_preload on;
# 当 upstream 响应包含 Link: <url>; rel=preload 时自动推送

九、GOAWAY 与连接管理

9.1 优雅关闭

GOAWAY 帧用于优雅关闭 HTTP/2 连接。它告诉对端”我不会接受新的流了,但已经在处理的流会继续完成”:

GOAWAY 帧结构:
+-----------------------------------------------+
|R|        Last-Stream-ID (31)                   |
+-----------------------------------------------+
|              Error Code (32)                   |
+-----------------------------------------------+
|     Additional Debug Data (optional)           |
+-----------------------------------------------+

# 正常关闭示例
GOAWAY
  Last-Stream-ID: 7     # Stream 7 及之前的流会被处理
  Error Code: NO_ERROR   # Stream 9, 11, ... 不会被处理
  Debug Data: "graceful shutdown"

# 客户端收到后:
# - Stream 1, 3, 5, 7: 继续完成
# - Stream 9, 11: 需要在新连接上重试

9.2 GOAWAY 的工程实践

# Nginx: HTTP/2 连接生命周期控制
http2_max_requests 1000;        # 连接处理的最大请求数
                                 # 达到后发送 GOAWAY
keepalive_timeout 300s;          # 空闲超时
// Go: 服务端优雅关闭
server := &http.Server{
    Addr: ":8443",
    // HTTP/2 自动启用
}

// 优雅关闭:先发送 GOAWAY,等待已有请求完成
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
server.Shutdown(ctx)
// Shutdown 会:
// 1. 停止接受新连接
// 2. 对已有 HTTP/2 连接发送 GOAWAY
// 3. 等待所有活跃请求完成或超时

9.3 连接错误与流错误

HTTP/2 区分连接错误和流错误:

类型 影响范围 处理方式 示例
连接错误 整个连接 发送 GOAWAY + 关闭 帧格式错误、流量控制违规
流错误 单个流 发送 RST_STREAM 流级超时、取消请求
# 常见错误码
NO_ERROR (0x0)          # 正常关闭
PROTOCOL_ERROR (0x1)    # 协议违规
INTERNAL_ERROR (0x2)    # 内部错误
FLOW_CONTROL_ERROR (0x3) # 流量控制违规
SETTINGS_TIMEOUT (0x4)  # SETTINGS ACK 超时
STREAM_CLOSED (0x5)     # 在关闭的流上收到帧
FRAME_SIZE_ERROR (0x6)  # 帧大小不合法
REFUSED_STREAM (0x7)    # 拒绝流(达到 MAX_CONCURRENT_STREAMS)
CANCEL (0x8)            # 取消流
COMPRESSION_ERROR (0x9) # HPACK 压缩错误
ENHANCE_YOUR_CALM (0xb) # 对端发送速率过高(限流信号)
HTTP_1_1_REQUIRED (0xd) # 需要 HTTP/1.1(如 CONNECT 方法)

ENHANCE_YOUR_CALM 是一个有趣的错误码——名字来自电影《Demolition Man》的台词。它用于告诉对端”你发得太快了,冷静一下”。

十、HTTP/2 的 Nginx 与 Go 实战配置

10.1 Nginx HTTP/2 完整配置

# /etc/nginx/conf.d/http2.conf

server {
    listen 443 ssl;
    http2 on;                            # Nginx ≥ 1.25.1 语法

    ssl_certificate     /etc/nginx/ssl/cert.pem;
    ssl_certificate_key /etc/nginx/ssl/key.pem;
    ssl_protocols TLSv1.2 TLSv1.3;

    # HTTP/2 核心参数
    http2_max_concurrent_streams 128;    # 最大并行流数
    http2_max_field_size 8k;             # 单个头字段最大值
    http2_max_header_size 32k;           # 头部列表最大值
    http2_body_preread_size 64k;         # 请求体预读大小
    http2_chunk_size 16k;                # DATA 帧大小

    # 超时设置
    http2_recv_timeout 30s;              # 接收超时
    http2_idle_timeout 3m;               # 空闲超时

    # 确保上游也使用 HTTP/2(可选)
    location /api/ {
        proxy_pass https://backend;
        proxy_http_version 1.1;          # 上游通常用 HTTP/1.1
        proxy_set_header Connection "";  # 清除 Connection 头
    }
}

# 将 HTTP 重定向到 HTTPS
server {
    listen 80;
    return 301 https://$host$request_uri;
}

10.2 Go HTTP/2 服务端

package main

import (
    "crypto/tls"
    "fmt"
    "net/http"

    "golang.org/x/net/http2"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
        // 查看当前使用的协议
        fmt.Fprintf(w, "Protocol: %s\n", r.Proto)
        // r.Proto == "HTTP/2.0" 表示 HTTP/2 连接
    })

    // TLS 配置
    tlsConfig := &tls.Config{
        MinVersion: tls.VersionTLS12,
        NextProtos: []string{"h2", "http/1.1"}, // ALPN 协议列表
    }

    server := &http.Server{
        Addr:      ":8443",
        Handler:   mux,
        TLSConfig: tlsConfig,
    }

    // 配置 HTTP/2 参数
    h2Server := &http2.Server{
        MaxConcurrentStreams: 250,
        MaxReadFrameSize:    1 << 20, // 1 MB
        IdleTimeout:         120,
    }
    http2.ConfigureServer(server, h2Server)

    server.ListenAndServeTLS("cert.pem", "key.pem")
}

10.3 HTTP/2 调试与验证

# 验证服务是否支持 HTTP/2
curl -v --http2 https://example.com 2>&1 | grep "ALPN"
# 应看到: ALPN: server accepted h2

# 使用 nghttp 详细查看 HTTP/2 帧交互
nghttp -v https://example.com

# 检查 HPACK 头部压缩效果
nghttp -v https://example.com 2>&1 | grep -E "(recv|send) HEADERS"

# 使用 h2load 做 HTTP/2 负载测试
h2load -n 10000 -c 10 -m 100 https://example.com
# -c 10: 10 个连接
# -m 100: 每个连接最多 100 个并行流
# 结果示例:
# finished in 2.53s, 3952.35 req/s, 12.45MB/s

# 在 Wireshark 中抓包分析 HTTP/2
# 需要配置 TLS 密钥日志文件
export SSLKEYLOGFILE=~/sslkeys.log
curl --http2 https://example.com
# Wireshark → Edit → Preferences → TLS → (Pre)-Master-Secret log filename
# 设置为 ~/sslkeys.log,即可解密 HTTP/2 帧

十一、HTTP/2 的已知问题与缓解

11.1 TCP 队头阻塞

HTTP/2 解决了 HTTP 层队头阻塞,但 TCP 层队头阻塞反而更严重了:

HTTP/1.1 + 6 连接:
  丢包率 2% 时,每个连接独立受影响
  整体可用性: 1 - 0.02^6 ≈ 99.999999%(极少所有连接同时受影响)

HTTP/2 + 1 连接:
  丢包率 2% 时,所有流同时受影响
  每 50 个包就有 1 个丢包 → 每 50 个包所有流暂停一次

缓解策略:

# 增加 HTTP/2 连接数(但违背了 HTTP/2 的设计初衷)
# 某些 CDN 和客户端会针对高丢包链路开多个连接

# 更好的方案:升级到 HTTP/3 + QUIC
# QUIC 在 UDP 上实现了独立流控,消除了 TCP 层队头阻塞

11.2 HPACK 压缩炸弹

恶意客户端可以发送精心构造的 HPACK 编码,在解压时消耗大量内存(压缩炸弹 Decompression Bomb)。

防御措施:
1. 设置 SETTINGS_MAX_HEADER_LIST_SIZE 限制解压后的头部大小
2. 限制 HPACK 动态表大小(SETTINGS_HEADER_TABLE_SIZE)
3. 限制头字段数量(Nginx: large_client_header_buffers)

11.3 流重置洪泛(Rapid Reset Attack)

2023 年 10 月披露的 CVE-2023-44487(HTTP/2 Rapid Reset Attack)是一种新型 DDoS 攻击。攻击者快速创建和重置流,消耗服务端资源:

攻击模式:
  客户端: HEADERS(S:1) → RST_STREAM(S:1) → HEADERS(S:3) → RST_STREAM(S:3) → ...
  
  # 每个流立即被重置,不计入 MAX_CONCURRENT_STREAMS 限制
  # 但服务端仍然需要处理每个流的创建和清理开销
  # 攻击者可以在单个连接上每秒发起数十万个流

缓解措施:

# Nginx ≥ 1.25.3 的缓解配置
http2_max_concurrent_streams 100;
limit_conn_zone $binary_remote_addr zone=addr:10m;
limit_conn addr 10;                     # 每 IP 最大连接数
limit_req_zone $binary_remote_addr zone=req:10m rate=100r/s;
limit_req zone=req burst=200 nodelay;   # 每 IP 请求速率限制

# 升级到修补版本
# Nginx ≥ 1.25.3 / Go ≥ 1.21.3 / Node.js ≥ 20.8.1

十二、总结

HTTP/2 是一次成功但不完美的协议升级。它的工程教训可以总结为以下几点:

  1. 多路复用是正确的方向,但在 TCP 上实现不够彻底。TCP 层队头阻塞让 HTTP/2 在高丢包网络中的优势打了折扣。HTTP/3 + QUIC 通过在 UDP 上实现独立流控彻底解决了这个问题。

  2. HPACK 是成功的。头部压缩在实际部署中平均减少了 85%+ 的头部流量。其安全设计(避免 CRIME 攻击)也经受了考验。HTTP/3 的 QPACK 在此基础上进一步优化。

  3. Server Push 是失败的。服务端无法准确预测客户端的缓存状态,导致推送频繁浪费带宽。103 Early Hints 是更务实的替代方案。

  4. 优先级模型过于复杂。依赖树 + 权重的模型在实际部署中被广泛忽略。RFC 9218 的简化方案(urgency + incremental)更加实用。

  5. 单连接架构是双刃剑。它减少了连接管理开销,但也意味着单点故障——一个连接出问题影响所有请求。生产环境中需要合理的连接重建和错误处理策略。

从 HTTP/1.1 迁移到 HTTP/2 的核心收益是多路复用和头部压缩,但也需要重新审视旧有的性能优化策略(域名分片、资源合并)。下一篇将深入 HTTP 缓存工程,探讨 Cache-Control 指令的全景和条件请求的工程实践。


参考文献


上一篇:HTTP/1.1 深度剖析:持久连接、管线化与队头阻塞

下一篇:HTTP 缓存工程:Cache-Control 全景与条件请求

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2025-07-31 · network

【网络工程】gRPC 深度剖析:HTTP/2 上的 RPC 框架

系统剖析 gRPC 的协议设计与工程实践:四种通信模式、HTTP/2 帧映射、Protobuf 编码效率、gRPC 负载均衡挑战(L7 vs client-side)、连接管理、拦截器、错误处理、性能调优与 gRPC-Web 的限制。

2026-09-10 · network

HTTP/3 实战:从 QUIC 到 H3 的完整请求链路

QUIC 解决了传输层的问题,但 HTTP 怎么跑在上面?HTTP/3 不是简单地把 HTTP/2 搬到 QUIC 上——帧格式变了,头部压缩换了,流控删了。这篇从 QPACK 压缩到完整请求链路,把 HTTP/3 拆干净。

2025-08-04 · network

【网络工程】QUIC 生态与工程部署:从实验到生产

QUIC 已经不是实验性协议——HTTP/3 标准化后,CDN、浏览器和主流服务端框架都在推进 QUIC 支持。本文从工程视角对比主流 QUIC 库的成熟度和性能特征,讲解 CDN/负载均衡器的 QUIC 适配方案、从 TCP 迁移到 QUIC 的渐进路径、QUIC 调试工具链,以及生产环境的部署陷阱和性能调优实践。


By .