前篇博文:图解HTTP中介绍了HTTP的演进史,同时也提到了HTTP/1.1 版本存在的性能瓶颈,比如服务器因为只能逐个顺序响应HTTP请求可能引起的队头阻塞问题,因为每个HTTP 报文都要传输臃肿的首部字段导致的网络效率降低等问题。
解决上面的问题也有相应的思路,比如HTTP 响应的队头阻塞问题可以让多个请求/响应报文并发复用一个TCP连接,不至于因为某个请求报文响应阻塞而影响后续所有请求报文的响应。HTTP报文重复传输臃肿的首部字段的问题,前篇博文也给出了相应的解决思路,通信双方可以都维护一张HTTP 首部字段索引列表,报文中只传输对应字段的索引值,就能大大压缩报文首部的长度,提高网络利用率。
2009年,Google提出了一种HTTP/1.1 的替代方案SPDY (发音同Speedy)来改善HTTP/1.1 遗留的性能瓶颈问题,经过几年的验证改进后,SPDY 带来了显著的网络访问效率提升。因其出色的性能,SPDY 成为HTTP/2 开发的基础,并证明了前面提出的一些解决思路的合理性,比如TCP连接多路复用、二进制编码和首部压缩等。
2012 年,HTTP 工作组(IETF 工作组中负责HTTP 规范的小组)启动了开发下一个HTTP 版本的工作,并最终决定使用SPDY 作为HTTP/2.0 的起点。2015年,HTTP/2 作为正式协议发布于RFC7540。
HTTP/2 是一个彻彻底底的二进制协议,头信息和数据包体都是二进制的,统称为“帧”。对比HTTP/1.1 中,头信息是文本编码(ASCII编码),数据包体可以是二进制也可以是文本。HTTP/2 使用二进制作为协议实现方式的好处是便于计算机直接处理,但不方便我们肉眼识别。
HTTP/2 中的基本协议单元是一个帧,在 HTTP/2 中定义了 10 种不同类型的帧,每种帧类型都有不同的用途。比如 HEADERS 和 DATA 帧构成了 HTTP 请求和响应的基础;其它帧类型(比如 PRIORITY、SETTINGS、PUSH_PROMISE、WINDOW_UPDATE 等 )用于支持其它 HTTP/2 功能。
HTTP/1.1 中由于服务器只能逐个顺序响应请求报文,前一个响应未完成就只能一直阻塞等待而无法传输下一个响应报文,这就白白浪费了TCP 连接的带宽资源,同时带来了队头阻塞问题。HTTP/2 支持多个资源请求/响应报文在同一个TCP连接上并发传输,可以充分利用TCP连接的带宽资源,也不会因为某个报文响应一直阻塞等待而影响其它报文的传输,同时解决了队头阻塞问题。
HTTP/2 中不同资源的数据包在同一个TCP连接上是乱序传输的,怎么判断哪些数据包是同一个资源的呢?怎么将收到的数据包正确组合成目标资源呢?这就要求每个数据包都包含相应的标识信息,用来标识它属于哪个资源或者哪个请求/响应报文。
HTTP/2 把每个 request 和 response 的数据包称为一个数据流(stream),每个数据流都有自己全局唯一的编号,每个数据包在传输过程中都需要标记它属于哪个数据流 ID。规定,客户端发出的数据流 ID 一律为奇数,服务器发出的数据流 ID 一律为偶数。
HTTP/2 在客户端与服务器端都维护了一张首部字段索引列表, header 字段列表是以key - value 键值对元素构成的有序集合,每个header 字段元素都映射为一个索引值,报文中使用header 字段的索引值进行二进制编码传输,显然比HTTP/1.1 直接使用header 字段ASCII 编码传输,数据量小得多,这种减少header 字段传输开销的技术可以称为首部压缩HPACK。
为了保证header 字段的索引值能正确解码,客户端与服务器端的header 字段列表索引映射关系应该完全一致。为了进一步降低header 字段的传输开销,这些 header 字段表可以在编码或解码新 header 字段时进行增量更新,新的header 字段采用Huffman 编码(摩斯电码就采用了霍夫曼编码)可以进一步降低编码后的字节数。
再回顾下 HTTP/2 解决了HTTP/1.1 的哪些性能瓶颈或者带来了哪些性能提升:
HTTP/1.1 性能瓶颈 | HTTP/2 改进优化 |
---|---|
存在队头阻塞问题,降低了TCP连接利用率 | 通过多数据流并发复用TCP连接,不仅解决了队头阻塞问题,还大大提高了TCP连接的利用率 |
重复传输臃肿的首部字段,降低了网络资源利用率 | 通过首部压缩,大大减少了需要传输的首部字段字节数,进一步提高了网络资源利用率 |
报文各字段长度不固定,增加了报文解析难度,只能串行解析 | 整个报文都采用二进制编码,且每个字段长度固定,可以并行处理,提高了报文处理效率 |
只能客户端发起请求,服务器响应请求,服务器端的数据更新不能及时反馈给客户端 | 支持服务器端向客户端推送资源,服务器端的数据更新可以及时反馈给客户端,也可以通过预判客户端需求提前向客户端推送相应资源,提高客户端的访问响应效率 |
HTTP/2 是为了解决HTTP/1.1 的性能瓶颈问题诞生的,在提供更高网络访问效率的同时,自然需要向前兼容HTTP/1.1。HTTP/2 和HTTP/1.1 使用相同的 “http” 和 “https” URI scheme,共享相同的默认端口号: “http” URI 为 80,“https” URI 为 443。既然从URL 上无法区分HTTP/2 和HTTP/1.1,我们只能从报文标头信息辨识网站使用的是哪个协议版本了(比如报文中的“协议版本”字段)。
前面已经简单介绍过HTTP/2 相比HTTP/1.1 比较重要的几个特性:二进制帧层、多数据流并发、Header 压缩、服务端推送等,下面分别对其介绍。
前面介绍了,HTTP/2 是基于帧 (frame)的协议,采用分帧是为了将重要信息都封装起来,让协议的解析方可以轻松阅读、解析并还原信息。 相比之下,HTTP/1.x 不是基于帧的,而是以文本分隔。 解析这种文本分割的数据往往速度慢且容易出错,你需要不断读入字节,直到遇到分隔符为止(这里指[CRLF]),这会带来以下弊端:
HTTP/2 使用帧来封装各字段信息,帧中包含表示整帧长度的字段,每个字段也有固定的长度,处理帧协议的程序就能预先知道会收到哪些字段信息,每个字段占用多少内存空间,也就可以并行处理数据帧。HTTP/2 的帧结构如下图示:
前9 个字节对于每个帧都是一致的,解析时只需要读取这些字节,就可以准确地知道在整个帧中期望的字节数。帧首部各字段说明见下表:
帧字段名称 | 长度 | 描述 |
---|---|---|
Length | 3字节 | 表示帧负载的长度(取值范围为214 ~ 224-1 字节), 值得注意的是,214 字节是默认的最大帧大小, 如果需要更大的帧,可在SETTINGS 帧中设置 |
Type | 1字节 | 当前帧类型 |
Flags | 1字节 | 具体帧类型的标识,不同的帧类型标识不一样 |
R | 1位 | 保留位,不要设置,否则可能带来严重后果 |
Stream Identifier | 31位 | 每个流的唯一ID |
Frame Payload | 长度可变 | 真实的帧内容,长度是在Length 字段中设置的 |
如果读者对TCP/IP 协议栈比较了解的话,会发现HTTP/2 的帧结构与TCP/UDP/IP数据报文格式有很大的相似之处,报文中都包含了总长度字段信息,每个字段的长度都是固定的,计算机处理这种报文/帧会更加简单高效。
得益于帧结构的优势,HTTP/2 可以并行处理多个数据帧,再借助Stream Identifier 字段标识每个请求/响应数据流,可以让不同数据流的数据帧交错的在TCP连接上传输(借助Stream ID,即便交错传输也可以重新组装),这就实现了多个数据流并发复用同一个TCP连接的效果。
多数据流并发复用同一个TCP连接,有点类似于多线程并发复用同一个处理器资源,不会因为某一个数据流的阻塞等待而影响其它数据流的传输,既提高了TCP连接的利用效率,又解决了HTTP/1.1 中的队头阻塞问题。可以说,HTTP/2 帧结构是数据流多路复用的基础,也是解决队头阻塞问题的关键。
HTTP/2 中定义了 10种不同的帧类型,每种帧类型都有不同的用途,其中 HEADERS 和 DATA 帧构成了 HTTP 请求和响应的基础,这十种帧类型及功能描述如下:
帧类型名称 | ID | 描述 |
---|---|---|
DATA | 0x0 | 传输流的核心内容 |
HEADERS | 0x1 | 包含HTTP 首部,和可选的优先级参数 |
PRIORITY | 0x2 | 指示或者更改流的优先级和依赖 |
RST_STREAM | 0x3 | 允许一端停止流(通常由于错误导致的) |
SETTINGS | 0x4 | 协商连接级参数 |
PUSH_PROMISE | 0x5 | 提示客户端,服务器要推送些东西 |
PING | 0x6 | 测试连接可用性和往返时延(RTT) |
GOAWAY | 0x7 | 告诉另一端,当前端已结束 |
WINDOW_UPDATE | 0x8 | 协商一端将要接收多少字节(用于流量控制) |
CONTINUATION | 0x9 | 用以扩展HEADER 数据块 |
可以说,HTTP/2 所有性能增强的核心在于新的二进制分帧层,它定义了如何封装 HTTP 消息并在客户端与服务器之间传输。这里所谓的“层”,指的是位于套接字接口与应用可见的高级 HTTP API 之间一个经过优化的新编码机制:HTTP 的语义(包括各种动词、方法、标头)都不受影响,不同的是传输期间对它们的编码方式变了。 HTTP/1.x 协议以换行符作为纯文本的分隔符,而 HTTP/2 将所有传输的信息分割为更小的消息和帧,并采用二进制格式对它们编码。
限于篇幅,没法对这十种类型的帧定义进行全面介绍,下面选择构成HTTP/2 请求响应基础的HEADERS 帧和 DATA 帧为例,简单介绍完整的帧定义格式。
DATA帧(类型Type = 0x0)比较简单,主要是用来封装报文主体数据的,DATA帧结构定义如下(HTTP/2帧首部定义前面介绍过了,这里的DATA帧结构属于HTTP/2帧结构中的Frame Payload部分,其余类型帧结构定义也是如此):
DATA 类型的帧包含的字节长度不定,如果超出帧容许的最大长度,资源数据会被切分到一个或者多个帧里面去。上图中的填充长度(Pad Length)字段和填充数据(Padding)属于可选字段,可以借助填充数据隐藏真实的消息大小(出于安全方面的考虑)。DATA帧字段及对应的帧类型标识如下:
DATA帧标识位Flags名称 | 位 | 描述 |
---|---|---|
END_STREAM | 0x1 | 表明这是流中最后的帧(流终止) |
PADDED | 0x8 | 表明此帧添加了填充数据,要处理Pad Length 和Padding 字段 |
DATA帧字段名称 | 长度 | 描述 |
---|---|---|
Pad Length | 1字节 | 填充字节的长度; 在帧首部Flags字段的PADDED 标识设置为1 的时候才会有该字段 |
DATA | 长度可变 | 帧的内容 |
Padding | 长度可变 | 长度为Pad Length 字段的值,所有的字节被设置为0; 在帧首部Flags字段的PADDED 标识设置为1 的时候才会有该字段 |
由于资源数据可能会被切分到多个DATA帧中,需要对最后一个DATA帧进行标识,以便处理程序快速获知该资源数据已接收完毕。DATA帧中用于标识最后帧的Flags是END_STREAM,表示当前资源数据发送结束(即 EOS,End of Stream),相当于 HTTP/1.x 中的 Chunked 分块结束标志(“0\r\n\r\n”)。
HEADER帧(类型Type = 0x1)相对比较复杂,主要是用来封装报文首部数据的,相当于HTTP/1.1 中的 start line + header,HEADER帧结构定义如下:
HEADERS帧结构中最主要的字段Header Block Fragment 在下文介绍首部压缩时再展开,这里先看HEADERS帧类型标识及各字段描述如下(填充相关的字段作用与DATA帧中的一致):
HEADERS帧标识位Flags名称 | 位 | 描述 |
---|---|---|
END_STREAM | 0x1 | 表明这是流中最后的帧(流终止) |
END_HEADERS | 0x4 | 表明这是流中最后一个HEADERS 帧; 如果此标识未设置,表示随后会有CONTINUATION 帧 |
PADDED | 0x8 | 表明此帧添加了填充数据,要使用Pad Length 和Padding 字段 |
PRIORITY | 0x20 | 如果设置了此标识,就表示要使用E、Stream Dependency 以及Weight 字段 |
HEADERS帧字段名称 | 长度 | 描述 |
---|---|---|
Pad Length | 1字节 | 填充字节的长度; 帧首部的PADDED 标识设置为1 时才会有该字段 |
E | 1位 | 表示流依赖是否为专用的; 只有设置了PRIORITY 标识才会有该字段 |
Stream Dependency | 31位 | 表示当前流所依赖的流,如果有的话; 只有设置了PRIORITY 标识才会有该字段 |
Weight | 1字节 | 当前流的相对权重; 只有设置了PRIORITY 标识才会有该字段 |
Header Block Fragment | 长度可变 | 消息的首部,包含各首部字段信息 |
Padding | 长度可变 | 长度为Pad Length 字段的值,所有的字节被设置为0; 帧首部的PADDED 标识设置为1 时才会有该字段 |
HEADERS帧结构中的Header Block Fragment 字段字节长度不定,有些报文首部长度可能超出帧容许的最大长度,后面会以CONTINUATION帧的形式继续封装剩下的附加首部字段,CONTINUATION帧实际上只有一个Header Block Fragment 字段(可变长度),CONTINUATION帧标识位Flags只有一个END_HEADERS。HEADERS帧与CONTINUATION帧标识位END_HEADERS 表示标头数据结束,相当于 HTTP/1.x 中报文头部后的空行(“\r\n”)。
HEADERS帧结构中的E、Stream Dependency、Weight 三个可选字段都依赖于PRIORITY帧设置,PRIORITY 帧是为了标识流的优先级,下文介绍流时再展开。
HTTP/2 规范对流(stream)的定义是:“HTTP/2 连接上独立的、双向的帧序列交换”,你可以将流看作在连接上的一系列帧,它们构成了单独的HTTP 请求和响应。如果客户端想要发出请求,它会开启一个新的流,服务器将在这个流上回复。这与HTTP/1.x 的请求、响应流程类似,重要的区别在于,HTTP/2 有分帧层和流 ID,所以多个请求和响应报文可以交错在TCP连接上传输,而不会互相阻塞,这就实现了多流并发复用的效果。
Stream 流是用来传输一对儿请求、响应消息(相当于HTTP/1.x 中的报文)的,一个消息至少由HEADERS 帧(它初始化流)组成,并且可以另外包含CONTINUATION 和DATA 帧。客户端到服务器的HTTP/2 连接建立之后,通过发送HEADERS 帧(如果首部过长可能还会发送CONTINUATION 帧)来启动新的流。后续有新的流启动时,会发送一个带有递增流 ID(每对儿请求与响应消息都有一个不同的流 ID,客户端会从1 开始设置流ID,之后每新开启一个流就会增加2,之后一直使用奇数)的新HEADERS 帧。
前面介绍过,Stream ID 使用无符号的 31 位整数标识,由客户端发起的流使用奇数编号,由服务器发起的流使用偶数编号。流标识符零(0x0)用于连接控制消息,零流标识符不能用于建立新的 stream 流。
Stream ID可以说是多流并发(也称多路复用)的关键,多个流的数据帧在同一个TCP连接上交错/并发传输,接收端主要就是根据这个stream ID 来辨识每个数据帧属于哪个流,并依序组装复原消息的(这就要求同一个 stream 内的数据帧必须是有序的)。
在一个TCP连接上支持的最大并发流数量由SETTINGS帧的SETTINGS_MAX_CONCURRENT_STREAMS 参数控制,前面也介绍过HTTP/2 帧负载的长度如果超过 214 字节就需要在SETTINGS帧中设置,这里简单介绍下SETTINGS帧结构及参数列表如下:
SETTINGS 帧包含了若干有序的 ID / Value 键值对(数量根据需要而定),每个键值对长度为6字节(ID占2 字节,Value占4 字节)。如果一端接收并处理了SETTINGS 帧,就必须返回一个SETTINGS 帧,在帧首部中Flags字段带上ACK 标识(0x1),这是SETTINGS 帧里定义的唯一的帧类型标识位。这样发送端就知道接收端收到了新的SETTINGS 帧,并会遵守SETTINGS 帧的设置。
借助分帧层和Stream ID,HTTP/2 可以在一个TCP 连接上实现多流并发,但多个数据流之间会相互竞争,某个数据流过大会阻塞其它stream 流的传输。我们怎么确保同一条连接上的流不会相互阻塞呢?我们很容易想到TCP 协议中的流量控制方案(可参考博文:网络传输管理之TCP协议中的流量控制部分),通信双方借助一个接收窗口来同步双方当前的发送与接收能力,提高对网络资源的利用效率。
HTTP/2 也采用类似的思路,使用WINDOW_UPDATE 帧来同步通信双方的发送与接收能力,数据流接收方通过WINDOW_UPDATE 帧向发送方更新流量控制窗口的大小,发送方传输数据流的速率受到该流量控制窗口大小的限制。当接收端处理完接收到的数据,它会发出一个WINDOW_UPDATE 帧来告诉发送方自己新的接收窗口大小。
你可能会疑惑,TCP 已经提供了流量控制功能,HTTP 通信是基于TCP 连接的,为何还需要HTTP/2 再提供流量控制功能呢?还记得HTTP/2 的多流并发复用同一个TCP 连接吗?TCP 流量控制的精度是针对一个TCP 连接的,单靠TCP 的流量控制并不能解决一个TCP 连接内的多流争用阻塞问题。HTTP/2 流量控制的精度是针对一个stream 流的,可以精细控制同一个TCP连接内的每个流中的数据传输速率,自然就能解决同一个TCP 连接上多个流相互干扰阻塞的问题了。
HTTP/2 的流量控制是基于WINDOW_UPDATE 帧的,但仅针对直接建立 TCP 连接的两端有效,如果对端是代理服务器,代理服务器不需要向上游转发 WINDOW_UPDATE 帧(代理两端的吞吐能力不同可能适用的流量控制窗口也有差别)。同时,为了确保重要的控制帧不被流量控制阻挡,流量控制目前只对DATA 帧有效。WINDOW_UPDATE 帧结构及其字段描述如下:
WINDOW_UPDATE 帧只有一个字段Window Size Increment 用来更新当前窗口大小的增量,既然是增量就有对应的基准或初始值,初始窗口大小的默认值由SETTINGS帧的SETTINGS_INITIAL_WINDOW_SIZE参数限制(默认值为65535字节),可以通过SETTINGS帧来设置新的初始窗口大小(可设置的最大窗口大小为231-1)。
WINDOW_UPDATE 帧没有定义任何 Flags 标志,主要对DATA 帧首部 stream ID 所标识的数据流进行流量控制窗口大小更新。如果 stream ID 值为“0”,则表示该数据帧所在的整个TCP 连接都受WINDOW_UPDATE 帧更新的流量控制窗口大小限制。
当我们访问一个网页时,平均会请求上百个资源,这些资源之间往往存在一定的依赖关系,比如浏览器请求到HTML后,需要收到CSS和关键的JavaScript 资源后才能开始页面渲染,也即其它资源的布局和渲染依赖于CSS和JavaScript 资源。如果浏览器以最优顺序获取资源,就可以带来网页访问效率的提升,这个获取资源的最优顺序该如何实现呢?
要想让服务器按照客户端想要的顺序响应资源,比较容易想到的方法就是对各资源进行优先级管理,对优先级高的资源优先响应。说到优先级管理,很容易想到RTOS 操作系统线程调度器的优先级管理方案,但这里我们不能简单地使用一个优先级变量来管理所有的流,我们是想管理同属于一个网页的不同资源的优先级关系,而不是跨网页对所有的数据流统一进行优先级管理。怎么实现只管理同属于一个网页的不同资源的优先级关系呢?
既然同属于一个网页的不同资源之间存在一定的依赖关系,我们可以从依赖关系入手来描述不同资源的相对优先级。HTTP/2 正是采用依赖关系树和树里的相对权重实现网页内不同资源的相对优先级管理的:
比如上图中优先级树,以第三棵树为例,资源 C 依赖资源 D,服务器优先响应资源 D,待资源 D 响应完毕后再响应资源 C;资源 A 和 B 共同依赖资源 C,服务器优先响应资源 C,待资源 C 响应完毕后再响应资源 A 和 B;资源 A 和 B 处于相同的依赖层级,那就看二者的相对权重值,资源 A 和 B 的权重比值为 3 :1,服务器为了响应资源 A 和 B 分配给二者的时间和空间资源比例为 3 :1,也即服务器为响应资源 A 花费的努力程度是资源 B 的三倍。
有了优先级树来描述不同资源的相对优先级,客户端还需要相应的帧(PRIORITY帧)来告诉服务器,请求的不同资源的优先级关系。前面介绍HEADERS 帧时,有三个字段(E、Stream Dependency、Weight)都依赖于HEADERS帧标识位Flags 的PRIORITY 标识,这三个字段也正是PRIORITY帧的字段,下面给出PRIORITY 帧结构及其字段描述如下:
HTTP/2 中每个资源的请求/响应消息构成一个流,不同资源之间的依赖关系也就等同于不同流之间的依赖关系。PRIORITY 帧中的Stream Dependency 和 Weight 字段可以完全描述不同资源之间的依赖关系和相对权重,也就可以描述页面内不同资源的相对优先级了。
PRIORITY 帧没有定义任何 Flags 标志,客户端可以多次发送PRIORITY 帧来动态调整不同资源的优先级关系,但后面指定的优先级会覆盖之前的。客户端可以通过PRIORITY 帧告诉服务器应该按照怎样的优先级关系来响应众多请求,但具体如何处理优先级,服务器会根据情况酌情调整,因此不能保证资源的响应顺序跟PRIORITY 帧中描述的完全一致。
HEADERS 帧中本就包含PRIORITY 帧中的三个字段,也就是说在启动一个流时,就可以在HEADERS 帧中给出该流的依赖关系和相对权重了(需要设置HEADERS帧标识PRIORITY)。
HTTP/1.x 协议主要是为通过网络共享超文本内容设计的,早期Web 应用比较简单,HTTP 协议也采用了简单的客户端请求 / 服务器响应模型,请求只能从客户端开始,客户端不可以接收除响应以外的指令。随着Web 应用越来越丰富,更新越来越频繁,客户端对访问信息的实时性要求越来越高,怎么快速高效的将服务器上的内容更新同步到客户端呢?
为了让服务器能够及时将内容的更新同步到客户端,最容易想到的方法就是让服务器支持主动向客户端推送数据的能力。但HTTP/1.x 的客户端不接收除响应以外的指令数据,服务器也不支持主动推送数据的能力,这也是HTTP/1.x 的性能瓶颈之一。在HTTP/2 诞生之前,人们为HTTP/1.x 设计了不少优化手段来临时解决其性能瓶颈问题。
目前看来,WebSocket 是可以完美替代 AJAX 短轮询和 Comet 长轮询的,但是某些场景还是不能替代 SSE,WebSocket 和 SSE 各有所长。WebSocket 是与HTTP 同属于应用层级的独立且并列的协议,为了加快普及,使用与HTTP相同的端口资源,也需要借助HTTP报文建立连接,WebSocket 建立连接后就不需要依赖HTTP协议而可以独立工作了。SSE可与HTTP较好的配合,弥补HTTP协议服务器向客户端发送流信息能力不足的弱点,SSE 还能提供 WebSockets 不具备的各种功能,比如自动重新连接、事件 ID 以及发送任意事件的能力。SSE与WebSocket 的原理示意图如下:
HTTP/2 设计时自然也要尝试解决HTTP/1.x 中服务器不能主动向客户端推送数据的弱点,SSE是创建一个从服务器到客户端的单向事件流实现服务器主动推送功能,WebSocket 是在TCP连接上创建一个双向数据流实现客户端与服务器的全双工通信,由于WebSocket 与HTTP 已经是两个完全不同又相互独立的协议,HTTP/2 更多的可从SSE 的实现思路获得启发。
HTTP/2 基于二进制数据帧通信,请求/响应消息或报文以数据流为单位进行管理,服务器向客户端主动推送资源或更新数据是通过PUSH_PROMISE 帧实现的,该帧也要依赖于某个数据流,也即服务器标识出要推送的资源或数据是属于哪个数据流的,服务器不能创建或初始化一个数据流。HTTP/2 服务端借助PUSH_PROMISE 帧向客户端推送数据的图示如下:
PUSH_PROMISE 帧的首部块与客户端请求资源时发送的HEADERS 帧首部块相似,二者都包含stream ID 字段,由于PUSH_PROMISE 帧都是由服务器发送给客户端,因此PUSH_PROMISE 帧中stream ID 字段总是为偶数。
服务端发送PUSH_PROMISE 帧来告诉客户端,它将发送一份客户端尚未明确请求的资源,PUSH_PROMISE 帧实际上是对客户端发送的HEADERS 帧的补充,PUSH_PROMISE 帧结构及其字段定义如下:
PUSH_PROMISE 帧结构中的字段Header Block Fragment 跟HEADERS 帧类似,PUSH_PROMISE 帧类型标识及各字段描述如下(填充相关的字段作用与DATA帧中的一致):
PUSH_PROMISE帧 标识位Flags名称 |
位 | 描述 |
---|---|---|
END_HEADERS | 0x4 | 表明这是流中最后一个PUSH_PROMISE 帧或HEADERS 帧; 如果此标识未设置,表示随后会有CONTINUATION 帧 |
PADDED | 0x8 | 表明此帧添加了填充数据,要使用Pad Length 和Padding 字段 |
PUSH_PROMISE帧字段名称 | 长度 | 描述 |
---|---|---|
Pad Length | 1字节 | 填充字节的长度; 帧首部的PADDED 标识设置为1 时才会有该字段 |
R | 1位 | 保留位,不必设置 |
Promised Stream ID | 31位 | 告知发送端将要使用的流ID; 总是偶数,因为是由服务端发送的 |
Header Block Fragment | 长度可变 | 消息的首部,包含各首部字段信息 |
Padding | 长度可变 | 长度为Pad Length 字段的值,所有的字节被设置为0; 帧首部的PADDED 标识设置为1 时才会有该字段 |
在客户端接收到 PUSH_PROMISE 帧后,它可以根据自身情况选择拒绝数据流,比如服务器推送的资源已经在客户端缓存中了,客户端可通过RST_STREAM 帧来拒绝或结束服务器通过PUSH_PROMISE 帧推送的数据流。如果客户端想禁用服务器的推送功能,可以将SETTINGS 帧的SETTINGS_ENABLE_PUSH 字段设置为 0,SETTINGS 帧各参数字段的作用在前面已经介绍过了。
值得注意的是,服务器可以在PUSH_PROMISE 发送后立即启动推送流,因此拒收正在进行的推送可能仍然无法避免推送大量资源。推送正确的资源是不够的,还需要保证只推送正确的资源,这是重要的性能优化手段。
前面已经提到过HTTP/1.x 重复传输臃肿的首部字段,降低了网络利用效率,因此迫切需要一种能对这些臃肿的首部字段进行压缩后传输的技术,以解决HTTP/1.x 的性能瓶颈问题。Google 开发的SPDY 协议也提供了相应的首部压缩格式deflate与gzip,但该方案存在被CRIME 攻击的漏洞,因此HTTP/2 设计了新的首部压缩格式 HPACK,这种压缩格式采用两种简单但强大的技术(HPACK 发布于RFC7541):
HPACK 是一种表查找压缩方案,它利用霍夫曼编码对传输的标头字段进行压缩,客户端和服务器同时维护和更新的索引列表实际上由一张静态表和一张动态表组合而成:
在 HTTP/2 中,请求和响应标头字段的定义保持不变,仅有一些微小的差异:所有标头字段名称均为小写,请求行现在拆分成各个 :method、:scheme、:authority 和 :path 等伪标头字段。
下面使用一个示例来展示 HPACK 首部压缩的原理:
上图中,Request #1 是新建TCP 连接上的首个请求报文,此时动态索引列表为空,需要传输未出现在静态索引列表中的标头字段键值信息(经过静态Huffman 编码压缩)。Request #2 是同一个TCP 连接上的第二个请求报文,此时动态索引列表已经维护了之前出现过的HTTP 标头字段,该请求报文中的绝大部分字段都与Request #1 中的标头字段一致,只有":path" 字段不一样,Request #2中只需要传输":path" 字段的键值信息和其它标头字段的索引编号即可,有效避免了通信中的重复字节的传输,显著提高了网络资源利用效率。
HTTP/2 允许的索引列表最大尺寸或字节数默认为4096个,若想改变索引列表最大尺寸,可通过SETTINGS 帧的SETTINGS_HEADER_TABLE_SIZE 字段设置。
HTTP/2 提倡使用尽可能少的TCP 连接数,尽可能在一个TCP连接上实现多流并发复用传输,头部压缩是其中一个重要的原因:在同一个连接上产生的请求和响应越多,动态索引列表累积的越全,头部压缩的效果就越好。据统计,HPACK 首部压缩相比HTTP/1.x 可以减少85% ~ 95% 的HTTP 标头数据传输量。
HTTP/2 报文采用二进制编码机制,HTTP/1.x 协议是无法理解HTTP/2 报文的,客户端与服务器要想正常使用HTTP 协议进行通信,双方必须能够互相理解对方的数据报文编码格式,也即双方必须使用相同版本的HTTP 协议。HTTP/2 带来的性能提升挺显著的,各大网站肯定都要逐渐迁移到HTTP/2 协议的,客户端浏览器一般支持HTTP/2 协议比各大网站服务端更快,问题是客户端怎么与服务器协商使用的HTTP 版本呢?
HTTP 协议都是由客户端向服务器发起请求,前面的问题也就转换为客户端怎么判断服务端是否支持HTTP/2 协议(这个过程也称为协议发现)?由于HTTP/1.x 有HTTP 和 HTTPS 两个版本,HTTP/2 也分别为其提供了两种协议发现机制:
对于没有使用TLS 协议的HTTP/1.x 来说,要进行协议切换通常借助HTTP 协议提供的通用首部字段Upgrade,比如从HTTP/1.1 切换到WebSocket 协议就是借助Upgrade 字段实现的,从HTTP/1.1 切换到HTTP/2 同样借助该字段实现。下面使用curl 命令工具集,看看从HTTP/1.1 切换到HTTP/2 都发送了哪些标头字段:
Admin@DESKTOP-PAUL C:\Users\Admin\Downloads\curl-7.70.0-win64-mingw\bin
> curl -v --http2 http://cn.bing.com -o D:\index.html
......
* Trying 202.89.233.101:80...
* Connected to cn.bing.com (202.89.233.101) port 80 (#0)
> GET / HTTP/1.1
> Host: cn.bing.com
> User-Agent: curl/7.70.0
> Accept: */*
> Connection: Upgrade, HTTP2-Settings
> Upgrade: h2c
> HTTP2-Settings: AAMAAABkAAQCAAAAAAIAAAAA
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Cache-Control: private, max-age=0
< Content-Length: 112488
< Content-Type: text/html; charset=utf-8
......
* Connection #0 to host cn.bing.com left intact
使用HTTP/2 访问"http://cn.bing.com" URI,需要在curl 命令中加上参数"–http2",在URI 后面加上"-o " 参数是为了将报文主体输出到其它地方,我们在交互界面只关注请求报文与响应报文的标头字段信息。
请求报文字段"Upgrade: h2c" 标识客户端向服务器请求升级为"h2c" 协议,“h2c” 表示通过明文运行的HTTP/2 协议。从 HTTP/1.1 升级到 HTTP/2 的请求还必须包含一个 “HTTP2-Settings” 标头字段,HTTP2-Settings 标头字段中包含管理 HTTP/2 连接的参数。如果HTTP2-Settings 字段不存在,则服务器不得升级到 HTTP/2 的连接。
HTTP2-Settings:
HTTP2-Settings 标头字段的内容是 SETTINGS 帧的有效负载,编码为 base64url 字符串(即 URL 的 Base64 编码)。由于升级仅用于立即连接,因此发送 HTTP2-Settings header 字段的客户端也必须在 Connection 头字段中发送 “HTTP2-Settings” 作为连接选项,以防止它被转发。
虽然HTTP/2 的规范并不明确要求TLS,也支持以明文通信,但主流浏览器都不支持基于非TLS 的 “h2c” ,因此上面的示例代码并没有从HTTP/1.1 成功切换到“h2c” 协议(bing网站的服务器不支持 “h2c” 协议),仍然使用HTTP/1.1 响应报文继续通信。如果协议切换成功(也即目标网站支持 “h2c” 协议),会通过"101 Switching Protocols"响应报文返回客户端,响应报文字段如下:
< HTTP/1.1 101 Switching Protocols
< Connection: Upgrade
< Upgrade: h2c
[ HTTP/2 connection ...
在 HTTP/2 中,每个端点都需要发送Connection Preface 作为正在使用的协议的最终确认,并建立 HTTP/2 连接的初始设置。因此,客户端接收到"101 Switching Protocols"响应报文后,会立刻向服务器发送Connection Preface(其后还会紧跟一个SETTINGS 帧,用于连接的初始设置),客户端发送的Connection Preface 以 24 个八位字节的序列开始,以十六进制表示法为:
0x505249202a20485454502f322e300d0a0d0a534d0d0a0d0a
解码为ASCII 是:
PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n
这个字符串的用处是,如果服务器(或者中间代理)不支持HTTP/2,就会产生并返回一个错误,让HTTP/2 客户端明确地知道发生了什么错误。如果服务器支持HTTP/2,会声明收到客户端的SETTINGS 帧,并返回一个它自己的SETTINGS 帧(用于确认连接的初始设置),然后确认环境正常,就可以开始使用HTTP/2 进行通信了。
从上面"http" URI 启动HTTP/2 的过程可以看出,中间涉及到HTTP/1.x 报文的明文传输,容易受到网络攻击,安全性肯定没法保证。而且主流浏览器基本都不支持采用明文传输的HTTP/2(也即"h2c" 协议),这就导致从HTTP/1.x 切换到"h2c" 协议的成功率很低,且浪费了因切换协议额外增加的一轮请求 / 响应通信。因此,这种从"http" URI 升级为"h2c" 协议的方案使用较少(该方案更多的用在从HTTP/1.x 升级到WebSocket 协议中)。
HTTPS 基于 TLS 协议,TLS 协议在握手过程中可借助ALPN(Application-Layer Protocol Negotiation)扩展来协商使用哪种应用层协议,客户端与服务器协商应用层协议的任务在TLS 握手过程中完成,不需要增加额外的网络通信。
从HTTPS 切换到HTTP/2 的任务也可以交由TLS 协议完成,客户端在TLS 握手报文ClientHello 的ALPN 扩展中提交自己支持的应用层协议列表给服务器(可将HTTP/2 协议排在列表前面,表示优先期望使用HTTP/2 协议),服务器会根据自身的支持情况确定使用的应用层协议(是否使用HTTP/2,根据服务器支持情况而定)并使用相同的扩展(也即ALPN)回复客户端选定的应用层协议。
下面使用curl 命令工具集,看看从HTTPS 切换到HTTP/2 都发送了哪些标头字段:
Admin@DESKTOP-PAUL C:\Users\Admin\Downloads\curl-7.70.0-win64-mingw\bin
> curl -v --http2 https://cn.bing.com -o D:\index.html
......
* Trying 202.89.233.101:443...
* Connected to cn.bing.com (202.89.233.101) port 443 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* successfully set certificate verify locations:
* CAfile: C:\Users\Admin\Downloads\curl-7.70.0-win64-mingw\bin\curl-ca-bundle.crt
CApath: none
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.2 (IN), TLS handshake, Certificate (11):
* TLSv1.2 (IN), TLS handshake, Server key exchange (12):
* TLSv1.2 (IN), TLS handshake, Server finished (14):
* TLSv1.2 (OUT), TLS handshake, Client key exchange (16):
* TLSv1.2 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.2 (OUT), TLS handshake, Finished (20):
* TLSv1.2 (IN), TLS handshake, Finished (20):
* SSL connection using TLSv1.2 / ECDHE-RSA-AES256-GCM-SHA384
* ALPN, server accepted to use h2
* Server certificate:
* subject: CN=www.bing.com
* start date: Apr 30 20:48:00 2019 GMT
* expire date: Apr 30 20:48:00 2021 GMT
* subjectAltName: host "cn.bing.com" matched cert's "*.bing.com"
* issuer: C=US; ST=Washington; L=Redmond; O=Microsoft Corporation; OU=Microsoft IT; CN=Microsoft IT TLS CA 2
* SSL certificate verify ok.
* Using HTTP2, server supports multi-use
* Connection state changed (HTTP/2 confirmed)
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
* Using Stream ID: 1 (easy handle 0x18dcd68b4c0)
> GET / HTTP/2
> Host: cn.bing.com
> user-agent: curl/7.70.0
> accept: */*
>
* Connection state changed (MAX_CONCURRENT_STREAMS == 100)!
< HTTP/2 200
< cache-control: private, max-age=0
< content-length: 112499
< content-type: text/html; charset=utf-8
......
* Connection #0 to host cn.bing.com left intact
使用HTTP/2 访问"https://cn.bing.com" URI,需要在curl 命令中加上参数"–http2",其后的"-o" 参数作用前面介绍过了。
从上面的示例代码可以看出,向目标主机的443端口发起连接后,客户端先通过TLS 握手报文ClientHello中的ALPN 扩展告诉服务器,自己优先支持"h2" 应用层协议(使用TLS 的HTTP/2 协议),其次支持"http/1.1" 协议。
客户端在TLS 握手报文ClientHello中也会告知服务器自己优先支持TLS 1.3(其次支持TLS 1.2,更低版本的目前已经被弃用),从上面的示例代码可以看出,服务器选定的协议版本为 TLS 1.2(可能是目标服务器不支持TLS 1.3),后续客户端与服务器通过TLS 1.2协议完成握手。在博文TLS 1.2/1.3 握手过程中已经详细介绍过TLS 协议的握手过程,下面再次展示TLS 1.2包含ALPN 扩展的完整握手过程如下:
Client Server
ClientHello --------> ServerHello
(ALPN extension & (ALPN extension &
list of protocols) selected protocol)
Certificate*
ServerKeyExchange*
CertificateRequest*
<-------- ServerHelloDone
Certificate*
ClientKeyExchange
CertificateVerify*
[ChangeCipherSpec]
Finished -------->
[ChangeCipherSpec]
<-------- Finished
Application Data <-------> Application Data
TLS 1.2 协议完成后续的密钥交换、身份认证、加密方案变更等握手过程,客户端与服务器协商出的加密方案为"TLSv1.2 / ECDHE-RSA-AES256-GCM-SHA384",协商使用的应用层协议为"h2"(“ALPN, server accepted to use h2”),后续双方使用HTTP/2 协议进行通信(“Using HTTP2, server supports multi-use”)。
借用TLS 协议的ALPN 扩展完成从HTTPS 升级为 HTTP/2 的过程后,开始使用HTTP/2 进行通信前,依然需要双方都发送Connection Preface 作为正在使用的协议的最终确认(“Connection state changed (HTTP/2 confirmed)”)。Connection Preface 字符串的构成及含义前面已经介绍过了,这里客户端向服务器发送Connection Preface 字符串及其紧随的SETTINGS 帧,服务器支持"h2" 协议并向客户端返回SETTINGS 帧确认消息,客户端与服务器完成HTTP/2 连接的初始设置(“Connection state changed (MAX_CONCURRENT_STREAMS == 100)”)。
客户端与服务器完成HTTP/2 的最终确认和连接状态变更后,就可以通过HTTP/2 二进制帧进行通信了,上面的示例代码 curl 为了更直观的展示标头字段内容,将HTTP/2 帧的标头字段以类似HTTP/1.x 的ASCII 码形式展示出来了,客户端发送的请求行"GET / HTTP/2",服务器返回的响应行"HTTP/2 200",协议版本字段均为HTTP/2。
借助TLS 协议的ALPN 扩展从"https" URI 切换到HTTP/2 的过程不涉及 HTTP 报文的明文传输,显著降低了被网络攻击的可能,网络安全更有保障。通过ALPN 扩展协商应用层协议的过程直接在TLS 握手阶段完成,不占用额外的网络通信,开销也比通过Upgrade 标头字段升级的方案更小。再加上目前主流浏览器实现的HTTP/2 都是基于TLS 协议的,不支持非TLS 的HTTP/2,所以使用TLS 协议的 ALPN 扩展来建立HTTP/2 连接的方案是绝对的主流。
HTTP 的性能优化可以从两方面考虑:
HTTP 数据传输能力往往成为制约整个协议性能提升的瓶颈,也是我们优化的重点,下面给出几种优化思路:
优化项 | 优化方向 |
---|---|
往返时间RTT 优化 | 影响网络往返时间的主要有两个因素: 1. 通信距离优化:可以使用前篇博文介绍的CDN(Content Delivery Network)技术实现; 2. 网络拥塞控制:可以使用更合理的网络拥塞控制算法改善拥塞程度,同时提高带宽降低拥塞概率; |
传输开销优化 | 在保证通信对端能准确解析有效数据的前提下,尽量减少传输的数据量,也可从两方面考虑: 1. 对传输数据进行压缩:比如HTTP/2 中的标头压缩HPACK 和报文主体压缩GZIP; 2. 对高频数据进行缓存:对后续可能使用的数据进行缓存,可以减少重复传输的开销; |
网络利用率优化 | 目前的HTTP协议都是基于TCP连接的,这里给出几个提高TCP连接利用率的优化建议: 1. TCP连接开销优化:建立TCP连接是需要三次握手开销的,应尽量减少建立TCP连接的数量并延长TCP连接时间,比如在一个TCP连接内并发传输多个数据流,达到多路复用一个TCP连接的效果; 2. TCP丢包重传优化:设计TCP的初衷是优先保证数据传输的可靠性,丢包重传机制对TCP的传输性能影响较大(特别在网络环境较差时),算是TCP为保证可靠性做的性能妥协,可通过使用UDP协议并在应用层重新设计丢包重传机制实现性能优化,比如QUIC 协议便采用了这种优化思路; 3. TCP拥塞控制优化:可从改善网络环境(比如提高带宽),设计更优秀的拥塞控制算法,减少丢包重传的概率(比如添加部分纠错数据可恢复部分丢失的数据,不需要重传丢失的数据包)等方向着手; |
按照上面的优化方向考虑,HTTP/2 的设计还是相当优秀的,比如采用二进制帧封装数据提高了数据帧的并行处理效率、标头压缩HPACK 的引入大幅降低了网络传输开销、数据流多路复用和服务端推送大幅提高了网络利用效率等。看似HTTP/2 的设计很完美,充分发挥了TCP协议的性能,但TCP协议是以可靠性为首要目标的,在优先保证可靠性的同时没法过多兼顾性能提升,HTTP/2 自然也继承了TCP 的性能瓶颈,比如丢包重传机制会阻塞整个TCP连接。
前面介绍HTTP/2 通过多数据流并发复用同一个TCP连接解决了HTTP/1.x 的队头阻塞问题,在保证TCP连接不阻塞的情况下确实通过多路复用同一个TCP连接解决了队头阻塞问题,某一个数据流阻塞不会影响其它数据流的传输。但TCP连接不阻塞的前提并不总是成立,如果遇上丢包触发了TCP的重传机制,将会阻塞丢失数据包所在的整个TCP连接,该TCP连接内的所有并发数据流也都会被阻塞,队头阻塞问题又出现了(甚至因为多路复用,阻塞的请求报文更多了)。TCP丢包重传机制还会触发慢启动过程并缩小拥塞窗口,进一步降低网络利用率。
怎么彻底解决HTTP/2 的队头阻塞问题呢?可以通过重新设计TCP的丢包重传机制来解决队头阻塞问题吗?TCP 协议已经诞生四十多年,并应用在数以亿万计的各种网络设备中,为了保证现有的网络设备能正常使用,TCP协议的更新必须保证前向兼容性,因此不可能对其进行重新设计。大多数现代操作系统为了提供通用的网络访问能力,都在内核中提供了TCP/IP 协议栈的实现,如果要修改TCP 协议,通常需要更新操作系统内核,内核修改和操作系统更新的成本较高且频率较低,也注定了TCP 协议的更新成本较高且更新频率较低,因此大幅修改甚至重新设计TCP 协议的方案是不现实的。
既然不能从TCP 协议着手解决HTTP/2 的队头阻塞问题,那只能放弃TCP协议,使用另一个传输层协议 – UDP 协议了。但UDP 协议的数据包相互独立,没有连接原语;没有传输可靠性保证;没有拥塞控制方案,无法适应不同的网络条件。要保证HTTP 协议的可靠、高效传输,单靠UDP 协议是不够的,我们需要在应用层实现类似TCP 的连接、可靠性、拥塞控制等特性。
基于UDP 在用户空间重新实现TCP 协议栈的诸多特性,网络开发者也就对应用数据的可靠传输、拥塞控制等方案的设计有了更大的控制权,吸收借鉴TCP 的优良特性,同时借机重新设计制约TCP 性能的机制,比如前面提到的丢包重传机制。在用户空间实现可靠传输方案,也能降低了修改这些实现机制的成本,用户只需要更新应用程序比如浏览器,就可以使用上更新后的实现机制。作为下一代HTTP协议开发基础的QUIC 协议就采用了上面的设计思想来进一步提升性能,具体的实现原理在下一篇博文:QUIC 是如何解决TCP 性能瓶颈的?中介绍。