72-HTTP2 改进

二进制分帧层(Binary Framing Layer)

二进制分帧层是 HTTP/2 性能增强的核心,他定义了消息的封装格式与传输方式

与 HTTP1.x 的采用的换行符分隔文本不同,HTTP/2 消息被分成很小的消息和 frame,然后每个消息和 frame 用二进制编码。客户端和服务端都采用二进制编码和解码。

为了说明这个传输的过程,我们需要先理解3个概念

  • 数据流(Stream):数据流定义了在一条 TCP 连接内的逻辑上的双向数据流,在一个数据流上可以承载一条或多条消息。一个 TCP 连接上可以承载很多数量的双向数据流。
  • 消息:与 HTTP 请求或响应消息对应的一系列帧,这个和传统的 request/response 是对应的。
  • 帧(frame):HTTP/2 通信的最小单位,每个帧都包含帧头,帧上会标明自己所属的数据流。来自不同数据流的帧可以交错发送。在 HTTP/2 中定义了很多种类的帧,最主要的有 HEADER 帧与 DATA 帧,对应了原来的 http header 和 body。

这些概念的关系总结如下:

  • 所有通信都在一个 TCP 连接上完成,此连接可以承载任意数量的双向数据流。

  • 每个数据流都有一个唯一的标识符和可选的优先级信息,用于承载双向消息。

  • 每条消息都是一条逻辑 HTTP 消息(例如请求或响应),包含一个或多个帧。

  • 帧是最小的通信单位,承载着特定类型的数据,例如 HTTP 标头、消息负载等等。 来自不同数据流的帧可以交错发送,然后再根据每个帧头的数据流标识符重新组装。

简单的说,HTTP/2 协议将 HTTP/1.x 中使用文本方式传递的请求与响应消息分解成为了二进制编码的帧的传递。所有这些帧能够复用一个TCP连接,通过任意数量的逻辑数据流进行传输。这也是 HTTP/2 协议性能与功能改进的基础。

连接多路复用

在 HTTP/1.x 中,有时并发多个 TCP 请求来提升性能,但是必须得使用多个 TCP 连接。请求的过程是这样的:

  • 浏览器请求 url -> 解析域名 -> 建立 HTTP 连接 -> 服务器处理文件 -> 返回数据 -> 浏览器解析、渲染文件

每次请求都需要建立一次 HTTP 连接,也就是我们常说的 3 次握手 4 次挥手,这个过程在一次请求过程中占用了相当长的时间,而且逻辑上是非必需的。

为了解决这个问题, HTTP 1.1 中提供了 Keep-Alive,允许我们建立一次 HTTP 连接,来返回多次请求数据。但是,HTTP 1.1 基于串行文件传输数据,因此这些请求必须是有序的,所以实际上我们只是节省了建立连接的时间,而获取数据的时间并没有减少。另外,大部分浏览器最大并发请求数 6 左右,那么假如 Tomcat 设置最大并发数 300,那么服务器能承受的最大并发数就是 50。

HTTP/2 彻底解决了这个问题,所有请求和响应都可以分成互不依赖的帧在同一 TCP 连接上通过不同的逻辑数据流自由发送,由接收端收到帧之后再组装起来。

HTTP/2 对同一域名下所有请求都是基于流,也就是说同一域名不管访问多少文件,也只建立一路连接。同样 Tomcat 的最大连接数为 300,因为有了这个新特性,最大的并发就可以提升到 300,比原来提升了 6 倍。

在这个图中我们可以看到,有三个数据流在同时传输。客户端正在向服务器传输一个 DATA 帧(数据流 5),与此同时,服务器正向客户端交错发送数据流 1 和数据流 3 的一系列帧。

将 HTTP 消息分解为独立的帧,交错发送,然后在另一端重新组装是 HTTP 2 最重要的一项增强。事实上,这个机制会在整个网络技术栈中引发一系列连锁反应,从而带来巨大的性能提升,让我们可以:

  • 并行交错地发送多个请求,请求之间互不影响。
  • 并行交错地发送多个响应,响应之间互不干扰。
  • 使用一个连接并行发送多个请求和响应。
  • 不必再为绕过 HTTP/1.x 限制而做很多工作(请参阅针对 HTTP/1.x 进行优化,例如级联文件、image sprites 和域名分片。
  • 消除不必要的延迟和提高现有网络容量的利用率,从而减少页面加载时间。
  • 等等…

HTTP/2 中的新二进制分帧层解决了 HTTP/1.x 中存在的队首阻塞问题,也消除了并行处理和发送请求及响应时对多个连接的依赖。 结果,应用速度更快、开发更简单、部署成本更低。

通过多路复用,我们就可以做到一个 origin 一个连接。实际应用场景中,大多数 HTTP 请求都是短暂的,而 TCP 协议却是为了长时间的数据传输设计的,所以复用一个 TCP 连接可以显著的提高性能,降低成本。对 TLS 连接来说,也可以减少加密连接建立的开销。

数据流的优先级

将 HTTP 消息分解为多个独立帧之后,这些帧就可以在客户端和服务器之间以任意顺序发送,那帧的传输顺序的先后和依赖关系就会很大地影响性能。HTTP/2 通过定义数据流的优先级和依赖关系来解决了这个问题。

  • HTTP/2 支持给每个数据流赋值一个 1~256 之间的整数权重,并且可以定义一个数据流和另一个数据流之间的依赖关系。

数据流的权重和依赖关系可以让数据流之间形成一个依赖关系树。客户端和服务端可以根据这个依赖关系树来决定发送的优先级。

  • 数据流依赖关系和权重的组合让客户端可以构建和传递“优先级树”,表明它倾向于如何接收响应。
  • 反过来,服务器可以使用此信息通过控制 CPU、内存和其他资源的分配设定数据流处理的优先级,在资源数据可用之后,带宽分配可以确保将高优先级响应以最优方式传输至客户端。

举例来说,在一个网页中,我们就可以设置 HTML 文件为高优先级,css 与 js 为中优先级,图片为最低优先级。但这里需要注意的是,这个优先级只是一个偏好,而不是强制的,所以优先级并不能保证高优先级的一定比低优先级的先传输。这个也是非常合理的,因为我们不希望一个低优先级的资源因为一个高优先级的资源处理和传输比较慢而被完全阻塞住。

每个来源一个连接

有了新的分帧机制后,HTTP/2 不再依赖多个 TCP 连接去并行复用数据流;每个数据流都拆分成很多帧,而这些帧可以交错,还可以分别设定优先级。 因此,所有 HTTP/2 连接都是永久的,而且仅需要每个来源一个连接,随之带来诸多性能优势。

SPDY 和 HTTP/2 的杀手级功能是,可以在一个拥塞受到良好控制的通道上任意进行复用。 这一功能的重要性和良好运行状况让我吃惊。 我喜欢的一个非常不错的指标是连接拆分,这些拆分仅承载一个 HTTP 事务(并因此让该事务承担所有开销)。 对于 HTTP/1,我们 74% 的活动连接仅承载一个事务 - 永久连接并不如我们所有人希望的那般有用。 但是在 HTTP/2 中,这一比例锐减至 25%。 这是在减少开销方面获得的巨大成效。 (HTTP/2 登陆 Firefox,Patrick McManus)

大多数 HTTP 传输都是短暂且急促的,而 TCP 则针对长时间的批量数据传输进行了优化。 通过重用相同的连接,HTTP/2 既可以更有效地利用每个 TCP 连接,也可以显著降低整体协议开销。 不仅如此,使用更少的连接还可以减少占用的内存和处理空间,也可以缩短完整连接路径(即,客户端、可信中介和源服务器之间的路径) 这降低了整体运行成本并提高了网络利用率和容量。 因此,迁移到 HTTP/2 不仅可以减少网络延迟,还有助于提高通量和降低运行成本。

注:连接数量减少对提升 HTTPS 部署的性能来说是一项特别重要的功能:可以减少开销较大的 TLS 连接数、提升会话重用率,以及从整体上减少所需的客户端和服务器资源。

流控

在某些场景下,接收方负载比较重或者比较忙的时候,可能不希望服务端继续发送响应数据过来。比方说在比较长的视频的播放场景下,用户暂停了或者数据传输速度远大于播放速度,那我们可能不想提前缓冲太多的数据下来。另外,在代理服务器的场景下,如果下游的连接速度比较快,上游的速度比较慢,那代理也会希望能控制下游发送请求的速度,以免太多的请求在代理处堆积。

TCP 协议本身也支持流控,但是因为 HTTP/2 数据流在一个 TCP 连接内复用,所以仅仅依靠 TCP 协议的流控显然是不够的。因此 HTTP/2 引入了一个简单的流控机制,主要特点有:

  • 流控是双向的,接收端和发送端都可以设置自己的流控窗口大小
  • 流控是基于信任的,接收端在创建连接的时候会告知对方自己的流控窗口字节大小,每当发送端发送一个 DATA 帧的时候,这个窗口会相应减少,如果窗口减少到0,发送端需要主动停止发送数据,接收端可以通过发送新的 WINDOW_UPDATE 帧来修改当前流控窗口的字节数
  • 流控无法被禁用。当连接建立的时候,双方会交换 SETTINGS 帧,定义了双方的流控窗口字节大小,默认的流控字节数是 65,536 字节,最大可以设置成 2 的 32 次方-1
  • 流控是基于直接节点之间的,不是端到端的。所以中间层(代理)也可以来对上下游进行流控

HTTP/2 未指定任何特定算法来实现流控制。不过,它提供了简单的构建块并推迟了客户端和服务器实现,可以实现自定义策略来调节资源使用和分配,以及实现新传输能力,同时提升网页应用的实际性能和感知性能(请参阅速度、性能和人类感知)。

基于上面的简单机制,HTTP/2 就可以实现比较复杂的流控,比方说可以通过让浏览器设置窗口为 0,使浏览器暂停某些资源的提取,等用户要求继续之后再开放窗口继续传输。

服务端推送

HTTP/2 新增的另一个强大的新功能是,服务器可以对一个客户端请求发送多个响应。 换句话说,除了对最初请求的响应外,服务器还可以向客户端推送额外资源,而无需客户端明确地请求。

比方说请求一个网页的时候,涉及到很多资源,有静态页面、图片、css、js,传统的方式需要对每个资源分别请求,但如果服务端知道请求某个静态页面的时候都是要同时请求相对应的其他资源的,通过这种服务端推送的机制就能主动的把资源一起返回,从而节省客户端的很多请求时间。比方说一个网页请求style.css的时候,服务端就可以同时把style.js也推送给客户端,这样客户端在需要style.js的时候就可以直接从缓存读取,而不需要再通过网络请求了。

注:HTTP/2 打破了严格的请求-响应语义,支持一对多和服务器发起的推送工作流,在浏览器内外开启了全新的互动可能性。 这是一项使能功能,对我们思考协议、协议用途和使用方式具有重要的长期影响。

PUSH_PROMISE 101

所有服务器推送数据流都由 PUSH_PROMISE 帧发起,表明了服务器向客户端推送所述资源的意图,并且需要先于请求推送资源的响应数据传输。 这种传输顺序非常重要:客户端需要了解服务器打算推送哪些资源,以免为这些资源创建重复请求。 满足此要求的最简单策略是先于父响应(即,DATA 帧)发送所有 PUSH_PROMISE 帧,其中包含所承诺资源的 HTTP 标头。

在客户端接收到 PUSH_PROMISE 帧后,它可以根据自身情况选择拒绝数据流(通过 RST_STREAM 帧)。 (例如,如果资源已经位于缓存中,便可能会发生这种情况。) 这是一个相对于 HTTP/1.x 的重要提升。 相比之下,使用资源内联(一种受欢迎的 HTTP/1.x“优化”)等同于“强制推送”:客户端无法选择拒绝、取消或单独处理内联的资源。

使用 HTTP/2,客户端仍然完全掌控服务器推送的使用方式。 客户端可以限制并行推送的数据流数量;调整初始的流控制窗口以控制在数据流首次打开时推送的数据量;或完全停用服务器推送。 这些优先级在 HTTP/2 连接开始时通过 SETTINGS 帧传输,可能随时更新。

推送的每个资源都是一个数据流,与内嵌资源不同,客户端可以对推送的资源逐一复用、设定优先级和处理。 浏览器强制执行的唯一安全限制是,推送的资源必须符合原点相同这一政策:服务器对所提供内容必须具有权威性。

头部压缩

每个 HTTP 传输都承载一组标头,这些标头说明了传输的资源及其属性。在 HTTP/1.x 中,请求和响应头部始终是以纯文本方式传输,通常会给每个传输增加 500–800 字节的开销。如果使用 HTTP Cookie,增加的开销有时会达到上千字节。(请参阅测量和控制协议开销。)

为了减少此开销和提升性能,HTTP/2 使用 HPACK 压缩格式压缩请求和响应标头元数据,这种格式采用两种简单但是强大的技术:

  1. 这种格式支持通过静态霍夫曼代码对传输的标头字段进行编码,从而减小了各个传输的大小。
  2. 这种格式要求客户端和服务器同时维护和更新一个包含之前见过的标头字段的索引列表(换句话说,它可以建立一个共享的压缩上下文),此列表随后会用作参考,对之前传输的值进行有效编码。

利用霍夫曼编码,可以在传输时对各个值进行压缩,而利用之前传输值的索引列表,我们可以通过传输索引值的方式对重复值进行编码,索引值可用于有效查询和重构完整的标头键值对。

作为一种进一步优化方式,HPACK 压缩上下文包含一个静态表和一个动态表:静态表在规范中定义,并提供了一个包含所有连接都可能使用的常用 HTTP 标头字段(例如,有效标头名称)的列表;动态表最初为空,将根据在特定连接内交换的值进行更新。 因此,为之前未见过的值采用静态 Huffman 编码,并替换每一侧静态表或动态表中已存在值的索引,可以减小每个请求的大小。

注:在 HTTP/2 中,请求和响应标头字段的定义保持不变,仅有一些微小的差异:所有标头字段名称均为小写,请求行现在拆分成各个 :method:scheme:authority:path 伪标头字段。

HPACK 的安全性和性能

早期版本的 HTTP/2 和 SPDY 使用 zlib(带有一个自定义字典)压缩所有 HTTP 标头。 这种方式可以将所传输标头数据的大小减小 85% - 88%,显著减少了页面加载时间延迟:

在带宽较低的 DSL 链路中,上行链路速度仅有 375 Kbps,仅压缩请求标头就显著减少了特定网站(即,发出大量资源请求的网站)的页面加载时间。 我们发现,仅仅由于标头压缩,页面加载时间就减少了 45 - 1142 毫秒。 (SPDY 白皮书, chromium.org)

然而,2012 年夏天,出现了针对 TLS 和 SPDY 压缩算法的“犯罪”安全攻击,此攻击会导致会话被劫持。 于是,zlib 压缩算法被 HPACK 替代,后者经过专门设计,可以解决发现的安全问题、实现起来也更高效和简单,当然,可以对 HTTP 标头元数据进行良好压缩。

TLS传输层加密

虽然理论上 HTTP/2 也是支持非加密连接传输的(这种非加密连接的HTTP/2简称为h2c),但实际上目前主流浏览器厂商都只实现了加密连接的模式,所以 https 变成了 HTTP/2 的事实上的标准。

一个例子

下面我们来看一个具体的例子,例子程序通过 gRPC 向服务端发起一次调用,并得到返回结果,从客户端的Netty日志中,我们可以看到HTTP/2连接建立与包传输的完整过程

1
2
3
4
5
6
7
8
9
[1]  [id: 0x3c68f114, L:/127.0.0.1:57280 - R:localhost/127.0.0.1:50051] OUTBOUND SETTINGS: ack=false settings={ENABLE_PUSH=0, MAX_CONCURRENT_STREAMS=0, INITIAL_WINDOW_SIZE=1048576, MAX_HEADER_LIST_SIZE=8192} 
[2] [id: 0x3c68f114, L:/127.0.0.1:57280 - R:localhost/127.0.0.1:50051] OUTBOUND WINDOW_UPDATE: streamId=0 windowSizeIncrement=983041
[3] [id: 0x3c68f114, L:/127.0.0.1:57280 - R:localhost/127.0.0.1:50051] INBOUND SETTINGS: ack=false settings={MAX_CONCURRENT_STREAMS=2147483647, INITIAL_WINDOW_SIZE=1048576, MAX_HEADER_LIST_SIZE=8192}
[4] [id: 0x3c68f114, L:/127.0.0.1:57280 - R:localhost/127.0.0.1:50051] OUTBOUND SETTINGS: ack=true
[5] [id: 0x3c68f114, L:/127.0.0.1:57280 - R:localhost/127.0.0.1:50051] INBOUND WINDOW_UPDATE: streamId=0 windowSizeIncrement=983041
[6] [id: 0x3c68f114, L:/127.0.0.1:57280 - R:localhost/127.0.0.1:50051] INBOUND SETTINGS: ack=true
[7] [id: 0x3c68f114, L:/127.0.0.1:57280 - R:localhost/127.0.0.1:50051] OUTBOUND HEADERS: streamId=3 headers=GrpcHttp2OutboundHeaders[:authority: localhost:50051, :path: /helloworld.Greeter/SayHello, :method: POST, :scheme: http, content-type: application/grpc, te: trailers, user-agent: grpc-java-netty/1.36.0, grpc-accept-encoding: gzip] streamDependency=0 weight=16 exclusive=false padding=0 endStream=false
[8] [id: 0x3c68f114, L:/127.0.0.1:57280 - R:localhost/127.0.0.1:50051] OUTBOUND DATA: streamId=3 padding=0 endStream=true length=5 bytes=0000000000 2021-07-16 14:27:27,889 [9] DEBUG i.g.n.s.i.g.n.NettyClientHandler - [id: 0x3c68f114, [9] [id: 0x3c68f114, L:/127.0.0.1:57280 - R:localhost/127.0.0.1:50051] INBOUND HEADERS: streamId=3 headers=GrpcHttp2ResponseHeaders[:status: 200, content-type: application/grpc, grpc-encoding: identity, grpc-accept-encoding: gzip] padding=0 endStream=false
[10] [id: 0x3c68f114, L:/127.0.0.1:57280 - R:localhost/127.0.0.1:50051] INBOUND DATA: streamId=3 padding=0 endStream=false length=13 bytes=00000000080a0648656c6c6f20
  • [1] 发送了一个SETTINGS帧,设置了禁用服务端推送、最大并发STEAM条数为0,初始流控窗口大小为1048576(1M)字节,最大Header大小为8192(8k)字节。
  • [2] 发送一个WINDOW_UPDATE帧,数据流Id为0,将流控窗口大小增加983041字节
  • [3] 收到服务端发送的一个SETTINGS帧,设置为最大并发数据流数量为2147483647,初始流控窗口大小为1048576(1M)字节,最大Header大小为8192(8k)字节。
  • [4] 发送SETTINGS帧,对服务端发送的SETTINGS进行ack
  • [5] 收到服务端WINDOW_UPDATE帧,数据流Id为0,将流控窗口大小增加983041字节
  • [6] 收到服务端SETTINGS帧,对客户端之前发送的SETTINGS进行ack
  • [7] 发送请求头HEADERS帧,可以看到header中的:method, content-type, user-agent等等都是和HTTP/1.x中是一样的
  • [8] 发送请求的DATA数据帧
  • [9] 收到响应的HEADERS帧,有传统的:status, content-type等响应头字段
  • [10] 收到响应的DATA数据帧

在 nginx 上开启 HTTP/2

nginx 提供了非常简便的开启 HTTP/2 的方式,只要 nginx 版本在 1.9.5 及以上,启用了 http_v2_modulehttp_ssl_module 模块,并且为网站配置了 https。然后只需要修改

1
2
3
4
5
server {
# 添加 http2
listen 443 ssl http2;
...
}

重新加载配置后,网站就支持HTTP/2的方式访问了。

总结

下面是 HTTP/2 的一些特点和改进之处:

  • 多路复用:HTTP/2 允许同时发送多个请求和响应,而不是像 HTTP/1.1 一样只能一个一个地处理。这样可以减少延迟,提高效率,提高网络吞吐量。
  • 二进制传输:HTTP/2 使用二进制协议,与 HTTP/1.1 使用的文本协议不同。二进制协议可以更快地解析,更有效地传输数据,减少了传输过程中的开销和延迟。
  • 头部压缩:HTTP/2 使用 HPACK 算法对 HTTP 头部进行压缩,减少了头部传输的数据量,从而减少了网络延迟。
  • 服务器推送:HTTP/2 支持服务器推送,允许服务器在客户端请求之前推送资源,以提高性能。
  • 改进的安全性:HTTP/2 默认使用 TLS(Transport Layer Security)加密传输数据,提高了安全性。
  • 兼容 HTTP/1.1:HTTP/2 可以与 HTTP/1.1 共存,服务器可以同时支持 HTTP/1.1 和 HTTP/2。如果客户端不支持 HTTP/2,服务器可以回退到 HTTP/1.1。

Reference


72-HTTP2 改进
https://flepeng.github.io/010-network-72-HTTP2-改进/
作者
Lepeng
发布于
2024年3月8日
许可协议