1. HTTP/2 介绍
HTTP/2相较于HTTP1.x时代来说,主要是引入了几个新的变化:1.二进制分桢;2.多路复用;3.服务器推送;4.首部压缩。
1.1 HTTP1.x 局限性
首先,我们需要知道HTTP1.x为什么会替代,找到这些原因最简单的方式就是去看,它的替代者HTTP/2提出了哪些新特性。这里在上面的介绍部分其实我已经列举出来了。
- HTTP1.x 采用的是文本格式进行数据传输,在 HTTP1.x 时代中,每个请求/响应报文其实都是由请求(响应)行,请求(响应)头,请求(响应)体,三大部分组成。 而 HTTP/2 则通过引入了二进制帧层,将请求/响应时通信单位拆成更小的“帧”来传输,并对它采用二进制编码;例如一个 HTTP 请求在 HTTP/2 中可能是由 3 个帧组成的,这 3 个帧通过具备的相同的流标识,在对端进行组合并还原成最初的消息。二进制帧带来的好处就是二进制的解析会更加高效,传输更加灵活,在这之上能够实现多路复用,首部压缩等技术。
- HTTP1.x 时代,为了提升传输效率,往往我们需要同时启动多个 tcp 连接,来与服务器通信,进行数据交换。这里其实在一定程度上对于数据传输效率问题有所缓解。但是一方面浏览器对于同一个域名下的链接数往往会有限制,超出限制的请求将会被挂起而等待。此外由于传输层使用的是 TCP 协议,每个链接建立-传输-关闭都会存在一个三次握手-慢启动-四次挥手的过程。我们考虑一个场景:当存在大量小文件传输时,这里的性能损耗尤为明显,每一个传输最少都需要经历 3次 握手和 4 次挥手的 TCP 通信过程,这里大概需要消耗 3.5RTT(数据往返时间),然而可能在 TCP 慢启动过程中,这个文件的传输就已经完成,这种TCP传输性能本质上很差。虽然 HTTP1.1 已经提出了管线化,能复用之前建立的 TCP 链接,但是一方面是使用上有限制,另外一方面也会带来更严重的队首阻塞问题。那么多个 TCP 链接建立/回收的时延以及队首阻塞的问题能否减少呢?
这里 HTTP/2 提出了多路复用的思路:
- 同域名下所有通信都在单个连接上完成。
- 单个连接可以承载任意数量的双向数据流。
- 在 HTTP1.x 时代,HTTP 首部字段往往存在大量的重复和冗余,从而导致不必要的网络流量,并可能导致初始 TCP 拥塞窗口被快速填充。当在一个新的 TCP 连接上进行多个请求时,这可能导致过多的延迟,因为大量的网络流量会被浪费用于传输这些首部数据了。因此在 HTTP/2 中对首部进行了压缩和编码,能够节省消息首部占用的网络流量。
1.2 HTTP/2 特性介绍
HTTP/2为HTTP语义提供了优化的传输。HTTP/2支持HTTP1.1的所有核心功能,但旨在通过多种方式提高效率。在介绍HTTP/2的几个新特性之前,先列出几个关键的概念:
- Connection (连接):1 个 TCP 连接,包含 1 个或者多个 stream。HTTP/2所有通信都在一个 TCP 连接上完成,此连接可以承载任意数量的双向数据流。
- Stream (数据流):一个双向通信的数据流,包含 1 条或者多条 消息。每个数据流都有一个唯一的标识符和可选的优先级信息,用于承载双向消息。
- Message(消息):对应与HTTP1.1 中的请求 request 或者响应 response,包含 1 条或者多条帧。
- Frame 数据帧:最小通信单位,以二进制压缩格式存放内容。来自不同数据流的帧可以在一个连接上交错发送,然后对端再根据每个帧头的数据流标识符重新组装。
一个Connection的大致逻辑示意图如下所示:
1.2.1 二进制帧
HTTP/2 协议的基础单元是一个叫做“帧”的概念。在 HTTP/1.1 中的一个消息是由 Start Line + header + body 组成的,而 HTTP/2 中一个消息是由 HEADER帧+ 若干个 DATA 帧组成的。在 HTTP/2 中有着各种各样的帧,比如 HEADERS 帧,DATA 帧就构成了 HTTP 请求/响应报文的基础。这里还有一些 SETTINGS帧,WINDOW_UPDATE帧 等用于支持 HTTP/2 其他特性的帧类型。
HTTP/2 中所有的帧都是以固定 9 字节长度的首部开头,后续跟可变长的有效载荷部分。一个二进制帧的格式如下所示:
这里帧首部的字段定义如下:
- Length(24bit):帧有效载荷部分的长度,不包括帧首部中的 9 个字节的长度。
- Type(8bit):帧的类型描述字段。帧的类型决定了这个帧的格式和语义。
- Flags(8bit):为特定的帧类型的布尔值所保留的 8 位字段。
- R(1bit): 保留字段。该字段的语义未定,发送时该位需要保持为 0x0,而接受时,该位需要忽略。
- Stream Identifier:流标识符,值 0x0 保留给与整个连接(而不是单个流)相关联的帧。多路复用时,需要结合该标识符,来完成在流对端的消息的重组。
- Frame Payload: 有效载荷部分的结构和内容取决于取决于帧的类型。
1.2.2 Stream 流
stream 流是在 HTTP/2 连接内在客户端和服务器之间交换的独立的双向帧序列。stream 流有几个重要的特征:
- 单个 HTTP/2 连接可以包含多个并发打开的 stream 流,任一一个端点都可能交叉收到来自多个 stream 流的帧。
- stream 流可以单方面建立和使用,也可以由客户端或服务器共享。
- 任何一个端都可以关闭 stream 流。
- 在 stream 流上发送帧的顺序非常重要,接收端按照收到的顺序处理帧。特别是,HEADERS 和 DATA 帧的顺序在语义上是十分重要的。
- stream 流由整数标识。stream 流标识符是由发起流的端点分配给 stream 流的。
实现多路复用的关键其实就是在于这里的stream流的标识。多个HTTP报文可以逻辑上划分到多个流上来发送,而流上所承载的帧(具体的消息内容)会标识它属于哪个流(通过流标识),在对端收到后,会通过流标识重组消息,在一个stream 内的帧必须要是有序的(这点很重要)。 流量控制 由于在应用层引入了流的概念,这本质上会导致同个连接上的多个流其实会产生争用该条TCP连接的问题,导致流的阻塞。于是类似于TCP的流量控制,在应用层级上HTTP/2规定了流之间可以以WINDOW_UPDATE帧来告知对端,下一次允许发送的最大字节数。 优先级策略 此外发送方可以通过在某一个新创建的流的HEADERS帧中包含优先级信息来为该流进行优先级分配。这个优先级可以在随后的传输的过程中通过PRIORITY帧来进行修改。优先级排序的目的是允许发送方在管理多个流时,来告知其对端处理资源的方式。最重要的是,当发送能力有限时,可以使用优先级来选择要发送帧的流。
1.2.3 服务器推送
HTTP/2 还增加了服务器推送的交互模式。当服务器预测某些资源是客户端所需要的,那么服务器可以主动推送资源到客户端。
服务端可以在发送页面HTML时主动推送其它资源,例如服务端可以主动把JS和CSS文件推送给客户端,而不需要客户端解析HTML时再发送这些请求。
当然服务端可以主动推送,客户端也有权利选择是否接收。如果服务端推送的资源已经被浏览器缓存过,浏览器可以通过发送RST_STREAM帧来拒收。主动推送也遵守同源策略,服务器不会随便推送第三方资源给客户端。
1.2.4 首部压缩
在 HTTP1.x 时代,首部字段往往是没有压缩的。随着 web 网页的发展,一个 web 页面往往会伴随着成百上千的请求,那么这些庞大的 headers 首部字段就难免会不必要的占用了网络带宽,显著的提升了传输时延。
最初 SPDY 协议是通过使用 DEFLATE 格式来对这些首部字段进行压缩,这的确做到了能有效的标识冗余的 header 字段。但是暴露出来了安全风险,比如 CRIME 攻击(进一步了解可以查阅CRIME 介绍)。新的压缩算法HPACK消除了冗余的headers字段,将攻击风险限制在了已知安全攻击之内。
对于一个HTTP报文的header部分,在编码时,HPACK会将header字段中这些键值对映射到一个名为header table的表中的索引值上。并且这个header table会在编码或者解码时,碰到新的header字段进行动态更新。最终在编码时,一个header字段要么是以字面量形式存 在,要么就是引用header table 中的某一个索引key。因此一大串的header字段就会被编码成引用值+字面量的混合体来进行传输。
HAPCK工作流程中,存在两个比较重要的映射表,用来辅助完成header字段的映射。
- 动态表:将存储的header 字段与索引相关联的一张表。这个表是动态的并且会存在与编码/解码上下文过程中,它会被当作先进先出队列来维护。
- 静态表:静态表是预先定义好的。它会包含最常见的那些header字段,只读,并会在编码/解码时共享。下图所示是部分静态表内容:
为了保持编码/解码端的映射表保持状态一致,在HTTP/2传输过程中,每个header块都必须要作为一个连续的帧序列传输,不允许中间穿插其他类型或者来自于其他流的帧。并且包含header数据的帧类型只能是HEADERS,PUSH_PROMISE或者CONTINUATION帧。因为这些帧是可以修改接受者的编码上下文的。如果在通信过程中需要丢弃帧,当接受到前面的三类帧时,还是需要重新组装首部数据,并且更新映射表。
2. HTTP/3 解析
HTTP/2 虽然提出了二进制帧,多路复用等方式来提升传输性能。但是由于 HTTP/2 传输层协议仍然是基于 TCP 协议的,因此其实 HTTP/2 还是遗留了几个问题:
-
队首阻塞:由于请求仍然是在 TCP 协议上传输,而 TCP 上强调要按序接收从而保证可靠,导致会出现如果前序的数据报文未及时收到回复时(出现发送窗口可用值为0的情况时),后序的数据报文其实是无法发送的(这里具体原因细节还涉及到ARQ和滑动窗口协议)。
如图所示:当发送窗口内的报文全部发送,但是未收到确认时,那么此时发送缓存区中的后续报文需要等待前面报文收到确认后,才能发送。
-
HTTP/2 使用 TLS1.2,与连接建立时第一次的 TCP 握手的时延叠加,初次建立连接的时延会较长。
-
在移动互联网时代,TCP 连接基于[源 IP,源端口号,目的 ip,目的端口号]四元组确定一个连接的技术方案不再适合。因为在移动互联网时代,用户从 wifi 到蜂窝数据,或者是基站之间的切换,都会导致这个四元组发生变化,最终 TCP 连接和 TLS 握手会多次重复进行,成本极高。
HTTP/3 主要就是解决了这几个问题:
- 基于 UDP 协议重新定义了连接,能够在 QUIC 层处,实现真正意义上的无序、多路复用的字节流的传输,解决了队首阻塞的问题。
- HTTP/3 使用了 TLS3 协议加密,能够实现 1RTT 完成建立连接 以及 密钥协商。
- HTTP/3 将 Packet、QUIC Frame、HTTP/3 Frame 分离,实现了连接迁移功能,降低了 5G 环境下移动设备的连接维护成本。
2.1 HTTP/3 协议简介
与 HTTP/2 一样,其实 HTTP/3 本质上并未改变 HTTP 的语义。换言之就是客户端和服务器端通信过程中,传输的信息其实是并未发生变化的。整个 HTTP 信息交换关键仍然是:
- 请求只能有客户端所发起,服务器针对请求来返回响应。
- 请求报文和响应报文都是有请求/响应行,Header、请求 /响应体构成。
- Header 中的 key-value 形式以及 key 的语义未发生过变化。
但是 HTTP/2 与 HTTP/3 为了减少传输时延,减少了编码长度,进行了首部压缩,并且均在应用层实现了多路复用功能。
首先HTTP1.x 时代的报文是使用 ascll 码传输的,这种其实编码效率很低下的,尤其是在首部字段大量重复的情况下,无意义的占用了大量网络资源。而HTTP/2 与 HTTP/3 都采用了二进制编码以及动态表+ 静态表的形式 结合哈夫曼编码来对 HTTP 首部进行了压缩,这里不仅是提高了传输效率,并且二进制的编码/解码也会更快。
HTTP/2 协议虽然实现了多路复用的形式,但仅仅是应用层上的多路复用,实际上在传输层受限于 TCP 协议的实现方式,实际上还是单通道的,并且会在出现丢包的情况下出现队首阻塞的问题。因此 HTTP/3 为了摆脱限制,采用了 UDP 协议作为传输层的协议,通过 QUIC 协议重新实现了无序连接,以及多路复用。下面是协议栈的结构对比图:
接下来将具体讲解 HTTP/3 的一些优化实现细节。
2.1.1 QUIC 层实现
HTTP/3 的 QUIC 帧其实于 HTTP/2 的帧很像,但是由于它是基于 UDP 协议的,但是为了保证可靠性,它需要一些额外信息来帮助处理。其实这里大家应该也能想到,因为 HTTP/2 依赖于 TCP 协议,因此在 HTTP/2 中不需要去考虑实现可靠性传输这一点。但是为了解决前面所提到的 TCP 的一些局限性问题,所以需要在 QUIC 协议上做一些类似于 TCP 传输时的 sequence+ack 确认的机制。
QUIC 中的基本传输单元是包(packet)和帧(frame),一个包会有多个帧所组成。包概念对应的其实是连接,因此在包的结构上,需要去维护连接 ID。而在断开重连时,只需要会话双方保证这个连接ID不变,就能重新恢复会话。建立连接时,连接是由服务器通过源连接ID分配的,这样后续在传输时,只需要固定目的连接ID就能在IP,端口变化后,实现连接迁移。在这里我大概做了一个逻辑上抽象的 QUIC 包的结构图,如下图所示:
| 连接建立packet报文 | 传输数据packet |
|---|---|
与 HTTP/2 的帧类型很像,QUIC 中也区分了许多种帧类型,比如 STREAM 帧主要用于传递HTTP消息,ACK 帧用于实现接受确认(这个帧的作用和 TCP 的 ACK 报文基本一致。)WINDOW_UPDATE 帧用于通知对端,进行流量控制(这个帧作用和 TCP 中设置了窗口值的报文基本一致)等。这里我所例举的几个帧类型,大家咋一看功能似乎和 TCP 很像,这也是为什么我说为了实现可靠性,会在 QUIC 协议上做了一系列机制来完成原本由 TCP 完成的功能了。
这里我具体介绍一下 STREAM 帧的格式,一个 STREAM 帧的结构图如下图所示:
这里我们可以看出来,STREAM 帧完成了多路复用,以及有序交付的功能。
- STREAM ID: 其实是在一个 QUIC 逻辑连接上,标识了一个有序的字节流。因为在这个 QUIC 连接上可能有多个流在传输,这些流之间的隔离其实就是通过这个字段区分的。同样的,当一个 HTTP 报文十分巨大需要跨越多个 packet 时,我们只需要去保证多个 packet 中携带的每个 STREAM 帧上的 stream ID 一致,就能够保证对端能够识别并还原消息。
- offset 偏移:消息的有序是通过该字段保证的,这个字段其实和 TCP 报文中的 sequence 序号类似,用于实现一个流上多个 STREAM 帧的累计确认功能(这点其实和 TCP 也很像)。
- 长度:用于标识数据载荷部分的长度。
- 数据:真实需要传输的 HTTP 报文数据部分。但是这里有点套娃,因为在这里数据部分并不是直接存放的我们以为的 HTTP 报文数据,而是存放的 HTTP 帧。它大概长这样:
其中,Length 指明了 HTTP 消息的长度,而 Type 字段包含了以下类型: 0x00:DATA 帧,用于传输 HTTP Body 包体; 0x01:HEADERS 帧,通过 QPACK 编码,传输 HTTP Header 头部; 0x03:CANCEL_PUSH 控制帧,用于取消 1 次服务器推送消息,通常客户端在收到 PUSH_PROMISE 帧后,通过它告知服务器不需要这次推送; 0x04:SETTINGS 控制帧,设置各类通讯参数; 0x05:PUSH_PROMISE 帧,用于服务器推送 HTTP Body 前,先将 HTTP Header 头部发给客户端,流程与 HTTP/2 相似; 0x07:GOAWAY 控制帧,用于关闭连接; 0x0d:MAX_PUSH_ID,客户端用来限制服务器推送消息数量的控制帧。 这里不同类型的HTTP帧的主要目的其实是为了实现服务器推送、流量控制等功能。
首部压缩 在HTTP/2中的,动态表是具有时序性的,如果首次出现的请求发生了丢包,后续的收到请求,对方就无法解码出 HPACK 头部,因为对方还没建立好动态表,因此后续的请求解码会阻塞到首次请求中丢失的数据包重传过来。因此头部压缩算法在 HTTP/3 里升级成了“QPACK”,使用方式上也做了改变。虽然也分成静态表和动态表,但在流上发送 HEADERS 帧时不能更新字段,只能引用,索引表的更新需要在专门的单向流上发送指令来管理,QUIC 会有两个特殊的单向流,这两个单向流的用法: 一个叫 QPACK Encoder Stream, 用于将一个字典(key-value)传递给对方,比如面对不属于静态表的 HTTP 请求头部,客户端可以通过这个 Stream 发送字典; 一个叫 QPACK Decoder Stream,用于响应对方,告诉它刚发的字典已经更新到自己的本地动态表了,后续就可以使用这个字典来编码了。 这两个特殊的单向流是用来同步双方的动态表,编码方收到解码方更新确认的通知后,才使用动态表编码 HTTP 头部。
另外,QPACK 的字典也做了优化,静态表由之前的 61 个增加到了 98 个。
最后在这里我们回到最初 HTTP/3 优化的问题,为什么HTTP/3能够解决队首阻塞的问题呢?因为在传输过程中,单个流是有序的,可能会因为丢包而阻塞,但多个流之间相互并不影响(连接本身基于UDP),当某个请求未收到响应时,它只会阻塞当前流上的后续数据的传输,而其他流上的传输并不会受到影响。
2.1.2 TLS 1.3 握手流程
HTTP/3 所使用的 TLS1.3 版本相较于 TLS1.2 版本在传输方面主要的增强就是初始建立连接只需要 1-RTT。而在 TLS1.2 版本中,初始握手时延最低需要 2-RTT。下面我们会比较这个握手过程,来了解具体原因。
在TLS1.3 握手流程中主要如下:
- 客户端发送Client Hello(CH)报文,包含有关密钥协商参数以及TLS版本,供筛选的加密套件列表等与TLS连接建立有关的拓展给服务端。
- 服务端发送Server Hello(SH)报文,包含有关密钥协商参数,服务器证书,TLS版本,确定的加密套件方法,握手消息签名等数据返还给客户端,双方根据CH和SH的协商结果可以得出密钥,这里的密钥安全依赖与一个密钥协商安全算法ECDHE。
- 客户端发送握手消息签名给服务器端,至此,后续消息都会使用会话密钥加密传输
- 客户端验证服务器证书和签名,以及双方都基于选定的加密方法,以及交互的密钥协商参数通过ECDHE算法计算出最终的会话密钥,至此已经可以发送数据。
这里我们可以看到,在TLS1.3版本中只需要1-RTT,会话双方就能得到用于通信加密的密钥。那么TLS1.3 比TLS 1.2 到底快在哪里呢?我们来对比一下TLS1.2 的握手流程,如下图所示。
- 客户端向服务器发送随机数client_random,TLS版本,支持的加密套件列表
- 服务器返回随机数server_random,确定的加密套件方法,以及自身的数字证书,并对 client_random,server_random,server_params等签名,返回给客户端。
- 客户端验证服务器证书和签名,若通过将client_params使用服务器证书的公钥发送给服务器。
- 客户端对之前的握手消息进行一个消息摘要,发送给服务器。 5.服务器对之前的握手消息进行一个消息摘要,发送给客户端
- 至此,双方都拥有了能够计算通信加密的会话密钥的参数(client_random、server_random,双方约定的加密套件方法),后续消息都会使用该会话密钥进行加密传输。
TLS1.2本身是基于Diffie-hellman进行的密钥协商的过程。然后TLS1.3基于这个过程进行了一个优化,在第一步发送的报文中,就已经携带了后续用于计算会话密钥的key_share参数。因此在收到服务器的key_share参数以及约定的加密套件方法后,就已经可以确定并计算出用于后续通信加密的会话密钥。
这里简单介绍一下原理大概就是ECDH密钥协商过程其实是基于一个椭圆曲线算法的,这个椭圆曲线存在一个特性:在该曲线上找一点P,给定一个整数K,求解Q=KP是容易的。但是如果给你Q和P,来求解K是十分困难的。于是在该特性下,给定一个大家都知道的大数G。
- client每次需要和server协商时,随机生成一个随机数a,然后发送key_share = a*G 给server。
- server收到该条消息,计算key_share = b*G 给client。
- 双方目前都能计算出aGb。但是对于监听该通信信道的攻击者而言,它手上只有3个数:G,aG,bG。由于椭圆曲线的特性,它是很难计算出随机数a,b的。所以它无法算到aGb。因此也就保证了安全。
- 另外为了安全性着想,这个握手过程中的b是每次都会重新生成的,也就是b*G中的b部分,这样也就保证了会话密钥会随时间是变化了,避免长期使用同一密钥,而密钥暴露后,攻击者可以还原之前所获得的所有消息。
这里可能还涉及到安全方面的知识,由于篇幅有限,对于安全方面的介绍并未涉及,有兴趣的可以深入去了解为什么能在客户端第一次直接明文传递随机数,而不会存在安全风险。