在 TLS与安全通道 中,看到了 TLS 如何在 TCP 之上构建加密通道——握手完成之后,应用层协议就可以在这个通道上安全传输数据。这个应用层协议,绝大多数情况下就是 HTTP。
但 HTTP 并非一成不变。从 1991 年 Tim Berners-Lee 写下的那个只能获取 HTML 的简单协议,到今天 HTTP/3 基于 QUIC 的多流并发传输,HTTP 经历了三十多年的演进。每一次版本更迭都指向同一个目标:减少延迟,提高连接利用率。
本章从 HTTP/0.9 的极简设计出发,逐代分析每个版本要解决的问题、引入的机制和遗留的缺陷,最后通过动手实践观察不同 HTTP 版本的真实行为差异。
一、HTTP/0.9 与 HTTP/1.0
1.1 HTTP/0.9:一行协议
1991 年的 HTTP/0.9 简单到令人难以置信:客户端发送一行 ASCII 文本作为请求,服务器返回 HTML 文档作为响应,然后连接关闭。
GET /index.html没有请求头,没有响应头,没有状态码,没有版本号。服务器只能返回 HTML,不支持其他内容类型。请求-响应-断开,就这么简单。
这种极简设计在当时完全够用——万维网只是 CERN 内部的文档共享系统,页面里嵌几张图片都算奢侈。但很快,人们需要传输图片、表单、视频,需要告诉客户端”这个响应是什么格式”,需要区分成功和失败。
1.2 HTTP/1.0:补上缺失的元数据
1996 年的 HTTP/1.0(RFC 1945)给请求和响应加上了头部字段,让协议具备了表达元数据的能力:
GET /index.html HTTP/1.0Host: www.example.comUser-Agent: Mozilla/5.0Accept: text/htmlHTTP/1.0 200 OKContent-Type: text/htmlContent-Length: 1234
<html>...关键改进包括:
- 版本号:请求行中标注
HTTP/1.0,让通信双方明确协议版本 - 状态码:响应行包含状态码和原因短语(如
200 OK、404 Not Found) - 请求头与响应头:
Content-Type标识响应体的媒体类型,Content-Length标识响应体长度 - 通用头部:
Date、Server、Last-Modified等提供缓存和元信息支持
但 HTTP/1.0 有一个致命问题:每个请求都需要建立新的 TCP 连接。一次 TCP 连接只能传输一个请求-响应对,传输完毕后连接关闭。一个典型网页可能包含几十个资源——HTML、CSS、JavaScript、图片——每个资源都要经历完整的 TCP 三次握手和 TLS 握手(如果是 HTTPS),延迟开销巨大。
HTTP/1.0 规范本身没有定义持久连接,但很多实现支持非标准的 Connection: Keep-Alive 头部来复用连接。这只是一个临时补丁,不是协议的正式行为。
二、HTTP/1.1 持久连接
2.1 Keep-Alive:连接复用
HTTP/1.1(RFC 2068,后更新为 RFC 2616)最核心的改进就是持久连接(persistent connection)——默认情况下 TCP 连接在请求-响应完成后不关闭,后续请求可以复用同一个连接。
GET /style.css HTTP/1.1Host: www.example.comConnection: keep-aliveHTTP/1.1 200 OKContent-Type: text/cssContent-Length: 5678Connection: keep-alive
body { ... }同一个 TCP 连接上,客户端可以连续发送多个请求,省去了重复握手的开销。HTTP/1.1 默认启用持久连接,只有显式发送 Connection: close 才会断开。
持久连接带来的收益是显著的:
| 场景 | HTTP/1.0(每请求新建连接) | HTTP/1.1(持久连接) |
|---|---|---|
| 加载 6 个资源 | 6 次 TCP 握手 + 6 次 TLS 握手 | 1 次 TCP 握手 + 1 次 TLS 握手 |
| 首个资源延迟 | 1 RTT(TCP)+ 2 RTT(TLS 1.2)+ 1 RTT(HTTP) | 同左 |
| 后续资源延迟 | 同上(每次重新握手) | 仅 1 RTT(HTTP 请求-响应) |
2.2 管道化:理想与现实的差距
持久连接解决了连接复用问题,但请求-响应仍然是串行的——客户端必须等上一个响应返回后才能发送下一个请求。如果第一个请求的响应慢了,后续所有请求都得排队。
HTTP/1.1 引入了管道化(pipelining):允许客户端在收到前一个响应之前就发送下一个请求,将多个请求”管道”式地推到服务器端:
管道化看起来很美,但实际部署中几乎没人用。问题出在队头阻塞(Head-of-Line Blocking)——服务器必须按请求的顺序返回响应。如果请求 A 的处理耗时 500ms,请求 B 和 C 即使已经处理完毕,也必须等 A 的响应发完才能发送。
更糟糕的是,中间代理和 CDN 对管道化的支持参差不齐,导致实际部署中经常出现请求丢失、响应错乱等问题。大多数浏览器最终选择禁用管道化,转而采用多连接策略——浏览器对同一域名同时打开 6 个 TCP 连接,用连接级别的并行来绕过请求级别的串行。
2.3 其他重要改进
HTTP/1.1 还引入了几个影响深远的特性:
Host 头部:允许同一 IP 地址上托管多个域名(虚拟主机),这是现代共享主机和 CDN 的基础:
GET /index.html HTTP/1.1Host: www.example.com分块传输编码(Chunked Encoding):当响应体在生成时无法确定总长度时,服务器可以分块发送,每块前标注该块长度:
HTTP/1.1 200 OKTransfer-Encoding: chunked
7\r\nMozilla\r\n9\r\nDeveloper\r\n7\r\nNetwork\r\n0\r\n\r\n缓存机制:ETag、If-None-Match、Cache-Control 等头部提供了更精细的缓存控制,减少了不必要的重复传输。
三、HTTP/2 多路复用
3.1 从文本到二进制:帧层重构
HTTP/1.x 的消息是纯文本格式,用换行符分隔头部和正文。这种格式对人眼友好,但对解析器不友好——需要处理各种边界情况(行结束符、头部折叠、编码问题等)。
HTTP/2 彻底重构了消息的传输格式,引入了二进制帧层(binary framing layer)。所有通信都在帧(frame)中进行,帧有固定的格式:
+-----------------------------------------------+| Length (24) |+---------------+---------------+---------------+| Type (8) | Flags (8) |+-+-------------+---------------+-------------------------------+|R| Stream Identifier (31) |+=+=============================================================+| Frame Payload (0...) |+---------------------------------------------------------------+| 字段 | 长度 | 说明 |
|---|---|---|
| Length | 24 bit | 帧载荷长度(不含帧头) |
| Type | 8 bit | 帧类型(DATA、HEADERS、SETTINGS 等) |
| Flags | 8 bit | 帧标志位(含义取决于帧类型) |
| R | 1 bit | 保留位,必须为 0 |
| Stream Identifier | 31 bit | 流标识符,标识帧属于哪个流 |
| Frame Payload | 可变 | 帧载荷内容 |
HTTP/2 定义了以下核心帧类型:
| 帧类型 | Type 值 | 用途 |
|---|---|---|
| DATA | 0x0 | 传输请求/响应体数据 |
| HEADERS | 0x1 | 传输请求/响应头部(含伪头部) |
| PRIORITY | 0x2 | 指定流的优先级和依赖关系 |
| RST_STREAM | 0x3 | 终止一个流 |
| SETTINGS | 0x4 | 通信双方的配置参数协商 |
| PUSH_PROMISE | 0x5 | 服务器推送资源的承诺 |
| PING | 0x6 | 往返延迟测量与存活检测 |
| GOAWAY | 0x7 | 通知对端停止创建新流 |
| WINDOW_UPDATE | 0x8 | 流控窗口更新 |
| CONTINUATION | 0x9 | HEADERS 帧的续帧 |
3.2 流、消息与帧
HTTP/2 引入了三个核心概念:
- 帧(Frame):HTTP/2 通信的最小单位,每个帧都属于某个流
- 消息(Message):一个完整的请求或响应,由一个或多个帧组成
- 流(Stream):连接内的双向字节流,承载一条完整的请求-响应消息
一个 TCP 连接上可以同时存在多个流,每个流有唯一的流 ID。客户端发起的流使用奇数 ID,服务器发起的流使用偶数 ID。不同流的帧可以交错发送,接收端根据流 ID 重新组装:
与 HTTP/1.1 管道化的关键区别:流之间相互独立,不需要按顺序返回。Stream 3 的响应可以先于 Stream 1 完成,客户端根据流 ID 将帧分配到对应的流中重组,互不干扰。
3.3 HPACK 头部压缩
HTTP/1.x 的头部是纯文本,每次请求都要重复发送大量相同的头部字段(如 User-Agent、Cookie、Accept),浪费带宽。一个典型的 HTTP/1.1 请求头部可能超过 800 字节,其中大部分内容与上一个请求完全相同。
HTTP/2 引入了 HPACK 头部压缩算法(RFC 7541),核心思路是:
- 静态表:预定义 61 个常见头部字段(如
:method: GET、:path: /、content-type: text/html),用索引号替代完整字符串 - 动态表:通信双方各自维护一个 FIFO 队列,记录之前发送过的头部字段,后续请求只需发送索引号
- 哈夫曼编码:对字符串值进行哈夫曼编码,进一步压缩
# HTTP/1.1:每次请求发送完整头部(约 800 字节):method: GET:path: /style.css:scheme: httpshost: www.example.comuser-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...accept: text/css,*/*;q=0.1accept-language: zh-CN,zh;q=0.9,en;q=0.8cookie: session_id=abc123def456ghi789...
# HTTP/2 + HPACK:大部分头部用索引号替代(约 50 字节):method: GET → 静态表索引 2:path: /style.css → 动态表索引 62(假设已缓存):scheme: https → 静态表索引 7host: www.example.com → 动态表索引 63cookie: ... → 增量编码 + 哈夫曼3.4 服务器推送
HTTP/2 的**服务器推送(Server Push)**允许服务器在客户端请求之前主动发送资源。当客户端请求 index.html 时,服务器知道页面还需要 style.css 和 app.js,可以主动推送这些资源,省去客户端解析 HTML 后再发起请求的往返:
# 客户端请求GET /index.html HTTP/2
# 服务器响应 index.html 的同时推送关联资源PUSH_PROMISE Stream 2: /style.cssPUSH_PROMISE Stream 4: /app.jsHEADERS Stream 1 → index.html 响应DATA Stream 1 → index.html 内容HEADERS Stream 2 → style.css 响应DATA Stream 2 → style.css 内容HEADERS Stream 4 → app.js 响应DATA Stream 4 → app.js 内容不过服务器推送在实践中争议很大——服务器很难准确判断客户端是否真的需要推送的资源(客户端可能已有缓存),推送不当反而浪费带宽。Chrome 后来在 HTTP/2 中默认禁用了服务器推送,HTTP/3 更是将其移除。
3.5 流优先级
HTTP/2 允许客户端为每个流指定优先级(priority)和依赖关系(dependency),告诉服务器哪些资源更重要,应该优先发送。例如,CSS 的优先级高于图片,关键渲染路径上的资源优先级高于懒加载资源:
# PRIORITY 帧示例Stream 3 依赖 Stream 1,权重 256(高优先级——CSS)Stream 5 依赖 Stream 1,权重 16(低优先级——图片)Stream 7 依赖 Stream 3,权重 128(中优先级——JavaScript)优先级信息通过 PRIORITY 帧传递,形成一棵依赖树。服务器根据这棵树调度帧的发送顺序。但优先级只是”建议”,服务器可以选择忽略——实际部署中,很多服务器和 CDN 对优先级的支持并不完善。
四、HTTP/2 的队头阻塞
3.1 TCP 层的队头阻塞
HTTP/2 的多路复用解决了应用层的队头阻塞——多个流可以交错发送,不再需要按顺序返回响应。但所有流都复用同一条 TCP 连接,传输层的队头阻塞依然存在。
当 TCP 连接上发生丢包时,问题就暴露了:
TCP 保证字节流的有序交付。一旦某个段丢失,后续所有已到达的段都必须在 TCP 接收缓冲区中等待,直到丢失的段重传成功。对于 HTTP/2 来说,这意味着一个流的丢包会阻塞所有流的交付——即使 Stream 5 的数据已经完整到达,只要 Stream 3 的某个 TCP 段丢了,Stream 5 的数据也无法被应用层读取。
4.2 丢包放大的影响
在 HTTP/1.1 中,6 个 TCP 连接中某个连接丢包,只影响那一个连接上的请求。但在 HTTP/2 中,所有请求共享一个连接,一次丢包的影响被放大到所有流上。
丢包率越高,HTTP/2 的性能退化越严重。在高丢包率环境(如移动网络,丢包率 1%-5%)下,HTTP/2 的性能可能反而不如 HTTP/1.1 的多连接方案。Google 2017 年的测试数据显示,在 1% 丢包率下,HTTP/2 的页面加载时间比 HTTP/1.1 慢约 10%-20%。
这就是 HTTP/2 的根本矛盾:多路复用解决了应用层队头阻塞,却让传输层队头阻塞的影响范围从单个请求扩大到了所有请求。
五、HTTP/3 over QUIC
5.1 换掉 TCP
HTTP/3 的核心决策是将传输层从 TCP 换成 QUIC。QUIC 在 QUIC与HTTP/3 中已有详细讨论,这里聚焦它如何解决 HTTP/2 的队头阻塞问题。
QUIC 的流是真正独立的——每个流有自己的流级序号和确认机制。一个流的丢包不会影响其他流的数据交付:
QUIC 在 UDP 之上实现了自己的可靠传输机制。每个 QUIC 流维护独立的包序号空间,丢包重传只影响丢包所在的流,其他流可以继续交付数据。
5.2 QPACK 头部压缩
HTTP/3 用 QPACK(RFC 9204)替代了 HPACK。两者的核心思路相同(静态表 + 动态表 + 哈夫曼编码),但设计上有重要差异:
| 特性 | HPACK(HTTP/2) | QPACK(HTTP/3) |
|---|---|---|
| 静态表条目数 | 61 | 99(新增 :status 421 等) |
| 动态表更新 | 与数据帧交错发送 | 通过专用控制流发送 |
| 编码阻塞风险 | 存在(动态表引用需按序确认) | 降低(使用绝对索引,减少阻塞) |
| 确认机制 | 隐式(TCP 有序交付保证) | 显式(INSERT_COUNT、ACK) |
HPACK 依赖 TCP 的有序交付来保证动态表状态一致。但 QUIC 的流是独立的,无法依赖传输层的顺序保证。QPACK 通过引入显式确认机制和绝对索引来解决这个问题——动态表的插入需要接收方显式确认后才能被引用,避免了状态不一致。
5.3 连接迁移
TCP 连接由四元组 (源IP, 源端口, 目的IP, 目的端口) 标识。当用户从 WiFi 切换到 4G 时,源 IP 地址改变,TCP 连接断开,必须重新握手。
QUIC 使用**连接 ID(Connection ID)**标识连接,与四元组解耦。网络切换时,客户端只需在新路径上发送携带相同 Connection ID 的包,连接即可无缝迁移,无需重新握手:
# 观察 QUIC 连接迁移# 在 Chrome 中访问支持 HTTP/3 的站点chrome://net-internals/#quic
# 切换网络(WiFi → 4G)后观察连接状态# 连接 ID 不变,路径改变,连接继续5.4 版本对比总览
| 特性 | HTTP/1.1 | HTTP/2 | HTTP/3 |
|---|---|---|---|
| 传输层 | TCP | TCP | QUIC(UDP) |
| 消息格式 | 文本 | 二进制帧 | 二进制帧 |
| 多路复用 | 无(管道化失败) | 流级多路复用 | 流级多路复用 |
| 应用层队头阻塞 | 有 | 无 | 无 |
| 传输层队头阻塞 | 有(单连接) | 有(所有流共享) | 无(流独立) |
| 头部压缩 | 无 | HPACK | QPACK |
| 服务器推送 | 无 | 有(已弃用) | 无 |
| 连接建立 | 1-RTT TCP | 1-RTT TCP + 1-RTT TLS | 1-RTT QUIC(含加密) |
| 0-RTT | 不支持 | 不支持 | 支持 |
| 连接迁移 | 不支持 | 不支持 | 支持 |
| 加密 | 可选(HTTP 层无加密) | 可选(但实践中 TLS 是事实标准) | 必选(QUIC 内置加密) |
HTTP/3 的 0-RTT 存在重放攻击风险——攻击者可以截获 0-RTT 数据并重放。0-RTT 请求必须是幂等的(如 GET 请求),不应包含非幂等操作(如支付请求)。服务器应实现 0-RTT 重放保护机制,如只允许特定路径使用 0-RTT。
六、HTTP 语义不变
6.1 变的是传输,不变的是语义
从 HTTP/1.0 到 HTTP/3,协议在传输层经历了翻天覆地的变化——文本变二进制、单流变多流、TCP 变 QUIC。但 HTTP 的语义层几乎没有变化:
- 请求方法:GET、POST、PUT、DELETE、PATCH、HEAD、OPTIONS——所有版本通用
- 状态码:200、301、404、500——所有版本通用
- 请求头与响应头:
Content-Type、Cache-Control、Authorization——所有版本通用 - URI 格式:
https://example.com/path?query=value——所有版本通用
HTTP/2 和 HTTP/3 引入了**伪头部(pseudo-headers)**来替代 HTTP/1.x 请求行和状态行中的信息:
# HTTP/1.1 请求行GET /index.html HTTP/1.1Host: www.example.com
# HTTP/2、HTTP/3 伪头部:method: GET:path: /index.html:scheme: https:authority: www.example.com伪头部以冒号开头,不是真正的头部字段,而是请求行/状态行的二进制编码形式。:authority 等价于 Host 头部,:method、:path、:scheme 对应请求行的各部分,:status 对应状态码。
6.2 为什么语义不变很重要
语义不变意味着:应用层代码不需要因为 HTTP 版本升级而修改。你的 REST API、表单提交、文件上传——无论底层跑的是 HTTP/1.1、HTTP/2 还是 HTTP/3,行为完全一致。
变化的只是帧的传输方式——如何把请求和响应切成帧、如何在连接上复用、如何压缩头部。这些对应用层完全透明。浏览器和服务器在 TLS 握手阶段通过 ALPN(Application-Layer Protocol Negotiation)协商 HTTP 版本,应用层无需感知。
七、动手实践:观察 HTTP 版本差异
7.1 用 curl 测试不同 HTTP 版本
# HTTP/1.1(默认)curl -I https://www.google.com
# 强制 HTTP/2curl --http2 -I https://www.google.com
# 强制 HTTP/3(需要 curl 编译时启用 QUIC 支持)curl --http3 -I https://www.google.com
# 查看协商过程(-v 显示详细握手信息)curl -v --http2 https://www.google.com 2>&1 | grep -i "ALPN\|h2\|http/2"
# 对比不同版本的响应时间time curl --http1.1 -so /dev/null https://www.google.comtime curl --http2 -so /dev/null https://www.google.comtime curl --http3 -so /dev/null https://www.google.com7.2 Chrome DevTools 观察协议版本
# 启用 Chrome HTTP/3 支持(Chrome 87+ 默认启用)# 访问 chrome://flags/#enable-quic 确认已启用
# 在 DevTools 中查看协议版本:# 1. 打开 DevTools(F12)# 2. 切换到 Network 面板# 3. 右键列标题 → 勾选 "Protocol" 列# 4. 刷新页面,观察每个请求的协议版本(h2 或 h3)Chrome DevTools 的 Network 面板中,Protocol 列显示每个请求使用的 HTTP 版本:
| Protocol 列值 | 含义 |
|---|---|
http/1.1 | HTTP/1.1 |
h2 | HTTP/2 |
h3 | HTTP/3 over QUIC |
h3-29 | HTTP/3(QUIC 草案版本 29) |
7.3 Wireshark 抓包分析
# 抓取 HTTP/2 流量sudo tshark -i eth0 -f "tcp port 443" -w http2_capture.pcap
# 抓取 HTTP/3(QUIC)流量sudo tshark -i eth0 -f "udp port 443" -w http3_capture.pcap
# Wireshark 中分析 HTTP/2 帧# 过滤器:http2# 常用过滤器:# http2.type == 1 # HEADERS 帧# http2.type == 0 # DATA 帧# http2.streamid == 1 # 特定流的帧# http2.flags.ack == 1 # ACK 标志位
# 分析 QUIC 包# 过滤器:quic# 常用过滤器:# quic.long.packet_type == 0 # Initial 包# quic.long.packet_type == 2 # Handshake 包# quic.dcil > 0 # 包含目标 Connection ID7.4 nghttp2 工具集
nghttp2 是 HTTP/2 和 HTTP/3 的命令行调试工具集:
# 安装 nghttp2 工具sudo apt install nghttp2-client
# 发送 HTTP/2 请求并显示帧详情nghttp -v https://www.google.com
# 显示 HPACK 压缩详情nghttp -v --header-table-size=4096 https://www.google.com
# 发送多个并发请求观察多路复用nghttp -v -m 10 https://www.google.com
# h2load:HTTP/2 压测工具h2load -n 1000 -c 100 -m 10 https://www.example.com# -n 1000:总共 1000 个请求# -c 100:100 个并发连接# -m 10:每个连接 10 个并发流7.5 Nginx 配置 HTTP/2
# Nginx 启用 HTTP/2server { listen 443 ssl http2; # HTTP/2 # listen 443 quic reuseport; # HTTP/3(Nginx 1.25+) server_name www.example.com;
ssl_certificate /etc/ssl/certs/server.crt; ssl_certificate_key /etc/ssl/private/server.key;
# 通知浏览器支持 HTTP/3 add_header Alt-Svc 'h3=":443"; ma=86400';
# HTTP/2 推送(可选,已不推荐) # http2_push /style.css; # http2_push /app.js;
location / { proxy_pass http://backend; }}
# 验证 HTTP/2 是否生效# curl -I --http2 https://www.example.com# 响应头应包含:HTTP/2 2007.6 Chrome QUIC 调试
# 查看 Chrome 的 QUIC 会话信息chrome://net-internals/#quic
# 启用 QUIC 日志chrome://flags/#enable-quic
# 导出 NetLog 进行详细分析chrome://net-export/# 选择 "Include socket bytes" 获取完整的帧级数据# 用 netlog_viewer 解析导出的 JSON 文件八、本章小结
HTTP 协议三十年的演进,核心驱动力始终是减少延迟和提高连接利用率:
| 版本 | 解决的问题 | 引入的机制 | 遗留的问题 |
|---|---|---|---|
| HTTP/1.0 | 无元数据 | 头部、状态码、Content-Type | 每请求新建连接 |
| HTTP/1.1 | 连接不复用 | 持久连接、管道化、Host、分块编码 | 应用层队头阻塞、头部冗余 |
| HTTP/2 | 应用层队头阻塞、头部冗余 | 二进制帧、流多路复用、HPACK | 传输层队头阻塞(TCP 丢包影响所有流) |
| HTTP/3 | 传输层队头阻塞、握手延迟 | QUIC 传输、QPACK、连接迁移、0-RTT | 0-RTT 重放风险、UDP 受限环境 |
每一代协议都解决了上一代的核心问题,同时也暴露出新的问题。HTTP/2 解决了应用层队头阻塞,却让传输层队头阻塞的影响范围扩大;HTTP/3 彻底解决了队头阻塞,却面临 UDP 在某些网络环境中被限速或封锁的挑战。
但贯穿所有版本的一个不变量是:HTTP 语义从未改变。方法、状态码、头部、URI——这些应用层开发者每天打交道的东西,在 HTTP/1.0 和 HTTP/3 中完全一致。变化的只是帧的传输方式,对应用层完全透明。
理解了 HTTP 协议的演进逻辑,下一章 CDN与内容分发 将讨论 HTTP 如何与 CDN 配合——当服务器不在用户附近时,CDN 如何通过边缘缓存、任播路由和回源策略,将内容”搬”到离用户更近的地方,进一步缩短延迟。
参考
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






