HTTP/2 协议抓包实战

研发 @ 字节跳动

作者:大力智能技术团队-服务端 HSF

引言

HTTP 协议在我们日常生活中随处可见,不管我们是用电脑、手机、平板,不论用浏览器还是手机 App,背后总有 HTTP 协议在传输着我们所浏览的内容。在 HTTP 协议中,目前应用最广泛的是 HTTP/1.1 协议,但 HTTP/1.1 协议也有其自身的缺点与不足,HTTP/2 协议在 HTTP/1.1 协议的基础上进行了大量的优化,于 2015 年正式发布,目前已经有越来越多的服务支持 HTTP/2 协议。我们大力智能服务端团队对 HTTP/2 协议及原理进行了一些的分析与抓包实战,希望本文能够帮助读者对 HTTP/2 协议基础有一定的了解。

HTTP/1.1 有什么问题?

无法适应互联网页面内容数量与体积的持续增大

在 2010 年之后,随着移动互联网蓬勃发展,网页上资源的数量与体积有着明显的增大,但是浏览器的对同域名下的请求并发度是有限制的,导致网页渲染的延时增大。

下图是对 2011 到 2015 年互联网平均一个页面的资源数量与体积的走势图。

例如下图是对某新闻页面的一个抓包结果,可以看到全部加载完毕会有 242 个请求发出,7.2 MB 资源,总完成时间达到了 23.77 秒。

浏览器为什么限制同域名下的请求并发度?

  • 对客户端浏览器而言,过多的并发涉及到端口数量消耗和线程间切换的开销。例如在上图浏览新闻页面中如果不加限制的使用 242 个线程并发请求资源,那么可能有无法忽视的性能开销。

  • HTTP/1.1 协议中使用了 TCP 协议中的 Keep Alive 属性支持复用现有 TCP 连接,等待服务端的数据返回之后再复用该 TCP 连接继续发送下一个 HTTP 请求,已经比 HTTP/1.0 的每次请求都断开 TCP 连接快很多。

  • 即便客户端不对并发度加以限制,但如果将所有请求一起发给服务器,也很可能会引发服务器的流控策略而被限制。

下图是各个浏览器对并发度的限制:

没有充分利用 TCP 连接资源

TCP 协议是一个全双工的协议,但在 HTTP/1.1 中同一个 TCP 连接下在同一时间只能完成一个 HTTP 请求,即为一问一答的半双工形式,无法最大限度的利用带宽资源。而当前的网络环境大多为长肥网络,即延迟*带宽较大的网络。我们可以想象为两条长而粗的水管,在一个时刻,水管中正在流淌的水就是飞行中的 TCP 报文,理想情况为两条水管分别源源不断的向对方传递水流。

头部与公参数据所占用的巨大比例

HTTP/1.1 的无状态特性带来的巨大 HTTP 头部与公参,每次请求需要携带非常多的头部信息与公参内容,例如下表是对某个 App 应用的一个 HTTP/1.1 请求数据统计,其中的公参与头部数据占了整体请求数据的 90% 以上,这些数据很可能在用户的使用过程中是不会发生变化的,而该现象在很多应用中是较常见的。

内容字节数比例
Header255973.6%
Query67219.3%
Body2457.1%
总计3476100%

HTTP/2 速度初体验

读者可以访问该网站:http2.akamai.com/demo ,对 HTTP/2 相较于 HTTP/1.1 速度的提升有一个直观的感受。

可以看到,在 HTTP/1.1 请求过程中,浏览器共使用 6 个并发 TCP 链接请求图片数据(符合上文中的提到 Chrome 并发度为 6 的限制),在很多图片的 HTTP/1.1 请求 Waterfall 中,有很明显的排队等待延时。

而 HTTP/2 中因为使用了多路复用,仅在一个 TCP 连接上传输数据,但其传输速度仍然有明显的提高。

HTTP/2 做了哪些改进?

HTTP/2 连接的建立过程

与 Websocket 协议一样,HTTP/2 协议也是在 HTTP/1.1 协议的基础上通过协议升级进行握手之后才能正式建立连接收发数据。另外,HTTP/2 协议默认使用 TLS 进行数据加密,而 h2c 表明使用明文协议不使用 TLS。

为了以下的抓包分析方便,我们使用 Wireshark 对支持 h2c 明文协议数据的 nghttp2.org 页面进行抓包,使用如下命令发送 HTTP/2请求:

curl --http2 -v http://nghttp2.org 
复制代码

读者可以使用该 Chrome 插件检查当前访问的页面是否支持 HTTP/2:chrome.google.com/webstore/de…

H2C 协议升级抓包过程:

  1. 客户端首先发送一个 HTTP/1.1 协议数据包,其中 Header 中的 "Connection: Upgrade" 表明要升级协议,具体升级为"Upgrade: h2c"。

  1. 服务端收到请求后,回包中状态码为 101 Switching Protocols,Header 中的“Upgrade: h2c”表明服务端支持 HTTP/2 h2c 协议,后续我们就都使用 HTTP/2 协议传输数据吧。

  1. 之后客户端会再向服务端发送固定的一个 Magic 消息,其内容固定为:PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n,之后双方就会使用 HTTP/2 的相关协议进行通讯了。

头部压缩

在 HTTP/1.1 的问题 3 中提到,一个 HTTP 报文中的 Header 一般都会携带许多固定的头字段,如“User Agent”、“Accept-Encoding”、“Accept”,这些 Header 数据经常会多达几百字节甚至上千字节,但请求体经常只有几十几百字节。另外,对一个用户而言,其发出的大多数请求里有很多字段值每次都是重复的,比如在很多 App 应用中,每个请求的公参与 Header 基本上不会发生改变,这将导致大量带宽消耗在了这些重复极高的数据上。

因此,HTTP/2 将头部压缩作为改进重点,不过 HTTP/2 并没有使用传统的字符串压缩算法,而是开发了专门的 HPACK 算法。为什么要新开发一个算法呢?因为传统的字符串压缩算法并没有考虑客户端与服务端之间多次请求的数据交互数据大量重复的问题,如果通过在客户端和服务器两端建立两个一样“字典”来存储 Header 的 Key-Value 数据,那么在下次使用该数据的时候只要传输索引号就可以了,另外还对整数和字符串采用了哈夫曼编码来进一步压缩,整体可以达到 50%~90% 的高压缩率。

HPACK 它是一个有状态的算法,需要客户端和服务器各自维护一份索引表。HTTP/2 还把 HTTP/1.1 中起始行里面的方法名、请求路径、状态码等也统一转换成了头字段的形式,将其命名为:pseudo-header fields,并在其名字前面加了一个“:”,比如“:method”、“:status”分别表示的是请求方法和状态码。

对于那些 HTTP/1.1 中最常用的头字段,HTTP/2 定义了一个只读的静态表:httpwg.org/specs/rfc75… ,因此只要查表就可以知道字段名和对应的值,比如索引 3 代表 POST 方法,索引 8 代表状态码 200,下图是其中的部分截图:

...

可以看到总共定义了 61 个静态表内容。其中部分定义是 KV 对定义(如 8 号定义 :status 200),部分只有 Key 定义(如 19 号定义:accept),对于只有 Key 定义的内容,其 Value 通常是不可枚举的,需要动态的填入。

那么对于静态表中没有对应 Value的情况,或者是用户自定义的字段怎么办呢? 协议规定使用动态表处理这部分数据,它添加在静态表后面,会在双方的传输过程中在双方的字典中更新。 随着在 HTTP/2 连接上发送的请求越来越多,两边的“字典”中的 Key-Value 也会越来越多,最终请求的每个头部字段都会变成一两个字节的索引号,在 HTTP/1.1 中的上千字节的头部数据用几十个字节就可以表示了,这也是为什么不用传统字符串压缩算法的原因。

另外,字符串在 HTTP/1.1 中采用了非压缩的明文传输方式,而 HTTP/2 支持使用哈夫曼对这些字符串进行编码,通过一个标志位标明是 ASCII 编码还是哈夫曼编码。而学过哈夫曼编码的同学都知道,其编码结果需要一个静态映射表才能进行解码,为此,标准制定者们对互联网上的巨量请求内容进行了收集,使用海量数据进行哈夫曼编码统计后,制定了一个哈夫曼编码表:httpwg.org/specs/rfc75… ,下面是其部分的截图:

二进制的消息格式

HTTP/1.1 通过纯文本形式传输数据,使用 Wireshark、Tcpdump 抓包工具就可以非常方便的抓包调试。但 HTTP/2 不再使用明文而全部采用二进制格式。 这样虽然对人类阅读不友好,但却方便了计算机的解析与代码的编写。HTTP/2 把原来的头部与消息体分割为数个二进制帧(Frame),并定义了多种帧类型,用 HEADERS 帧存放头数据、DATA 帧存放请求正文数据。

帧的结构

一个 HTTP/2 的帧结构如下:

+-----------------------------------------------+
|                 Length (24)                   |
+---------------+---------------+---------------+
|   Type (8)    |   Flags (8)   |
+-+-------------+---------------+-------------------------------+
|R|                 Stream Identifier (31)                      |
+=+=============================================================+
|                   Frame Payload (0...)                      ...
+---------------------------------------------------------------+
复制代码
  • Length:3 个字节,表示该帧的数据长度(但不包括头的 9 个字节)。
  • Type:1 个字节,表示该帧后续的内容的类型(总共定义了 10 种类型的帧,大体上可以分为数据帧和控制帧两类)。
  • Flag:1 个字节,在 Type 不同的情况下有不同的定义。
  • R:一位保留位,目前未定义,且必须是 0。
  • Stream Identifier:流标识符 ID,也就是帧所属的“流”(后面会解释流的概念),接收方使用该 ID 可以从乱序的帧里识别出具有相同流 ID 的帧序列,并按照顺序重新组装起来,就实现了虚拟的“流”。
  • Frame Payload:该 Type 对应的帧 Payload。

下面对最常见的 DATA 帧与 HEADER 帧进行分析。

DATA 帧格式

一个 HTTP/2 的 DATA 帧结构如下:

 +---------------+
 |Pad Length? (8)|
 +---------------+-----------------------------------------------+
 |                            Data (\*)                         ...
 +---------------------------------------------------------------+
 |                           Padding (\*)                       ...
 +---------------------------------------------------------------+
复制代码
  • Pad Length:Padding 数据的填充长度,? 表示此字段的出现是有条件的,只有 PADDED flag 被设置时该值存在。
  • Data:传递的数据。
  • Padding:没有具体语义的填充字节,并且需全部设置为 0,目的为混淆报文长度。

一个抓包例子:

  1. 该帧的数据长度是 62 个字节。
  2. 类型是 0,即 Data 帧。
  3. Flags 标志位中:最低位为 1 表示该流的数据已经发送结束(相当于 HTTP/1.1 的Chunked 分块结束标志 0\r\n\r\n)、Padded false 标识没有填充字节。
  4. Stream Identifier 为1,标志是 1 号流。
  5. 数据内容:
User-agent: \*
Disallow: 

Sitemap: //nghttp2.org/sitemap.xml 
复制代码

HEADERS 帧格式

一个 HTTP/2 的 HEADERS 帧结构如下:

 +---------------+
 |Pad Length? (8)|
 +-+-------------+-----------------------------------------------+
 |E|                 Stream Dependency? (31)                     |
 +-+-------------+-----------------------------------------------+
 |  Weight? (8)  |
 +-+-------------+-----------------------------------------------+
 |                   Header Block Fragment (\*)                 ...
 +---------------------------------------------------------------+
 |                           Padding (\*)                       ...
 +---------------------------------------------------------------+
复制代码
  • Pad Length:Padding 数据的填充长度,? 表示此字段的出现是有条件的,只有 PADDED flag 被设置时该值存在。
  • E:流的依赖性是否是排他的,只有 PRIORITY flag 被设置时存在。
  • Stream Dependency: 代表当前流所依赖的流的 ID,只有 PRIORITY flag 被设置时存在。
  • Weight: 流的优先级权重值 (1~256),存在则代表 PRIORITY flag 被设置。
  • Header Block Fragment:Header 块片段。
  • Padding:没有具体语义的填充字节,并且需全部设置为 0。
Header 的具体编码方式

我们思考一下,对于 Header 会有哪些数据组成?

  1. Key 和 Value 都已经可以使用索引方式编码。
  2. Key 以索引方式编码,而 Value 以字面形式编码。
  3. Key 和 Value 都以字面形式编码。

另外,对于动态内容,还需要控制是否进入动态表:

  1. 进入动态表,供后续传输优化使用。
  2. 不进入动态表。
  3. 不进入动态表,并约定该头部永远不进入动态表。

下面对其中两种最简单的情况进行抓包分析:

静态表已有索引的情况

Key 与 Value 都在索引表中(包括静态表与动态表),其编码方式为:首位传 1,其余 7 位传索引号:

  0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
| 1 |        Index (7+)         |
+---+---------------------------+
复制代码

一个抓包例子:

Header: :status: 200 OK 的编码内容为:1000 1000,那么表达的含义是什么呢?

  1. 前面的1标识该header是静态表中已经存在的 KV。
  2. 我们再回顾一下之前的静态表内容,“:status: 200 ok”其静态表编码是8,即1000。

因此,整体加起来就是 1000 1000。

Key 已经在索引表中,Value 需要编码传递并新增至动态表中

其编码方式如下:

  0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
| 0 | 1 |      Index (6+)       |
+---+---+-----------------------+
| H |     Value Length (7+)     |
+---+---------------------------+
| Value String (Length octets)  |
+-------------------------------+
复制代码
  1. 首两位传 01,后续 6 位传递索引号。
  2. 一位的 H 标志后续 Value 是否使用了哈夫曼编码,后续跟 7 位的 Value 长度。
  3. Value 数据内容。

一个抓包例子:

我们观察其数据内容,首两位是 01,即“名称在索引表中,值需要编码传递”,后面内容是 10 0001,即33,那么 33 在静态表中对应什么呢?

index 为 33 的内容是 “data”,其二进制为 10 0001,前面加01,即0110 0001。

之后,是否为哈夫曼编码的标志位 H 发现是 1,即后续的字符串内容需要使用哈夫曼进行解码。 再看 Wireshark 显示的数据长度为 29,需要注意的是,Wireshark 显示的长度是哈夫曼解码之后的长度,未解码的长度为第二个字节:1001 0110,第一位标识为哈夫曼编码,长度1 0110,即 22 个字节。

再验证一下后续数据是否是上面提到的哈夫曼静态表中对应的内容,其内容的第一个字符是 S,而通过查表得到 S 的哈夫曼编码对应的是 7 位的 1101110,正好对应上第三个字节的内容起始 7 位。

同理查表发现下一字符 u 的哈夫曼编为 101101,对应后面的数据。

虚拟的流

HTTP/2 定义了一个“流”(Stream)的概念,每个流在此 HTTP/2 连接有其内唯一的一个 ID 标识,一个流为一个二进制的双向传输通道,同一个消息的所有往返帧都处于唯一的流中,在里面传输的是一系列有先后顺序的数据帧(因为 TCP 报文是有先后顺序的,且客户端和服务端在发送消息的时候,也能保证顺序的正确性),把这些数据帧按照次序组装起来就是 HTTP/1.1 中的请求报文和响应报文。

所以 HTTP/2 就可以在一个 TCP 连接上用流同时传输属于不同流 ID 的消息帧,这也就是常说的多路复用,即多个往返通信都复用一个 TCP 连接来处理。

在流的层面上看,消息是一些有序的帧序列,而在 TCP 连接的层面上看,消息却是乱序收发的帧。多个请求响应之间没有了顺序关系,也就不会出现浏览器中的排队等待,也不会出现队头阻塞问题,降低了延迟,提高了连接的利用率。

比如下面抓包中,就有标号 “0、1、3”的 3 个流存在。

服务器端主动推送数据

在 HTTP/1.1 中,服务端是无法主动推送数据给客户端的,而 HTTP/2 中对服务端推送做了支持。如下图所示,index.html 中依赖了 some.css 数据,在 HTTP/1.1 中,需要浏览器对 html 进行解析后发现依赖了 some.css 后,再主动发送一个 HTTP/1.1 请求获取该数据。而在 HTTP/2 中,服务端在发送 index.html 之前,发现其依赖了 some.css,那么会主动推送一个标志为 PROMISE 帧,通知客户端 some.css 数据即将来临。

如下图所示:在流 1 中通知客户端 CSS 资源即将来临,在流 2 中发送 CSS 资源,而流 1 与流 2 是可以并发的。

总结

HTTP/2 在语义上兼容了 HTTP/1.1,通过使用协议升级的方式从 HTTP/1.1 升级到 HTTP/2。HTTP/2 使用了头部压缩、二进制帧结构、虚拟的流等方法大幅优化了性能,同时支持了服务端推送功能。另外,HTTP/2 也增加了安全性,默认需要使用 TLS 1.2 协议。根据 W3Techs 的数据,截至 2019 年 6 月,全球有 36.5% 的网站支持了 HTTP/2。

服务端岗位正在火热招聘中,详细JD请点击 服务端(高级)开发工程师

掘金尾部官号.png

文章分类
后端