HTTP/2 是万维网(WWW)基础协议 HTTP 16年来的首次重大升级。共经历了18版草案(00-17),于2015年2月18日正式定稿,2015年5月14日 HTTP/2 正式版发布,正式版 HTTP/2 规格标准叫做 RFC7540 。

好吧,我相信你一转身就忘了上面提到的这一长串你内容,特别是这个复杂的规范名称。恩~没关系,要了解 HTTP/2,还是先要了解它的新特性以及实现原理。

一、首先你必须知道一点儿 HTTP/1.x

作为一个前端小白,前些天我做了一个简单的活动,本地调试完后我把它放到服务器(假设域名为:jdc.jd.com)上,这时候你通过在客户端(浏览器)访问以下:jdc.jd.com/act/index.html,就可以看到我的活动页面了(当然,要经过一小段时间的等待)。

那么客户端是如何取得服务端的这些资源的?它们之间的通信是怎样的?

侧重讲讲三个过程:

  • TCP 连接: 浏览器与服务器三次握手,建立 TCP 连接
  • 客户端请求: 建立 TCP 连接后,客户端就会向服务器发送一个 HTTP 请求信息(比如请求 HTML 资源,我们暂且就把这个称为“ HTML 请求”)
  • 服务器响应: 服务器接收到请求后进行处理并发回一个 HTTP 响应信息

当然,接下来还有浏览器解析渲染的过程~巴拉巴拉~我们才能最终看到页面~~

着重看下 HTTP/1.0 和 HTTP/1.1 这三个的过程不同:

HTTP/1.0 的通信

在 HTTP/1.0 下,每完成一次请求和响应,TCP 连接就会断开。但我们知道,客户端发送一个请求只能请求一个资源,而我们的网站不可能只有单单一个 HTML 文件吧?至少还要有 CSS 吧?还要有图片吧?于是又要一次 TCP 连接,然后请求和响应。

下图展示了 HTTP/1.0 请求一个 HTML 和一个 CSS 需要经历的两次 TCP 连接:

img

HTTP/1.1 的通信

要知道,TCP 连接有 RTT(Round Trip Time,即往返时延)的,每请求一个资源就要有一次 RTT ,用户可是等不得这种慢节奏的响应的。于是到了 HTTP/1.1 ,TCP 可以持久连接了,也就是说,一次 TCP 连接要等到同域名下的所有资源请求/响应完毕了连接才会断开。恩!听起来情况好像好了很多,请求同域名下的 n 个资源,可以节约 (n-1)*RTT 的时间。

下图展示了 HTTP/1.1 时请求一个 HTML 和一个 CSS 只需要经历一次 TCP 连接:

img

但前面提到了,客户端发送一个请求只能请求一个资源,那么我们会产生如下疑问:

Q:为什么不一次发送多个请求?

事实上,HTTP/1.x 多次请求必须严格满足先进先出(FIFO)的队列顺序:发送请求,等待响应完成,再发送客户端队伍中的下一个请求。也就是说,每个 TCP 连接上只能同时有一个请求/响应。这样一来,服务器在完成请求开始回传到收到下一个请求之间的时间段处于空闲状态。

Q:有什么办法去改变吗?

“ HTTP 管道”技术实现了客户端向服务器并行发送多个请求。而服务器也是可以并行处理多个请求的。这么一来,不就可以多路复用了吗?但是, HTTP/1.x 有严格的串行返回响应机制,通过 TCP 连接返回响应时,就是必须 one by one ,前一个响应没有完成,下一个响应就不能返回。所以使用“ HTTP 管道”技术时,万一第一个响应时间很长,那么后面的响应处理完了也无法发送,只能被缓存起来,占用服务器内存,这就是传说中的“队首阻塞”。

Q:既然一个 TCP 连接解决不了问题,那么可以开多个吗?

既然一条通道(TCP 连接)通信效率低,那么就开多条通道呗!的确,HTTP/1.1 下,浏览器是支持同时打开多个 TCP 会话的(一般为6个)。一个 TCP 只能响应一个请求,那么六个 TCP 岂不就能达到六倍速?想想还有点儿小激动!但事情往往不是这么简单。开启多个 TCP 会话,无疑会给客户端和服务器都带来负担,比如缓存、CPU 时钟周期等,而且并行的 TCP 也会竞争带宽,并行能力也是受限制的,往往无法达到理想状态下的六倍速。

可见,我们采取了许多方法,希望可以并行处理请求/响应,但都不能从根本上解决问题。况且,很多方法与 HTTP/1.x 的设计理念是背道而驰的,在 HTTP/1.x 下,却没有正确利用好 HTTP/1.x 的特性。

于是, HTTP/2 带着提高性能的使命,应运而生。

二、那么 HTTP/2 做了什么改变

先对 HTTP/2 产生的影响有一个直观的认识:

这是Akamai公司(全球最大的CDN服务商)的一个官方演示,HTTP/1.1 和 HTTP/2 请求300+张图片的对比:

img

明显可以看出, HTTP/2 下加载时间为 HTTP/1.1 的 1/5 不到,那么 HTTP/2 到底为什么这么快?我们还是从它的新特性来进行全面的了解。

以下着重介绍五个特性:二进制分帧层、多向请求与响应、优先级和依赖性、首部压缩、服务器推送。

二进制分帧层(Binary Framing Layer)

指的是位于套接字接口与应用可见的高层 HTTP API 之间的一个新机制:HTTP 的语义,包括各种动词、方法、首部,都不受影响,不同的是传输期间对它们的编码方式变了。

在新引进的二进制分帧层上,HTTP/2 将所有传输的信息分割为更小的消息和帧,且都采用二进制格式的编码。如下图所示:

img

从图上可以看到:在高层 HTTP API 和低层 TCP 连接中引入了一个二进制分帧层;在二进制分帧层上,HTTP/1.1 的一个 POST 请求(起始行、首部、实体正文),被分割成了更小的 HEADERS 帧和 DATA 帧。起始行、首部被分割到 HEADERS 帧,实体正文被分割到 DATA 帧。

接下来,我们再深入地了解下这些被分割后的二进制帧是怎么工作的:

HTTP/2 同域名的所有通信都是在一个 TCP 连接上完成,这个连接可以承载任意数量的双向数据流。而每个数据流都是以消息的形式发送的,消息由一个帧或多个帧组成。

  • 流:已建立的连接上的双向字节流
  • 消息:与逻辑消息对应的完整的一系列数据帧
  • 帧:HTTP/2 通信的最小单位,每个帧包含帧首部

好像很复杂的样子,咱们来捋一捋:

TCP 连接在客户端和服务器间建立了一条运输的通道,可以双向通行,当一端要向另一端发送消息时,会先把这个消息拆分成几部分(帧),然后通过发起一个流对这些帧进行发送,最后在另一端将同一个流的帧重新组合。

这个过程就好像我们在搬家的时候,会把一个桌子先拆散成零部件,然后通过几次的搬运,到了新家后,再把桌子重新拼装起来。

下图展示了流、消息与帧的关系(注意到没,HEADERS 帧总是在最前面的):

img

HTTP/2 规范一共规定了 10 种不同的帧, 其中最基础的两种分别对应于 HTTP/1.1 的 DATA 帧 和 HEADERS 帧 。

多向请求与响应(多路复用)

多路复用允许同时通过单一的 TCP 连接发起多重的请求/响应消息,客户端和服务器可以把 HTTP 消息分解为互不依赖的帧,然后乱序发送,最后再在另一端根据 Stream ID 把它们重新组合起来。

前面提到的一端发送消息会先对消息进行拆分,与此同时,也会给同一个消息拆分出来的帧带上一个编号(Stream ID),这样在另一端接收这些帧后就可以根据编号对它们进行组合。

也正是有了这种编号的方式,当某一端发送消息时,可以发送多个消息拆分出来的多个帧(发起多个流),且这些帧可以乱序发送,因为这些帧都有自己的编号,它们之间互不影响。

下图展示了单一的 TCP 连接上有多个请求/响应并行交换:

img

从图上可以看出,服务器向客户端发送 stream1 的多个 DATA 帧(说明 HEADERS 帧已发送完毕)与 stream3 的 HEADERS 帧和 DATA 帧 ,客户端正在向服务器发送 stream5 的 DATA 帧,可见,帧的发送是乱序的,且请求/响应是并行的。

细心的你会发现,stream1 中有多个 DATA 帧,这是为什么呢?因为有 DATA 帧有长度的控制(2的14次方-1 字节,约 16383 个字节),应用数据过大时,会被拆分成多个 DATA 帧(还记得讲二进制分帧层展示的 HTTP/1.1 的请求被分割成更小的帧吗?DATA 帧就是用来携带应用数据的)。

优先级和依赖性

新建流的终端可以在报头帧中包含优先级信息来对流标记优先级

优先级的目的是允许终端表达它如何让对等端管理并发流时分配资源。更重要的是,在发送容量有限时优先级能用来选择流来传输帧。

HTTP/2 中,流可以有一个优先级属性(即“权重”):

  • 可以在 HEADERS 帧中包含优先级 priority 属性;
  • 可以单独通过 PRIORITY 帧专门设置流的优先级属性。

流的优先级用于发起流的终端(客户端/服务器)向对端(接收的一方)表达需要多大比重的资源支持,但这只是一个建议,不能强制要求对端一定会遵守。

借助于 PRIORITY 帧,客户端同样可以告知服务器当前的流依赖于其他哪个流。该功能让客户端能建立一个优先级“树”,所有“子流”会依赖于“父流”的传输完成情况。

不依赖任何流的流的流依赖为 0x0。换句话说,不存在的流标识 0 组成了树的根。

我们通过以下几个例子来理解下优先级“树”:
img

  • 第一种情况:流 A 和流 B 不依赖流,即为 0x0 ;流 A 的权重为 12 ,流 B 的权重为 4 ;则流 A 分配到的资源占比为 12/(12+4)= 12/16,流 B 分配到的资源占比为 4/(12+4)= 4/16。
  • 第二种情况:流 D 为 0x0 ,流 C 依赖于流 D ;流 D 能被分配到全额资源,等到流 D 关闭后,依赖于流 D 的流 C 也会被分配到全额资源(它是唯一依赖于流 D 的流,它的权重的大小此时并不重要,因为没有竞争的流)。
  • 第三种情况:流 D 为 0x0 ,流 C 依赖于流 D ,流 A 和 流 B 依赖于流 C ;流 D 能被分配到全额资源,等到流 D 关闭后,依赖于流 D 的流 C 也会被分配到全额资源;等到流 C 关闭后,依赖于流 C 的流 A 和流 B 根据权重分配资源(3:1)。
  • 第四种情况:流 D 为 0x0 ,流 C 和流 E 依赖于流 D ,流 A 和 流 B 依赖于流 C ;流 D 能被分配到全额资源,等到流 D 关闭后,依赖于流 D 的流 C 的流 E 和流 B 根据权重分配资源(1:1);等到流 C 关闭后,依赖于流 C 的流 A 和流 B 根据权重分配资源(3:1)。

前面说到,“可以单独通过 PRIORITY 帧专门设置流的优先级属性”,也就是说可以对原本没有优先级属性(包括依赖关系)的流进行设置,也可以对原本已有优先级属性的流进行修改。因此,优先级可以在传输过程中被动态的改变。

首部压缩

HPACK 是专门为 HTTP/2 量身定制的压缩技术。

在服务器和客户端各维护一个“首部表”,表中用索引代表首部名,或者首部键 - 值对,上一次发送两端都会记住已发送过哪些首部,下一次发送只需要传输差异的数据,相同的数据直接用索引表示即可。

具体实现如下图所示:

img

这个过程比较容易理解:通过索引表的对应关系,来标记首部表中的不同信息。

同一个域名下的请求/响应的首部往往有很多重复的信息,当客户端要向服务器发送某个请求时,通过查找索引表,发现该信息的首部已经发送过,此时服务器端的索引表也应该有对应的信息,则不需要再次发送;若查找发现部分首部信息不在索引表中,则发送该部分信首部息即可。

如在上图的示例中,第二个请求只需要发送变化了的路径首部(:path),其他首部没有变化,就不用再发送了。

服务器推送(server Push)

服务器可以对一个客户端请求发送多个响应。也就是说,除了对最初请求的响应外,服务器还可以额外向客户端推送资源

在了解“二进制分帧层”的时候我们提到,“HTTP/2 规范规定了10种不同的帧”,其中有一种名为“PUSH_PROMISE”,就是在服务器推送的时候发送的。当客户端解析帧时,发现它是一个 PUSH_PROMISE 类型,便会准备接收服务端要推送的流。

img

从上图可以看出,当服务器响应了 HTML 请求后,可以知道客户端接下来要发送 JS 请求、CSS 请求,于是服务器通过推送的方式(主动发起新流,而不是等客户端请求然后再响应),向客户端发出要约(PUSH_PROMISE)。当然,客户端可以选择缓存这个资源,也可以拒绝这个资源。

这个过程有点类似于我们常用的资源内嵌的手段:将一个图片资源转为 base64 编码嵌入 CSS 文件中,当客户端发起 CSS 请求时,也会请求该图片。因此在响应 CSS 请求后,服务器会强制(客户端是无法拒绝的)向客户端发送图片响应。但内嵌资源是无法被单独缓存的,而服务器推送的资源是可以被缓存的。

需要注意,服务器必须遵循请求-响应的循环,只能借着请求的响应来推送资源,也就是说,如果客户端没有发送请求,服务器是没法先手推送的。而且,如上图中 stream4 ,PUSH_PROMISE 帧必须在返回响应(DATA 帧)之前发送,因为万一客户端请求的恰好是服务器打算推送的资源,那传输过程就会混乱了。

注:由客户端发起的流 Stream ID 为奇数,由服务器发起的流 Stream ID 为偶数,回顾上面的图就能发现啦!

三、小结

  • HTTP/2 通过二进制分帧与多路复用机制,有效解决了 HTTP/1.x 下请求/响应延迟的问题
  • 新的首部压缩技术使 HTTP/1.x 首部信息臃肿的问题得到解决
  • 优先级和依赖性与服务器推送使得我们可以更有效地利用好这个单一的 TCP 连接

可见,HTTP/2 在 HTTP/1.1 的基础上有了一个较大的性能提升。这时候你会发现,我们针对 HTTP/1.x 的一些优化手段(如上文提到的资源内嵌)似乎有点不适用了。

下一次的文章《前端开发与 HTTP/2 的羁绊——性能篇》,将和大家一起深入探讨 HTTP/2 下的性能优化问题。


文章撰写参考:

图片来源:

如有错漏,欢迎斧正。