在上一篇实验中,了解了 HTTP/1.1 引入的持久连接(Persistent Connection)和管道化(Pipelining)。这些改进缓解了 HTTP/1.0「每个请求新建连接」的性能问题,但 HTTP/1.1 仍然存在几个根本性的瓶颈:
队头阻塞(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/2 的基础。2015 年,HTTP/2 作为 RFC 7540 正式发布。
HTTP/2 的核心改进包括:
- 二进制分帧层(Binary Framing Layer):将 HTTP 消息分解为更小的帧,在 TCP 连接上交织传输
- 多路复用(Multiplexing):单个 TCP 连接上并行处理多个请求和响应
- 头部压缩(HPACK):使用索引表和 Huffman 编码大幅压缩头部
- 服务器推送(Server Push):服务器可以主动向客户端推送资源
- 流优先级(Stream Priority):客户端可以指定请求的优先级
一、核心概念详解
1.1 二进制分帧层:帧、流、消息
HTTP/2 最根本的改变是引入了二进制分帧层。HTTP/1.x 是基于文本的,请求和响应以换行符分隔;HTTP/2 则将所有数据分解为更小的二进制帧。
┌─────────────────────────────────────────────────────────────┐│ TCP Connection ││ ┌───────────────────────────────────────────────────────┐ ││ │ HTTP/2 Connection │ ││ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ ││ │ │ Stream 1│ │ Stream 3│ │ Stream 5│ │ Stream 7│ │ ││ │ │ ( Req ) │ │ ( Req ) │ │ ( Resp )│ │ ( Push )│ │ ││ │ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │ ││ │ │ │ │ │ │ ││ │ ▼ ▼ ▼ ▼ │ ││ │ ┌─────────────────────────────────────────────────┐ │ ││ │ │ Framed Binary Data │ │ ││ │ │ ┌─────┐┌─────┐┌─────┐┌─────┐┌─────┐┌─────┐ │ │ ││ │ │ │Frame││Frame││Frame││Frame││Frame││Frame│ │ │ ││ │ │ │ 1 ││ 3 ││ 5 ││ 1 ││ 3 ││ 5 │ │ │ ││ │ │ └─────┘└─────┘└─────┘└─────┘└─────┘└─────┘ │ │ ││ │ └─────────────────────────────────────────────────┘ │ ││ └───────────────────────────────────────────────────────┘ │└─────────────────────────────────────────────────────────────┘三个核心概念:
- 帧(Frame):HTTP/2 通信的最小单位。每个帧包含帧头(标识长度、类型、标志位、流 ID)和帧体。
- 流(Stream):已建立的 TCP 连接内的双向字节流,可以承载一条或多条消息。每个流有唯一的整数 ID。
- 消息(Message):完整的 HTTP 请求或响应,由一个或多个帧组成。
HTTP/2 帧格式(9 字节帧头 + 可变长度帧体):
0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+| Length (24) | Type (8) |+-+-------------+---------------+-------------------------------+|R| Stream Identifier (31) |+=+=============================================================+| Frame Payload (0...) ...+---------------------------------------------------------------+
帧类型(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 连接就能并行传输所有资源。
HTTP/1.1 管道化(响应必须按序返回):
Client Server │ │ │──── Request 1 ─────────>│ │──── Request 2 ─────────>│ │──── Request 3 ─────────>│ │ │ (处理 Request 1...) │<─── Response 1 ─────────│ │ │ (Request 2 的响应必须等 1 完成) │<─── Response 2 ─────────│ │<─── Response 3 ─────────│ │ │
HTTP/2 多路复用(响应可乱序返回):
Client Server │ │ │──── Frame (Stream 1) ──>│ │──── Frame (Stream 3) ──>│ │──── Frame (Stream 5) ──>│ │<─── Frame (Stream 3) ───│ (Stream 3 先处理完,先返回) │<─── Frame (Stream 1) ───│ │<─── Frame (Stream 5) ───│ │──── Frame (Stream 1) ──>│ (继续发送流 1 的数据) │<─── Frame (Stream 1) ───│ │ │多路复用的优势:
- 消除队头阻塞:每个流独立处理,一个流的延迟不影响其他流
- 减少连接数:单个 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 :authority2 :method GET3 :method POST4 :path /5 :path /index.html...14 :status 20015 :status 20416 :status 206...33 date34 etag35 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 通常会引用这些资源。
传统模式(客户端驱动):
Client Server │ │ │──── GET /index.html ─────────────>│ │<─── 200 OK (HTML) ────────────────│ │ │ │ (解析 HTML,发现需要 style.css) │ │ │ │──── GET /style.css ─────────────>│ │<─── 200 OK (CSS) ─────────────────│ │ │ │ (继续解析,发现需要 main.js) │ │ │ │──── GET /main.js ────────────────>│ │<─── 200 OK (JS) ──────────────────│
HTTP/2 服务器推送:
Client Server │ │ │──── GET /index.html (Stream 1) ──>│ │ │ (服务器知道 HTML 需要 CSS/JS) │<─── HEADERS (Stream 1, HTML) ─────│ │<─── PUSH_PROMISE (Stream 2, CSS) ─│ (承诺推送 CSS) │<─── PUSH_PROMISE (Stream 3, JS) ─│ (承诺推送 JS) │<─── HEADERS (Stream 2, CSS) ─────│ │<─── DATA (Stream 2, CSS body) ───│ │<─── HEADERS (Stream 3, JS) ──────│ │<─── DATA (Stream 1, HTML body) ──│ │<─── DATA (Stream 3, JS body) ────│注意:服务器推送需要客户端接受(现代浏览器支持 RST_STREAM 拒绝不需要的推送),且 HTTP/3 中服务器推送的支持变得可选。
1.5 流优先级
HTTP/2 允许客户端为每个流指定优先级,确保关键资源(如 CSS、HTML)优先传输。
优先级通过两种机制表达:
- 依赖关系:一个流可以依赖另一个流,形成依赖树
- 权重:依赖同一父流的子流之间按权重分配资源
优先级依赖树示例:
Root │ ┌───────────┼───────────┐ │ │ │ HTML (1) CSS (3) Images (5) │ │ ┌───┼───┐ │ │ │ │ │ JS (7) Fonts (9) img1 img2 img3
规则:- HTML 加载完成前,CSS 和 Images 可能部分加载- CSS 优先于 Images(因为 CSS 阻塞渲染)- JS 可能在 HTML 之后加载二、实验一:用 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 的帧级别交互:
# 安装 nghttp2 工具# Ubuntu/Debian: sudo apt install nghttp2-client# macOS: brew install nghttp2
# 使用 -v 选项显示详细信息nghttp -v https://nghttp2.org输出示例(截取关键部分):
[ 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,方便抓包分析。
步骤 1:创建测试文件和启动 HTTP/2 服务器
#!/usr/bin/env python3# http2_server.py -- Simple HTTP/2 server for learning# 需要安装: pip install "hypercorn[http2]" 或使用 nghttpd
# 更简单的方式:使用 nghttpd(nghttp2 工具包的一部分)# 创建测试文件目录import osimport 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:启动明文 HTTP/2 服务器
# 创建测试目录和文件mkdir -p www_http2echo '<html><body><h1>HTTP/2 Test</h1></body></html>' > www_http2/index.html
# 启动 nghttpd 服务器(明文 HTTP/2)nghttpd -v 8443 www_http2/步骤 3:用 Wireshark 或 tcpdump 抓包
# 方法 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"RESOURCES = [ "/httpbin/get", "/httpbin/headers", "/httpbin/ip", "/httpbin/user-agent", "/httpbin/cache",]
async def load_with_http_version(http2: bool, concurrency: int = 5): """使用指定 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())运行结果示例:
=== HTTP/1.1 vs HTTP/2 Performance Comparison ===Target: https://nghttp2.orgResources per test: 5
--- Run 1/3 ---
=== Testing with HTTP/1.1 === Resources: 5 Total bytes: 2456 Time: 456.78 ms All successful: True
=== Testing with HTTP/2 === Resources: 5 Total bytes: 2456 Time: 123.45 ms All successful: True
==================================================RESULTS SUMMARY==================================================HTTP/1.1 average: 445.23 msHTTP/2 average: 118.67 msSpeedup: 3.75x==================================================性能提升的原因:
- 多路复用:HTTP/2 在单个 TCP 连接上并行发送所有请求,避免了 HTTP/1.1 的连接管理开销
- 头部压缩:减少了传输的数据量
- 减少 TCP 连接数:HTTP/1.1 通常需要建立多个 TCP 连接来实现并行
五、实验四:观察 HPACK 头部压缩
HTTP/2 的 HPACK 压缩效果可以通过对比请求头大小来观察:
#!/usr/bin/env python3# hpack_demo.py -- Demonstrate HPACK compressionimport httpx
def show_headers_comparison(): """展示 HTTP/1.1 和 HTTP/2 的头部差异"""
# 模拟一组典型的请求头 headers = { "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "accept-encoding": "gzip, deflate, br", "accept-language": "zh-CN,zh;q=0.9,en;q=0.8", "cache-control": "max-age=0", "user-agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36", "referer": "https://example.com/", }
# HTTP/1.1 头部大小(文本格式) http1_header_size = sum(len(k) + len(v) + 4 for k, v in headers.items()) # ": " + "\r\n" print("=== HTTP/1.1 Headers (plain text) ===") for k, v in headers.items(): print(f" {k}: {v}") print(f"Total size: {http1_header_size} bytes\n")
# HTTP/2 HPACK 压缩后的大小(估算) # 静态字典可以完全匹配的头部会被压缩到 1 字节 # 部分匹配的被压缩到几字节 # Huffman 编码可以额外压缩 20-30%
print("=== HTTP/2 Headers (HPACK compressed) ===") print("After HPACK compression (estimated):") print(" :method: GET -> [index 2] = 1 byte (static table)") print(" :path: / -> [index 4] = 1 byte (static table)") print(" :scheme: https -> [index 7] = 1 byte (static table)") print(" accept: ... -> ~45 bytes (Huffman encoded)") print(" user-agent: ... -> ~35 bytes (indexed + Huffman)") print(" ...") print(f"Estimated compressed size: ~80-100 bytes") print(f"Compression ratio: ~{http1_header_size / 90:.1f}x smaller")
print("\n=== Key HPACK Features ===") print("1. Static table: 61 pre-defined common header name/value pairs") print("2. Dynamic table: stores headers seen during connection") print("3. Huffman coding: compresses string values") print("4. Differential encoding: subsequent requests reference previous headers")
if __name__ == "__main__": show_headers_comparison()输出:
=== HTTP/1.1 Headers (plain text) === accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 accept-encoding: gzip, deflate, br accept-language: zh-CN,zh;q=0.9,en;q=0.8 cache-control: max-age=0 user-agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 referer: https://example.com/Total size: 286 bytes
=== HTTP/2 Headers (HPACK compressed) ===After HPACK compression (estimated): :method: GET -> [index 2] = 1 byte (static table) :path: / -> [index 4] = 1 byte (static table) :scheme: https -> [index 7] = 1 byte (static table) accept: ... -> ~45 bytes (Huffman encoded) user-agent: ... -> ~35 bytes (indexed + Huffman) ...Estimated compressed size: ~80-100 bytesCompression ratio: ~3.2x smaller
=== Key HPACK Features ===1. Static table: 61 pre-defined common header name/value pairs2. Dynamic table: stores headers seen during connection3. Huffman coding: compresses string values4. Differential encoding: subsequent requests reference previous headers六、HTTP/2 的局限与 HTTP/3 的诞生
HTTP/2 解决了 HTTP 层面的队头阻塞,但 TCP 层面的队头阻塞依然存在。当 TCP 包丢失时,整个 TCP 连接上的数据传输都会被阻塞,直到丢失的包被重传。
HTTP/2 多路复用但仍存在 TCP 层队头阻塞:
TCP 连接│├── Stream 1: Frame 1 [OK] ──> Frame 2 [LOST] ──> Frame 3 [WAITING...]│ │├── Stream 3: Frame 1 [OK] ──> Frame 2 [WAITING...] ──┘│ (TCP 必须按序交付)│└── Stream 5: Frame 1 [WAITING...] (所有流都被阻塞)这就是 HTTP/3 使用 QUIC(基于 UDP)的原因:在传输层也实现多路复用,彻底消除队头阻塞。
七、观察总结
通过这次实验,你应该能够:
理解 HTTP/2 的核心改进:二进制分帧层将 HTTP 消息分解为帧,支持多路复用、头部压缩、服务器推送和流优先级。
掌握帧、流、消息的关系:帧是最小单位,流是双向字节流,消息是完整的 HTTP 请求/响应。一个消息可能分多个帧传输,多个流可以交错传输。
理解 HPACK 压缩原理:静态字典、动态字典和 Huffman 编码共同作用,将头部大小减少 85-90%。
能用工具观察 HTTP/2 帧:使用 nghttp -v 观察帧级别交互,用 Wireshark 分析连接建立和数据传输过程。
认识到 HTTP/2 的局限:解决了 HTTP 层的队头阻塞,但 TCP 层的队头阻塞仍存在,这推动了 HTTP/3 的诞生。
HTTP/2 标志着 HTTP 从「文本协议」向「二进制协议」的根本转变,是 Web 性能优化的。理解 HTTP/2 的设计,是理解现代 Web 性能优化的关键。
参考
- RFC 7540 — Hypertext Transfer Protocol Version 2 (HTTP/2)
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






