大话 HTTP 协议前世今生

HTTP 全称 Hypertext Transfer Protocol,中文是超文本传输协议。网上讲 HTTP 协议的资料可以说是五花八门,但大多数都在罗列 HTTP 协议具体的规定,很少有讲 HTTP 协议这样设计的原因。今天我就尝试从解决问题的角度分析 HTTP 协议主要特性,希望能帮助大家快速理解 HTTP 协议。

HTTP 是一种通过网络传输数据的协议。我们不希望数据在传输的过程中出现丢失或者损坏的问题。所以 HTTP 选用 TCP 作为底层网络协议,因为 TCP 是一种可靠的传输层协议。

通信双方就建立 TCP 连接后立马发现一个新问题:服务端要给客户端发送什么数据呢?所以客户端必需在连接建立后将自己想要的内容发送给服务端,这就是所谓的「请求」,也就 HTTP Request。由此就确立了 HTTP 协议最根本的设计,即由客户端主导的请求应答式协议。

客户端上来就给服务端发了一个「请求」。但服务端有可能收到的内容跟客户端并不完全一样。等等,TCP不是可靠传输协议吗?接收到的数据怎么会不一样?这就涉及到数据分段的问题。比如客户端发送”abcdef”,底层 TCP 协议可能分两次传输 “abc” 和 “def”,也可能分好多次传输。不论分几次,它们的顺序是固定的,跟客户端发送的顺序完全一致。服务端可能会收到多段数据,所以服务端需要把收到的数据「攒」起来,等到客户端的数据全部收到之后才能看到客户端「请求」的全貌。

那到什么时候算全部收到呢?这是 TCP 通信的一个基本问题。解决这个问题有两个流派:长度流和分隔符流。

所谓长度流就是在实际发送数据之前,先发送数据的长度。服务端先读取长度信息,然后再根据长度来「攒」后面的数据。那服务端在读取长度的时候不会碰到分段问题吗?其实不会,因为 TCP 只会对比较长的数据做分段。前面说的”abcdef”分两段只是一种极端的例子,实际上很难发生。所以,只要先发送的长度数据不要太长,服务端就能一次性收到。退一步,即便是真的会分段,这类长度流协议都会规定长度数据自身的长度。比如用两个字节表示长度,那范围就是数据长度的范围就是0-65535。服务端可以先收两个字节,然后再根据数据长度来接收后面的内容。

长度流最大的优点就是实现简单,内存效率高,服务端不用事先分配很多内存。但缺点也比较突出,长度的范围不够灵活。如果我们规定长度字段为两个字节,但就不能传输超过64k的数据。但如果规定长度字段为八个字节,那在传输比较短的数据时就造成浪费。如何设置最优长度字段,大家可以参考我的另一篇文章。

此外,长度流的扩展性也比较差。如果我们想在长度之外传输其他信息,比如数据类型、版本号之类,我们都需要提前规定好这些数据的长度。长度一旦定好,以后就很难扩展了。最典型的长度流协议就是 IP 报文。有兴趣的朋友可以去看看 IP 协议是怎么规定数据长度的。

有鉴于长度流的不足,人们又搞出了分割符流。简单来说就是用一个特殊的分割符表示数据的结尾。最经典的例子就是C语言的字符串,结尾用\0来表示。使用这个流派的服务端程序要不停地从客户端接收数据,直到收到某一个分割符,就表明已经收到了完整的「请求」。

因为不需要事先指定数据的长度,所以分割符流派一下子就解决了长度流长度范围不灵活的问题。分割符流派的协议可以接收任意长度的数据。但是,分割符流派为些也付出了代价。因为长度不固定,服务端必须分配比较大的内存或者多次动态分配内存,这会产生比较大的资源消耗。恶意用户可能通过构造很长的数据来占满服务器的内存。

但是 HTTP 协议还是加入了这个流派,它用的分割符是\r\n。这里的\r表示回车,就是让打印机把打印头回到最左边的位置。\n表示换行,就是让打印机把纸向上挪一行,准备打印新的实符。上古时代的电脑没用现在的液晶屏,用电传打印机来「显示」内容,所以需要传输\r\n两个字符。现在这些都淘汰了,理论上用\n也可以,像 Nginx 就支持只用\n。

所以,一个最简单的 HTTP 请求长这个样子:

GET /mypage.html\r\n

这里的GET是一种拟人的说法,从服务拿什么东西。这也是 HTTP 语义化设计的开端(所谓语义化就是普通人能看懂)。后面跟一个空格,再后面是文件的路径。最后是分割符\r\n。因为最后是\r\n,所以上面的数据也叫请求行(request line)。

客户端跟服务器建立连接后就立即发送上面的数据。服务端等收到\r\n后开始解析,也就是把/mypage.html提取出来,然后找到对应的文件,把文件内容发送给客户端。

到这里,客户端就收到了服务端发送的文件内容,也叫「响应」。但是,客户端马上面临服务端同样的问题:如何确定已经收到了 mypage.html 的完整的内容呢?服务端要不要在最后发送分割符\r\n呢?不能!因为 mypage.html 的内容里本身就可能包含\r\n。如果客户端还是以\r\n当作结束标记,那可能会丢失数据。

为此 Tim Berners-Lee (HTTP 协议之父) 采用了更简单的办法——关闭连接。也就是说,服务器在传输完成之后要主动关闭 TCP 连接,这样客户端就明确知道所有的内容已经传输完成了。

以上就是最原始的 HTTP 协议,大约在1990发布。现在称这个时代的 HTTP 协议为 HTTP/0.9,主要是跟后面标准化之后的 1.x 进行区分。就这样,万维网的时代开启了。

HTTP/0.9 发布后得到了广泛的应用。但它的功能太简单了,所以很多浏览器都在它的基础上做了扩展。最主要的扩展功能有如下几个:

  • 添加版本信息
  • 添加扩展头信息
  • 添加返回状态信息

添加版本信息是为了方便客户端和服务端相互识别,这样才能开启扩展功能。添加之后的请求行如下:

GET /mypage.html HTTP/1.0\r\n

添加扩展头信息是为了传递更多的扩展信息。比如,这时候不同的浏览器会在请求中标记自己的身份。为方便后续添加各种不同的扩展信息,HTTP协议继续使用「行」和分割符的概念。

首先,跟请求行保持一致,每一条扩展信息占一行,以冒号分割,以\r\n结尾,比如:

User-Agent: NCSA_Mosaic/2.0 (Windows 3.1)\r\n

其次,这种信息可以有多行。那服务端怎么确定到底有几行呢,这还得用到分割符\r\n。HTTP 协议用一个空行表示后面扩展信息都结束了。所以完整的请求是:

GET /mypage.html HTTP/1.0\r\n
Host: taoshu.in\r\n
User-Agent: NCSA_Mosaic/2.0 (Windows 3.1)\r\n
\r\n

服务端先接收一行,提取文件路程,然后再根据\r\n逐行提取扩展信息。如果收到一个空行,则说明扩展信息接收完成。这些扩展信息也叫头信息(header),后续 HTTP 协议的各种特性都是基于它来实现。

HTTP/0.9 收到请求后直接传输文件内容。但用些场景需要返回其他信息,比如文件不存在之类的,所以人们给它添加了返回状态信息。此外,扩展后的 HTTP 协议也支持服务端在发送数据前返回多个头信息。一个典型的扩展响应为:

200 OK\r\n
Date: Tue, 15 Nov 1994 08:12:32 GMT\r\n
Server: CERN/3.0 libwww/2.17\r\n
Content-Type: image/gif\r\n
\r\n
(image content)

服务器首先会发一行数据200 OK\r\n。这里的200是状态码,表示成功。后面的OK是给人看的语义部分。这一行也叫 status code line。紧接着就是扩展信息,形式跟请求里的一模一样,每行一条,以空行表示结束。最后才是文件内容。

因为有了头信息,HTTP协议的扩展性直接起飞。人们不断给 HTTP 协议添加各种种样的特性。

HTTP/0.9 只能传输纯文本文件。因为有了 Header,我们可以传输更多的描述信息,比如文件在的类型、长度、更新时间等等。这些传输数据的描述信息也被称为 Entity Header,数据本身称为 Entiy。

常见的 Entiy Header 有:

  • Content-Type 内容类型
  • Content-Length 内容长度
  • Content-Encoding 数据编码

Content-Type 表示数据类型,比如 gif 的类型是image/gif。类型的取值最终被标准化为 Multipurpose Internet Mail Extensions(MIME)。

Content-Length 表示数据长度。但我们前面说过,HTTP/0.9 的服务器不需要返回文件长度,等传输完毕后关闭 TCP 连接就好了。为什么又要定义长度信息呢?

这里有两个问题。第一个是在请求里支持上传内容,第二个是连接优化问题。

HTTP/0.9 只有一种 GET 请求。显然光下载是不够的。人们陆续引入了 HEAD 和 POST 等请求,用来给服务器提交数据。一但要提交数据,光用分割符就不够了。因为提交的数据本身就可能包含分割符。所以需要事先指定数据的长度。这个长度用的就是 Content-Length 头来指定。

另外一个是连接优化问题。其实 HTTP 协议的发展史很大程度上就是传输性能的优化史。

HTTP/0.9每次请求都会创建一个 TCP 连接,读取结束后连接就会被关闭。如果一次只下载一个文件也没什么问题。但后来 HTML 页面支持嵌入图片等内容,一个页面可能有多个图片。这样浏览器打开一个 HTML 页面的时候就需要发起多次 HTTP 请求,每次请求都要反复建立和关闭 TCP 连接。不但浪费服务器资源,还会拖慢页面的加载速度。

所以,大家就想办法复用底层的 TCP 连接。简单来说就是服务器在内容发送完成后不主动关闭连接。但不关闭就会出现前面说的问题,客户端不知道响应内容什么时候传输完毕。所以需要事先指定数据的长度。因为 HTTP 协议已经有了 header 机制,所以添加 Content-Length 就是最自然的办法。

这里还有一个兼容性问题。如果客户端不支持复用 TCP 连接,那服务端不关闭连接的话客户端就会一直在等待。所以复用 TCP 连接这个功能不能默认开启,而是应该由客户端决定要不要使用。这就引出了Connection:Keep-Alive这个头信息。如果客户在请求中指定 Keep-Alive,服务端才不会主动关闭 TCP 连接。

除了复用 TCP 连接之外,HTTP/0.9 另一个值得优化的地方就是数据压缩。那个时代网速很慢,如果能把数据压缩之后再传输可以显著降低传输耗时。服务端不能随意压缩,因为有的客户端可能不支持。所以就先引入了Accept-Encoding这个头,可能的取值如compress或者gzip。服务端收到这个请求之后才对内容做压缩。因为浏览器可能支持多种压缩算法,浏览器需要选择一种自己也支持的来压缩数据,所以就需要在返回内容的时候指定自己用了哪种算法。这就是Content-Encoding头的用途。

不论是前面的 Connection 还是后面的 Accept-Encoding,为了尽可能地兼容不同客户端,HTTP 协议会通过添加新的 header 来协商是否使用扩展特性。这种协商由客户端来主导,服务器需要根据客户端的请求来配合完成。

还是因为网络比较慢而且成本很高,HTTP协议需要进一步优化数据传输效率。一个典型的场景是客户端已经下载过某文件内容。当客户端再次请求的时候,服务端还要不要返回。如果不返回,则客户端拿不到最新的内容;如果返回,当服务端的文件没有变化的时候,客户端会花很长时间加载一个已经下载过的文件。怎么优化这个问题呢?

人们引入了如下 Entity Header:

  • Last-Modified 最近修改时间
  • Expires 过期时间

如果文件不经常改动,服务器可以对过 Last-Modified 把最近修改时间发送给浏览器。浏览器如果支持,可以在下次请求该资源的时候带上这个时间,也就是在请求里添加下面的头:

If-Modified-Since: Sat, 29 Oct 1994 19:43:31 GMT\r\n

服务器收到后会跟文件的当前修改时间做对比,如果没有修改则直接返回304:

304 Not Modified\r\n

这种叫作条件请求,可以显著减少不必要的网络传输。

即使如此,客户端还是发起一次 HTTP 请求才能拿到 304 响应,也会产生网络传输和服务端开销。为了进一步优化,HTTP又引入了 Expires 头,它的含义是一个未来的过期时间。在这个时间之前浏览器可以安全使用本地缓存的副本,不需要从服务器下载。这样连条件请求都不需要发起了。

不过 Expires 特性有一个副作用,文件一旦下发,在过期之前根本无法修改。

大约是在1991-1995这个时间,各浏览器厂商陆续实现了上述功能。但不同浏览器和服务端软件支持的功能不同,带来各种兼容问题。于是到 1996 年,IETF 发布 RFC1945。RFC1945 只能说是当前最佳实践的总结,并不是推荐标准。但人们还是称它为 HTTP/1.0。

没过一年,也就是1997年,IETF就发布了RFC2068,也就是大名鼎鼎的 HTTP/1.1 协议规范。

HTTP/1.1 是对 HTTP/1.0 的梳理和扩展。核心的改动有:

  • 默认开启 TCP 连接复用,客户端不需要再发送 Connection:Keep-Alive
  • 添加了所谓 pipeline 特性,进一步优化传输效率
  • 支持 chunked 传输编码
  • 扩展缓存控制
  • 内容协商,包括语言、传输编码、类型等
  • 在同一IP上建立多个 HTTP 网站

所谓的 pipeline 特性是对 HTTP 协议传输效率的进一步优化,但最终失败了。

HTTP 协议是请求应答式协议。客户端发一个请求,然后等待服务端返回内容。虽然在 HTTP/1.0 时代就有了 TCP 连接复用、内容压缩和条件请求等优化机制,但客户端发起新请求之前必须等待服务器返回内容。换言之就是客户端无法在一个连接上并行发起多个请求。为此,HTTP/1.1 的 pipeline 就规定客户端可以依次发起多个 HTTP 请求,然后等待服务器返回结果。服务器需要按照请求顺序依次返回对应的响应内容。

  c        s              c        s
  |  req1  |              |  req1  |
  |------->|              |------->|
  |  resp1 |              |  req2  |
  |<-------|              |------->|
  |  req2  |              |  req3  |
  |------->|              |------->|
  |  resp2 |              |  resp1 |
  |<-------|              |<-------|
  |  req3  |              |  resp2 |
  |------->|              |<-------|
  |  resp3 |              |  resp3 |
  |<-------|              |<-------|
  
without pipeline         with pipeline

虽然服务器收到多个请求的时候可以并发处理,这种并发带来的优化有限,而且 pipeline 特性并没有减少实际的网络传输。几乎没有软件实现 pipeline 特性,所以这个优化设计以失败告终。

chunked 编码是一项非常成功的优化,主要解决服务端动态生成响应内容的情况。

HTTP/1.0 只能使用 Content-Length 指定内容长度,而且是先发送 header 再发送 body。这就要求必须在传输内容之前确定内容的长度。对于静态文件,这当然不是问题。但如果要加载一个由 PHP 动态渲染的 HTML 就有问题了。因为 HTML 是程序动态生成的,没法事先确定内容长度。如果还用原来的办法,只能先把内容生成好保存到一个临时文件,再发送给客户端。显然这种性能太差。

为了解决这个问题,HTTP/1.1 引入 chunked 编码。简单来说就是回到之前的长度流,将数据逐段发送给客户端,每一段前面加上长度信息:

HTTP/1.1 200 OK\r\n
Content-Type: text/plain\r\n
Transfer-Encoding: chunked\r\n

7\r\n
Mozilla\r\n
9\r\n
Developer\r\n
7\r\n
Network\r\n
0\r\n
\r\n

Transfer-Encoding 指定为 chunked。接下来的数据也是分行传输。一行长度,一行数据。结束的时候长度指定为零,然后再加一个空行。这样服务端就不需要事先确定响应内容的长度,PHP 就可以有边渲染一边发送。这个特性还是 WebSocket 没有普及的年代被用于实现消息推送。大家可以搜索 Comet 或者 HTTP 长轮询了解更多信息。

HTTP/1.1 对缓存做了更粗细化的定义,引入了 Cache-Control 扩展信息。这一部分内容比较复杂,除了会影响浏览器的缓存行为之外,还会影响 CDN 节点的行为。部分 CDN 厂商还会扩展 标准缓存指令的语义。限于篇幅,在此就不展开了。

但 HTTP/1.1 对条件请求做了扩展,可以说一下。

操作系统会自动记录文件的修改时间,读取该时间也非常方便,但 Last-Modified 不能覆盖所有情况。有时候我们需要用程序定时生成某些文件,它的修改时间会周期性变化,但内容不一定有改变。所以光用 Last-Modified 还是可能产生不必要的网络传输。于是 HTTP 协议引入了一个新的头信息 Etag。

Etag 的语义是根据文件内容计算一个值,只有在修改内容的时候才会产生新的 Etag。客户端每次请求的时候把上一次的 Etag 带回来,也就是添加下面的头:

If-None-Match: "c3piozzzz"\r\n

服务端收到后会对比 Etag,只有发生变化的时候才会返回新的文件内容。

那个时候的网络很不稳定,断网是家常便饭。想想一个文件下载到99%然后断网了是一种怎样的体验。为了减少不必要的数据传输,人们很快就给 HTTP 协议添加了「断点续传」功能。其实断点续传是从客户端视角来看的。从协议角度来看,需要添加的功能是根据指定范围传输数据。也就是说原来的文件是100字节,客户端可以指定只下载最后的10字节:

Content-Range: bytes 91-100/100\r\n 这里的91-100表示要下载的范围,后面的100表示整个文件的长度。如果服务器支持,则会返回:

HTTP/1.1 206 Partial content\r\n
Date: Wed, 15 Nov 1995 06:25:24 GMT\r\n
Last-modified: Wed, 15 Nov 1995 04:58:08 GMT\r\n
Content-Range: bytes 91-100/100\r\n
Content-Length: 10\r\n
Content-Type: image/gif\r\n
\r\n
(image data)

该功能除了用于断点续传外,还可以实现并行下载加速。客户端可以起多个线程,建立多条 TCP 连接,每个线程下载一部分,最后把有的内容连到一直。就这么简单。

另外,HTTP/1.1 还要求客户端在请求的时候必须发送 Host 头信息。这里面保存着当前请求对应的网站域名。服务器收到请求后会根据 Host 里的域名和请求行里的路径来确定需要返回的内容。这样就能实现在同一个 IP 上搭建不同域名的网站,也就是所谓的虚拟主机。这大大降低了网站的建设成本,对 Web 生态的发展起到了至关重要的作用。

除了扩展 HTTP/1.0 原来的功能外,HTTP/1.1 还引入了连接升级功能。其实这个功能后面用的不多,但有一个重量级的协议 WebSocket 在用,所以不得不说。

所以连接升级就是把当前用于 HTTP 会话的 TCP 连接切换到其他协议。以 WebSocket 为例:

GET /chat HTTP/1.1
Host: taoshu.in
Upgrade: websocket
Connection: Upgrade

这里把 Connection 设成了 Upgrade,表示希望切换协议。而 Upgrade:websocket 表示要切换到 websocket 协议。在切换之前,这还是一个普通的 HTTP 请求。服务器可以对该请求做各种鉴权等 HTTP 动作。服务器如果接受用户的请求,则会返回:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade

从这一该起,双方就不能在该 TCP 连接上发送 HTTP 协议数据了。因为协议已经切换到 WebSocket。

从 1999 年开始,到 2015 年 HTTP/2 发布,HTTP 协议有15年的时候没有大的变化。与此同时,互联网蓬勃发展,从 Web 1.0 过渡到 Web 2.0,从 PC 互联网发展到移动互联网,从明文 HTTP 也切换到加密 HTTPS。整个过程 HTTP 协议都发挥了核心作用。这从侧面也说明 HTTP 协议是一种扩展性非常好的协议。

但 HTTP/1.1 毕竟是九十年代设计的协议。2010年之后,移动互联网兴起,业界希望对 HTTP 的问题做够进一步优化。那还有哪些问题可以优化呢?主要有几个方面:

  1. 协议使用文本格式,传输和解析效率都比较低
  2. Header 部分信息无法压缩,但现实情况是 Header 体积也不小(比如 cookie)
  3. 无法在单一 TCP 连接上并发请求资源(pipeline 失败了)
  4. 服务端无法主动给客户发送内容

文本格式其实是 HTTP 的一大特色。我们在调试的时候可以直接使用 telnet 连接服务器,然后用肉眼看服务器的返回结果。但对人类友好的设计对机器一定不友好。HTTP协议使用\r\n作为分割符,双不限制头信息的数量,这必然导致解析的时候需要动态分配内存。而且还要把数字、日期等信息转换成对应的二进制格式,这都需要额外的解析成本。

HTTP/1.x 支持压缩数据内容,而且使用头信息保存压缩算法。所以就不能用相同的算法压缩头信息了。只能另辟蹊径。

HTTP/1.1 的 pipeline 已然失败,无法充分复用 TCP 连接。HTTP 从一开始就是请求应答式的设计,服务器没办法主动推送内容到客户端。

为了解决这几个问题,Google 挟 YouTube 和 Chrome 两大杀器,推出了 SPDY 协议。该协议有两个特点:

  1. 兼容 HTTP 语义
  2. 使用二进行格式传输数据 SPDY 引入了帧做为最小的传输单位:
+-----------------------------------------------+
|                 Length (24)                   |
+---------------+---------------+---------------+
|   Type (8)    |   Flags (8)   |
+-+-------------+---------------+-------------------------------+
|R|                 Stream Identifier (31)                      |
+=+=============================================================+
|                   Frame Payload (0...)                      ...
+---------------------------------------------------------------+

                      Figure 1: Frame Layout

每一帧前三个字节表示数据长度,然后用一个字节表示类型,再用一个字节保存一些扩展标记。然后就是四个字节的 stream ID,最后是真正的数据。这其实就表明 HTTP 协议从分割符流转向了长度流。

在同一个 TCP 连接上,数据帧可以交替发送,不再受请求应答模式制约。也就是说服务端也可以主动给客户端发消息了。同一个请求的 header 和数据部分也可以分开发送,不再要求先发 header 再发 body。也正是因为数据帧交错传输,同一个会话下的数据需要能关联起来,所以 SPDY 给每一帧添加了 stram ID。换句话说 SPDY 在一个 TCP 连接上虚拟出了多个 stream,每一个 stream 从效果看都是一个 TCP 连接。不同的 HTTP 请求和响应数据可以使用自己的 stream 并发传输,互不影响。这样一下子就解决了上面的一、三和四这三个问题。

第二个问题比较麻烦。但解决思路也很简单。HTTP/1.x 的头信息都是 K-V 型的,而且都是字符串。这里的 K-V 都很少变化。比如只要是访问我的博客,不论有多少请求,都得发送 Host: taoshu.in。对于这种不变的,我们完全可以在两端各保存一张映射表,给每个 Key 和 Value 都指定一个编号。这样后续的请求只要传 Key 和 Value 的编号就行了,从而实现压缩的效果。单看 Host 可能不觉得有多少进步。但大家想想自己的 cookie,里面有登录会话信息,每次都重复发送浪费相当惊人。所以压缩头信息带来的优化还是惊人的。

因为谷歌一边控制着市场份额最大的 Chrome 浏览器,另一边又控制像 Google/YouTube 这样的内容服务,所以开发下一代 HTTP 协议便一件非常容易的事情。SPDY 于 2012 年发布,最终在 IETF 完成标准化,并于 2015 年发布,也就是RFC7540。

随着社会的发展,隐私保护成了人们关注的重要课题。为了保护用户信息,业界一真在推动 HTTP + TLS 也就是 HTTPS 的普及。HTTPS 服务使用 443 端口。我们前面讲过,HTTP/2 使用二进制编码,跟 HTTP/1.x 并不兼容。但客户端又不会一夜之间都升级的 HTTP/2。那怎么才能在一个端口上同时支持两种 HTTP 协议呢?这就用到了 TLS 协议的 ALPN 扩展。简单来说就是客户端在发起 TLS 会话的时候会通过 ALPN 扩展附带自己支持的应用层协议,比如 http/1.1 和 h2。服务端收到后会把自己支持的应用层协议返回给客户端。这样双方就能确定接下来在 TLS 会话是使用什么协议。

理论上 HTTP/2 可以通过 HTTP/1.1 的升级机制来协商,这样也能解决两个版本共用 TLS 会话的问题。但这种升级会再来额外的延迟,所以主流的浏览器都不支持。

HTTP/2 发布之后,整个业界都在积极迁移到新的协议。但实践证明,HTTP/2并没有想象中的那么好。为什么呢?因为对于同一个域名,浏览器默认只会开一个连接,所有请求都使用一个TCP连接收发。虽然不同的请求使用不同的 stream,但底层的连接只有一个。如果网络出现抖动,不论是哪一个请求的数据需要重传,其他请求的数据都必须等待。这就是所谓的 Head of Line blocking 问题。HTTP/2 非但没有优化,甚至还比 HTTP/1.x 还要差。因为在 HTTP/1.x 时代,浏览器自知 HTTP 无法复用连接,所以会为同一个域名创建多个 TCP 连接。不同的请求可能会分布到不同的连接上,出现网络抖动的影响比只用一个连接要好一点。

HTTP/2 的另一个问题就是功能太复杂。比如它支持在服务器主动推送资源(比如 CSS 文件)到浏览器,这样客户端在加载的时候就需要等待网络传输。但该功能非常复杂,而且效果有限,最终连 Chrome 自己都放弃支持该功能了。这部分功能被 HTTP 103 Early Hints 状态码代替,具体可以参考RFC8297。

一计不成,再生一计。谷歌的工程师跟 Head of Line blocking 问题死磕。这次他们把矛头指向了问题的根源 TCP 协议。因为 TCP 是可靠传输协议,数据必须按顺序收发,而且要边确认边发送。如果底层用 TCP 连接,就不可能解决 Head of Line blocking 问题。为此,他们基于 UDP 协议设计了 QUIC 协议。

QUIC 协议简单来说就是一种面向消息的传输协议(TCP 是面向数据流的传输协议)。QUIC 也有 stream 的概念,每个会话可以有多个流。不同的流的数据都使用 UDP 收发,互不干扰。跟 TCP 一样,数据发出后也需要对方确认。然后再把 QUIC 跟 HTTP/2 的帧映射到一起,最终形成 HTTP/3 协议,也就是RFC9114。

那 QUIC 有没有问题呢?也有,但基本都不是设计上的问题。

第一个问题就是运营商可能对 UDP 流量做限流,很多防火墙可能会阻止 QUIC 流量。这是之前 UDP 通信使用不广泛导致的。随着 HTTP/3 技术的普及,这些问题会逐渐改善。

第二个问题是 HTTP/3 启动延迟的问题。HTTP/3 使用 UDP 通信,跟 HTTP/1.x 和 HTTP/2 不兼容,所以浏览器没法判断服务器是否支持 HTTP/3。

目前主流的做法是网站同时支持 HTTP/2 和 HTTP/3。浏览器先通过过 TCP 连接访问服务器。服务器在第一个响应中返回一个特殊的 Header:

Alt-Svc: h3=":4430"; ma=3600

这里的意思是在 UDP 的 4430 端口提供 HTTP/3 服务,该信息的有效时间为 3600 秒。后面浏览器就可以使用 QUIC 连接 4430 端口了。

明眼人一看就知道这里有问题,建立 HTTP/3 会话之前还得先用一下 HTTP/2 启动有把。这不科学🔬而且这会带来额外的耗时。为此,人们又开始想别的办法,这就是 DNS SVCB/HTTPS 记录。

DNS SVCB/HTTPS 简单来说就是用一种特殊的 DNS 记录把前面的 Alt-Svc 信息曝露出来。浏览器在访问网站之前先通过 DNS 查询是否支持 HTTP/3 以及对应的 UDP 端口,然后就直接发起 HTTP/3 会话就好。这样就完全不依赖 TCP 连接了。关于 DNS SVCB/HTTPS 记录的更多信息请看我的专门文章。

顺便说一句,HTTP/3 默认可以工作在任意 UDP 端口,不像 HTTPS 那样默认工作在 443 端口。如果运营商封掉 443 就没法对外服务。等 HTTP/3 普及了,所有人都可以使用自家的宽带搭建网站😄具体做法可以参考我的这篇文章。

好了,到现在快肝了一万字了。我认为基本讲清楚了 HTTP 协议的发展脉络。现于篇幅,没能详细讨论 HTTP/2 和 HTTP/3 的技术细节,不能说不是个遗憾。先开个坑,后面有时间再补上。希望本文能帮助你更好地理解 HTTP 协议。

链接:https://taoshu.in/net/http.html

(版权归原作者所有,侵删)

相关新闻

历经多年发展,已成为国内好评如潮的Linux云计算运维、SRE、Devops、网络安全、云原生、Go、Python开发专业人才培训机构!