目录

网络-15 HTTP 版本演进

1. HTTP/0.9

HTTP/0.9 虽然简单,但是它充分验证了 Web 服务的可行性」

  • 首先它只有一个命令GET。
  • 它没有HEADER等描述数据的信息。因为这个时候的请求非常简单,它需要达到的目的也非常简单,没有那么多数据格式。
  • 服务器发送完内容之后,就关闭TCP连接。

2. HTTP/1.0

随着互联网的发展,之前的HTTP/0.9已经无法满足用户需求了,浏览器希望通过HTTP来传输脚本、样式、图片、音视频等不同类型的文件,所以在1996年HTTP进行了一次版本更新:

  • 增加了HEAD、POST等新方法
  • 增加了响应状态码,标记可能的错误原因
  • 引入了协议版本号概念
  • 引入了HTTP header的概念,让HTTP处理请求和响应更加灵活
  • 传输的数据不再局限于文本

HTTP/1.0最主要的缺点还是跟HTTP/0.9一样,每一个TCP连接只能发送一个HTTP请求,服务器发送完响应,就关闭连接。

为了解决这个问题,在1.0版本使用了一个非标准的Connection头部字段。当客户端再请求头部信息里面带上Connection:keep-alive的时候,服务器在发送完响应数据之后,就不会断开TCP连接了,从而达到复用同一个TCP连接的目的。但是由于不是标准字段,不同的实现可能导致表现得不一致,因此不能从根本上解决这个问题。

HTTP/1.0最核心的改变是增加了头部设定,头部内容以键值对的形式设置。请求头部通过 Accept 字段来告诉服务端可以接收的文件类型,响应头部再通过 Content-Type 字段来告诉浏览器返回文件的类型。头部字段不仅用于解决不同类型文件传输的问题,也可以实现其他很多功能如缓存、认证信息等。

HTTP/1.0 并不是一个“标准”,只是一份参考文档,不具有实际的约束力。

3. HTTP/1.1

为了解决 HTTP/1.0 的问题,1999 年推出的 HTTP/1.1 有以下特点:

  • 长连接:引入了 TCP 连接复用,即一个 TCP 默认不关闭,可以被多个请求复用
  • 并发连接:对一个域名的请求允许分配多个长连接(缓解了长连接中的「队头阻塞」问题)
  • 引入管道机制(pipelining),一个 TCP 连接,可以同时发送多个请求。(响应的顺序必须和请求的顺序一致,因此不常用)
  • 增加了 PUT、DELETE、OPTIONS、PATCH 等新的方法
  • 新增了一些缓存的字段(If-Modified-Since, If-None-Match)
  • 请求头中引入了 range 字段,支持断点续传
  • 允许响应数据分块(chunked),利于传输大文件
  • 强制要求 Host 头,让互联网主机托管称为可能

3.1 http/1.1的keep-alive

http 1.0中默认是关闭的,如果客户端浏览器支持Keep-Alive,那么就在HTTP请求头中添加一个字段 Connection: Keep-Alive,当服务器收到附带有Connection: Keep-Alive的请求时,它也会在响应头中添加一个同样的字段来使用Keep-Alive。

http 1.1中默认启用Keep-Alive, 默认情况下所在HTTP1.1中所有连接都被保持,除非在请求头或响应头中指明要关闭:Connection: Close ,这也就是为什么Connection: Keep-Alive字段再没有意义的原因。

3.2 HTTP管道机制(pipelining)

它指的是在一个TCP连接内,多个HTTP请求可以并行,客户端不用等待上一次请求结果返回,就可以发出下一次请求,但服务器端必须按照接收到客户端请求的先后顺序依次回送响应结果,以保证客户端能 够区分出每次请求的响应内容。

3.3 HTTP/1.1的局限

HTTP 1.1 还是暴露出一些局限性:

  • 虽然加入 keep-alive 可以复用一部分连接,但域名分片等情况下仍然需要建立多个 connection,耗费资源,给服务器带来性能压力。
  • pipeling 只部分解决了队头阻塞( HOLB)。 HTTP 1.1 尝试使用 pipeling 来解决队头阻塞问题,即浏览器可以一次性发出多个请求(同个域名、同一条 TCP 链接)。 但 pipeling 要求返回是按序的,那么前一个请求如果很耗时(比如处理大图片),那么后面的请求即使服务器已经处理完,仍会等待前面的请求处理完才开始按序返回。
  • 协议开销大,没有相应的压缩传输优化方案。 HTTP/1.1 在使用时,header 里携带的内容过大,在一定程度上增加了传输的成本,并且每次请求 header 基本不怎么变化,尤其在移动端增加用户流量。

4. SPDY:HTTP1.X的优化(改进版HTTP/1.1)

2012年google提出了SPDY的方案,优化了HTTP1.X的请求延迟,解决了HTTP1.X的安全性,具体如下:

  • 降低延迟: 针对HTTP高延迟的问题,SPDY优雅的采取了多路复用(multiplexing)。多路复用通过多个请求stream共享一个tcp连接的方式,解决了HOL blocking的问题,降低了延迟同时提高了带宽的利用率。
  • 请求优先级(request prioritization):多路复用带来一个新的问题是,在连接共享的基础之上有可能会导致关键请求被阻塞。SPDY允许给每个request设置优先级,这样重要的请求就会优先得到响应。比如浏览器加载首页,首页的html内容应该优先展示,之后才是各种静态资源文件,脚本文件等加载,这样可以保证用户能第一时间看到网页内容。
  • header压缩:前面提到HTTP1.x的header很多时候都是重复多余的。选择合适的压缩算法可以减小包的大小和数量。
  • 基于HTTPS的加密协议传输:大大提高了传输数据的可靠性。
  • 服务端推送(server push):可以让服务端主动把资源文件推送给客户端。当然客户端也有权利选择是否接收。

5. HTTP/2.0

因为必须要保持功能上的兼容,所以 HTTP/2 把 HTTP 分解成了“语义”和“语法”两个部分,“语义”层不做改动,与 HTTP/1 完全一致(即 RFC7231)。比如请求方法、URI、状态码、头字段等概念都保留不变,这样就消除了再学习的成本,基于 HTTP 的上层应用也不需要做任何修改,可以无缝转换到 HTTP/2。

5.1 头部压缩

由于报文 Header 一般会携带“User Agent”“Cookie”“Accept”“Server”等许多固定的头字段,多达几百字节甚至上千字节,但 Body 却经常只有几十字节(比如 GET 请求、204/301/304 响应),成了不折不扣的“大头儿子”。更要命的是,成千上万的请求响应报文里有很多字段值都是重复的,非常浪费,“长尾效应”导致大量带宽消耗在了这些冗余度极高的数据上。

专门的“HPACK”算法,在客户端和服务器两端建立“字典”,用索引号表示重复的字符串,还釆用哈夫曼编码来压缩整数和字符串,可以达到 50%~90% 的高压缩率。

5.2 二进制格式

HTTP/1 里纯文本形式的报文了,它的优点是“一目了然”,用最简单的工具就可以开发调试,非常方便。

但 HTTP/2 在这方面没有“妥协”,决定改变延续了十多年的现状,不再使用肉眼可见的 ASCII 码,而是向下层的 TCP/IP 协议“靠拢”,全面采用二进制格式。

把原来的“Header+Body”的消息“打散”为数个小片的二进制“帧”(Frame),用“HEADERS”帧存放头数据、“DATA”帧存放实体数据。

./pic.png

5.3 虚拟的“流”

消息的“碎片”到达目的地后应该怎么组装起来呢?

HTTP/2 为此定义了一个“流”(Stream)的概念,它是二进制帧的双向传输序列,同一个消息往返的帧会分配一个唯一的流 ID。你可以想象把它成是一个虚拟的“数据流”,在里面流动的是一串有先后顺序的数据帧,这些数据帧按照次序组装起来就是 HTTP/1 里的请求报文和响应报文。

因为“流”是虚拟的,实际上并不存在,所以 HTTP/2 就可以在一个 TCP 连接上用“流”同时发送多个“碎片化”的消息,这就是常说的“多路复用”( Multiplexing)——多个往返通信都复用一个连接来处理。

在“流”的层面上看,消息是一些有序的“帧”序列,而在“连接”的层面上看,消息却是乱序收发的“帧”。多个请求 / 响应之间没有了顺序关系,不需要排队等待,降低了延迟,大幅度提高了连接的利用率。

注意:HTTP/2 只解决了 HTTP 层的队头阻塞,TCP 层的队头阻塞依然存在。

这一点很容易被误读成“HTTP/2 彻底消灭了队头阻塞”,其实不然:

  • HTTP/1.1 的队头阻塞在 HTTP 层——pipelining 要求响应按请求顺序返回,前一个响应慢就堵住后面所有响应。HTTP/2 用“流 + 帧”把它解决了。
  • 但所有的流最终都跑在同一条 TCP 连接上。TCP 是可靠的字节流协议,必须按序交付给上层。一旦某个 TCP 段丢失,内核就必须等它重传成功,才能把后续已经到达的数据交给应用层——哪怕这些数据属于完全不相干的其他流

也就是说,HTTP/1.1 时代一个慢响应堵住一条连接,HTTP/2 时代一个丢包堵住连接上的全部流。在丢包率较高的弱网环境(移动网络)下,HTTP/2 的表现有时甚至不如开了多条连接的 HTTP/1.1。

这个问题在 TCP 之上无解——它是 TCP 有序字节流语义的固有代价。要绕过它只能换掉传输层,这正是 HTTP/3 转向基于 UDP 的 QUIC 的根本动因,详见 HTTP/3 与 QUIC

./pic2.png

为了更好地利用连接,加大吞吐量,HTTP/2 还添加了一些控制帧来管理虚拟的“流”,实现了优先级和流量控制。

5.4 server push

HTTP/2 引入了 Server Push 机制,允许服务端在客户端还没有请求某个资源时,就主动把它推送过去。

典型场景:浏览器请求 index.html,服务端知道这个页面必然要用到 main.css,于是在返回 HTML 的同时顺手把 CSS 也推过去。这样浏览器解析到 <link> 标签时,CSS 已经在本地缓存里了,省掉一个 RTT。

协议层面,推送是通过 PUSH_PROMISE 帧完成的:

  1. 服务端在响应流上发送一个 PUSH_PROMISE 帧,里面包含它替客户端伪造的请求头(相当于告诉客户端“我猜你接下来要请求这个,我先给你”),并为这次推送分配一个新的流 ID。
  2. 随后服务端在这个新流上发送 HEADERS + DATA 帧,把资源内容传过去。
  3. 客户端把收到的资源放入缓存。之后真的解析到需要该资源时,直接命中缓存,不再发请求。
  4. 客户端如果不想要,可以发 RST_STREAM 拒绝这次推送。

触发方式上,服务端应用通常不直接操作帧,而是在响应头里带上 Link 字段,由 Web 服务器(Nginx、边缘 CDN 等)读取后转换成实际的推送:

Link: </css/main.css>; rel=preload; as=style

注意两点:一是 Link响应头而不是请求头——它是服务端发给客户端/中间层的指示;二是 rel=preload 本身只是浏览器预加载提示(让浏览器提早自己去请求),和 Server Push 是两套独立机制,只是习惯上被复用为触发推送的信号。

Server Push 目前已经基本退出历史舞台。 主要原因是收益远不如预期:

  • 服务端无法知道客户端缓存里已经有什么,很容易推送一份客户端早就缓存好的资源,纯属浪费带宽。
  • 推送的数据会和 HTML 本身争抢连接带宽,反而可能拖慢首屏。
  • 实测中它带来的性能提升非常有限,而实现和调试复杂度不低。

Chrome 在 106 版本(2022 年)中移除了对 Server Push 的支持,其他主流浏览器也已停止或不再积极支持。需要区分两件事:浏览器基本放弃了 Push,但 HTTP/3 协议本身仍定义了 Push ID、PUSH_PROMISE、MAX_PUSH_ID 等机制,并没有从 RFC 9114 删除。 工程上现在推荐 103 Early Hints 配合 rel=preload——由服务端提前告知浏览器“待会儿要用这些资源”,让浏览器自己决定是否请求,既省掉等待时间,又不会盲目推送。

http2与websocket

HTTP2虽然支持服务器推送资源到客户端,但那不是应用程序可以感知的,主要是让浏览器(用户代理)提前缓存静态资源,所以我们不能指望HTTP2可以像WebSocket建立双向实时通信。

5.5 响应无序,怎么与请求对应

HTTP/2.0引入了多路复用(Multiplexing)的机制,允许客户端同时发送多个请求,而这些请求可以在一个TCP连接上进行交错发送和接收。由于这种交错的特性,HTTP/2.0中的响应是无序的。

为了解决这个问题,HTTP/2.0引入了流(Stream)的概念。每个流代表一个独立的请求和响应。客户端和服务器可以通过流标识符(Stream Identifier)进行流的标识和区分。客户端在发送请求时分配一个唯一的流标识符,服务器在返回响应时使用相同的流标识符来标识响应属于哪个请求。

客户端可以通过流的标识符来将响应与请求对应。当客户端收到响应后,可以使用流标识符来确定响应属于哪个请求,从而完成请求和响应的对应。

补充一个细节:流 ID 并非随意分配——客户端发起的流用奇数,服务端发起的流用偶数,这样双方各自分配 ID 时不会冲突,不需要额外协商。ID 还必须单调递增,接收方看到一个比当前最大值更小的新流 ID 就可以判定是协议错误。

6. HTTP/3

HTTP/2 虽然解决了 HTTP 层的队头阻塞,但它跑在 TCP 上,TCP 层的队头阻塞依然无法回避(见上文"虚拟的流"一节末尾)。HTTP/3 因此把传输层从 TCP 换成了基于 UDP 的 QUIC,于 2022 年由 RFC 9114 标准化。

主要变化:

  • 传输层换为 QUIC,流级别独立可靠,一个流丢包不再阻塞其他流;
  • 握手 1-RTT(会话恢复 0-RTT),TLS 1.3 内嵌进 QUIC,不再是独立的一层;
  • 连接迁移,用 Connection ID 而非四元组标识连接,切换网络不断连;
  • 头部压缩换为 QPACK,因为 HPACK 的动态表依赖有序传输,在 QUIC 上会引入新的队头阻塞;
  • 协议层仍保留 Server Push,但主流浏览器已基本弃用,实际使用 103 Early Hints + preload。

详见 HTTP/3 与 QUIC

7. 本章面试题

HTTP/1.1 相比 1.0 有哪些改进?
  • 默认长连接(keep-alive)——一条 TCP 连接可复用于多个请求,省掉重复握手。1.0 是一请求一连接;
  • 强制 Host——让虚拟主机成为可能,一个 IP 可托管多个站点(1.0 时代一个 IP 只能放一个站点);
  • 管道化(pipelining)——可连续发多个请求,但要求响应按序返回,因此实际很少用;
  • 新增 PUT、DELETE、OPTIONS、PATCH 等方法;
  • 新增缓存字段 If-Modified-SinceIf-None-Match(见 网络-13);
  • Range 头支持断点续传
  • 支持分块传输Transfer-Encoding: chunked),便于传输大文件和动态内容。
什么是队头阻塞?HTTP/1.1、HTTP/2、HTTP/3 分别怎么处理?(高频)

关键是区分两个层次的队头阻塞:

  • HTTP/1.1 —— HTTP 层队头阻塞:pipelining 要求响应按请求顺序返回,前一个响应慢就堵住后面所有响应。当时的绕法是开多条连接(每域名 6 条)、域名分片、资源合并。
  • HTTP/2 —— 解决了 HTTP 层,但引入了 TCP 层队头阻塞:用"流 + 帧"实现单连接多路复用,HTTP 层不再排队。但所有流跑在同一条 TCP 连接上,而 TCP 必须按序交付——一个 TCP 段丢失会阻塞连接上的全部流
  • HTTP/3 —— 换掉传输层才真正解决:基于 QUIC,每个流独立维护序列号和重传状态,一个流丢包只阻塞它自己。

加分点:指出 HTTP/2 在高丢包环境(移动网络)下有时反而不如多连接的 HTTP/1.1,因为它把"一个慢响应堵一条连接"变成了"一个丢包堵全部流"。详见 网络-18

HTTP/2 有哪些核心改进?
  1. 二进制分帧——不再用纯文本,把"Header + Body"打散成 HEADERS 帧和 DATA 帧;
  2. 多路复用——引入"流(Stream)“概念,一条 TCP 连接上并发多个流,每个流有唯一 ID(客户端用奇数、服务端用偶数,避免冲突);
  3. 头部压缩(HPACK)——双方维护同步的动态表,用索引号代替重复字符串,并用哈夫曼编码,压缩率 50%~90%;
  4. 流优先级流量控制
  5. Server Push——协议仍定义,但 Chrome 106 起等主流浏览器已基本弃用,现在推荐 103 Early Hints 替代。

注意 HTTP/2 保持了语义与 HTTP/1 完全一致(方法、URI、状态码、头字段都不变),只改了语法,所以上层应用无需修改。

HTTP/2 的响应是无序的,怎么和请求对应上?

流 ID(Stream Identifier)

客户端发起请求时分配一个唯一的流 ID,服务端返回响应时使用相同的流 ID。客户端据此把响应归属到对应的请求。

流 ID 的分配规则:客户端发起的用奇数,服务端发起的用偶数,这样双方独立分配也不会冲突,无需额外协商。ID 还必须单调递增,收到更小的新 ID 可判定为协议错误。