这篇文章将详细介绍 HTTP/2 协议的基础知识,通过介绍 HTTP/2 的数据传输方式,可以理解为什么 HTTP/2 比 HTTP/1.1 更加高效。
为什么是 HTTP/2 而不是 HTTP/1.2
HTTP/2 主要用来解决 HTTP/1 的性能问题,新版本的协议与原来的协议有很大的不同,新增了如下概念:
- 二进制协议
- 多路复用
- 流量控制功能
- 数据流优先级
- 首部压缩
- 服务端推送
以上概念是新协议根本上的变化,不向前兼容。出于这个原因,HTTP/2 被视为主版本更新。
新版本的变化主要与 HTTP/2 在网络中传输的方式有关。一般 Web 开发者都关注更高层面的 HTTP 语义,这块 HTTP/2 和 HTTP/1 基本上保持一致,所以一般而言上层应用无需区别对待不同的版本。但是深入理解 HTTP/2,可能会改变网站开发的方式(例如不再使用 HTTP/1.1 的优化方法),从而获得更好的性能优化。
使用二进制格式替换文本格式
HTTP/2 是一个二进制的、基于数据包的协议。虽然 HTTP/1.0 引入了二进制 HTTP 消息体,支持在响应中发送图片或其他媒体问题。HTTP/1.1 引入了管道化和分块编码。分块编码允许先发送消息体的一部分,其余部分可用时再接着发。这时 HTTP 消息体被分成多个块,客户端可以在完整接收到所有数据块之前就开始处理这些分块的内容(服务端也可以收到分块请求)。该技术常用于数据长度动态生成的场景,预先不知道数据的总长度。分块编码和管道化都有队头阻塞问题(HOL),即在队列首部的消息会阻塞后面消息的发送,而且管道化在实际中也没有得到很好支持。
HTTP/2 则是一个完全的二进制协议,HTTP 消息被分成清晰定义的数据帧发送。这里的帧类似于 TCP 中的分段,当收到所有的数据帧后,可以将它们组合成完整地 HTTP 消息。HTTP/2 中的二进制表示用于消息的传输,消息本身和之前的 HTTP/1 类似,二进制帧由 Web 浏览器或服务器处理,Web 应用不需要关注消息是如何发送的。
多路复用替代同步请求
HTTP/1 是一种同步的、独占的请求-响应协议。客户端发送 HTTP/1 请求,然后服务器返回 HTTP/1 响应。HTTP/2 允许在单个连接上同时执行多个请求,每个 HTTP 请求或响应使用不同的流。通过使用二进制分帧层,给每个帧分配一个流标识符,以支持同时发出多个独立请求。当接收到该流的所有帧时,接收方可以将帧组合成完整消息。
帧是同时发送多个消息的关键,每个帧都由标签表明它属于哪个消息(流)。因此一个连接上就可以同时有多个消息。在 HTTP/2 中,某一个请求发出后,并不需要阻塞到该请求的响应返回,而是可以直接发送下一个请求。类似地,响应也可以混合在一起返回。每个请求都有一个新的、自增的流 ID,返回响应时使用相同的流 ID。响应完成后,流将被关闭。为了防止流 ID 冲突,客户端发起的请求使用奇数流 ID,服务器发起的请求使用偶数流 ID。ID 为 0 的流是用于管理连接的控制流。
总结一下:HTTP/2 的两个基本原理:
- HTTP/2 使用过多个二进制帧发送 HTTP 请求和响应,使用单个 TCP 连接,以流的方式多路复用
- HTTP/2 与 HTTP/1 的不同主要在消息传输方面,在更上层,HTTP 的核心概念不变
流的优先级和流量控制
在 HTTP/2 之前,HTTP 是单独的请求-响应协议,因此无法在协议中进行优先级排序,客户端在 HTTP 之外就决定了请求的优先级。但是由于同时只能发送有限数量(通常单域名限制 6 个 TCP 连接)的 HTTP/1 请求,因此通常需要优先请求关键资源,由浏览器对请求队列进行管理。而 HTTP/2 对并发请求数量的限制放宽了许多,此时许多请求不再需要浏览器进行排队,可以立即发送它们。这可能会导致带宽浪费在较低优先级的资源上,从而导致在 HTTP/2 下页面的加载速度变慢。
因此需要控制流的优先级,使用更高的优先级发送最关键的资源。流的优先级是通过如下方式实现的:当数据帧在排队时,服务器会给高优先级的请求发送更多的帧
。
而流量控制是在同一个连接上使用多个流的另一种方式,如果接收方处理消息的速度慢于发送方,就会存在积压,需要将数据放入缓冲区。而当缓冲区满时会导致丢包,需要重新发送。HTTP/2 在流的层面实现流量控制(不同于 TCP 层面的流量控制)。
首部压缩
HTTP 首部用于携带与请求、响应相关的额外信息。在这些首部中,有很多信息是重复的,通常和之前的请求使用相同的值。HTTP/1 允许压缩 HTTP body,但是不会压缩 HTTP 首部,HTTP/2 引入了首部压缩的概念,但是使用了和 body 压缩不同的技术。该技术支持跨请求压缩首部。
服务端推送
HTTP/2 还增加了服务端推送的概念,它允许服务端给一个请求返回多个响应。在 HTTP/1 中,当主页加载完成后,在渲染之前,浏览器需要解析它并请求其他资源(CSS、JavaScript等)。有了 HTTP/2 服务端推送,那些资源可以与首个请求的响应一起被返回。当然,决定什么时候推送、如何推送,是充分利用服务端推送的关键。
如何创建一个 HTTP/2 连接
在建立 HTTP/2 连接之前,需要一个过程来确认客户端、服务器两端都想、都能使用 HTTP/2。不同于 HTTPS 使用新的 URL scheme(https://
)来提供服务(通常也使用一个不同的默认端口),HTTP/2 并没有使用新的 scheme,而是通过其他方法来建立 HTTP/2 连接。HTTP/2 主要提供了 3 种建立 HTTP/2 连接的方法:
- 使用 HTTTPS 协商
- 使用 HTTP Upgrade 首部
- 和之前的连接保持一致
理论上,HTTP/2 支持基于未加密的 HTTP 创建连接,也支持基于加密的 HTTPS 建立连接。但是所有浏览器仅支持基于 HTTPS(h2)建立连接。所以浏览器使用方法 1 来协商 HTTP/2。服务器之间的 HTTP/2 连接可以基于未加密的 HTTP(h2c)或者 HTTPS(h2),因此可以使用上述所有方法。
使用 HTTPS 协商
使用 HTTPS 意味着使用 SSL/TLS 来加密一个标准的 HTTP/1 连接或者 HTTP/2 连接。因此使用 HTTPS 时,可以在 TLS 握手阶段完成 HTTP/2 的协商。
ALPN(Application-Layer Protocol Negotiation)是 TLS 的一个扩展,客户端可以在 ClientHello 中通过 ALPN 来声明支持的应用层协议,服务端可以用它来确认在 HTTPS 协商之后所使用的应用层协议。除了 HTTP/2 以外,ALPN 还可以应用于其他协议支持。
如下是通过 Wireshark 看到的 ClientHello、ServerHello 中的 ALPN TLS 扩展。
1 | Extension: application_layer_protocol_negotiation (len=14) |
1 | Extension: application_layer_protocol_negotiation (len=11) |
使用 curl
工具也可以看到 TLS 握手阶段的 APLN 协商:
1 | $ curl --http2 -I https://nghttp2.org/ -vv |
NPN(Next Protocol Negotiation)是 ALPN 之前的一个实现,两者工作方式类似,但它并不是一个正式的互联网标准,ALPN 成为正式标准,很大程度上也是基于 NPN 实现的。两者主要区别是,在使用 NPN 时,客户端决定最终使用的协议;而在使用 ALPN 时,服务端决定最终使用的协议。
现在已经不再推荐使用 NPN,应该使用 ALPN。
使用 HTTP Upgrade 首部
通过发送 Upgrade 首部,客户端可以请求将现有的 HTTP/1.1 连接升级为 HTTP/2。这个首部应该只用于未加密的 HTTP 连接(h2c),基于 HTTPS 连接的 HTTP/2(h2)不能使用此方法进行 HTTP/2 协商,它必须使用 ALPN。
客户端什么时候发送 Upgrade 首部取决于客户端本身,带 Upgrade
首部的请求必须包含一个 HTTP2-Settings
首部,它是一个 Base-64 编码的 HTTP/2 SETTINGS 帧。
- 不支持 HTTP/2 的服务器可以像之前一样返回一个 HTTP/1.1 消息,如同 Upgrade 首部没有发送一样
- 支持 HTTP/2 的服务器可以返回一个 HTTP/1.1 101 响应,以表明它将切换协议,而不是忽略升级请求。然后服务器直接切换到 HTTP/2,发送 SETTINGS 帧,之后以 HTTP/2 格式发送响应
如下展示了这一过程:
1 | $ curl nghttp2.org --http2 -vv |
即使客户端没有携带 Upgrade
首部,服务器也可以通过 Upgrade
响应首部来说明自己支持 HTTP/2。此时它是一个升级建议而不是升级请求,只有客户端才能发送升级请求(仍然是通过 Upgrade
请求首部)。
在网络环境中存在代理服务器时,Upgrade
首部可能会导致问题。例如假设代理服务器不支持 HTTP/2,而业务服务器支持。此时代理服务器直接透传该首部到上游业务服务器,上游业务服务器支持 HTTP/2,因此会触发升级流程。但之后浏览器和代理服务器之间建立 HTTP/2 连接又会出现错误。
使用先验知识
HTTP/2 规范还描述了一个 客户端使用 HTTP/2
的方法是,看它是否已经知道服务器支持 HTTP/2。如果它知道,则可以马上使用 HTTP/2,不需要任何升级请求。此方法是风险最高的方法,因为它假设服务器可以支持 HTTP/2。使用先验知识的客户端必须注意妥善处理拒绝信息,以防之前的信息有误。只有客户端和服务器都在你的掌控之下时,才应该使用该方法。
在使用 curl 工具时,可以直接使用 --http2-prior-knowledge
直接触发 HTTP/2 访问,此时对于 HTTP,将直接触发 HTTP/2 通信,而对于 HTTPS 则仍使用 SSL ALPN 的方式协商 HTTP/2 访问。
HTTP Alternative Services
HTTP Alternative Service(替代服务)是在 HTTP/2 发布之后,单独发布的一个标准。该标准允许服务器使用 HTTP/1.1 协议(通过 Alt-Svc 首部)通知 HTTP 客户端,它所请求的资源在另一个位置,可以使用不同的协议访问它们。
Alternative Services 不仅适用于 HTTP/1,还可以通过现有 HTTP/2 连接进行通信(通过新的 ALTSVC 帧),以使客户端切换到不同的连接。
HTTP/2 前奏消息
不管使用哪种方法启用 HTTP/2 连接,在 HTTP/2 连接上发送的第一个消息必须是 HTTP/2 连接前奏,或者说是魔法字符串。该消息是客户端在 HTTP/2 连接上发送的第一个消息,它是一个 24 字节序列,ASCII 字符串表示为 PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n
。
当服务器不支持 HTTP/2 时,由于它无法识别该消息,因此会解析失败,从而拒绝该消息。而对于支持 HTTP/2 的服务器,根据根据收到这个前奏消息推断出客户端支持 HTTP/2,它不会拒绝该消息,它必须发送 SETTINGS 帧作为其第一条消息(可以为空)。
HTTP/2 帧
建立好 HTTP/2 连接后,就可以发送 HTTP/2 消息了。HTTP/2 消息由数据帧组成,通过在 一个连接上
的 多路复用的流
发送。
查看 HTTP/2 帧
有一些工具可以用来查看 HTTP/2 帧,例如 Chrome 的 net-export 页面、nghttp 和 Wireshark。
- net-export:通过
chrome://net-export
可以打开 net-export,再记录完日志后,可以通过 NetLog 查看器查看日志文件 - nghttp:它是一个基于
nghttp2
C 库开发的命令行工具,许多 Web 服务器和客户端使用它来处理底层的 http2 协议 - Wireshark:由于 Wireshark 是个流量嗅探工具,不是一个专门的 http 客户端。所以除非知道加解密的 SSL 秘钥,否则无法读取 https 流量(浏览器的 HTTP/2 都是基于 https)。好在 Chrome 等浏览器均允许你将 HTTPS 秘钥保存到单独的文件中
如下展示了使用 nghttp
工具查看 HTTP2:
1 | $ nghttp https://nghttp2.org/ -v | more |
HTTP/2 帧数据格式
在查看帧数据之前,需要了解 HTTP/2 帧的组成结构。每个 HTTP/2 帧由一个固定长度的头部和不固定长度的负载组成。对于 HTTP/1.1,需要扫描换行符和空格符来解析 HTTP 消息,而 HTTP/2 由于严格定义了帧的格式,帧的解析更容易,需要传输的数据更少。如下展示了 HTTP/2 帧头部格式
字段 | 长度 | 描述 |
---|---|---|
Length | 24bit | 帧的长度,不包含帧头部字段,单位字节 |
Type | 8bit | 帧类型 |
Flags | 8bit | 标志位 |
Reserved Bit | 1bit | 保留位,现在未使用,必须设置为 0 |
Stream Identifier | 31bit | 无符号的 31 位整数,用于标记帧所属的流 ID |
标志位的含义和值取决于帧类型。接下来来查看这些数据帧。
HTTP/2 帧示例
仍然先看一个使用 HTTP/2 通信示例,nghttp
的 -v
表示查看 http2 通信的详细信息,-n
表示隐藏数据,仅显示帧头部:
1 | $ nghttp -vn https://www.weibo.com |
首先通过 HTTPS(h2)协商建立 HTTP/2 连接,由于 nghttp 不输出 HTTPS 建立过程以及 HTTP/2 前奏消息,因此看不到建立 HTTP/2 连接的详细过程。之后则是 SETTINGS 帧。
SETTINGS 帧
SETTINGS 帧是服务器和客户端必须发送的第一个帧(在 HTTP/2 前奏魔术消息之前)。该帧不包含业务数据,只包含若干个键/值对,即 Identifier(16 bit)/Value(32 bit)。SETTINGS 帧仅定义一个标志,即 ACK。如果 HTTP/2 连接的一端正在发起设置,将该标志位设置为 0,当确认另一端发送的设置消息时,将其设置为 1。对于确认帧(标志位为 1),不应该在帧中包含其他设置。SETTINGS 帧属于控制消息,使用流 ID 0。
详细分析上面收到的第一个 SETTINGS 帧:
1 | [ 0.170] recv SETTINGS frame <length=18, flags=0x00, stream_id=0> |
- 包含 3 个设置项,每个设置项 2+4 字节,因此帧的总长度 18 字节
- 设置项的顺序可以是任意的
- 许多设置项都提供默认值,对于这些设置项可以不用显式发送
接下的三个 SETTINGS 帧,分别是:
1 | # 客户端发送的带设置项的 SETTINGS 帧 |
需要注意,在 SETTINGS 帧发送出去、但还未收到对端的确认期间,不能使用非默认值。由于所有 HTTP/2 实现都可以处理默认值,而且 SETTINGS 帧必须是第一个发送的帧,因此不会有什么问题。
WINDOW_UPDATE 帧
服务器在发送完 SETTINGS 帧后,还发送了一个 WINDOW_UPDATE 帧,该帧用于流量控制,比如限制发送数据的数量,防止接收端处理不完。在 HTTP/2 下,在同一个连接上有多个流,所以不能仅仅依赖 TCP 流量控制,必须自己实现针对每个流的减速方法。
1 | [ 0.170] recv WINDOW_UPDATE frame <length=4, flags=0x00, stream_id=0> |
初始的数据窗口大小可以通过 SETTINGS 帧设置,然后使用 WINDOW_UPDATE 帧来改变它的大小:
- WINDOW_UPDATE 帧没有定义标志位
- 如果流 ID 为 0,表示应用于整个 HTTP/2 连接
- 流量控制仅应用于 DATA 帧,其他类型的帧不受该窗口大小影响,这样可以防止重要的控制消息被较大的 DATA 帧阻塞(DATA 帧是唯一可以为任意大小的帧)
PRIORITY 帧
接下来是几个 PRIORITY 帧:
1 | [ 0.170] send PRIORITY frame <length=5, flags=0x00, stream_id=3> |
这是 nghttp 创建了几个流,使用不同的优先级。实际上,nghttp 并不直接使用流 3-11,通过 dep_stream_id,它将其他流悬挂在开始时创建的流之下。使用之前创建的流的优先级,可以方便地对请求进行优先级排序,无需为每个后续新创建的流明确指定优先级。
HEADERS 帧
在所有设置完成之后,可以看到协议所发送的请求部分。一个 HTTP/2 请求以 HEADERS 帧开始发送
1 | [ 0.170] send HEADERS frame <length=38, flags=0x25, stream_id=13> |
在 HTTP/2 中,没有请求行的概念,所有东西都通过首部发送。HTTP/2 定义了新的伪首部(以冒号开始)。HTTP/2 伪首部定义严格,不能像标准 HTTP 首部那样可以在其中添加新的自定义首部。如果应用需要定义首部,还得用普通的 HTTP 首部(没有冒号开头的首部)。HTTP/2 强制将 HTTP 首部名称小写,而且格式要求也更严格(开头的空格、双冒号或者换行都会带来问题)。
HEADERS 帧定义了 4 个标志位:
- END_STREAM:如果 HEADERS 帧之后没有其他请求,设置改标志。CONTINUATION 帧不受此限制,它们由 END_HEADERS 标志控制
- END_HEADERS:表明所有的 HTTP 首部都已经包含在该帧中,后面没有 CONTINUATION 帧了
- PADDED:当使用数据填充时设置该标志位
- PRIORITY:表明在帧中设置了 E、Stream Dependency 和 Weight 字段
如果 HTTP 首部超过一个帧的容量,就会使用 CONTINUATION 帧(紧跟 HEADERS 帧),这是因为 HEADERS 帧的某些字段如果被多次设置,会带来问题。因此只能有一个 HEADERS 帧。而 HTTP 首部如果的确很大,应该放到 CONTINUATION 帧中。
每个新的请求都会分配一个独立的流 ID,其值是在上一个流 ID 的基础上自增。在该例子中上一个流 ID 是 11(奇数流 ID 是客户端使用、偶数流 ID 是服务端使用),同时 END_STREAM
、END_HEADERS
标志位说明了该帧就已经包含了请求的所有内容,PRIORITY
标志位表示该帧使用了优先级策略。nghttp
的注释也说明,该流是新建的(Open new stream)。最后则是多个 HTTP 伪首部和 HTTP 请求首部。
在同一个流上收到 HTTP 响应,也是以 HEADERS 帧开始:
1 | [ 0.233] recv (stream_id=13) :status: 302 |
DATA 帧
在 HEADERS 帧之后是 DATA 帧,用来发送消息体。HTTP/2 的帧比较简单,它包含所需要的任何格式数据。由于帧头已经包含了长度,所以 DATA 帧不需要包含数据长度字段。DATA 帧也支持填充。
DATA 帧定义了两个标志位:
- END_STREAM:当前帧是流中的最后一个
- PADDED:当使用数据填充时设置该标志位
由于 HTTP/2 的 DATA 帧默认支持被分成多个部分,因此就没有必要使用 HTTP/1 中的分块编码了。HTTP/2 规范甚至规定:分块编码不能在 HTTP/2 中使用了
。
GOAWAY 帧
1 | [ 0.233] send GOAWAY frame <length=8, flags=0x00, stream_id=0> |
最后是客户端发送的 GOAWAY 帧,用于关闭连接。当连接上没有更多消息或者发生严重错误时,将使用该帧。在该示例的 GOAWAY 帧显示:
- 从服务器收到的最后一个流 ID 是 0(服务器没有发起过流)
- 没有收到过错误码
- 没有附加的调试数据
该示例是当不需要再使用连接时,标准的关闭连接的方式。
CONTINUATION 帧
太大的首部需要 CONTINUATION 帧,它紧跟在 HEADERS 帧或者 PUSH_PROMISE 帧后面。紧跟着的 CONTINUATION 帧的数量不限。
CONTINUATION 帧只定义了一个标志位:
- END_HEADERS:表示 HTTP 首部内容到此结束
PING 帧
PING 帧用于消息发送方计算消息往返时间,也可以用来保持一个不使用的连接。当收到该帧时,接收方应答马上回复一个类似的 PING 帧。两个 PING 帧都应当在控制流(流 ID 为 0)上发送。
PING 帧定义了一个标志位:
- ACK:在发起方的 PING 帧中不设置,在返回方中需要设置
PUSH_PROMISE 帧
服务器使用 PUSH_PROMISE 帧通知客户端它将推送一个客户端没有明确请求的资源。PUSH_PROMISE 帧需要提供关于该资源的客户端信息,所以它包含那些通常在 HEADERS 帧中包含的那些首部信息。PUSH_PROMISE 帧定义了两个标志位:
- END_HEADERS:表明所有的 HTTP 首部都已经包含在该帧中,后面没有 CONTINUATION 帧了
- PADDED:当使用数据填充时设置该标志位
RST_STREAM 帧
无论出于何种原因,如果客户端决定不再接收服务端的响应,或者服务器过了很长时间都没发送相应,那么客户端可以发送一个 RST_STREAM 帧,直接取消一个流。
HTTP/1.1 不提供类似的功能,HTTP/1.1 没有办法取消一个正在进行中的请求,除非你中断连接。
ALTSVC 帧
ALTSVC 帧在一个单独的规范中定义,它允许服务端宣告获取资源时其他可用的服务。该帧可用于服务升级、或者重定义流量到另外一个版本上。
ORIGIN 帧
服务器用 ORIGIN
帧来宣告自己可以处理哪些源(比较域名)的请求。当客户端决定是否合并 HTTP/2 连接的时候,该帧非常有用。
CACHE_DIGEST 帧
客户端使用 CACHE_DIGEST
来表明自己缓存了哪些资源。例如它指示服务器不必再推送这些资源,因为客户端已经有了。
由于 HTTP/2 支持帧类型的扩展,因此以后可能会出现更多的帧类型。