Contents
  1. 1. RTT
    1. 1.1. QUIC 协议概述
  2. 2. 多路复用,避免队头阻塞
    1. 2.1. Pipiline
    2. 2.2. 队头阻塞
    3. 2.3. SPDY
    4. 2.4. 茶歇小结
    5. 2.5. TCP 窗口
    6. 2.6. QUIC 多路复用与纠错
  3. 3. 更少的 RTT
    1. 3.1. SSL 握手优化
    2. 3.2. TCP 快速打开
    3. 3.3. 0-RTT
  4. 4. Why QUIC
  5. 5. 总结
  6. 6. 参考资料:

QUIC 协议是 Google 提出的一套开源协议,它基于 UDP 来实现,直接竞争对手是 TCP 协议。QUIC 协议的性能非常好,甚至在某些场景下可以实现 0-RTT 的加密通信。所以这篇文章主要讨论以下几个问题:

  1. QUIC 协议有哪些优点,如何实现 0-RTT
  2. 传统的 HTTP2 + SSL + TCP 协议栈有哪些缺点
  3. 为什么 Google 要另起炉灶,基于 UDP 去做

这三个问题并不独立,所以我不会依次解答他们,我选择从一些常识入手,逐渐深入到底层细节。希望读者能够带着问题阅读并最终获得满意的答案。

RTT

网络请求中一个常见的名词是 RTT(Round Trip Time),表示客户端从发出一个请求数据,到接收到响应数据之间间隔的时间。

RTT

RTT 可以理解成由两部分组成,一部分受到物理条件的限制,比如间隔距离除以信号传递速度,以及包大小除以带宽。另一部分则是客户端、服务器以及沿途各路由器对包的处理解析时间。一般情况下,RTT 大约在几十毫秒左右,网络很好的情况下可以达到个位数,恶劣网络环境下达到几百毫秒也有可能。

根据阮一峰的 SSL延迟有多大? 一文中做的计算,用户访问支付宝时,一个 RTT 大约需要 22ms,算上 SSL 握手的三个 RTT 则大约消耗了 64ms。

可见网络请求时间绝对不是简单的数据量除以网速这么简单, RTT 是网络请求耗时中不可忽略的一部分,不仅仅是握手阶段需要三个 RTT,在实际网络请求中,还有可能因为丢包等问题而额外增加 RTT。因此任何一个能减少 RTT 的技术都值得认真考虑,因为他们真的能够显著降低网络请求耗时。

QUIC 协议概述

下面进入正题,本文的主角,QUIC 协议主要具备以下优点:

  1. 多路复用,避免队头阻塞
  2. 减少 RTT,请求更快速
  3. 快速迭代,广泛支持

多路复用,避免队头阻塞

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

Pipiline

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

早在 1999 年提出的 HTTP 1.1 协议 中就把 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 语句执行非常慢)。但对于客户端来说所有后续的请求都会因此而被阻塞。

HOC

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

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

Multi Request

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

支持并发请求是解决解决 HOC 问题的一种方案,这句话没有错,但是我们要理解到: “并发请求并非是直接解决了 HOC 的问题,而是尽可能减少 HOC 造成的影响”,以上图为例,HOC 的问题依然存在,只是不会太浪费带宽而已。有读者可能会好奇,为什么不多搞几个并发的 HTTP 请求呢?刚刚说过笔者的电脑最多支持 6 个并发请求,谷歌曾经做过实验,把 6 改成 10,然后尝试访问了三千多个网页,发现平均访问时间竟然还增加了 5% 左右。这是因为一次请求涉及的域名有限,再多的并发 HTTP 请求并不能显著提高带宽利用率,反而会消耗性能。

SPDY

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

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

有些读者对 SPDY 协议可能不太了解,其实把它当做 HTTP2 的前身和试验品就好。当然 HTTP2.0 的好处远远不止这些,比如我们可以很容易的基于 HTTP2.0 实现长连接,而以往的选择要么是用更底层的 TCP,要么是使用与 HTTP 同级的 Web Socket 协议。现在 HTTP 协议直接支持了长连接,对开发者而言确实是一大利好。考虑到本篇文章主要是讨论 QUIC 协议相关,就不对 HTTP 2.0 做详细分析了。

茶歇小结

刚刚聊到了三个技术名词,Pipeline、并发请求和多路复用,千万不要被绕晕了。

Pipeline 是为了减少不必要的 TCP 连接,但依然存在队头阻塞(HOC)的缺点,一种解决思路是利用并发连接减少某一个 HOC 的影响,另一个是共享(注意与复用的区别) TCP 连接,直接避免 HOC 问题的发生。

TCP 窗口

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

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

TCP 窗口

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

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

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

QUIC 多路复用与纠错

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

QUIC 多路复用

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

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

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

我们知道异或运算的规则是,0 ^ 1 = 11 ^ 1 = 0,也就是相同数字异或成 1,不同数字异或成 0。对两个数字做异或运算,其实就是将他们转成二进制后按位做异或,因此对于任何数字 a,都有:

1
2
a ^ a = 0
a ^ 0 = a

同时很容易证明异或运算满足交换律和结合律,我们假设有下面这个等式:

1
A1 ^ A2 ^ A3 ^ ... ^ An = T

如果想让等式的左边只留下一个一个元素,只要在等号两边做 n-1 次异或就可以了:

1
2
3
4
5
6
7
(A1 ^ A1) ^ A2 ^ A3 ^ ... ^ An = T ^ A1
// 所以
A2 ^ A3 ^ ... ^ An = T ^ A1
// 所以
A3 ^ ... ^ An = T ^ A1 ^ A2
// 所以 ......
Ai = T ^ A1 ^ A2 ^ ... Ai-1 ^ Ai+1 ^ Ai+2 ^ ... ^ An

换句话说,A1 到 An 和 T 这总共 n+1 个元素中,不管是任何一个元素缺失,都可以从另外 n 个元素推导出来。如果把 A1、A2 一直到 An 想象成要发送的数据,T 想象成冗余数据,那么除了丢包重传,我们还可以采用冗余数据包的形式来保证数据准确性。

举个例子,假设有 5 个数据包要发送,我可以额外发送一个包(上面例子中的 T),它的值是前五个包的异或结果。这样不管是前五个包中丢失了任何一个,或者某个包数据有错(可以当成丢包来处理),都可以用另外四个包和这个冗余的包 T 进行异或运算,从而恢复出来。

当然要注意的是,这种方案仅仅在只发生一个错包或丢包时有效,如果丢失两个包就无能为力了(这也就是为什么只发一个冗余包就够的原因)。因此数据包和冗余包之间的比值需要精心设计,如果比值过高,很容易出现丢两个包的情况,如果比值过低,又会导致冗余度太高,需要设计者根据概率计算结果进行权衡。

利用冗余数据的思想,QUIC 协议基本上避免了重发数据的情况,这种利用已有数据就能进行错误恢复的技术叫做前向恢复(FEC: Fowrard Error Correcting)。当然 QUIC 协议还是支持重传的,比如某些非常重要的数据或者丢失两个包的情况。

更少的 RTT

我们考虑一次 HTTPS 请求,它的基本流程是三次 TCP 握手外加四次 SSL/TLS 握手,从图中可以看到这需要三个 RTT:

3 RTT

对于 HTTP 2.0 来说,本来需要一个额外的 RTT 来进行协商,判断客户端与服务器是不是都支持 HTTP 2.0,不过好在它可以和 SSL 握手的请求合并。这也就是为什么大多数主流浏览器(比如 Chrome、Firefox) 仅支持 HTTPS 2.0 而不单独支持 HTTP 2.0 的原因,毕竟 HTTP 2.0 需要一个额外的 RTT,HTTPS 2.0 需要两个额外的 RTT,仅仅是增加一个 RTT 就能获得数据安全性,还是很划算的。

SSL 握手优化

有关 HTTPS 的详细解释可以参考我之前的文章: 九个问题从入门到熟悉HTTPS,这里我们简单复习一下 SSL 握手的大致流程:

  1. 客户端发送第一个握手,包含一个随机数,以及对协议的支持情况(版本、加密方法、压缩方法等)
  2. 服务器返回证书,以及服务端生成随机数
  3. 客户端校验证书,生成一个新的随机数,用证书中的公钥加密后发给服务端
  4. 服务端确认消息,双方根据上述三个随机数生成后续会话的公钥

由于需要确认证书,生成多个随机数来保证安全,握手阶段的两个 RTT 很难节省。不过之前我们见过 HTTP 的 Pipeline 技术可以复用 TCP 连接,那么按照类似的思想,SSL 连接也可以被恢复。思考一下为什么 SSL 要设计这么复杂的握手机制,它本质上是为了保证对称秘钥的安全传输,所以 SSL 会话恢复主要考虑的也是如何恢复对称秘钥。

一个常用的方案是采用 Session Ticket,实现起来很容易: 一旦 SSL 会话建立起来,服务端把会话的基本信息,比如对称秘钥、加密方法等信息加密后发给客户端,客户端可以缓存下来这个 Session Ticket。需要恢复 SSL 会话时直接把它发回给服务端校验即可,这样可以在 SSL 层减少一个 RTT。

TCP 快速打开

聊完了 SSL 层,下面说说 TCP 的优化方案。我们都知道 TCP 的三次握手需要花费一个 RTT,有没有可能做到 0-RTT 呢?比如我们在握手的时候就带上要传递的数据。

实际上 TCP 协议已经规定了这种情况的处理方式,即客户端可以在发送第一个 SYN 握手包时携带数据,但是 TCP 协议的实现者绝对不允许(原文: MUST NOT) 把这个数据包上传给应用层。这主要是为了防止 TCP 泛洪攻击。

TCP 泛洪攻击是指攻击者利用多台机器发送 SYN 请求从而耗尽服务器的 backlog 队列,backlog 队列维护的是那些接受了 SYN 请求但还没有正式开始会话的连接。这样做的好处是服务器不会过早的分配端口、建立连接。RFC 4987 详细的描述了各种防止 TCP 泛洪攻击的方法,包括尽早释放 SYN,增加队列长度等等。

如果 SYN 握手的包能被传输到应用层,那么现有的防护措施都无法防御泛洪攻击,而且服务端也会因为这些攻击而耗尽内存和 CPU。所以人们设计了 TFO (TCP Fast Open),这是对 TCP 的拓展,不仅可以在发送 SYN 时携带数据,还可以保证安全性。

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

TFO 工作示意图

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

回退到普通三次握手

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

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

  1. TFO 设计的 cookie 思想和 SSL 恢复握手时的 Session Ticket 很像,都是由服务端生成一段 cookie 交给客户端保存,从而避免后续的握手,有利于快速恢复。
  2. 第一次请求绝对不会触发 TFO,因为服务器会在接收到 SYN 请求后把 cookie 和 ACK 一起返回。后续客户端如果要重新连接,才有可能使用这个 cookie 进行 TFO
  3. TFO 并不考虑在 TCP 层过滤重复请求,以前也有类似的提案想要做过滤,但因为无法保证安全性而被拒绝。所以 TFO 仅仅是避免了泛洪攻击(类似于 backlog),但客户端接收到的,和 SYN 包一起发来的数据,依然有可能重复。不过也只有可能是 SYN 数据重复,所以 TFO 并不处理这种情况,要求服务端程序自行解决。这也就是说,不仅仅要操作系统的支持,更要求应用程序(比如 MySQL) 也支持 TFO。

0-RTT

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

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

Why QUIC

从以上分析可以发现,HTTP2 和 SSL 可以说已经进行了大量的优化,可以提升的空间非常小。而 TCP 存在诸多不足之处,一方面它设计较早,而且主要目的是设计一种通用、可靠的传输协议,并非专门为网页或者 App 而设计,另一方面对 TCP 的改进要比对 SSL 和 HTTP 的改进麻烦的多,因为 TCP 是由各个操作系统实现,就以 TFO 为例吧,它在新版本的 Linux 内核中被实现,但想等到它普及开来就不知道要到猴年马月了,有兴趣的读者可以参考参考现在 Windows XP 系统的市场占有率。

反观 HTTP 和 SSL,虽然早期 HTTP 1.0 的问题更多,但是经过 1.1、SPDY、2.0 等版本的更迭,已经非常优秀了。其中的根本原因还是在于 HTTP 和 SSL 位于应用层,优化升级比较容易实现,所以经过长年累月的优化升级,现在大部分瓶颈都集中于 TCP 层。但 TCP 不仅优化点较多,而且还不容易更新。那么能不能在传输层搞一个和 TCP、UDP 类似的协议呢?答案也是否定的,其实曾经有一个 SCTP 协议打算进行一系列优化,但并没有被广泛接受。这是因为数据在传输的过程中需要经过各个路由器,这些设备只能识别并解析 TCP 和 UDP 协议的数据包,无法解析新的协议。所以 SCTP 也只能用于内网的实验环境中。

TCP 要改进,但不方便改,新增一个协议又不被已有的设备支持,看起来唯一的方案就是使用 UDP 了。虽然 UDP 协议不保证数据可达性,但这也是 UDP 的优点所在,它天然支持 0-RTT 的通信,所以一个比较新颖激进的想法就冒出来了:

采用 UDP 作为底层协议,在 UDP 之上实现数据可达性

目前,QUIC 协议内置在 Chrome 浏览器中,每次更新只需要升级浏览器即可,在 2014 年前就已经迭代了 13 个版本。

总结

最后,作为总结,我简单回答一下文章开头的几个问题:

  1. QUIC 协议有哪些优点,如何实现 0-RTT?
    1. QUIC 协议在传输层就支持多路复用,避免了队头阻塞问题。
    2. QUIC 协议基于 UDP,更自由更高效
    3. QUIC 协议借鉴了 TFO 的思想,支持会话上下文缓存,方便恢复,具备实现 0-RTT 的可能
  2. 传统的 HTTP2 + SSL + TCP 协议栈有哪些缺点?
    1. SSL 的会话恢复依然需要一个 RTT,而且难以合并到 TCP 层
    2. TCP 的滑动窗口存在队头阻塞问题
    3. TCP 的重传纠错会浪费一个 RTT
  3. 为什么 Google 要另起炉灶,基于 UDP 去做?
    1. TCP 由操作系统实现,很难更新
    2. UDP 非常高效,几乎没有性能负担
    3. 将 QUIC 嵌入到 Chrome 中可以方便后续的升级迭代

参考资料:

  1. 图解SSL/TLS协议
  2. SSL/TLS协议运行机制的概述
  3. Facebook App对TLS的魔改造:实现0-RTT
  4. 视频: QUIC: next generation multiplexed transport over UDP
  5. 官方文档: QUIC Wire Layout Specification
Contents
  1. 1. RTT
    1. 1.1. QUIC 协议概述
  2. 2. 多路复用,避免队头阻塞
    1. 2.1. Pipiline
    2. 2.2. 队头阻塞
    3. 2.3. SPDY
    4. 2.4. 茶歇小结
    5. 2.5. TCP 窗口
    6. 2.6. QUIC 多路复用与纠错
  3. 3. 更少的 RTT
    1. 3.1. SSL 握手优化
    2. 3.2. TCP 快速打开
    3. 3.3. 0-RTT
  4. 4. Why QUIC
  5. 5. 总结
  6. 6. 参考资料:
Fork me on GitHub