当前位置: 首页 > 科技观察

字节端:如何用UDP实现可靠传输?

时间:2023-03-18 18:16:55 科技观察

大家好,我是小林。记得之前在群里看到有位读者在字节端的时候被问到:“如何基于UDP协议实现可靠传输?”很多同学第一反应会说TCP可靠传输的特性(序列号、确认Response、超时重传、流量控制、拥塞控制)是在应用层实现的。实现的思路确实没错,但是大家有没有想过,既然TCP天生就支持可靠传输,为什么还要基于UDP来实现可靠传输呢?这不是重新发明轮子吗?因此,首先要搞清楚TCP协议有哪些痛点。?而这些痛点能否在基于UDP协议的可靠传输协议中得到改善?在上一篇文章:TCP就没有缺陷吗?,我已经提到了TCP协议的四大缺陷:升级TCP的工作非常困难;TCP连接建立的延迟;TCP存在队头阻塞问题;网络迁移需要重新建立TCP连接;现在市场上已经有基于UDP协议的可靠传输协议的成熟解决方案,即QUIC协议,已经应用于HTTP/3。这次我们就来说说QUIC是如何实现可靠传输的?它是如何解决TCP协议上述四个方面的缺陷的呢?QUIC是如何实现可靠传输的?要实现一个基于UDP的可靠传输协议,必须在应用层下实现努力,即设计协议的头域。以HTTP/3为例,在UDP报文头和HTTP报文之间有3层头:整体视角如下:接下来分别介绍各个头。PacketHeaderPacketHeader第一次建立连接时使用的包头与日常数据传输使用的包头不同。如下图,注意我没有画出Header的所有字段,只画出重要的字段:PacketHeader又细分这两种:LongPacketHeader用于第一次建立连接。ShortPacketHeader用于日常数据传输。QUIC也需要三次握手来建立连接,主要目的是确定连接ID。连接建立时,连接ID由服务器端根据客户端的SourceConnectionID字段生成,这样在后续的传输中,双方只需要固定DestinationConnectionID(连接ID)即可实现连接迁移功能。所以可以看到日常数据传输的ShortPacketHeader是不需要传输SourceConnectionID字段的。ShortPacketHeader中的PacketNumber是每个数据包唯一的编号,并且是严格递增的。也就是说,即使PacketN丢失,重传的PacketN的PacketNumber也不再是N,而是一个大于N的值。为什么要这样设计?我们先来看看TCP的问题。TCP重传报文时的序列号与原报文的序列号相同。正是因为这个特点,引入了TCP重传的二义性。问题。TCP重传的二义性问题比如上图中,当超时后发生TCP重传时,客户端发起重传,然后收到服务器的确认ACK。由于客户端的原始报文和重传报文的序号相同,所以服务器对这两条报文回复相同的ACK。在这种情况下,客户端无法判断是原始消息的响应还是重传消息的响应,因此在计算RTT(往返时间)时,应该选择从发送原始消息开始计算还是从重传开始计算原始信息。什么?如果算作原始消息的响应,但实际上是重传消息的响应(上图右侧),会导致采样RTT变大;如果算作重传消息的响应,但实际上是原始消息文本的响应(上图左边),很容易造成采样RTT过小;如果RTT计算不准确,RTO(超时时间)也会不准确,因为RTO是根据RTT计算的,不准确的RTO计算可能会导致重传事件的概率增加。QUIC消息中的PakcetNumber是严格递增的。即使重传消息,它的PakcetNumber也在增加,这样可以更准确地计算出消息的RTT。如果ACK的PacketNumber为N+M,采样RTT是根据重传包计算的。如果ACK的PakcetNumber为N,则采样RTT是根据原始数据包的时间计算的,不存在歧义问题。除此之外,还有一个好处。QUIC采用的PacketNumber单调递增设计,可以让数据包不再像TCP一样需要确认顺序。QUIC支持乱序确认。当数据包PacketN丢失时,只要有新的数据包收到确认后,当前窗口就会继续向右滑动。发送端在一定时间内没有收到PacketN的确认报文后,会将需要重传的数据包放入待发队列,并对之前的PacketN+M等数据包重新编号将它们重新发送到接收端。对数据包的处理类似于发送一个新的数据包,这样当前窗口就不会因为丢包重传而阻塞在原地,从而解决了队头阻塞问题。因此,单调递增PacketNumber有两个好处:可以更准确地计算RTT,不存在TCP重传的歧义问题;可以支持乱序确认,防止当前窗口因为丢包重传而阻塞在原地,TCP必须是有序确认的,当丢包时,窗口会滑动;QUICFrameHeader可以在一个Packet消息中存储多个QUICFrames。每个Frame都有明确的类型,不同类型的功能和自然格式也不同。这里我只举一个Stream类型的Frame格式的例子。Stream可以被认为是一个HTTP请求。看起来是这样的:StreamID功能:多个并发传输的HTTP报文通过不同的StreamID来区分;偏移功能:类似于TCP协议中的Seq序号,保证数据的顺序和可靠性;Length函数:表示Frame数据的长度。前面介绍PacketHeader的时候提到PacketNumber是严格递增的,即使是重传的PacketNumber也是递增的。由于重传的数据包的PacketN+M和丢失的数据包的PacketN号不一致,那么我们如何确定这两个数据包的内容是一样的呢?所以引入FrameHeader层,通过StreamID+Offset字段信息实现数据的排序,比较两个数据包的StreamID和StreamOffset,如果一致,说明两个数据包的内容是一致的。例如下图中,数据包PacketN丢失,重传的数据包编号为PacketN+2。丢失的数据包和重传的数据包具有相同的StreamID和Offset,说明这两个数据包的内容是一致的。这些数据包传输到接收端后,接收端可以根据StreamID和Offset字段信息,依次组织Streamx和Streamx+y,然后交给应用程序处理。总的来说,QUIC通过单向增加PacketNumber和StreamID和Offset字段信息,可以在不影响数据包正确组装的情况下支持乱序确认。由于数据包重传,TCP会阻止所有要发送的后续数据包。QUIC是如何解决TCP队头阻塞问题的?什么是TCP队首阻塞问题?TCP的队头阻塞问题应该从两个角度来看,一个是发送窗口的队头阻塞,一个是接收窗口的队头阻塞。先说发送窗口的队头阻塞。TCP发送的数据需要按顺序确认。只有数据按顺序确认后,发送窗口才会向前滑动。例如下图中的发送方已经将发送窗口内的数据全部发送完,可用窗口的大小为0,表示可用窗口已经耗尽,只有收到ACK确认后才能发送数据。可用窗口已用完。然后,当发送方收到第32到第36字节的ACK确认响应时,滑动窗口向右移动5字节,因为有5字节的数据是响应确认的。52~56字节再次成为可用窗口,则可以稍后发送5字节数据52~56。32~36字节已经确认,但是如果一个数据报文丢失或者其对应的ACK报文在网络中丢失,发送方将无法移动发送窗口。发送这条数据报文后,发送窗口不会移动,直到收到这条重传报文的ACK,才会继续后续的发送行为。例如下图中,客户端是发送方,服务器是接收方。客户端发送第5到第9字节的数据,但是第5字节的ACK确认报文在网络中丢失,所以即使客户端收到第6到第9字节的ACK确认报文,发送窗口也会关闭。不会前进。这时候第5个字节就相当于“headofline”,因为还没有收到“headofline”的ACK确认报文,所以发送窗口不能向前移动。这个时候发送端不能继续发送后面的数据,相当于按下了发送行为的暂停键,就是发送窗口的队头阻塞问题。让我们谈谈接收窗口中的队首阻塞。接收方接收到的数据范围必须在接收窗口范围内。如果它接收到超出接收窗口范围的数据,它将丢弃该数据。比如下图中接收窗口的范围是32到51字节。该部分上方的数据将被丢弃。接收窗口什么时候可以滑动?当接收窗口接收到有序数据时,接收窗口可以向前滑动,然后应用层就可以读取那些已经接收并确认的“有序”数据。但是,当接收窗口接收到的数据顺序不对时,比如接收到第33到第40字节的数据,接收窗口不能向前滑动,因为第32字节的数据没有接收到,那么即使第33字节先接收到~40字节数据,应用层无法读取。只有当发送方重传第32字节的数据并被接收方接收到时,接收窗口才会向前滑动,应用层才能从内核中读取到第32到40字节的数据。好了,至此发送窗口和接收窗口的队头阻塞问题就讲完了。造成这两个问题的原因是TCP必须按顺序处理数据,也就是为了保证数据的顺序,TCP层只有有序的数据后,滑动窗口才能向前滑动,否则会停留。停留在“发送窗口”将使发送方无法继续发送数据。停留在“接收窗口”将阻止应用层读取新数据。其实也不能怪TCP协议。它最初是为了保证数据的顺序而设计的。HTTP/2head-of-lineblockingHTTP/2通过抽象Stream的概念来实现HTTP并发传输。Stream表示HTTP/1.1中的请求和响应。HTTP/2在HTTP/2连接上,可以乱序发送不同Streams的帧(所以可以同时发送不同的Streams),因为每个帧的头部都会携带StreamID信息,所以接收端可以通过StreamID。序列被组装成HTTP消息,同一个Stream中的帧必须是严格顺序的。但是HTTP/2的多个Stream请求是在一个TCP连接上传输的,也就是说多个Streams共享同一个TCP滑动窗口,所以当数据丢失时,滑动窗口无法向前移动,此时会阻塞所有HTTP请求被阻塞,属于TCP层队头阻塞。无队头阻塞的QUIC也借鉴了HTTP/2中Stream的概念,可以在一个QUIC连接上并发发送多个HTTP请求(Stream)。但是QUIC给每一个Stream分配了一个独立的滑动窗口,这样一个连接上的多个Streams之间就没有了依赖关系,它们都是相互独立的,各自控制着滑动窗口。如果Stream2丢了一个UDP包,只会影响Stream2的处理,不会影响其他的Streams。与HTTP/2不同的是,只要一个流中的一个数据包在HTTP/2中丢失,其他流也会受到影响。QUIC是如何做流量控制的?TCP流量控制就是让“接收方”告诉“发送方”它(接收方)的接收窗口有多大,这样“发送方”就可以利用“接收方”的实际接收能力来控制发送的数据量。前面说过,TCP的接收窗口只有在接收到有序数据后才能向前滑动,否则滑动会停止;TCP的发送窗口只有在收到发送数据的顺序确认ACK后才能向前移动。向前滑动,否则停止滑动。QUIC是基于UDP传输的,而UDP没有流控,所以QUIC实现了自己的流控机制。但是QUIC的滑动窗口滑动的条件和TCP是不一样的。QUIC实现了两级流控,即Stream和Connection:Stream级流控:每个Stream都有一个独立的滑动窗口,因此每个Stream都可以进行流控,防止单个Stream消耗掉连接(Connection)的整个接收缓冲区.连接流量控制:限制连接中所有流的总字节数,防止发送方超过连接的缓冲区容量。流级流量控制回想一下TCP。当发送方发送seq1、seq2、seq3包时,由于seq2包丢失,接收方收到seq1后会ack1,然后接收方收到seq3后返回ack1(因为没有收到seq2),发送窗口此时不能向前滑动。然而,QUIC不同。即使中途丢失一条消息,发送窗口仍然可以向前滑动。怎么做?让我们来看看。一开始,接收者接收窗口的初始状态如下:接下来,接收者接收到发送者发送的数据,部分数据被上层读取,部分数据丢失。此时接收窗口的状态如下:OK可以看出,接收窗口的左边界取决于接收到的最大offset字节数。此时接收窗口=最大窗口数-最大接收偏移数,这点和TCP不同。接收窗口触发的滑动条件是什么?看下图:接收窗口触发的滑动。当图中绿色部分的数据超过最大接收窗口的一半时,最大接收窗口向右移动,同时向对端帧发送“窗口更新”。当发送方收到接收方的窗口更新帧时,发送窗口会向前滑动,即使中途有丢包也会滑动,防止丢包时发送窗口无法移动像TCP,从而避免无法继续发送数据。前面我们说过,每个流都有自己的滑动窗口,不同的流之间是相互独立的。队列头部的流A被阻塞后,并不妨碍流B和C的读取。对于TCP来说,它不知道将不同的Streams交给上层哪个请求,所以在同一个Connection中,经过StreamA被阻塞,StreamB和C必须等待。了解了QUIC的流控机制后,队列头阻塞的问题就解决的比较彻底了。QUIC协议中同一个Stream中,滑动窗口的移动只依赖于接收到的最大字节偏移量(虽然期间可能有些数据收不到),而对于TCP,窗口滑动必须保证前面的数据包有sequence被接收,其中一个丢包会导致窗口等待。Connection流控对于Connection级别的流窗口,其接收窗口大小为各个Stream的接收窗口大小之和。连接流量控制上图示例中,所有Streams的最大接收偏移量为120,其中:Stream1的最大接收偏移量为100,可用窗口=120-100=20的最大接收偏移量Stream2为90,availablewindow=120-90=30Stream3的最大接收偏移量为110,availablewindow=120-110=10那么整个Connection的availablewindow=20+30+10=60Availablewindow=Stream1可用窗口+Stream2可用窗口+Stream3可以使用windowQUIC提高拥塞控制。QUIC协议目前默认使用TCP的Cubic拥塞控制算法(我们熟悉的慢启动、拥塞避免、快速重传、快速恢复策略),也支持CubicBytes、Reno、RenoBytes、BBR、PCC等拥塞控制算法相当于照搬了TCP的拥塞控制算法。QUIC是如何改进TCP的拥塞控制算法的?QUIC处于应用层,可以在应用层实现不同的拥塞控制算法。不需要操作系统,不需要内核支持。这是一个飞跃,因为传统的TCP拥塞控制必须要有端到端的网络协议栈支持才能达到控制效果。但是内核和操作系统的部署成本很高,升级周期很长,所以TCP拥塞控制算法的迭代速度很慢。但是QUIC可以随浏览器更新,QUIC的拥塞控制算法可以有更快的迭代速度。TCP改变的拥塞控制算法对系统中的所有应用程序都有效,不能根据不同的应用程序设置不同的拥塞控制策略。但是因为QUIC是在应用层,可以针对不同的应用设置不同的拥塞控制算法,所以灵活性非常高。QUIC更快的连接建立对于HTTP/1和HTTP/2协议,TCP和TLS是分层的,属于内核实现的传输层和openssl库实现的表现层,所以很难合并在一起,需要batched握手时,先是TCP握手(1RTT),再是TLS握手(2RTT),所以传输数据需要3RTT的延时,即使使用Session会话也至少需要2RTT。虽然HTTP/3在数据传输前需要QUIC协议握手,但这个握手过程只需要1个RTT。握手的目的是确认双方的“连接ID”,连接迁移是根据连接ID实现的。但是HTTP/3的QUIC协议并没有和TLS分层,而是QUIC内部包含了TLS,它会在自己的frame中携带TLS中的“记录”,而QUIC使用的是TLS1.3,所以只需1个RTT就可以完成连接“同时”建立和密钥协商。即使在二次连接时,应用数据包也可以和QUIC握手信息(连接信息+TLS信息)一起发送,达到0-RTT的效果。如下图右侧所示,当HTTP/3会话恢复时,payload数据与第一个数据包一起发送,可以实现0-RTT:QUIC是如何迁移连接的?HTTP协议基于TCP传输协议,因为它是通过四元组(源IP、源端口、目的IP、目的端口)来确定一个TCP连接的。那么当移动设备的网络从4G切换到WIFI时,就意味着IP地址发生了变化,那么就必须断开连接,然后重新建立TCP连接。建立连接的过程包括TCP三次握手和TLS四次握手的延时,以及TCP慢启动的减速过程。用户感觉网络突然卡顿了,所以连接的迁移成本非常高。QUIC协议并没有使用四元组来“绑定”连接,而是通过连接ID来标记通信的两个端点。客户端和服务端可以各自选择一组ID来标记自己,这样即使移动设备的网络发生变化,最终IP地址也会发生变化。只要仍然保留上下文信息(如连接ID、TLS密钥等),就可以“无缝”地复用原来的连接,免去重连的成本,实现连接迁移的功能。参考:https://www.taohui.tech/2021/02/04/%E7%BD%91%E7%BB%9C%E5%8D%8F%E8%AE%AE/%E6%B7%B1%E5%85%A5%E5%89%96%E6%9E%90HTTP3%E5%8D%8F%E8%AE%AE/https://zhuanlan.zhihu.com/p/32553477