从 HTTP 0.9 到 QUIC

从 HTTP 0.9 到 QUIC

孙宁孙宁

1989 年 World Wide Web 诞生之后,HTTP 和 HTML 迅速成为主导世界的应用层协议。在今天,几乎任何场景的应用都或多或少地使用 HTTP(就像 JavaScript 一样)。HTTP 本身也不仅仅用于网页、浏览器,各式各样的 API,移动应用同样使用这个原本为 HTML 设计的协议。80 和 443 端口成了网络上最重要的端口。

在近 30 年的历史中,HTTP 协议本身有比较大的发展,同时,还有一些重大的变动也在酝酿之中。这些演化使得这个协议的表现力更强,性能更好,更能满足日新月异的应用需求。这里就来回顾和展望一下 HTTP 的历史和未来。

HTTP 0.9

历史上第一个有记载的 HTTP 版本是 0.9(The HTTP Protocol As Implemented In W3),它诞生在 1991 年。这个协议被设计用于从服务器获取 HTML 文档。

telnet example.com 80
GET /
<html>...
🔚

整个协议的请求只有1行:`GET` 加文档路径。`GET` 无需多解释,是 HTTP 至今都一直保留的 "method",是 HTTP 的动词。1991 年的 HTTP 仅支持 `GET` 这唯一的动词。之后的路径是文档在服务器的位置(逻辑位置),即实际要获取的内容。请求以换行结束。服务器收到请求之后,就会返回对应的 HTML 文档内容。输出完毕后,关闭连接。

当时的 HTTP 协议是一个非常简单的文本协议。直到今天,我们熟悉的 memcached 和 redis 还是使用类似风格的协议。使用者甚至可以直接在 `telnet` 中与服务器交互。

HTTP 1.0

1996 年,一个更加完整,更加接近我们目前对 HTTP 认知的版本,HTTP 1.0(Hypertext Transfer Protocol -- HTTP/1.0) 发布了。这个版本中已经包含了很多我们如今耳熟能详的概念:

  • HTTP 响应状态码:在响应的第一行,首先返回状态码和说明文本。相当于在 HTTP 0.9 基础上增加了返回类型的支持。
  • HTTP 头:除了首行的动词和路径之外,请求和响应都支持一系列的「头」。这些「头」以键值对的形式出现,为当时和日后 HTTP 的各种周边设置提供了载体。
  • HTTP 方法:增加了 HEAD 和 POST 等方法。

这个时代的请求和响应已经接近现代的 HTTP 了:

telnet example.com 80
GET / HTTP/1.0
User-Agent: HappyBrowser
Accept: */*
HTTP/1.0 200 OK
Content-Type: text/html
Server: HappyServer

<h1>It works</h1>
🔚

HTTP 1.1

随着互联网的迅速发展,人们对 HTTP 协议有了更高的要求。1999 年,现在最常见的 [HTTP 1.1](Hypertext Transfer Protocol -- HTTP/1.1) 版本诞生了。从此之后,这个 HTTP 协议一直服务至今。并且,在后来的十多年里,这个协议还不断更新和细化,最终在 2014 年形成了 5 个 RFC:

HTTP 1.1 协议已经相对庞大,不过要选择其中对 HTTP 1.0 最大的改进,非连接管理莫属。 HTTP 1.0 仅仅介绍了该协议可以使用在 TCP/IP 之上,但是没有进一步地介绍。实际使用的方式仍然是请求结束后就将连接断开,这样我们需要为每一个 HTTP 请求都重新创建 TCP 连接。然而,每一个新的 TCP 连接在创建时需要经历[握手](Transmission Control Protocol)和[慢启动](TCP congestion control)的机制,客户端在使用新的连接发送 HTTP 请求时,都要经历可观的延迟。随着网页内容的丰富,交互增加,使用新连接的代价是相当大的。

HTTP 1.1 针对这个问题,对连接管理有了明确的说明(RFC 7230 - Hypertext Transfer Protocol (HTTP/1.1): Message Syntax and Routing),默认即使用持久连接的机制。在 HTTP 头中,包含了一个 `Connection` 字段,对是否保持、重用连接进行说明。当 `Connection: keep-alive`时,连接将被保持,之后客户端可以继续在这个 TCP 连接上发送新的请求。当客户端确实需要关闭连接时,发送的请求要明确说明`Connection: close`,服务器端在处理完成后就会关闭连接。

一个典型的请求与响应:

telnet example.com 80
GET / HTTP/1.1
User-Agent: HappyBrowser
Accept: */*
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 16
Server: HappyServer
Connection: keep-alive

<h1>It works</h1>

之后 TCP 连接得以保持,直到:

GET / HTTP/1.1
User-Agent: HappyBrowser
Accept: */*
Connection: close
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 16
Server: HappyServer
Connection: close

<h1>It works</h1>
🔚

通过持久连接的机制,同一个 TCP 连接可以传输多次的 HTTP 请求、响应,他的使用率已经得到了一定的提高。不过,由于 HTTP 协议采用请求-响应的模型,在一个 TCP 连接上,同一个时刻只能有一个请求,请求发送后,客户端必须等待返回。这种同步、阻塞的方式限制了连接的吞吐量。当页面上有大量元素时,这样的等待会造成页面内容的顺序加载。因此,为了提高吞吐量,客户端(浏览器)通常会打开多个连接,很多现代浏览器会对每个域名至多打开6个连接 (html - Max parallel http connections in a browser?),并且重用它们。然而即使是6个连接,在加载包含大量外部资源的页面时仍然会捉襟见肘。为此,还出现了切分域名(Sharding Dominant Domains)等优化方法。但终归来说,各种方式仍然会面临创建 TCP 连接所带来的开销、延迟。

HTTP Pipelining(HTTP pipelining) 机制允许客户端在响应返回前直接发送下一个请求,以 Firefox 为例,一旦启用了 HTTP Pipelining,至多可以同时发送 32 个请求,之后只需要等待这些请求次第返回即可。然而这种特性绝非没有代价。Pipelining 机制对顺序有严格的要求。如果响应返回的顺序与请求的顺序不一致,就要求客户端在更高层面增加顺序判断的机制,否则就将引起混乱。而在日常的浏览器场景下,是无从增加这种机制的,这就对服务器的实现提出了要求:单一连接上的请求必须顺序处理,顺序返回。对顺序的要求就导致了机制上的出现排队的可能。如果一个请求的处理时间较慢,那么后续所有的请求都会被拖慢,即使后续的请求很「便宜」。

对顺序的要求是 HTTP 1.1 无法回避的。因此,后续的改变就需要大刀阔斧了。

SPDY & HTTP/2

从 2009 年开始,Google 开始设计和开发一个能够解决上述问题的新协议:SPDY。在 SPDY 的基础上,2015 年,HTTP/2(RFC 7540 - Hypertext Transfer Protocol Version 2 (HTTP/2))终于诞生了。针对 HTTP 1.1 已有的顺序问题,HTTP/2 给出了相应的回答:多路复用。

HTTP/2 仍然使用持久连接,客户端与服务器需要长时间保持一个连接。但由于增加了多路复用的机制,因此这里也仅仅需要一个连接即可,这样就避免了 TCP 连接反复创建的开销。多路复用机制是在纯的 TCP 抽象在再抽象出一层 stream 的概念。请求-响应全部搭载在 stream 之上,在同一个 stream 上仍然保持原先的请求-响应模型。但是两端之间可以创建多组 stream,这样就避免了排队等待的问题。而且由于抽象的 stream 几乎没有创建代价,在用户体验和实际性能上,都比原先创建新的 TCP 连接要快很多。服务器在处理多个 stream 的请求时,也无需受限于顺序,这样就可以充分使用多个线程并行处理,且无需做任何等待。

表面上 HTTP/2 解决了之前所有的问题,然而事实并不尽然。由于今天 HTTP/2 下层的传输协议仍然是 TCP,而 TCP 本身是要求顺序的,对顺序的要求就会带来排队的问题。HTTP/2 层面的多路复用可以说解决了应用层的顺序问题:比如服务器可以用多线程并行处理同一个 TCP 连接上的请求。但是在传输层,顺序问题仍然会限制吞吐量,一旦服务器将结果写到了连接上,这个顺序就将被保证。尽管上文说 stream 之间可以互不干扰,不过在传输层 TCP 仍然会“老老实实”地按照服务器端写出的顺序交付给客户端。可惜客户端并不完全在乎这个顺序,客户端只需要保证在同一个 stream 之内有序即可。如果传输中出现了丢包的情况,操作系统必须等到之前的数据重传完成后才会交付到上层的应用程序。在这部分等待的数据中,很可能就包含其他 stream 的完整数据包,而它们原本可以提前交付给应用层。

这就是一个多路复用的应用层协议运行在一个非多路复用的传输层协议上产生的问题。

QUIC

QUIC(draft-tsvwg-quic-protocol-02) 并非是一个应用层协议。它诞生于 2012 年,目的就是在 SPDY 之后解决传输层上积累的问题:

  • 传输层的多路复用
  • TCP+TLS 连接创建的代价过大

QUIC 的一大特点是基于 UDP,对于这个长期几乎被应用层遗忘的协议来说,是一件令人兴奋的事情。但是 QUIC 选用 UDP 并非意味着它对完整性、顺序传输等机制地放弃,而仅仅是因为 UDP 是 IP 的一个很薄的包装。现在的网络已经不允许人们随意发明一个新的传输层协议了,各种网络设备对数据包的传输层协议都有要求。QUIC 在 UDP 上实现了一个多路复用的 TCP,它几乎包含了 TCP 所有的主要功能。

与 HTTP/2 + TLS + TCP 相比,QUIC 承担了 HTTP/2 中的整个 stream 管理部分、TLS 安全连接部分和 TCP 的重传、顺序、流控等机制。针对上一节描述的问题,QUIC 可以将 TCP 对顺序的要求进一步细化到 stream 上。在 QUIC 中,重传的粒度被提高到 stream 的层面,这样使得上层的抽象与下层的实现达成了一致。QUIC 重新将 HTTP/2 中较复杂的流管理(在应用层显得不伦不类)移到了本该存在传输协议中,也简化了 HTTP/2 协议,使这个应用层协议可以专注于 HTTP 的语义本身。

另一方面,目前使用的 TCP + TLS 握手环节存在数据交互过多的问题。尽管 HTTP/2 已经可以实现对一个地址只创建一个连接的机制,但是创建连接仍然需要反复的握手,是一个亟待优化的环节。针对这个问题,Google 推动了一个 TCP 的改进: [TCP Fast Open](TCP Fast Open)。Fast Open 允许 TCP 客户端在握手的第一个 SYN 包中携带应用层的第一个数据包,握手完成时服务器端可以直接处理这个数据包。与之思路类似的是 TLS 1.3 的 0-RTT 机制,允许客户端在 TLS 握手的 `Client HELLO` 环节就带上应用层数据,服务器端回复 `Server HELLO` 时就可以直接返回应用层的结果。这两个改进目前还没有完全推广到业界,不过 QUIC 已经吸收了 TCP Fast Open 的机制,并且将在未来直接支持 TLS 1.3

总结

看 HTTP 协议从过去到未来的发展历程,如果要总结一下的话:

  • 由于创建连接代价较大,尽可能提高连接使用率:持久连接,Pipelining 机制,多路复用机制
  • 减小创建连接的代价:减少客户端服务器端交互次数,持久连接,TCP FastOpen, TLS 1.3 0-RTT
  • 保持应用层与传输层的抽象、实现一致:持久连接,stream 管理
文章被以下专栏收录
6 条评论
推荐阅读