知行札记
专题技术实践通信协议实践HTTP 网络通信

HTTP 版本、连接与多路复用

比较文本消息、分帧、流、头部压缩及 QUIC 的保证。

本页沿 HTTP 的具体接口和通信行为展开。沿用归档中的消息与代码示例,归档所称的实测仅作为原作者历史记录;2026-10-05 整理未向外部站点发送实验请求,也未操作浏览器。命令输出随服务、网络、版本和代理条件变化。

打开一个网页,背后是浏览器和服务器之间几十上百次 HTTP 通信。HTTP 不是一天设计好的:三十多年里换了四代,每一代都在解决上一代「最疼」的问题。

先把时间线钉在墙上:

时间版本关键文档
1989–1991HTTP/0.9未标准化
1996.05HTTP/1.0RFC 1945(信息类文档)
1997.01 → 1999.06 → 2014.06HTTP/1.1RFC 2068 → 2616 → 7230–7235(三个历史文本)
2015.05HTTP/2RFC 7540(2022 年由 RFC 9113 接替)
2021.05QUIC 传输层RFC 9000(HTTP/3 的地基)
2022.061.1、2、3 现行版齐发RFC 9110+9112(属 1.1)、RFC 9113(属 2)、RFC 9114(属 3)

编号不用背,记住分工即可:RFC 9110 管「语义」(HTTP Semantics),RFC 9112 管 1.1 的报文格式与连接管理,RFC 9113 是 HTTP/2,RFC 9114 是 HTTP/3,RFC 9000 则是 HTTP/3 脚下的 QUIC 传输协议。

HTTP/0.9:一张纸条(1989–1991)

1989 年,Tim Berners-Lee 的需求很小:让科研文档互相链接、能取回来读。需求小,协议就小。HTTP/0.9 的请求只有一行:

GET /page.html

响应直接就是 HTML 文件本身:没有响应头,也没有状态码——出错时服务器只能回一个写给人看的 HTML 描述页,程序无从判断成败;也没有能声明「正文是什么格式」的字段,所以除了 HTML 什么都传不了。这版协议从未标准化,却是后续一切的起点:它假设「网页 = 纯文档」,一旦想要图片、样式和报错信息,纸条就写不下了。

HTTP/1.0:说一句话,挂一次电话(1996)

1996 年 5 月的 RFC 1945 给协议补上三样东西:请求行带上版本号、响应以状态码行开头(如 HTTP/1.0 200 OK)、请求和响应都可携带 HTTP 头。其中 Content-Type 头声明正文格式,HTML 以外的图片、样式表才有了合法身份。

但 1.0 有个结构性缺陷:连接用完就扔。RFC 1945 §1.3 的原话是「连接由客户端在每个请求之前建立、由服务器在发送响应之后关闭」。类比打电话:每说一句就挂断,下一句再重新拨号。每次「拨号」都要先做一次 TCP 三次握手——TCP 是 HTTP 脚下的传输协议,开始传数据前双方要用三个来回互相确认「你听得见吗」「听得见」。寒暄半天还没换来一个字节有用数据;而一个网页往往要请求几十个资源。

两个常见误会先澄清:

  • 「HTTP/1.0 和 HTTP/1.1 一样是正式互联网标准」——不对。RFC 1945 是 Informational(信息类)文档,并非正式标准;HTTP/1.1 才在 2022 年升级为互联网标准(见下一节)。
  • 「HTTP/1.0 早就被淘汰、没人用了」——也不对。原调研记录 curl --http1.0 https://www.cloudflare.com 依然返回 200:curl 发出的是 HTTP/1.0 格式的请求行(> GET / HTTP/1.0),服务器照常处理,返回 HTTP/1.1 200 OK——1.0 格式的请求至今仍被接受。
  • 「要复用连接就得写 Connection: keep-alive」——Keep-Alive 头根本不在 RFC 1945 里,它是后来服务器厂商的非标准扩展,1.0 时代要双方声明才能复用;1.1 起连接默认持久,无需声明。

HTTP/1.1:默认不挂线,但车道只有一条(1997–2022)

HTTP/1.1 服役最久:1997 年 RFC 2068 首发,1999 年 RFC 2616 修订,2014 年拆分为 RFC 7230–7235,2022 年更新为 RFC 9110/9112 并双双升级为互联网标准(RFC 9110 为 STD 97,RFC 9112 为 STD 99)。它的主题是「把连接养起来」——但连接快了,又堵出新问题。下文引用的条款号以现行标准为准。

持久连接成为默认

持久连接是 1.1 相对 1.0 最核心的改进,现行标准 RFC 9112 §9.3 写明「HTTP/1.1 默认使用持久连接」:请求一个接一个,都走同一条 TCP 连接。想结束才需要主动说 Connection: close(发完当前消息即关闭,§9.6)。顺带澄清:协议不规定统一的空闲连接超时,RFC 9112 §9.5 连超时的长短乃至是否存在都不提要求,多久关闭由服务器自己决定——网上流传的「keep-alive 默认 75 秒」其实来自 nginx 等个别服务器的默认配置(nginx 的 keepalive_timeout 默认 75s),不是协议规定。

这个差别可以亲手验证。下面的脚本只用 Python 标准库,两种行为的唯一差别是 protocol_version 一行,客户端用同一个 TCP 连接连发两个 GET:

# lab.py:对比 HTTP/1.0 与 HTTP/1.1 的连接行为(python3 lab.py HTTP/1.0)
import socket, sys, time, threading
from http.server import BaseHTTPRequestHandler, HTTPServer

version = sys.argv[1]                      # 传 "HTTP/1.0" 或 "HTTP/1.1"

class H(BaseHTTPRequestHandler):
    protocol_version = version             # 两种服务器的唯一差别
    def log_message(self, *a): pass
    def do_GET(self):
        self.send_response(200)
        self.send_header("Content-Length", "2")
        self.end_headers()
        self.wfile.write(b"hi")

srv = HTTPServer(("127.0.0.1", 8765), H)
threading.Thread(target=srv.serve_forever, daemon=True).start()

s = socket.create_connection(("127.0.0.1", 8765))
for i in (1, 2):                           # 同一个 TCP 连接连发两个 GET
    try:
        s.sendall(f"GET / {version}\r\nHost: localhost\r\n\r\n".encode())
        time.sleep(0.3)                    # 等响应到齐
        print(f"第 {i} 个响应:", s.recv(4096).split(b"\r\n")[0].decode())
    except OSError as e:
        print(f"第 {i} 个请求失败: {e}")
        break

两种模式各跑一次(归档实验输出):

$ python3 lab.py HTTP/1.0
第 1 个响应: HTTP/1.0 200 OK
第 2 个请求失败: [Errno 54] Connection reset by peer

$ python3 lab.py HTTP/1.1
第 1 个响应: HTTP/1.1 200 OK
第 2 个响应: HTTP/1.1 200 OK

前者发完第一个响应就关连接、第二个请求被拒;后者同一条连接安然承载了两个请求。

Host 头:一栋楼住进多家公司

IP 地址稀缺,一台服务器只放一个网站太浪费。HTTP/1.1 要求每个请求带 Host 头写明目标域名,于是虚拟主机(virtual hosting)成为可能:同一个 IP、同一栋楼里,按域名把请求分给不同网站。MDN 的示例请求:

GET / HTTP/1.1
Host: developer.mozilla.org
Accept-Language: fr

注意:Host 不是可有可无的礼貌性字段。RFC 9112 §3.2 规定所有 HTTP/1.1 请求必须(MUST)携带 Host,缺失、重复或无效的,服务器必须返回 400 Bad Request。1.0 没有这个头,服务器无从分辨你找楼里哪家公司,一个 IP 只能托管一个网站。

分块传输编码:不知道总量时怎么发货

动态生成的页面(搜索结果、实时数据)开始发送时往往不知道总长度,Content-Length 写不出来。分块传输编码(chunked transfer coding)把正文切成一块块发:每块前面用十六进制数字标出长度,最后用一个长度为 0 的块宣告正文结束(RFC 9112 §7.1):

HTTP/1.1 200 OK
Transfer-Encoding: chunked

4
Wiki
5
pedia
0

正文即 "Wiki"+"pedia",4 和 5 是十六进制的块长度。像火锅边煮边端:一道道上菜,空盘(0 块)表示结束。若还有正文传完才知道的补充信息,可以在 0 块之后附一小段 trailer——即放在正文末尾的元数据字段——最终以空行收尾。1.1 还带来了更丰富的缓存控制和内容协商机制。

管线化:一次失败的尝试

1.1 也试图解决「等一个才能问下一个」:管线化(pipelining)允许客户端不等响应就连发多个请求。但 RFC 9112 §9.3.2 同时要求服务器必须按请求的相同顺序返回响应——第一句回话慢了,后面全部干等;再加上夹在客户端和服务器之间的老式代理、防火墙等「中间设备」兼容问题,现代浏览器默认不启用管线化。别把它和 HTTP/2 的多路复用(multiplexing)混为一谈:管线化只是「连着问」,回话仍要排队;多路复用是把消息拆开交错传输,排队本身消失了。

队头阻塞:一条车道的堵车

响应必须排队、采用串行请求时,客户端需要等待当前响应;HTTP/1.1 流水线允许提前发送请求,但响应仍按请求顺序返回,这就是队头阻塞(head-of-line blocking)。注意此时堵的是 HTTP 应用层自己的队列,与后面 HTTP/3 一节要讲的 TCP 传输层队头阻塞是两回事。浏览器的土办法:对每个站点同时开约 6 个 TCP 连接,再配合域名分片(domain sharding,故意把资源摊到多个域名好开更多连接)、雪碧图(sprite,把一堆小图标拼成一张大图,一次请求顶多次用)这类「历史优化技巧」硬扛。一条车道堵就修六条,收费站还是老样子。

基础事实:端口与版本无关——http 默认 TCP 80、https 默认 TCP 443(RFC 9110 §4.2)。

HTTP/2:一条连接拆出多条车道(2015)

HTTP/2 的前身是 Google 2009 年公开的实验协议 SPDY,在 Chrome 上验证可行后成为标准化基础;2015 年 5 月 RFC 7540 发布,2022 年 6 月被 RFC 9113 取代。它不叫 2.0,因为标准委员会不再发布子版本。

二进制分帧:从「写信」到「标准集装箱」

1.1 及以前的消息是人能直接读懂的文本,亲切但机器解析费劲。HTTP/2 把消息嵌入二进制结构:所有帧(frame)以固定的 9 字节帧头开始,后接变长 payload,分为 HEADERS、DATA 等类型(RFC 9113 §4)。原来的头部变成以冒号开头的伪头部(pseudo-header field)。语义没变(客户端在虚拟层面仍能重组出等价的 HTTP/1.1 请求),变的只是运输包装。原调研记录 curl -v 访问 cloudflare.com 可见:

[HTTP/2] [1] OPENED stream
:method: GET
:scheme: https
:authority: www.cloudflare.com
:path: /

方括号里的 1 是流编号。

多路复用:真正消灭应用层排队

每个请求/响应交换关联到自己的流(stream),单条 HTTP/2 连接可同时承载多个并发打开的流,两端交错发送不同流的帧(RFC 9113 §2、§5)。好比一条传送带上混装多个订单的包裹,每个包裹贴着订单号(流 ID),到站后各自组装。客户端发起的流用奇数 ID,服务器发起的(推送)用偶数 ID;取消单个请求只需发一个 RST_STREAM 帧——1.1 只能整条连接关掉。

HPACK:重复的话只说编号

每个请求都带着 Host、User-Agent 等重复的长头部。HPACK 用静态表加动态表维护「头部内容 ↔ 索引」的对照,重复的头部只发索引号——第一次报全名,之后一个编号就代表整串内容(编号由两张表统一分配)。压缩表允许多大,通过设置帧 SETTINGS_HEADER_TABLE_SIZE 告知对端,这个上限的初始值是 4096 字节;动态表本身从空表开始,随通信逐步填入常用头部(RFC 9113 §6.5.2)。

服务器推送:生不逢时的招牌功能

规范允许服务器「顺手」推送浏览器即将请求的资源,听上去很美,实践却是差评:仅 1.25% 的 HTTP/2 站点使用(近期复测跌到 0.7%),收益不明确甚至倒退,Chrome 自 106 版起默认禁用,许多 HTTP/3 实现干脆不支持。RFC 9113 里推送机制(PUSH_PROMISE 帧)仍保留——真正被废弃的是旧的 PRIORITY 帧方案与明文升级机制——但如今的推荐替代是让浏览器「提前知道要下载什么」:一是 103 Early Hints——服务器在正式响应前先回一个 103 状态码的小响应,用 link 头预告接下来会用到的资源;二是 preload——页面自己声明哪些关键资源要提前拉取。实测访问 cloudflare.com,先收到 HTTP/2 103(link 头预告 woff2 字体 preload),随后才是 HTTP/2 200。

没修好的地基:TCP 队头阻塞

HTTP/2 消灭的只是应用层排队,传输层依旧。RFC 9113 §1 原文:"TCP head-of-line blocking is not addressed by this protocol"。TCP 是一条单一字节流,任何丢包都要等重传补齐才能继续,卡住的是整条连接上的所有流。所以「HTTP/2 解决了队头阻塞、HTTP/3 没必要再提」是误解——它解决了一半,另一半必须换传输层。

也澄清另一个常见误解:明文 HTTP/2(h2c)并不是和 HTTPS 上的 HTTP/2 并列常用的两种方式。现实中浏览器只在 TLS(Transport Layer Security,HTTPS 中负责加密的那层协议)加密连接上通过 ALPN 用 h2 标识协商 HTTP/2;HTTP/2 协议本身并不内建加密,「默认加密」要到 HTTP/3 的 QUIC 才成立。

HTTP/3:把地基从 TCP 换成 QUIC(2022)

RFC 9114(2022 年 6 月)定义的 HTTP/3 语义与早期 HTTP 相同,但传输层不再用 TCP,而是 QUIC(RFC 9000,2021 年 5 月)。它在 QUIC 上定义 HTTP 的流映射,并采用下列连接和压缩机制。

为什么要动传输层?TCP 深植于操作系统和全网中间设备,几乎改不动;QUIC 于是把分组封装在 UDP(User Datagram Protocol,TCP 的同层邻居,只管把小包发出去、不保证可靠送达)数据报里(RFC 9000 标题即 "A UDP-Based Multiplexed and Secure Transport",§1:这样便于在现有系统和网络中部署)。UDP 极简,恰好给重新设计留出空间。

三大件:

  1. 传输层换 QUIC,且内建 TLS 1.3——加密是默认的,连包号这类连接元数据也被加密,这是 TCP + TLS 组合做不到的。
  2. 头部压缩从 HPACK 换成 QPACK——HPACK 依赖头部字段的有序到达,而 QUIC 恰恰不保证不同流之间的字节顺序;QPACK 改在单独的单向流上维护字段表状态(RFC 9114 §2)。
  3. 每个请求/响应对独占一个 QUIC 流——不同流之间不保证顺序,丢包只需重传所在流的数据,一个丢包不再阻塞所有流,传输层队头阻塞由此解决。

两项新能力:

  • 连接迁移(connection migration):连接靠连接标识符(connection ID)识别而非 IP 地址,手机从 WiFi 切到蜂窝网络、IP 变了,连接照样延续(RFC 9000 §9)。好比网购订单号不变、收货地址变了,包裹照常派送。
  • 0-RTT(零往返):重连时客户端可立即发送数据,省去等待。但 RFC 9000 §5 明确写着 "0-RTT provides no protection against replay attacks"——「重放(replay)」指攻击者把截获的旧数据原样再发给服务器冒充新请求,0-RTT 对此没有防护,只该用于幂等(idempotent,发一次和发多次效果相同)的请求。「又快又安全、没有任何代价」是误解。

另外,HTTP/3 的服务器推送被保留但明确为可选(RFC 9114 §6.2.2 首句:"Server push is an optional feature introduced in HTTP/2"):服务器必须等客户端发来 MAX_PUSH_ID 帧授权后才能开始推送(RFC 9114 §4.6、§7.2.7)。QUIC 跑在 UDP 上,端口沿用 443。

版本是怎么协商的?怎么检测?

先澄清一个直觉误会:从网址看不出网站用 HTTP 几。版本是在建立连接的握手阶段协商出来的,同一个 URL 这次用 h2、下次可能用 h3,网址完全不变。

HTTPS 连接在 TLS 握手阶段通过 ALPN(应用层协议协商,Application-Layer Protocol Negotiation)确定协议。ALPN 标识符现行常用的四种:http/1.1、h2(TLS 上的 HTTP/2)、h3,以及 h2c(明文 HTTP/2 的标识,不会出现在 TLS 协商里);IANA 注册表里另收着 http/0.9、http/1.0 和历史遗留的 spdy/1–3。原调研记录 curl -sv 能看到全过程(本机 curl 8.7.1,调研原调研记录,原稿称撰写时复核一致,本次未重跑):

$ curl -sv -o /dev/null https://www.cloudflare.com 2>&1 | grep -iE 'ALPN|HTTP/'
* ALPN: curl offers h2,http/1.1
* ALPN: server accepted h2
* using HTTP/2
> GET / HTTP/2
< HTTP/2 103
< HTTP/2 200

只想看版本号时,一条命令足够:

$ curl -sI https://www.cloudflare.com -o /dev/null -w '%{http_version}\n'
2

加 --http1.1 输出 1.1,加 --http2 输出 2。(macOS 自带的 curl 8.7.1 不支持 --http3-only,会报 "the installed libcurl version doesn't support this",验证 HTTP/3 需安装带 QUIC 支持的构建。)

浏览器怎么从 h2 升级到 h3?靠响应头 Alt-Svc(RFC 7838):

$ curl -sI https://www.cloudflare.com | grep -i alt-svc
alt-svc: h3=":443"; ma=86400

意思是「443 端口也支持 h3,这条信息缓存 86400 秒(一天)」。典型路径是:第一次用 h2 连上、看到 Alt-Svc,下次访问直接走 h3——切换不改变 URL、不引入额外往返、对用户不可见。Chrome DevTools 的 Network 面板也能显示每条请求的协议:右键请求表头勾选「Protocol」列即可(依据 developer.chrome.com 官方文档)。

成绩单(MDN 数据):HTTP/2 于 2022 年 1 月达峰值 46.9%,HTTP/3 到 2022 年 10 月已有 26% 的网站在用。而无论怎么升级,HTTP/1.1 仍是所有客户端和服务器共同的保底。

最后更新于

本页目录