HTTP 各版本简述

HTTP 1.0

HTTP 1.0 实际上作为最原始的版本,并没有使用多久就被 HTTP 1.1 代替了,后续实际上普遍认为 HTTP 1.1 是第一个广泛使用的 HTTP 版本。

原始的 HTTP 由于没有长连接,也就是每个 HTTP 请求都是单独的 TCP 连接,这就会导致在请求多个小文件时延迟较高,而网页资源大部分都是由多个小文件组成。

这一性能缺陷源自 TCP 连接的握手过程和流量控制的特性,每个 TCP 连接都要经过握手过程,这给每个连接带来了固定的延迟开销。另一方面,TCP 连接在开始时要经过慢开始阶段,而小文件的传输经常在慢开始阶段就发送完了,吃不满网络带宽。这一问题称之为 连接无法复用

另一个严重的问题是 队头阻塞问题,HTTP 在响应请求时,只能按照请求顺序响应。也就是第二个请求必须等第一个请求响应完毕才能发送。这样就会导致假设第一个请求失败了,后续所有请求都要等待,网络利用率较低。

HTTP 1.1

HTTP 1.1 为了解决连接无法复用的问题,引入了 长连接(Keep-Alive),长连接可以使得多个 HTTP 请求和响应在一个 TCP 中传输,从而避免多个 TCP 连接和断开的开销。通过 HTTP 头部 Connection: keep-alive 字段进行指定,在 HTTP 1.1 版本中默认就是长连接。

然而长连接并不能解决队头阻塞问题,为了解决队头阻塞问题,HTTP 1.1 提出了 管线化(Pipelining)。也就是 HTTP 请求时可以不必等待上一个响应到达,可以直接发送下一个请求。然而这个技术仍然没有完全解决队头阻塞问题,因为服务端仍然需要按照顺序响应,当一个请求响应阻塞时,后续的请求都不会得到响应。

除了这两个特性之外,另一个主要特性就是缓存机制,通过 HTTP 头部字段 ExpiredCache-Control 等字段控制。当缓存满足强缓存状态时(根据缓存过期时间和用户行为判断),直接从本地读取缓存并使用。当缓存满足协商缓存状态时,会向服务器发起请求,服务器会根据头部字段来判断缓存是否命中,如果命中,则通知客户端直接使用,否则通知客户端重新发起一个请求,来获取资源。

HTTP 1.1 还有其他新增的特性和功能,但是比较庞杂的本文就略过了。

外围工程

由于管线化并不能完全解决队头阻塞问题,各个浏览器和开发人员发明了很多技巧尝试解决队头阻塞问题。

第一个就是域名分片,将多个资源文件分散到不同的域名中,在请求时,可以并行地去多个域名中请求资源,虽然这不同的域名最终指向的服务器可能就只有一个。此时就没有队头阻塞问题了,不需要等待哪个资源请求完才能请求下一个。

在具体实践中,浏览器在请求时也不会只有一个 TCP 连接,一般都会产生多个 TCP 连接来请求资源,避免队头阻塞问题。

然而这些外围工程为了解决队头阻塞问题都引入了并行操作,而并行操作最终需要负责并发顺序的管理,给开发带来了更多的复杂度。

还有一些资源整合的方式避免小文件的多次请求,例如多个图片放在一个图片里,然后在客户端进行拆分;CSS 和 JS 文件与 HTML 文件整合等等。

HTTP 2

二进制帧

HTTP 2 为了优化解析、传输效率,使用 二进制格式传输数据,而不是 HTTP 1.1 中的文本传输数据。

为什么数据用二进制传输数据能够提高解析和传输效率?

在各类编程语言中,数据在内存中就是二进制的格式,当需要传输时,需要转化为文本,随后再传输给对方,而对方在接收到文本之后,还要将文本信息解析成二进制数据。当然,用二进制传输并不能完全避免解析过程,但是二进制解析通常比文本解析快得多。

另一方面是二进制数据流的压缩效率比文本数据更高,所以同样的字节数能够承载更多的数据。

基于以上两点原因,HTTP 2 采用二进制帧来作为传输的基本单位。一个帧是传输信息的最小单位,一个消息是逻辑信息的最小单位。例如一次请求,就可以看作是一个消息,而一个消息可能由多个帧构成。

多路复用

另一个主要的改进是 多路复用,队头阻塞问题终于在这个版本得到解决。多路复用是基于二进制帧的,为了理解多路复用,我们需要先了解二进制传输中的基本概念。

在一个 TCP 连接中,我们将帧视为最小的传输单元,假想一个流作为一次请求响应交互的虚拟通道。也就是说在一个流上,只会传输该次请求和响应的数据帧。

在底层的 TCP 连接上,不同流之间的帧可以交替传输,而到达终端之后,会将帧的所属按照帧的顺序拼接回来,以此实现真正的并发。

为了在不同流中拼接出正确请求和响应的消息,帧头部包含了数据长度、帧类型、一些额外的 flag 和流标识符(标识属于哪个流)。每个流中,帧是按照顺序发送的,所以对方只需要按照接收到的顺序还原成消息即可。

除此之外,为了保证上层应用的功能,还有流控制、优先级和依赖等机制来决定帧的发送顺序。

头部压缩

在 HTTP 1.1 中,每次请求都会附带很多头部字段,有些字段在大部分情况下是冗余的。HTTP 2 提出了 HPACK 压缩算法来减少头部大小,降低了传输开销。

HPACK 算法通过在客户端和服务器之间共享一张头部字段表,当字段表中能够找到匹配时,只需要发送索引号即可,而不是完整的头部信息,有效地减少了重复头部的传输量。

服务器推送

服务器推送功能允许在客户端发出请求之前,「预判」地发送一些资源给客户端,能够减少请求的次数。在服务器推送一个资源时会发送 PUSH_PROMISE 帧,此时客户端可以拒绝或接收,假如接收,则会创建一个新的流来接收这些资源。

HTTP 3

QUIC 协议

然而 HTTP 2 仍然存在问题,由于到 HTTP 2 开始,TLS 都是默认支持的,在连接建立层面,首先要进行 TCP 握手,然后再经过 TLS 握手,导致握手阶段的开销就很大。所以 HTTP 3 尝试将 TCP 握手和 TLS 握手整合到一次握手过程中。

然而 TCP 握手是底层握手协议,修改不了,于是 HTTP 3 基于 UDP 实现了 QUIC 协议,QUIC 一边实现了 TCP 可靠交付,另一边将 TLS 握手进行了整合。所以 QUIC 握手 = TCP 握手 + TLS 握手。

更好的多路复用

在 HTTP 2 中,多路复用虽然一定程度上解决了应用层面的队头阻塞问题,然而在 TCP 层面仍然存在队头阻塞问题,如果 TCP 报文队头部分丢失,TCP 为了保证可靠性交付,必须等待这个报文到达才递交给上层。所以在这一部分存在 TCP 层面的队头阻塞问题。

而 HTTP 3 采用了 QUIC 协议之后,基于 UDP 协议进行,可以自己实现自己的可靠交付机制。该可靠交付机制是基于流的,也就是一个流中,所有数据必须到达之后才交付给上层。

在原始 TCP 中,由于无法修改可靠交付机制,TCP 流并不管上层应用是如何定义可靠交付的。举个例子,假设存在两个流,第一个流中的数据已经到达了,然而第二个流中的数据丢失了,且该帧在 TCP 层面是处于队头位置,则第一个流必须等待第二个流中的数据到达,TCP 协议才会将数据交付给上层。

换句话说,TCP 流实际上可以先将流 1 中的数据先交给应用层处理,然而在 TCP 层面并不知道这一点,所以他必须等待队头数据。

应用层:HTTP2
TCP 流:| 流2_1(x) | 流1_1(√) | 流1_2(√) | 流1_3(√) | 流2_2(√) | 流2_3(√) |

HTTP 3 的 QUIC 协议重写了可靠交付机制,于是 TCP 层面的队头阻塞问题就得到解决了。

连接迁移

假如在移动网络中,信号切换、基站切换等都会导致 IP 地址的变化,此时就必须重新建立连接,重新发送请求。在 QUIC 协议中是通过一个唯一 ID 来区别的,即使 IP 地址发生变化,ID 号不变,就可以继续之前的请求继续传输,而不用重新来过。

历史局限

虽然 HTTP 3 解决了很多问题,但是他大刀阔斧地修改了底层协议,导致目前很多中间设备支持不好。现存的很多网络基础设施(如负载均衡器、防火墙、透明代理)是为 TCP/IP 优化的。这些设备需要更新或替换才能支持基于 UDP 的 QUIC 协议,这需要时间和成本。

许多网络运营商对 UDP 协议不友好,可能会导致引发错误的流量拦截。

上一篇 下一篇

评论 | 0条评论