引言

在《图解HTTP》的读书笔记《图解HTTP》- HTTP协议历史发展(重点) 当中介绍了一部分关于HTTP/2的内容,但是内容比较简短没有过多深入,本文对于HTTP/2 协议做一个更深入的介绍。

概览

HTTP1.X 有两个主要的缺点:安全不足性能不高

所谓安全不足,是指HTTP1.X 大部分时候使用了明文传输,所以很多时候黑客可以通过抓包报文的方式对于网络数据进行监控和尝试破解,为了安全传输数据,HTTP通常和TLS组合实现网络安全连接。

性能不高则指的是HTTP在请求传输中会传输大量的重复字段,Body的数据可以通过GZIP进行压缩。这达到了可以勉强接收传输效率,但是Header头部字段依旧非常臃肿和低效,并且HTTP1.X 后续也没有效的头部压缩手段,HTTP/2 借用了哈夫曼编码对于Header进行高效压缩,提高传输效率。

除了上面的问题,HTTP1.X中最大的问题是队头阻塞,HTTP1.X中浏览器对于同一域名的并发连接访问此时是有限的,所以常常会导致只有个位数的连接可以正常工作,后续的连接都会被阻塞。

HTTP/2 解决队头阻塞是以 HTTP1.X 管道化的为基础拓展,它使用了二进制流和帧概念解决应用层队头阻塞。应用层的阻塞被解决便是实现流并发传输

为了控制资源的资源的获取顺序,HTTP在并发传输的基础上实现请求优先级以及流量控制,流的流量控制是考虑接收方是否具备接收能力。

在发送方存在WINDOWS流量窗口,而接收方可以通过一个叫做WINDOW_UPDATE帧限制发送方的传输长度。

要理解HTTP/2的细节需要有一个宏观的概念:为了提高效率,HTTP/2整体都在向着TCP协议贴近

以上就是对于HTTP/2升级的模糊理解,HTTP/2 的改进从整体上分为下面几个部分:

  • 兼容HTTP1.X
  • 应用层队头阻塞解决
  • 并发传输
  • 多路复用
  • 二进制帧
  • 服务器推送
  • HPACK/头部压缩
  • 请求优先级
  • 补充

    • 连接前言
    • 流和管道化关系
    • 请求头字段约束

思维导图

https://www.mubucm.com/doc/3kTM1b8PGV5

兼容HTTP1.X

HTTP和TLS协议一样背着巨大的历史包袱,所以不能在结构上做出过多的改动,HTTP/2为了进行推广也必须要进行前后兼容,而兼容HTTP1.X 则引导出下面三个点:

  • HTTP协议头平滑过渡
  • 应用层协议改变
  • 基本传输格式的保证

HTTP协议头平滑过渡

所谓的平滑过渡指的是协议头的识别依然是 HTTP开头,不管是HTTP1 还是 HTTP/2,甚至是HTTP3,都将会沿用http开头的协议名进行向后兼容。

应用层协议改变

HTTP/2只改变了应用层并没有改变TCP层和IP层,所以数据依然是通过TCP报文的方式进行传输,经过TCP握手和HTTP握手完成。

基本传输格式的保证

HTTP1.X中的请求报文格式如下,结合来说请求报文可以总结为下面的格式:

  • 请求行
  • 请求首部字段和其他字段
  • 空行
  • 请求负载

HTTP请求报文结构

HTTP 虽然把内部的格式大变样,但是请求报文的结构总体是没有变的。

推广安全

HTTP/2是“事实上的安全协议”,HTTP/2虽然并没有强制使用SSL安全传输,但是许多主流浏览器已经不支持非HTTPS进行HTTP2 请求,同时可以发现很多实现了HTTP/2的网站基本都是都是具备HTTPS安全传输条件的。

因为HTTP/2要比TLS1.3早出几年,HTTP/2推广加密版本的 HTTP/2 在安全方面做了强化,要求下层的通信协议必须是TLS1.2 以上,并且此时TLS1.2很多加密算法已经被证实存在安全隐患(比如DES、RC4、CBC、SHA-1不可用),所以使用HTTP/2被要求保证前向安全,更像是TLS1.25

因为TLS1.3要比HTTP/2要晚几年才出台,而HTTP/2出现的时候TLS很多加密套件早已经没法使用了,所以HTTP/2使用的TLS1.2加密套件是带椭圆曲线函数的TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256

HTTP/2协议还对加密和不加密的报文进行划分,HTTP/2 定义了字符串标识标识明文和非明文传输,“h2”表示加密的 HTTP/2,“h2c”表示明文的 HTTP/2,这个c表示"clear text"

协议栈变化

从HTTP1.X 到HTTPS以及HTTP/2的协议栈变化图可以看到整个应用层协议虽然结构上没有过多调整,但是内容出现了天翻地覆的变化,实现细节也更加复杂。

虽然HTTP/2的“语法”复杂了很多,但是“语义”本身是没有变化的,用HTTP1.X 的一些思路抽象理解HTTP/2的结构定义是适用的。

二进制帧(Stream)

二进制帧是HTTP/2的“语法”变动,HTTP/2的传输格式由明文转为了二进制格式, 属于向 TCP/IP 协议“靠拢”,可以通过位运算提高效率。

二进制的格式虽然对于人阅读理解不是很友好,但是对于机器来说却刚好相反,实际上二进制传输反倒要比明文传输省事多了,因为二进制只有0和1绝对不会造成解析上的歧义,也不会因为编码问题需要额外转译。

二进制帧保留Header+Body传输结构,但是打散了内部的传输格式,把内容拆分为二进制帧的格式,HTTP/2把报文的基本传输单位叫做,而帧分为两个大类 HEADERS(首部)DATA(消息负载),一个消息划分为两类帧传输同时采用二进制编码。

这种做法类似Chunked化整数据的方式,把Header+body等同的帧数据,同时内部通过类型判断进行标记。

这里可以举个简单例子,比如常见的状态码 200,用文本数据传输需要3个字节(二进制:00110010 00110000 00110000),而用HTTP/2的二进制只需要1个字节(10001000)

HTTP/2.0二进制帧

二进制分帧结构

HTTP/2的数据传输基本单位(最小单位)是帧,帧结构如下:

二进制分帧结构

注意这里的单位是bit不是byte,头部实际上占用的字节数非常少,一共加起来也就9个字节大小。其中3个字节的长度表示长度,帧长度后面表示帧类型,HTTP/2定义了多大10种类型帧,主要分为数据帧控制帧

帧类型后面接着标志位,标志位用于携带一些控制信息,比如下面:

  • END_HEADERS:表示头数据结束标志,相当于 HTTP1.X 里头后的空行“\r\n”。
  • END_Stream:表示单方向数据发送结束,后续不会再有数据帧。
  • PRIORITY:表示流的优先级。

最后是31位的流标识符以及1个最高位保留不用的数据,流标识符的最大值是 2^31,大约是 21 亿大小,此标志位的主要作用是标识该 Frame 属于哪个 Stream,乱序传输中根据这部分乱序的帧流标识符号找到相同的Stream Id 进行传输。

RFC 文档定义:
Streams are identified with an unsigned 31-bit integer. Streams initiated by a client MUST use odd-numbered stream identifiers; those initiated by the server MUST use even-numbered stream identifiers.

最后是帧数据,这部分为了提高利用效率使用了HPACK算法压缩的头部和实际数据。

实际上SPDY早期的方案也是使用GZIP压缩,只不过CRIME压缩漏洞攻击之后才专门研究出HPACK算法它防止压缩漏洞攻击。

流与多路复用

核心概念:

  • 流是二进制帧的双向传输序列
  • 一个 HTTP/2 的流就等同于一个 HTTP/1 里的“请求 - 应答”。
  • HTTPP2的流特点

    • 一个TCP复用多个“请求响应”,支持并发传输
    • 流和流之间独立,但是内部通过StreamId保证顺序。
    • 流可以进行请求优先级设置
    • 流ID不允许重复
    • 0号流是用于流量控制的控制帧
      ....

理解多路复用我们需要先了解二进制帧,因为流的概念在HTTP/2中其实是 不存在的,HTTP/2讨论的流是基于二进制帧的数据传输形式的考量。流是二进制帧的双向传输序列

我们这里再复习一遍二进制帧的结构,里面的流标识符就是流ID。

二进制分帧结构

通过抓包可以看到HTTPS2很多时候会出现流被拆分的情况,比如下面的Headers就传输了3个流,把这些帧进行编号并且排队之后进行传输就转化为流传输:

一个 HTTP/2 的流就等同于一个 HTTP/1 里的“请求 - 应答”,而在HTTP1里面,它表示一次报文的“请求响应”,所以HTTP1和HTTP/2在这一点上概念是一样的。

不过按照TCP/IP 的五层传输模型来看,其实TCP的连接概念也是虚拟的,它需要依赖IP运输和MAC地址寻址,但是从功能上来说它们都是实实在在的完成传输动作,所以不需要纠结流虚拟还是不虚拟的概念,我们直接把他当成实际存在的更容易好理解。

HTTP/2 的流主要有下面的特点:

  1. HTTP/2遵循一个TCP上复用多个“请求 - 应答”,意味着一个 HTTP/2 连接上可以同时发出多个流传输数据,并且流可以并发传输实现“多路复用”;
  2. 客户端和服务器都可以创建流,并且互不干扰;
  3. HTTP/2支持服务端推送,流可以从客户端或者服务端出发;
  4. 流内部的帧是有严格顺序的,但是流之间互相独立;
  5. 流可以设置优先级,让服务器优先处理特定资源,比如先传 HTML/CSS,后传图片,优化用户体验;
  6. 流 ID 不能重用,只能顺序递增,客户端发起的 Stream ID 是奇数,服务器端发起的 Stream ID 是偶数;
  7. 在流上发送“RST_STREAM”帧可以随时终止流,取消流的接收或发送;
  8. 第 0 号流比较特殊,它不能关闭,也不能发送数据帧,只能发送控制帧,用于流量控制。

从上面特点那中我们还可以发现一些细节。

默认长连接

比如第一条可以推理出HTTP/2遵循的请求跑在一个TCP连接上,而多个请求的并发传输跑在一个TCP连接的前提是连接有相对长时间占用,也就是说HTTP/2 在一个连接上使用多个流收发数据本身默认就会是长连接,所以永远不需要“Connection”头字段(keepalive 或 close)。

RST_STREAM帧的常见应用是大文件中断重传,在 HTTP/1 里只能断开 TCP 连接重新“三次握手”进行请求重连,这样处理的成本很高,而在 HTTP/2 里就可以简单地发送一个“RST_STREAM”中断流即可进行暂停,此时长连接会继续保持

流标识符不是无限的,如果ID递增到耗尽,此时可以发送控制帧“GOAWAY”,真正关闭 TCP 连接

因为流双向传输,HTTP/2使用了奇数和偶数划分请求来源方向,奇数为客户端发送的帧,而偶数为服务端发送的帧,客户端在一个连接最多发出2^30请求,大约为10亿个。

流状态转化

既然RST_STREAM帧可以改变整个流的传输状态,那么意味着HTTP/2的流是存在状态帧的概念的,翻阅RFC文档果然发现了状态机的图,从下面的可以看到比较复杂。我们重点关注四个状态:

  • idle
  • open
  • half closed
  • closed

    是不是感觉有点熟悉?没错这和TCP层的连接握手状态其实是有不少相似性的,从这里也可以看出HTTP/2的整个理念是贴近TCP协议层。
           +--------+
                      send PP |        | recv PP
                     ,--------|  idle  |--------.
                    /         |        |         \
                   v          +--------+          v
            +----------+          |           +----------+
            |          |          | send H /  |          |
     ,------| reserved |          | recv H    | reserved |------.
     |      | (local)  |          |           | (remote) |      |
     |      +----------+          v           +----------+      |
     |          |             +--------+             |          |
     |          |     recv ES |        | send ES     |          |
     |   send H |     ,-------|  open  |-------.     | recv H   |
     |          |    /        |        |        \    |          |
     |          v   v         +--------+         v   v          |
     |      +----------+          |           +----------+      |
     |      |   half   |          |           |   half   |      |
     |      |  closed  |          | send R /  |  closed  |      |
     |      | (remote) |          | recv R    | (local)  |      |
     |      +----------+          |           +----------+      |
     |           |                |                 |           |
     |           | send ES /      |       recv ES / |           |
     |           | send R /       v        send R / |           |
     |           | recv R     +--------+   recv R   |           |
     | send R /  `----------->|        |<-----------'  send R / |
     | recv R                 | closed |               recv R   |
     `----------------------->|        |<----------------------'
                              +--------+
    
        send:   endpoint sends this frame
        recv:   endpoint receives this frame
    
        H:  HEADERS frame (with implied CONTINUATIONs)
        PP: PUSH_PROMISE frame (with implied CONTINUATIONs)
        ES: END_STREAM flag
        R:  RST_STREAM frame
    
Note that this diagram shows stream state transitions and the frames
and flags that affect those transitions only. In this regard,
CONTINUATION frames do not result in state transitions; they are
effectively part of the HEADERS or PUSH_PROMISE that they follow.

有关流状态转化的细节都在RFC的文档中,链接如下:
https://datatracker.ietf.org/doc/html/rfc7540#section-5.1,上面的图理解起来比较吃力,我们先看一个极简风格的图:

当连接没有开始的时候,所有流都是空闲状态,此时的状态可以理解为“不存在待分配”。客户端发送 HEADERS帧之后,流就会进入"open"状态,此时双端都可以收发数据,发送数据之后客户端发送一个带“END_STREAM”标志位的帧,流就进入了“半关闭”状态。响应数据也需要发送 END_STREAM 帧,表示自己已经接收完所有数据,此时也进入到“半关闭”状态。如果请求流ID耗尽,此时就可以发送一个 GOAWAY 完全断开TCP连接,重新建立TCP握手。

以上就是一个简单的流交互过程。

idel:Sending or receiving a HEADERS frame causes the stream to become "open".
END_STREAM flag causes the stream state to become "half-closed
  (local)"; an endpoint receiving an END_STREAM flag causes the
  stream state to become "half-closed (remote)".

并发传输

并发传输是依靠流的多路复用完成的,根据上面的内容我们知道Stream 可以并行在一个TCP连接上,每一个Stream就是一次请求响应,HTTP/2在并发传输中设置了下面几个概念:

  • Stream
  • Message
  • Frame

这三者的关系如下

我们根据结合图以及之前所学,对于这几个概念做出如下定义:

Connection 连接:1 个 TCP 连接,包含 1 个或者多个 stream。所有通信都在一个 TCP 连接上完成,此连接可以承载任意数量的双向数据流。

Stream 数据流:一个双向通信的数据流,包含 1 条或者多条 Message。每个数据流都有一个唯一的标识符和可选的优先级信息,用于承载双向消息。

Message 消息:对应 HTTP/1.1 中的请求 request 或者响应 response,包含 1 条或者多条 Frame。

Frame 数据帧:最小通信单位,以二进制压缩格式存放内容。来自不同数据流的帧可以交错发送,然后再根据每个帧头的数据流标识符重新组装。

HTTP1.1 由 Start Line + header + body 组成,HTTP2转变为HEADER frame + 若干个 DATA frame 组成。

在HTTP2中,消息允许客户端或者服务器以Stream为基础进行乱序发送,内部被拆分为独立的帧。

客户端并发传输

服务端并发传输

客户端和服务器双方都可以建立 Stream,HTTP2允许服务端主动推送资源给客户端,但是HTTP2页规定 客户端建立的 Stream 必须是奇数号,而服务器建立的 Stream 必须是偶数号。

以第二个图为例,可以看到有三个流在进行并行传输, 1 为奇数,代表了客户端推送的资源, 2和4位偶数,代表了服务器端推送的资源。

最后我们小结一波:

  • 所有通信都通过一个 TCP 连接执行,该连接可以携带任意数量的双向流。
  • 每个流都有一个唯一标识符和可选的优先级信息,用于携带双向消息。
  • 每条消息都是一个逻辑 HTTP 消息(请求或响应),它由一个或多个帧组成。
  • 帧是承载特定类型数据的最小通信单位,例如 HTTP 标头、消息负载等。 来自不同流的帧可以被交叉传输,然后通过每个帧头中的流标识符重新组合
  • 并发传输指的是多个流可以同时的跑在一个连接上。

应用层队头阻塞解决

先说一下结论:HTTP2 解决了应用层的的队头阻塞,但没有解决TCP队头阻塞问题,我们可以认为HTTP2的队头阻塞很像是把管道化的概念实现的更好。

首先是HTTP1.X的队头阻塞问题,HTTP1在浏览器中的同一域名的并发连接数有限,如果连接数超过上限,排在后面的连接就需要等待前面的资源加载完成。

过去常常出现的浏览器空白并且一直“转圈”就是因为这个问题。

各大服务网站的解决方式是使用资源分割的方式,配合多域名和主机进行多个IP避开浏览器单个域名的限制,同时结合CDN加速请求。但是这样做需要分片多个TCP请求,TCP的连接请求的资源消耗比较大。

前面内容我们知道了,HTTP 2 通过改写HTTP数据交互方式为二进制,使用二进制帧的结构实现了应用层的多路复用,所有的二进制帧可以组成流并行可以跑在一个TCP连接上面,每个Stream都有一个唯一的StreamId,通过每个帧上设置ID(流标识符)在双方向上完成组装来还原报文,接收方需要根据ID的顺序拼接出完整的报文。

应用层上的队头阻塞是解决了,为什么说没有解决TCP队头阻塞?

我们需要明确HTTP本身是不具备数据传输能力的,虽然HTTP2识别数据和响应数据的方式变了,但是运载数据的还是TCP协议,而TCP协议实际上根本不认识什么HTTP数据,也不知道什么流,它只负责保证数据的安全传输。

在一个可靠的网络中,并发传输和配合没什么问题,HTTP和TCP互相不认识对方也不打紧,但是问题就出在现代社会的网络环境是移动和固定网络频繁切换的,网络不畅事情时有发生。

在不稳定的网络传输中很有可能出现TCP数据传输阻塞问题,假设A网站要给B用户一个CSS文件,HTTP知道他要被拆分为三个独立资源的包,按照ID连起来拼成完整的数据。此时如果数据包1和3都传输过去了,但是2在传输过程突然出现丢包,此时接收方组装的时候发现ID不连续,这时候是不能够把1后面的数据包3传出去的,TCP的处理方式是 将数据包3保存在其接收缓冲区(receive buffer)中,直到它接收到数据包2的重传副本然后重新拼出完整的文件,然后才能给浏览器(这至少需要往返服务器一次)。

在HTTP1.X中如果出现上面TCP队头阻塞情况,可以通过直接丢弃原有的TCP开新的TCP连接解决问题,虽然开销很大但是至少可以确保传输在正常进行。

而HTTP2在这种情况下就开倒车了,因为HTTP2的理念是一个TCP连接,所以只能通过等待TCP连接重传来解决丢包的问题,这种情况下整个TCP连接都要阻塞,如果是大文件传输,这种体验会更加糟糕。

结论:
TCP 协议本身的缺陷加上HTTP2一个TCP连接设计,HTTP2的TCP层队头阻塞问题十分显著。HTTP1.X在解决TCP队头阻塞虽然笨,但是实际体验要比HTTP2好得多。

以上这就是TCP的队头阻塞问题。顺带提一句HTTP3 通过了QUIC协议替换掉TCP协议,彻底实现了无队头阻塞的HTTP连接。

Header压缩(Header Compression)

HTTP1.X的头部压缩可以总结出下面几个缺点:

  • ASCII 编码明文传输虽然容易阅读,但是传输效率低。
  • 大量重复的请求和响应头部字段消耗无用网络传输带宽。
  • 请求负载可以使用GZIP压缩但是请求头部字段缺乏有效的压缩手段。

综上所述HTTP/2为什么要引入头部压缩?主要的原因是HTTP1.X 中所有的内容都是明文传输的,而很多情况下对于轮询请求和频繁调用的接口,经常需要传输重复请求头部,而随着网络传输报文越来越复杂,累赘的请求头部优化亟待优化。

头部压缩可以带来多少效率提升,官方的答案是至少50%,重复字段越多优化越发明显,具体可以看Patrick McManus对于头部压缩的性能提升的倡导讨论:# In Defense of Header Compresson

HTTP/2 头部压缩是基于HPACK算法实现的,主要通过三个技术点实现:

  • 静态表 :内部预定义了61个Header的K/V 数值
  • 动态表 :利用动态表存储不在静态表的字段,从62开始进行索引,主要存储一些动态变化的请求头部。
  • 哈夫曼(霍夫曼、赫夫曼)编码:一种高效数据压缩的数据结构,被广泛应用在计算机的各个领域。

理解这几个概念作为初学可以简单理解设计思路是借用了DNS查表的方式,在HTTP连接的双端构建缓存表,对于传输重复字段采用缓存到表里面的方式进行替代。

静态表

静态表包含了一下基本不会出现变化的字段,静态表设计固定61个字段,这些字段都是请求中高频出现的字段,比如请求方法,资源路径,请求状态等等。

那么这个Index是什么意思?这个类似于数组定位,index标识索引,在传输的过程中固定的字段用固定的索引标识和传输,header name 标识请求头的名称,而Header Value则表示内容。

下面的内容来摘自小林的博客,我们来看一下静态表是如何存储请求头部字段的。

注意Value字段是动态变化的,Value设置之前都需要进行哈夫曼编码,编码之后通常具备50%左右的字节占用减少,比如高亮部分是 server 头部字段,只用了 8 个字节来表示 server 头部数据。

RFC中规定,如果头部字段属于静态表范围并且 如果Value 是变化的,那么它的 HTTP/2 头部前 2 位固定为 01

通过抓包了解server在HTTP的格式:

server: nghttpx\r\n
哈夫曼编码之后:
server: 01110110

算上冒号空格和末尾的\r\n,共占用了 17 字节,而使用了静态表和 Huffman 编码,可以将它压缩成 8 字节,压缩率大概 47 %

上面的 server的值是如何定义的,首先通过index找到 server字段的序列号为54,二进制为110110,同时它的Value是变化的,所以是01开头,最后组成01110110

接着是Value部分,根据上文RFC哈夫曼编码的规则,首个比特位是用来标记是否哈夫曼编码的,所以跳过字节首位,后面的7位才是真正用于标识Value的长度,10000110,它的首位比特位为 1 就代表 Value 字符串是经过 Huffman 编码的,经过 Huffman 编码的 Value 长度为 6。

整个进化结果就是,字符串 nghttpx 转为二进制之后,然后经过 Huffman 编码后压缩成了 6 个字节。 哈夫曼的核心思想就是把高频出现的“单词”用尽可能最短的编码进行存储,比如 nghttpx 对应的哈夫曼编码表如下:

一共是六个字节的数据,从二进制通过查表的结果如下:

server 头部的二进制数据对应的静态头部格式如下:

注意\r\n是不需要二进制编码的。01 表示变化的静态表字段。

动态表

静态表包含了固定字段但是值不一定固定的表,而动态表则用存储静态表中不存在的字段,动态表从索引号62开始,编码的时候会随时进行更新。

比如第一次发送user-agent字段,值经过哈夫曼编码之后传输给接收方,双方共同存储值到各自的动态表上,下一次如果需要同样的user-agent字段只需要发送序列号index即可,因为双方都把值存储在各自对应的index索引当中。

所以哪怕字段越来越多,只要经过了哈夫曼编码存储以及通过索引号能找到对应的参数,就可以有效减少重复数据的传输。

哈夫曼编码

哈夫曼编码是一种用于无损数据压缩熵编码(权编码)算法。由美国计算机科学家大卫·霍夫曼(David Albert Huffman)在1952年发明。 霍夫曼在1952年提出了最优二叉树的构造方法,也就是构造最优二元前缀编码的方法,所以最优二叉树也别叫做霍夫曼树,对应最优二元前缀码也叫做霍夫曼编码。

哈夫曼编码对于初学者来说不是特别好理解,这部分内容放到了[[哈夫曼编码]]中进行讨论。

概念不好理解,初学建议多去找找视频教程对比学习

Header 压缩问题

这部分实际上指的是HTTP3 对于HTTPS的Header压缩优化,既然是优化,我们反向思考就可以知道问题了,主要是下面三点:

  • 请求接收端的处理能力有限,Header 压缩不能设置过于极限,缓存表如果占用超过一定的占比就会释放掉整个连接重新请求。(空间换时间不可避问题)
  • 静态表容量不够,HTTP3 升级到91个。
  • HTTP/2的动态表存在时序性问题,编码重传会造成网络拥堵。

缓存表限制:浏览器的内存以及客户端以及服务端的内存都是有限的,尤其是动态表的不确定因素很大,HTTP标准设计要求防止动态表过度膨胀占用内存导致客户端崩溃,并且在超过一定长度过后会自动释放HTTP/2请求。

保守设置:压缩表的设置有点过于保守了,所以HTTP3 对于这个表进行进一步扩展。

时序性问题:时序性问题是在传输的时候如果出现丢包,此时一端的动态表做了改动但是另一端是没改变的,同样需要把编码重传,这也意味着整个请求都会阻塞掉。

时序性问题

请求优先级

在开头介绍过,因为HTTP/2实现了应用层的多路复用,但是因为双向接收能力不对等问题,在使用多个Stream的时候容易单向请求阻塞问题。

这个问题是因为管道连接的设计思想带来的,在起草协议之前,SPDY中通过设置优先级的方式让重要请求优先处理解决这个问题,比如页面的内容应该先进行展示,之后再加载CSS文件美化以及加载脚本互动等等,实际减少用户在等待过程中关闭页面的几率,也有更好的上网体验。

为此HTTP2设计允许每个流都可以配置单独的权重和依赖关系:

  • 可以为每个流分配一个介于 1 和 256 之间的整数权重。
  • 可以为每个流提供对另一个流的显式依赖关系。

可以通过流依赖和权重值可以通过构建请求“优先级树”来更好的接收响应信息,反过来说,服务端也可以以此权重值和流依赖来实现控制CPU、内存、或者其他资源处理顺序的目的,在为响应的过程中为各种分配带宽,以获得更好的用户体验。

权重值越小,优先级越高

HTTP/2 中的流依赖项是通过引用另一个流的唯一标识符作为其父级来进行声明的。如果没有标识,则认为是root stream,声明流依赖项设计表示应在其依赖项之前尽可能为父级分配资源,举例来说就是在上面的响应中,先交付并且处理D,然后才进行C的处理。

共享同一父级的流(换句话说,同级流)应按其权重的比例分配资源。例如如果流 A 的权重为 12,同级 B 的权重为 4,每个流应接收资源比例计算如下:

  1. 首先把所有的权重值相加, 4+12 = 16。
  2. 计算A和B在权重值中所占据的比例:4 / 16,12 / 16。
  3. 按照比例计算,流A 获得3/4的可用资源,流B获得1/4的可用资源。
  4. D依赖root stream,而C依赖D,所以D可以获得全部的资源分配,然后再轮到C分配。
  5. 流D先于C获得资源的全部分配,C应在A和B之前获得资源的全部分配,剩下的再分配给A和B,同时流 B 应接收分配给流 A 的资源之后剩下的 1 / 4。
  6. 按照同样的道理,流D应在E和C之前获得资源的全部分配,E和C应该在A和B之前获得相等的分配,A 和 B 应根据其权重获得比例分配,流B接收分配给A 3/4 的最后1/4。

流依赖和权重值简洁易懂的实现一种权重分配的表达语言,通过这些表达语言来强化浏览器性能,比如用户看的见的CSS、JS脚本、HTML页面优先暂时,第一时间告知网站在积极响应而提高用户体验。

HTTP/2 协议允许客户端随时更新这些首选项从而进一步优化浏览器,换句话说我们可以随时更改依赖关系并重新分配权重,以响应用户交互和其他信号。

注意⚠️:流依赖关系和权重表示传输首选项而不是强制要求,因此实际上哪怕指定了请求优先级也并不能不保证一定按照特定的处理或传输顺序。也就是说客户端不能强制使用流优先级要求服务器按特定顺序处理流。
所以可以认为优先级的设置更像是“期望”,双端期望对方按照自己想要的结果处理。比如期望浏览器获取较高优先级的资源之前,阻止服务器在较低优先级的资源上进行处理。

小结

  • 请求优先级关键设计来源于一个有趣的“语言模型”:

    • 1 和 256 之间的整数权重
    • 树状流和流之间依赖关系
  • 流依赖关系和权重表示传输首选项而不是强制要求
  • 请求优先级不能规定行为,而是期望

流量控制

HTTP/2的流量控制是依靠帧结构实现的,通过关键字段WINDOW_UPDATE帧来提供流量控制,根据结构体定义,这个帧固定为4个字节的长度:

WINDOW_UPDATE Frame {
  Length (24) = 0x04,
  Type (8) = 0x08,

  Unused Flags (8),

  Reserved (1),
  Stream Identifier (31),

  Reserved (1),
  Window Size Increment (31),
}

对于流量控制,存在下面几个显著特征:

  • 流控制仅适用于被识别为受流量控制的帧(DATA 帧),同时流量的控制存在方向概念,由数据的双端负责流量控制,可以设置每一个流窗口的大小。
  • 流量控制需要受到各种代理服务器限制,并不完全靠谱,比如如果IP的一跳中存在代理,则代理和双端都有流控,所以特别注意这并非端到端的控制;
  • 基于信用基础公布每个流在每个连接上接收了多少字节,WINDOW_UPDATE 框架没有定义任何标志;换句话说只定义了几个基本的帧字段格式定义,怎么发送接收和控制完全由实现方决定,保证流控的自由度。
  • WINDOW_UPDATE 可以对已设置了 END_STREAM 标志的帧进行发送,表示接收方这时候有可能进入了半关闭或者已经关闭的状态接收到WINDOW_UPDATE帧,但是接收者不能视作错误对待;
  • 接收者必须将接收到流控制窗口增量为 0 的 WINDOW_UPDATE 帧视为PROTOCOL_ERROR类型的流错误 ;
  • 对于连接与所有新开启的流而言,流控窗口大小默认都是 65535,且最大值为 2^32;
  • 流控无法禁用
  • 流控既可以作用于 stream 也可以作用于 connection。

了解流量控制的注意事项,我们看看它是如何实现的?

流量控制窗口 (Flow Control Window)

每个发送端会存在一个叫做流量窗口的东西,里面简单保存了整数值,标识发送端允许传输的,当流量窗口没有可用空间时,可以发送带有END_STREAM 的帧标记。

但是发送端的流量窗口没有多大意义,这有点类似把井水装到一个桶里面,主要的限制不是井里有多少水,而是看桶可以装多少水,所以为了确保网络正常传输,发送端传输长度不能超过超出接收端广播的流量控制窗口大小的可用空间长度。

WINDOW_UPDATE 帧

前面多次提到的 WINDOW_UPDATE帧有什么用?主要作用是给接收端告知自己的接收能力,如果提供这个帧,那么发送方不管有多强能力,都需要按照提供的长度限制进行数据发送。

WINDOW_UPDATE帧要么单独作用于 stream,要么单独作用于 connection(streamid 为 0 时,表示作用于 connection,无接收能力)

我们根据流量窗口和WINDOW_UPDATE帧了解基本算法流程如下:

  1. 发送方提供流量窗口初始值,初始值是SETTING 帧,这个帧的参数设置十分关键,比如 SETTINGS_INITIAL_WINDOW_SIZE表示窗口初始大小,默认初始值和最大值均为 65535
SETTINGS_INITIAL_WINDOW_SIZE (0x4): Indicates the sender's initial
  window size (in octets) for stream-level flow control.  The
  initial value is 2^16-1 (65,535) octets.
  1. 发送端每发送一个DATA帧,就把window流量窗口的值递减,递减量为这个帧的大小,如果流量窗口大小小于DATA帧,则必须对于流进行拆分,直到小于windows流量窗口为止,而流量窗口递减到0的时候,不能发送任何帧。
  2. 接收端通过 WINDOW_UPDATE 帧,告知发送方自己的负载能力。

SETTING 帧

本节最后我们再补充一下SETTING 帧的选项含义:

  • SETTINGS_HEADER_TABLE_SIZE:HPACK(header压缩算法) header表的最大长度,默认值 4096
  • SETTINGS_ENABLE_PUSH:客户端发向服务端的配置,若设置为 true,客户端将允许服务端推送响应,默认值 true
  • SETTINGS_MAX_CONCURRENT_STREAMS:同时打开的 stream 最大数量,通常意味着同一时刻能够同时响应的请求数量,默认无限
  • SETTINGS_INITIAL_WINDOW_SIZE:流控的初始窗口大小,默认值 65535
  • SETTINGS_MAX_FRAME_SIZE:对端能够接收帧的最大长度,默认值16384
  • SETTINGS_MAX_HEADER_LIST_SIZE:对端能够接收的 header 列表最大长度,默认不限制

题外话:httpcore5 的 BUG

httpcore5 过去的版本存在流控的BUG,但是这个问题很快被发现并且被修复。

因为涉及流控触发BUG的概率还是挺大的,也是比较严重的BUG,BUG修复可以看这个 COMMIT,想看具体分析可以看参考文章的第一篇。下面为个人阅读文章之后分析思路。

我们以 URL https://www.sysgeek.cn/ 为例,通过在本地做代码 debug 发现,最终抛异常的原因在于接收到 WINDOW_UPDATE 帧后,更新后窗口大小值大于 2^32 - 1导致抛异常:

首先根据 commit log,修复者自己也进行了说明。

The connection flow-control window can only be changed using
> WINDOW_UPDATE frames.

我们接着对照 RFC 的文档定义:

意思是说connection 窗口大小仅在接收到 WINDOW_UPDATE 后才可能修改这个规则被违背的。

把代码扒出来看一下改了什么:

private void applyRemoteSettings(final H2Config config) throws H2ConnectionException {
    
    remoteConfig = config;
    
    hPackEncoder.setMaxTableSize(remoteConfig.getHeaderTableSize());
    
    final int delta = remoteConfig.getInitialWindowSize() - initOutputWinSize;
    
    initOutputWinSize = remoteConfig.getInitialWindowSize();
    
      
    
    if (delta != 0) {
        // 关键BUG修复
        updateOutputWindow(0, connOutputWindow, delta);
    
    if (!streamMap.isEmpty()) {
    
        for (final Iterator<Map.Entry<Integer, H2Stream>> it = streamMap.entrySet().iterator(); it.hasNext(); ) {
            
            final Map.Entry<Integer, H2Stream> entry = it.next();
            
            final H2Stream stream = entry.getValue();
            
            try {
            
            updateOutputWindow(stream.getId(), stream.getOutputWindow(), delta);
            
            } catch (final ArithmeticException ex) {
            
            throw new H2ConnectionException(H2Error.FLOW_CONTROL_ERROR, ex.getMessage());
        
        }
    
    }
    
    }
    
    }

}

delta 是对方告知的 WINDOW_UPDATE 大小,问题出在接收 SETTINGS 指令之后,初始化的窗口大小被修改了,原本的6555被改成更大的值,这个值超过了流量窗口的默认值和最大值的上限,但是流量窗口的大小必须是WINDOW_UPDATE帧传输之后才允许更改,发送方擅自修改并且发送了超过接收方能力的流量,被检查出异常流量而在代码中抛出异常。

这个很好理解,就好像井水不管桶有多大,就一个劲的往里面灌水,这肯定是有问题的。

服务器推送

概括:

  • 管道化改良
  • 偶数帧数为起始
  • 依靠PUSH_PROMISE帧传输头部信息
  • 通过帧中的 Promised Stream ID 字段告知偶数号

服务器推送的RFC定义:RFC 7540: Hypertext Transfer Protocol Version 2 (HTTP/2) (rfc-editor.org)

服务器推送是为了弥补HTTP这个半双工协议的短板,虽然HTTP1.X 尝试使用管道流实现服务端推送,但是管道流存在各种缺陷所以HTTP1.X并没有实现服务端推送的功能。

注意在上面提到的二进制帧数据传输中中,客户端发起的请求必须使用的是奇数号 Stream,服务器主动的推送请求使用的是偶数号 Stream,所以如果是服务端推送通常是从偶数开始。

服务端推送资源需要依靠PUSH_PROMISE帧传输头部信息,并且需要通过帧中的 Promised Stream ID 字段告知客户端自己要发送的偶数号。

需要服务端推送存在诸多限制,从整体上看服务端推送的话语权基本是在客户端这边,下面简单列举几点:

  • 客户端可以设置 SETTINGS_MAX_CONCURRENT_STREAMS=0 或者重置PUSH_PROMISE拒绝服务端推送。
  • 客户端可以通过SETTINGS_MAX_CONCURRENT_STREAMS设置服务端推送的响应。
  • PUSH_PROMISE帧只能通过服务端发起,使用客户端推送是“不合法“的,服务端有权拒绝。

补充

连接前言

这个连接前言算是比较偏门的点,也常常容易被忽略。如果能看懂下面的内容,那么基本就知道怎么会回事了。

   In HTTP/2, each endpoint is required to send a connection preface as
   a final confirmation of the protocol in use and to establish the
   initial settings for the HTTP/2 connection.  The client and server
   each send a different connection preface.

   The client connection preface starts with a sequence of 24 octets,
   which in hex notation is:

     0x505249202a20485454502f322e300d0a0d0a534d0d0a0d0a

   **That is, the connection preface starts with the string "PRI *
   HTTP/2.0\r\n\r\nSM\r\n\r\n").**  This sequence MUST be followed by a
   SETTINGS frame ([Section 6.5](https://datatracker.ietf.org/doc/html/rfc7540#section-6.5)), which MAY be empty.  The client sends
   the client connection preface immediately upon receipt of a 101
   (Switching Protocols) response (indicating a successful upgrade) or
   as the first application data octets of a TLS connection.  If
   starting an HTTP/2 connection with prior knowledge of server support
   for the protocol, the client connection preface is sent upon
   connection establishment.

连接前言的关键点如下:

  • “连接前言”是标准的 HTTP/1 请求报文,使用纯文本的 ASCII 码格式,请求方法是特别注册的一个关键字“PRI”,全文只有 24 个字节。
  • 如果客户端在建立连接的时候使用 PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n,并且通过 SETTINGS 帧告知服务端自己期望HTTPS2 连接,服务端就知道客户端需要的是TLS的HTTP/2连接。

为什么是这样的规则,以及为什么是传输这样一串奇怪的字符无需纠结,这是HTTP/2标准制定者指定的规矩,所以就不要问“为什么会是这样”了。

其实把这一串咒语拼起来还是有含义的,PRISM,2013年斯诺登的“棱角计划”,这算是在致敬?

流和管道化关系

HTTP/2的流是对于HTTP1.X的管道化的完善以及改进,所以在流中可以看到不少管道化的概念。而HTTP/2 要比管道化更加完善合理,所以管道化的概念在HTTP/2之后就被流取代而消失了。

请求头字段约束

因为HTTP1.X对于头字段写法很随意,所以HTTP/2设置所有的头字段必须首字母小写。

 Just as in HTTP/1.x, header field names are strings of ASCII
   characters that are compared in a case-insensitive fashion.  However,
   header field names MUST be converted to lowercase prior to their
   encoding in HTTP/2

就像在 HTTP/1.x 中一样,标头字段名称是 ASCII 字符串
    以不区分大小写的方式比较的字符。 然而,
    标头字段名称必须在其之前转换为小写
    HTTP/2 中的编码

总结

我们按照重点排序,来从整体上看一下HTTP2的知识点,为此我总结了几个关键字:

重塑:不是指完全重造,而是借用HTTP协议的基本架构,从内部进行重新调整。

兼容:HTTP协议背负巨大的历史包袱,所有的改动如果无法向后兼容,那么就是失败的升级,也不会受到广泛认可。所以HTTP2整体结构沿用HTTP1.X,加入连接前言这种和TLS握手类似的“咒语”完成新协议的启用。

状态:Header压缩的HACK技术加入之后,HTTP似乎不再像是以前那样的无状态协议,它的动态表和静态表都是实际存在的,每个HTTP2的连接都会出现状态维护,所以虽然本身外部实现不需要关注这些细节,实际上HTTP2 内部确实加了状态这个概念。

贴合TCP:HTTP2的很多细节不难看出是为了更好的和TCP协调,比如二进制数据。

管道化延伸:管道化在HTTP1.X中非常鸡肋,而HTTP2则把管道化的理念改进为流的概念进行数据传输,并且依靠流实现并发传输。

写到最后

来来回回改了很多次,自认为把HTTP2主要的知识点普及了,更多细节需要深入RFC文档,不过不是专攻网络编程方向的个人也就点到为止了。

参考文章


Xander
195 声望50 粉丝