学习也要思考,思考也要练习
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性能问题,可以有如下的优化手段:
- 将多张小图合并成一张大图供浏览器JavaScript来切割使用,这样可以将多个请求合并成一个请求,但是带来了新的问题,当某张小图片更新了,an么需要重新请求大图片,浪费了大量的网络带宽;
- 将图片的二进制数据通过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/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 的形式如下:
server: nghttpx\r\n
算上冒号空格和末尾的\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
头部字段的 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 响应,划分成了两个帧来传输,并且采用二进制来编码。
HTTP/2 二进制帧的结构如下图:
帧头很小,只有9个字节,帧开头的前3个字节表示帧数据的长度。
帧长度后面的一个字符是表示帧的类型,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中的内容(头部和包体);
因此,我们可以得出2个结论:HTTP消息可以有多个Frame构成,以及1个Frame可以由多个TCP报文构成。
在HTTP/2连接上,不同Stream的帧是可以乱序发送的(因此可以并发不同的Stream),因为灭个帧的头部会携带Stream ID信息,所以接收端可以通过Stream ID有序组装成HTTP消息,而同一Stream内部的正必须是严格有序的。
客户端和服务器双方都可以建立Stream,Stream ID也是有区别的,客户端建立的Stream必须是奇数号,而服务器建立的Stream必须是偶数号。
同一个连接中的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文件,减少了消息传递的次数。
那HTTP/2的推送是怎么实现的?
客户端发起的请求,必须使用的是奇数号Stream,服务器主动的推送,使用的是偶数号Stream。服务器在推送资源时,会通过PUSH_PROMISE帧传输HTTP头部,并通过帧中的Promised Stream ID字段告知客户端,接下来会在那个偶数号Stream中发送包体。
如上图,在Stream 1中通知客户端CSS资源即将到来,然后在Stream2中发送CSS资源,注意Stream1和2是可以并发的。
总结
HTTP/2协议其实还有很多内容,比如流控制、流状态、依赖关系等等。
这次主要介绍了关于HTTP/2是如何提示性能的几个方向,它相比HTTP/1大大提高了传输效率、吞吐能力。
第一点,对于常见的HTTP头部,通过静态表和Huffman编码的方式,将体积压缩了近一半,而且针对后续的请求头部,还可以建立动态表,将体积压缩近90%,大大提高了编码效率,同时节约了带宽资源。
不过,动态表并非可以无限增大,因为动态表是会占用内存的,动态表越大,内存也越大,容易影响服务器总体的并发能力,因此服务器需要限制HTTP/2连接时长或者请求次数。
第二点,HTTP/2实现了Stream并发,多个Stream只需复用1个TCP连接,节约了TCP和TLS握手时间,以及减少了TCP慢启动阶段对流量的影响。不同的Stream ID才可以并发,即使乱序发送帧也没问题,但是同一个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协议,转而使用UDP协议作为传输层协议。