HTTP/2 协议详解

AI 摘要: HTTP/2是一种新的HTTP通信协议,它将HTTP消息分解为独立的帧,并通过多个数据流交错发送和传输。新的二进制分帧机制改变了客户端与服务器之间交换数据的方式,提高了性能。在HTTP/2中,数据流之间可以建立依赖关系,并按照权重进行资源分配。通过nghttp的分析可以查看HTTP/2的性能优化。

1. HTTP 协议发展历程 1 2

1.1. HTTP/0.9

  • 1991 年推出
  • 仅 GET 请求,服务端仅回复 HTML 格式内容

1.2. HTTP1.0

  • 1995 年推出
  • 加入了 POST\HEAD 请求
  • 约定了 HTTP 协议格式(头信息+\r\n+内容)
  • 头信息丰富,状态码、Content-Type MIME 类型、内容编码、权限认证、缓存处理、内容长度、压缩处理、持久连接处理
  • 默认 HTTP1.0 下,一个连接仅发送一个 HTTP 请求和接收一个 HTTP 响应,发送完毕就关闭 TCP 连接,TCP 开销很大,TCP3 次连接建立、TCP 慢启动
  • 默认不支持 TCP 连接复用,可以通过首部加Connection: Keep-Alive对 TCP 连接保持,但Connection不是标准头

1.3. HTTP/1.1

  • 1996 年推出
  • 加入更请求方法,诸如 OPTIONS、TRACE、PUT、PATCH、DELETE;
  • 默认开启Conenction: Keep-Alive持久连接,支持链接复用,基于Connection: Close主动关闭
  • 支持管道传输(同时往一个 TCP 连接发送多个 HTTP 请求,但一个一个按顺序响应,并基于 HTTP Content-Length 长度处理每个 HTTP 响应)
  • 分块传输编码Transfer-Encoding: chunked:对于一些很耗时的动态操作来说,利用 Content-Length 方式,服务器要等到所有操作完成,才能发送数据,显然这样的效率不高。更好的处理方法是,产生一块数据,就发送一块,采用"流模式"(stream)取代"缓存模式"(buffer)
  • 客户端加入 Host 字段,迎来了基于 IP 转型到基于 Host 虚拟主机的兴起
  • 同一个 TCP 连接里面,所有的数据通信是按次序进行的,如果一个 Page 有很多 HTTP 请求,只能请求排队(优化方式:一是减少请求数,二是同时多开持久连接),加上浏览器并发连接请求限制(比如一些客户端针对单 Host 仅开启指定数量的 TCP 连接),由此有很多优化经验(CSSSprite、静态资源拆分子域名、JS、CSS 脚本合并,延迟加载等)

1.4. SPDY 协议

  • 2009 年由 Google 推出,解决 HTTP/1.1 效率不高的问题
  • HTTP/2 的基础,Google 在 Chrome 浏览器中通过 SPDY 协议进行 HTTP 内容数据传输,证明可行

1.5. HTTP/2 - HPACK 标头压缩、基于帧多路复用、ServerPush、批量请求

  • 2015 年由 IEFT 推出,HTTP/2 也称h2或者h2c,不考虑子版本,下一个版本为 HTTP/3
  • 二进制协议:基于帧形式传递数据,支持多种帧,比如头信息帧、数据帧
  • 头信息压缩:客户端和服务端共同维护一张头信息表,基于索引标识头,避免带宽浪费
  • 多路复用:支持在单一 TCP 连接中并发请求 HTTP,无需请求和响应序列对应,通过流 ID 进行标记,指出帧归属于哪一个流;
  • 基于流 ID 编码进行数据区分,客户端基于奇数、服务端流 ID 基于偶数,双端发送过程中可以针对流进行终止而无需断开 TCP 连接
  • 允许设置设定请求优先级,通过流的权重和依赖进行综合评定
  • 支持服务端推送:可以实现未经请求,主动向客户端发送资源,可以用于优化请求-响应-解析-再请求-再响应的模式,利用服务端推送后,可以实现请求-响应-预加载内容响应的模式(有利有弊,比如客户端网络拥塞、TCP 慢启动、预推送策略等问题,效果需要多方综合)

2. HTTP/2 关键内容:帧、消息、流和 TCP 连接复用

一个 TCP 连接分为若干个流(Stream),每个流中可以传输若干消息(Message),每个消息由若干最小的二进制帧(Frame)组成(有头帧和数据帧)

简言之,HTTP/2 将 HTTP 协议通信分解为二进制编码帧的交换,这些帧对应着特定数据流中的消息。所有这些都在一个 TCP 连接内复用。 这是 HTTP/2 协议所有其他功能和性能优化的基础。

下面是 HTTP/2 中 TCP 连接复用的工作原理:

  • 帧(Frame)HTTP/2 使用帧作为最小的数据单位进行传输。每个帧都有一个帧头,包含了帧的长度、类型和标识符等信息。
  • 流(Stream)HTTP/2中的每个请求和响应都被关联到一个唯一的流ID。流是由多个帧组成的,可以同时在一个 TCP 连接上传输多个流。
  • 多路复用:在 HTTP/2 中,多个流可以同时在一个 TCP 连接上传输,而不需要按照顺序等待前一个请求的响应。这样就避免了 HTTP/1.1 中的队头阻塞问题,提高了性能和效率。
  • 流的优先级:HTTP/2 允许为每个流设置优先级,以便在网络拥塞时优先处理重要的请求。通过设置流的权重和依赖关系,可以确保关键资源优先传输。
  • 头部压缩:为了减少传输的开销,HTTP/2 使用了头部压缩(Header Compression)机制。客户端和服务器之间维护一个动态的头部表,用于存储和共享重复的头部字段,从而减小了每个请求和响应的头部大小。

2.1. 二进制分帧 3

分成头帧和数据帧:HTTP1.x 的头部被放到头帧(减少冗余数据传输),数据部分放到数据帧

这里所谓的“层”,指的是位于套接字接口与应用可见的高级 HTTP API 之间一个经过优化的新编码机制:HTTP 的语义(包括各种动词、方法、标头)都不受影响,不同的是传输期间对它们的编码方式变了。

HTTP/1.x 协议以换行符作为纯文本的分隔符,而 HTTP/2 将所有传输的信息分割为更小的消息和帧,并采用二进制格式对它们编码。

这样一来,客户端和服务器为了相互理解,都必须使用新的二进制编码机制:

HTTP/1.x 客户端无法理解只支持 HTTP/2 的服务器,反之亦然。另外,现有的应用不必担心这些变化,因为客户端和服务器会替我们完成必要的分帧工作。

2.2. 数据流、消息和帧

新的二进制分帧机制改变了客户端与服务器之间交换数据的方式,HTTP/2 的三个概念:

  1. 数据流:已建立的连接内的双向字节流,可以承载一条或多条消息。
  2. 消息:与逻辑请求或响应消息对应的完整的一系列帧,消息可以理解为由多个帧组成。
  3. :HTTP/2 通信的最小单位,每个帧都包含帧头,至少也会标识出当前帧所属的数据流。

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

  • 所有通信都在一个 TCP 连接上完成,此连接可以承载任意数量的双向数据流。
  • 每个数据流都有一个唯一的标识符(SteamID)和可选的优先级(Weight)信息,用于承载双向消息。
  • 每条消息都是一条逻辑 HTTP 消息(例如请求或响应),包含一个或多个帧。
  • 帧是最小的通信单位,承载着特定类型的数据,例如 HTTP 标头、消息负载等等。 来自不同数据流的帧可以交错发送,然后再根据每个帧头的数据流标识符重新组装。

2.3. TCP 连接多路复用(请求响应排队=>分帧传输)

HTTP/1.x 中,如果客户端要想发起多个并行请求以提升性能,则必须使用多个 TCP 连接,这是 HTTP/1.x 交付模型的直接结果,该模型可以保证每个连接每次只交付一个响应(响应排队), 更糟糕的是,这种模型也会导致队首阻塞,从而造成底层 TCP 连接的效率低下。

HTTP/2 中新的二进制分帧层突破了这些限制,实现了完整的请求和响应复用:客户端和服务器可以将 HTTP 消息分解为互不依赖的帧,然后交错发送,最后再在另一端把它们重新组装起来。

将 HTTP 消息分解为独立的帧,交错发送,然后在另一端重新组装是 HTTP 2 最重要的一项增强:

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

2.4. 数据流优先级

将 HTTP 消息分解为很多独立的帧之后,我们就可以复用多个数据流中的帧,客户端和服务器交错发送和传输这些帧的顺序就成为关键的性能决定因素。为了做到这一点,HTTP/2 标准允许每个数据流都有一个关联的权重和依赖关系:

  • 可以向每个数据流分配一个介于 1 至 256 之间的整数。
  • 每个数据流与其他数据流之间可以存在显式依赖关系。

声明数据流依赖关系指出,应尽可能先向父数据流分配资源,然后再向其依赖项分配资源,如果多个依赖根,则按其权重占比分配资源。

图示第一个占比(数据流 A 应获得四分之三的可用资源,数据流 B 应获得四分之一的可用资源,A=12/(12+4),B=4/(12+4)

图示的说明(依赖优先、其次是权限占比):

  • 数据流 A 和数据流 B 都没有指定父依赖项,依赖于显式“根数据流”;A 的权重为 12,B 的权重为 4。因此,根据比例权重:数据流 B 获得的资源是 A 所获资源的三分之一。
  • 数据流 D 依赖于根数据流;C 依赖于 D。 因此,D 应先于 C 获得完整资源分配。 权重不重要,因为 C 的依赖关系拥有更高的优先级
  • 数据流 D 应先于 C 获得完整资源分配;C 应先于 A 和 B 获得完整资源分配;数据流 B 获得的资源是 A 所获资源的三分之一。
  • 数据流 D 应先于 E 和 C 获得完整资源分配;E 和 C 应先于 A 和 B 获得相同的资源分配;A 和 B 应基于其权重获得比例分配。

通过 nghttp 的分析查看:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 流3依赖0,权重为201,流5依赖0,权重101,流7、9、11依赖0,权重1,流13依赖流11权重16
$ nghttp -unv 'https://h2o.examp1e.net' |less
[  0.032] Connected
The negotiated protocol: h2
[  0.475] send SETTINGS frame <length=12, flags=0x00, stream_id=0>
            (niv=2)
            [SETTINGS_MAX_CONCURRENT_STREAMS(0x03):100]
            [SETTINGS_INITIAL_WINDOW_SIZE(0x04):65535]
[  0.475] send PRIORITY frame <length=5, flags=0x00, stream_id=3>
            (dep_stream_id=0, weight=201, exclusive=0)
[  0.475] send PRIORITY frame <length=5, flags=0x00, stream_id=5>
            (dep_stream_id=0, weight=101, exclusive=0)
[  0.475] send PRIORITY frame <length=5, flags=0x00, stream_id=7>
            (dep_stream_id=0, weight=1, exclusive=0)
[  0.475] send PRIORITY frame <length=5, flags=0x00, stream_id=9>
            (dep_stream_id=7, weight=1, exclusive=0)
[  0.475] send PRIORITY frame <length=5, flags=0x00, stream_id=11>
            (dep_stream_id=3, weight=1, exclusive=0)
[  0.475] send HEADERS frame <length=39, flags=0x25, stream_id=13>
            ; END_STREAM | END_HEADERS | PRIORITY
            (padlen=0, dep_stream_id=11, weight=16, exclusive=0)
            ; Open new stream
            :method: GET
            :path: /
            :scheme: https
            :authority: h2o.examp1e.net
            accept: */*
            accept-encoding: gzip, deflate
            user-agent: nghttp2/1.39.1

数据流依赖关系和权重的组合明确表达了资源优先级,这是一种用于提升浏览性能的关键功能,网络中拥有多种资源类型(比如 html 文档、css、js、图片资源等),它们的依赖关系和权重各不相同。

不仅如此,HTTP/2 协议还允许客户端随时更新这些优先级,进一步优化了浏览器性能。

2.5. TCP 连接复用

有了新的分帧机制后,HTTP/2 不再依赖多个 TCP 连接去并行请求了,而是通过多个数据流复用同一个底层 TCP 连接,每个数据流都拆分成很多帧,而这些帧可以交错,还可以分别设定优先级。

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

2.6. HTTP/2 流控

HTTP/2 数据流在一个 TCP 连接内复用,TCP 流控制既不够精细,也无法提供必要的应用级 API 来调节各个数据流的传输。 为了解决这一问题,HTTP/2 提供了一组简单的构建块,这些构建块允许客户端和服务器实现其自己的数据流和连接级流控制。

另外,我们也看到基于 HTTP/2 的涉及的流控较为复杂,一定程度是违背分层原则,应该由 TCP 传输控制层的流控功能,因此这块也被 Varnash 作者批评。4

2.7. HTTP/2 标头压缩

每个 HTTP 传输都承载一组标头,这些标头说明了传输的资源及其属性。 在 HTTP/1.x 中,此元数据始终以纯文本形式,通常会给每个传输增加 500–800 字节的开销。如果使用 HTTP Cookie,增加的开销有时会达到上千字节。为了减少此开销和提升性能,HTTP/2 使用 HPACK 压缩格式压缩请求和响应标头元数据,这种格式采用两种简单但是强大的技术:

  • 通过静态霍夫曼代码对传输的标头字段进行编码
  • 要求客户端和服务器同时维护和更新一个包含之前见过的标头字段的索引列表(客户端和服务端共享标头上下文)

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

2.8. 服务器推送

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

为什么在浏览器中需要一种此类机制呢?

一个典型的网络应用包含多种资源,客户端需要检查服务器提供的文档才能逐个找到它们。 那为什么不让服务器提前推送这些资源,从而减少额外的延迟时间呢? 服务器已经知道客户端下一步要请求什么资源,这时候服务器推送即可派上用场。 对于将资源手动内联到文档中的过程,我们实际上是在将资源推送给客户端,而不是等待客户端请求。

推送资源可以进行以下处理:

  • 由客户端缓存,方便重用
  • 在不同页面之间重用
  • 与其他资源一起复用
  • 由服务器设定优先级
  • 被客户端拒绝

2.9. PUSH_PROMISE 101

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

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

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

推送的每个资源都是一个数据流,与内嵌资源不同,客户端可以对推送的资源逐一复用、设定优先级和处理。

浏览器强制执行的唯一安全限制是,推送的资源必须符合同源策略:服务器对所提供内容必须具有权威性。

3. HTTP/2 Server Push 服务端推送 5

服务器推送(server push)是 HTTP/2 协议里面,唯一一个需要开发者自己配置的功能。其他功能都是服务器和浏览器自动实现,不需要开发者关心。

3.1. 传统资源加载优化

默认情况下,浏览器发起请求,获取 HTTP 文档后,通过解析 HTTP DOM,拿到 src 或者 href 等内容,再度发起 HTTP 请求,类似于请求-解析-再请求模式,我们可以通过提前载入的方式来做一定优化:

  1. 将资源内联到 HTML 文档中,不走外链,比如图片走 base64 编码、css 和 js 走内联方式,问题是会带来文档臃肿,重复 js 和 css 内容页没有缓存,且表现和内容未分离(违反分工原则)
  2. 利用在 HTML 文档中,加入preload属性,在 HTML 响应后,浏览器解析完后就会立马请求内容(可以设想提前加载一些当前页不需要,但后续 Page 页可能需要的内容,体验会有一定优化),比如:
    • <link rel="preload" href="/styles.css" as="style">
    • <link rel="preload" href="/example.png" as="image">

通过上述方法,实际上在 HTTP 请求层面,依旧是 3 次 RTT:一次 HTML DOM、一次 CSS、一次 PNG 图片,如果利用 ServerPush,可以在发起 HTTP 首次请求后,将后续两个资源再同一个 TCP 请求中预先响应回来,不等到浏览器去解析的时候,才发起资源请求;

3.2. HTTP/2 Server Push 实现5

主要是通过 HTTP 响应头内容中添加:Link: "</styles.css>; rel=preload; as=style"模式,可以在 Nginx Web 服务器配置支持,或者基于服务端动态 Header 头响应+Web 服务解析http2_push_preload on;处理;

  • 直接在 Nginx 配置,基于http2_push命令:
1
2
3
4
5
6
location / {
  root   /data/www/html;
  index  index.html index.htm;
  http2_push /style.css;
  http2_push /example.png;
}
  • 后端服务,在 HTTP 响应中直接加入响应头:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 在Go中推送单个资源
Link: </styles.css>; rel=preload; as=style
// 推送多个资源
Link: </styles.css>; rel=preload; as=style, </example.png>; rel=preload; as=image

// Nginx配置
location = / {
    proxy_pass http://upstream;
    http2_push_preload on;
}

3.3. 通俗理解 ServerPush

假定你去一个餐馆吃饭点餐:

  1. 你可以从菜单挑选一批想点的菜,写好后给到老板,老板喊厨师一个菜一个菜的烧好后,逐步端出来(类似 HTTP/1.x,先提请求,再一个一个响应)
  2. 还有一种方式就是,老板知道你天天来就写那两个菜,索性就提前炒好放着,你一写完菜单,就立马端上来(类似 preload 模式)

3.4. nghttp 调试工具

HTTP/2 的帧层实现为可重用的 C 库。最重要的是,我们已经实现了 HTTP/2 客户端,服务器和代理。我们还为 HTTP/2 开发了负载测试和基准测试工具。

参见:https://github.com/nghttp2/nghttp2

1
2
3
4
5
// brew install nghttp2
-u: 升级为HTTP/2协议,如果是https请求,可以忽略
-n: 不下载内容
-v: verbose
nghttp -unv 'https://h2o.examp1e.net'