从TCP/UDP到HTTP,我们可以学到些什么?

448 阅读25分钟

从TCP/UDP到HTTP,我们可以学到些什么?

一般来说,我们自定义协议都是基于传输层的TCP/UDP进行开发。

  1. 如果你希望开发一套全新的传输层协议,那么你可能就需要修改内核,这将会尤其复杂。除此之外,目前的网络设备,例如防火墙、路由器,都支持TCP/UDP协议,如果再另外开发一套独立于TCP/UDP的传输层协议,那么就可能出现通信无法穿透的问题。
  2. 如果你不担心透传问题,那么基于TCP/UDP协议开发协议还可以为你提供一定的服务能力。例如UDP可以提供最基础的多路复用多路分解能力;TCP还可以额外提供可靠传输、流量控制、拥塞控制能力。

一、深入底层UDP数据报和和TCP报文段

首先从底层的数据链路层开始说起。我们知道对于以太网帧来说,MTU是1500,它是怎么得来的呢?

  • 对于以太网帧,他有14字节的帧头以及4字节的帧尾,中间的负载部分实际上可以承载1500字节。而MTU也就是以太网帧的负载数据能力。我们更乐意传输小而多的数据,从而使得各个帧可以更公平地获取传输资源,而不是某个大数据帧长时间占用传输资源。
  • 但除此之外还有一个容易遗漏的地方——早期使用总线和集线器的CSMA/CD协议来说,我们需要进行冲突检测,而此时为了保证极端情况也能检测到冲突,就需要保证以太网帧有一个最小的字节长度。这个最小的字节长度就是64字节,除去帧头帧尾后就可知道,以太网帧的负载部分最少为46字节
    • 当不满足64字节时,是通过MAC子层来实现的填充的。如果未及时填充则会导致数据丢失的情况。

但实际上MTU值在不同的网络设备上可能有不同的定义,以上是最常见的定义。

接下来看看网络层。

  • 对于IP数据包,固定首部有20字节。其中有个字段值得我们注意——16位的Total Length。那么是不是说我们的IP数据包就可以最多承载65535-20字节的数据了呢?
  • 刚才我们已经知道了以太网帧的MTU最多是1500字节,而网络层是基于数据链路层所构建的协议,故这是必须遵守的约定。因此在网络层中超过1500字节的IP数据包都需要进行分片。而这也是IP首部中Identification、Flags、Fragment Offset字段的作用。

终于到了我们最关心的传输层了。

  • 对于UDP数据报来说,首部仅有8字节。其中也有一个标识整个数据报长度的字段——16位的Length。同理我们知道传输层是基于网络层和数据链路层构建的协议,依托于网络层IP协议的能力,我们不需要再去关心更底层的数据链路层规定的MTU1500字节。那么只管将上层的数据传入下层的协议栈即可。IP协议会将超过1500字节限制的UDP数据报进行分片再传入更底层的以太网帧中。
    • 但事实却是,RFC中定义了UDP的最长长度应该是576。这是为什么呢?首先来看看分片丢失的情况,由于UDP是不可靠的传输协议,发送方是不会重新发送数据报分片的,故当接收方发现分片丢失时,只能将整个数据报的其他分片一同舍弃。
    • 那么只要保证数据报的长度为1500-20(IP首部)=1480字节不就好了吗?但对于不同的网络设备来说,MTU值将会不同。故RFC就规定了MTU的下限576字节。那么实际上为了保证我们的UDP数据报一定不会重新分片,数据报的长度应该维持在576-20=556字节以内
  • 对于TCP报文段来说,固定首部有20字节。它并没有像IP协议和UDP协议一样有一个表示整个数据的长度字段。上层就可以源源不断地发送数据到TCP协议,而TCP协议就会将数据依次地转发到下层协议。这也是为什么说TCP属于流协议的原因。
    • 同理,由于下层的IP协议可以帮助我们解决报文段长度超过MTU1500字节的问题,在TCP协议当中我们省下了很多功夫。除此之外,由于TCP属于可靠传输协议,当分片丢失时,发送方将会重传分片,那么接收方检查到分片丢失就不需要将整个报文段分片数据丢弃了。那么对于TCP来说就不存在任何报文段长度的限制了吗?
    • 实际上在TCP协议中,我们规定了MSS来限制上层传输接收的最大字节数量。通常MSS为1500-20(IP首部)-20(TCP首部)=1460字节,实际的MSS大小需要在TCP连接时进行协商。MSS的目的恰好也是为了避免IP协议进行分片传输。为什么呢?首先我们要知道,分片和重组的操作都是发生在网络层中,这意味着,当主机A发送一个大TCP报文段时,另一端的主机B实际上也是在等待接收这个大TCP报文段。如果出现报文延迟、丢失到需要重传的情况时,对于IP协议来说并不存在可靠传输的能力,故不能控制重传哪个分片,而对于具有可靠传输能力的TCP来说,此时只能重传一整个大TCP报文段,而在已经出现报文丢失的情况下,网络大概率是十分拥塞的,此时再重新发送大量数据,无异于火上浇油,故这是不可取的行为
    • 那么是不是意味着我们只要保证每次发送的TCP报文段小于MSS就可以无限发送TCP报文段了呢?并不是。为了达到最佳的传输效能,TCP协议提供了流量控制和拥塞控制能力——不断地根据网络拥塞情况以及连接双方的socket缓冲区容量来调整同一时间一条TCP连接上可以发送的最大字节数量,即发送窗口大小=min(接收窗口大小, 拥塞窗口大小)
      • 流量控制是为了协调发送方和接收方的读写速率
      • 拥塞控制是为了协调发送方的发送速率和整个网络的传输速率

      感兴趣的还可以深入了解一下流量控制和拥塞控制能力是如何在socket缓冲区表现的;Nagle的批量发送优化策略也可以了解一下。总之再往下探究就需要掌握socket编程知识点。现在只需要在心里默念socket缓冲区大小和网络拥塞是导致丢包的最常见原因,同时这也是我们优化的方向。

    • 似乎我们还遗忘了一件很重要的事情,即如何分段重组一个完整上层消息呢,即如何解决拆包粘包问题?也许我们会想当然地希望TCP协议帮助我们完成这件事,但是如果是这样,那么从上层的角度来看,此时TCP就不再是流协议了。在上层的角度来看,TCP应该属于一种可以源源不断发送数据和接收数据的一种协议,尽管站在TCP协议的角度来看,它还是发送一个个MSS大小的报文段。因此拆包粘包的问题只能通过上层自己解决

二、基于TCP的HTTP

关于HTTP/0.9、HTTP/1.0在本文中不会涉及,有兴趣的读者可以自行学习。

1 如何解决拆包粘包问题?

常见的解决方案有以下几种:

  1. 采用固定消息的长度的方式。这种方案十分简单粗暴。在消息长度太大时,这种方式将无法满足传输要求,在消息长度太小时,又必须填充剩余的字节。
  2. 采用特定分隔符来确定消息边界的方式。这种情况考虑到有效消息内部可能会包含特定分隔符,故一般会将消息编码,例如base64,然后利用64个字符之外的字符作为特定的分隔符。
  3. 采用固定首部标识消息负载长度的方式。这是最常见的方式,也是整个网络协议栈中很标准的协议定义方式。在固定首部的长度,既可以准确的读取到首部的内容;然后根据首部中的消息长度来确定接下来需要读取多少字节即可。

对于HTTP/1.1来说,采用的是一种混合策略。它具有请求行/状态行、请求头/响应头、请求体/响应体,并且通过CRLF进行分隔。除此之外在请求头/响应头中Content-Length也表明了请求体/响应体的长度。

对于HTTP/2.0来说,则是采用了比较标准的固定首部和标识消息负载长度的方式来解决拆包粘包问题。

2 文本协议和二进制协议有什么区别?

我们可以认为HTTP/1.1就是文本协议,HTTP/2.0、TCP都属于二进制协议

  • 可读性以及效率:文本协议需要通过机器额外地将二进制数据编解码转换为人类可读的文本,通常这需要花费更多的计算资源。二进制协议通常人类不可读,可以省去将文本编码为字节的计算开销
  • 兼容性:
    • 文本协议通常会采用一些类似于读时模式的语法来表述数据,例如HTTP/1.1中使用CRLF来分隔多个HEADER,负载数据采用JSON来描述。读时模式不强制要求写入时的格式,故可以省去格式转换或者检查的计算资源,对应地读时模式是在读取时才计算所需数据是否符合约定。如此一来我们可以很好的保证向前向后兼容
    • 对于二进制协议来说,采用的则类似于写时模式的语法来表述数据,即所有数据都是有长度限制的,例如HTTP/2.0就规定好帧长、帧类型、流标识符等等字段在一个完整的二进制帧中所处的位置。一旦这些规定好的字段所占比特数在后续版本发生了改变,那么就会出现向前向后兼容性问题

在对数据结构进行定义和编解码时,我们常常会考虑以下四个方面:

  • 向前兼容:站在当前版本的角度来看待未来版本的兼容性问题,即当前代码能否读取/解码新代码写入/编码的数据
  • 向后兼容:站在当前版本的角度来看待过往版本的兼容性问题,即当前代码能否读取/解码旧代码写入/编码的数据
  • 写时模式:数据的结构是显式的,需要根据数据结构的定义写入数据,在读取时不必再做额外操作。
    • 在添加新字段时,新数据仍然可以被旧代码解码得到,只不过新字段或者修改字段可能无法正确解码得到,此时舍去对应字段的值即可,故还是可以符合向前兼容性的。
    • 在添加新字段时,旧数据仍然可以被新代码解码得到,只不过新字段或者修改字段可能无法正确解码得到,此时令对应字段为空值即可,故还是可以符合向后兼容性的。
    • 对于删除来说,比较简单的舍去对应值或者赋为空值就可以符合兼容性。具体的分析过程添加新字段一样分析即可。
    • 但是对于修改字段来说,则会发生兼容性问题。直接往从二进制的角度来看就是一个字段的可承载的数据范围发生了改变,即可能会导致数据错乱的问题。
  • 读时模式:数据的结构是隐式的,在写入时不做任何额外操作,在读取时才根据数据结构的定义去解释描述数据。对于读时模式来说,基本上不存在兼容性问题。

3 首部压缩

首先重点来看看数据传输大小问题:

  • 通常我们采用我们为了传输的数据更小,我们会采用高效的序列化/编码方式。
    • 常见的序列化方式有JSON、Protocol Buffers等等
  • 更进一步地,我们可以将我们的序列化数据压缩得更小再传输
    • 常见的压缩方式有gzip、Huffman编码等等

对于HTTP这种用于Web服务交换数据的协议来说,消息负载一般采用的都是可读性较高的序列化方式。此时只能通过压缩算法来实现传输数据大小降低。

但对于首部来说,就有很大的优化空间了。对于HTTP/1.1来说,还未对首部做任何序列化、压缩优化操作。到了HTTP/2.0,则做出了很大的改变。

  • 首先依托于二进制协议的好处,我们可以采用更加高效的序列化方式,例如HTTP/2.0中就规定了首部的格式,前 2 位固定为 01image.png
  • 其次,还采用了Huffman编码对序列化后的数据进行压缩,来达到高压缩率

刚刚我们提到的是如何降低数据传输大小,那么有没有可能不传输数据,仅仅发送一个类似于信令的方式来告知对方这次发送了些什么数据呢

  • 显而易见地,这不就属于约定俗成一些默认值嘛。让双方提前载入可能收发的数据,以及标识这个数据的索引,后续就可以通过索引来得到对应地数据。而这就是HTTP/2.0静态表的应用。通过提前载入常用首部到一个哈希表中,后续就可以避免发送冗长的首部数据了。
  • 那么可不可以做得更加灵活呢?当然可以。我们可以动态的增减哈希表中的数据。而这就是HTTP/2.0动态表的应用。对于一些需要通信过后才能具体得知的首部,也会添加进哈希表中,后续再使用到相同首部时,就可以不再发送完整数据,而是仅仅发送一个索引即可。

4 短连接与长连接

由于短连接意味着每个请求之前需要新建TCP连接,响应结束之后又立马丢弃该TCP连接。我们可以理解为这仅仅利用了TCP提供的可靠传输能力,而将其流量控制和拥塞控制能力弃之于不顾。

再仔细一思考这乃是弊大于利的,首先TCP的新建由于需要三次握手,也就是1.5RTT,销毁又需要2RTT。这将会十分耗费时间也浪费资源。除此之外,由于TCP拥塞控制的存在,每次新建的TCP发送窗口是比较小的,这意味着如果请求的消息比较大,那么还需要等待慢启动增长发送窗口的大小,这就意味着还需要多个RTT之后才能获得到完整消息。

因此HTTP/1.1中默认开启长连接,此时多个请求排队等待占用一条HTTP长连接,此时就可以避免上面提到的问题。同理对于HTTP/2.0来说,也是默认开启长连接。


更进一步地,我们应该思考一件事情——如何管理这些HTTP连接的生命周期?

  1. 首先来思考一下内存资源分配情况,即需要考虑内存资源是否支持建立大量HTTP连接并行请求。站在应用层的角度来说,大多数HTTP库都会利用缓冲批量技术来提升性能,用户态内定义的大量缓冲区占满后是否会导致OOM。站在传输层的角度来说,需要注意socket缓冲区是否会被占满,而导致发送阻塞或者返回错误(取决于socket阻塞/非阻塞模式)
  2. 对于短连接来说,用完即销毁。但是对于长连接来说,什么时候销毁呢?当某方主动提出关闭时,可以进行销毁。那么如果说关闭请求无法正常被接收呢?那么长连接岂不是一直占据着资源?实际上对于双方无法正常通信的情况,我们可以称之为死链,而为了防止死链一直占据资源,我们就需要一种检测机制,使其在发现死链后进行销毁死链,释放资源。而这种检测机制就是心跳检测机制。

例如nginx默认的HTTP长连接超时时间就是75秒。

5 心跳检测机制

即发送一个心跳包来达到两个目的——保活和检测死链。简单来说,发送一个心跳包,如果对方能接收到,则说明双方都还活着。反之如果接收不到,则说明可能出现死链的情况,此时我们再加上一个超时重传机制,当超过一定时间后还是收不到心跳包,则可以确定这是一条死链,那么就可以及时销毁连接,释放资源。

但是实际上心跳检测的实现却还是很多细节值得我们讨论,注意!始终围绕连接的生命周期(死活)进行分析

5.1 谁需要发送心跳包?

直接揭晓答案——获取资源的一方需要发送心跳包。其底层逻辑就是,我需要保证资源持有方存活,我才能获取到资源

  • 对于HTTP这种请求/响应的通信方式来说,就是客户端需要请求获取服务端的资源,此时客户端就是需要发送心跳包的一方。
  • 同理,对于TCP这种全双工的通信方式来说,哪一方需要获取资源,那么哪一方就需要发送心跳包。
5.2 什么时候发送心跳包,什么时候检测心跳包/死链?
  • 关键在于我们如何定义死链。我们一般认为死链就是经过一定的时间后,还是无法收到数据的连接。
  • 那么顺着这个定义,实际上就是在说可以传输数据且正常收发数据的连接不是死链。因此当连接正在发送有效数据时,并不需要再额外地发送心跳包数据,这将会浪费资源,也占用带宽。
  • 因此收发双方就都需要维护一个计时器,用于记录未收发有效数据的时间间隔/空闲连接的时间间隔。对于发送方,当超过指定的时间间隔后,就需要发送心跳包。对于接收方,当超过指定的时间间隔后,则需要检测死链。这里就要求发送方的时间间隔应该小于接收方的时间间隔
  • 但是网络可能会出现一定的拥塞,导致心跳包不能及时到达对端,那么就需要引入超时重传机制。当超过重传心跳包的时间间隔时,就再次发送心跳包;当超出重传心跳包的次数时,就认定这是一条死链,发送方可以释放资源。同理,当接收方迟迟未收到数据,最终也会认定这是一条死链,此时接收方也会释放资源

综上我们就可以得到以下参数配置:

  • 对于持有资源的一方(服务端):检测死链的时间间隔/空闲连接的时间间隔
  • 对于获取资源的一方(客户端):发送心跳包的时间间隔/空闲连接的时间间隔、重传心跳包的次数、重传心跳包的时间间隔
5.3 TCP和HTTP/1.1的keepalive有什么区别?

换句话说,这实际上是在问传输层和应用层的keepalive有什么区别。让我们再次回到心跳检测的目的——保活和检测死链。

站在传输层的角度来看,就是保证TCP连接可以传输数据且正常收发数据。同理站在应用层的角度,例如HTTP/1.1,就是一个HTTP连接可以传输数据且正常收发数据。而协议栈的存在,让我们知道,下层可以传输数据且正常收发数据不代表着上层就可以。如果仅仅依靠TCP的keepalive机制,那么将无法得知应用层服务是否具有可用性。再换句话说就是传输层的心跳检测不能够满足应用层的要求,它提供的服务能力太弱了

除此之外,我们再了解一下TCP的keeppalive机制的参数配置:

  • net.ipv4.tcp_keepalive_time = 7200,发送心跳包的时间间隔
  • net.ipv4.tcp_keepalive_intvl = 75,重传心跳包的时间间隔
  • net.ipv4.tcp_keepalive_probes = 9,重传心跳包的次数

很容易发现,它的心跳检测周期太长了,因此更多时候需要在应用层实现一套心跳检测机制。

6 队头阻塞问题

对于HTTP/1.1来说,一条HTTP连接只能在同一时间内进行一次请求响应,对于多个请求使用同一条HTTP连接来说,只能排队等候正在处理的请求使用完该HTTP连接。因此就出现了HTTP队头阻塞问题

  • 虽然利用管道技术可以突破同一时间只能发送一个请求的限制,但是服务端却还是需要根据请求的顺序来一个接一个地返回响应数据,故实际上并没有解决HTTP队头阻塞问题。
  • 更常见的做法是建立多条HTTP连接进行请求从而避免HTTP队头阻塞的问题,目前浏览器支持一个HOST建立6个HTTP连接。

对于HTTP/2.0来说,由于使用了多路复用机制,多个请求响应可以同时在一条HTTP连接进行全双工的处理,请求的二进制分帧和响应的二进制分帧会根据帧中的stream id来重组消息。故可以解决HTTP队头阻塞问题。

但是一条HTTP/2.0连接对应的还是一条TCP连接,而一条TCP连接中的数据是需要保证可靠传输的,当一个报文段丢失时,需要等到重传,由于采用的是累计确认的方式,故即使收到后续的报文段也无法确认接收。因此就出现了TCP队头阻塞问题

三、基于UDP(QUIC)的HTTP

QUIC是基于UDP且在用户态开发的一套传输层协议。这意味着开发迭代更加简单迅速。并且可以很好地适配目前的软硬件。

1 QUIC连接与TCP连接

TCP连接需要通过源/目的Port和源/目的IP的四元组进行唯一确定。对于QUIC来说,它是基于UDP的,发送数据需要确定目的Port、目的IP,除此之外在UDP之上还额外增加了一个Connection ID,每个端点是在发送数据时应该携带对方的Connection ID,而不是自己的Connection ID。我们可以认为这个Connection ID + 目的Port + 目的IP三元组可以确认一条QUIC连接。

那么这么做有什么好处呢?和TCP的四元组确定的连接相比有什么区别呢?其实应该换个角度思考,让我们站在更抽象的层面——内核态与用户态的层面来讨论这个问题。可以发现最本质的区别是完成一个功能是否脱离了内核的介入。对于QUIC通过在用户态完成所需功能,那么就可以扩展很多由内核限制住的能力。例如连接迁移能力。

让我们再换一个角度思考,内核态即相当于物理层面,难以变动;用户态相当于逻辑层面,灵活易变。对于传统的TCP连接来说,每次切换连接实际上都只能让内核销毁旧连接,然后根据新的四元组来创建新连接。而对于QUCI来说,切换连接时,在用户态实现的逻辑标识符——Connection ID保持不变,而UDP并不是面向连接的一个协议,当二元组变化时,并不会涉及到复杂的创建与销毁。


还有一个问题——连接的创建效率会相差多少?

首先我们要知道QUIC连接是要求必须使用TLS1.3以保证连接的安全性。其中TLS1.3需要1个RTT,而TLS1.2需要2个RTT。

对于TCP创建来说需要1.5个RTT,而对于QUIC连接来说,它只需要1个RTT,也就是TLS1.3协商时会出现一定的延迟。当双方保存了协商后的密钥,再次连接时甚至不需要进行TLS1.3协商,那么此时就是0RTT,故连接开销相较于TCP来说是大大降低。

2 QUIC流与TCP流

相较于TCP仅仅存在一条流来说,QUIC天生就可以同时收发多条流的数据。并且更加灵活的是,可以使用流标识符的低两位比特来确定流的单/双向以及流的发起者。

前面提到内核限制住的能力,可以在用户态进行扩展,而粘包拆包就是这么一个很好的例子。当初是由于TCP的传输单条流这个身份定位,导致它无法增加这个粘包拆包处理能力,而QUIC协议直接在用户态实现了多条流的能力,再增加一个粘包拆包处理能力也不在话下。

除此之外,TCP该有的可靠传输、流量控制能力,QUIC也一并实现了。但是需要注意的是,可靠传输、流量控制能力都是基于QUIC中的单条流的。

总结一下就是:

  • QUIC连接可以并行传输多条流数据,TCP连接只有一条流,仅可以串行传输数据。从这个角度来看,QUIC连接就解决了TCP队头阻塞问题
  • QUIC流可以控制流向,而TCP流理论上是双向的,但也可以通过半关闭或者应用层约定来自行控制流向。
  • QUIC流直接解决了粘包拆包问题
  • QUIC流具有可靠传输、流量控制能力

3 安全性

实际上安全性是QUIC的最大亮点。

我们知道HTTP/1.1、HTTP/2.0如果要进行数据加密,都是在TCP和HTTP之间再多加上一个TLS层,那么此时只加密应用层数据而已。而对于QUIC这种深度集成TLS的协议来说,则可以加密部分传输层和全部应用层的数据,此时安全性大大提升。

四、总结

我们来看看目前都涉及到了哪些值得注意的知识点:

  1. 注意发送数据的负载大小,这常常是软硬件缓冲区大小网络拥塞情况做出的一种权衡。
  2. 协议的传输效率和数据结构序列化的方式关系密切,而不同的序列化方式往往和兼容性密切相关。除了从协议本身的数据结构进行优化,我们还可以从其他方面,例如对数据进行压缩或者引入哈希表缓存等等方式来优化传输效率。
  3. 注意连接的管理,这将涉及到连接的内存占用、连接的生命周期。除此之外还引出了心跳检测机制和超时重传机制。
  4. 注意流式传输时发生的粘包拆包问题
  5. 由于需要竞争单一资源而导致的阻塞问题,例如HTTP/1.1和TCP队头阻塞,而我们可以增加所需资源,从而避免阻塞、实现并发。这就是HTTP/2.0解决HTTP/1.1队头阻塞,QUIC解决TCP队头阻塞的思路。