真正“搞”懂HTTP协议13之HTTP2

  在前面的章节,我们把HTTP/1.1的大部分核心内容都过了一遍,并且给出了基于Node环境的一部分示例代码,想必大家对HTTP/1.1已经不再陌生,那么HTTP/1.1的学习基本上就结束了。这两篇文章,我会和大家一起,学习一下HTTP/2和HTTP/3。

  还记得我们在之前的时间回溯那篇文章里,简单的聊过HTTP/2和HTTP/3,是为了提升HTTP/1.1所存在的性能问题的,这篇文章我们先来看看HTTP/2带来了哪些性能上的改进和提升。下一篇我们再来学习HTTP/3的性能优化。

  不知道大家在第一次接触HTTP/2、HTTP/3这样的名字的时候会不会有些诧异?怎么不是HTTP/2.0、HTTP/3.0呢?针对这个问题,HTTP/2的工作组给出了官方的回答。他们认为以前的“1.0”“1.1”造成了很多的混乱和误解,让人在实际的使用中难以区分差异,所以就决定 HTTP 协议不再使用小版本号(minor version),只使用大版本号(major version),从今往后 HTTP 协议不会出现 HTTP/2.0、2.1,只会有“HTTP/2”“HTTP/3”……这样就可以明确无误地辨别出协议版本的“跃进程度”,让协议在一段较长的时期内保持稳定,每当发布新版本的 HTTP 协议都会有本质的不同,绝不会有“零敲碎打”的小改良。

一、兼容HTTP/1

  当我们在实际工作中想要开发基于之前版本的新版本代码时,第一个想到的问题就是兼容,我要如何兼容以前的代码,使得使用旧版本的用户也可以尽可能无感的切换到新版本,享受新版本带来的丝滑感受。HTTP/2也是如此,它在背负众多期待的同时,也背负了HTTP/1庞大的历史包袱,所以协议的修改就必须要考虑如何兼容HTTP/1,否则就会破坏互联网上无数现有的资产,这肯定不是大家想要看到的。那HTTP/2是怎么做的呢?

  HTTP/2把HTTP分解成了“语法”和“语义”两部分,语法层面不做改动,与HTTP/1也就是RFC7231完全一致。比如请求方法、URI、状态码、头字段等都保持不变,这样就消除了再学习的成本,基于HTTP的上层不需要任何的改动,可以无缝转换到HTTP/2。

  特别要说的是,HTTP/2没有再URI里引入新的协议名,仍然用“http”表示明文协议,用“https”表示加密协议。这是一个非常了不起的决定,可以让浏览器或者服务器去自动升级或降级协议,免去了选择的麻烦,让用户在上网的时候都意识不到协议的切换,实现平滑过渡。

  在“语义”保持稳定之后,HTTP/2 在“语法”层做了“天翻地覆”的改造,完全变更了 HTTP 报文的传输格式。

二、头部压缩

  首先,为啥要对头部进行压缩呢?假设这样一种场景,一个GET请求,返回的body十分简单啊,可能就是个简单的文本,几十个字节。但是头字段却又几百个,限制的十分严谨细腻,而这样的请求在整个系统项目中又应用的十分频繁,成了不折不扣的“大头儿子”。更要命的是,这些报文的传输中,大部分的头字段都是一样的。再者,HTTP针对body有很多优化的手段,却对Header一点优化都没有。

  基于以上的这些原因,为了优化“长尾效应”导致大量的带宽消耗在这了这些冗余度极高的数据上的情况,HTTP/2就把头部压缩作为性能改进的一个重点,优化的方式,就是压缩。但是HTTP/2的头部压缩并不是想body那样的压缩手段,而是专门开发了“HPACK”算法,在客户端和服务器端建立“字典”,用索引号表示重复的字符串,还釆用哈夫曼编码来压缩整数和字符串,可以达到 50%~90% 的高压缩率。

定制的HPACK

  由于HTTP/2在语义上与HTTP/1兼容,所以报文还是Header+Body的形式,但是在请求发送前,必须要用“HPACK”算法来压缩头部数据。

  “HPACK”算法是专门为压缩HTTP头部定制的算法,与gzip、zlib等压缩算法不同,它是一个“有状态”的算法,需要客户端和服务器都维护一份“索引表”,也可以说是字典,压缩和解压缩就是查表和更新表的操作。

  为了方便管理和压缩,HTTP/2废除了原有的起始行的概念,把起始行里面的请求方法、URI、状态码等统一转换成了头字段的形式,并且给这些“不是头字段的头字段”起了个特别的名字——“伪头字段”。而起始行里面的版本号和错误短语因为没啥大用,就给废弃了。

  为了与“真头字段”区分开,这些伪头字段会在名字前面加上一个“:”,比如“:authority”、“:method”、“:status”,分别表示的是域名、请求方法和状态码。现在 HTTP 报文头就简单了,全都是“Key-Value”形式的字段,于是 HTTP/2 就为一些最常用的头字段定义了一个只读的“静态表”(Static Table)。

  下面的这个表格列出了“静态表”的一部分,这样只要查表就可以知道字段名和对应的值,比如数字“2”代表“GET”,数字“8”代表状态码 200。

   但如果表里只有 Key 没有 Value,或者是自定义字段根本找不到该怎么办呢?这就要用到“动态表”(Dynamic Table),它添加在静态表后面,结构相同,但会在编码解码的时候随时更新。

  比如说,第一次发送请求时的“user-agent”字段长是一百多个字节,用哈夫曼压缩编码发送之后,客户端和服务器都更新自己的动态表,添加一个新的索引号“65”。那么下一次发送的时候就不用再重复发那么多字节了,只要用一个字节发送编号就好。

  你可以想象得出来,随着在 HTTP/2 连接上发送的报文越来越多,两边的“字典”也会越来越丰富,最终每次的头部字段都会变成一两个字节的代码,原来上千字节的头用几十个字节就可以表示了,压缩效果比 gzip 要好得多。

三、二进制帧

  大家知道HTTP/1是纯文本形式的报文,它的优点就是对人友好,一目了然,用最简单的工具,甚至不用工具就可以开发调试,非常方便。

  但是HTTP/2改变了延续十多年的现状,不再使用肉眼可见的ASCII码,而是向下层的TCP/IP协议“靠拢”,全面采用二进制格式。这样虽然对人不友好,但却大大方便了计算机的解析。原来使用纯文本的时候容易出现多义性,比如大小写、空白字符、回车换行、多字少字等等,程序在使用时必须用复杂的状态机,效率低,还很麻烦。

  二进制里只有0和1,可以严格规定字段大小、顺序、标志位等格式,对错分明,解析起来没有歧义,实现简单,而且体积小、速度快,做到“内部提效”。

  基于二进制的基础,HTTP/2进行了大刀阔斧的改革。

  它把TCP协议的部分特性挪到了应用层,把原来“Header+Body”的消息“打散”为数个小片的二进制“帧”(Frame),用"HEADERS"帧存放头数据,“DATA”帧存放实体数据。

  这种做法有点像是“Chunked”分块编码的方式(参见第 16 讲),也是“化整为零”的思路,但 HTTP/2 数据分帧后“Header+Body”的报文结构就完全消失了,协议看到的只是一个个的“碎片”。

二进制帧的结构

  我们先来看张图吧:

   我们看图说话。帧开头就是三个字节的长度,默认上限是2^14到2^24,也就是说HTTP/2的帧的大小通常不超过16K,最大是16M。当然,这个长度不包括帧头(Frame Header)的9个字节。

  长度后面的一个字节是帧类型,大致可以分为数据帧和控制帧两类,HEADERS帧和DATA帧属于数据帧,存放的是HTTP报文,而SETTINGS、PING、PRIORITY等则是用来管理流的控制帧。

  HTTP/2总共定义了10种类型的帧,但一个字节最多可以标识256种,所以也允许在标准之外定义其他类型实现功能扩展。

  第五个字节是非常重要的帧标志信息,可以保存8个标志位,携带简单的控制信息。常用的标志位有 END_HEADERS 表示头数据结束,相当于 HTTP/1 里头后的空行(“\r\n”),END_STREAM 表示单方向数据发送结束(即 EOS,End of Stream),相当于 HTTP/1 里 Chunked 分块结束标志(“0\r\n\r\n”)。

  报文头里最后4个字节流标识符,也就是帧所属的“流”,接收方使用它就可以从乱序的帧里识别出具有相同流 ID 的帧序列,按顺序组装起来就实现了虚拟的“流”。

四、流与多路复用

  有了二进制格式的数据后,就可以把一整块的数据打散,然后发送出去。那碎片到了目的地后要怎么组装起来呢?

  HTTP/2为此定义了一个流(Stream)的概念,它是二进制帧的双向传输序列,同一个消息往返的帧会分配一个唯一的流ID。你可以把它想象成是一个虚拟的“数据流”,在里面流动的是一串有先后顺序的数据帧,这些数据帧按照次序组装起来就是HTTP/1里的请求报文和响应报文。

  因为“流”是虚拟的,实际上并不存在,所以 HTTP/2 就可以在一个 TCP 连接上用“流”同时发送多个“碎片化”的消息,这就是常说的“多路复用”( Multiplexing)——多个往返通信都复用一个连接来处理。

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

  为了更好地利用连接,加大吞吐量,HTTP/2 还添加了一些控制帧来管理虚拟的“流”,实现了优先级和流量控制,这些特性也和 TCP 协议非常相似。

  HTTP/2 还在一定程度上改变了传统的“请求 - 应答”工作模式,服务器不再是完全被动地响应请求,也可以新建“流”主动向客户端发送消息。比如,在浏览器刚请求 HTML 的时候就提前把可能会用到的 JS、CSS 文件发给客户端,减少等待的延迟,这被称为“服务器推送”(Server Push,也叫 Cache Push)。

  这么说还是有点僵硬,不那么好理解,我们来看张图,再深入的理解下什么是虚拟的流和多路复用。

    我们先来看第一部分,有Stream1、Stream2标识的就代表着虚拟流,其实在实际的传输种并不存在,只是一种往返的标识,表示我是属于这一次通信的,所以才说流是虚拟的。

  然后是下面的这一部分,就是打散的在TCP信道种传输的一个又一个二进制帧数据,每个帧数据种会有流ID,到达终点后会根据流ID来拼接成一个完整的数据。这样是不是就更好理解了什么是虚拟流。

  在 HTTP/2 连接上,虽然帧是乱序收发的,但只要它们都拥有相同的流 ID,就都属于一个流,而且在这个流里帧不是无序的,而是有着严格的先后顺序。

  其实上面的图稍微缺失了一点东西,我们把它加上:

   我们看上图,其实在传输的时候是乱序的,每个帧都有其独立的流ID,然后就像是虚拟了流的传输。

HTTP/2流的特点

  我们学了不少关于HTTP/2流的内容,那么我们继续看看HTTP/2的流有哪些特点吧。

  流是可并发的,一个 HTTP/2 连接上可以同时发出多个流传输数据,也就是并发多请求,实现“多路复用”;

  客户端和服务器都可以创建流,双方互不干扰;

  流是双向的,一个流里面客户端和服务器都可以发送或接收数据帧,也就是一个“请求 - 应答”来回;

  流之间没有固定关系,彼此独立,但流内部的帧是有严格顺序的;

  流可以设置优先级,让服务器优先处理,比如先传 HTML/CSS,后传图片,优化用户体验;

  流 ID 不能重用,只能顺序递增,客户端发起的 ID 是奇数,服务器端发起的 ID 是偶数;

  在流上发送“RST_STREAM”帧可以随时终止流,取消接收或发送;

  第 0 号流比较特殊,不能关闭,也不能发送数据帧,只能发送控制帧,用于流量控制。

  基于这些内容,我们还可以推断出一些更深层次的东西。比如说,HTTP/2 在一个连接上使用多个流收发数据,那么它本身默认就会是长连接,所以永远不需要“Connection”头字段(keepalive 或 close)。

  又比如,下载大文件的时候想取消接收,在 HTTP/1 里只能断开 TCP 连接重新“三次握手”,成本很高,而在 HTTP/2 里就可以简单地发送一个“RST_STREAM”中断流,而长连接会继续保持。

  再比如,因为客户端和服务器两端都可以创建流,而流 ID 有奇数偶数和上限的区分,所以大多数的流 ID 都会是奇数,而且客户端在一个连接里最多只能发出 2^30,也就是 10 亿个请求。所以就要问了:ID 用完了该怎么办呢?这个时候可以再发一个控制帧“GOAWAY”,真正关闭 TCP 连接。

流状态转换

  大家记不记得TCP的三次握手,其实本质上是数据包的交换和双方状态的转换,最开始的时候,客户端和服务器都处于CLOSED状态,当客户端发起一个SYN的时候,服务器会进入LISTEN状态。然后往复的数据包会使客户端和服务器切换状态,我们贴一下之前贴过的图:

   那么,HTTP/2的流其实也有一个状态转换的过程。我们先来看下流状态转换的图:

   最开始的时候,流都是空闲(idle)状态,也就是”不存在“,可以理解成是待分配的”号段资源“。

  当客户端发送HEADERS帧后,有了流ID,流就进入了”打开“状态,两端都可以收发数据,然后客户端发送一个带“END_STREAM”标志位的帧,流就进入了“半关闭”状态。

  这个“半关闭”状态很重要,意味着客户端的请求数据已经发送完了,需要接受响应数据,而服务器端也知道请求数据接收完毕,之后就要内部处理,再发送响应数据。

  响应数据发完了之后,也要带上“END_STREAM”标志位,表示数据发送完毕,这样流两端就都进入了“关闭”状态,流就结束了。

  刚才也说过,流 ID 不能重用,所以流的生命周期就是 HTTP/1 里的一次完整的“请求 - 应答”,流关闭就是一次通信结束。

  下一次再发请求就要开一个新流(而不是新连接),流 ID 不断增加,直到到达上限,发送“GOAWAY”帧开一个新的 TCP 连接,流 ID 就又可以重头计数。

  我们再看看这张图,是不是和 HTTP/1 里的标准“请求 - 应答”过程很像,只不过这是发生在虚拟的“流”上,而不是实际的 TCP 连接,又因为流可以并发,所以 HTTP/2 就可以实现无阻塞的多路复用。

五、小结

  本来我是想写个HTTP/2的例子的,但是代码其实Node官网有,我写也是照抄,另外,还需要本地安装openssl的证书(因为虽然协议不强制加密,但是现在的浏览器不加密就不能用HTTP/2),我嫌麻烦,就不写了~

  我们目前学完了HTTP/2的大部分核心特性,这些内容肯定不是HTTP/2的全部,但是却是最重要的一部分。

  另外,HTTP/2为了兼容HTTP/1的明文特点,可以像以前一样使用明文传输数据,不强制使用加密通信,不过格式还是二进制,只是不需要解密。但是由于HTTPS是大势所趋,基本上互联网上的HTTP/2都是加密的。但是为了区分加密和明文这两个不同版本,HTTP/2定义了h2和h2c两个字符串来区分。

  相比于HTTPS,HTTP/2的下层实际上是HPACK和STREAM,加密则是TLS1.2+,这个大家了解下就可以了。

  最后,还有一个核心的概念叫做”连接前言“,我刚刚也说了,HTTP/2事实上是基于TLS的,所以在正式发送数据前就会有TCP握手和TLS握手,当TLS握手成功后,客户端必须发送一个”连接前言“,用来确认建立HTTP/2连接。

  这个“连接前言”是标准的 HTTP/1 请求报文,使用纯文本的 ASCII 码格式,请求方法是特别注册的一个关键字“PRI”,全文只有 24 个字节:

PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n

  那为啥要这样做呢?没有为啥,就是王八的屁股~规定。

  还有,HTTP/2固然有很多优点,不然还搞它干啥,但是HTTP/2也有不少的问题。最严重的问题就是丢包和TCP的重新连接。丢包问题是在TCP级别的,HTTP/2解决不了TCP级别的队头阻塞,所以当包丢失后,就要等待后续的包再重新传一遍,当达到一定的丢包率,甚至性能表现还不如HTTP/1。而重新连接,则发生在IP地址切换的时候,TCP就要再次握手,经历慢启动,而且之前连接里积累的HPACK字典也都没了,必须重新计算,导致带宽的浪费和延迟。

  好啦,HTTP/2的内容很多,仅仅是这一篇文章肯定不够,但是大家学会了虚拟流、理解了多路复用、头部压缩的HPACK,其实也就了解了HTTP/2的核心,其它的细节,大家可以去规范中自行查阅学习。

  

posted @ 2023-02-12 20:59  Zaking  阅读(412)  评论(0编辑  收藏  举报