HTTP 是浏览器中最重要且使⽤最多的协议,是浏览器和服务器之间的通信语⾔,也是互联⽹的基⽯。随着浏览器的发展,HTTP 为了能适应新的形式也在持续进化。
HTTP/0 .9
于 1991 年提出的,主要⽤于学术交流,需求很简单⸺⽤来在⽹络之间传递 HTML 超⽂本的内容。
整体来看,它的实现也很简单,采⽤基于请求响应的模式,从客⼾端发出请求,服务器返回数据。
⼀个完整的请求流程:
- 因为 HTTP 基于 TCP 协议,所以客⼾端先要根据 IP 地址、端⼝和服务器建⽴ TCP 连接,⽽建⽴连接的过程就是 TCP 协议三次握⼿的过程
- 建⽴好连接后,发送⼀个 GET 请求⾏的信息,如
GET /index.html
⽤来获取index.html
- 服务器接收请求信息后,读取对应的 HTML⽂件,并将数据以 ASCII 字符流返回给客⼾端
- HTML ⽂档传输完成后,断开连接
HTTP/0.9 的需求就是⽤来传输体积很⼩的 HTML ⽂件,所以有以下三个特点:
- 只有⼀个请求⾏,没有请求头和请求体,因为只需要⼀个请求⾏就可以完整表达客⼾端的需求
- 服务器也没有返回头信息,因为服务器端并不需要告诉客⼾端太多信息,只需要返回数据就可以
- 返回的⽂件内容是以 ASCII 字符流来传输的,因为都是 HTML 格式的⽂件,所以使⽤ ASCII 字节码来传输最合适
HTTP/1.0
1994 年底出现了拨号上⽹服务,同年⽹景推出⼀款浏览器,从此万维⽹就不局限于学术交流了,⽽是进⼊了⾼速的发展阶段。万维⽹的⾼速发展带来了很多新的需求,⽽ HTTP/0.9 已经不能适⽤新兴⽹络的发展,所以这时就需要⼀个新的协议来⽀撑新兴⽹络,这就是 HTTP/1.0 诞⽣的原因。
新的特性
核⼼诉求:⽀持多种类型的⽂件传输
在浏览器中展⽰的不单是 HTML⽂件,还包括了 JavaScript、CSS、图⽚、⾳频、视频等不同类型的⽂件,⽽且⽂件格式不仅仅局限于 ASCII 编码,还有很多其他类型编码的⽂件
如何实现:
- 引⼊请求头和响应头:以 Key-Value 形式保存,在 HTTP 发送请求时,会带上请求头信息,服务器返回数据时,会先返回响应头信息
- 在发起请求时通过请求头告诉服务器它期待服务器返回什么类型的⽂件、采取什么形式的压缩、提供什么语⾔的⽂件以及⽂件的具体编码
- 服务器接收到浏览器发送过来的请求头信息之后,会根据请求头的信息来准备响应数据,并将具体情况通过响应头返回
HTTP/1.0 的其他特性(都是通过请求头和响应头来实现的):
- 有的请求服务器可能⽆法处理,或处理出错,就需要告诉浏览器服务器最终处理该请求的情况,这就引⼊了状态码。状态码是通过响应⾏的⽅式来通知浏览器的
- 为了减轻服务器的压⼒,在 HTTP/1.0 中提供了 Cache 机制,⽤来缓存已经加载过的数据
- 服务器需要统计客⼾端的基础信息,⽐如 Windows 和 macOS 的⽤⼾数量分别是多少,所以 HTTP/1.0 的请求头中还加⼊了⽤⼾代理的字段
存在的问题
无法复用连接
HTTP/1.0 为每个请求单独新开一个 TCP 连接。每一个 HTTP 请求-响应,都要先建立 TCP 连接(三次握手),然后完成请求-响应后,再销毁连接(四次挥手)。这就导致每次请求-响应都是相互独立的,无法保持状态。
由于每个请求都是独立的连接,因此会带来下面的问题:
- 连接的建立和销毁都会占用服务器和客户端的资源,造成内存资源的浪费
- 连接的建立和销毁都会消耗时间,造成响应时间的浪费
- 无法充分利用带宽,造成带宽资源的浪费
- TCP 协议的特点是「慢启动」(拥塞控制),即一开始传输的数据量少,一段时间之后达到传输的峰值。而上面这种做法,会导致大量的请求在 TCP 达到传输峰值前就被销毁了
实际上,在 HTTP/1.0 后期,虽然没有官方标准,但开发者们慢慢形成了一个共识:只要请求头中包含 Connection: keep-alive
,就表示客户端希望开启长连接,希望服务器响应后不要关闭 TCP 连接。如果服务器认可这一行为,即可保持 TCP 连接。
HTTP 队头阻塞
HTTP/1.1
新的特性
改进持久连接(长连接)
为了解决 HTTP/1.0 的问题,HTTP/1.1 默认开启长连接,即让同一个 TCP 连接服务于多个请求-响应。
在这种情况下,多次请求响应可以共享同一个 TCP 连接,这不仅减少了 TCP 的握手和挥手时间,同时可以充分利用 TCP「慢启动」的特点,有效的利用带宽。
当需要的时候,任何一方都可以关闭 TCP 连接。
连接关闭的情况主要有三种:
- 客户端在某一次请求中设置了
Connection:close
,服务器收到此请求后,响应结束立即关闭 TCP - 在没有请求时,客户端会不断对服务器进行心跳检测(一般每隔 1 秒)。一旦心跳检测停止,服务器立即关闭 TCP
- 当客户端长时间没有新的请求到达服务器,服务器会主动关闭 TCP。运维人员可以设置该时间(如 Nginx 配置)
管线化和 HTTP 队头阻塞
HTTP 队头阻塞
持久连接虽然能减少 TCP 的建⽴和断开次数,但是它需要等待前⾯的请求返回之后,才能进⾏下⼀次请求。
如果 TCP 通道中的某个请求因某些原因没有及时返回,就会阻塞后⾯的所有请求。
队头阻塞虽然发生在服务器,但这个问题的根源是客户端无法知晓服务器的响应是针对哪个请求的。
正是由于存在队头阻塞,我们常常使用下面的手段进行优化:
- 通过减少文件数量,从而减少队头阻塞的几率
- 通过开辟多个 TCP 连接,实现真正的、有缺陷的并行传输
浏览器会根据情况,为打开的页面自动开启 TCP 连接,对于同一个域名的连接最多 6 个,如果要突破这个限制,就需要把资源放到不同的域中。
管线化
HTTP/1.1 中的管线化是指将多个 HTTP 请求整批提交给服务器的技术,虽然可以整批发送请求,不过服务器依然需要根据请求顺序来回复浏览器的请求
然而,管道化并非一个成功的模型,它带来的队头阻塞造成非常多的问题,FireFox、Chrome 都做过管线化的试验,但是由于各种原因,最终都放弃了管线化技术
其他特性
缓存处理
新增 cache-control
、etag
等消息头,优化缓存机制,详见 HTTP 缓存协议
断点传输
在上传/下载资源时,如果资源过大,将其分割为多个部分,分别上传/下载,如果遇到网络故障,可以从已经上传/下载好的地方继续请求,不用从头开始,提高效率。详见 断点续传
提供虚拟主机的⽀持
在 HTTP/1.0,每个域名绑定⼀个唯⼀的 IP 地址,⼀个服务器只能⽀持⼀个域名。但是随着虚拟主机技术的发展,需要实现在⼀台物理主机上绑定多个虚拟主机,每个虚拟主机都有⾃⼰的单独的域名,这些单独的域名都公⽤同⼀个 IP 地址
因此,HTTP/1.1 的请求头中增加了 Host 字段,⽤来表⽰当前的域名地址,这样服务器就可以根据不同的 Host 值做不同的处理
对动态⽣成的内容提供了完美⽀持
在 HTTP/1.0,需要在响应头设置完整的数据⼤⼩,如 Content-Length: 901
,这样浏览器就可以根据设置的数据⼤⼩来接收数据。不过随着服务器端的技术发展,很多⻚⾯的内容都是动态⽣成的,因此在传输数据之前并不知道最终的数据⼤⼩,这就导致了浏览器不知道何时会接收完所有的⽂件数据
HTTP/1.1 通过引⼊ Chunk transfer 机制来解决这个问题,服务器会将数据分割成若⼲个任意⼤⼩的数据块,每个数据块发送时会附上上个数据块的⻓度,最后使⽤⼀个零⻓度的块作为发送数据完成的标志。这样就提供了对动态内容的⽀持
客⼾端 Cookie、安全机制
详见 cookie
存在的问题
HTTP/1.1 的主要问题:对带宽的利⽤率并不理想
第⼀个原因:TCP 的慢启动
⼀旦⼀个 TCP 连接建⽴后,就进⼊了发送数据状态,刚开始 TCP 协议会采⽤⼀个⾮常慢的速度去发送数据,然后慢慢加快发送数据的速度,直到发送数据的速度达到⼀个理想状态
慢启动是 TCP 为了减少⽹络拥塞的⼀种策略:拥塞控制,判断当前网络环境状态
之所以说慢启动会带来性能问题,是因为⻚⾯中常⽤的⼀些关键资源⽂件本来就不⼤,如 HTML⽂件、CSS ⽂件和 JavaScript ⽂件,通常这些⽂件在 TCP 连接建⽴好之后就要发起请求的,但这个过程是慢启动,所以耗费的时间⽐正常的时间要多很多,这样就推迟了宝贵的⾸次渲染⻚⾯的时⻓
第⼆个原因:多个 TCP 连接竞争带宽
同时开启多条 TCP 连接,这些连接会竞争固定的带宽。带宽不⾜时,各个 TCP 连接就需要动态减慢接收数据的速度
有的 TCP 连接下载的是⼀些关键资源,如 CSS ⽂件、JS ⽂件等,⽽有的 TCP 连接下载的是图⽚、视频等普通的资源⽂件,但是多条 TCP 连接之间不能协商让哪些关键资源优先下载,这样可能影响那些关键资源的下载速度
第三个原因:HTTP/1.1 队头阻塞
在 HTTP/1.1 使⽤持久连接时,虽然能公⽤⼀个 TCP 管道,但是在⼀个管道中同⼀时刻只能处理⼀个请求,在当前的请求没有结束之前,其他的请求只能处于阻塞状态
其中慢启动和 TCP 连接之间相互竞争带宽是由于 TCP 本⾝的机制导致的,队头阻塞是由于 HTTP/1.1 的机制导致的
HTTP/2
新的特性
多路复⽤与二进制分帧
解决方案
解决 HTTP/1.1 对带宽的利⽤率不理想的问题
⼀个域名只使⽤⼀个 TCP ⻓连接:
- 整个⻚⾯资源的下载过程只需要⼀次慢启动
- 同时也避免了多个 TCP 连接竞争带宽所带来的问题
实现资源的并⾏请求:解决了应⽤层⾯的队头阻塞问题
- 任何时候都可以将请求发送给服务器,⽽并不需要等待其他请求的完成
- 服务器也可以随时返回处理好的请求资源给浏览器
多路复⽤机制
每个请求都有⼀个对应的 ID,浏览器可以随时将请求发送给服务器,服务器也可以随意返回内容(浏览器接收到之后,会筛选出相同 ID 的内容,将其拼接为完整的 HTTP 响应数据)
HTTP/2 使⽤了多路复⽤技术,可以将请求分成⼀帧⼀帧的数据去传输,这样带来了⼀个额外的好处,就是当收到⼀个优先级⾼的请求时,服务器可以暂停之前的请求来优先处理关键资源的请求
⼆进制分帧
HTTP/2 允许以更小的单元传输数据,每个传输单元称为帧,而每一个请求或响应的完整数据称为流,每个流有自己的编号,每个帧会记录所属的流(每个帧都带了一个头部,记录了流的 ID)。
引⼊⼆进制分帧层,就实现了 HTTP 的多路复⽤技术:
HTTP/2 的请求和接收过程:
- 浏览器准备好请求数据,包括请求⾏、请求头、请求体
- 这些数据经过⼆进制分帧层处理后,会被转换为⼀个个带有请求 ID 编号的帧,通过协议栈将这些帧发送给服务器
- 服务器接收到所有帧之后,会将所有相同 ID 的帧合并为⼀条完整的请求信息
- 服务器处理该条请求,并将处理的响应⾏、响应头和响应体分别发送⾄⼆进制分帧层
- ⼆进制分帧层会将这些响应数据转换为⼀个个带有请求 ID 编号的帧,经过协议栈发送给浏览器
- 浏览器接收到响应帧之后,会根据 ID 编号将帧的数据提交给对应的请求
其他特性
都是基于⼆进制分帧层
可以设置请求的优先级
在发送请求时,可以标上该请求的优先级,这样服务器接收到请求后,会优先处理优先级⾼的请求
服务器推送
当⽤⼾请求⼀个⻚⾯后,服务器知道该⻚⾯会引⽤⼏个重要的⽂件,那么在接收到⻚⾯请求后,附带将要使⽤的⽂件⼀并发送给浏览器,这样当浏览器解析完页面之后,就能直接拿到需要⽂件,这对⾸次打开⻚⾯的速度起到了⾄关重要的作⽤
头部压缩
HPACK 算法,静态表和动态表
HTTP/2 之前,所有的消息头都是以字符的形式完整传输的。可实际上,大部分头部信息都有很多的重复。为了解决这一问题,HTTP/2 使用头部压缩来减少消息头的体积
对于两张表都没有的头部,则使用 Huffman 编码压缩后进行传输,同时添加到动态表中
其他说明
HTTP/2 的语义和 HTTP/1.1 依然是⼀样的,也就是说它们通信的语⾔并没有改变,开发者依然可以通过 Accept 请求头告诉服务器希望接收到什么类型的⽂件,依然可以使⽤ Cookie 来保持登录状态,依然可以使⽤ Cache 来缓存本地⽂件,这些都没有变,发⽣改变的只是传输⽅式。这⼀点对开发者来说尤为重要,这意味着我们不需要为 HTTP/2 去重建⽣态,并且 HTTP/2 推⼴起来会也相对更轻松
存在的问题(TCP 的缺陷)
TCP 的队头阻塞
在 TCP 传输过程中,由于单个数据包的丢失⽽造成的阻塞
虽然 HTTP/2 解决了应⽤层⾯的队头阻塞问题,不过和 HTTP/1.1 ⼀样,HTTP/2 依然是基于 TCP 协议的,⽽ TCP 最初就是为了单连接⽽设计的。可以把 TCP 连接看成是两台计算机之前的⼀个虚拟管道,计算机的⼀端将要传输的数据按照顺序放⼊管道,最终数据会以相同的顺序出现在管道的另外⼀头
如果在数据传输的过程中,有⼀个数据因为⽹络故障或其他原因⽽丢包了,那么整个 TCP 的连接就会处于暂停状态,需要等待丢失的数据包被重新传输过来
HTTP/2 VS HTTP/1.1:
在 HTTP/2 中,多个请求是跑在⼀个 TCP 管道中的,如果其中任意⼀路数据流中出现了丢包的情况,那么就会阻塞该 TCP 连接中的所有请求
这不同于 HTTP/1.1,使⽤ HTTP/1.1 时,浏览器为每个域开启了 6 个 TCP 连接,如果其中的 1 个 TCP 连接发⽣了队头阻塞,那么其他的 5 个连接依然可以继续传输数据
所以随着丢包率的增加,HTTP/2 的传输效率也会越来越差。有测试数据表明,当系统达到了 2% 的丢包率时,HTTP/1.1 的传输效率反⽽⽐ HTTP/2 表现得更好
TCP 建⽴连接的延时
除了 TCP 队头阻塞之外,TCP 的握⼿过程也是影响传输效率的⼀个重要因素
⽹络延迟 RTT(Round Trip Time):从浏览器发送⼀个数据包到服务器,再从服务器返回数据包到浏览器的整个往返时间,RTT 是反映⽹络性能的⼀个重要指标
建⽴TCP 连接需要花费多少个 RTT?
HTTP/1 和 HTTP/2 都是使⽤TCP 协议来传输的,⽽如果使⽤ HTTPS 的话,还需要使⽤ TLS 协议进⾏安全传输,⽽使⽤ TLS 也需要⼀个握⼿过程,这样就需要有两个握⼿延迟过程:
- 在建⽴ TCP 连接时,需要和服务器进⾏三次握⼿来确认连接成功,也就是说需要在消耗完 1.5 个 RTT 之后才能进⾏数据传输
- 进⾏TLS 连接,TLS 有两个版本⸺TLS1.2 和 TLS1.3,每个版本建⽴连接所花的时间不同,⼤致是需要 1〜2 个 RTT
总之,在传输数据之前,需要花掉 3〜4 个 RTT
TCP 协议僵化问题
是否可以通过改进 TCP 协议来解决上述的这些问题呢?答案是:⾮常困难
- 原因一:中间设备的僵化
- 中间设备:在互联⽹的各处搭建各种设备,如路由器、防⽕墙、NAT、交换机等
- 中间设备通常依赖⼀些很少升级的软件,这些软件使⽤了⼤量的 TCP 特性,这些功能被设置之后就很少更新了
- 所以,如果在客⼾端升级了 TCP 协议,但是当新协议的数据包经过这些中间设备时,它们可能不理解包的内容,于是这些数据就会被丢弃掉
- 原因二:操作系统
- 因为 TCP 协议都是通过操作系统内核来实现的,应⽤程序只能使⽤不能修改
- 通常操作系统的更新都滞后于软件的更新,因此要想⾃由地更新内核中的 TCP 协议也是⾮常困难的
协议有了新的特征,而在中间设备引入(了解)这些新特性之前,它们会认为这种特征的数据包是非法的、恶意的,于是会将这种流量直接扔掉,或是拖延到用户不再想使用这些新特征的程度
这种问题就被称之为“协议僵化”,协议僵化影响了 TCP 协议的改变
尽可能将通信加密是对抗僵化的唯一有效手段,加密可以防止中间设备看到协议传输的绝大部分内容
HTTP/3
QUIC 协议
HTTP/2 存在⼀些⽐较严重的与 TCP 协议相关的缺陷,但由于 TCP 协议僵化,⼏乎不可能通过修改 TCP 协议⾃⾝来解决这些问题,那么解决问题的思路是绕过 TCP 协议,发明⼀个 TCP 和 UDP 之外的新的传输协议
但是这也⾯临着和修改 TCP⼀样的挑战,因为中间设备的僵化,这些设备只认 TCP 和 UDP,如果采⽤了新的协议,新协议在这些设备同样不被很好地⽀持
因此,HTTP/3 选择了⼀个折衷的⽅法⸺UDP 协议,基于 UDP 实现了类似于 TCP 的多路数据流、传输可靠性等功能,我们把这套功能称为 QUIC 协议
HTTP/3 中的 QUIC 协议集合了以下⼏点功能:
- 实现了类似 TCP 的流量控制、传输可靠性的功能。虽然 UDP 不提供可靠性的传输,但 QUIC 在 UDP 的基础之上增加了⼀层来保证数据可靠性传输。它提供了数据包重传、拥塞控制以及其他⼀些 TCP 中存在的特性
- 集成了 TLS 加密功能
- 实现了 HTTP/2 中的多路复⽤功能。和 TCP 不同,QUIC 实现了在同⼀物理连接上可以有多个独⽴的逻辑数据流。实现了数据流的单独传输,就解决了 TCP 中队头阻塞的问题
- 实现了快速握⼿功能。由于 QUIC 是基于 UDP 的,所以 QUIC 可以实现使⽤ 0-RTT 或者 1-RTT 来建⽴连接,这意味着 QUIC 可以⽤最快的速度来发送和接收数据,这样可以⼤⼤提升⾸次打开⻚⾯的速度
HTTP/3 的挑战
- 从⽬前的情况来看,服务器和浏览器端都没有对 HTTP/3 提供⽐较完整的⽀持。Chrome 虽然在数年前就开始⽀持 Google 版本的 QUIC,但是这个版本的 QUIC 和官⽅的 QUIC 存在着⾮常⼤的差异
- 部署 HTTP/3 也存在着⾮常⼤的问题:因为系统内核对 UDP 的优化远远没有达到 TCP 的优化程度,这也是阻碍 QUIC 的⼀个重要原因
- 中间设备僵化的问题:这些设备对 UDP 的优化程度远远低于 TCP,据统计使⽤ QUIC 协议时,⼤约有 3%〜7% 的丢包率