HTTP/1.1 协议的性能问题
3.6 HTTP/2 牛逼在哪?
不多 BB 了,直接发⻋!
一起来看看 HTTP/2 牛逼在哪?

HTTP/1.1 协议的性能问题
我们得先要了解下 HTTP/1.1 协议存在的性能问题,因为 HTTP/2 协议就是把这些性能问题逐个攻
破了。
现在的站点相比以前变化 太多 了,比如:
消息的大小变大了,从几 KB 大小的消息,到几 MB 大小的消息;
⻚面资源变多了,从每个⻚面不到 10 个的资源,到每⻚超 100 多个资源;
内容形式 变多样了,从单纯到文本内容,到图片、视频、音频等内容;
实时性要求变高了,对⻚面的实时性要求的应用越来越多;
这些变化 带来的最大性能问题就是 HTTP/1.1 的高延迟,延迟高必然影响的就是用⼾体验。主要原
因如下几个:
延迟难以下降,虽然现在网络的「带宽」相比以前变多了,但是延迟降到一定幅度 后,就很难
再下降了,说白了就是到达了延迟的下限;
并发连接有限,谷歌浏览器最大并发连接数是 6 个,而且每一个连接都要经过 TCP 和 TLS 握手
耗时,以及 TCP 慢启动过程给流量带来的影响;
队头阻塞问题,同一连接只能在完成一个 HTTP 事务(请求和响 应)后,才能处理下一个事
务;
HTTP
头部巨大且重复,由于 HTTP 协议是无状态的,每一个请求都得携带 HTTP 头部,特别是
对于有携带 Cookie 的头部,而 Cookie 的大小通常很大;
不支持服务器推送消息,因此当客⼾端需要获取通知时,只能通过定时器不断地拉取消息,这
无疑浪费大量了带宽和服务器资源。
为了 解决 HTTP/1.1 性能问题,具体的优化手段你可以看这篇文章「HTTP/1.1 如何优 化? 」,这
里我举例几个常⻅的优化手段:
将多张小图合并成一张大图供浏览器 JavaScript 来切割 使用,这样可以将多个请求合并成一个
请求,但是带来了新的问题,当某张小图片更新了,那么需要重新请求大图片,浪费了大量的
网络带宽;
将图片的二进制数据通过 Base64 编码后,把编码数据嵌入到 HTML 或 CSS 文件中,以此来减
少网络请求次数;
将多个体积较小的 JavaScript 文件使 用 Webpack 等工具打包成一个体积更大的 JavaScript 文
件,以一个请求替代了很多个请求,但是带来的问题,当某个 js 文件变化 了,需要重新请求同
一个包里的所有 js 文件;
将同一个⻚面的资源分散到不同域名,提升并发连接上限,因为浏览器通常对同一域名的 HTTP
连接最大只能是 6 个;
尽管对 HTTP/1.1 协议的优化手段如此之多,但是效果还是不尽人意,因为这些手段都是对
HTTP/1.1 协议的“外部”做优化,而一些关键的地方是没办法优化的,比如请求-响应模型、头部巨
大且重复、并发连接耗时、服务器不能主动推送等,要改变这些必须重新设计 HTTP 协议,于是
HTTP/2 就出来了!
兼容 HTTP/1.1
HTTP/2 出来的目的是为了 改善 HTTP 的性能。协议升级有一个很重要的地方,就是要兼容老版本
的协议,否则新协议推广起来就相当困难,所幸 HTTP/2 做到了兼容 HTTP/1.1 。
那么,HTTP/2 是怎么做的呢?
第一点,HTTP/2 没有在 URI 里引入新的协议名,仍然用「http:// 」表示明文协议,用「https:// 」
表示加密协议,于是只需要浏览器和服务器在背后自动升级协议,这样可以让用⼾意识不到协议
的升级,很好的实现了协议的平滑升级。
第二点,只在应用层做了改变,还是基于 TCP 协议传输,应用层方面为了 保持功能上的兼容,
HTTP/2 把 HTTP 分解成了「语义」和「语法」两个 部分,「语义」层不做改动,与 HTTP/1.1 完全
一致,比如请求方法、状态码、头字段等规则保留不变。
但是,HTTP/2 在「语法」层面做了很多改造,基本改变了 HTTP 报文的传输格式。
头部压缩
HTTP 协议的报文是由「Header + Body 」构成的,对于 Body 部分,HTTP/1.1 协议可以使 用头字
段 「Content-Encoding 」指定 Body 的压缩方式,比如用 gzip 压缩,这样可以节约带宽,但报文
中的另外一部分 Header ,是没有针对它的优化手段。
HTTP/1.1 报文中 Header 部分存在的问题:
含很多固定的字段,比如 Cookie 、User Agent 、Accept 等,这些字段加起来也高达几百字节甚
至上千字节,所以有必要压缩;
大量的请求和响 应的报文里有很多字段值都是重复的,这样会使 得大量带宽被这些冗余的数据
占用了,所以有必须要避免重复性;
字段是 ASCII 编码的,虽然易于人类观察,但效率低,所以有必要改成二进制编码;
HTTP/2 对 Header 部分做了大改造,把以上的问题都解决了。
HTTP/2 没使用常⻅的 gzip 压缩方式来压缩头部,而是开发了 HPACK 算法,HPACK 算法主要包
含三个 组成部分:
静态字典;
动态字典;
Huffman 编码(压缩算法);
客⼾端和服务器两端都会建立和维护「字典」,用⻓度较小的索引号表示重复的字符串,再用
Huffman 编码压缩数据,可达到 50%~90% 的高压缩率。
静态表编码
HTTP/2 为高频出现在头部的字符串和字段建立了一张静态表,它是写入到 HTTP/2 框架里的,不
会变化 的,静态表里共有 61 组,如下图:

表中的 Index 表示索引(Key ), Header Value 表示索引对应的 Value , Header Name 表示字段
的名字,比如 Index 为 2 代表 GET ,Index 为 8 代表状态码 200 。
你可能注意到,表中有的 Index 没有对应的 Header Value ,这是因为这些 Value 并不是固定的而
是变化 的,这些 Value 都会经过 Huffman 编码后,才会发送出去。
这么说有点抽象,我们来看个具体的例子,下面这个 server 头部字段,在 HTTP/1.1 的形式 如
下:
算上冒号空格和末尾的 \r\n ,共占用了 17 字节,而使用了静态表和 Huffman 编码,可以将它
压缩成 8 字节,压缩率大概 47% 。
我抓 了个 HTTP/2 协议的网络包,你可以从下 图看到,高亮部分就是 server 头部字段,只用了
8 个字节来表示 server 头部数据。

根据 RFC7541 规范,如果头部字段属于静态表范围,并且 Value 是变化 ,那么它的 HTTP/2 头部
前 2 位固定为 01 ,所以整个头部格式如下图:

HTTP/2 头部由于基于二进制编码,就不需要冒号空格和末尾的\r\n 作为分隔符,于是改用表示字
符串⻓度(Value Length )来分割 Index 和 Value 。
接下来,根据这个头部格式来分析上面抓包的 server 头部的二进制数据。
server: nghttpx\r\n首先,从静态表中能查到 server 头部字段的 Index 为 54 ,二进制为 110110 ,再加上固定 01 ,
头部格式第 1 个字节就是 01110110 ,这正是上面抓包标注的红色部分的二进制数据。
然后,第二个字节的首个比特位表示 Value 是否经过 Huffman 编码,剩余的 7 位表示 Value 的⻓
度,比如这次例子的第二个字节为 10000110 ,首位比特位为 1 就代表 Value 字符串是经过
Huffman 编码的,经过 Huffman 编码的 Value ⻓度为 6。
最后,字符串 nghttpx 经过 Huffman 编码后压缩成了 6 个字节,Huffman 编码的原理是将高频
出现的信息用「较短」的编码表示,从而缩减字符串⻓度。
于是,在统计大量的 HTTP 头部后,HTTP/2 根据出现频率将 ASCII 码编码为了 Huffman 编码表,
可以在 RFC7541 文档找到这张静态 Huffman 表,我就不把表的全部内容列出 来了,我只列出 字
符串 nghttpx 中每个字符对应的 Huffman 编码,如下图:

通过查表后,字符串 nghttpx 的 Huffman 编码在下图看到,共 6 个字节,每一个字符的
Huffman 编码,我用相同的颜色将他们对应起来了,最后的 7 位是补位的。

最终, server 头部的二进制数据对应的静态头部格式如下:

动态表编码
静态表只包含了 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/1 的文本格式改成二进制格式传输数据,极大提高了 HTTP 传输
效率,而且二进制数据使用位运算能高效解析。
你可以从下 图看到,HTTP/1.1 的响应和 HTTP/2 的区别:

HTTP/2 把响应报文划分 成了两 类帧(Frame ),图中的 HEADERS (首部)和 DATA (消息负载)
是帧的类型,也就是说一条 HTTP 响应,划分 成了两 类帧来传输,并且采用二进制来编码。
比如状态码 200 ,在 HTTP/1.1 是用 '2''0''0' 三个 字符来表示(二进制:00110010 00110000 00110000 ),共用了 3 个字节,如下图

在 HTTP/2 对于状态码 200 的二进制编码是 10001000 ,只用了 1 字节就能表示,相比于
HTTP/1.1 节省了 2 个字节,如下图:

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

- 最前面的 1 标识该 Header 是静态表中已经存在的 KV 。
2. 我们再回顾一下之 前的静态表内容,“:status: 200 OK” 其静态表编码是 8,即 1000 。因此,整体加起来就是 1000 1000 。
HTTP/2 二进制帧的结构如下图:

帧头(Frame Header )很小,只有 9 个字节,帧开头的前 3 个字节表示帧数据(Frame Playload )的⻓度。
帧⻓度后面的一个字节是表示帧的类型,HTTP/2 总共定义了 10 种类型的帧,一般分为数据帧和
控制帧两类,如下表格:

帧类型后面的一个字节是标志位,可以保 存 8 个标志位,用于携带简单的控制信息,比如:
END_HEADERS 表示头数据结束标志,相当于 HTTP/1 里头后的空行(“\r\n” );END_Stream 表示单方向数据发送结束,后续不会再有数据帧。
PRIORITY 表示流的优先级;
帧头的最后 4 个字节是流标识符(Stream ID ),但最高位被保留不用,只有 31 位可以使 用,因此
流标识符的最大值是 2^31 ,大约是 21 亿,它的作用是用来标识该 Frame 属于哪个 Stream ,接收
方可以根据这个信息从乱 序的帧里找到相同 Stream ID 的帧,从而有序组装信息。
最后面就是帧数据了,它存 放的是通过 HPACK 算法压缩过的 HTTP 头部和包体。
并发传输
知道了 HTTP/2 的帧结构后,我们再来看看 它是如何实现并发传输的。
我们都知道 HTTP/1.1 的实现是基于请求-响应模型的。同一个连接中,HTTP 完成一个事 务(请求
与响应),才能处理下一个事 务,也就是说在发出请求等待响应的过程中,是没办法做其他事 情
的,如果响应迟迟 不来,那么后续的请求是无法发送的,也造成了队头阻塞的问题。
而 HTTP/2 就很牛逼了,通过 Stream 这个设计 ,多个 Stream 复用一条 TCP 连接,达到并发的效
果,解决了 HTTP/1.1 队头阻塞的问题,提高了 HTTP 传输的吞吐 量。
为了 理解 HTTP/2 的并发是怎样实现的,我们先来理解 HTTP/2 中的 Stream 、Message 、Frame 这
3 个概念。

你可以从上 图中看到:
1 个 TCP 连接包含一个或者多个 Stream ,Stream 是 HTTP/2 并发的关键技术;
Stream 里可以包含 1 个或多个 Message ,Message 对应 HTTP/1 中的请求或响应,由 HTTP 头
部和包体构成;
Message 里包含一条或者多个 Frame ,Frame 是 HTTP/2 最小单位,以二进制压缩格式存放
HTTP/1 中的内容(头部和包体);
因此,我们可以得出个结论:多个 Stream 跑在一条 TCP 连接,同一个 HTTP 请求与响应是跑在同
一个 Stream 中,HTTP 消息可以由多个 Frame 构成, 一个 Frame 可以由多个 TCP 报文构成。

在 HTTP/2 连接上,不同 Stream 的帧是可以乱序发送的(因此可以并发不同的 Stream ),因为每
个帧的头部会携带 Stream ID 信息,所以接收端可以通过 Stream ID 有序组装成 HTTP 消息,而同
一 Stream 内部的帧必须是严格有序的。
比如下图,服务端并行交错地发送了两个 响应: Stream 1 和 Stream 3 ,这两个 Stream 都是跑在
一个 TCP 连接上,客⼾端收到后,会根据相同的 Stream ID 有序组装成 HTTP 消息。

客⼾端和服务器双方都可以建立 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 还可以对每个 Stream 设置不同优先级,帧头中的「标志位」可以设置优先级,比如客⼾
端访问 HTML/CSS 和图片资源时,希望服 务器先传递 HTML/CSS ,再传图片,那么就可以通过设
置 Stream 的优先级来实现,以此提高用⼾体验。
服务器主动推送资源
HTTP/1.1 不支持服务器主动推送资源给客⼾端,都是由客⼾端向服务器发起请求后,才能获取到
服务器响应的资源。
比如,客⼾端通过 HTTP/1.1 请求从服务器那获取到了 HTML 文件,而 HTML 可能还需要依赖 CSS
来渲染⻚面,这时客⼾端还要再发起获取 CSS 文件的请求,需要两次消息往返,如下图左边部
分:

如上图右边部分,在 HTTP/2 中,客⼾端在访问 HTML 时,服务器可以直接主动推送 CSS 文件,
减少了消息传递的次数。
在 Nginx 中,如果你希望客⼾端访问 /test.html 时,服务器直接推 送 /test.css ,那么可以这么配
置:
location /test.html {
http2_push /test.css;
}那 HTTP/2 的推送是怎么实现的?
客⼾端发起的请求,必须使用的是奇数号 Stream ,服务器主动的推送,使用的是偶数号 Stream 。
服务器在推送资源时,会通过 PUSH_PROMISE 帧传输 HTTP 头部,并通过帧中的 Promised StreamID 字段告知客⼾端,接下来会在哪个偶数号 Stream 中发送包体。

location /test.html { http2_push /test.css; }如上图,在 Stream 1 中通知客⼾端 CSS 资源即将到来,然后在 Stream 2 中发送 CSS 资源,注意
Stream 1 和 2 是可以并发的。
总结
HTTP/2 协议其实还有很多内容,比如流控制、流状态、依赖关系等等 。
这次主要介绍了关于 HTTP/2 是如何提升性能的几个方向,它相比 HTTP/1 大大提高了传输效率、
吞吐 能力。
第一点,对于常⻅的 HTTP 头部通 过静态表和 Huffman 编码的方式,将体积压缩了近一半,而且
针对后续的请求头部,还可以建立动态表,将体积压缩近 90% ,大大提高了编码效率,同时节约
了带宽资源。
不过,动态表并非可以无限增大, 因为动态表是会占用内存的,动态表越大,内存也越大,容易
影响服务器总体的并发能力,因此服务器需要限制 HTTP/2 连接时⻓或者请求次数。
第二点,HTTP/2 实现了 Stream 并发,多个 Stream 只需复用 1 个 TCP 连接,节约了 TCP 和 TLS
握手时间,以及减少了 TCP 慢启动阶段对流量的影响。不同的 Stream ID 可以并发,即使乱序发
送帧也没问题,比如发送 A 请求帧 1 -> B 请求帧 1 -> A 请求帧 2 -> B 请求帧2,但是同一个
Stream 里的帧必须严格有序。
另外,可以根据资源的渲染顺序来设置 Stream 的优先级,从而提高用⼾体验。
第三点,服务器支持主动推送资源,大大提升了消息的传输性能,服务器推送资源时,会先发送
PUSH_PROMISE 帧,告诉客⼾端接下来在哪个 Stream 发送资源,然后用偶数号 Stream 发送资源
给客⼾端。
HTTP/2 通过 Stream 的并发能力,解决了 HTTP/1 队头阻塞的问题,看似很完美了,但是 HTTP/2
还是存在“队头阻塞”的问题,只不过问题不是在 HTTP 这一层面,而是在 TCP 这一层。
HTTP/2 是基于 TCP 协议来传输数据的,TCP 是字节流协议,TCP 层必须保证收到的字节数据是完
整且连续的,这样内核才会将缓冲区里的数据返回给 HTTP 应用,那么当「前 1 个字节数据」没
有到达时,后收到的字节数据只能存放在内核缓冲区里,只有等到这 1 个字节数据到达时,
HTTP/2 应用层才能从内核中拿到数据,这就是 HTTP/2 队头阻塞问题。
有没有什么 解决方案呢?既然是 TCP 协议自身的问题,那干脆放弃 TCP 协议,转而使用 UDP 协议
作为传输层协议,这个大胆的决定,HTTP/3 协议做了!
