写在前边
这个问题其实回答起来都能想到在应用层层面来对UDP进行一定程度的设计与约束,从而达到可靠传输的效果。但在回答这个问题之前得弄清楚,为什么不用TCP这个现成的玩意儿而要用UDP,TCP的痛点在哪?
TCP痛点
1.TCP的完备性:内容太多了,太复杂了,不是那么容易升级和更新换代的
2.TCP的连接建立较慢,也就是三次握手,有点浪费时间。
3.队头阻塞问题,其实这可能是最主要的原因,也是http3为什么舍弃tcp改用udp的原因。大家都知道http2中利用stream技术,已经可以多个http连接独立了,但依旧存在队头阻塞问题,就是因为TCP的滑动窗口机制,不论你在应用层设计的多好,到了传输层,还是得按照滑动窗口来发数据,滑动窗口在前边的数据包未收到ack确认时是不会向后滑动的,这是一个很大的痛点。
4.连接转移问题,设想一下你现在在用wifi,下载一个东西,然后你突然切换到另外一个wifi,或者用数据流量了。那你的连接还是之前那个吗,当然不是!tcp判断你是不是同一个链接是通过五元组的形式(源IP、源端口、目的IP、目的端口、协议)。当你切换网的时候,你的机子IP就变了,至于导致什么后果,DDDD!
解决方案
接下来从两个方面来聊一下今天的主角,QUIC协议(Quick UDP Internet Connection):可靠传输的实现;解决TCP痛点。先说说QUIC协议的数据包格式,这是后边一切的基础:
从UDP数据头到HTTP数据,共有三个包头。
如何实现可靠传输的
先问一下,TCP怎么实现可靠传输的呢?
1.三次握手
2.序列号
3.滑动窗口
4.流量控制
5.拥塞控制
6.校验和
7.重传机制
OK。就参照这个,一个一个来说
三次握手
http2已经支持https了,所以其握手过程包括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 的效果。
序列号
上文不是说过QUIC协议的包结构了嘛,共存在三个包头,序列号就在Packet Header中。
这是部分包头结构。Long Packet Header是首次连接的包头,Short Packet Header是后边传输时候的包头。QUIC 也是需要三次握手来建立连接的(上边说过),主要目的是为了协商连接 ID。协商出连接 ID 后,后续传输时,双方只需要固定住连接 ID,从而实现连接迁移功能。所以,你可以看到日常传输数据的 Short Packet Header 不需要在传输 Source Connection ID 字段了,只需要传输 Destination Connection ID。
这一小节要讨论的序列号就是这个Packet Number了,这个字段是严格递增的,也就是说你有一个编号为N的数据包丢失了,你超时重传的时候,序列号可就不是N了,而是N+M,为什么要这么设计,就是为了解决队头阻塞问题。TCP的队头阻塞不就是因为前边的包没确认导致滑动窗口不能滑动吗,在QUIC协议中就不存在这个问题。同时还存在RTT的计算问题,TCP中第一个序号为N的包和第二个序号为N的包都发出去了,收到了一个ACK,那么这个RTO到底怎么算,存在二义性。这个问题QUIC里边也不存在。那么问题来了,你的序列号不按照顺序来,怎么保证接收到数据之后能够按照顺序进行组合,从而传给应用层?
那就来看看下一个头QUIC Frame Header:
每一个Packet Header后边可能都跟着很多frame。结构如下所示
每一个 Frame 都有明确的类型,针对类型的不同,功能也不同,自然格式也不同。
我这里只举例 Stream 类型的 Frame 格式,Stream 可以认为就是一条 HTTP 请求,它长这样:
- Stream ID 作用:多个并发传输的 HTTP 消息,通过不同的 Stream ID 加以区别,类似于 HTTP2 的 Stream ID;
- Offset 作用:类似于 TCP 协议中的 Seq 序号,保证数据的顺序性和可靠性;
- Length 作用:指明了 Frame 数据的长度。
到此,乱序确认下数据的有序拼接问题就解决了。即通过Stream ID和Offset两个字段来有序组装。
滑动窗口
QUIC协议也是存在滑动窗口的,但是和TCP不一样。
QUIC 借鉴 HTTP/2 里的 Stream 的概念,在一条 QUIC 连接上可以并发发送多个 HTTP 请求 (Stream)。
但是 QUIC 给每一个 Stream 都分配了一个独立的滑动窗口,这样使得一个连接上的多个 Stream 之间没有依赖关系,都是相互独立的,各自控制的滑动窗口。
假如 Stream2 丢了一个 UDP 包,也只会影响 Stream2 的处理,不会影响其他 Stream,与 HTTP/2 不同,HTTP/2 只要某个流中的数据包丢失了,其他流也会因此受影响。
流量控制
QUIC 实现了两种级别的流量控制,分别为 Stream 和 Connection 两种级别:
- Stream 级别的流量控制:Stream 可以认为就是一条 HTTP 请求,每个 Stream 都有独立的滑动窗口,所以每个 Stream 都可以做流量控制,防止单个 Stream 消耗连接(Connection)的全部接收缓冲。
- Connection 流量控制:限制连接中所有 Stream 相加起来的总字节数,防止发送方超过连接的缓冲容量。
Stream级别的流量控制
最开始,接收方的接收窗口初始状态如下
接着,接收方收到了发送方发送过来的数据,有的数据被上层读取了,有的数据丢包了,此时的接收窗口状况如下:
可以看到,接收窗口的左边界取决于接收到的最大偏移字节数,此时的接收窗口 = 最大窗口数 - 接收到的最大偏移数
。
这里就可以看出 QUIC 的流量控制和 TCP 有点区别了:
- TCP 的接收窗口只有在前面所有的 Segment 都接收的情况下才会移动左边界,当在前面还有字节未接收但收到后面字节的情况下,窗口也不会移动。
- QUIC 的接收窗口的左边界滑动条件取决于接收到的最大偏移字节数。
那接收窗口右边界触发的滑动条件是什么呢?看下图:
当图中的绿色部分数据超过最大接收窗口的一半后,最大接收窗口向右移动,接收窗口的右边界也向右扩展,同时给对端发送「窗口更新帧」,当发送方收到接收方的窗口更新帧后,发送窗口的右边界也会往右扩展,以此达到窗口滑动的效果。
绿色部分的数据是已收到的顺序的数据,如果中途丢失了数据包,导致绿色部分的数据没有超过最大接收窗口的一半,那接收窗口就无法滑动了,这个只影响同一个 Stream,其他 Stream 是不会影响的,因为每个 Stream 都有各自的滑动窗口。
说完接受方,聊聊发送方:
如图所示,当前发送方的缓冲区大小为8,发送方 QUIC 按序(offset顺序)发送 29-36 的数据包:
31、32、34数据包先到达,基于 offset 被优先乱序确认,但 30 数据包没有确认,所以当前已提交的字节偏移量不变,发送方的缓存区不变。
30 到达并确认,发送方的缓存区收缩到阈值,接收方发送 MAX_STREAM_DATA Frame(协商缓存大小的特定帧)给发送方,请求增长最大绝对字节偏移量。
协商完毕后最大绝对字节偏移量右移,发送方的缓存区变大,同时发送方发现数据包33超时
发送方将超时数据包重新编号为 42 继续发送
综上,QUIC可实现乱序确认。
Connection级别的流量控制
而对于 Connection 级别的流量窗口,其接收窗口大小就是各个 Stream 接收窗口大小之和。
拥塞控制
QUIC 协议当前默认使用了 TCP 的 Cubic 拥塞控制算法(我们熟知的慢开始、拥塞避免、快重传、快恢复策略),同时也支持 CubicBytes、Reno、RenoBytes、BBR、PCC 等拥塞控制算法,相当于将 TCP 的拥塞控制算法照搬过来了。
QUIC 是如何改进 TCP 的拥塞控制算法的呢?
QUIC 是处于应用层的,应用程序层面就能实现不同的拥塞控制算法,不需要操作系统,不需要内核支持。这是一个飞跃,因为传统的 TCP 拥塞控制,必须要端到端的网络协议栈支持,才能实现控制效果。而内核和操作系统的部署成本非常高,升级周期很长,所以 TCP 拥塞控制算法迭代速度是很慢的。而 QUIC 可以随浏览器更新,QUIC 的拥塞控制算法就可以有较快的迭代速度。
TCP 更改拥塞控制算法是对系统中所有应用都生效,无法根据不同应用设定不同的拥塞控制策略。但是因为 QUIC 处于应用层,所以就可以针对不同的应用设置不同的拥塞控制算法,这样灵活性就很高了。
校验和
QUIC
采用了一种脑洞极大的前向纠错(FEC)方案,类似于RAID5,将N个包的校验和(异或)建立一个单独的数据包发送,这样如果在这N个包中丢了一个包可以直接恢复出来,完全不需要重传,有利于保证高速性,N可以根据网络状况动态调整
重传机制
有,前边有提到。注意序列号不同
解决TCP的痛点问题
版本更新容易
由于QUIC协议是在应用层实现的,不像TCP协议一样需要适配整个协议栈,因此更改更加容易,甚至可以根据不同的应用场景使用不同的策略,例如拥塞控制算法。
连接建立较慢
参见三次握手部分。
连接迁移问题
QUIC连接用序列号进行标识,而不是四元组。客户端和服务器可以各自选择一组 ID 来标记自己,因此即使移动设备的网络变化后,导致 IP 地址变化了,只要仍保有上下文信息(比如连接 ID、TLS 密钥等),就可以“无缝”地复用原连接,消除重连的成本,没有丝毫卡顿感,达到了连接迁移的功能。
队头阻塞问题
参见序列号、滑动窗口以及流量控制部分。
以上部分很大程度参考了小林coding中的内容,感谢大佬的分享,大家也可以去看看4.17 如何基于 UDP 协议实现可靠传输? | 小林coding (xiaolincoding.com)