HTTP 2
目前HTTP/1.1所存在的一些问题
- 慢启动、TCP连接之间会相互竞争带宽——TCP机制原因
- 队头阻塞——HTTP/1.1机制原因
为了解决这些问题,HTTP2的思路就是:
- 一个域名只使用一个TCP长连接来传输数据,这样就只需一次慢启动,同时也避免了多个TCP连接竞争带宽所带来的问题
- 对于队头阻塞的问题,可以采用资源的并行下载,也就是任何时候都可以发送请求给服务器,而不需要等待其它请求的完成,服务器也可以随时返回处理好的数据——也就是我们所说的多路复用机制
1. 二进制分帧
从图中可以看出,HTTP/2添加了一个二进制分帧层,即多路复用是通过在协议栈中添加二进制分帧层来实现的。 帧是 HTTP2 通信中最小的单位信息。HTTP/2 采用二进制格式传输数据,而非 HTTP 1.x的文本格式,解析起来更高效;
HTTP/2 把响应报文划分成了两类帧(Frame) ,图中的 HEADERS(头部)和 DATA(负载)是帧的类型,也就是说一条 HTTP 响应,划分成了两类帧来传输,并且采用二进制来编码。
HTTP/2 二进制帧的结构如下图:
帧头(Frame Header)很小,只有 9 个字节,帧开头的前 3 个字节表示帧数据(Frame Playload)的长度。帧长度后面的一个字节是表示帧的类型,HTTP/2 总共定义了 10 种类型的帧,一般分为数据帧和控制帧两类,
HTTP2中,同域名下所有通信都在单个连接上完成,该连接可以承载任意数量的双向数据流
每个数据流都以消息的形式发送,而消息又由一个或多个帧组成。多个帧之间可以乱序发送,根据帧首部的流标识可以重新组装,这也是多路复用同时发送数据的实现条件
HTTP/2的请求和接收过程如下:
- 首先,浏览器准备好请求数据,包括请求行、请求头等信息,如果是POST方法,那么还有请求体。
- 这些数据经过二进制分帧层处理之后,会被转换为一个个带有请求ID编号的帧,通过协议栈将这些帧发送给服务器
- 服务器接收到所有帧之后,会将所有相同ID的帧合并为一条完整的请求消息
- 然后服务器处理该条请求,并将处理的响应行、响应头和响应体分别发送至二进制分帧层
- 同样,二进制分帧层会将这些响应数据转换为一个个带有请求ID编号的帧,经过协议栈发送给浏览器。
- 浏览器接收到响应帧之后,会根据ID编号将帧的数据提交给对应的请求。
2. 多路复用
了解 HTTP/2 的帧结构后,我们再来看看它是如何实现并发传输的
HTTP/2 是通过 Stream 这个设计,多个 Stream 复用一条 Tcp 连接,达到并发的效果,解决了 HTTP/1.1 的队头阻塞问题,提高了 HTTP 传输的吞吐量。
为了理解 HTTP/2 的并发是怎样实现的,我们先来理解 HTTP/2 中的 Stream、Message、Frame 这 3 个概念。
- 1 个 TCP 连接包含一个或多个 Stream,Stream 是 HTTP/2 并发的关键技术
- Stream 里可以包含一个或多个 Message,Message 对应 HTTP/1 中的请求或响应,由 HTTP 头部和包体构成
- Message 里包含一条或多个 Frame(帧),Frame 是 HTTP/2 的最小单位,以二进制压缩格式存放在 HTTP/1 中的内容(头部和包体)
总结:多个 Stream 跑在一条 TCP 连接中,一个Stream(流)可以包含请求的所有相关帧(包括头部帧、数据帧等),也可以包含对应的响应的所有相关帧。同一个 HTTP 请求和响应可以跑在同一个 Stream中,HTTP 消息可以由多个 Frame 构成,一个 Frame 可以由多个 TCP 报文构成。
在 HTTP/2 连接上,不同 Stream 的帧是可以乱序发送的(因此可以并发不同的 Stream ) ,因为每个帧的头部会携带 Stream ID 信息,所以接收端可以通过 Stream ID 有序组装成 HTTP 消息,而同一 Stream 内部的帧必须是严格有序的。
客户端和服务器 两方都可以建立 Stream,因为服务端可以主动推送资源给客户端, 客户端建立的 Stream 必须是奇数号,而服务器建立的 Stream 必须是偶数号。
比如下图,Stream 1 是客户端向服务端请求的资源,属于客户端建立的 Stream,所以该 Stream 的 ID 是奇数(数字 1);Stream 2 和 4 都是服务端主动向客户端推送的资源,属于服务端建立的 Stream,所以这两个 Stream 的 ID 是偶数(数字 2 和 4)。
同一个连接中的 Stream ID 是不能复用的,只能顺序递增,所以当 Stream ID 耗尽时,需要发一个控制帧 GOAWAY,用来关闭 TCP 连接。
在 Nginx 中,可以通过 http2_max_concurrent_Streams 配置来设置 Stream 的上限,默认是 128 个。
HTTP/2 通过 Stream 实现的并发,比 HTTP/1.1 通过 TCP 连接实现并发要牛逼的多,因为当 HTTP/2 实现 100 个并发 Stream 时,只需要建立一次 TCP 连接,而 HTTP/1.1 需要建立 100 个 TCP 连接,每个 TCP 连接都要经过 TCP 握手、慢启动以及 TLS 握手过程,这些都是很耗时的。
HTTP/2 复用TCP连接,在一个连接里,客户端和浏览器都可以同时发送多个请求或回应,而且不用按照顺序一一对应,这样就避免了”队头堵塞”
3. 请求优先级
HTTP/2 还可以对每个 Stream 设置不同优先级,帧头中的「标志位」可以设置优先级,比如客户端访问 HTML/CSS 和图片资源时,希望服务器先传递 HTML/CSS,再传图片,那么就可以通过设置 Stream 的优先级来实现,以此提高用户体验。
4. 服务器推送
除了设置请求的优先级外,HTTP/2还可以直接将数据提前推送到浏览器。
比如,客户端通过 HTTP/1.1 请求从服务器那获取到了 HTML 文件,而 HTML 可能还需要依赖 CSS 来渲染页面,这时客户端还要再发起获取 CSS 文件的请求,需要两次消息往返,如下图左边部分:
在HTTP/2 中,当用戶请求一个HTML⻚面之后,服务器知道该HTML⻚面会引用几个重要的JavaScript文件和CSS文件,那么在接收到HTML请求之后,附带将要使用的CSS文件或JavaScript文件一并发送给浏览器,这样当浏览器解析完HTML文件之后,就能直接拿到需要的CSS文件和JavaScript文件,减少了消息传递的次数。
在 Nginx 中,如果你希望客户端访问 /test.html 时,服务器直接推送 /test.css,那么可以这么配置:
location /test.html {
http2_push /test.css;
}
那 HTTP/2 的推送是怎么实现的?
客户端发起的请求,必须使用的是奇数号 Stream,服务器主动的推送,使用的是偶数号 Stream。服务器在推送资源时,会通过 PUSH_PROMISE 帧传输 HTTP 头部,并通过帧中的 Promised Stream ID 字段告知客户端,接下来会在哪个偶数号 Stream 中发送包体。
如上图,在 Stream 1 中通知客户端 CSS 资源即将到来,然后在 Stream 2 中发送 CSS 资源,注意 Stream 1 和 2 是可以并发的。
5. 头部压缩
在 HTTP 1.X 中,HTTP协议的报文是由 「Header + Body」构成的,对于Body部分,可以使用头字段「Content-Encoding」指定Body的压缩方式,比如使用 gzip 压缩,这样可以节省带宽,但是报文中的 Header 是没有针对它的优化手段的。
HTTP/1.1 报文中 Header 部分存在的问题:
- 含很多固定的字段,比如 Cookie、User Agent、Accept 等,特别在携带 cookie 的情况下,可能每次都需要重复传输几百到几千的字节。
- 大量的请求和响应的报文中有很多字段值都是重复的,这样会使得大量带宽被这些冗余的数据占用了,所以有必要避免重复性
- 字段是 ASCII 编码的,虽然易于人类观察,但传输格式受限、效率低,所以有必要改成二进制编码;
HTTP/2 对 Header 部分做了大改造,把以上的问题都解决了。
在 HTTP 2.0 中,使用了 HPACK 压缩格式对传输的 header 进行编码,减少了 header 的大小。
HPACK 算法主要包含三个组成部分:
- 静态字典;
- 动态字典;
- Huffman 编码(压缩算法);
客户端和服务器两端都会建立和维护字典,用长度较小的索引号表示重复的字符串,再用 Huffman 编码压缩数据,可达到 50%~90% 的高压缩率。
静态表编码
HTTP/2 为一些在头部高频出现的字段建立了一张 静态表,它是写入到 HTTP/2 框架里的,不会变化
表中的 Index 表示索引(Key),Header Name 表示字段的名字,Header Value 表示索引对应的 Value,比如 Index 为 2 代表 请求方法method为 GET,Index 为 8 代表状态码 200。
表中有的index没有对应的 Header Value,这是因为这些 Value 并不是固定的而是变化的,这些 Value 都会经过 Huffman 编码后,才会发送出去。
比如server 头部字段——server: nghttpx\r\n
根据 RFC7541 规范,如果头部字段属于静态表范围,并且 Value 是变化,那么它的 HTTP/2 头部前 2 位固定为 01,所以整个头部格式如下图:
server 头部的二进制数据对应的静态头部格式如下:
从静态表中能查到 server 头部字段的 Index 为 54,二进制为 110110,再加上固定 01,头部格式第 1 个字节就是 01110110。下面绿色部分则是Huffman 编码表所对应的编码
动态表编码
静态表只包含了 61 种高频出现在头部的字符串,不在静态表范围内的头部字符串就要自行构建动态表,它的 Index 从 62 起步,会在编码解码的时候随时更新。
比如,第一次发送时头部中的「User-Agent 」字段数据有上百个字节,经过 Huffman 编码发送出去后,客户端和服务器双方都会更新自己的动态表,添加一个新的 Index 号 62。那么在下一次发送的时候,就不用重复发这个字段的数据了,只用发 1 个字节的 Index 号就好了,因为双方都可以根据自己的动态表获取到字段的数据。
所以,使得动态表生效有一个前提:必须同一个连接上,重复传输完全相同的 HTTP 头部。如果消息字段在 1 个连接上只发送了 1 次,或者重复传输时,字段总是略有变化,动态表就无法被充分利用了。
因此,随着在同一 HTTP/2 连接上发送的报文越来越多,客户端和服务器双方的「字典」积累的越来越多,理论上最终每个头部字段都会变成 1 个字节的 Index,这样便避免了大量的冗余数据的传输,大大节约了带宽。
理想很美好,现实很骨感。动态表越大,占用的内存也就越大,如果占用了太多内存,是会影响服务器性能的,因此 Web 服务器都会提供类似 http2_max_requests 的配置,用于限制一个连接上能够传输的请求数量,避免动态表无限增大,请求数量到达上限后,就会关闭 HTTP/2 连接来释放内存。
综上,HTTP/2 头部的编码通过「静态表、动态表、Huffman 编码」共同完成的。
大致流程
表在 HTTP/2 的连接存续期内始终存在,由客户端和服务器共同渐进的更新
例如:下图中的两个请求, 请求一发送了所有的头部字段,第二个请求则只需要发送差异数据,这样可以减少冗余数据,降低开销
总结
- 二进制分帧,改进传输性能
- 多路复用(链接共享)——HTTP/2 实现了 Stream 并发,解决了HTTP/1.1的队头阻塞和TCP连接数限制问题,不需要排队等待。
- 服务器支持主动推送资源
- 同一个连接里面发送多个请求,功能基于“二级制分帧”的特性。废弃了1.1里面的管道,响应不再需要按照顺序来,引入了流的概念,可以同时响应多个请求;
- 服务器推送
- 使用专用算法HPACK来 压缩头部来减小传输量,提高效率
流(stream):已建立连接上的双向字节流
帧(frame):HTTP2.0通信的最小单位,每个帧包含头部,至少也会标识出当前所属的流(stream_id)
文章借鉴了部分小林coding的图解网络