既然有 HTTP/3
的诞生,那么就说明在 HTTP/1.1
和 HTTP/2
当中是存在一些问题的,下面我们就先从这个根本上的问题开始看起
本文主要参考的是 HTTP/3 原理与实践,内容有所调整,主要是方便自己理解,更为详细的内容可以参考原文
RTT
RTT
是 Round Trip Time
的缩写,通俗地说就是通信一来一回的时间,下面我们就先来看看在连接过程当中需要哪些 RTT
TCP 建立连接时间
最早大家使用 TCP
来运输 HTTP
,TCP
需要三次握手,建立了 TCP
虚拟通道,那么这三次握手需要几个 RTT
时间呢?
- 一去(
SYN
) - 二回(
SYN + ACK
) - 三去(
ACK
)
相当于一个半来回,故 TCP
连接的时间 = 1.5 RTT
HTTP 通信时间
这意味着,用户在浏览器里输入的网址 URL
,直到时间流逝了 1.5 RTT
之后,TCP
才开始运输 HTTP Request
,浏览器收到服务器的 HTTP Response
,又要等待的时间为
- 一去(
HTTP Request
) - 二回(
HTTP Responses
)
故 HTTP
的通信时间 = 1 RTT
,那么我们可以大致得出,基于 TCP
传输的 HTTP
通信,一共花费的时间总和
1 | HTTP 通信时间总和 = TCP 连接时间 + HTTP 通信时间 = 1.5 RTT + 1 RTT = 2.5 RTT |
HTTPS
随着互联网的爆发式增长,人类发现完全明文传输的 HTTP
通信很不安全,做为 OSI
七层参考模型的现实实现的 TCP/IP
协议,在设计之初没有考虑安全加密的环节,互联网先驱 Netscape
公司,创造性发明了 SSL
(Secure Socket Layer
),SSL
位于 TCP
与 HTTP
之间,做为 HTTP
的安全供应商,全权负责 HTTP
的安全加密工作,也就像下面这样
1 | IP / TCP / SSL / [HTTP] |
随着 SSL
的名气攀升,互联网标准化组织 IETF
在 SSL 3.0
版本的基础上,重新设计并命名了这个协议,其全新的名字为 TLS
(Transport Layer Security
),最初的版本为 1.0
版本,从其名字就可以看出,其核心使命就是保证传输层的安全,各个通信部门成员的占位与 SSL
占位一致
1 | IP / TCP / TLS / [HTTP] |
我们通常将 TLS
安全保护的 HTTP
通信,称之为 HTTPS
,以区别于没有 TLS
安全防护的 HTTP
明文通信,那么下面我们就以 1.2
版本为例,看看 HTTPS
通信一共要消耗几个 RTT
时间?
- 浏览器给服务器发送的
Client Hello
消息(一去) - 服务器给浏览器发送的
Server Hello
消息(二回) - 浏览器给服务器发送的
Key Exchange
消息(三去)
所以我们可以得出
1 | HTTPS 通信时间总和 = TCP 连接时间 + TLS 连接时间 + HTTP 通信时间 = 1.5 RTT + 1.5 RTT + 1 RTT = 4 RTT |
如果浏览器与服务器物理距离很近,RTT < 10 ms
,即使 4 RTT
最大也不过 40 ms
的时间,用户压根感觉不到慢,但是如果浏览器与服务器相隔上万公里,一个 RTT
时间通常在 200ms
以上,4RTT
时间通常在 1
秒以上,用户会明显感觉到网速慢了
HTTP/1.x
浏览器从服务器获取的一个页面,通常由很多资源链接所组成,服务器给浏览器推送的第一个页面,页面里通常嵌入了图片资源文本链接、以及动态页面资源链接、或第三方网站的链接资源,还需要浏览器根据这些文本链接内容,去链接所对应的服务器,继续下载链接所对应的内容
浏览器通常采用的流程是,重新建立一个 TCP
连接、TLS
连接、HTTP
通信,这又是一个漫长的 4RTT
等待过程,用户看到浏览器完整页面的时间为
1 | 完整页面加载时间 = 4 RTT * 2 = 8 RTT |
HTTP/2
既然第一次页面与第二次页面都是同一个网站服务器,为何第二次页面要重新建立一个 TCP
连接,一个 TLS
连接?如果重用第一个 TCP
连接,那么就少了 1.5 RTT + 1.5 RTT = 3 RTT
的时间
的确,用户的多个 HTTP Request
请求,使用同一个逻辑通道进行运输,这样会大大减少重新建立连接所花费的时间,但是,这样会带来一个副作用,多个 HTTP
流使用同一个 TCP
连接,遵守同一个流量状态控制,只要第一个 HTTP
流遭遇到拥塞,剩下的 HTTP
流压根没法发出去,这就是头部阻塞(Head of line Blocking
)
至此,我们可以发现问题的根源所在,那就是 TCP
连接,那么有人就会问了,我们不使用 TCP
不就可以了吗?所以这也就引出了我们今天的主角,HTTP/3
和 QUIC
HTTP/3
在介绍 HTTP/3
之前,我们先简单看下 HTTP
的历史,了解下 HTTP/3
出现的背景
随着网络技术的发展,1999
年设计的 HTTP/1.1
已经不能满足需求,所以 Google
在 2009
年设计了基于 TCP
的 SPDY
,后来 SPDY
的开发组推动 SPDY
成为正式标准,不过最终没能通过,不过 SPDY
的开发组全程参与了 HTTP/2
的制定过程,参考了 SPDY
的很多设计,所以我们一般认为 SPDY
就是 HTTP/2
的前身
无论 SPDY
还是 HTTP/2
,都是基于 TCP
的,TCP
与 UDP
相比效率上存在天然的劣势,所以 2013
年 Google
开发了基于 UDP
的名为 QUIC
的传输层协议,QUIC
全称 Quick UDP Internet Connections
,希望它能替代 TCP
,使得网页传输更加高效,后经提议,互联网工程任务组正式将基于 QUIC
协议的 HTTP
(HTTP over QUIC
)重命名为 HTTP/3
关于
UDP
与TCP
的区别可见之前整理过的 TCP 和 UDP 的比较
QUIC 协议
TCP
一直是传输层中举足轻重的协议,而 UDP
则默默无闻,长期以来 UDP
给人的印象就是一个很快但不可靠的传输层协议,正是看中了 UDP
的速度与效率,同时 QUIC
也整合了 TCP
、TLS
和 HTTP/2
的优点,并加以优化,用一张图可以清晰地表示他们之间的关系
那 QUIC
和 HTTP/3
什么关系呢?QUIC
是用来替代 TCP
、SSL/TLS
的传输层协议,在传输层之上还有应用层,我们熟知的应用层协议有 HTTP
、FTP
、IMAP
等,这些协议理论上都可以运行在 QUIC
之上,其中运行在 QUIC
之上的 HTTP
协议被称为 HTTP/3
,这就是 HTTP over QUIC
即 HTTP/3
的含义
因此想要了解 HTTP/3
,QUIC
是绕不过去的,下面我们就通过几个比较重要的特性来简单的了解一下 QUIC
相关内容
零 RTT 建立连接
用一张图可以形象地看出 HTTP/2
和 HTTP/3
建立连接的差别
HTTP/2
的连接需要 3 RTT
,如果考虑会话复用,即把第一次握手算出来的对称密钥缓存起来,那么也需要 2 RTT
,更进一步的,如果 TLS
升级到 1.3
,那么 HTTP/2
连接需要 2 RTT
,考虑会话复用则需要 1 RTT
,有人会说 HTTP/2
不一定需要 HTTPS
,握手过程还可以简化,这没毛病,HTTP/2
的标准的确不需要基于 HTTPS
,但实际上所有浏览器的实现都要求 HTTP/2
必须基于 HTTPS
,所以 HTTP/2
的加密连接必不可少,而 HTTP/3
首次连接只需要 1 RTT
,后面的连接更是只需 0 RTT
,意味着客户端发给服务端的第一个包就带有请求数据,这一点在 HTTP/2
当中是难以实现的,那这背后又是什么原理呢?我们可以具体看下 QUIC
的连接过程
- 首次连接时,客户端发送
Inchoate Client Hello
给服务端,用于请求连接 - 服务端生成
g、p、a
,根据g、p
和a
算出A
,然后将g、p、A
放到Server Config
中再发送Rejection
消息给客户端 - 客户端接收到
g、p、A
后,自己再生成b
,根据g、p、b
算出B
,根据A、p、b
算出初始密钥K
,B
和K
算好后,客户端会用K
加密HTTP
数据,连同B
一起发送给服务端 - 服务端接收到
B
后,根据a、p、B
生成与客户端同样的密钥,再用这密钥解密收到的HTTP
数据,为了进一步的安全(前向安全性),服务端会更新自己的随机数a
和公钥,再生成新的密钥S
,然后把公钥通过Server Hello
发送给客户端,连同Server Hello
消息,还有HTTP
返回数据 - 客户端收到
Server Hello
后,生成与服务端一致的新密钥S
,后面的传输都使用S
加密
这样,QUIC
从请求连接到正式接发 HTTP
数据一共花了 1 RTT
,这 1
个 RTT
主要是为了获取 Server Config
,后面的连接如果客户端缓存了 Server Config
,那么就可以直接发送 HTTP
数据,实现 0 RTT
建立连接
这里使用的是 DH
密钥交换算法,DH
算法的核心就是服务端生成 a、g、p
三个随机数,a
自己持有,g
和 p
要传输给客户端,而客户端会生成 b
这 1
个随机数,通过 DH
算法客户端和服务端可以算出同样的密钥,在这过程中 a
和 b
并不参与网络传输,安全性大大提高,因为 p
和 g
是大数,所以即使在网络中传输的 p、g、A、B
都被劫持,那么靠现在的计算机算力也没法破解密钥
连接迁移
TCP
连接基于四元组(源 IP
、源端口、目的 IP
、目的端口),切换网络时至少会有一个因素发生变化,导致连接发生变化,当连接发生变化时,如果还使用原来的 TCP
连接,则会导致连接失败,就得等原来的连接超时后重新建立连接,所以我们有时候发现切换到一个新网络时,即使新网络状况良好,但内容还是需要加载很久,如果实现得好,当检测到网络变化时立刻建立新的 TCP
连接,即使这样,建立新的连接还是需要几百毫秒的时间
QUIC
的连接不受四元组的影响,当这四个元素发生变化时,原连接依然维持,那这是怎么做到的呢?道理很简单,QUIC
连接不以四元组作为标识,而是使用一个 64
位的随机数,这个随机数被称为 Connection ID
,即使 IP
或者端口发生变化,只要 Connection ID
没有变化,那么连接依然可以维持
队头阻塞/多路复用
HTTP/1.1
和 HTTP/2
都存在队头阻塞问题(Head of line blocking
),那什么是队头阻塞呢?
TCP
是个面向连接的协议,即发送请求后需要收到 ACK
消息,以确认对方已接收到数据,如果每次请求都要在收到上次请求的 ACK
消息后再请求,那么效率无疑很低,后来 HTTP/1.1
提出了 Pipelining
技术,允许一个 TCP
连接同时发送多个请求,这样就大大提升了传输效率
在这个背景下,下面就来谈 HTTP/1.1
的队头阻塞,下图中,一个 TCP
连接同时传输 10
个请求,其中第 1、2、3
个请求已被客户端接收,但第 4
个请求丢失,那么后面第 5 - 10
个请求都被阻塞,需要等第 4
个请求处理完毕才能被处理,这样就浪费了带宽资源
因此 HTTP
一般又允许每个主机建立 6
个 TCP
连接,这样可以更加充分地利用带宽资源,但每个连接中队头阻塞的问题还是存在
HTTP/2
的多路复用解决了上述的队头阻塞问题,不像 HTTP/1.1
中只有上一个请求的所有数据包被传输完毕下一个请求的数据包才可以被传输,HTTP/2
中每个请求都被拆分成多个 Frame
通过一条 TCP
连接同时被传输,这样即使一个请求被阻塞,也不会影响其他的请求,如下图所示,不同颜色代表不同的请求,相同颜色的色块代表请求被切分的 Frame
HTTP/2
虽然可以解决请求这个粒度的阻塞,但 HTTP/2
的基础 TCP
协议本身却也存在着队头阻塞的问题,HTTP/2
的每个请求都会被拆分成多个 Frame
,不同请求的 Frame
组合成 Stream
,Stream
是 TCP
上的逻辑传输单元,这样 HTTP/2
就达到了一条连接同时发送多条请求的目标,这就是多路复用的原理
我们看一个例子,在一条 TCP
连接上同时发送 4
个 Stream
,其中 Stream1
已正确送达,Stream2
中的第 3
个 Frame
丢失,TCP
处理数据时有严格的前后顺序,先发送的 Frame
要先被处理,这样就会要求发送方重新发送第 3
个 Frame
,Stream3
和 Stream4
虽然已到达但却不能被处理,那么这时整条连接都被阻塞
不仅如此,由于 HTTP/2
必须使用 HTTPS
,而 HTTPS
使用的 TLS
协议也存在队头阻塞问题,TLS
基于 Record
组织数据,将一堆数据放在一起(即一个 Record
)加密,加密完后又拆分成多个 TCP
包传输,一般每个 Record
有 16K
左右,包含 12
个 TCP
包,这样如果 12
个 TCP
包中有任何一个包丢失,那么整个 Record
都无法解密
队头阻塞会导致 HTTP/2
在更容易丢包的弱网络环境下比 HTTP/1.1
更慢,那 QUIC
是如何解决队头阻塞问题的呢?主要有两点
QUIC
的传输单元是Packet
,加密单元也是Packet
,整个加密、传输、解密都基于Packet
,这样就能避免TLS
的队头阻塞问题QUIC
基于UDP
,UDP
的数据包在接收端没有处理顺序,即使中间丢失一个包,也不会阻塞整条连接,其他的资源会被正常处理
拥塞控制
拥塞控制的目的是避免过多的数据一下子涌入网络,导致网络超出最大负荷,QUIC
的拥塞控制与 TCP
类似,并在此基础上做了改进,所以我们先简单介绍下 TCP
的拥塞控制,关于 TCP
当中的拥塞控制更为详细的内容可以参考 TCP 的流量控制和拥塞控制 这篇博文,这里我们只简单介绍一二
TCP
拥塞控制由 4
个核心算法组成,即 『慢启动』、『拥塞避免』、『快速重传』和『快速恢复』,理解了这 4
个算法,对 TCP
的拥塞控制也就有了大概了解
- 慢启动,发送方向接收方发送
1
个单位的数据,收到对方确认后会发送2
个单位的数据,然后依次是4
个、8
个呈指数级增长,这个过程就是在不断试探网络的拥塞程度,超出阈值则会导致网络拥塞 - 拥塞避免,指数增』长变为线性增长
- 快速重传,发送方每一次发送时都会设置一个超时计时器,超时后即认为丢失,需要重发
- 快速恢复,在上面快速重传的基础上,发送方重新发送数据时,也会启动一个超时定时器,如果收到确认消息则进入拥塞避免阶段,如果仍然超时,则回到慢启动阶段
QUIC
重新实现了 TCP
协议的 Cubic
算法进行拥塞控制,并在此基础上做了不少改进,下面介绍一些 QUIC
改进的拥塞控制的特性
热插拔
TCP
中如果要修改拥塞控制策略,需要在系统层面进行操作,QUIC
修改拥塞控制策略只需要在应用层操作,并且 QUIC
会根据不同的网络环境、用户来动态选择拥塞控制算法
前向纠错 FEC
QUIC
使用前向纠错(FEC
,Forward Error Correction
)技术增加协议的容错性,一段数据被切分为 10
个包后,依次对每个包进行异或运算,运算结果会作为 FEC
包与数据包一起被传输,如果不幸在传输过程中有一个数据包丢失,那么就可以根据剩余 9
个包以及 FEC
包推算出丢失的那个包的数据,这样就大大增加了协议的容错性
这是符合现阶段网络技术的一种方案,现阶段带宽已经不是网络传输的瓶颈,往返时间才是,所以新的网络传输协议可以适当增加数据冗余,减少重传操作
单调递增的 Packet Number
TCP
为了保证可靠性,使用 Sequence Number
和 ACK
来确认消息是否有序到达,但这样的设计存在缺陷
超时发生后客户端发起重传,后来接收到了 ACK
确认消息,但因为原始请求和重传请求接收到的 ACK
消息一样,所以客户端就郁闷了,不知道这个 ACK
对应的是原始请求还是重传请求,如果客户端认为是原始请求的 ACK
,但实际上是左图的情形,则计算的采样 RTT
偏大,如果客户端认为是重传请求的 ACK
,但实际上是右图的情形,又会导致采样 RTT
偏小,图中有几个术语,RTO
是指超时重传时间(Retransmission TimeOut
),跟我们熟悉的 RTT
长得很像,采样 RTT
会影响 RTO
计算,超时时间的准确把握很重要,长了短了都不合适
QUIC
解决了上面的歧义问题,与 Sequence Number
不同的是,Packet Number
严格单调递增,如果 Packet N
丢失了,那么重传时 Packet
的标识不会是 N
,而是比 N
大的数字,比如 N + M
,这样发送方接收到确认消息时就能方便地知道 ACK
对应的是原始请求还是重传请求
ACK Delay
TCP
计算 RTT
时没有考虑接收方接收到数据到发送确认消息之间的延迟,如下图所示,这段延迟即 ACK Delay
,QUIC
考虑了这段延迟,使得 RTT
的计算更加准确
更多的 ACK 块
一般来说,接收方收到发送方的消息后都应该发送一个 ACK
回复,表示收到了数据,但每收到一个数据就返回一个 ACK
回复太麻烦,所以一般不会立即回复,而是接收到多个数据后再回复,TCP SACK
最多提供 3
个 ACK block
,但有些场景下,比如下载,只需要服务器返回数据就好,但按照 TCP
的设计,每收到 3
个数据包就要返回一个 ACK
,而 QUIC
最多可以捎带 256
个 ACK block
,在丢包率比较严重的网络下,更多的 ACK block
可以减少重传量,提升网络效率
流量控制
TCP
会对每个 TCP
连接进行流量控制,流量控制的意思是让发送方不要发送太快,要让接收方来得及接收,不然会导致数据溢出而丢失,TCP
的流量控制主要通过滑动窗口来实现的,可以看出,拥塞控制主要是控制发送方的发送策略,但没有考虑到接收方的接收能力,流量控制是对这部分能力的补齐
QUIC
只需要建立一条连接,在这条连接上同时传输多条 Stream
,好比有一条道路,两头分别有一个仓库,道路中有很多车辆运送物资,QUIC
的流量控制有两个级别
- 连接级别(
Connection Level
) Stream
级别(Stream Level
)
好比既要控制这条路的总流量,不要一下子很多车辆涌进来,货物来不及处理,也不能一个车辆一下子运送很多货物,这样货物也来不及处理
那 QUIC
是怎么实现流量控制的呢?我们先看单条 Stream
的流量控制,Stream
还没传输数据时,接收窗口(flow control receive window
)就是最大接收窗口(flow control receive window
),随着接收方接收到数据后,接收窗口不断缩小,在接收到的数据中,有的数据已被处理,而有的数据还没来得及被处理,如下图所示,蓝色块表示已处理数据,黄色块表示未处理数据,这部分数据的到来,使得 Stream
的接收窗口缩小
随着数据不断被处理,接收方就有能力处理更多数据,当满足下列条件时
1 | (flow control receive offset - consumed bytes) < (max receive window / 2) |
接收方会发送 WINDOW_UPDATE frame
告诉发送方你可以再多发送些数据过来,这时 flow control receive offset
就会偏移,接收窗口增大,发送方可以发送更多数据到接收方
Stream
级别对防止接收端接收过多数据作用有限,更需要借助 Connection
级别的流量控制,理解了 Stream
流量那么也很好理解 Connection
流控,Stream
中,接收窗口为
1 | (flow control receive window) = 最大接收窗口(max receive window) - 已接收数据(highest received byte offset) |
而对 Connection
来说
1 | 接收窗口 = Stream1 接收窗口 + Stream2 接收窗口 + ... + StreamN 接收窗口 |