TCP协议在创建链接前会进行三次握手。如果增加传输层协议(TLS),则握手次数更多
QUIC协议 可以在1到2个数据包(取决于连接服务是未知还是已知)内,完成连接的创建(包括TLS)
QUIC 非常类似于在 UDP 上实现的 TCP + TLS + HTTP/2。相比于 TCP,流控功能在用户空间而不在内核空间,可以不受限于 CUBIC 或是 BBR,而是可以自由选择甚至根据应用场景自由调整优化
为什么不修改TCP协议? TCP 是在操作系统内核和中间件固件中实现的
这么好为什么没有大规模使用?
- 可能会被路由封杀UDP 443端口( 这正是QUIC 部署的端口);
- UDP包过多,由于QS限定,会被服务商误认为是攻击,UDP包被丢弃;
- 无论是路由器还是防火墙目前对QUIC都还没有做好准备。
QUIC 优点
QUIC 与现有 TCP + TLS + HTTP/2 方案相比,有以下几点主要特征:
- 利用缓存,显著减少连接建立时间;
- 改善拥塞控制,拥塞控制从内核空间到用户空间;
- 没有 head of line 阻塞的多路复用;
- 前向纠错,减少重传;
- 连接平滑迁移,网络状态的变更不会影响连接断线

TCP 的拥塞控制实际上包含了四个算法:慢启动,拥塞避免,快速重传,快速恢复
QUIC 协议当前默认使用了 TCP 协议的 Cubic 拥塞控制算法,同时也支持 CubicBytes, Reno, RenoBytes, BBR, PCC 等拥塞控制算法。
从拥塞算法本身来看,QUIC 只是按照 TCP 协议重新实现了一遍,那么 QUIC 协议到底改进在哪些方面呢?
可插拔
就是能够非常灵活地生效,变更和停止
- 应用程序层面就能实现不同的拥塞控制算法,不需要操作系统,不需要内核支持。而传统的 TCP 拥塞控制,必须要端到端的网络协议栈支持,才能实现控制效果。
- 即使是单个应用程序的不同连接也能支持配置不同的拥塞控制。能提供更加有效的拥塞控制。比如 BBR 适合,Cubic 适合;
- 程序不需要停机和升级就能实现拥塞控制的变更
单调递增的 Packet Number
TCP 为了保证可靠性,使用了基于字节序号的 Sequence Number 及 Ack 来确认消息的有序到达
QUIC 同样是一个可靠的协议,它使用 Packet Number 代替了 TCP 的 sequence number,并且每个 Packet Number 都严格递增,也就是说就算 Packet N 丢失了,重传的 Packet N 的 Packet Number 已经不是 N,而是一个比 N 大的值。而 TCP 呢,重传 segment 的 sequence number 和原始的 segment 的 Sequence Number 保持不变,也正是由于这个特性,引入了 Tcp 重传的歧义问题
如上图,超时事件 RTO 发生后,客户端发起重传,然后接收到了 Ack 数据。由于序列号一样,这个 Ack 数据到底是原始请求的响应还是重传请求的响应呢?不好判断。
如果算成原始请求的响应,但实际上是重传请求的响应(上图左),会导致采样 RTT 变大。如果算成重传请求的响应,但实际上是原始请求的响应,又很容易导致采样 RTT 过小。
由于 Quic 重传的 Packet 和原始 Packet 的 Pakcet Number 是严格递增的,所以很容易就解决了这个问题
如上图所示,RTO 发生后,根据重传的 Packet Number 就能确定精确的 RTT 计算。如果 Ack 的 Packet Number 是 N+M,就根据重传请求计算采样 RTT。如果 Ack 的 Pakcet Number 是 N,就根据原始请求的时间计算采样 RTT,没有歧义性。
但是单纯依靠严格递增的 Packet Number 肯定是无法保证数据的顺序性和可靠性。QUIC 又引入了一个 Stream Offset 的概念。
即一个 Stream 可以经过多个 Packet 传输,Packet Number 严格递增,没有依赖。但是 Packet 里的 Payload 如果是 Stream 的话,就需要依靠 Stream 的 Offset 来保证应用数据的顺序。
假设 Packet N 丢失了,发起重传,重传的 Packet Number 是 N+2,但是它的 Stream 的 Offset 依然是 x,这样就算 Packet N + 2 是后到的,依然可以将 Stream x 和 Stream x+y 按照顺序组织起来,交给应用程序处理。
不允许 Reneging
Reneging: 接收方丢弃已经接收并且上报给 SACK 选项的内容。TCP 协议不鼓励这种行为,但是协议层面允许这样的行为。主要是考虑到服务器资源有限,比如 Buffer 溢出,内存不够等情况。
Reneging 对数据重传会产生很大的干扰。因为 Sack 都已经表明接收到了,但是接收端事实上丢弃了该数据。
QUIC 在协议层面禁止 Reneging,一个 Packet 只要被 Ack,就认为它一定被正确接收,减少了这种干扰。
更多的ACK块
TCP 的 Sack 选项能够告诉发送方已经接收到的连续 Segment 的范围,方便发送方进行选择性重传。
由于 TCP 头部最大只有 60 个字节,标准头部占用了 20 字节,所以 Tcp Option 最大长度只有 40 字节,再加上 Tcp Timestamp option 占用了 10 个字节 [25],所以留给 Sack 选项的只有 30 个字节。每一个 Sack Block 的长度是 8 个,加上 Sack Option 头部 2 个字节,也就意味着 Tcp Sack Option 最大只能提供 3 个 Block。
Quic Ack Frame 可同时提供 256 个 Ack Block,在丢包率比较高的网络下,更多的 Sack Block 可以提升网络的恢复速度,减少重传量。
Ack Delay 时间
Tcp 的 Timestamp 选项存在一个问题:只回显发送方的时间戳,但没有计算接收端接收到 segment 到发送 Ack 该 segment 的时间。这个时间可以简称为 Ack Delay
这样就会导致 RTT 计算误差。如下图:

TCP 的 RTT 计算:RTT = timestamp2 - timestamp1
Quic 的RTT 计算:RTT = timestamp2 - timestamp1 - Ack Delay
当然RTT的具体计算需要采样,参考历史数据平滑计算
SRTT = SRTT + α(RTT - SRTT)
RTO = β * SRTT + α * DevRTT
基于 stream 和 connection 级别的流量控制
QUIC 的流量控制类似 HTTP2,即在 Connection 和 Stream 级别提供了两种流量控制
Connection 可以类比一条 TCP 连接,Stream 可以认为就是一条 HTTP 请求。多路复用意味着在一条 Connetion 上会同时存在多条 Stream。既需要对单个 Stream 进行控制,又需要针对所有 Stream 进行总体控制。
QUIC 实现流量控制的原理比较简单:
通过 window_update 帧告诉对端自己可以接收的字节数,这样发送方就不会发送超过这个数量的数据。
通过 BlockFrame 告诉对端由于流量控制被阻塞了,无法发送数据。
QUIC 的流量控制和 TCP 有点区别,TCP 为了保证可靠性,窗口左边沿向右滑动时的长度取决于已经确认的字节数。如果中间出现丢包,就算接收到了更大序号的 Segment,窗口也无法超过这个序列号。
但 QUIC 不同,就算此前有些 packet 没有接收到,它的滑动只取决于接收到的最大偏移字节数
针对Stream: 可用窗口 = 最大窗口数 - 接收到的最大偏移数
针对Connection: 可用窗口 = stream1 可用窗口 + stream2 可用窗口+ …… + streamN 可用窗口
STGW 也在连接和 Stream 级别设置了不同的窗口数。可以在内存不足或者上游处理性能出现问题时,通过流量控制来限制传输速率,保障服务可用性
没有对头阻塞的多路复用
QUIC 的多路复用和 HTTP2 类似。在一条 QUIC 连接上可以并发发送多个 HTTP 请求 (stream)。但是 QUIC 的多路复用相比 HTTP2 有一个很大的优势,很大程度上缓解甚至消除了队头阻塞的影响。
QUIC 一个连接的多个 stream 之间没有依赖。假如 stream2 丢了一个 udp packet,不会影响 其他stream 的处理。
多路复用是 HTTP2 最强大的特性,能够将多条请求在一条 TCP 连接上同时发出去。但也恶化了 TCP 的一个问题,队头阻塞。
HTTP2 在一个 TCP 连接上同时发送 4 个 Stream。其中 Stream1 已经正确到达,并被应用层读取。但是 Stream2 的第三个 tcp segment 丢失了,TCP 为了保证数据的可靠性,需要发送端重传第 3 个 segment 才能通知应用层读取接下去的数据,虽然这个时候 Stream3 和 Stream4 的全部数据已经到达了接收端,但都被阻塞住了
不仅如此,由于 HTTP2 强制使用 TLS,还存在一个 TLS 协议层面的队头阻塞
上面两段存在歧义TODO ??? gRPC 基于 HTTP2 但是并没有TLS

Record 是 TLS 协议处理的最小单位,最大不超过 16K,Nginx 默认的大小就是 16K。由于一个 record 必须经过数据一致性校验才能进行加解密,所以一个 16K 的 record,就算丢了一个字节,也会导致已接收的 15.99K 数据无法处理,因为不完整
那 QUIC 多路复用为什么能避免上述问题呢?
- QUIC 最基本的传输单元是 Packet,不会超过 MTU 的大小,整个加密和认证过程都是基于 Packet 的,不会跨越多个 Packet。这样就能避免 TLS 协议存在的队头阻塞;
- Stream 之间相互独立,比如 Stream2 丢了一个 Pakcet,不会影响 Stream3 和 Stream4。不存在 TCP 队头阻塞
当然,并不是所有的 QUIC 数据都不会受到队头阻塞的影响,比如 QUIC 当前也是使用 Hpack 压缩算法 [10],由于算法的限制,丢失一个头部数据时,可能遇到队头阻塞。
总体来说,QUIC 在传输大量数据时,比如视频,受到队头阻塞的影响很小。
为什么压缩之后就出现对头阻塞了? TODO
加密认证的报文
TCP 协议头部没有经过任何加密和认证,所以在传输过程中很容易被中间网络设备篡改,注入和窃听。比如修改序列号、滑动窗口。这些行为有可能是出于性能优化,也有可能是主动攻击。
但是 QUIC 的 packet 可以说是武装到了牙齿。除了个别报文比如 PUBLIC_RESET 和 CHLO,所有报文头部都是经过认证的,报文 Body 都是经过加密的。
这样只要对 QUIC 报文任何修改,接收端都能够及时发现,有效地降低了安全风险。
如下图所示,红色部分是 Stream Frame 的报文头部,有认证。绿色部分是报文内容,全部经过加密。
连接迁移
一条 TCP 连接是由四元组标识的(源 IP,源端口,目的 IP,目的端口)。什么叫连接迁移呢?就是当其中任何一个元素发生变化时,这条连接依然维持着,能够保持业务逻辑不中断。当然这里面主要关注的是客户端的变化,因为客户端不可控并且网络环境经常发生变化,而服务端的 IP 和端口一般都是固定的。
比如大家使用手机在 WIFI 和 4G 移动网络切换时,客户端的 IP 肯定会发生变化,需要重新建立和服务端的 TCP 连接。
又比如大家使用公共 NAT 出口时,有些连接竞争时需要重新绑定端口,导致客户端的端口发生变化,同样需要重新建立 TCP 连接。
那 QUIC 是如何做到连接迁移呢?任何一条 QUIC 连接不再以 IP 及端口四元组标识,而是以一个 64 位的随机数作为 ID 来标识,这样就算 IP 或者端口发生变化时,只要 ID 不变,这条连接依然维持着,上层业务逻辑感知不到变化,不会中断,也就不需要重连。
由于这个 ID 是客户端随机产生的,并且长度有 64 位,所以冲突概率非常低。
其他
此外,QUIC 还能实现前向冗余纠错,在重要的包比如握手消息发生丢失时,能够根据冗余信息还原出握手消息。
QUIC 还能实现证书压缩,减少证书传输量,针对包头进行验证等
QUIC原理
代码实现:https://github.com/lucas-clemente/quic-go
参考链接
- http://www.52im.net/thread-2816-1-1.html
- https://docs.google.com/document/d/1F2YfdDXKpy20WVKJueEf4abn_LVZHhMUMS5gX6Pgjl4/edit#
- 网络编程懒人入门(十):一泡尿的时间,快速读懂QUIC协议
- 让互联网更快:新一代QUIC协议在腾讯的技术实践分享
- 七牛云技术分享:使用QUIC协议实现实时视频直播0卡顿!
- https://hungryturbo.com/HTTP3-explained/quic/为什么需要QUIC.html#回顾http-2
- https://zhuanlan.zhihu.com/p/32553477