写点什么

深入理解 Web 协议 (三):HTTP 2

发布于: 2021 年 02 月 23 日

本篇将详细介绍 http2 协议的方方面面,知识点如下:

  • HTTP 2 连接的建立

  • HTTP 2 中帧和流的关系

  • HTTP 2 中流量节省的奥秘:HPACK 算法

  • HTTP 2 协议中 Server Push 的能力

  • HTTP 2 为什么要实现流量控制?

  • HTTP 2 协议遇到的问题

一、HTTP 2 连接的建立


和许多人的固有印象不同的是 HTTP 2 协议本身并没有规定必须建立在 TLS/SSL 之上,其实用普通的 TCP 连接也可以完成 HTTP 2 连接的建立。只不过现在为了安全市面上所有的浏览器都仅默认支持基于 TLS/SSL 的 HTTP 2 协议。简单来说我们可以把构建在 TCP 连接之上的 HTTP 2 协议称之为 H2C,而构建在 TLS/SSL 协议之上的就可以理解为是 H2 了。


输入命令:

tcpdump -i eth0 port 80 and host nghttp2.org -w h2c.pcap &
复制代码


然后用 curl 访问基于 TCP 连接,也就是 port 80 端口的 HTTP 2 站点(这里是没办法用浏览器访问的,因为浏览器不允许)

curl http://nghttp2.org --http2 -v
复制代码


其实看日志也可以大致了解一下这个连接建立的过程:


我们将 TCPDump 出来的 pcap 文件拷贝到本地,然后用 Wireshark 打开以后还原一下整个 HTTP 2 连接建立的报文:


首先是 HTTP 1.1 升级到 HTTP 2 协议



然后客户端还需要发送一个“魔法帧”:



最后还需要发送一个设置帧:

之后,我们来看一下,基于 TLS 的 HTTP 2 连接是如何建立的,考虑到加密等因素,我们需要提前做一些准备工作。可以在 Chrome 中下载这个插件。


然后打开任意一个网页只要看到这个闪电的图标为蓝色就代表这个站点支持 HTTP 2;否则不支持。如下图:



将 Chrome 浏览器的 TLS/SSL 之类的信息 输出到一个日志文件中,需要额外配置系统变量,如图所示:



然后将我们的 Wireshark 中 SSL 相关的设置也进行配置。



这样浏览器在进行 TLS 协议交互的时候,相关的加密解密信息都会写入到这个 log 文件中,我们的 Wireshark 就会用这个 log 文件中的信息来解密出我们的 TLS 报文。


有了上述的基础,我们就可以着手分析基于 TLS 连接的 HTTP 2 协议了。比如我们访问 tmall 的站点 https://www.tmall.com/ 然后打开我们的 Wireshark。



看一下标注的地方可以看出来,是 TLS 连接建立以后 然后继续发送魔法帧和设置帧,才代表 HTTP 2 的连接真正建立完毕。我们看一下 TLS 报文的 client hello 这个信息:



其中这个 alpn 协议的信息 就代表客户端可以接受哪两种协议。server hello 这个消息 就明确的告知 我们要使用 H2 协议。



这也是 HTTP 2 相比 spdy 协议最重要的一个优点:spdy 协议强依赖 TLS/SSL,服务器没有任何选择。而 HTTP 2 协议则会在客户端发起请求的时候携带 alpn 这个扩展,也就是说客户端发请求的时候会告诉服务端我支持哪些协议。从而可以让服务端来选择,我是否需要走 TLS/SSL。

二、HTTP 2 中帧和流的关系



简单来说,HTTP 2 就是在应用层上模拟了一下传输层 TCP 中“流”的概念,从而解决了 HTTP 1.x 协议中的队头拥塞的问题,在 1.x 协议中,HTTP 协议是一个个消息组成的,同一条 TCP 连接上,前面一个消息的响应没有回来,后续的消息是不可以发送的。在 HTTP 2 中,取消了这个限制,将所谓的“消息”定义成“流”,流跟流之间的顺序可以是错乱的,但是流里面的帧的顺序是不可以错乱的。如图:



也就是说在同一条 TCP 连接上,可以同时存在多个 stream 流,这些流 由一个个 frame 帧组成,流跟流之间没有顺序关系,但是每一个流内部的帧是有先后顺序的。注意看这张图中的 135 等数字其实就是 stream id,WebSocket 中虽然也有帧的概念,但是因为 WebSocket 中没有 stream id,所以 Websocket 是没有多路复用的功能的。HTTP 2 因为有了 stream id 所以就有了多路复用的能力。可以在一条 TCP 连接上存在 n 个流,就意味着服务端可以同时并发处理 n 个请求然后同时将这些请求都响应到同一条 TCP 连接上。当然这种在同一条 TCP 连接上传送 n 个 stream 的能力也是有限制的,在 HTTP 2 连接建立的时候,setting 帧 中会包含这个设置信息。例如下图 在访问天猫的站点的时候,浏览器携带的 setting 帧的消息里面就标识了 浏览器这个 HTTP 2 的客户端可以支持并发最大的流为 1000。



当天猫服务器返回这个 setting 帧的响应的时候,就告知了浏览器,我能支持的最大并发 stream 为 128。



同时 我们也要知道,HTTP 2 协议中 流 id 为单数就代表是客户端发起的流,偶数代表服务端主动发起的流(可以理解为服务端主动推送)。


三、 HTTP 2 中流量节省的奥秘:HPACK 算法


相比与 HTTP 1.x 协议,HTTP 2 协议还在流量消耗上做了极大改进。主要分为三块:静态字典,动态字典,和哈夫曼编码. 可以安装如下工具探测一下 对流量节省的作用:

apt-get install nghttp2-client
复制代码


然后可以探测一下一些已经开启 HTTP 2 的站点,基本上节约的流量都是百分之 25 起,如果频繁访问的话 会更多:



对于流量消耗来说,其实 HTTP 2 相比 HTTP 1.x 协议最大的改进就是在 HTTP 2 中我们可以对 HTTP 的头部进行压缩了,而在以往 HTTP 1.x 协议中,gzip 等是无法对 header 进行压缩的,尤其对于绝大多数的请求来说,其实 header 的占比是最大的。


我们首先来了解一下静态字典,如图所示:



这个其实不难理解,无非就是将我们那些常用的 HTTP 头部,用固定的数字来表示,那当然可以起到节约流量的作用.这里要注意的是 有些 value 情况比较复杂的 header,他们的 value 是没有做静态字典的。比如 cache-control 这个缓存控制字段,这后面的值因为太多了就无法用静态字典来解决,而只能靠霍夫曼编码。下图可以表示 HPACK 这种压缩算法 起到的节约流量的作用:


例如,我们看下 62 这个 头部,user-agent 代指浏览器,一般我们请求的时候这个头部信息都是不会变的,所以最终经过 hpack 算法优化以后 后续再传输的时候 就只需要传输 62 这个数字就可以代表其含义了。


又例如下图:


也是一样的,多个请求连续发送的时候,多数情况下变化的只有 path,其余头部信息是不变的,那么基于此场景,最终传输的时候也就只有 path 这一个头部信息了。


最后我们来看看 hpack 算法中的核心:哈夫曼编码。哈弗曼编码核心思想就是出现频率较高的用较短的编码,出现频率较低的用较长的编码(HTTP 2 协议的前身 spdy 协议采用的是动态的哈夫曼编码,而 HTTP 2 协议则选择了静态的哈夫曼编码)。



来看几个例子:



例如这个 header 帧,注意看这个 method:get 的头部信息。因为 method:get 在静态索引表中的索引值为 2.对于这种 key 和 value 都在索引表中的值,我们用一个字节也就是 8 个 bit 来标识,其中第一个 bit 固定为 1,剩下 7 位就用来表示索引表中的值,这里 method:get 索引表的值为 2,所以这个值就是 1000 0010,换算成 16 进制就是 0x82.



再看一组,key 在索引表中,value 不在索引表中的 header 例子。



对于 key 在索引表中,value 不在索引表中的情况,固定是 01 开头的字节,后面 6 个 bit(111010 换算成十进制就是 58)就是静态索引的值, user-agent 在索引中 index 的值是 58 再加上 01 开头的 2 个 bit 换算成二进制就是 01111010,16 进制就 7a 了。然后接着看第二个字节,0xd4,0xd4 换算成二进制就是 1 101 0100,其中第一个 bit 代表后面采用的是哈夫曼编码,后面的 7 个 bit 这个 key-value 的 value 需要几个字节来表示,这里是 101 0100 换算成 10 进制就是 84,也就是说这个 user-agent 后面的 value 需要 84 个字节来表示,我们数一下图中的字节数 16*5+第一排 d4 后面的 4 个字节,刚好等于 84 个字节。


最后再看一个 key 和 value 都不在索引表中的例子。



四、HTTP 2 协议中 Server Push 的能力


前文我们提到过,H2 相比 H1.x 协议提升最大的就是 H2 可以在单条 TCP 连接的基础上 同时传输 n 个 stream。从而避免 H1.x 协议中队头拥塞的问题。实际上在大部分前端的页面中,我们还可以使用 H2 协议的 Server Push 能力 进一步提高页面的加载速度。例如通常我们用浏览器访问一个 Html 页面时,只有当 html 页面返回到浏览器,浏览器内核解析到这个 Html 页面中有 CSS 或者 JS 之类的资源时,浏览器才会发送对应的 CSS 或者 JS 请求,当 CSS 和 JS 回来以后 浏览器才会进一步渲染,这样的流程通常会导致浏览器处于一段时间内的白屏从而降低用户体验。有了 H2 协议以后,当浏览器访问一个 Html 页面到服务器时,服务器就可以主动推送相应的 CSS 和 JS 的内容到浏览器,这样就可以省略浏览器之后重新发送 CSS 和 JS 请求的步骤。


有些人对 Server Push 存在一定程度上的误解,认为这种技术能够让服务器向浏览器发送“通知”,甚至将其与 WebSocket 进行比较。事实并非如此,Server Push 只是省去了浏览器发送请求的过程。只有当“如果不推送这个资源,浏览器就会请求这个资源”的时候,浏览器才会使用推送过来的内容。否则如果浏览器本身就不会请求某个资源,那么推送这个资源只会白白消耗带宽。当然如果与服务器通信的是客户端而不是浏览器,那么 HTTP 2 协议自然就可以完成 push 推送的功能了。所以都使用 HTTP 2 协议的情况下,与服务器通信的是客户端还是浏览器 在功能上还是有一定区别的。


下面为了演示这个过程,我们写一段代码。考虑到浏览器访问 HTTP 2 站点必须要建立在 TLS 连接之上,我们首先要生成对应的证书和秘钥。



然后开启 HTTP 2,在接收到 Html 请求的时候主动 push Html 中引用的 CSS 文件。


package main
import ( "fmt" "net/http"
"github.com/labstack/echo")

func main() {
e := echo.New() e.Static("/", "html") //主要用来验证是否成功开启http2环境 e.GET("/request", func(c echo.Context) error { req := c.Request() format := ` <code> Protocol: %s<br> Host: %s<br> Remote Address: %s<br> Method: %s<br> Path: %s<br> </code> ` return c.HTML(http.StatusOK, fmt.Sprintf(format, req.Proto, req.Host, req.RemoteAddr, req.Method, req.URL.Path)) })
//在收到html请求的时候 同时主动push html中引用的css文件,不需要等待浏览器发起请求 e.GET("/h2.html", func(c echo.Context) (err error) { pusher, ok := c.Response().Writer.(http.Pusher) if ok { if err = pusher.Push("/app.css", nil); err != nil { println("error push") return }
}
return c.File("html/h2.html") }) // e.StartTLS(":1323", "cert.pem", "key.pem")}
复制代码


然后 Chrome 访问这个网页的时候,看下 NetWork 面板:



可以看出来这个 CSS 文件 就是我们主动 push 过来的。再看下 Wireshark。



可以看出来 stream id 为 13 的 是客户端发起的请求,因为 id 是单数的,在这个 stream 中,还存在着 push_promise 帧,这个帧就是由服务器发送给浏览器的,看一下他的具体内容。



可以看出来这个帧就是用来告诉浏览器,我主动 push 给你的是哪个资源,这个资源的 stream-id 是 6.图中我们也看到了有一个 stream-id 为 6 的  data 在传输了,这个就是服务器主动 push 出来的 CSS 文件。到这里,一次完整的 Server Push 就交互完毕了。


但在实际线上应用 Server Push 的时候 挑战远远比我们这个 demo 中来的复杂。首先就是大部分 cdn 供应商(除非自建 cdn)对 Server Push 的支持比较有限。我们不可能让每一次资源的请求都直接打到我们的源服务器上,大部分静态资源都是前置在 CDN 中。其次,对于静态资源来说,我们还要考虑缓存的影响,如果是浏览器自己发出去的静态资源请求,浏览器是可以根据缓存状态来决定这个资源我是否真的需要去请求,而 Server Push 是服务器主动发起的,服务器多数情况下是不知道这个资源的缓存是否过期的。当然可以在浏览器接收到 push Promise 帧以后,查询自身的缓存状态然后发起 RST_STREAM 帧,告知服务器这个资源我有缓存,不需要继续发送了,但是你没办法保证这个 RST_STREAM 在到达服务器的时候,服务器主动 push 出去的 data 帧还没发出去。所以还是会存在一定的带宽浪费的现象。总体来说,Server Push 还是一个提高前端用户体验相当有效的手段,使用了 Server Push 以后 浏览器的性能指标 idle 指标 一般可以提高 3-5 倍(毕竟浏览器不用等待解析 Html 以后再去请求 CSS 和 JS 了)。


五、HTTP 2 为什么要实现流量控制?


很多人不理解,为什么 TCP 传输层已经实现了流量控制,我们的应用层 HTTP 2 还要实现流量控制。下面我们看一张图。



在 HTTP 2 协议中,因为我们支持多路复用,也就是说我们可以同时发送多个 stream 在同一条 TCP 连接中,上图中,每一种颜色就代表一个 stream,可以看到 我们总共有 4 种 stream,每一个 stream 又有 n 个 frame,这个就很危险了,假设在应用层中我们使用了多路复用,就会出现 n 个 frame 同时不停的发送到目标服务器中,此时流量达到顶峰就会触发 TCP 的拥塞控制,从而将后续的 frame 全部阻塞住,造成服务器响应过慢了。HTTP 1.x 中因为不支持多路复用自然就不存在这个问题。且我们之前多次提到过,一个请求从客户端到达服务器端要经过很多的代理服务器,这些代理服务器内存大小以及网络情况都可能不一样,所以在应用层上做一次流量控制尽量避开触发 TCP 的流控是十分有必要的。在 HTTP 2 协议中的流量控制策略,遵循以下几个原则:


  1. 客户端和服务端都有流量控制能力。

  2. 发送端和接收端可以独立设置流控能力。

  3. 只有 data 帧才需要流控,其他 header 帧或者 push promise 帧等都不需要。

  4. 流控能力只针对 TCP 连接的两端,中间即使有代理服务器,也不会透传到源服务器上。


访问知乎的站点看一下抓包。



这些标识 window_update 帧的 就是所谓的流控帧了。我们随意点开一个看一下,就可以看到这个流量控制帧告诉我们的帧大小。



聪明如你一定能想到,既然 HTTP 2 都能做到流控了,那一定也可以来做优先级。比方说在 HTTP 1.x 协议中,我们访问一个 Html 页面,里面会有 JS 和 CSS 还有图片等资源,我们同时发送这些请求,但是这些请求并没有优先级的概念,谁先出去谁先回来都是未知的(因为你也不知道这些 CSS 和 JS 请求是不是在同一条 TCP 连接上,既然是分散在不同的 TCP 中,那么哪个快哪个慢是不确定的),但是从用户体验的角度来说,肯定 CSS 的优先级最高,然后是 JS,最后才是图片,这样就可以大大缩小浏览器白屏的时间。在 HTTP 2 中 实现了这样的能力。比如我们访问 sina 的站点,然后抓包就可以看到:


可以看下这个 CSS 帧的的优先级:



JS 的优先级



最后是 gif 图片的优先级 ,可以看出来这个优先级是最低的。



有了 weight 这个关键字来标识优先级,服务器就知道哪些请求需要优先被响应优先被发送 response,哪些请求可以后一点被发送。这样浏览器在整体上提供给用户的体验就会变的更好。

六、HTTP 2 协议遇到的问题


基于 TCP 或者 TCP+TLS 的 HTTP 2 协议 还是遇到了很多问题,比如:握手时间过长问题,如果是基于 TCP 的 HTTP 2 协议,那么至少要三次握手,如果是 TCP+TLS 的 HTTP 2 协议,除了 TCP 的握手还要经历 TLS 的多次握手(TLS1.3 已经可以做到只有 1 次握手)。每一次握手都需要发送一个报文然后接收到这个报文的 ack 才可以进行下一次握手,在弱网环境下可以想象的到这个连接建立的效率是极低的。此外,TCP 协议天生的队头拥塞 问题也一直在困扰着 HTTP 21.x 协议和 HTTP 2 协议。我们看一下谷歌 spdy 的宣传图,可以更加精准的理解这个拥塞的本质:


图一很好理解,我们多路复用支持下同时发了 3 个 stream,然后经过 TCP/IP 协议 发送到服务器端,然后 TCP 协议把这些数据包再传给我们的应用层,注意这里有个条件是,发送包的顺序要和接收包的顺序一致。上图中可以看到那些方块的图的顺序是一致的,但是如果碰到下图中的情况,比如说这些数据包恰好第一个红色的数据包传丢了,那么后续的数据包即使已经到了服务器的机器里,也无法立刻将数据传递给我们的应用层协议,因为 TCP 协议规定好了接收的顺序要和发送的顺序保持一致,既然红色的数据包丢失了,那么后续的数据包就只能阻塞在服务器里,一直等到红色的数据包经过重新发送以后成功到达服务器了,再将这些数据包传递给应用层协议。


TCP 协议除了有上述的一些缺陷以外,还有一个问题就是 TCP 协议的实现者是在操作系统层面,我们任何语言,包括 Java,C,C++,Go 等等 对外暴露的所谓 Socket 编程接口 最终实现者其实都是操作系统自己。要让操作系统自己升级 TCP 协议的实现是非常非常困难的,况且整个互联网中那么多设备想要整体实现 TCP 协议的升级是一件不现实的事情(IPV6 协议升级的过慢也有这方面的原因)。基于上述问题,谷歌就基于 udp 协议封装了一层 quic 协议(其实很多基于 udp 协议的应用层协议,都是在应用层上部分实现了 TCP 协议的若干功能),来替代 HTTP 21.x-HTTP 2 中的 TCP 协议。


我们打开 Chrome 中的 quic 协议开关:



然后访问一下 youtube(国内的 b 站其实也支持)。



可以看出来已经支持 quic 协议了。为什么这个选项在 Chrome 浏览器中默认是关闭的,其实也很好理解,这个 quic 协议实际上是谷歌自己搞出来的,还没有被正式纳入到 HTTP 3 协议中,一切都还在草案中。所以这个选项默认是关闭的。看下 quic 协议相比于原来的 TCP 协议主要做了哪些改进?其实就是将原来队列传输报文改成了无需队列传输,那自然也就不存在队头拥塞的问题了。



此外在 HTTP 3 中还提供了 变更端口号或者 ip 地址也可以复用之前连接的能力,个人理解这个协议支持的特性可能更多是为了物联网考虑的。物联网中很多设备的 ip 都可能是一直变化的。能复用之前的连接将会大大提高网络传输的效率。这样就可以避免目前存在的断网以后重新连接到网络需要至少经过 1-3 个 rtt 才可以继续传输数据的弊端。



最后要提一下,在极端弱网环境中,HTTP 2 的表现有可能不如 HTTP 1.x,因为 HTTP 2 下面只有一条 TCP 连接,弱网下,如果丢包率极高,那么会不断的触发 TCP 层面的超时重传,造成 TCP 报文的积压,迟迟无法将报文传递给上面的应用层,但是 HTTP 1.x 中,因为可以使用多条 TCP 连接,所以在一定程度上,报文积压的情况不会像 HTTP 2 那么严重,这也是我认为的 HTTP 2 协议唯一不如 HTTP 1.x 的地方,当然这个锅是 TCP 的,并不是 HTTP 2 本身的。


作者:vivo 互联网-WuYue

发布于: 2021 年 02 月 23 日阅读数: 85
用户头像

官方公众号:vivo互联网技术,ID:vivoVMIC 2020.07.10 加入

分享 vivo 互联网技术干货与沙龙活动,推荐最新行业动态与热门会议。

评论

发布
暂无评论
深入理解 Web 协议(三):HTTP 2