QUIC 是如何实现可靠传输的?
4.17 如何基于 UDP 协议实现可靠传输?
「如何基于 UDP 协议实现可靠传输?」
很多同学第⼀反应就会说把 TCP 可靠传输的特性(序列号、确认应答、超时重传、流量控制、拥
塞控制)在应⽤层实现⼀遍。
实现的思路确实这样没错,但是有没有想过,既然 TCP 天然⽀持可靠传输,为什么 还需要基于
UDP 实现可靠传输呢?这不是重复造轮⼦吗?
所以,我们要先弄清楚 TCP 协议有哪些痛点?⽽这些痛点是否可 以在基于 UDP 协议实现的可靠传
输协议中得到改进?
在之前这篇⽂章:TCP 就没什么 缺陷吗? ,我已经说了 TCP 协议四个⽅⾯的缺陷:
升级 TCP 的⼯作很困难;
TCP 建⽴连接的延迟;
TCP 存在队头阻塞问题;
⽹络迁移需要重新建⽴ TCP 连接;
现在市⾯上已经有基于 UDP 协议实现的可靠传输协议的成熟⽅案了,那就是 QUIC 协议,已经应
⽤在了 HTTP/3 。
这次,聊聊 QUIC 是如何实现可靠传输的?⼜是如何解决上⾯ TCP 协议四个⽅⾯的缺陷?

QUIC 是如何实现可靠传输的?
要基于 UDP 实现的可靠传输协议,那么就要在应⽤层下功夫,也就是要设计 好协议的头部字段。
拿 HTTP/3 举例⼦,在 UDP 报⽂头部与 HTTP 消息之间,共有 3 层头部

整体看的视⻆是这样的:

接下来,分别 对每⼀个 Header 做个介 绍。
Packet Header
Packet Header ⾸次建⽴连接时和⽇常传输数据时使⽤的 Header 是不同的。如下图(注意我没有把 Header 所有字段都画出来,只是画出了重要的字段):

Packet Header 细分这两种:
Long Packet Header ⽤于⾸次建⽴连接。
Short Packet Header ⽤于⽇常传输数据。
QUIC 也是需要三次握⼿来建⽴连接的,主要⽬的是为了 协商连接 ID 。协商出连接 ID 后,后续传
输时,双⽅只需要固定住连接 ID ,从⽽实现连接迁移功能。所以,你可以看到⽇常传输数据的
Short Packet Header 不需要在传输 Source Connection ID 字段了,只需要传输 Destination Connection ID 。
Short Packet Header 中的 Packet Number 是每个报⽂独⼀⽆⼆的编号,它是严格递增的,也就是
说就算 Packet N 丢失了,重传的 Packet N 的 Packet Number 已经不是 N,⽽是⼀个⽐ N ⼤的
值。

为什么 要这么设计 呢?
我们先来看看 TCP 的问题,TCP 在重传报⽂时的序列号和 原始报⽂的序列号是⼀样的,也正是由
于这个特性,引⼊了 TCP 重传的歧义问题。

⽐如上图,当 TCP 发⽣超时重传后,客⼾端发起重传,然后接收到了服务端确认 ACK 。由于客⼾
端原始报⽂和重传报⽂序列号都是⼀样的,那么服务端针对这两个 报⽂回复的都是相同的 ACK 。
这样的话,客⼾端就⽆法判断出是「原始报⽂的响应」还是「重传报⽂的响应」,这样在计算 RTT
(往返时间) 时应该选择从发送原始报⽂开始计算,还是重传原始报⽂开始计算呢?
如果算成原始请求的响应,但实际上是重传请求的响应(上图左),会导致采样 RTT 变⼤。
如果算成重传请求的响应,但实际上是原始请求的响应(上图右),⼜很容易导致采样 RTT 过
⼩。
RTO (超时时 间)是基于 RTT 来计算的,那么如果 RTT 计算不精准,那么 RTO (超时时 间)也会
不精确,这样可能导致重传的概率事件增⼤。
QUIC 报⽂中的 Pakcet Number 是严格递增的, 即使是重传报⽂,它的 Pakcet Number 也是递增
的,这样就能更加精确计算出报⽂的 RTT 。

如果 ACK 的 Packet Number 是 N+M ,就根据重传报⽂计算采样 RTT 。如果 ACK 的 Pakcet Number 是 N,就根据原始报⽂的时间计算采样 RTT ,没有歧义性的问题。
另外,还有⼀个好处 ,QUIC 使⽤的 Packet Number 单调递增的设计 ,可以让数据包不再像 TCP
那样必须有序确认,QUIC ⽀持乱序确认,当数据包Packet N 丢失后,只要有新的已接收数 据包确
认,当前窗⼝就会继续向右 滑动(后⾯讲流量控制的时候,会举例⼦)。
待发送端获知数据包Packet N 丢失后,会将需要重传的数据包放到待发送队列,重新编号⽐如数
据包Packet N+M 后重新发送给接收端,对重传数据包的处理跟发送新的数据包类似,这样就不会
因为丢 包重传将当前窗⼝阻塞在原地,从⽽解决了队头阻塞问题。
所以,Packet Number 单调递增的两个 好处 :
可以更加精确计算 RTT ,没有 TCP 重传的歧义性问题;
可以⽀持乱序确认,因为丢 包重传将当前窗⼝阻塞在原地,⽽ TCP 必须是顺序确认的,丢包时
会导致窗⼝不滑动;
QUIC Frame Header
⼀个 Packet 报⽂中可以存放多个 QUIC Frame 。

每⼀个 Frame 都有明确的类型,针对类型的不同,功能也不 同,⾃然格式也不 同。
我这⾥只举例 Stream 类型的 Frame 格式,Stream 可以认为就是⼀条 HTTP 请求,它⻓这样:

Stream ID 作⽤:多个并发传输的 HTTP 消息,通过不同的 Stream ID 加以区别,类似于 HTTP2
的 Stream ID;
Offset 作⽤:类似于 TCP 协议中的 Seq 序号,保证数据的顺序性和可 靠性;
Length 作⽤:指明了 Frame 数据的⻓度。
在前⾯介绍 Packet Header 时,说到 Packet Number 是严格递增,即使重传报⽂的 Packet Number 也是递增的,既然重传数据包的 Packet N+M 与丢 失数据包的 Packet N 编号并不⼀致,
我们怎么确定这两个 数据包的内容⼀样呢?
所以引⼊ Frame Header 这⼀层,通过 Stream ID + Offset 字段信息实现数据的有序性,通过⽐较
两个 数据包的 Stream ID 与 Stream Offset ,如果都是⼀致,就说明这两个 数据包的内容⼀致。
举个 例⼦,下图中,数据包 Packet N 丢失了,后⾯重传该数据包的编号为 Packet N+2 ,丢失的数
据包和重传的数据包 Stream ID 与 Offset 都⼀致,说明这两个 数据包的内容⼀致。这些数据包传
输到接收端后,接收端能根据 Stream ID 与 Offset 字段信息将 Stream x 和 Stream x+y 按照顺序
组织 起来,然后交给应⽤程序处理。

总的来说,QUIC 通过单向递增的 Packet Number ,配合 Stream ID 与 Offset 字段信息,可以⽀
持乱序确认⽽不影响数据包的正确组装,摆脱了TCP 必须按顺序确认应答 ACK 的限制,解决了
TCP 因某个数据包重传⽽阻塞后续所有待发送数据包的问题。
QUIC 是如何解决 TCP 队头阻塞问题的?
什么 是 TCP 队头阻塞问题?
TCP 队头阻塞的问题,其实就是接收窗⼝的队头阻塞问题。
接收⽅收到的数据范围必须在接收窗⼝范围内,如果收到超过接收窗⼝范围的数据,就会丢弃该
数据,⽐如下图接收窗⼝的范围是 32 〜 51 字节,如果收到第 52 字节以上数据都会被丢弃。

接收窗⼝什么 时候才能滑动?当接收窗⼝收到有序数据时,接收窗⼝才能往前滑动,然后那些已
经接收并且被确认的「有序」数据就可以被应⽤层读取。
但是,当接收窗⼝收到的数据不是有序的,⽐如收到第 33 〜40 字节的数据,由于第 32 字节数据
没有收到, 接收窗⼝⽆法向前滑动,那么即使先收到第 33 〜40 字节的数据,这些数据也⽆法被
应⽤层读取的。只有当发送⽅重传了第 32 字节数据并且被接收⽅收到后,接收窗⼝才会往前滑
动,然后应⽤层才能从内核读取第 32 〜40 字节的数据。
导致接收窗⼝的队头阻塞问题,是因为 TCP 必须按序处理数据,也就是 TCP 层为了 保证数据的有
序性,只有在处理完有序的数据后,滑动窗⼝才能往前滑动,否则就停留,停留「接收窗⼝」会使得应⽤层⽆法读取新的数据。
其实也不 能怪 TCP 协议,它本来 设计 ⽬的就是为了 保证数据的有序性。
HTTP/2 的队头阻塞
HTTP/2 通过抽象出 Stream 的概念,实现了 HTTP 并发传输,⼀个 Stream 就代表 HTTP/1.1 ⾥的
请求和响 应。

在 HTTP/2 连接上,不同 Stream 的帧是可以乱序发送的(因此可以并发不同的 Stream ),因为每
个帧的头部会携带 Stream ID 信息,所以接收端可以通过 Stream ID 有序组装成 HTTP 消息,⽽同
⼀ Stream 内部的帧必须是严格有序的。
但是 HTTP/2 多个 Stream 请求都是在⼀条 TCP 连接上传输,这意味着多个 Stream 共⽤同⼀个
TCP 滑动窗⼝,那么当发⽣数据丢失,滑动窗⼝是⽆法往前移动的,此时就会阻塞住所有的 HTTP
请求,这属于 TCP 层队头阻塞。

没有队头阻塞的 QUIC
QUIC 也借鉴 HTTP/2 ⾥的 Stream 的概念,在⼀条 QUIC 连接上可以并发发 送多个 HTTP 请求
(Stream) 。但是 QUIC 给每⼀个 Stream 都分配了⼀个独⽴的滑动窗⼝,这样使得⼀个连接上的多个 Stream
之间没有依赖关系,都是相互独⽴的,各⾃控制的滑动窗⼝。
假如 Stream2 丢了 ⼀个 UDP 包,也只会影响 Stream2 的处理,不会影响其他 Stream ,与 HTTP/2
不同,HTTP/2 只要某个流中的数据包丢失了,其他流也会因此受影响。

QUIC 是如何做流量控制的?
TCP 流量控制是通过让「接收⽅」告诉「发送⽅」,它(接收⽅)的接收窗⼝有多⼤,从⽽让「发
送⽅」根据「接收⽅」的实际接收能⼒控制发送的数据量。
QUIC 实现流量控制的⽅式:
通过 window_update 帧告诉对端⾃⼰可以接收的字节数,这样发送⽅就不会发送超过这 个数量的数据。
通过 BlockFrame 告诉对端由于流量控制被阻塞了,⽆法发送数据。
在前⾯说到,TCP 的接收窗⼝在收到有序的数据后,接收窗⼝才能往前滑动,否则停⽌滑动。
QUIC 是基于 UDP 传输的,⽽ UDP 没有流量控制,因此 QUIC 实现了⾃⼰的流量控制机制,QUIC
的滑动窗⼝滑动的条件跟 TCP 有⼀点差别,但是同⼀个 Stream 的数据也是要保证顺序的,不然⽆
法实现可靠传输,因此同⼀个 Stream 的数据包丢失了,也会造成窗⼝⽆法滑动。
QUIC 的 每个 Stream 都有各⾃的滑动窗⼝,不同 Stream 互相独⽴,队头的 Stream A 被阻塞
后,不妨碍 StreamB 、C的读取。⽽对于 HTTP/2 ⽽⾔,所有的 Stream 都跑在⼀条 TCP 连接上,
⽽这些 Stream 共享⼀个滑动窗⼝,因此同⼀个Connection 内,Stream A 被阻塞后,StreamB 、C
必须等待。
QUIC 实现了两 种级别的流量控制,分别 为 Stream 和 Connection 两种级别:
Stream 级别的流量控制:Stream 可以认为就是⼀条 HTTP 请求,每个 Stream 都有独⽴的滑动
窗⼝,所以每个 Stream 都可以做流量控制,防⽌单个 Stream 消耗连接(Connection )的全部
接收缓冲。
Connection 流量控制:限制连接中所有 Stream 相加起来的总字节数,防⽌发送⽅超过连 接的
缓冲容量。
Stream 级别的流量控制
最开始,接收⽅的接收窗⼝初始状态如下(⽹上的讲 QUIC 流量控制的资料太少了,下⾯的例⼦我
是参考 google ⽂档的:Flow control in QUIC ):

接着,接收⽅收到了发送⽅发送过来的数据,有的数据被上层读取了,有的数据丢包了,此时的
接收窗⼝状况如下:

可以看到,接收窗⼝的左边界取决于接收到的最⼤偏移字节数,此时的 接收窗口 = 最大窗口数 -接收到的最大偏移数 。
这⾥就可以看出 QUIC 的流量控制和 TCP 有点区别了:
TCP 的接收窗⼝只有在前⾯所有的 Segment 都接收的情况下才会移动左边界,当在前⾯还有字
节未接收但收到后⾯字节的情况下,窗⼝也不 会移动。
QUIC 的接收窗⼝的左边界滑动条件取决于接收到的最⼤偏移字节数。
PS
:但是你要问我这么设计 有什么 好处 ?我也暂时 没想到,因为资料太少了,⾄今没找到⼀个合
理的说明,如果你知道,欢迎告诉我啊!
那接收窗⼝右边界触发的滑动条件是什么 呢?看下图:

当图中的绿⾊部分数据超过最⼤接收窗⼝的⼀半后,最⼤接收窗⼝向右 移动,接收窗⼝的右边界
也向右 扩展,同时给对端发送「窗⼝更新帧」,当发送⽅收到接收⽅的窗⼝更新帧后,发送窗⼝的
右边界也会往右扩展,以此达到窗⼝滑动的效果。
绿⾊部分的数据是已收到的顺序的数据,如果中途丢失了数据包,导致绿⾊部分的数据没有超过
最⼤接收窗⼝的⼀半,那接收窗⼝就⽆法滑动了,这个只影响同 ⼀个 Stream ,其他 Stream 是不
会影响的,因为每个 Stream 都有各⾃的滑动窗⼝。
在前⾯我们说过 QUIC ⽀持乱序确认,具体是怎么做到的呢?
接下来,举个 例⼦(下⾯的例⼦来源于:QUIC—— 快速UDP ⽹络连接协议 ):
如图所⽰,当前发送⽅的缓冲区⼤⼩为8,发送⽅ QUIC 按序(offset 顺序)发送 29-36 的数据
包:

31 、32 、34 数据包先到达,基于 offset 被优先乱序确认,但 30 数据包没有确认,所以当前已提交
的字节偏移量不变,发送⽅的缓存区不变。

30 到达并确认,发送⽅的缓存区收缩到阈值,接收⽅发送 MAX_STREAM_DATA Frame (协商缓存
⼤⼩的特定帧)给发送⽅,请求增⻓最⼤绝对字节偏移量。

协商完毕后最⼤绝对字节偏移量右移,发送⽅的缓存区变 ⼤,同时发送⽅发现数据包33 超时

发送⽅将超时数据包重新编号为 42 继续发送

以上就是最基本的数据包发 送-接收过程,控制数据发送的唯⼀限制就是最⼤绝对字节偏移量,该
值是接收⽅基于当前已经提交的偏移量(连续已确认并向上层应⽤提交的数据包offset )和发送⽅
协商得出。
Connection 流量控制
⽽对于 Connection 级别的流量窗⼝,其接收窗⼝⼤⼩就是各个 Stream 接收窗⼝⼤⼩之和。

上图所⽰的例⼦,所有 Streams 的最⼤窗⼝数为 120 ,其中:
Stream 1 的最⼤接收偏移为 100 ,可⽤窗⼝ = 120 - 100 = 20
Stream 2 的最⼤接收偏移为 90 ,可⽤窗⼝ = 120 - 90 = 30
Stream 3 的最⼤接收偏移为 110 ,可⽤窗⼝ = 120 - 110 = 10
那么整个 Connection 的可⽤窗⼝ = 20 + 30 + 10 = 60
可用窗口 = Stream 1 可用窗口 + Stream 2 可用窗口 + Stream 3 可用窗口QUIC 对拥塞控制改进
QUIC 协议当前默认使⽤了 TCP 的 Cubic 拥塞控制算法(我们熟知的慢开始、拥塞避免、快重传、
快恢复策略),同时也⽀持 CubicBytes 、Reno 、RenoBytes 、BBR 、PCC 等拥塞控制算法,相当于
将 TCP 的拥塞控制算法照搬过来了。
QUIC 是如何改进 TCP 的拥塞控制算法的呢?
QUIC 是处于应⽤层的,应⽤程序层⾯就能实现不同的拥塞控制算法,不需要操作系统,不需要内
核⽀持。这是⼀个⻜跃,因为传统的 TCP 拥塞控制,必须要端到端的⽹络协议栈⽀持,才能实现
可用窗口 = Stream 1 可用窗口 + Stream 2 可用窗口 + Stream 3 可用窗口控制效果。⽽内核和操作系统的部署成本⾮常⾼,升级周期很⻓,所以 TCP 拥塞控制算法迭代速
度是很慢的。⽽ QUIC 可以随浏览器更新,QUIC 的拥塞控制算法就可以有较快的迭代速度。
TCP 更改拥塞控制算法是对系统中所有应⽤都⽣效,⽆法根据不同应⽤设定不同的拥塞控制策略。
但是因为 QUIC 处于应⽤层,所以就可以针对不同的应⽤设置不同的拥塞控制算法,这样灵活性就
很⾼了。
QUIC 更快的连接建⽴
对于 HTTP/1 和 HTTP/2 协议,TCP 和 TLS 是分层的,分别 属于内核实现的传输层、openssl 库实
现的表⽰层,因此它们难以合并在⼀起,需要分批次来握⼿,先 TCP 握⼿(1RTT ),再 TLS 握⼿
(2RTT ),所以需要 3RTT 的延迟才能传输数据,就算 Session 会话服⽤,也需要⾄少 2 个 RTT 。
HTTP/3 在传输数据前虽然需要 QUIC 协议握⼿,这个握⼿过程只需要 1 RTT ,握⼿的⽬的是为确
认双⽅的「连接 ID 」,连接迁移就是基于连接 ID 实现的。
但是 HTTP/3 的 QUIC 协议并不是与 TLS 分层,⽽是QUIC 内部包含了 TLS ,它在⾃⼰的帧会携带
TLS ⾥的“记录”,再加上 QUIC 使⽤的是 TLS1.3 ,因此仅需 1 个 RTT 就可以「同时」完成建⽴连接
与密钥协商,甚⾄在第⼆次连接的时候,应⽤数据包可以和 QUIC 握⼿信息(连接信息 + TLS 信
息)⼀起发送,达到 0-RTT 的效果。
如下图右边部分,HTTP/3 当会话恢复时,有效负载数据与第⼀个数据包⼀起发送,可以做到 0-RTT (下图的右下⻆):

QUIC 是如何迁移连接的?
基于 TCP 传输协议的 HTTP 协议,由于是通过四元组(源 IP 、源端⼝、⽬的 IP 、⽬的端⼝)确定
⼀条 TCP 连接。

那么当移动设备的⽹络从 4G 切换到 WIFI 时,意味着 IP 地址 变化 了,那么就必须要断开连接,然
后重新建⽴ TCP 连接。
⽽建⽴连接的过程包含 TCP 三次握⼿和 TLS 四次握⼿的时延,以及 TCP 慢启动的减速过程,给⽤
⼾的感觉就是⽹络突然卡顿了⼀下,因此连接的迁移成本是很⾼的。
QUIC 协议没有⽤四元组的⽅式来“绑定”连接,⽽是通过连接 ID 来标记通信的两个 端点,客⼾端和
服务器可以各⾃选择⼀组 ID 来标记⾃⼰,因此即使移动设备的⽹络变化 后,导致 IP 地址 变化 了,
