HTTP 演进
HTTP 的语义——方法、状态码、header、body——并没有在 HTTP/2 或 HTTP/3 中被推翻。演进的重点是:在高延迟、有丢包的网络上,怎样更有效地承载同样的请求/响应语义。另一个容易忽略的事实是,HTTP 从来不只为“浏览器直连应用”设计:缓存、代理、网关和 CDN 都能理解并转发它。
先把“HTTP 的语义”与“HTTP 版本如何承载它”分开,会更容易理解后面的演进:

图的上半部分是跨版本不变的 HTTP 语义:GET、header、缓存和 Cookie 并不属于 HTTP/1.1,而是 HTTP 的共同语言。下半部分才是承载方式的变化:HTTP/1.1 用文本 message,HTTP/2 把 message 拆成可交错传输的二进制 frame 与 stream,HTTP/3 仍保留 frame 与 stream,但把它们放进 QUIC(运行于 UDP,并整合 TLS 1.3)。
图中“HTTP/3 无队头阻塞”更准确地说是:一个 stream 丢包不会再卡住其他 stream,因为 QUIC 不像 TCP 那样要求整条连接的字节流先后到齐;但同一个 stream 内的内容仍要按序交付,丢失的数据仍会阻塞它自己。HTTP/2 和 HTTP/3 主要改变的是消息编码、并发单位与底层传输,而不是把应用层重新发明一次。
HTTP 的核心:统一语义,允许中间层参与
一个 HTTP 请求描述的是对某个资源表示(representation)的操作,而不只是“调用一个后端函数”。方法表达意图:GET 是读取,POST 常用于提交处理,PUT / DELETE 则有各自的更新语义;状态码表达结果;header 是可扩展的元数据载体,例如内容类型、缓存策略、认证信息与协商能力;body 承载具体表示。
这种语义上的稳定性很重要。HTTP/2 把文本改成二进制 frame,HTTP/3 又换成 QUIC,但 GET /articles/42、Cache-Control、Set-Cookie 仍然有相同含义。中间层因而可以只理解 HTTP 语义,就完成缓存、路由、压缩、鉴权或限流,而不必知道业务代码的内部结构。
方法的安全性和幂等性也是工程语义,而不是命名习惯:读取型 GET 应不改变服务端状态;PUT、DELETE 预期可安全重试;POST 通常不保证幂等。网络超时后,client 不知道 server 是否已处理请求,代理或 SDK 是否能重试,取决于这些语义是否可信。
HTTP/1.0:一次连接,服务一次交换
HTTP/1.0 的典型模式是一个请求占用一个 TCP 连接,响应结束即关闭。它简单,但页面有几十个资源时,需要反复 TCP/TLS 建连;慢启动和握手让延迟被放大。浏览器后来通过并行开多个连接缓解问题,却也让拥塞控制彼此竞争。
HTTP/1.1:keep-alive 和管线化,但顺序仍是枷锁
HTTP/1.1 默认允许持久连接,同一 TCP 连接可顺序处理多个请求,节省重复握手。它定义的消息仍是文本 start-line、header、空行、可选 body。
HTTP pipelining 曾试图让客户端连续发送请求,但响应必须按请求顺序返回:一个慢响应会挡住后面的快响应,形成应用层 head-of-line blocking。现实中的代理兼容性也使它没有成为通用解法。因此浏览器长期依赖“对同一域名开多个 TCP 连接”。
HTTP/2:在一个 TCP 连接内把请求分成 stream
HTTP/2 将消息改成二进制 frame,并为每个请求/响应分配 stream。多个 stream 的 frame 可以交错传输;流量控制与优先级也进入协议。这样一个连接就能同时承载很多请求,解决 HTTP/1.1 的应用层队头阻塞。
HTTP/1.1: req A → response A → req B → response B
HTTP/2: HEADERS A, HEADERS B, DATA B, DATA A ...
但 HTTP/2 仍建立在一条 TCP 字节流上。TCP 必须按序交付:如果一个底层包丢失,即使它只属于 stream A,内核也要等待重传后才能向应用交付后续字节,stream B 也会被拖住。这是传输层队头阻塞。
Header 压缩:HPACK 解决的是每个请求都重复说一遍的话
多路复用之外,HTTP/2 还有一项很实际的优化:header 经常冗长且高度重复。Cookie、User-Agent、Accept、认证信息以及伪 header(:method、:scheme、:authority、:path)会随着页面的每个资源请求重复出现。在新建连接的初始拥塞窗口很小的情况下,这些重复字节本身就会增加延迟。
HPACK 为此维护两类表:规范内置的静态表保存常见字段名/值;连接级的动态表记录近期出现的字段。后续请求不必再次发送完整字符串,而可以发送“静态表第 N 项”或“动态表第 N 项”的索引;它还可对字面量使用 Huffman 编码。这里压缩的是 header field,不是 response body 的 gzip/br。
动态表是有状态的:通信两端必须对“第 N 项是什么”保持一致。因此 HTTP/2 的压缩上下文按连接共享,不能随意让任意 stream 独立更新、独立解码。再加上 HTTP/2 仍运行在 TCP 上,一个早到不了的字节会让后续 frame 都无法交付;这也是为什么“已经有 stream”不等于所有层面都彻底彼此独立。
HTTP/3:把复用下沉到 QUIC
HTTP/3 将 HTTP 语义映射到 QUIC,而 QUIC 建立在 UDP 之上并在用户态实现可靠传输、拥塞控制与 stream。每个请求使用独立 QUIC stream;A 的丢包只阻塞 A,不必阻塞 B。QUIC 还把 TLS 1.3 融入连接建立,减少握手往返。
HTTP/3 也没有直接沿用 HPACK,而是使用 QPACK。它仍有静态表和动态表,但把动态表的插入与确认放到专用的 QUIC 单向 stream;每个 header block 标明自己依赖到哪个动态表状态。这样某条 encoder stream 的数据尚未到达时,最多只会阻塞引用了该状态的 header block,其他请求可继续用静态表或字面量解码、继续前进。代价是实现要管理“避免阻塞”和“压缩率”之间的取舍:越积极引用动态表,压缩可能越好,也越可能让某个 stream 暂时等表项到齐。
这不意味着 HTTP/3 在所有场景更快:UDP 可能被网络设备限制,连接迁移和 0-RTT 也带来运维与重放语义上的新考量。但它把“多路复用”从应用层 frame 提升到了传输层,正面处理 HTTP/2 仍遗留的丢包耦合。
缓存:把“是否需要回源”变成协议的一部分
HTTP cache 的价值不只是快,还包括减少 origin 压力、让 CDN 在离用户更近的位置服务内容。它依赖的是明确的响应语义,而不是“把结果塞进 Redis”这么简单。
浏览器 / CDN 命中仍新鲜的响应 → 直接复用
缓存已过期,但有 ETag → If-None-Match: "v42" → 304 Not Modified
缓存已过期,但有 Last-Modified → If-Modified-Since → 304 或新的 200
没有可复用响应 → 回源取得 200 与新表示
Cache-Control: max-age=600 定义新鲜度;共享缓存还可用 s-maxage 单独指定 CDN 的时长。no-store 表示不保存响应,private 表示不应由共享缓存复用,public 则允许共享缓存保存。过期不意味着内容一定要重新下载:ETag / If-None-Match 或 Last-Modified / If-Modified-Since 允许 cache 向 origin 验证,未变化时只返回没有 body 的 304。
缓存键还要考虑 Vary。例如同一 URL 会按 Accept-Encoding 返回 gzip 与 br 两种表示,响应应声明 Vary: Accept-Encoding;否则共享缓存可能把一种变体错误地给了不支持它的 client。带用户身份的 Cookie 或 Authorization 的响应尤其要谨慎:没有明确的 private / public 与缓存策略,最严重的问题不是命中率,而是把一个用户的数据复用给另一个用户。
Cookie:在无状态 HTTP 上建立浏览器会话
HTTP 请求本身彼此独立。登录态、购物车或偏好之类的状态,常由 server 通过 Set-Cookie 让浏览器保存,再由浏览器在匹配的后续请求中带回 Cookie header。Cookie 不是认证协议本身,而是浏览器自动携带状态的一套规则。
常用属性决定了它的边界:Domain / Path 限制发送范围,Max-Age / Expires 控制寿命,Secure 要求只经 HTTPS 发送,HttpOnly 禁止 JavaScript 读取,SameSite 限制跨站请求时的携带方式以降低 CSRF 风险。把会话 ID 放入 Cookie 时,至少应使用 Secure、HttpOnly 和合适的 SameSite;而缓存层通常应把带个性化 Cookie 的响应视作私有内容,除非业务明确设计了安全的共享缓存策略。
Proxy、gateway 与 tunnel:一次请求往往不止两端
HTTP 的中间节点同时扮演 server 与 client:对下游接收请求,再向上游发起请求。不同部署位置决定了职责:
| 角色 | 常见位置 | 主要职责 |
|---|---|---|
| 正向代理(forward proxy) | client 一侧或企业出口 | 代表 client 访问外网、统一审计与访问控制 |
| 反向代理 / gateway | origin 前面 | TLS 终止、路由、负载均衡、限流、缓存与故障隔离 |
| CDN | 用户与 origin 之间 | 在边缘缓存和回源,缩短传输距离 |
HTTPS 经正向代理时,client 常先发送 CONNECT host:443,让代理建立一条字节隧道;随后 TLS 在 client 与目标站之间完成,普通代理只能看到目标地址与连接元数据,不能直接读取其中的 HTTP 内容。反向代理若在边缘终止 TLS,则能看到 HTTP 并据此做路由和缓存,但它也成为新的信任边界。
这解释了两个常见排障原则:X-Forwarded-For、X-Forwarded-Proto 一类 header 只有在可信代理覆盖或清洗后才能信任;而 Connection、Keep-Alive、Transfer-Encoding 等 hop-by-hop header 属于相邻一跳,代理不应把它们原封不动传给下一跳。
一个实用的比较维度
| 版本 | 并发单位 | 底层 | 主要瓶颈 |
|---|---|---|---|
| 1.0 | 连接 | TCP | 重复建连 |
| 1.1 | 连接/顺序响应 | TCP | 应用层 HOL |
| 2 | stream | 单 TCP 连接 | TCP 层 HOL |
| 3 | QUIC stream | QUIC/UDP | UDP 可达性与实现成熟度 |
排查性能时,不要只问“服务支持 HTTP/2 吗”:还要看资源数量、连接复用、丢包率、TLS 握手、CDN/代理链路以及客户端协议协商结果。
References
Notion 相关零散笔记
- TCP
- Https
- Network
- DNS
- NAT