前言
HTTP 的版本演进可以先压成一句话:
1 | HTTP/1.0:短连接为主,每次请求成本高。 |
这篇文章围绕几个问题展开:
- HTTP/1.0 为什么慢?
- HTTP/1.1 的长连接解决了什么?
- 为什么 HTTP/1.1 还有队头阻塞?
- HTTP/2 为什么能在 TCP 上实现多路复用?
- 为什么 HTTP/2 仍然有 TCP 层队头阻塞?
- ETag 是哪个版本引入的缓存机制?
1. HTTP/1.0:一个请求基本一次连接
HTTP/1.0 的典型特点是:
一个请求基本对应一次 TCP 连接,请求完就关闭。
假设页面需要这些资源:
1 | index.html |
HTTP/1.0 的典型流程是:
1 | 请求 index.html: |
页面资源越多,连接建立和关闭成本越高。
2. 为什么连接成本高
普通 HTTP 不是直接发送请求。
它至少需要:
1 | TCP 三次握手 |
如果是 HTTPS,还要更多:
1 | TCP 三次握手 |
如果每个资源都重新建连接,就会浪费很多 RTT。
RTT 是一次往返时间:
1 | 客户端发出数据 -> 服务端响应回来 |
网络延迟越高,频繁建连越伤。
3. HTTP/1.0 有没有长连接
HTTP/1.0 默认不是长连接。
后来有些实现支持:
1 | Connection: keep-alive |
但这不是 HTTP/1.0 的默认行为,也不是特别统一。
真正把长连接变成默认能力的是 HTTP/1.1。
HTTP/1.0 的核心问题可以总结成:
1 | 连接复用弱 |
4. HTTP/1.1:默认长连接
HTTP/1.1 的核心改进是:
默认长连接,复用 TCP 连接。
也就是:
1 | 建立一次 TCP 连接 |
这减少了:
1 | TCP 三次握手成本 |
HTTP/1.1 里长连接是默认行为。
如果要关闭连接,可以显式写:
1 | Connection: close |
5. HTTP/1.1 为什么还有队头阻塞
HTTP/1.1 虽然能复用 TCP 连接,但同一条连接上的请求和响应仍然容易排队。
常见模式是:
1 | 请求 A -> 响应 A |
如果 A 很慢:
1 | 请求 A:慢 |
B、C 也可能被 A 卡住。
这叫:
1 | HTTP 层队头阻塞 |
HTTP/1.1 有 pipelining,允许客户端不等前一个响应回来就连续发请求:
1 | 请求 A |
但响应仍然必须按请求顺序返回:
1 | 响应 A |
如果 A 慢,即使 B、C 已经处理完,也不能先完整返回。
这就是 pipelining 没有真正解决队头阻塞的原因。
6. 浏览器如何缓解 HTTP/1.1 队头阻塞
浏览器通常会对同一个域名开多个 TCP 连接。
比如:
1 | 连接 1:请求 A |
这样一个连接被慢请求卡住,其他连接还能继续跑。
但代价是:
1 | 更多 TCP 连接 |
这也是为什么 HTTP/1.1 时代常见这些前端优化:
1 | 合并 JS / CSS |
它们很多都是为了缓解请求数量和连接排队带来的成本。
7. HTTP/1.1 的其他重要能力
HTTP/1.1 不只是长连接。
它还强化了很多现代 Web 的基础能力。
Host 头
HTTP/1.1 要求请求里带 Host:
1 | Host: example.com |
这让一个 IP 可以托管多个站点:
1 | a.com |
服务端通过 Host 判断客户端请求的是哪个域名。
缓存控制
HTTP/1.1 强化了缓存相关能力:
1 | Cache-Control |
这些对浏览器性能非常重要。
Range 请求
HTTP/1.1 支持请求资源的一部分:
1 | Range: bytes=0-999 |
这可以用于:
1 | 断点续传 |
Chunked 分块传输
服务端可以边生成边发送:
1 | Transfer-Encoding: chunked |
不必一开始就知道完整 Content-Length。
8. ETag 是什么
ETag 是 HTTP/1.1 引入并标准化的重要缓存机制。
服务端响应时可以返回:
1 | ETag: "abc123" |
意思是:
1 | 这是这个资源当前版本的标识。 |
浏览器下次请求时可以带:
1 | If-None-Match: "abc123" |
服务端比较后,如果资源没变,就返回:
1 | 304 Not Modified |
响应体可以不传,浏览器继续使用本地缓存。
所以 ETag 的作用是:
1 | 用资源版本标识判断缓存是否仍然有效。 |
它比 Last-Modified 更精确,因为 Last-Modified 基于时间,而 ETag 可以基于内容 hash、版本号、文件 inode 等生成。
常见协商缓存对比:
1 | Last-Modified / If-Modified-Since:按修改时间判断 |
通常:
1 | If-None-Match / ETag 优先于 If-Modified-Since / Last-Modified |
9. HTTP/2:语义不变,传输方式变化
HTTP/2 的目标是:
在尽量保持 HTTP 语义不变的前提下,提升传输效率。
也就是说,这些语义还在:
1 | GET / POST |
但底层传输方式变了。
HTTP/1.1 是文本报文。
HTTP/2 改成二进制分帧:
1 | Frame |
每个 Frame 有明确结构:
1 | 长度 |
这让机器解析更高效,也为多路复用打基础。
10. HTTP/2 的 Stream 是什么
HTTP/2 引入 Stream。
可以理解成:
1 | 一次请求 / 响应对应一条 Stream。 |
比如页面加载:
1 | /index.html -> Stream 1 |
每个 Frame 都带有 Stream ID。
接收方看到 Frame 后,就知道:
1 | 这帧属于 HTML |
然后分别拼回对应的请求或响应。
11. HTTP/2 为什么能在 TCP 上多路复用
TCP 给 HTTP/1.1 和 HTTP/2 的底座其实一样:
1 | 一条可靠、有序的字节流 |
区别是 HTTP 自己怎么把数据放进这条字节流里。
HTTP/1.1 按完整文本请求/响应排队:
1 | 请求 A |
HTTP/2 把请求和响应拆成带 Stream ID 的二进制 Frame。
所以可以交错发送:
1 | Stream 1 的一帧 |
接收方根据 Stream ID 拼回去:
1 | Stream 1 -> HTML |
所以:
HTTP/2 不是让 TCP 懂多路复用,而是在 TCP 字节流里自己定义了 Frame 和 Stream。
12. HTTP/2 为什么仍有队头阻塞
HTTP/2 解决的是 HTTP 层队头阻塞,但没有解决 TCP 层队头阻塞。
HTTP/2 可以让多个 Stream 交错发送。
但底层 TCP 仍然要求:
1 | 字节必须按顺序交付给上层。 |
假设 TCP 字节流里有这些包:
1 | 包 1:包含 Stream 1 的帧 |
即使包 3、包 4 已经到达,TCP 也不能先交给 HTTP/2。
因为 TCP 看到的是连续字节流:
1 | 包 1 -> 包 2 -> 包 3 -> 包 4 |
包 2 没到,后面的字节不能越过它交付。
于是:
1 | Stream 3 和 Stream 5 也被包 2 卡住 |
这就是 TCP 层队头阻塞。
所以:
1 | HTTP/2 解决 HTTP 层排队 |
HTTP/3 / QUIC 就是为了进一步减少这个问题。
13. HTTP/2 的 Header 压缩
HTTP 请求头经常重复。
比如多个请求都有:
1 | Host: example.com |
HTTP/1.1 每次都要重复发送这些文本 Header。
HTTP/2 使用 HPACK 做 Header 压缩。
它会维护一张 Header 表,把重复 Header 用索引表示。
第一次发:
1 | User-Agent: Chrome... |
后面可以用更短的索引引用。
这能减少请求头体积,尤其对很多小请求很有帮助。
14. HTTP/2 对前端优化的影响
HTTP/1.1 时代,很多优化是为了减少请求数:
1 | 合并 JS |
HTTP/2 更擅长处理并发小请求。
所以一些策略需要重新评估:
1 | 过度合并资源可能降低缓存效率 |
但这不代表完全不用打包。
现实中还要考虑:
1 | 模块加载 |
15. 一句话总结
可以这样记:
1 | HTTP/1.0:短连接为主,请求多时建连成本高。 |
再补一句:
1 | HTTP/2 解决 HTTP 层队头阻塞,但仍受 TCP 层队头阻塞影响。 |
最短记法:
HTTP/1.1 是“排队办事”,HTTP/2 是“给每件事编号,拆成小片交错办事”。