HTTP/2 在引入头部字段压缩和允许单连接并发交换后,使更加高效的网络资源使用和更低的接收延迟成为可能。同时也引入了从服务端到客户端的主动推送。本篇结合 RFC 7540 谈谈 HTTP version 2。

前言

超文本传输协议是一个大获成功的协议,尽管如此,HTTP/1.1 使用的基础传输却有着会对当下的 应用性能 造成不利影响的特性。具体地讲,HTTP/1.0 只允许在单个 TCP 连接中处理 一个 请求,在 HTTP/1.1 新增请求管道后,也只实现了部分的并发请求,并仍遭受着头部阻塞。因此,HTTP/1.0 和 HTTP/1.1 的客户端为了实现并发并降低延迟,需要使用多个与服务端的连接来发送多个请求。此外,HTTP 头部字段经常是重复且冗余的,导致了不必要的网络流量,且拥塞窗口会被快速填充。这将在单个 TCP 连接处理多个请求时导致过度的延迟。

HTTP/2 通过在基础连接中定义一个更优的 HTTP 语义映射来处理这些问题。具体地,它允许在同个连接中多个请求和响应消息的交织,并对 HTTP 头部字段采用了高效的编码。它还支持请求的优先级,让一些更重要的请求能够更快地完成,以此来提升性能。生成的协议对网络更加友好,因为相比 HTTP/1.x 只需使用更少的连接。这也意味着与其他流和长连接之间更少的竞争,反过来能更好地利用可用的网络容量。

HTTP 2 协议概览

HTTP/2 给 HTTP 语义提供了一个改良的传输,它支持 HTTP/1.1 的所有核心特征,但旨在某些方面变得更加高效。HTTP/2 的基础协议单元是一个帧 frame,每个帧类型服务一个不同的目的。例如,HEADERSDATA 帧组成了 HTTP 请求和响应的基础,其他帧类型如 SETTINGSWINDOW_UPDATEPUSH_PROMISE 被用来支持 HTTP/2 的其他特性。

请求的多路复用通过让每个 HTTP 请求/响应与它自己的流交换来实现。流是 各自独立 的,因此一个被阻塞的请求或响应不会阻止其他流的进程。流的控制和优先级使使用多路的流成为可能。流的控制保证了被接收者使用的数据被传输了。优先级保证了有限的资源能被首先引导至更重要的流上。

HTTP/2 增加了一个新的交互模式,借此一个服务可以向客户端推送响应。服务推送允许一个服务在权衡网络使用和潜在的延迟收益后,向客户端任意的发送(预计它将会需要的)数据。服务端通过合成一个发送 PUSH_PROMISE 帧的请求来完成这个。随后服务端就能在一个独立的流中给这个合成的请求发送一个响应。因为在连接中使用的 HTTP 头部字段可能包含了大量多余的数据,包含它们的帧可以被压缩。这对通常情况下的请求大小有着特殊的优势,且允许许多请求被压缩在一个包里。

HTTP 2 连接

一个 HTTP/2 连接是一个运行在 TCP 连接之上的应用层协议。客户端是 TCP 连接的初始化器。HTTP/2 使用与 HTTP/1.1 相同的 http 和 https URI 帧。HTTP/2 共享着相同的默认端口,http URIs 80,https URIs 443。因此,对于目标资源比如 “http://example.org/foo” 或者 “https://example.com/bar” 的 URLs 的请求实现被要求首先判断(客户端希望立即与之建立连接的)上游服务是否支持 HTTP/2。

客户端可以在没有先验知识的情况下通过 HTTP 升级 机制,在 HTTP/1.1 请求中包含一个带有 h2c 令牌的升级头字段和一个 HTTP2-Settings 头字段来支持 HTTP/2。(基于 TLS 的 HTTP/2 使用 h2 作为协议标识符)

GET / HTTP/1.1
Host: Server.example.com
Connection: Upgrade, HTTP2-Settings
Upgrade: h2c
HTTP2-Settings: <base64url encoding of HTTP/2 SETTINGS payload>

一个不支持 HTTP/2 的服务端能够在缺少升级头字段的情况下响应请求。而支持 HTTP/2 的服务会给出一个 101 交换协议的响应。在一个结束 101 响应的空行后,服务可以开始发送 HTTP/2 帧。

// 不支持 HTTP/2 的服务
HTTP/1.1 200 OK
Content-Length: 243
Content-Type: text/html
...

// 支持 HTTP/2 的服务
HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Upgrade: h2c

[ HTTP/2 connection ... ]

这个提前发送来升级的请求会被认定为流 1,在 HTTP/2 连接开始后,流 1 会被用来作响应。因为升级只会应用在接下来的连接,客户端在发送 HTTP2-Settings 头字段时必须也发送 HTTP2-Settings 作为连接头字段的连接选项来防止请求被转发。

在有先验知识的情况下,客户端必须发送连接引语和 HTTP/2 帧,服务器就可以通过连接引语来识别这些连接。

头部压缩与解压

与在 HTTP/1 中相同,一个 HTTP/2 的头部字段是一个名字对应一个或者多个值。头部字段被用在 HTTP 请求和响应的消息中,在服务推送操作中也是。头部列表是许多头部字段的集合,当在连接中传送时,头部列表会被 HTTP 头部 压缩序列化 成一个头部块。序列化后的头部块随后被划分成一个或多个八位序列,称头部块片段,并在 HEADERS, PUSH_PROMISE 或者 CONTINUATION 帧中被传送。

接收节点将通过拼接片段来装配头部块,随后解压头部块来重现这个头部列表。头部压缩是有状态的,一个压缩和解压的上下文会在整个连接中被使用。一个在头部块的解压错误会被当作是一个压缩错误的连接异常。

流与多路复用

在 HTTP/2 连接中流是一个在客户端和服务端之间,独立且双向的帧交换序列。一个 HTTP/2 连接能够包含 多个 并发的流,它可以被客户端或服务端单方面地创建和使用或者共享。帧们在流中被发送的顺序非常重要,接收者会按照顺序处理帧们。 下图展示了流的状态转变图,节点与流的创建并不相关,它们可以由各个节点单方面地创建。流有以下几种状态:

  1. 空闲: 所有的流都从空闲状态开始,发送或者接收一个 HEADERS 帧会导致流变成开放状态。在另一个流发出 PUSH_PROMISE 会预留一个空闲的流,并使其变成本地预留状态。在收到另一个流的 PUSH_PROMISE 后会预留一个空闲的流,并使其称为远程预留状态。
  2. 开放:处于开放状态的流被用来在同辈间发送任意类型的帧。在发送 END_STREAM 标识后将进入本地半关闭的流状态,在收到 END_STREAM 标识后将进入远程半关闭的状态。
  3. 本地预留:通过发送 PUSH_PROMISE 允诺的流,PUSH_PROMISE 通过将被远程同辈初始化的流与一个开放的流关联来预留一个空闲的流。在这个状态,节点可以发送一个 HEADERS 帧,这会导致流以远程半关闭状态打开。
  4. 远程预留:远程预留状态的流是被一个远程同辈预留的,在这个状态下,在收到一个 HEADERS 帧后会导致这个流转变到本地半关闭状态。
  5. 本地半关闭:可在接收或者发送包含 RST_STREAM 标识的流或者发送 END_STREAM 后变成关闭状态。
  6. 远程半关闭:可在接收或者发送包含 RST_STREAM 标识的流或者接收 END_STREAM 后变成关闭状态。
  7. 关闭:关闭是一个终止状态。

流的并发

最大并发的流数量由各个节点设置且只应用于收到这个设置的同辈。也就是说,客户端指定服务端可以初始化的最大并发流数量,服务端指定客户端能够初始化的最大并发流的数量。这个流的数量指在开放状态和半关闭状态的流,而在预留状态的流则不受这个限制。想要降低最大流并发数的节点可以关闭多余的流或者。

流的控制

使用流实现多路复用会在 TCP 连接上引入冲突,导致流被阻塞。流的控制可以保证在同个连接上的流不会破坏性地相互影响。HTTP/2 通过使用 WINDOW_UPDATE 帧来提供流的控制。HTTP/2 流控旨在不改动协议的前提下允许使用一个流控算法的变种。流控是针对单个连接的,接收者会广播他们准备在一个流和整个连接上接收多少个八位字节(初始为 65535),而发送者必须尊重接收者强加的流控限制。

只有 DATA 帧需要遵守流的控制,其他帧并不会消耗流控窗口的空间,这保证了重要的控制帧不会被流的控制阻塞。HTTP/2 只定义了 WINDOW_UPDATE 帧的格式和术语,并没有指定具体的实现,可以选择任何满足需求的算法。实现还应负责管理请求和响应根据优先级发送,避免请求的头部行阻塞,管理新的流的创建。

流控被设计用来保护那些 资源有限 的节点,比如一个需要在许多连接间共享内存,且拥有一个较慢的上游连接和一个较快的下游连接的代理。流控着手于接收者无法在一个流上处理数据,但仍想继续在这个连接的其他流上处理数据的场景。不需要此功能的开发可以将通告将流控窗口设置为最大值 2^31-1,并在接收到任何数据后都发送这个 WINDOW_UPDATE 帧来更新窗口,这会使接收者的流控失效。相反地,发送方会遵守接收者建议的流控窗口。资源受限(比如内存)的开发可以使用流控来限制同辈节点消耗的内存量,然而,如果在缺少带宽延迟产品知识的情况下使用流控会导致网络资源次优的使用可用性。即使拥有当前带宽延迟产品的全部知识,实现流控也会相当 困难。当在使用流控时,接收者必须及时读取 TCP 接收缓冲,否则可能导致死锁。

流的优先级

客户端可以在 HEADERS 帧给一个流指定一个优先级,此外可以使用 PRIORITY 帧来改变一个流的优先级。使用优先级可以让节点处理并发流的时候有侧重地分配资源,更重要的是,优先级可以在发送量受限时选择要传输的帧。可以将流标记其依赖其他流的完成来提高它的优先级,每个依赖指定一个相对比重,用来决定分配给依赖同个流的流们可用资源的相对比例。它并不会保证流与流之间特殊的处理或传输顺序,因此优先级只是一个建议。

依赖同个父流的两个流之间并没有一个排序,比如流 B 和 C 都依赖流 A,这时创建一个同样依赖流 A 的流 D,其结果是流 A 被流 B, C, D 分别依赖。

独家 excluse 标识可以让一个流变成其父流的单独依赖,在上述例子中,如果流 D 被创建成为流 A 的独家依赖,这会使流 D 变成流 B 和 C 的父依赖。

服务端推送

HTTP/2 允许服务端向客户端端推送响应,这在客户端需要这些响应来处理原始请求时会很有用。PUSH_PROMISE 帧会被服务端发送在任一客户端初始的流上,其中包含完整的请求头字段,且推送的响应要与具体的客户端请求相关联。

发送 PUSH_PROMISE 帧将为服务端创建一个本地预留状态的流,给客户端一个远程预留的流。随后客户端可以开始递送响应,并将服务端侧流置为远程半关闭状态,客户端侧的流置为本地半关闭状态,在推送以 END_STREAM 帧结束后,再将流置为已关闭状态。如果客户端决定不想接收服务端的推送或者服务端在开始推送时花费过长的时间,客户端可以发送一个 RST_STREAM 帧。

客户端在收到推送后必须鉴权服务端或者提供推送的代理,比如一个只给 example.com 提供证书的服务端并不被允许推送一个响应到 https://www.example.org/doc