当我谈 HTTP 时,我谈些什么?

版主 又拍云 2020-7-15 3677



当我们打开网站时也许不会去留意网站前面的HTTP是怎么来的。但是它毫无疑问在网络中有着举足轻重的地位。本文从起源到发展,详说HTTP从1到3的演变。





说在前面


本文不致力于讲完 HTTP 的全部内容,事实上短短的篇幅也不可能讲完。本文也无意于深挖 HTTP 中的某一点,这是像 《HTTP 权威指南》或者是 RFC 协议做的事。

本文目标是帮助读者理清 HTTP 的演化过程,说说 HTTP 变化的那些事。



HTTP 的起源


HTTP 最初是 Tim BernersLee 1989 年在欧洲核子研究组织(CERN)所发起的。Tim BernersLee 提出了一种能让远隔两地的研究者们共享知识的设想。这个设想的基本理念是:借助多文档之间相互关联形成的超文本(HyperText),连成可相互参阅的 WWW(World Wide Web,万维网)。用于传输的超文本传输协议(HyperText Transfer Protocol),即 HTTP 由此诞生。

WWW 这一名称,是 Web 浏览器当年用来浏览超文本的客户端应用程序时的名称。现在则用来表示这一系列的集合,也可简称为 Web。

HTTP 本身是一个简单的请求-响应协议,它通常运行在 TCP 之上。从整个网络模型来看,HTTP 是应用层的一个协议。在 OSI 七层模型中,HTTP 位于最上层。它并不涉及数据包的传输,只是规定了客户端和服务器之间的通信格式。定了客户端可能发送给服务器什么样的消息以及得到什么样的响应。请求和响应消息的头以 ASCII 码形式给出。

HTTP 采用 BS 架构,也就是浏览器到服务器的架构,客户端通过浏览器发送 HTTP 请求给服务器,服务器经过解析响应客户端的请求。就是这个简单实用的模型,使得 HTTP 这个基于 TCP/IP 的协议迅速推广。



 HTTP/0.9 到 HTTP/1.1


HTTP 的演化并不是一蹴而就的。当年 HTTP 的出现主要是为了解决文本传输的难题。由于协议本身非常简单,于是在此基础上设想了很多应用方法并投入了实际使用。现在 HTTP 已经超出了 Web 这个框架的局限,被运用到了各种场景里。



HTTP/0.9



HTTP 协议最早的一个版本是 1990 年发布的 HTTP/0.9。

前面说到,HTTP 于 1989 年问世。那时的 HTTP 并没有作为正式的标准被建立。这时的 HTTP 其实含有 HTTP/1.0 之前版本的意思,因此被称为 HTTP/0.9。这个版本只有一个命令:GET。通过 GET 可以获取服务器的资源,比如请求服务器根目录下的 index.html 文件。这个版本的协议规定,服务器只能回应 HTML 格式的字符串,不能回应其它格式,也就是说图像、视频等多媒体资源,在 HTTP/0.9 这个版本上是无法进行传输的。



HTTP/1.0



HTTP 正式作为标准被公布是在 1996 年的 5 月,版本被命名为 HTTP/1.0,并记载于 RFC1945[https://www.ietf.org/rfc/rfc1945.txt]。虽说是初期标准,但该协议标准至今仍被广泛使用在服务器端。

HTTP/1.0 版本发布,增加了 POST 命令和 HEAD 命令,丰富了浏览器与服务器的互动手段。这个版本的 HTTP 协议可以发送任何格式的内容,包括传输文字、图像、视频、文件等,这为互联网的大发展奠定了基础。

HTTP/1.0 除了增加了请求方法以及对发送文件的支持之外,还增加了格式的改变。除了数据部分,每次通信都必须包括头信息(HTTP header),用来描述一些元数据。另外还增加了状态码、多字符集支持、多部分发送(multi-part type)、权限(authorization)、缓存(cache)、内容编码(content encoding)等等。



HTTP/1.1



HTTP/1.0 版也并不是完美的,它的主要缺点是,每一次建立 TCP 连接只能发送一个请求。发送数据完毕,连接就关闭,如果还要请求其他资源,就必须再新建一个连接。如果多次请求,势必就会对服务器产生较大的资源性能损耗。

1997 年 1 月公布的 HTTP/1.1 是目前主流的 HTTP 协议版本。当初的标准是 RFC2068,之后发布的修订版 RFC2616 就是当前的最新版本。

其中最著名的是 1999 年 6 月公布的 RFC 2616 [https://tools.ietf.org/html/rfc2616],定义了 HTTP 协议中现今广泛使用的一个版本——HTTP/1.1。

这个版本最大的变化就是将持久化连接加入了 HTTP 标准,即 TCP 连接默认不关闭,可以被多个请求复用。此外,HTTP/1.1 版还新增了许多方法,例如:PUT、PATCH、HEAD、OPTIONS、DELETE。得到进一步完善的HTTP/1.1 版本,一直沿用至今。



HTTP 协议简单介绍




请求



客户端发送一个 HTTP 请求到服务器,请求消息包括以下格式:

请求行(request line)、请求头部(header)、空行和请求数据四个部分组成。
Get 请求例子
> GET / HTTP/1.1> Host: www.baidu.com> User-Agent: curl/7.52.1> Accept: */*
第一部分:请求行,用来说明请求类型,要访问的资源以及所使用的 HTTP 版本。
第二部分:请求头部,紧接着请求行(即第一行)之后的部分,用来说明服务器要使用的附加信息

从第二行起为请求头部,HOST 将指出请求的目的地。User-Agent,服务器端和客户端脚本都能访问它,它是浏览器类型检测逻辑的重要基础。该信息由你的浏览器来定义,并且在每个请求中自动发送等等。

第三部分:空行,请求头部后面的空行是必须的

即使第四部分的请求数据为空,也必须有空行。

第四部分:请求数据也叫主体,可以添加任意的其他数据。

这个例子的请求数据为空。



响应消息



一般情况下,服务器接收并处理客户端发过来的请求后,会返回一个 HTTP 的响应消息。

HTTP 响应也由四个部分组成,分别是:状态行、消息报头、空行和响应正文。

例子

    < HTTP/1.1 200 OK< Accept-Ranges: bytes< Cache-Control: private, no-cache, no-store, proxy-revalidate, no-transform< Connection: keep-alive< Content-Length: 2381< Content-Type: text/html< Date: Thu, 11 Jun 2020 16:04:33 GMT< Etag: "588604c8-94d"< Last-Modified: Mon, 23 Jan 2017 13:27:36 GMT< Pragma: no-cache< Server: bfe/1.0.8.18< Set-Cookie: BDORZ=27315; max-age=86400; domain=.baidu.com; path=/< <!DOCTYPE html><!--STATUS OK--><html> <head><meta HTTP-equiv=content-type content=text/html;charset=utf-8><meta HTTP-equiv=X-UA-Compatible content=IE=Edge><meta content=always name=referrer>... </html>

    第一部分:状态行,由 HTTP 协议版本号、状态码、状态消息三部分组成。

    第一行为状态行,(HTTP/1.1)表明 HTTP 版本为 1.1 版本,状态码为 200,状态消息为(ok)

    第二部分:消息报头,用来说明客户端要使用的一些附加信息

    第二行和第三行为消息报头。

    Date:生成响应的日期和时间;Content-Type:指定了 MIME 类型的 HTML(text/html),编码类型是 UTF-8

    第三部分:空行,消息报头后面的空行是必须的
    第四部分:响应正文,服务器返回给客户端的文本信息。

    空行后面的 HTML 部分为响应正文。



    状态码



    状态代码有三位数字组成,第一个数字定义了响应的类别,共分五种类别:

    • 1xx:指示信息–表示请求已接收,继续处理
    • 2xx:成功–表示请求已被成功接收、理解、接受
    • 3xx:重定向–要完成请求必须进行更进一步的操作
    • 4xx:客户端错误–请求有语法错误或请求无法实现
    • 5xx:服务器端错误–服务器未能实现合法的请求



    安全性与 HTTPS


    HTTP 的诞生是为了解决信息传递和共享的问题,并没有考虑到互联网高速发展后面临的安全问题。

    一般来说 HTTP 从 TCP 三次握手后,便开始了数据传输。由于 HTTP 本身以明文形式来传输数据,并不具备任何数据加密、身份校验的机制。同时下层协议并不对数据安全性、保密性提供保证。所以在网络传输的过程中,任意节点的第三方都可以随意劫持流量、篡改数据或窃取信息。

    HTTP 无法确保数据的保密性、完整性和真实性,已经不能适应现代互联网应用的安全需求。

    随着 Web 的日益壮大,HTTP 的使用呈巨额增长趋势,对信息安全的需求也愈来愈迫切,SSL(Secure SocketsLayer ,安全套接层)应运而生。

    当对于安全需求,首先想到的就是对信息进行加密。SSL ,安全套接层,顾名思义是在 TCP 上提供的安全套接字层。其位于应用层和传输层之间,应用层数据不再直接传递给传输层而是传递给 SSL 层,SSL 层对从应用层收到的数据进行加密,利用数据加密、身份验证和消息完整性验证机制,为网络上数据的传输提供安全性保证。HTTPS 便是指 Hyper Text Transfer Protocol over SecureSocket Layer。

    谈到具体实施上,业内通常采用的一般有对称加密和非对称加密。采用何种方式进行加密?如何判断服务器未被篡改?如何传递加密密钥?带着这样的问题,我们来看看 HTTPS 的工作流程。

    1、客户端发起 HTTPS 请求

    这个没什么好说的,就是用户在浏览器里输入一个 HTTPS 网址,然后连接到 server 的 443 端口。

    2、服务端的配置

    采用 HTTPS 协议的服务器必须要有一套数字证书,可以自己制作,也可以向组织申请,区别就是自己颁发的证书需要客户端验证通过,才可以继续访问,而使用受信任的公司申请的证书则不会弹出提示页面(Let‘s Encrypt 就是个不错的选择,免费的 SSL 证书)。

    这套证书其实就是一对公钥和私钥,如果对公钥和私钥不太理解,可以想象成一把钥匙和一个锁头,只是全世界只有你一个人有这把钥匙,你可以把锁头给别人,别人可以用这个锁把重要的东西锁起来,然后发给你,因为只有你一个人有这把钥匙,所以只有你才能看到被这把锁锁起来的东西。

    3、传送证书

    这个证书其实就是公钥,只是包含了很多信息,如证书的颁发机构,过期时间等等。

    4、客户端解析证书

    这部分工作是有客户端的 TLS 来完成的,首先会验证公钥是否有效,比如颁发机构,过期时间等等,如果发现异常,则会弹出一个警告框,提示证书存在问题。

    如果证书没有问题,那么就生成一个随机值,然后用证书对该随机值进行加密,就好像上面说的,把随机值用锁头锁起来,这样除非有钥匙,不然看不到被锁住的内容。

    5、传送加密信息

    这部分传送的是用证书加密后的随机值,目的就是让服务端得到这个随机值,以后客户端和服务端的通信就可以通过这个随机值来进行加密解密了。

    6、服务段解密信息

    服务端用私钥解密后,得到了客户端传过来的随机值(私钥),然后把内容通过该值进行对称加密,所谓对称加密就是,将信息和私钥通过某种算法混合在一起,这样除非知道私钥,不然无法获取内容,而正好客户端和服务端都知道这个私钥,所以只要加密算法够彪悍,私钥够复杂,数据就够安全。

    7、传输加密后的信息

    这部分信息是服务段用私钥加密后的信息,可以在客户端被还原。

    8、客户端解密信息

    客户端用之前生成的私钥解密服务段传过来的信息,于是获取了解密后的内容,整个过程第三方即使监听到了数据,也束手无策。

    简单说完了 HTTPS 的工作流程。让我们再将注意力放在 SSL 的演化上。

    1994年,Netscape 创建了 SSL 协议的原始规范并逐步发布协议改进版本。1995 年发布 SSL 2.0。1996年,Netscape 和 Paul Kocher 共同设计发布 SSL 3.0 协议,获得互联网广泛认可和支持。因特网工程任务组(IETF)接手负责该协议,并将其重命名为 TLS(传输层安全)协议。

    我们看到,SSL 2.0 规范是在 1995 年左右发布的,而 SSL 3.0 是在 1996 年 11 月发布的。有趣的是,SSL 3.0 是在 RFC 6101 [https://tools.ietf.org/html/rfc6101] 中描述的,该 RFC 于 2011 年 8 月发布。它位于历史类别中,该类别通常是被考虑和被丢弃的文档想法,或者是在决定记录它们时已经具有历史意义的协议(根据 IETF[https://www.ietf.org/about/groups/iesg/statements/] 说明)。在这种情况下,有一个描述 SSL 3.0 的 IETF 文档是很有必要的,因为在其可以被用作规范参考。

    再来看看,SSL 是如何激发 TLS 的发展的。后者在 1996 年 11 月以 draft-ietf-tls-protocol-00 [https://tools.ietf.org/html/draft-ietf-tls-protocol-00] 宣告开始。它经历了六个草案版本,并于 1999 年初作为 RFC 2246 [https://tools.ietf.org/html/rfc2246] - TLS 1.0 正式发布。

    在 1995 和 1999 年间,SSL 和 TLS 协议用于保护互联网上的 HTTP 通信。这作为事实上的标准运行良好。直到 1998 年 1 月,随着 I-D draft-ietf-tls-HTTPs-00[https://tools.ietf.org/html/draft-ietf-tls-HTTPs-00] 的发布,HTTPS 的正式标准化过程才开始。该工作于 2000 年 5 月以 RFC 2616 - HTTP 上的 TLS 的发布结束。

    TLS 在 2000 到 2007 年间继续发展,标准化为 TLS 1.1 和 1.2。直至七年后,TLS 的下一个版本开始进行,该版本在 2014 年四月被采纳为 draft-ietf-tls-tls13-00[https://tools.ietf.org/html/draft-ietf-tls-tls13-00],并在 28 份草稿后,于 2018 年八月出了完成版本 RFC 8446 [https://tools.ietf.org/html/rfc8446] - TLS 1.3。



    改进与 HTTP2


    回到 HTTP 本身。在很长一段时间里,HTTP/1.1 已经足够好了(确实是,现在仍应用最为广泛),但是,Web 不断变化的需求使得我们需要一个更好更合适的协议。

    HTTP/1.1 自从 1997 年发布以来,我们已经使用 HTTP/1.x 相当长一段时间了。但随着互联网近十年爆炸式的发展,从当初网页内容以文本为主,到现在以富媒体(如图片、声音、视频)为主,而且对页面内容实时性高要求的应用越来越多(比如聊天、视频直播),所以当时协议规定的某些特性,已经逐渐无法满足现代网络的需求了。

    如果你有仔细观察,那些最流行的网站首页所需要下载资源的话,会发现一个非常明显的趋势。近年来加载网站首页需要下载的数据量在逐渐增加,并已经超过了 2100K。但在这里我们更关心的是:平均每个页面为了完成显示与渲染所需要下载的资源数也已经超过了 100 个。

    基于此,在 2010 年到 2015 年,谷歌通过实践一个实验性的 SPDY 协议,证明了一个在客户端和服务器端交换数据的另类方式。其收集了浏览器和服务器端的开发者的焦点问题,明确了响应数量的增加和解决复杂的数据传输。在启动 SPDY 这个项目时预设的目标是:

    • 页面加载时间 (PLT) 减少 50%。

    • 无需网站作者修改任何内容。

    • 将部署复杂性降至最低,无需变更网络基础设施。

    • 与开源社区合作开发这个新协议。

    • 收集真实性能数据,验证这个实验性协议是否有效。为了达到降低目标,减少页面加载时间的目标,SPDY 引入了一个新的二进制分帧数据层,以实现多向请求和响应、优先次序、最小化及消除不必要的网络延迟,目的是更有效地利用底层 TCP 连接。

    HTTP/1.1 有两个主要的缺点:安全不足和性能不高,由于背负着 HTTP/1.x 庞大的历史包袱,所以协议的修改,兼容性是首要考虑的目标,否则就会破坏互联网上无数现有的资产。

    而如上图所示,SPDY 位于 HTTP 之下,TCP 和 SSL 之上,这样可以轻松兼容老版本的 HTTP 协议同时可以使用已有的 SSL 功能。

    SPDY 协议在 Chrome 浏览器上证明可行以后,就被当作 HTTP/2 的基础,主要特性都在 HTTP/2 之中得到继承。

    于是时间来到 2015 年,HTTP/2.0 问世。

    HTTP/2 相比 HTTP/1.1 的修改并不会破坏现有程序的工作,但是新的程序可以借由新特性得到更好的速度。

    HTTP/2 保留了 HTTP/1.1 的大部分语义,例如请求方法、状态码、乃至 URI 和绝大多数 HTTP 头部字段一致。而 HTTP/2 采用了新的方法来编码、传输客户端和服务器间的数据。

    来看看 HTTP/2 的具体特点:

    • 二进制分帧层:在应用层与传输层之间增加一个二进制分帧层,以此达到在不改动 HTTP 的语义,HTTP 方法、状态码、URI 及首部字段的情况下,突破 HTTP/1.1 的性能限制,改进传输性能,实现低延迟和高吞吐量。在二进制分帧层上,HTTP/2.0 会将所有传输的信息分割为更小的消息和帧,并对它们采用二进制格式的编码,其中 HTTP1.x 的首部信息会被封装到 Headers 帧,而我们的 request body 则封装到 Data 帧里面。

    • 多路复用:对于 HTTP/1.x,即使开启了长连接,请求的发送也是串行发送的,在带宽足够的情况下,对带宽的利用率不够,HTTP/2.0 采用了多路复用的方式,可以并行发送多个请求,提高对带宽的利用率。

    • 数据流优先级:由于请求可以并发发送了,那么如果出现了浏览器在等待关键的 CSS 或者 JS 文件完成对页面的渲染时,服务器却在专注的发送图片资源的情况怎么办呢?HTTP/2.0 对数据流可以设置优先值,这个优先值决定了客户端和服务端处理不同的流采用不同的优先级策略。

    • 服务端推送:在 HTTP/2.0 中,服务器可以向客户发送请求之外的内容,比如正在请求一个页面时,服务器会把页面相关的 logo,CSS 等文件直接推送到客户端,而不会等到请求来的时候再发送,因为服务器认为客户端会用到这些东西。这相当于在一个 HTML 文档内集合了所有的资源。

    • 头部压缩:使用首部表来跟踪和存储之前发送的键值对,对于相同的内容,不会再每次请求和响应时发送。

    • HTTP/2.0 支持明文 HTTP 传输,而 SPDY 强制使用 HTTPS。

    • HTTP/2.0 消息头的压缩算法采用 HPACK,而非 SPDY 采用的 DEFLATE。



    QUIC 和 HTTP3


    虽然 HTTP/2 提高了网页的性能,但是并不代表它已经是完美的了,HTTP/3 就是为了解决 HTTP/2 所存在的一些问题而被推出来的。随着时间的演进,越来越多的流量都往手机端移动,手机的网络环境会遇到的问题像是封包丢失机率较高、较长的 Round Trip Time (RTT)和连接迁移等问题,都让主要是为了有线网路设计的HTTP/TCP协议遇到贫颈。

    我们可以看两个典型的问题。

    第一握手带来的消耗。HTTP/2 使用 TCP 协议来传输的,而如果使用 HTTPS 的话,还需要使用 TLS 协议进行安全传输,而使用 TLS 也需要一个握手过程,这样就需要有两个握手延迟过程:

    • 在建立 TCP 连接的时候,需要和服务器进行三次握手来确认连接成功,也就是说需要在消耗完 1.5 个 RTT 之后才能进行数据传输。

    • 进行 TLS 连接,TLS 有两个版本——TLS 1.2 和 TLS 1.3,每个版本建立连接所花的时间不同,大致是需要1~2个 RTT。

    总之,在传输数据之前,我们需要花掉 3~4 个 RTT。

    第二,TCP 的队头阻塞并没有得到彻底解决。我们知道,为了实现多路复用,在 HTTP/2 中多个请求是跑在一个 TCP 管道中的。但当出现了丢包时,HTTP/2 的表现反倒不如 HTTP/1.X 了。因为 TCP 为了保证可靠传输,有个特别的丢包重传机制,丢失的包必须要等待重新传输确认,HTTP/2 出现丢包时,整个 TCP 都要开始等待重传,那么就会阻塞该 TCP 连接中的所有请求。而对于 HTTP/1.1 来说,可以开启多个 TCP 连接,出现这种情况反到只会影响其中一个连接,剩余的 TCP 连接还可以正常传输数据。

    至此,我们很容易就会想到,为什么不直接去修改 TCP 协议?其实这已经是一件不可能完成的任务了。因为 TCP 存在的时间实在太长,已经充斥在各种设备中,并且这个协议是由操作系统实现的,更新起来非常麻烦,不具备显示操作性。

    HTTP/3 乘着 QUIC 来了。


    HTTP3 是基于 QUIC 的协议,如上图。先说 QUIC,QUIC 协议是 Google 提出的一套开源协议,它基于 UDP 来实现,直接竞争对手是 TCP 协议。QUIC 协议的性能非常好,甚至在某些场景下可以实现 0-RTT 的加密通信。

    在 Google 关于 QUIC [https://docs.google.com/document/d/1gY9-YNDNAB1eip-RTPbqphgySwSNSDHLq9D5Bty4FSU/edit] 的文件中提到,与 HTTP/2 相比,QUIC 主要具有下列优势:

    • Reduce connection establishment latency (减少连接建立时间)

    • Improved congestion control (改进拥塞控制)

    • Multiplexing without head-of-line blocking (没有队头阻塞的多路复用)

    • Forward error correction (修复之前的错误)

    • Connection migration(支持网络迁移)



    多路复用,避免队头阻塞



    这句话说起来很容易,但理解起来并不那么显然,要想理解 QUIC 协议到底做了什么以及这么做的必要性,我想还是从最基础的 HTTP/1.0 聊起比较合适

    Pipiline



    根据谷歌的调查, 现在请求一个网页,平均涉及到 80 个资源,30 多个域名。考虑最原始的情况,每请求一个资源都需要建立一次 TCP 请求,显然不可接受。HTTP 协议规定了一个字段 Connection,不过默认的值是 close,也就是不开启。

    早在 1999 年提出的 HTTP 1.1 [https://www.ietf.org/rfc/rfc2616.txt] 协议 中就把 Connection 的默认值改成了Keep-Alive,这样同一个域名下的多个 HTTP 请求就可以复用同一个 TCP 连接。这种做法被称为 HTTP Pipeline,优点是显著的减少了建立连接的次数,也就是大幅度减少了 RTT。

    以上面的数据为例,如果 80 个资源都要走一次 HTTP 1.0,那么需要建立 80 个 TCP 连接,握手 80 次,也就是 80 个 RTT。如果采用了 HTTP 1.1 的 Pipeline,只需要建立 30 个 TCP 连接,也就是 30 个 RTT,提高了 62.5% 的效率。

    Pipeline 解决了 TCP 连接浪费的问题,但它自己还存在一些不足之处,也就是所有管道模型都难以避免的队头阻塞问题。

    队头阻塞



    我们再举个简单而且直观的例子,假设加载一个 HTML 一共要请求 10 个资源,那么请求的总时间是每一个资源请求时间的总和。最直观的体验就是,网速越快请求时间越短。然而如果某一个资源的请求被阻塞了(比如 SQL 语句执行非常慢)。但对于客户端来说所有后续的请求都会因此而被阻塞。


    队头阻塞(Head of line blocking,下文简称 HOC)说的是当有多个串行请求执行时,如果第一个请求不执行完,后续的请求也无法执行。比如上图中,如果第四个资源的传输花了很久,后面的资源都得等着,平白浪费了很多时间,带宽资源没有得到充分利用。

    因此,HTTP 协议允许客户端发起多个并行请求,比如在笔者的机器上最多支持六个并发请求。并发请求主要是用于解决 HOC 问题,当有三个并发请求时,情况会变成这样:


    可见虽然第四个资源的请求被阻塞了,但是其他的资源请求并不一定会被阻塞,这样总的来说网络的平均利用率得到了提升。

    支持并发请求是解决 HOC 问题的一种方案,这句话没有错。但是我们要理解到:“并发请求并非是直接解决了 HOC 的问题,而是尽可能减少 HOC 造成的影响“,以上图为例,HOC 的问题依然存在,只是不会太浪费带宽而已。

    有读者可能会好奇,为什么不多搞几个并发的 HTTP 请求呢?刚刚说过笔者的电脑最多支持 6 个并发请求,谷歌曾经做过实验,把 6 改成 10,然后尝试访问了三千多个网页,发现平均访问时间竟然还增加了 5% 左右。这是因为一次请求涉及的域名有限,再多的并发 HTTP 请求并不能显著提高带宽利用率,反而会消耗性能。

    SPDY 的做法



    有没有办法解决队头阻塞呢?

    答案是肯定的。SPDY 协议的做法很值得借鉴,它采用了多路复用(Multiplexing)技术,允许多个 HTTP 请求共享同一个 TCP 连接。我们假设每个资源被分为多个包传递,在 HTTP 1.1 中只有前面一个资源的所有数据包传输完毕后,后面资源的包才能开始传递(HOC 问题),而 SPDY 并不这么要求,大家可以一起传输。

    这么做的代价是数据会略微有一些冗余,每一个资源的数据包都要带上标记,用来指明自己属于哪个资源,这样客户端最后才能把他们正确的拼接起来。不同的标记可以理解为图中不同的颜色,每一个小方格可以理解为资源的某一个包。

    TCP 窗口



    是不是觉得 SPDY 的多路复用已经够厉害了,解决了队头阻塞问题?很遗憾的是,并没有,而且我可以很肯定的说,只要你还在用 TCP 链接,HOC 就是逃不掉的噩梦,不信我们来看看 TCP 的实现细节。

    我们知道 TCP 协议会保证数据的可达性,如果发生了丢包或者错包,数据就会被重传。于是问题来了,如果一个包丢了,那么后面的包就得停下来等这个包重新传输,也就是发生了队头阻塞。当然 TCP 协议的设计者们也不傻,他们发明了滑动窗口的概念:


    这样的好处是在第一个数据包(1-1000) 发出后,不必等到 ACK 返回就可以立刻发送第二个数据包。可以看出图中的 TCP 窗口大小是 4,所以第四个包发送后就会开始等待,直到第一个包的 ACK 返回。这样窗口可以向后滑动一位,第五个包被发送。

    如果第一、二、三个的包都丢失了也没有关系,当发送方收到第四个包时,它可以确信一定是前三个 ACK 丢了而不是数据包丢了,否则不会收到 4001 的 ACK,所以发送方可以大胆的把窗口向后滑动四位。

    滑动窗口的概念大幅度提高了 TCP 传输数据时抗干扰的能力,一般丢失一两个 ACK 根本没关系。但如果是发送的包丢失,或者出错,窗口就无法向前滑动,出现了队头阻塞的现象。

    QUIC 是如何做的



    所以说 HOC 不仅仅在 HTTP 层存在,在 TCP 层也存在,这也正是 QUIC 协议要解决的问题。回顾 SPDY 是如何解决 HOC 的,没错,多路复用(Multiplex)。QUIC 协议也采用了多路复用技术。


    QUIC 协议基于 UDP 实现,我们知道 UDP 协议只负责发送数据,并不保证数据可达性。这一方面为 QUIC 的多路复用提供了基础,另一方面也要求 QUIC 协议自己保证数据可达性。

    SPDY 为各个数据包做好标记,指明他们属于哪个 HTTP 请求,至于这些包能不能到达客户端,SPDY 并不关心,因为数据可达性由 TCP 协议保证。既然客户端一定能收到包,那就只要排序、拼接就行了。QUIC 协议采用了多路复用的思想,但同时还得自己保证数据的可达性。

    TCP 协议的丢包重传并不是一个好想法,因为一旦有了前后顺序,队头阻塞问题将不可避免。而无序的数据发送给接受者以后,如何保证不丢包,不错包呢?这看起来是个不可能完成的任务,不过如果把要求降低成:最多丢一个包,或者错一个包。事情就简单多了,操作系统中有一种存储方式叫 RAID 5,采用的是异或运算加上数据冗余的方式来保证前向纠错(FEC: Forward Error Correcting)。QUIC 协议也是采用这样的思想,这里不再赘述。

    利用冗余数据的思想,QUIC 协议基本上避免了重发数据的情况。当然 QUIC 协议还是支持重传的,比如某些非常重要的数据或者丢失两个包的情况。



    少 RTT,请求更快速



    前面说到,一次 HTTPS 请求,它的基本流程是三次 TCP 握手外加四次 SSL/TLS 握手。也就是需要三个 RTT。但是 QUIC 在某些场景下,甚至能够做到 0RTT。

    首先介绍下什么是 0RTT。所谓的 0RTT 就是通信双方发起通信连接时,第一个数据包便可以携带有效的业务数据。而我们知道,这个使用传统的TCP是完全不可能的,除非你使能了 TCP 快速打开特性,而这个很难,因为几乎没人愿意为了这个收益去对操作系统的网络协议栈大动手脚。未使能 TCP 快速打开特性的TCP传输第一笔数据前,至少要等1个RTT。

    我们这里再说说 HTTP2。对于 HTTP2 来说,本来需要一个额外的 RTT 来进行协商,判断客户端与服务器是不是都支持 HTTP2,不过好在它可以和 SSL 握手的请求合并。这也导致了一个现象,就是大多数主流浏览器仅支持 HTTPS2 而不单独支持 HTTP2。因为 HTTP2 需要一个额外的 RTT,HTTPS2 需要两个额外的 RTT,仅仅是增加一个 RTT 就能获得数据安全性,还是很划算的。

    TCP 快速打开



    何谓 TCP 快速打开,即客户端可以在发送第一个 SYN 握手包时携带数据,但是 TCP 协议的实现者不允许将把这个数据包上传给应用层。这主要是为了防止 TCP 泛洪攻击[https://tools.ietf.org/html/rfc4987]

    因为如果 SYN 握手的包能被传输到应用层,那么现有的防护措施都无法防御泛洪攻击,而且服务端也会因为这些攻击而耗尽内存和 CPU。

    当然 TCP 快速打开并不是完全不可行的。人们设计了 TFO (TCP Fast Open),这是对 TCP 的拓展,不仅可以在发送 SYN 时携带数据,还可以保证安全性。

    TFO 设计了一个 Cookie,它在第一次握手时由 server 生成,Cookie 主要是用来标识客户端的身份,以及保存上次会话的配置信息。因此在后续重新建立 TCP 连接时,客户端会携带 SYN + Cookie + 请求数据,然后不等 ACK 返回就直接开始发送数据。


    服务端收到 SYN 后会验证 Cookie 是否有效,如果无效则会退回到三次握手的步骤,如下图所示:


    同时,为了安全起见,服务端为每个端口记录了一个值 PendingFastOpenRequests,用来表示有多少请求利用了 TFO,如果超过预设上限就不再接受。

    关于 TFO 的优化,可以总结出三点内容:

    • TFO 设计的 Cookie 思想和 SSL 恢复握手时的 Session Ticket 很像,都是由服务端生成一段 Cookie 交给客户端保存,从而避免后续的握手,有利于快速恢复。

    • 第一次请求绝对不会触发 TFO,因为服务器会在接收到 SYN 请求后把 Cookie 和 ACK 一起返回。后续客户端如果要重新连接,才有可能使用这个 Cookie 进行 TFO

    • TFO 并不考虑在 TCP 层过滤重复请求,以前也有类似的提案想要做过滤,但因为无法保证安全性而被拒绝。所以 TFO 仅仅是避免了泛洪攻击(类似于 backlog),但客户端接收到的,和 SYN 包一起发来的数据,依然有可能重复。不过也只有可能是 SYN 数据重复,所以 TFO 并不处理这种情况,要求服务端程序自行解决。这也就是说,不仅仅要操作系统的支持,更要求应用程序(比如 MySQL)也支持 TFO。

    TFO 使得 TCP 协议有可能变成 0-RTT,核心思想和 Session Ticket 的概念类似: 将当前会话的上下文缓存在客户端。如果以后需要恢复对话,只需要将缓存发给服务器校验,而不必花费一个 RTT 去等待。

    结合 TFO 和 Session Ticket 技术,一个本来需要花费 3 个 RTT 才能完成的请求可以被优化到一个 RTT。如果使用 QUIC 协议,我们甚至可以更进一步,将 Session Ticket 也放到 TFO 中一起发送,这样就实现了 0-RTT 的对话恢复。

    QUIC 是怎么做的



    让我们看看 QUIC 是怎么做的。

    首先声明一点,如果一对使用 QUIC 进行加密通信的双方此前从来没有通信过,那么 0-RTT 是不可能的,即便是 QUIC 也是不可能的。

    QUIC 握手的过程需要一次数据交互,0-RTT 时延即可完成握手过程中的密钥协商,比 TLS 相比效率提高了 5 倍,且具有更高的安全性。在握手过程中使用 Diffie-Hellman 算法协商初始密钥,初始密钥依赖于服务器存储的一组配置参数,该参数会周期性的更新。初始密钥协商成功后,服务器会提供一个临时随机数,双方根据这个数再生成会话密钥。

    具体握手过程如下:

    (1) 客户端判断本地是否已有服务器的全部配置参数,如果有则直接跳转到(5),否则继续

    (2) 客户端向服务器发送 inchoate client hello(CHLO) 消息,请求服务器传输配置参数

    (3) 服务器收到 CHLO,回复 rejection(REJ) 消息,其中包含服务器的部分配置参数

    (4) 客户端收到 REJ,提取并存储服务器配置参数,跳回到(1)

    (5) 客户端向服务器发送 full client hello 消息,开始正式握手,消息中包括客户端选择的公开数。此时客户端根据获取的服务器配置参数和自己选择的公开数,可以计算出初始密钥。

    (6) 服务器收到 full client hello,如果不同意连接就回复 REJ,同(3);如果同意连接,根据客户端的公开数计算出初始密钥,回复 server hello(SHLO)消息,SHLO 用初始密钥加密,并且其中包含服务器选择的一个临时公开数。

    (7) 客户端收到服务器的回复,如果是 REJ 则情况同(4);如果是 SHLO,则尝试用初始密钥解密,提取出临时公开数

    (8) 客户端和服务器根据临时公开数和初始密钥,各自基于 SHA-256 算法推导出会话密钥

    (9) 双方更换为使用会话密钥通信,初始密钥此时已无用,QUIC 握手过程完毕。之后会话密钥更新的流程与以上过程类似,只是数据包中的某些字段略有不同。



    写在最后


    想起有一个名言:计算机领域没有什么问题是加一层解决不了的,如果有,就再加一层。网络模型本来就是层层累加,到了 Web 得以快速生动的展现给人们以丰富的内容。从 HTTP 的演变过程中,我们可以看到中间又累加了若干层。不知道以后,又会是怎么样呢?

    大家会发现,笔者在文中不止一次提到了演变这个词。是的,这是来自达尔文进化论中的理论。在笔者看来,“物竞天择,适者生存”的演变理论和计算机领域的技术变化是很类似的,只不过在这里,不是天择,而是人择。由市场,由用户来选择。不知道接下来,作为选择者的我们,又将怎样主导技术的走向?



    快来找又小拍




    返回