在上一篇实验中,我们看到了 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 则将所有数据分解为更小的二进制帧。
三个关键概念:
- 帧(Frame):HTTP/2 通信的最小单位。每个帧包含帧头(标识长度、类型、标志位、流 ID)和帧体。
- 流(Stream):已建立的 TCP 连接内的双向字节流,可以承载一条或多条消息。每个流有唯一的整数 ID。
- 消息(Message):完整的 HTTP 请求或响应,由一个或多个帧组成。
HTTP/2 帧格式(9 字节帧头 + 可变长度帧体):
| 偏移 | 长度 | 字段 |
|---|---|---|
| 0-2 | 24bit | Length(帧体长度) |
| 3 | 8bit | Type(帧类型) |
| 4 | 8bit | Flags(标志位) |
| 5-8 | 1+31bit | R(保留位)+ Stream Identifier |
| 9+ | 变长 | Frame Payload |
帧类型(Type 字段):
| 值 | 类型 | 说明 |
|---|---|---|
| 0x00 | DATA | 请求/响应体数据 |
| 0x01 | HEADERS | 请求/响应头部 |
| 0x02 | PRIORITY | 流优先级 |
| 0x03 | RST_STREAM | 终止流 |
| 0x04 | SETTINGS | 连接配置 |
| 0x05 | PUSH_PROMISE | 服务器推送承诺 |
| 0x06 | PING | 心跳检测 |
| 0x07 | GOAWAY | 关闭连接 |
| 0x08 | WINDOW_UPDATE | 流量控制 |
| 0x09 | CONTINUATION | 延续头部块 |
1.2 多路复用:单连接并行传输
多路复用是 HTTP/2 最直观的性能提升。在 HTTP/1.1 中,即使使用持久连接和管道化,浏览器通常也会对每个域名开启 6 个 TCP 连接来并行加载资源。HTTP/2 只需要一个 TCP 连接就能并行传输所有资源。
多路复用的优势:
- 消除队头阻塞:每个流独立处理,一个流的延迟不影响其他流
- 减少连接数:单个 TCP 连接承载所有请求,减少 TCP 握手开销
- 更高效的 TCP 利用:单一长连接让 TCP 拥塞控制更稳定
1.3 头部压缩:HPACK 算法
HTTP/1.x 的头部是纯文本,每次请求都完整传输。假设一个请求头部有 800 字节,100 个请求就要传输 80KB 的头部数据:其中大部分是重复的。
HPACK 是 HTTP/2 的头部压缩算法,核心思想是:
- 静态字典:预定义 61 个常用头部名称和值,如
:method GET、:status 200、content-type text/html - 动态字典:连接期间维护的增量字典,存储之前传输过的头部
- Huffman 编码:对头部值进行 Huffman 压缩
静态字典示例(索引号 → 名称/值):
| 索引 | 名称 | 值 |
|---|---|---|
| 1 | ||
| 2 | GET | |
| 3 | POST | |
| 4 | / | |
| 5 | /index.html | |
| … | … | … |
| 14 | 200 | |
| 15 | 204 | |
| 16 | 206 | |
| … | … | … |
| 33 | date | |
| 34 | etag | |
| 35 | location | |
| … | … | … |
| 61 | user-agent |
压缩示例:
原始 HTTP/1.1 请求头:
:method: GET:path: /index.html:host: example.comuser-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.css 和 main.js,因为服务器知道 HTML 通常会引用这些资源。
注意:服务器推送需要客户端接受(现代浏览器支持 RST_STREAM 拒绝不需要的推送),且 HTTP/3 中服务器推送的支持变得可选。
服务器推送在理论上很美好,实际使用中问题不少:浏览器实现复杂、推送缓存难以管理、客户端可能已有缓存导致白推、与现代预加载策略冲突。这些问题让服务器推送在 HTTP/3 中被移除,取而代之的是 103 Early Hints 状态码。这是下一篇 HTTP/3 的内容。
1.5 流优先级
HTTP/2 允许客户端为每个流指定优先级,确保关键资源(如 CSS、HTML)优先传输。
优先级通过两种机制表达:
- 依赖关系:一个流可以依赖另一个流,形成依赖树
- 权重:依赖同一父流的子流之间按权重分配资源
规则:
- 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 learningimport asyncioimport 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输出示例:
HTTP Version: HTTP/2Status: 200Headers: {'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 版本而异):
[ 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从输出可以清晰看到:
- 连接建立后首先发送
SETTINGS帧(协商连接参数) - 然后发送
HEADERS帧(请求头) - 服务器返回
SETTINGS、HEADERS、DATA帧 END_STREAM标志表示流结束
三、实验二:用 Wireshark 抓包分析 HTTP/2 帧
HTTP/2 通常运行在 TLS 之上(h2),但可以用 nghttp2 的 nghttpd 服务器在明文模式下运行 HTTP/2,方便抓包分析。
#!/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")# 创建测试目录和文件
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/# 方法 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 performanceimport asyncioimport timeimport 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())实际加速比取决于网络延迟和并发请求数。在低延迟网络(如同一机房)中,即使 20 个并发请求,HTTP/1.1 和 HTTP/2 的差距也不大,因为 TCP 连接建立本身就很快。HTTP/2 的多路复用优势在高延迟(跨国访问、移动网络)、高丢包、大量并发请求(50+)的场景下才显著体现。
运行结果示例(20 个并发请求,低延迟网络下差异不大):
=== HTTP/1.1 vs HTTP/2 Performance Comparison ===Target: https://nghttp2.orgResources 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 msHTTP/2 average: 1298.51 msSpeedup: 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 scenariosimport asyncioimport timeimport 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())如果你有条件,可以在高延迟网络(比如跨国服务器)上测试。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 compressionimport 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 等重复头部。")输出示例:
=== 第一次请求 ===HTTP/1.1 头部大小: 268 bytesHPACK 编码后: 127 bytes压缩率: 2.1x
=== 第二次请求(动态表已有条目)===HTTP/1.1 头部大小: 254 bytesHPACK 编码后: 48 bytes压缩率: 5.3x第一次请求的压缩主要来自静态字典和 Huffman 编码(2.1x)。第二次请求的压缩率显著提高(5.3x),因为动态表已经缓存了 user-agent、accept-encoding 等重复头部,后续请求只需引用索引号,几个字节就能表达原本几十字节的头部。这就是 HPACK 的”增量编码”:连接越久,压缩率越高。
六、HTTP/2 的局限与 HTTP/3 的诞生
HTTP/2 解决了 HTTP 层面的队头阻塞,但 TCP 层面的队头阻塞依然存在。当 TCP 包丢失时,整个 TCP 连接上的数据传输都会被阻塞,直到丢失的包被重传。
这就是 HTTP/3 使用 QUIC(基于 UDP)的原因:在传输层也实现多路复用,彻底消除队头阻塞。
七、观察总结
HTTP/2 将 HTTP 从文本协议转变为二进制协议,这是协议设计层面的一次根本性转变。帧、流、消息三层抽象让多路复用成为可能,HPACK 把头部开销压缩到原来的 10%-15%,服务器推送和流优先级则补上了资源调度的短板。
但 HTTP/2 的多路复用只解决了应用层的队头阻塞,TCP 层面的问题依然存在。丢一个 TCP 包,整条连接上所有流都得等重传。这正是 HTTP/3 放弃 TCP、转向 QUIC 的直接原因。
参考资料
- RFC 7540 - HTTP/2 协议规范
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






