HTTP学习笔记04 - 未来已来的HTTP2


HTTP/1当前的困境

当今的web,已经不再是当年的纯粹的一个展示性页面,而是基于浏览器的应用(browser-server application)。同时越来越多的页面元素导致页面需要加载的资源也日益增多。alexa排名前100的web站点上,平均每个页面需要加载的资源最少也会有大几十个。同时出于性能考虑,在浏览器里每个域名同时可以打开的连接数最多不超过8个。这就导致了每个连接最少也要处理10个左右的请求才能保证整个页面加载完毕。这势必会消耗大量的时间!

对于这些问题,我们通常会从以下几个方面进行优化:

  1. 异步接口合并(Batch Ajax Request)
  2. 图片合并,雪碧图(CSS Sprite)
  3. CSS、JS合并(Concatenation)
  4. CSS、JS内联(inline)
  5. 图片、音频内联(Data URI)

然而,由于协议设计时的一些权限,因此有些问题并不好解决,例如:

  1. 一个TCP连接上同时只能有1个请求/响应,所以为了提高用户体验,我们需要开启多个TCP连接(同域名最多8个)
  2. 域名散列,即在比较复杂的页面上将资源分配到多个域名上,这样提高并发亮,最终提高网页加载速度。但是带来的问题有:DNS开销、TCP慢启动、TCP额外开销、缓存失效
  3. 减少请求数量,合并请求或资源。因此带来得问题:木桶效应、内存消耗、解析时间变长、缓存失效
  4. HTTP头部没有压缩,大量重复头部信息造成额外消耗,因此需要减少请求数量(降低冗余字段传传输次数),启用Cookie-Less域名(比如静态资源文件使用独立域名)
  5. 需遵守一个请求一个响应的模式,无法推送重要资源。并且由于使用FIFO,所以必须把重要CSS放在头部,JS放在底部

HTTP2技术简介

HTTP2协议:

RFC7540 - Hypertext Transfer Protocol Version 2 (HTTP/2) RFC7541 - HPACK: Header Compression for HTTP/2

HTTP2新增功能概述:

  1. HTTP/2采用二进制格式传输数据,而非HTTP/1的文本格式。
  2. HTTP/2对消息头采用HPACK进行压缩传输,能够节省消息头占用的网络的流量。而HTTP/1每次请求都会携带大量冗余头信息,浪费了很多带宽资源。头压缩能够很好的解决这个问题。
  3. 多路复用,直白的说就是所有的请求都是通过一个TCP连接并发完成。HTTP/1虽然能利用一个连接完成多次请求,但是这个请求之间是有先后顺序的,如果某个请求阻塞了,那么后面的请求就都必须等待。而HTTP/2则做到了真正的并发请求。同时,流还支持优先级和流量控制。
  4. Server Push:服务端能够更快的把资源推送给客户端。例如服务端可以主动把JS和CSS文件推送给客户端,而不需要客户端解析HTML再发送这些请求。当客户端需要的时候,文件已经在客户端了。

HTTP/2是HTTP/1在底层传输机制上的完全重构,HTTP/2基本兼容HTTP/1语义。但是也有一些不同,例如: Content-Type依然是Content-Type,但是它不再是文本传输了。

HTTP2的基础 - Frame(帧)

Frame(帧)是HTTP/2二进制格式的基础,基本可以把它理解为和TCP里面的数据包一样。HTTP/2之所以能够有如此多的新特性,正是因为底层数据格式的改变。Frame的基本格式如下(摘自: https://tools.ietf.org/html/draft-ietf-httpbis-http2-17)

+---------------------------------------------------+
|                 Length (24)                       |
+---------------+---------------+-------------------+
|        Type (8)         |         Flags (8)       |
+-+-------------+---------------+-------------------+
|R|                 Stream Identifier (31)          |
+=+=================================================+
|                   Frame Payload (0...)        ...
+---------------------------------------------------+

字段解释:

Length:表示Frame Payload部分的长度,Frame Header的长度是固定的9个字节(Length + Type + Flags + R + Stream Identifier = 72bit) Type: 区分这个Frame Payload存储的数据是属于HTTP Header还是HTTP Body;另外HTTP/2新定义了一些其他的Frame Type,例如,这个字段是0时,表示DATA类型(即HTTP/1中的Body部分数据) Flags: 共8位,每位都起标记作用。每种不同的Frame Type都有不同的Frame Flags。例如发送最后一个DATA类型的Frame时,就会将Flags最后一位设置为1(flags &= 0x01),表示END_STREAM,说明这个Frame是流的最后一个数据包。 R: 保留位 Stream Identifier: 流ID,当客户端和服务端建立TCP链接时,就会先发送一个Stream ID = 0的流用来做初始化工作。之后客户端和服务端从1开始发送请求/响应。

Frame由Frame Header和Frame Payload两部分组成。不论是原来的HTTP Header还是HTTP Body,在HTTP/2中,都将这些数据存储到Frame Payload,组成一个个的Frame,再发送响应/请求。通过Frame Header中的Type区分这个Frame的类型。所以HTTP/2相比于HTTP/1,在语义上并没有太大的变化,而是数据的格式变成二进制的Frame。二者的转换和关系如下图: HTTP1vsHTTP2

头部压缩(Header Compression, HPACK):

上文提到,在HTTP/1中,Header部分都是文本形式并且是明文传输。但是在HTTP/2中,rfc专门定义了一个header字段-值的表(http://http2.github.io/http2-spec/compression.html#rfc.section.A),例如:5表示

:path=/index.html

所以,如果想请求一个域名的根页面信息时,只要给服务端发送一个Frame,该Frame的Payload部分存储0x8285,Frame的Type设置为Header类型,便可以表示这个Frame属于HTTP Header,请求的内容是:

GET /index.html

之所以是0x8285而不是0x0205是因为高位设置为1表示这个字节是一个完全索引值(key和value都在索引中)。类似的,通过高位的标志位可以区分出这个字节是属于一个完全索引值,还是仅索引了key,还是key和value都没有索引,因为索引表的大小是有限的,它仅保存了一些常用的HTTP Header,同时每次请求还可以在表的末尾动态追加新的HTTP Header缓存。动态部分称之为Dynamic Table。 Static Table和Dynamic Table在一起组合成了索引表:

<----------  Index Address Space ---------->
<-- Static  Table -->  <-- Dynamic Table -->
+---+-----------+---+  +---+-----------+---+
| 1 |    ...    | s |  |s+1|    ...    |s+k|
+---+-----------+---+  +---+-----------+---+
                       ^                   |
                       |                   V
                 Insertion Point      Dropping Point

HPACK不仅通过索引键值对来降低数据量,同时还会将字符串进行霍夫曼编码来压缩字符串大小。 以常用的 User-Agent为例,它在索引表中的索引值是58,它的值没有在表中纯在,因为它的值是多变的。第一次请求的时候它的key用58表示,表示这是一个User-Agent,它的值部分会进行霍夫曼编码(但是如果编码后的字符串变更长了,则不采用霍夫曼编码)。服务端收到请求后,会将这个User-Agent添加到Dynamic Table缓存起来,分配一个新的索引值。客户端下一次请求时,假设上一次请求的User-Agent在表中的索引位置是62,此时只需要发送0xBE(同样的,高位1),便可以代表:

User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/33.0.1750.146 Safari/537.36

其过程如下图所示:

最终,相同的Header只需要发送索引值,新的Header会重新加入到Dynamic Table。

Multipexing多路复用

每个Frame Header都有一个Stream ID就是被用于实现该特性。每次请求/响应使用不同的Stream ID。就像同一个TCP连接上的数据包通过IP:PORT来区分出数据包去往哪里一样。通过Stream ID标识,所有的请求和响应都可以正确地跑在一条TCP连接上了。下图是HTTP和SPDY的并发模型对比:

当流并发时,就会涉及到流的优先级和依赖。优先级高的流会被优先发送,图片请求的优先级要低于CSS和SCRIPT。这个设计可以确保重要的东西可以被优先加载完。

Server Push:

当服务端需要主动推送某个资源时,变会发送一个Frame Type为PUSH_PROMISE的Frame,里面带了PUSH需要新建的Stream ID。这个Frame是通知客户端:接下来服务端要用这个ID向你发送数据,客户端准备好接收。客户端解析Frame时,发现它是一个PUSH_PROMISE类型,便会准备接收服务端要推送的流。