HTTP 1.0

HTTP 1.0 设计了一个 Keep-Alive 机制来实现 TCP 连接的复用。具体来说,就是客户端在 HTTP 请求的头部加上一个字段 Connection:Keep-Alive。服务器收到带有这样字段的请求,在处理完请求之后不会关闭连接,同时在 HTTP 的 Response 里面也会加上该字段,然后等待客户端在该连接上发送下一个请求。

当然,这会给服务器带来一个问题:连接数有限。如果每个连接都不关闭的话,一段时间之后,服务器的连接数就耗光了。因此,服务器会有一个 Keep-Alive timeout 参数,过一段时间之后,如果该连接上没有新的请求进来,则连接就会关闭。

连接复用之后又产生了一个新问题:以前一个连接就只发送一个请求,返回一个响应,服务器处理完毕,把连接关闭,这个时候客户端就知道连接的请求处理结束了。但现在,即使一个请求处理完了,连接也不关闭,那么客户端怎么知道连接处理结束了呢?或者说,客户端怎么知道接收回来的数据包是完整的呢?

答案是在 HTTP Response 的头部,返回了一个 Content-Length:xxx 的字段,这个字段可以告诉客户端 HTTP Response 的 Body 共有多少个字节,客户端接收到这么多个字节之后,就知道响应成功接收完毕。

HTTP 1.1

连接复用优化 & Chunk 机制

HTTP 1.1 之后,就把连接复用变成了一个默认属性。即使不加 Connection:Keep-Alive 属性,服务器也会在请求处理完毕之后不关闭连接。除非在请求头部显示地加上 Connection:Close 属性,服务器才会在请求处理完毕之后主动关闭连接。

Content-Length 有个问题,如果服务器返回的数据是动态语言生成的内容,则要计算 Content-Length,这点对服务器来说比较困难。即使能够计算,也需要服务器在内存中渲染出整个页面,然后计算长度,非常耗时。

为此,在 HTTP 1.1 中引用了 Chunk 机制(Http Streaming)。具体来说,就是在响应的头部加上 Transfer-Encoding:chunked 属性,其目的是告诉客户端,响应的 Body 是分成了一块块的,块与块之间有间隔符,所有块的结尾也有个特殊标记。这样,即使没有 Content-Length 字段,也能方便客户端判断出响应的末尾。

下面显示了一个简单的具有 Chunk 机制的 HTTP 响应,头部没有 Content-Length 字段,而是 Transfer-Encoding:chunked 字段。该响应包含了 4 个 chunk,数字 25(16 进制)表示第一个 chunk 的字节数,1C(16 进制)表示第二个 chunk 的字节数……最后的数字 0 表示整个响应的末尾。

HTTP/1.1 200 OK
Content-Type:text/plain
Transfer-Encoding:chunked
25
This is the data in the first chunk
1C
and this is the second one
3
con
8
sequence
0

HTTP/2 出现之前的性能提升方法

一方面,Pipeline 不能用,在同一个 TCP 连接上面,请求是串行的;另一方面,对于同一个域名,浏览器限制只能开 6 ~ 8 个连接。但一个网页可能要发几十个 HTTP 请求,却只有 6 ~ 8 个连接可用。如何提高并发度,或者说提高网页渲染的性能呢?

  1. Spriting 技术
    这种技术专门针对小图片,假设在一个网页里,要从服务器加载很多的小图片(比如各种小图标),可以在服务器里把小图片拼成一张大图,到了浏览器,再通过 JS 或者 CSS,从大图中截取一小块显示。之前要发送很多个小图片的 HTTP 请求,现在只要发送一个请求就可以了。
  2. 内联(Inlining)
    内联是另外一种针对小图片的技术,它将图片的原始数据嵌入在 CSS 文件里面,方法是 base64 编码形式。
  3. JS 拼接
    把大量小的 JS 文件合并成一个文件并压缩(前端开发工具很容易实现),让浏览器在一个请求里面下载完。
  4. 请求的分片技术
    前面说了,对于一个域名,浏览器会限制只能开 6 ~ 8 个连接。对于网站的开发者来说,要提高页面的加载速度,其中的一个方法就是多做几个域名,这就相当于绕开了浏览器的限制。尤其是现在 CDN 用得非常广泛,网站的静态资源(img,js,css)可能都在 CDN 上面,可以做一批 CDN 的域名,这样浏览器就可以为每个域名都建立 6 ~ 8 个连接,从而提高页面加载的并发度。

断点续传

相比于 HTTP 1.0,HTTP 1.1 还有一个很实用的特性是“断点续传”。当客户端从服务器下载文件时,如果下载到一半连接中断了,再新建连接之后,客户端可以从上次断的地方继续下载。具体实现也很简单,客户端一边下载一边记录下载的数据量大小,一旦连接中断了,重新建立连接之后,在请求的头部加上 Range:first offset-last offset 字段,指定从某个 offset 下载到某个 offset,服务器就可以只返回(first offset,last offset)之间的数据。

这里要补充说明,HTTP 1.1 的这种特性只适用于断点下载。要实现断点上传,就需要自行实现了。

HTTP/2

与 HTTP 1.1 兼容

既然 HTTP 1.1 已经成了当今互联网的主流,因此 HTTP/2 在设计过程中,首先要考虑的就是和 HTTP 1.1 的兼容问题,所谓兼容,意味着:

  • 不能改变 http://https:// 这样的 URL 范式。
  • 不能改变 HTTP Request/Http Response 的报文结构。HTTP 协议是一来一回,一个 Request 对应一个 Response,并且 Reqeust 和 Response 的结构有明确的规定。

如何能做到在不改变 Reqeust/Response 报文结构的情况下,发明出一个新的 HTTP/2 协议呢?这点正是理解 HTTP/2 协议的关键所在。

如图 5-3 所示,HTTP/2 和 HTTP1.1 并不是处于平级的位置,而是处在 HTTP1.1 和 TCP 之间。以前 HTTP 1.1 直接构建在 TCP 之上;现在相当于在 HTTP 1.1 和 TCP 之间多了一个转换层,这个转换层就是 SPDY,也就是现在的 HTTP/2。

二进制分帧

二进制分帧是 HTTP/2 为了解决 HTTP 1.1 的“队头阻塞”问题所设计的核心特性,是图 5-3 中所示转换层所做的核心工作。

HTTP 1.1 本身是明文的字符格式,所谓的二进制分帧,是指在把这个字符格式的报文给 TCP 之前转换成二进制,并且分成多个帧(多个数据块)来发送。
如图 5-4 所示,对于每一个域名,在浏览器和服务器之间,只维护一条 TCP 连接。因为 TCP 是全双工的,即来回两个通道。

这里的请求 1、2、3,响应 1、2、3 是 HTTP 1.1 的明文字符报文。每个请求在发送之前被转换成二进制,然后分成多个帧发送;每个响应在回复之前,也被转成了二进制,然后分成多个帧发送。如图 5-4 中所示,请求 1 被分成了 F1、F2、F3 三个帧;请求 2 被分成了 F4、F5 两个帧;请求 3 被分成了 F6、F7 两个帧;F1 ~ F7 是被乱序地发送出去的,到了服务器端被重新组装。同理,响应 1、2、3 也是同样的过程。

900|600

这里有一个关键问题:请求和响应都是被打散后分成多个帧乱序地发出去的,请求和响应都需要重新组装起来,同时请求和响应还要一一配对。那么组装和配对应如何实现呢?原理也很简单,每个请求和响应实际上组成了一个逻辑上的“流”,为每条流分配一个流 ID,把这个 ID 作为标签,打到每一个帧上。在图 5-4 中,有三条流、三个流 ID,分别打到三条流里面每一个帧上。

有了这个二进制分帧之后,在 TCP 层面,虽然是串行的;但从 HTTP 层面来看,请求就是并发地发出去、并发地接收的,没有了 HTTP 1.1 的 Pipeline 的限制,请求和响应的时序如图 5-5 所示。请求 1、2、3 虽然是按顺序发出去的,但响应 1、2、3 可以乱序地返回。

300

有了二进制分帧,是不是就彻底解决了 Pipeline 的“队头阻塞”问题呢?其实还没有。只是把“队头阻塞”问题从 HTTP Request 粒度细化到了“帧”粒度。
只要用 TCP 协议,就绕不开“队头阻塞”问题,因为 TCP 协议是先进先出的!如图 5-5 所示,如果帧 F3(队头的第一个帧)在网络上被阻塞了(或者丢包了),则服务器会一直等 F3,如果 F3 不来,后面的包都不会成功被接收。反向队头的 FF1 也是同样的道理。

当然,虽然 HTTP/2 的二进制分帧没有完全解决队头阻塞问题,但降低了其发生的可能性,为什么这么说呢?下面具体分析一下:

前面说到请求 1 的响应 1 迟迟不能回来,原因可能有两种:

  • 原因 1:服务器对请求 1 处理得慢;
  • 原因 2:服务器对请求 1 处理得很及时,但网络传输慢了。

对于原因 2,如果刚好请求 1 的第一个帧又处在队头,则即使二进制分帧也解决不了队头阻塞问题;但对于原因 1,请求 2、请求 3 的响应分帧之后,是先于请求 1 的响应发出去的,那么请求 2 和请求 3 的响应就不会被请求 1 阻塞,从而就避免了队头阻塞问题。

同时,在 HTTP/2 里面,还可以指定每个流的优先级,当资源有限的时候,服务器根据流的优先级来决定,应该先发送哪些流。从而避免那些高优先级的请求被低优先级的请求阻塞。

如果要彻底解决“队头阻塞”问题,需要怎么做呢?不用 TCP!这正是 Google 公司的 QUIC 协议要做的事情。接下来,在对 TCP 和 UDP 进行讨论之后,专门来分析 QUIC 协议。

头部压缩

除了二进制分帧,HTTP/2 另外一个提升效率的方法是头部压缩。在 HTTP 1.1 里,对于报文的报文体,已经有相应的压缩,尤其对于图片,本来就是压缩过的;但对于报文的头部,一直没有做过压缩。

随着互联网的发展,应用场景越来越复杂,很多时候报文的头部也变得很大,这时对头部做压缩就变得很有必要。为此,HTTP/2 专门设计了一个 HPACK 协议和对应的算法。

为解决 HTTP 1.1 的效率问题,除引入的这两个关键特性之外,HTTP/2 还有一些其他特性,比如服务器推送、流重置等,此处不再一一叙述。