HTTP 协议全称为 HyperText Transfer Protocol,即超文本传输协议。
HTTP 协议是干啥的呢?答案是用于客户端与服务器端之间的通信。我们日常上网过程中最常见的就是 HTTP 协议了,浏览器是最常见的 HTTP 客户端。
比如我们使用浏览器访问淘宝时,浏览器就会发送一个遵循 HTTP 协议的请求报文到淘宝服务器,告诉淘宝服务器自己想要获取淘宝首页信息。
淘宝服务器收到此报文后,则会发送一个同样遵循 HTTP 协议的响应报文到浏览器,此响应报文中包含淘宝首页的内容。
浏览器收到响应报文后解析其内容并展示在界面上。
客户端向服务器端发送的信息称为请求报文,一般结构如下:
请求行用于说明要做些什么,包含三部分内容,中间用空格分割。
举个栗子:
面试中常见的一个问题: GET 和 POST 的区别是什么?,在这里做一下解答。
请求头用于向服务器传递一些额外的重要信息,比如所能接收的语言等。
请求头由字段名和字段值构成,二者之间用冒号进行分隔。常见的一些请求头有:
请求头 |
含义 |
Host |
接收请求的域名 |
User-Agent |
客户端软件的名称和版本号等相关信息 |
Connection |
设置发送响应之后 TCP 连接是否继续保持的通信选项 |
Cache-Control |
控制缓存的相关信息 |
Referer |
记录请求的来源(当通过点击超级链接进入下一个页面时,会记录上一个页面的 URI) |
Accept |
客户端可支持的数据类型, 以 MIME 类型来表示 |
Accept-Encoding |
客户端可支持的编码格式 |
Accept-Language |
客户端可支持的语言 |
If-Modified-Since |
用于判断资源的缓存是否有效(客户端通知服务器,本地缓存的最后变更时间) |
If-None-Match |
用于判断资源的缓存是否有效 |
Range |
用于断点续传,指定第一个字节的位置和最后一个字节的位置。 |
Cookie |
表示请求者的身份,用于保存状态信息 |
请求空行用于表明请求头已经结束。
请求体用于传送客户端要发给服务器的数据,比如请求参数,通常出现在 POST 请求方法中,而 GET 方法无请求体,它的请求参数直接会显示在网址上面。
请求行和请求头的数据都是文本形式且格式化的,而请求体不同,其可以包含任意的二进制数据,比如文本、图片、视频等等。
服务器向客户端发送的信息称为响应报文,响应报文的结构一般如下:
响应行用于说明对请求的处理情况,包含三部分内容,中间用空格分割。
举个栗子:
面试中常见的一个问题: HTTP 有哪些常见状态码?,在这里做一下解答。
响应头用于向客户端传递一些额外的重要信息,比如响应内容的长度等。
响应头由字段名和字段值构成,二者之间用冒号进行分隔。常见的一些响应头有:
响应头 |
含义 |
Date |
日期时间信息,表示服务器产生并发送响应报文的日期和时间。 |
Server |
表示HTTP服务器应用程序的信息,类似于请求报文中的 User-Agent |
Location |
此字段会配合重定向使用,用于提供重定向后新的 URI。 |
Connection |
设置发送响应之后 TCP 连接是否继续保持的通信选项 |
Cache-Control |
控制缓存的相关信息 |
Content-Type |
服务器返回的响应类型 |
Content-length |
服务器返回的响应长度 |
Content-Encoding |
服务器返回的响应编码 |
Content-Language |
服务器返回的响应语言 |
Last-Modified |
指定响应内容最后的修改时间 |
Expires |
表示资源失效的时间,浏览器会在指定过期时间内使用本地缓存 |
Etag |
用于协商缓存,返回一个摘要值 |
Accept-Ranges |
用于断点续传,指定服务器所支持的内容范围 |
Set-Cookie |
设置状态信息 |
响应空行用于表明响应头已经结束。
响应体用于传送服务器要发给浏览器的正文。
同请求报文的请求体一样,响应体可包含任意的二进制数据。浏览器收到响应报文后,则会将正文加载到内存,然后解析渲染,最后显示页面内容。
客户端发送一系列请求给服务器,如果服务器与客户端对每个请求/响应对都经过一个单独的 TCP 连接发送,则称为非持续连接,也称为短连接;如果经过相同的 TCP 连接发送,则称为持续连接,也称为长连接。
比如打开一个 Web 页面时,假设该页面含有一个 HTML 基础文件和 2 张图片,如果客户端与服务器通过同一个 TCP 连接来获取这 3 个数据,则为持续连接,如果通过建立 3 次不同的 TCP 连接,则为非持续连接。
非持续连接的缺点:
HTTP(1.1 及之后) 默认采用持续连接方式,但也可配置成非持续连接方式。在报文中使用 Connection 字段来表示是否使用持久连接。
注意:持久连接不是永久连接,一般在一个可配置的超时间隔后,如果此连接仍未被使用,HTTP 服务器就会关闭该连接。
对于一些短时间内不会产生变化的资源,客户端(浏览器)可以在一次请求后将服务器响应的资源缓存在本地,之后直接读取本地的数据,而不必再重新发送请求。
我们经常会接触到『缓存』这一概念,比如由于内存和 CPU 之间速度差距较大,为了进一步提升电脑性能,于是设计了 L1 缓存、L2 缓存等,让 CPU 先从缓存中取数据,如果取不到,再去内存取。
又比如在后端开发中,由于数据库一般存储在硬盘上,读取速度较慢,于是可能会采用 redis 等内存数据库作为缓存,先去 Redis 中取数据,如果取不到,再去数据库中取。
再比如在操作系统中,由于页表进行地址转换的速度较慢,于是有了 TLB 快表,当需要进行逻辑地址到物理地址的转换时,先去查询速度更快的 TLB 快表,如果查不到,再去查询页表,此时 TLB 快表就是一种缓存。
缓存的主要目的在于提升查询速度,一般逻辑如图所示。
同样,在 HTTP 设计中也有缓存的概念,主要是为了加快响应速度,HTTP 缓存的实现依赖于请求报文和响应报文中的一些字段,分为强缓存和协商缓存。
强缓存指的是在缓存数据未失效的情况下,那么就会直接使用浏览器的缓存数据,不会再向服务器发送任何请求,逻辑类似于前面举的 L1 缓存、Redis、TLB 快表。
具体实现主要是通过 Cache-Control字段和 Expires字段。
Cache-Control 是一个相对时间(即多长时间后过期,http1.1 规范),Expires 是一个绝对时间(即在某个时间点过期,http1.0 规范),如果两个字段同时存在,Cache-Control 的优先级更高。
由于服务器端时间和客户端时间可能不同步,存在偏差,这也就是导致了使用 Expires 可能会存在时间误差,因此一般更推荐使用 Cache-Control 来实现强缓存。
以 Cache-Control 为例,强缓存的具体的实现流程如下:
max-age=秒,表示缓存将于指定毫秒值后过期。比如:cache-control: max-age=31536000,表示缓存将于 365 天后过期。
no-store,表示不允许缓存(包括强缓存和协商缓存)。
no-cache,表示不使用强缓存,而是使用协商缓存,即使用之前必须要先去服务器端验证是否失效,如果没失效,则再使用缓存,如果失效了,则返回最新数据。等价于max-age=0, must-revalidate。
must-revalidate,表示允许缓存,并且如果缓存不过期的话,先使用缓存,如果缓存过期的话,再去服务器端进行验证缓存是否还有效。这里很多小伙伴可能会有疑问,即使没有加上 must-revalidate,有了 max-age 后,缓存过期了不也会去服务器验证吗,加不加 must-revalidate 有什么区别呢?在 HTTP 协议规范中,允许客户端在某些特殊情况下直接使用过期缓存,比如校验请求错误时(如无法再次连通服务器),而加上了 must-revalidate 后,在校验请求错误时,会返回 504 错误码,而不是使用过期缓存。
如果没有过期(且 Cache-Control 没有设置 no-cache 属性和 no-store 属性),则使用该缓存,结束;
否则重新请求服务器;
协商缓存指的是当第一次请求后,服务器响应头 Cache-Control 字段属性设置为 no-cache 或者缓存时间过期了,那么浏览器再次请求时就会与服务器进行协商,判断缓存资源是否有效,即资源是否进行了修改更新。
协商缓存可以基于以下两种方式来实现:
第一种(HTTP/1.0 规范):请求头部中的 If-Modified-Since 字段与响应头部中的 Last-Modified 字段:
如果最后修改时间较大,说明资源有被修改过,则返回最新资源和 200 状态码;
否则说明资源无新修改,返回 304 状态码。
基于时间实现,可能会由于时间误差而出现不可靠问题,并且只能精确到秒级,在同一秒内,Last-Modified 无感知。
如果某些文件被修改了,但是内容并没有任何变化(比如只是修改时间发生了变化),而 Last-Modified 却改变了,导致文件没法使用缓存。
第二种(HTTP/1.1 规范):请求头部中的 If-None-Match 字段与响应头部中的 ETag 字段:
如果二者相等,则资源没有变化,则返回 304 状态码。
如果资源变化了,则返回新资源和 200 状态码。
注意 :
HTTP 是一种无状态协议,即其本身不会记忆请求和响应之间的通信状态,那么 Web 服务器就无法判断此请求到底来自于哪个用户,HTTP 协议中并不会保存关于用户的任何信息。这样设计的好处是不需要额外资源保存用户状态信息,减少了服务器的 CPU 及内存资源的消耗。
但是随着 Web 的发展,很多业务需要保存用户状态。
为了实现保持状态的功能,这就出现了 Cookie。Cookie (服务器给的凭证)类似于我们逛商场时的会员卡(商家给的凭证),记录着我们的身份信息,只要出示了会员卡,商场工作人员就能确定我们的身份。同样的,只要给服务器发送报文时带上了 Cookie,他就知道我们是谁了。
Cookie 中可以包含任意信息,最常见的是包含一个服务器为了进行跟踪而产生的独特的识别码。
举个栗子:
张三在发出第一次请求后,服务器将其状态信息记录下来,比如他的名字、年龄、地址、购物历史等,并通过响应头 Set-Cookie字段,给予其一个 id=12345 的独特识别码作为 Cookie,那么其再次向服务器发出请求时,浏览器会自动在请求报文中的 Cookie 字段中带上 id=12345,服务器就可以通过这个查询到张三的具体信息,从而实现了保持状态的功能。
Cookie 属性:
负数,表示浏览器关闭即失效。默认即为 -1。
正数:失效时刻= 创建时刻+ max-age。
0:表示 Cookie 立即删除,即 Cookie 直接过期(从而实现使 cookie 失效)。
Cookie 的缺点是如果传递的状态信息较多,使得包过大,将会降低网络传输效率。
一般浏览器限制 Cookie 大小为 4KB。
随着互联网的发展,HTTP 也在不断升级打怪,下面分别介绍一下 HTTP/1.1、HTTP/2 以及 HTTP/3 在前一版本基础上的改进之处。
HTTP/1.1 是目前最常见的 HTTP 版本,其相对于 HTTP/1.0 有以下改进。
① 持久连接
这个在前文中已经提到过,HTTP/1.0 中一个 TCP 连接只能发送一个请求和响应,而 HTTP/1.1 进行了优化,同一个 TCP 连接可以发送多次 HTTP 请求,减少了建立和关闭连接的性能开销。
Web 服务软件一般都会提供 keepalive_timeout 参数,用来指定 HTTP 持久连接的超时时间。比如设置了 HTTP 持久连接的超时时间是 60 秒,Web 服务软件就会启动一个定时器,如果完成某个 HTTP 请求后,在 60 秒内都没有再发起新的请求,就会触发回调函数来释放该连接。
② 管道机制
持久连接虽然可以多个请求复用同一个连接,但是每次都需要等到上一个请求响应完成后,才能发送下一个请求。
管道机制中,只要第一个请求发出去了,不必等其回来,就可以发第二个请求出去,即相当于同时发出多个请求,因而可以减少整体的响应时间。
虽然客户端可以同时发出多个 HTTP 请求,不用⼀个个等待响应,但是服务器必须按照接收请求的顺序依次发送对这些管道化请求的响应,以保证客户端能够区分出每次请求的响应内容。这存在下面问题:
如果服务端在处理一个请求时耗时比较长,那么后续请求的处理都会被阻塞住,会导致客户端迟迟收不到数据,这称为「队头堵塞」。
实际上,虽然管道机制的想法很好,但实现却非常困难,因而很多浏览器根本不支持它。一般为了提升性能,采用并行多个 TCP 连接的形式来实现请求的同时发送。
③ 缓存控制
前文已经提到过,HTTP/1.1 在 HTTP/1.0 基础之上,增加了一些请求响应头,以更好的实现对缓存的控制。比如
④ 断点续传
利⽤ HTTP 消息头使⽤分块传输编码,将实体主体分块传输。
HTTP/2 协议本身是基于 HTTPS 的,因此更加安全,其相对于 HTTP/1.1 有以下改进。
① 头部压缩
HTTP/1.1 中的请求头携带大量信息,而且每次都要重复发送,即使是同样的内容,每次请求都需要附带,这会造成性能的损耗。HTTP/2 进行了优化,引入了头信息压缩机制。
客户端和服务器同时维护一张头信息表,高频出现的字段会存入这个表,生成一个索引号。发送报文时直接使用索引号替代字段。另外,索引表中不存在的字段使用哈夫曼编码压缩。
同时,多个请求中,如果请求头相同,则后续请求只需要发送差异的部分,重复的部分无需再发送。
② 二进制帧
HTTP/1.1 的报文为纯文本格式,而 HTTP/2 的报文全面采用二进制格式,并将原始的报文拆分为头信息帧(Headers Frame)和数据帧(Data Frame)。采用二进制格式有利于提升数据传输效率。
③ 多路复用
在 HTTP/2 中定义了流(Stream)的概念,它是二进制帧的双向传输序列,一个数据流对应着一个完整的请求-响应过程,在同一个请求响应过程中,往返的帧会分配一个唯一的流编号(Stream ID)。
在流的支持下,HTTP/2 可以在一个 TCP 连接中传输多个请求或响应,而不用按照顺序一一对应(即实现多路复用),因为它们属于不同的流,所发送的帧头部都会携带 Stream ID,可以通过此 Stream ID 有效区分不同的请求-响应。
因而 HTTP/2 解决了 HTTP/1.1 的『队头阻塞』问题,多个请求 - 响应之间没有了顺序关系,不需要排队等待,降低了延迟,大幅度提高了连接的利用率。
举个栗子:
在一个 TCP 连接里面,服务器同时收到了 A 请求和 B 请求,于是先回应 A 请求,结果发现处理过程非常耗时,于是就发送 A 请求已经处理好的部分,接着回应 B 请求,完成后,再发送 A 请求剩下的部分。
④ 服务端推送
在 HTTP/1.1 中,只能客户端发起请求,服务器对请求进行响应。
而在 HTTP/2 中,服务端可以主动给客户端推送必要的资源,以减少请求延迟时间。
比如当客户端向服务器请求一个 HTML 文件后,服务器除了将此 HTML 文件响应给客户端外,还可以提前主动将此 HTML 中所依赖的 JS 和 css 文件推送给客户端,这样客户端在解析 HTML 时,无需耗费额外的请求去得到相应的 JS 和 CSS 文件。
google 公司为了解决 HTTP/2 存在的一些问题,提出了 QUIC 协议,而 HTTP-over-QUIC 就是 HTTP/3,其相对于 HTTP/2 有以下改进。
① 无队头阻塞
前面提到,HTTP/2 通过多路复用解决了 HTTP1.1 的『队头阻塞』问题,但其只是解决了 HTTP 这一层面的『队头阻塞』问题,底层仍然采用的 TCP 连接,HTTP/2 并没有解决 TCP 的『队头阻塞』问题。
TCP 是可靠的、面向字节流的协议。HTTP/2 的多个请求虽然可以跑在同一个 TCP 连接中,但如果出现丢包现象,TCP 就需要进行重传,这可能就会导致整个 TCP 连接上的所有流阻塞,直到丢的包重传成功,这就是 TCP 的『队头阻塞』问题。
为了解决此问题,HTTP/3 底层不再使用 TCP,而是采用 UDP!而 UDP 是无连接的,多个流互相独立,之间不再有依赖,因而即使某个流发生了丢包,只会对该流产生影响,并不会使得其他流阻塞!
这时候有的小伙伴可能会问了,HTTP/3 底层不采用 TCP,那怎么保证可靠传输呢?答案就是 HTTP/3 在应用层自己重新实现了可靠性机制。也就是说,HTTP/3 将原先 TCP 协议提供的部分功能上移至 QUIC,而且进行了改进。
② 优化重传机制
TCP 采用序号+确认号+超时重传机制来保证消息的可靠性,即如果某条消息超过一定时间还没有得到确认,则重新发送此消息。
由于网络拥堵情况不断变化,因而消息的超时时间并不是固定的,而是通过不断采样消息的往返时间不断调整的,但 TCP 超时采样存在不准确的问题。
举个栗子:
客户端发送一个序号为 N 的包,然后超时了(可能丢了,也可能网络堵塞了),于是重新发送一个序号为 N 的包,之后服务器收到后返回一个确认号 ACK 为 N+1 的包。但此时客户端并无法判断这个确定包是对原始报文的确认还是重传报文的确认,那么此时往返时间应该如何计算呢?
因而 TCP 的重传超时时间计算不准确,如果计算偏大,则效率慢,很久才会重传,而如果计算偏小,则可能确认报文已经在路上了,但却重传了!
QUIC 是如何解决此问题呢?其定义了一个递增的序列号(不再叫 Seq,而是 Packet Number),每个序列号的包只发送一次,即使重传相同的包,其序列号也不一样。
举个栗子:
客户端发送一个序号为 N 的包,然后超时了,于是重新发送一个相同的包,但序号不再是 N,而是 N+1;那么如果返回的确认包 ACK 为 N+1,就是对原始报文的响应,如果 ACK 为 N+2,就是对重传报文的响应,因而采样时间计算相对更加准确!
那此时怎么知道包 N 和包 N+1 是同一个包呢?QUIC 定义了一个 Offset 概念。发送的数据有个偏移量 Offset,可以通过 Offset 知道数据目前发送到了哪里,因而如果某个 Offset 的包没有收到确认,就重发。
③ 连接迁移
众所周知,一条 TCP 连接是由四元组标识的,分别是源 IP、源端口、目的 IP、目的端口。一旦其中一个元素发生了变化,就需要断开重连。
当手机信号不稳定或者在 wifi 与移动网络切换时,都将会导致重连,而重连就意味着需要重新进行三次握手,将产生一定的时延,用户感到卡顿,体验不友好。
而 QUIC 不采用四元组的方式标识连接,而是以一个 64 位的随机数作为 ID 来标识,通过此连接 ID 标记通信的两端,之后即使网络发生变化,IP 或端口变了,但只要 ID 不变,则无需重连,只需要复用原先连接即可,时延低,减少了用户的卡顿感,实现连接迁移。
本文转载自微信公众号「 一枫说码」,作者「一枫说码」