HTTP 2.0 为什么这么设计

HTTP 1.0 是 1996 年发布的,奠定了 web 的基础。时隔三年,1999 年又发布了 HTTP 1.1,对功能上做了扩充。之后又时隔十六年,2015 年发布了 HTTP 2.0。

同学们肯定会觉得,隔了这么长时间,而且还从版本号还从 1 到了 2,那肯定有很多的新功能。其实不是的,HTTP 2.0 没有没有功能上的新增,只是优化了性能。

为什么要这么大的版本升级来优化性能,HTTP 1.1 的性能很差么?

那我们就来看下 HTTP 1.1 有什么问题:

HTTP 1.1 的问题

我们知道,HTTP 的下层协议是 TCP,需要经历三次握手才能建立连接。而 HTTP 1.0 的时候一次请求和响应结束就会断开链接,这样下次请求又要重新三次握手来建立连接。

为了减少这种建立 TCP 链接的消耗,HTTP 1.1 支持了 keep-alive,只要请求或响应头带上 Connection: keep-alive,就可以告诉对方先不要断开链接,我之后还要用这个链接发消息。当需要断开的时候,再指定 Connection: close 的 header。

这样就可以用同一个 TCP 链接进行多次 HTTP 请求响应了:

但这样虽然减少了链接的建立,在性能上却有问题,下次请求得等上一个请求返回响应才能发出。

这个问题有个名字,叫做队头阻塞,很容易理解,因为多个请求要排队嘛,队前面的卡住了,那后面的也就执行不了了。

怎么解决这个问题呢?

HTTP 1.1 提出了管道的概念,就是多个请求可以并行发送,返回响应后再依次处理。

也就是这样:

其实这样能部分解决问题,但是返回的响应依然要依次处理,解决不了队头阻塞的问题。

所以说管道化是比较鸡肋的一个功能,现在绝大多数浏览器都默认关闭了,甚至都不支持。

那还能怎么解决这个队头阻塞的问题呢?

开多个队不就行了。

浏览器一般会同一个域名建立 6-8 个 TCP 链接,也就是 6-8 个队,如果一个队发生队头阻塞了,那就放到其他的队里。

这样就缓解了队头阻塞问题。

我们写的网页想尽快的打开就要利用这一点,比如把静态资源部署在不同的域名下。这样每个域名都能并发 6-8 个下载请求,网页打开的速度自然就会快很多。

这种优化手段叫做“域名分片”,CDN 一般都支持这个。

除了队头阻塞的问题,HTTP 1.1 还有没有别的问题?

有,比如 header 部分太大了。

不知道大家有没有感觉,就算你内容只传输几个字符,也得带上一大堆 header:

而且这些 header 还都是文本的,这样占据的空间就格外的大。

比如,如果是二进制,表示 true 和 false 直接 1 位就行了,而文本的那就得经过编码,“true” 就占了 4 个字节,也就是 32 位。那就是 32 倍的差距呀!

所以呢,HTTP 1.1 的时候,我们就要尽量避免一些小请求,因为就算请求的内容很少,也会带上一大段 header。特别是有 cookie 的情况,问题格外明显。

因此,我们的网页就要做打包,也就是需要打包工具把模块合并成多个 chunk 来加载。需要把小图片合并成大图片,通过调整 background:position 来使用。需要把一些 css、图片等内联。而且静态资源的域名也要禁止携带 cookie。

这些都是为了减少请求次数来达到提高加载性能的目的。

而且 HTTP 的底层是 TCP,其实是可以双向传输数据的,现在却只能通过请求---响应这种一问一答的方式,并没有充分利用起 TCP 的能力。

聊了这么多,不知道大家是否有优化它的冲动了。

也就是因为这些问题,HTTP 2.0 出现了,做了很多性能优化,基本解决了上面那些问题。

那 HTTP2 都做了哪些优化呢?

HTTP 2.0 的优化

先不着急看 HTTP 2.0 是怎么优化的,就上面那些问题来说,如果让我们解决,我们会怎么解决?

比如队头阻塞的问题,也就是第二个响应要等第一个响应处理完之后才能处理。怎么解决?

这个很容易解决呀,每个请求、响应都加上一个 ID,然后每个响应和通过 ID 来找到它对应的请求。各回各家,自然就不用阻塞的等待了。

再比如说 header 过大这个问题,怎么解决?

文本传输太占空间,换成二进制的是不是会好很多。

还有,每次传输都有很多相同的 header,能不能建立一张表,传的时候只传输下标就行了。

还有,body 可以压缩,那 header 是不是可以压缩。

这样处理之后,应该会好很多。

那没有充分利用 TCP 的能力,只支持请求--响应的方式呢?

那就支持服务端主动推送呀,但是客户端可以选择接收或者不接收。

上面是我们对这些问题的解决方案的思考,我们再来看看 HTTP2 是怎么解决这些问题的:

HTTP2 确实是通过 ID 把请求和响应关联起来了,它把这个概念叫做流 stream。

而且我们之前说了 header 需要单独的优化嘛,所以把 header 和 body 部分分开来传送,叫做不同的帧 frame。

每个帧都是这样的格式:

payload 部分是传输的内容这没啥可说的。

header 部分最开始是长度,然后是这个帧的类型,有这样几种类型:

  • SETTINGS 帧:配置信息,比如最大帧 size,是否支持 server push 等。
  • HEADERS 帧:请求或响应的 header
  • DATA 帧:请求或响应的 body
  • CONTINUATION 帧:一个帧不够装的时候,可以分帧,用这个可以引用上一个帧。
  • PUSH_PROMISE 帧:服务端推送数据的帧
  • END_STREAM 帧:表示流传输结束
  • RST_STREAM 帧,用来终止当前流

这几种帧里面 HEADERS 和 DATA 帧没啥可说的。

SETTING 帧是配置信息,先告诉对方我这里支持什么,帧大小设置为多大等。

帧大小是有个上限的,如果帧太大了,可以分成多个,这时候帧类型就是 CONTINUATION(继续)。也很容易理解。

HTTP2 确实是支持服务端推送的,这时候帧类型也是单独的,叫做 PUSH_PROMISE。

流是用来传输请求响应或者服务端推送的,那传输完毕的时候就可以发送 END_STREAM 帧来表示传输完了,然后再传输 RST_STREAM 来结束当前流。

帧的类型讲完了,我们继续往后看,后面还有个 flags 标志位,这个在不同的帧类型里会放不同的内容:

比如 header 帧会在 flags 中设置优先级,这样高优先级的流就可以更早的被处理。

HTTP 1.1 的时候都是排队处理的,没什么优先级可言,而 HTTP 2.0 通过流的方式实现了请求的并发,那自然就可以控制优先级了。

后面还有个 R,这个现在还没啥用,是一个保留的位。

再后面的流标识符就是 stream id 了,关联同一个流的多个帧用的。

帧的格式讲完了,大家是不是有点晕晕的。确实,帧还是有很多种的。这些帧之间发送顺序也不同,不同的帧会在不同状态下发送,也会改变流的状态。

我们来看下流的状态机,也就是流收到什么帧会进入什么状态,并且在什么状态下会发送什么帧:

(看不明白可以先往后看)

刚开始,流是 idle 状态,也就是空闲。

收到或发送 HEADERS 帧以后会进入 open 状态。

oepn 状态下可以发送或接收多次 DATA 帧。

之后发送或接收 END_STREAM 帧进入 half_closed 状态。

half_closed 状态下收到或者发送 RST_STREAM 帧就关闭流。

这个流程很容易理解,就是先发送 HEADER,再发送 DATA,之后告诉对方结束,也就是 END_STREAM,然后关闭 RST_STREAM。

但是 HTTP2 还可以服务端推送呀,所以还有另一条状态转换流程。

流刚开始是 idle 状态。

接收到 PUSH_PROMISE 帧,也就是服务端推送过来的数据,变为 reserved 状态。

reserved 状态可以再发送或接收 header,之后进入 half_closed 状态。

后面的流程是一样的,也是 END_STREAM 和 RST_STREAM。

这个流程是 HTTP2 特有的,也就是先推送数据,再发送 headers,然后结束流。

这就是 http2 发送一次请求、响应,或者一次服务端推送的流程,都是封装在一个个流里面的。

流和流之间可以并发,还可以设置优先级,这样自然就没有了队头阻塞的问题,这个特性叫做多路复用。也就是复用同一个链接,建立起多条通路(流)的意思。

而且传输的 header 帧也是经过处理的,就像我们前面说的,会用二进制的方式表示,用做压缩,而且压缩算法是专门设计的,叫做 HPACK:

两端会维护一个索引表,通过下标来标识 header,这样传输量就少了不少:

首先,header 里其实不止有 header,还有一行 GET xxx/xxx 的请求行,和 200 xxx 的响应行,为了统一处理,就换成了 :host :path 等 header 来表示。

这样发送的时候只需要发送下标就行:

比如 :method: get 就只需要发送个 2: get。

这个编码也是根据频率高低来设置的,频率高的用小编码,这种方式叫做哈夫曼编码。

这样就实现了 header 的压缩。

至此, HTTP2.0 的主要特性就讲完了,也就是多路复用服务端推送头部压缩二进制传输

最主要的特性是多路复用,也就是流和帧,流在什么状态下发送什么帧。其他的特性是围绕这个来设计的。

回过头来看一下 HTTP1.1 的问题是否都得到了解决:

队头阻塞:通过流的来标识请求、响应,同一个流的分为多个帧来传输,多个流之间可以并发,不会相互阻塞。

header 太大:通过二进制的形式,加上 HPACK的压缩算法,使得 header 减小了很多。

没有充分利用 TCP 的特性:支持了服务端推送。

这样看来,HTTP2.0 确实解决了 HTTP 1.1 的问题。

看起来,HTTP 2.0 已经很完美了?

其实不是的,虽然 HTTP 层面没有了队头阻塞问题,多个请求响应可以并行处理。但是同一个流的多个帧还是有队头阻塞问题,以为你 TCP 层面会保证顺序处理,丢失了会重传,这就导致了上一个帧没收到的话,下一个帧是处理不了的。

这个问题是 TCP 的可靠传输的特性带来的,所以想彻底解决队头阻塞问题,只能把 HTTP 的底层传输协议换掉了。

这就是 HTTP3 做的事情了,它的传输层协议换成了 UDP。当然,现在 HTTP3 还不是很成熟,我们先重点关注 HTTP2 即可。

总结

1996 年发布 HTTP 1.0,1999 年 HTTP 1.1,2015 年 HTTP 2.0。

1.1 和 2 之间间隔了 16 年,确实改变了很多,但只是性能方面的。

1.1 的问题是第二个请求要等第一个响应之后才能发出,就算用了管道化,多个响应之间依然也会阻塞,这就是“队头阻塞”问题。

而且 header 部分太大了,还是纯文本的,可能比 body 部分传的都多。

针对 1.1 的队头阻塞问题,我们会做域名分片,针对 header 过大的问题,我们会减少请求次数,也就是打包分 chunk、资源内联、雪碧图、静态资源请求禁止 cookie 等优化策略。

HTTP 2.0 解决了 1.1 的这些问题,通过多路复用,也就是请求和响应在一个流里,通过同一个流 id 来关联多个帧的方式来传输数据。多个流可以并发。

我们看了帧的格式,有长度、类型、stream id、falgs 还有 payload 等部分。

帧的类型还是挺多的,有 HEADRS、DATA、SETTINGS、PUSH_PROMISE、END_STREAM、EST_STREAM、等。

这些帧类型之间也不是毫无关联的,流在不同的状态下会发送、接收不同的帧,而且发送、接收不同的帧也会进入不同的状态。

理解 HTTP2.0 的 stream 就要理解这样的一个状态流转流程。

此外,HTTP 2.0 通过单独设计的 HPACK 算法对 header 做了压缩,也支持服务端推送。而且内容是通过二进制传输的,解决了 HTTP 1.1 的问题。

但是 HTTP 2.0 的底层是 TCP,它的可靠传输的特性使得同一个流内的多个帧依然是顺序传输的,依然有队头阻塞问题。也是因为 HTP 3 把底层协议换成 UDP。

虽然还是有一些问题,但 HTTP 2.0 已经基本上把 HTTP 1.1 的各方面性能不好的点都优化到了极致,是很有意义的一次版本升级。