Skip to content

4.18 如何基于 UDP 协议实现可靠传输?

不会将TCP 可靠传输的特性(序列号、确认应答、超时重传、流量控制、拥塞控制)在应用层实现一遍。否则直接使用TCP就行了

QUIC 是如何实现可靠传输的?

要基于 UDP 实现的可靠传输协议,那么就要在应用层下功夫,也就是要设计好协议的头部字段。

拿 HTTP/3 举例子,在 UDP 报文头部与 HTTP 消息之间,共有 3 层头部:

Packet Header

Packet Header 细分这两种:

  • Long Packet Header 用于首次建立连接,数据包含源连接ID和目标连接ID
    • 通过三次握手协商连接ID,有了目标连接ID,就能进行连接迁移
    • 首次建立连接还需要传输源连接ID,后续数据传输只需要携带目标连接ID即可
  • Short Packet Header 用于日常传输数据。数据包含下面三个
    • 有目标连接ID(确认连接关系)

    • 编号:每个报文唯一,严格递增,报文丢失,会重新分配一个新的编号,避免出现歧义

      • 同时接收端数据包不需要有序确认,只要有新的已接收数据包确认,当前窗口就会继续往右滑动。
      • 发送端会重新编号数据包发送,新数据包放在队列尾部,窗口就能继续滑动,就能解决队头阻塞问题。
    • 负载数据

TCP重传报文使用相同的序号,因此确认号也是相同的,接收报文的以方就无法判断出是「原始报文的响应」还是「重传报文的响应」,这样在计算 RTT(往返时间)时

  • 如果选择从发送原始报文开始计算,可能导致采样的RTT变大
  • 如果选择从重传原始报文开始计算,可能导致采样的RTT过小

RTO(超时时间)是基于 RTT 来计算的,那么如果 RTT 计算不精准,那么 RTO(超时时间)也会不精确,这样可能导致重传的概率事件增大。QUIC用不同的ID就能避免

QUIC Frame Header

一个 Packet 报文中可以存放多个 QUIC Frame(数据帧)。

每一个 Frame 都有明确的类型,针对类型的不同,功能也不同,自然格式也不同。

我这里只举例 Stream 类型的 Frame 格式,Stream 可以认为就是一条 HTTP 请求,它长这样:

  • Stream ID 作用:多个并发传输的 HTTP 消息,通过不同的 Stream ID 加以区别,类似于 HTTP2 的 Stream ID;

  • Offset 作用:类似于 TCP 协议中的 Seq 序号,保证数据的顺序性和可靠性

    • 数据包丢失后,通过比较两个数据包的 Stream ID 与 Stream Offset 确定是否是同一个数据包,后续凭借也是通过offset保证数据的完整性。
  • Length 作用:指明了 Frame 数据的长度。

QUIC 是如何解决 TCP 队头阻塞问题的?

什么是 TCP 队头阻塞问题?

1、发送窗口的队头阻塞。

TCP 发送出去的数据,都是需要按序确认的,只有在数据都被按顺序确认完后,发送窗口才会往前滑动。

如果队头的某个数据丢失,会导致整个发送窗口阻塞在当前位置,也就没有办法发送新数据,只能超时重传这个报文,极端情况:窗口最左侧的报文或者对应的ACK多次丢失或超时,窗口剩余的报文正常收到ACK,则因为一个报文导致了无法发送新数据。

2、接收窗口的队头阻塞。

接收方收到的数据范围必须在接收窗口范围内,如果收到超过接收窗口范围的数据,就会丢弃该数据,如果接收窗口最左侧序号对应的数据一直没有被接收到,那么即使收到了后续序号的数据,也无法被应用层读取,同时接收窗口也会无法右移。

其实也不能怪 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 告诉对端由于流量控制被阻塞了,无法发送数据。

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 级别的流量控制
  • Connection 流量控制:限制连接中所有 Stream 相加起来的总字节数,防止发送方超过连接的缓冲容量。

Stream 级别的流量控制

Stream 可以认为就是一条 HTTP 请求,每个 Stream 都有独立的滑动窗口,所以每个 Stream 都可以做流量控制,防止单个 Stream 消耗连接(Connection)的全部接收缓冲。

最开始,接收方的接收窗口初始状态如下(网上的讲 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 接收窗口大小之和。

Connection 流量控制

上图所示的例子,所有 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

text
可用窗口 = Stream 1 可用窗口 + Stream 2 可用窗口 + Stream 3 可用窗口

QUIC 对拥塞控制改进

QUIC 协议当前默认使用了 TCP 的 Cubic 拥塞控制算法(我们熟知的慢开始、拥塞避免、快重传、快恢复策略),同时也支持更多的算法。

QUIC 是处于应用层的,只要浏览器更新就能更新,迭代更快,且可以针对不同的应用设置不同的拥塞控制算法,而TCP是针对系统所有应用都统一一个算法。

QUIC 更快的连接建立

因为HTTP 1和2 用TCP,而TCP在传输层,TLS在应用层,所以很难合并,必须分别进行握手

而HTTP/3 用QUIC,QUIC和TLS同属于应用层,所以可以在QUIC握手的时候携带TLS记录。所以会快很多,配合 TLS1.3甚至于只需要一次RTT。

甚至在第二次连接的时候,应用数据包可以和 QUIC 握手信息(连接信息 + TLS 信息)一起发送,达到 0-RTT 的效果。

QUIC 是如何迁移连接的?

基于 TCP 传输协议的 HTTP 协议,由于是通过四元组(源 IP、源端口、目的 IP、目的端口)确定一条 TCP 连接。客户端只要切换网络就会变化IP,就需要重新建立连接。

而建立连接的过程包含 TCP 三次握手和 TLS 四次握手的时延,以及 TCP 慢启动的减速过程,给用户的感觉就是网络突然卡顿了一下,因此连接的迁移成本是很高的。

QUIC 协议通过连接 ID来标记通信的两个端点,客户端和服务器可以各自选择一组 ID 来标记自己,因此即使IP变化了,只要仍保有上下文信息(比如连接 ID、TLS 密钥等),就可以“无缝”地复用原连接,消除重连的成本,没有丝毫卡顿感,达到了连接迁移的功能。


QUIC 协议的特点

UDP 包之间是无序的,也没有依赖关系。且UDP 是不需要连接的,也就不需要握手和挥手的过程,所以天然的就比 TCP 快。

HTTP/3 基于 UDP 协议在「应用层」实现了 QUIC 协议,它具有类似 TCP 的连接管理、拥塞窗口、流量控制的网络特性,相当于将不可靠传输的 UDP 协议变成“可靠”的了,所以不用担心数据包丢失的问题。

无队头阻塞

QUIC 协议可以在同一条连接上并发传输多个 Stream,Stream 可以认为就是一条 HTTP 请求。

和TCP一样, QUIC 协议会保证数据包的可靠性,每个数据包都有一个序号唯一标识。当某个流中的一个数据包丢失了,即使该流的其他数据包到达了,数据也无法被 HTTP/3 读取,直到 QUIC 重传丢失的报文,数据才会交给 HTTP/3。

且QUIC 连接上的多个 Stream 之间并没有依赖,都是独立的,某个流发生丢包了,只会影响该流,其他流不受影响。和HTTP/2相比不会导致队头阻塞

更快的连接建立

对于 HTTP/1 和 HTTP/2 协议,TCP 和 TLS 是分层的,分别属于内核实现的传输层、OpenSSL 库实现的表示层,因此它们难以合并在一起,需要分批次来握手,先 TCP 握手,再 TLS 握手。

HTTP/3 在传输数据前虽然需要 QUIC 协议握手,这个握手过程只需要 1 RTT,握手的目的是为确认双方的「连接 ID」,连接迁移就是基于连接 ID 实现的。

HTTP/3 的 QUIC 协议并不是与 TLS 分层,而是 QUIC 内部包含了 TLS,它在自己的帧会携带 TLS 里的“记录”,再加上 QUIC 使用的是 TLS 1.3,因此仅需 1 个 RTT 就可以「同时」完成建立连接与密钥协商,甚至在第二次连接的时候,应用数据包可以和 QUIC 握手信息(连接信息 + TLS 信息)一起发送,达到 0-RTT 的效果

甚至,在第二次连接的时候,应用数据包可以和 QUIC 握手信息(连接信息 + TLS 信息)一起发送,达到 0-RTT 的效果。

如下图右边下半部分,HTTP/3 当会话恢复时,有效负载数据与第一个数据包一起发送,可以做到 0-RTT(下图的右下角):

连接迁移

QUIC 协议没有用IP和端口的四元组的方式来“绑定”连接,而是通过连接 ID 来标记通信的两个端点,客户端和服务器可以各自选择一组 ID 来标记自己,因此即使移动设备的网络变化后,导致 IP 地址变化了,只要仍保有上下文信息(比如连接 ID、TLS 密钥等),就可以“无缝”地复用原连接,消除重连的成本,没有丝毫卡顿感,达到了连接迁移的功能。

正在精进