mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2925 字
8 分钟
HTTP/2:多路复用
2024-04-27

在上一篇实验中,我们看到了 HTTP/1.1 的 keep-alive 如何复用 TCP 连接,也看到了管道化因为队头阻塞和兼容性问题从未真正普及。HTTP/1.1 解决了连接复用,但没有解决请求复用:Keep-Alive 让一个 TCP 连接可以发多个请求,但请求仍然是串行的,响应必须按顺序返回。这个”连接复用但请求串行”的矛盾,就是 HTTP/2 多路复用的出发点。

队头阻塞(Head-of-Line Blocking):HTTP/1.1 的管道化允许在一个 TCP 连接上发送多个请求,但响应必须按顺序返回。如果第一个请求的处理时间很长,后续请求的响应都会被阻塞,即使它们已经处理完毕。

冗余的头部:HTTP/1.x 的头部是纯文本格式,每次请求都要携带完整的头部信息。一个典型的网页可能需要加载 80-100 个资源,每个请求的头部动辄几百字节,Cookie、User-Agent 等重复头部累计起来是巨大的浪费。

有限的优先级控制:HTTP/1.1 没有标准化的请求优先级机制。浏览器无法告诉服务器「先发送 CSS,再发送图片」,导致关键资源可能被非关键资源阻塞。

2012 年,Google 提出了 SPDY 协议(读作「speedy」),用于解决这些问题。SPDY 引入了二进制分帧层,将 HTTP 消息分解为帧在 TCP 连接上交织传输,实现了单个连接上的多路复用;同时用 HPACK 算法压缩头部,并允许服务器主动推送资源。这些特性被证明非常有效,最终成为 HTTP/2 的基础。2015 年,HTTP/2 作为 RFC 7540 正式发布。

一、核心概念详解#

1.1 二进制分帧层:帧、流、消息#

HTTP/2 最根本的改变是引入了二进制分帧层。HTTP/1.x 是基于文本的,请求和响应以换行符分隔;HTTP/2 则将所有数据分解为更小的二进制帧。

graph TD TCP["TCP Connection"] HTTP2["HTTP/2 Connection"] S1["Stream 1\n(Req)"] S3["Stream 3\n(Req)"] S5["Stream 5\n(Resp)"] S7["Stream 7\n(Push)"] Frames["Framed Binary Data"] F1["Frame 1"] F3a["Frame 3"] F5a["Frame 5"] F1b["Frame 1"] F3b["Frame 3"] F5b["Frame 5"] TCP --> HTTP2 HTTP2 --> S1 HTTP2 --> S3 HTTP2 --> S5 HTTP2 --> S7 S1 --> Frames S3 --> Frames S5 --> Frames S7 --> Frames Frames --> F1 Frames --> F3a Frames --> F5a Frames --> F1b Frames --> F3b Frames --> F5b

三个关键概念

  • 帧(Frame):HTTP/2 通信的最小单位。每个帧包含帧头(标识长度、类型、标志位、流 ID)和帧体。
  • 流(Stream):已建立的 TCP 连接内的双向字节流,可以承载一条或多条消息。每个流有唯一的整数 ID。
  • 消息(Message):完整的 HTTP 请求或响应,由一个或多个帧组成。

HTTP/2 帧格式(9 字节帧头 + 可变长度帧体):

偏移长度字段
0-224bitLength(帧体长度)
38bitType(帧类型)
48bitFlags(标志位)
5-81+31bitR(保留位)+ Stream Identifier
9+变长Frame Payload

帧类型(Type 字段):

类型说明
0x00DATA请求/响应体数据
0x01HEADERS请求/响应头部
0x02PRIORITY流优先级
0x03RST_STREAM终止流
0x04SETTINGS连接配置
0x05PUSH_PROMISE服务器推送承诺
0x06PING心跳检测
0x07GOAWAY关闭连接
0x08WINDOW_UPDATE流量控制
0x09CONTINUATION延续头部块

1.2 多路复用:单连接并行传输#

多路复用是 HTTP/2 最直观的性能提升。在 HTTP/1.1 中,即使使用持久连接和管道化,浏览器通常也会对每个域名开启 6 个 TCP 连接来并行加载资源。HTTP/2 只需要一个 TCP 连接就能并行传输所有资源。

sequenceDiagram participant C as Client participant S as Server Note over C,S: HTTP/1.1 管道化(响应必须按序返回) C->>S: Request 1 C->>S: Request 2 C->>S: Request 3 Note right of S: 处理 Request 1... S->>C: Response 1 Note right of S: Request 2 的响应必须等 1 完成 S->>C: Response 2 S->>C: Response 3
sequenceDiagram participant C as Client participant S as Server Note over C,S: HTTP/2 多路复用(响应可乱序返回) C->>S: Frame (Stream 1) C->>S: Frame (Stream 3) C->>S: Frame (Stream 5) S->>C: Frame (Stream 3) — Stream 3 先处理完,先返回 S->>C: Frame (Stream 1) S->>C: Frame (Stream 5) C->>S: Frame (Stream 1) — 继续发送流 1 的数据 S->>C: Frame (Stream 1)

多路复用的优势:

  • 消除队头阻塞:每个流独立处理,一个流的延迟不影响其他流
  • 减少连接数:单个 TCP 连接承载所有请求,减少 TCP 握手开销
  • 更高效的 TCP 利用:单一长连接让 TCP 拥塞控制更稳定

1.3 头部压缩:HPACK 算法#

HTTP/1.x 的头部是纯文本,每次请求都完整传输。假设一个请求头部有 800 字节,100 个请求就要传输 80KB 的头部数据:其中大部分是重复的。

HPACK 是 HTTP/2 的头部压缩算法,核心思想是:

  1. 静态字典:预定义 61 个常用头部名称和值,如 :method GET:status 200content-type text/html
  2. 动态字典:连接期间维护的增量字典,存储之前传输过的头部
  3. Huffman 编码:对头部值进行 Huffman 压缩

静态字典示例(索引号 → 名称/值):

索引名称
1
2GET
3POST
4/
5/index.html
14200
15204
16206
33date
34etag
35location
61user-agent

压缩示例:

原始 HTTP/1.1 请求头:

:method: GET
:path: /index.html
:host: example.com
user-agent: Mozilla/5.0 ...

HPACK 压缩后(伪代码表示):

[索引 2] // :method GET(静态字典)
[索引 5] // :path /index.html(静态字典)
[索引 1, "example.com"] // :authority(静态名称 + 动态值)
[索引 61, Huffman 编码值] // user-agent(静态名称 + Huffman 编码值)

压缩率可达 85-90%。

1.4 服务器推送:预加载资源#

HTTP/2 允许服务器在客户端请求之前主动推送资源。当客户端请求 index.html 时,服务器可以同时推送 style.cssmain.js,因为服务器知道 HTML 通常会引用这些资源。

sequenceDiagram participant C as Client participant S as Server Note over C,S: 传统模式(客户端驱动) C->>S: GET /index.html S->>C: 200 OK (HTML) Note left of C: 解析 HTML,发现需要 style.css C->>S: GET /style.css S->>C: 200 OK (CSS) Note left of C: 继续解析,发现需要 main.js C->>S: GET /main.js S->>C: 200 OK (JS)
sequenceDiagram participant C as Client participant S as Server Note over C,S: HTTP/2 服务器推送 C->>S: GET /index.html (Stream 1) Note right of S: 服务器知道 HTML 需要 CSS/JS S->>C: HEADERS (Stream 1, HTML) S->>C: PUSH_PROMISE (Stream 2, CSS) — 承诺推送 CSS S->>C: PUSH_PROMISE (Stream 3, JS) — 承诺推送 JS S->>C: HEADERS (Stream 2, CSS) S->>C: DATA (Stream 2, CSS body) S->>C: HEADERS (Stream 3, JS) S->>C: DATA (Stream 1, HTML body) S->>C: DATA (Stream 3, JS body)

注意:服务器推送需要客户端接受(现代浏览器支持 RST_STREAM 拒绝不需要的推送),且 HTTP/3 中服务器推送的支持变得可选。

服务器推送在理论上很美好,实际使用中问题不少:浏览器实现复杂、推送缓存难以管理、客户端可能已有缓存导致白推、与现代预加载策略冲突。这些问题让服务器推送在 HTTP/3 中被移除,取而代之的是 103 Early Hints 状态码。这是下一篇 HTTP/3 的内容。

1.5 流优先级#

HTTP/2 允许客户端为每个流指定优先级,确保关键资源(如 CSS、HTML)优先传输。

优先级通过两种机制表达:

  1. 依赖关系:一个流可以依赖另一个流,形成依赖树
  2. 权重:依赖同一父流的子流之间按权重分配资源
graph TD Root --> HTML["HTML (1)"] Root --> CSS["CSS (3)"] Root --> Images["Images (5)"] HTML --> JS["JS (7)"] CSS --> Fonts["Fonts (9)"] Images --> img1["img1"] Images --> img2["img2"] Images --> img3["img3"]

规则:

  • HTML 加载完成前,CSS 和 Images 可能部分加载
  • CSS 优先于 Images(因为 CSS 阻塞渲染)
  • JS 可能在 HTML 之后加载

实际实现中,Chrome 简化了优先级模型,从 RFC 7540 的树形依赖改为 5 级权重(非常低/低/中/高/非常高)。原因是原始模型太复杂,服务器和 CDN 的实现参差不齐,反而导致性能不如预期。HTTP/3 进一步简化了优先级设计,鼓励使用更简单的模型。

二、实验一:用 Python 观察 HTTP/2 连接建立#

由于 HTTP/2 的二进制特性,无法像 HTTP/1.x 那样用 nc 手动发送请求。需要借助 Python 的 httpx 库来观察 HTTP/2 的行为。

#!/usr/bin/env python3
# http2_client.py -- HTTP/2 client for learning
import asyncio
import httpx
async def test_http2():
# httpx 支持 HTTP/2,需要安装 httpx[http2]
async with httpx.AsyncClient(http2=True) as client:
# 发送请求到一个支持 HTTP/2 的服务器
response = await client.get("https://nghttp2.org")
print(f"HTTP Version: {response.http_version}")
print(f"Status: {response.status_code}")
print(f"Headers: {dict(response.headers)}")
print(f"Content length: {len(response.content)} bytes")
# 发送多个并发请求(多路复用)
urls = [
"https://nghttp2.org/httpbin/get",
"https://nghttp2.org/httpbin/headers",
"https://nghttp2.org/httpbin/ip",
]
tasks = [client.get(url) for url in urls]
responses = await asyncio.gather(*tasks)
print("\n=== Multiplexed requests ===")
for i, resp in enumerate(responses):
print(f"Request {i+1}: {resp.status_code} ({len(resp.content)} bytes)")
if __name__ == "__main__":
asyncio.run(test_http2())

运行前安装依赖:

pip install "httpx[http2]"
python http2_client.py

输出示例:

Terminal window
HTTP Version: HTTP/2
Status: 200
Headers: {'date': '...', 'content-type': 'text/html', ...}
Content length: 12345 bytes
=== Multiplexed requests ===
Request 1: 200 (456 bytes)
Request 2: 200 (789 bytes)
Request 3: 200 (123 bytes)

2.1 用 nghttp 工具观察帧级别交互#

nghttp 是 nghttp2 项目提供的命令行工具,可以显示 HTTP/2 的帧级别交互。安装方式:Ubuntu/Debian 执行 sudo apt install nghttp2-client,macOS 执行 brew install nghttp2

# 使用 -v 选项显示详细信息
nghttp -v https://nghttp2.org

输出示例(截取关键部分,格式因 nghttp2 版本而异):

Terminal window
[ 0.000] Connected
[ 0.000] send SETTINGS frame <length=12, flags=0x00, stream_id=0>
(niv=2)
[SETTINGS_MAX_CONCURRENT_STREAMS(0x03):100]
[SETTINGS_INITIAL_WINDOW_SIZE(0x04):65535]
[ 0.001] send HEADERS frame <length=45, flags=0x05, stream_id=1>
; END_STREAM | END_HEADERS
(padlen=0)
:method: GET
:path: /
:scheme: https
:authority: nghttp2.org
accept: */*
accept-encoding: gzip, deflate
user-agent: nghttp2/1.43.0
[ 0.050] recv SETTINGS frame <length=24, flags=0x00, stream_id=0>
(niv=4)
[SETTINGS_MAX_CONCURRENT_STREAMS(0x03):100]
...
[ 0.100] recv HEADERS frame <length=256, flags=0x04, stream_id=1>
; END_HEADERS
:status: 200
date: ...
content-type: text/html
...
[ 0.150] recv DATA frame <length=4096, flags=0x00, stream_id=1>
[ 0.160] recv DATA frame <length=4096, flags=0x00, stream_id=1>
[ 0.170] recv DATA frame <length=825, flags=0x01, stream_id=1>
; END_STREAM

从输出可以清晰看到:

  1. 连接建立后首先发送 SETTINGS 帧(协商连接参数)
  2. 然后发送 HEADERS 帧(请求头)
  3. 服务器返回 SETTINGSHEADERSDATA
  4. END_STREAM 标志表示流结束

三、实验二:用 Wireshark 抓包分析 HTTP/2 帧#

HTTP/2 通常运行在 TLS 之上(h2),但可以用 nghttp2 的 nghttpd 服务器在明文模式下运行 HTTP/2,方便抓包分析。

1
#!/usr/bin/env python3
# http2_server.py -- Simple HTTP/2 server for learning
# 需要安装: pip install "hypercorn[http2]" 或使用 nghttpd

# 更简单的方式:使用 nghttpd(nghttp2 工具包的一部分)
# 创建测试文件目录
import os
import subprocess

WWW_DIR = "www_http2"

os.makedirs(WWW_DIR, exist_ok=True)

# 创建测试文件
with open(os.path.join(WWW_DIR, "index.html"), "w") as f:
    f.write("""<!DOCTYPE html>
<html>
<head>
    <title>HTTP/2 Demo</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <h1>HTTP/2 Demo Server</h1>
    <p>This page is served over HTTP/2!</p>
    <img src="image.png" alt="demo">
    <script src="script.js"></script>
</body>
</html>""")

with open(os.path.join(WWW_DIR, "style.css"), "w") as f:
    f.write("body { font-family: sans-serif; margin: 2rem; }\nh1 { color: #333; }")

with open(os.path.join(WWW_DIR, "script.js"), "w") as f:
    f.write("console.log('HTTP/2 loaded');")

print(f"Created test files in {WWW_DIR}/")
print("\nTo start HTTP/2 server (requires nghttpd):")
print(f"  nghttpd -v 8443 {WWW_DIR}/")
print("\nThen test with:")
print("  nghttp -v http://localhost:8443/index.html")
2
# 创建测试目录和文件
mkdir -p www_http2
echo '<html><body><h1>HTTP/2 Test</h1></body></html>' > www_http2/index.html

# 启动 nghttpd 服务器(明文 HTTP/2)
nghttpd -v 8443 www_http2/
3
# 方法 1:使用 tcpdump 抓包
sudo tcpdump -i lo -w http2.pcap port 8443

# 方法 2:在另一个终端发起请求
nghttp -v http://localhost:8443/index.html

# 停止 tcpdump 后用 Wireshark 打开 http2.pcap 分析

在 Wireshark 中,你可以看到:

  • 连接前言(Client Magic):客户端发送的 HTTP/2 连接初始化字符串
  • SETTINGS 帧:连接参数协商
  • HEADERS 帧:压缩后的请求/响应头
  • DATA 帧:请求/响应体
  • WINDOW_UPDATE 帧:流量控制
  • PING 帧:心跳检测

四、实验三:对比 HTTP/1.1 和 HTTP/2 的性能#

来编写一个简单的性能测试脚本,对比加载多个资源时两种协议的差异:

#!/usr/bin/env python3
# http_comparison.py -- Compare HTTP/1.1 vs HTTP/2 performance
import asyncio
import time
import httpx
# 测试目标:一个包含多个资源请求的页面
BASE_URL = "https://nghttp2.org"
# 使用 20 个并发请求,才能体现多路复用的优势
# 5 个请求时 HTTP/1.1 和 HTTP/2 差异不大,因为连接开销占比低
RESOURCES = [
"/httpbin/get",
"/httpbin/headers",
"/httpbin/ip",
"/httpbin/user-agent",
"/httpbin/cache",
"/httpbin/cookies",
"/httpbin/response-headers",
"/httpbin/anything",
"/httpbin/delay/0",
"/httpbin/delay/0",
"/httpbin/get",
"/httpbin/headers",
"/httpbin/ip",
"/httpbin/user-agent",
"/httpbin/cache",
"/httpbin/cookies",
"/httpbin/response-headers",
"/httpbin/anything",
"/httpbin/delay/0",
"/httpbin/delay/0",
]
async def load_with_http_version(http2: bool, concurrency: int = 20):
"""使用指定 HTTP 版本加载资源"""
version = "HTTP/2" if http2 else "HTTP/1.1"
print(f"\n=== Testing with {version} ===")
async with httpx.AsyncClient(http2=http2) as client:
start = time.perf_counter()
# 并发请求所有资源
tasks = [client.get(f"{BASE_URL}{path}") for path in RESOURCES[:concurrency]]
responses = await asyncio.gather(*tasks)
end = time.perf_counter()
total_bytes = sum(len(r.content) for r in responses)
print(f" Resources: {len(responses)}")
print(f" Total bytes: {total_bytes}")
print(f" Time: {(end - start) * 1000:.2f} ms")
print(f" All successful: {all(r.status_code == 200 for r in responses)}")
return end - start
async def main():
print("=== HTTP/1.1 vs HTTP/2 Performance Comparison ===")
print(f"Target: {BASE_URL}")
print(f"Resources per test: {len(RESOURCES)}")
# 运行多次取平均
runs = 3
http1_times = []
http2_times = []
for i in range(runs):
print(f"\n--- Run {i + 1}/{runs} ---")
http1_times.append(await load_with_http_version(http2=False))
http2_times.append(await load_with_http_version(http2=True))
await asyncio.sleep(0.5) # 冷却
avg_http1 = sum(http1_times) / len(http1_times) * 1000
avg_http2 = sum(http2_times) / len(http2_times) * 1000
print("\n" + "=" * 50)
print("RESULTS SUMMARY")
print("=" * 50)
print(f"HTTP/1.1 average: {avg_http1:.2f} ms")
print(f"HTTP/2 average: {avg_http2:.2f} ms")
print(f"Speedup: {avg_http1 / avg_http2:.2f}x")
print("=" * 50)
if __name__ == "__main__":
asyncio.run(main())
Note

实际加速比取决于网络延迟和并发请求数。在低延迟网络(如同一机房)中,即使 20 个并发请求,HTTP/1.1 和 HTTP/2 的差距也不大,因为 TCP 连接建立本身就很快。HTTP/2 的多路复用优势在高延迟(跨国访问、移动网络)、高丢包、大量并发请求(50+)的场景下才显著体现。

运行结果示例(20 个并发请求,低延迟网络下差异不大):

Terminal window
=== HTTP/1.1 vs HTTP/2 Performance Comparison ===
Target: https://nghttp2.org
Resources per test: 20
--- Run 1/3 ---
=== Testing with HTTP/1.1 ===
Resources: 20
Total bytes: 2734
Time: 1246.51 ms
All successful: True
=== Testing with HTTP/2 ===
Resources: 20
Total bytes: 2734
Time: 1293.23 ms
All successful: True
==================================================
RESULTS SUMMARY
==================================================
HTTP/1.1 average: 1278.63 ms
HTTP/2 average: 1298.51 ms
Speedup: 0.98x
==================================================

本地测试中 HTTP/2 反而略慢,这是正常的。多路复用的优势不在低延迟网络中的少量请求,而在高延迟、高并发的场景。原因有三:本地 localhost 的 TCP 握手本身就很快(<1ms),HTTP/2 的帧封装反而增加了开销;20 个请求对 HTTP/1.1 来说只需要几个并行连接就能处理完;HTTP/2 的 HPACK 编解码本身有 CPU 开销。真正的加速需要更多并发请求和更高的网络延迟。

来重新设计实验,加入两个对比场景:高并发小文件 vs 单个大文件。

#!/usr/bin/env python3
# http_comparison_v2.py -- Compare HTTP/1.1 vs HTTP/2 in two scenarios
import asyncio
import time
import httpx
BASE_URL = "https://nghttp2.org"
# 场景 A:50 个小文件请求
RESOURCES_A = ["/httpbin/get"] * 50
# 场景 B:1 个大文件请求
RESOURCES_B = ["/httpbin/image/png"]
async def load_with_http_version(http2: bool, resources: list[str], label: str):
"""使用指定 HTTP 版本加载资源"""
version = "HTTP/2" if http2 else "HTTP/1.1"
print(f"\n=== {label} | {version} ===")
async with httpx.AsyncClient(http2=http2) as client:
start = time.perf_counter()
tasks = [client.get(f"{BASE_URL}{path}") for path in resources]
responses = await asyncio.gather(*tasks)
end = time.perf_counter()
total_bytes = sum(len(r.content) for r in responses)
print(f" Resources: {len(responses)}")
print(f" Total bytes: {total_bytes}")
print(f" Time: {(end - start) * 1000:.2f} ms")
print(f" All successful: {all(r.status_code == 200 for r in responses)}")
return end - start
async def main():
print("=== HTTP/1.1 vs HTTP/2: Two Scenarios ===")
print(f"Target: {BASE_URL}")
runs = 3
# 场景 A:50 个小文件
print("\n" + "=" * 50)
print("SCENARIO A: 50 small file requests")
print("=" * 50)
a_http1_times = []
a_http2_times = []
for i in range(runs):
print(f"\n--- Run {i + 1}/{runs} ---")
a_http1_times.append(await load_with_http_version(False, RESOURCES_A, "A"))
a_http2_times.append(await load_with_http_version(True, RESOURCES_A, "A"))
await asyncio.sleep(0.5)
avg_a_http1 = sum(a_http1_times) / len(a_http1_times) * 1000
avg_a_http2 = sum(a_http2_times) / len(a_http2_times) * 1000
# 场景 B:1 个大文件
print("\n" + "=" * 50)
print("SCENARIO B: 1 large file request")
print("=" * 50)
b_http1_times = []
b_http2_times = []
for i in range(runs):
print(f"\n--- Run {i + 1}/{runs} ---")
b_http1_times.append(await load_with_http_version(False, RESOURCES_B, "B"))
b_http2_times.append(await load_with_http_version(True, RESOURCES_B, "B"))
await asyncio.sleep(0.5)
avg_b_http1 = sum(b_http1_times) / len(b_http1_times) * 1000
avg_b_http2 = sum(b_http2_times) / len(b_http2_times) * 1000
# 汇总
print("\n" + "=" * 50)
print("RESULTS SUMMARY")
print("=" * 50)
print(f"Scenario A (50 small files):")
print(f" HTTP/1.1 average: {avg_a_http1:.2f} ms")
print(f" HTTP/2 average: {avg_a_http2:.2f} ms")
print(f" Speedup: {avg_a_http1 / avg_a_http2:.2f}x")
print(f"\nScenario B (1 large file):")
print(f" HTTP/1.1 average: {avg_b_http1:.2f} ms")
print(f" HTTP/2 average: {avg_b_http2:.2f} ms")
print(f" Speedup: {avg_b_http1 / avg_b_http2:.2f}x")
print("=" * 50)
if __name__ == "__main__":
asyncio.run(main())
Note

如果你有条件,可以在高延迟网络(比如跨国服务器)上测试。50 个并发请求时 HTTP/2 的优势会更明显,因为 HTTP/1.1 需要建立多个 TCP 连接来并行加载,每次握手在高延迟网络中都是显著开销。

五、实验四:观察 HPACK 头部压缩#

HTTP/2 的 HPACK 压缩效果可以用 hpack 库直接验证。安装方式:

pip install hpack
#!/usr/bin/env python3
# hpack_demo.py -- Demonstrate real HPACK compression
import hpack
# 第一次请求:所有头部都需要编码
encoder = hpack.Encoder()
headers1 = [
(':method', 'GET'),
(':path', '/index.html'),
(':scheme', 'https'),
(':authority', 'example.com'),
('user-agent', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36'),
('accept', 'text/html,application/xhtml+xml'),
('accept-encoding', 'gzip, deflate, br'),
('accept-language', 'zh-CN,zh;q=0.9,en;q=0.8'),
]
# 计算等价的 HTTP/1.1 头部大小
http1_size = sum(len(k) + len(v) + 4 for k, v in headers1) # ": " + "\r\n"
encoded1 = encoder.encode(headers1)
print(f"=== 第一次请求 ===")
print(f"HTTP/1.1 头部大小: {http1_size} bytes")
print(f"HPACK 编码后: {len(encoded1)} bytes")
print(f"压缩率: {http1_size / len(encoded1):.1f}x")
# 第二次请求:动态表已有之前的头部
headers2 = [
(':method', 'GET'),
(':path', '/style.css'),
(':scheme', 'https'),
(':authority', 'example.com'),
('user-agent', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36'),
('accept', 'text/css'),
('accept-encoding', 'gzip, deflate, br'),
('accept-language', 'zh-CN,zh;q=0.9,en;q=0.8'),
]
http1_size2 = sum(len(k) + len(v) + 4 for k, v in headers2)
encoded2 = encoder.encode(headers2)
print(f"\n=== 第二次请求(动态表已有条目)===")
print(f"HTTP/1.1 头部大小: {http1_size2} bytes")
print(f"HPACK 编码后: {len(encoded2)} bytes")
print(f"压缩率: {http1_size2 / len(encoded2):.1f}x")
print(f"\n第二次请求的压缩率显著提高,因为动态表已经缓存了 user-agent、accept-encoding 等重复头部。")

输出示例:

Terminal window
=== 第一次请求 ===
HTTP/1.1 头部大小: 268 bytes
HPACK 编码后: 127 bytes
压缩率: 2.1x
=== 第二次请求(动态表已有条目)===
HTTP/1.1 头部大小: 254 bytes
HPACK 编码后: 48 bytes
压缩率: 5.3x

第一次请求的压缩主要来自静态字典和 Huffman 编码(2.1x)。第二次请求的压缩率显著提高(5.3x),因为动态表已经缓存了 user-agent、accept-encoding 等重复头部,后续请求只需引用索引号,几个字节就能表达原本几十字节的头部。这就是 HPACK 的”增量编码”:连接越久,压缩率越高。

六、HTTP/2 的局限与 HTTP/3 的诞生#

HTTP/2 解决了 HTTP 层面的队头阻塞,但 TCP 层面的队头阻塞依然存在。当 TCP 包丢失时,整个 TCP 连接上的数据传输都会被阻塞,直到丢失的包被重传。

graph LR subgraph tcp_conn["TCP 连接"] S1_1["Stream 1: Frame 1 [OK]"] --> S1_2["Frame 2 [LOST]"] --> S1_3["Frame 3 [WAITING...]"] S3_1["Stream 3: Frame 1 [OK]"] --> S3_2["Frame 2 [WAITING...]"] S5_1["Stream 5: Frame 1 [WAITING...]"] end S1_2 -.->|TCP 必须按序交付| S3_2 S1_2 -.->|所有流都被阻塞| S5_1

这就是 HTTP/3 使用 QUIC(基于 UDP)的原因:在传输层也实现多路复用,彻底消除队头阻塞。

七、观察总结#

HTTP/2 将 HTTP 从文本协议转变为二进制协议,这是协议设计层面的一次根本性转变。帧、流、消息三层抽象让多路复用成为可能,HPACK 把头部开销压缩到原来的 10%-15%,服务器推送和流优先级则补上了资源调度的短板。

但 HTTP/2 的多路复用只解决了应用层的队头阻塞,TCP 层面的问题依然存在。丢一个 TCP 包,整条连接上所有流都得等重传。这正是 HTTP/3 放弃 TCP、转向 QUIC 的直接原因。


参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

HTTP/2:多路复用
https://blog.souloss.cn/posts/web/http/http-2/
作者
Souloss
发布于
2024-04-27
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时