分布式系统基础学习(01)--通信(TCP/UDP)

分布式系统是一个很庞大的话题, 在我个人的知识版图中, 也仅仅只是对一小部分土地进行了开荒。 不管是分布式系统, 还是单机应用系统, 都是建立在互联网通信机制之上的。 而提到通信, 就不得不提到TCP/UDP这两个非常经典的协议。

1. 为什么要学习TCP和UDP?

不管是科班出身还是自学出身的程序员, 或多或少的都会接触过这两个协议。 曾经我一度认为这玩意儿的底层原理有什么用? 我直接使用语言给我封装好的socket接口或者是http协议不是更加方便吗, 了解了其原理又能怎样, 难道还能再实现一个相关协议出来吗?

的确, 操作系统和框架已经将很多的细节隐藏了起来, 只为我们暴露非常简易的接口用于使用, 例如socket。 通过socket我们可以非常方便的建立TCP连接或者是UDP连接, 数据包的传输和解析也被隐藏, 使我们更加的专注于业务代码的编写。

在绝大多数场景中, 我们根本不需要去了解其中的细节, 拿过来直接用就好了。 但是在高并发的系统中, 一个很重要的调优手段就是优化服务器的TCP连接, 在这其中有诸多参数我们可以进行配置, 有些参数不能被修改, 有的参数能够提升服务器的性能, 这个时候, 学习原理的价值也就体现出来了。

我一直认为, 优化==学习原理, 当我们将原理掌握之后, 对一行代码, 或者一个系统进行优化时, 就会游刃有余。

2. 分层模型

虽然TCP/UDP协议属于传输层协议, 但是我们仍然需要以全局的眼光来看待。 网络分层有时会被分为5层, 有时会被分成7层, 后者是对5层模型的一个拓展, 本质上说的都是一个东西。

在这里以5层分层为例, 上图略去了物理层, 也就是光钎, 双绞线的比特传输, 这一层更偏向于物理硬件。 信息在互联网中传输是以数据包的形式进行的, 在传输时你发送一个包, 我接受一个包。

最开始的报文由应用层组成, 里面包含所要发送的信息, 该包从上往下, 依次经过运输层, 网络层以及链路层的封装, 最终组成一个完整的数据包。 真实的组包过程要比上图复杂许多, 这个给出一个大致的利于理解的组包模型。

3. UDP协议

柿子挑软的捏, 相较于TCP协议, UDP协议更加的简单, 纯粹, 所以首先从UDP协议开始。

UDP协议是一种不可靠的、无连接的传输层协议, 该协议只实现了传输层所要求的最低实现, 即进程到进程的数据交付和差错检查。 UDP协议只负责将数据报发送至远程, 不保证数据传输的有序性, 我们粗略的认为UDP协议就是直接和IP协议打交道, 自己并没有做什么额外的工作。

3.1 UDP报文段结构

 | center

UDP报文段比较简单, 头部一共仅包含4个字段, 包括源端口号, 目的端口号, UDP包总长以及校验和, 其中校验和就是UDP的差错检查。

其中伪头部是UDP报文经过IP协议封装之后所添加的头部, 包括的内容如上图。 之所以要加一个伪头部, 是因为UDP在做校验时需要这些数据。

3.2 UDP校验和

我们通过抓包的方式来实际的看一下UDP校验和到时是什么, 首选写一个非常简单的代码, 能够让我们抓到UDP包即可:

1
2
3
4
5
6
7
8
from socket import *

client = socket(AF_INET, SOCK_DGRAM)
message = b'Hello~'
client.sendto(message, ("111.111.122.122", 7070))
modified_message, server_address = client.recvfrom(4096)
print(modified_message)
client.close()

通过wireshark对该数据的发送进行抓包:

wireshark的界面中, 可以非常清晰的看到应用层数据包, UDP包, IP包以及以太网包的内容和每一层的封装过程, 与上图的4层模型是可以对应起来的。

将抓包所得的数据填充至上图中, 可以得到:

然后刨去校验和(6ee5), 将剩余的9个数据进行循环加法, 并对结果进行按位取反即可, 所得到的结果与UDP校验和进行对比, 若不相同, 则说明该包已经遭到损坏, 直接丢弃。

事实上, 当校验失败的时候, UDP协议对差错的恢复根本无能为力, 因为是无连接的协议, 所以不能通知发送方再发送一次, 只能将包丢弃。

3.3 为何使用UDP协议

在实际的体验了UDP协议的封包过程之后, 大致可以理解为什么UDP协议是不可靠的, 因为它对已损坏的包只能采取丢弃行为, 那么在客户端看来, 就切切实实的丢失了数据。

但是由于无连接的特性, 使得UDP协议要比面向连接的TCP协议拥有更快的数据传输。 这里的更快是指UDP协议不需要建立连接, 省去了这个过程。 此外UDP协议还提供广播服务, 可以用于内网的多播服务, 效率要比TCP协议更高。

基于上面的特性, DNS协议就是基于UDP协议所实现的, 这样一来就能够尽可能快的返回某个域名的IP地址。

4. 可靠数据传输

TCP协议是面向连接的、可靠的、具有拥塞机制的传输协议, 是目前使用最为广泛的传输层协议。 更极端的来讲, 99%的互联网应用都是由TCP协议所构成的。 TCP协议伟大的地方就在于它在不可靠的通信链路上实现了可靠的数据传输, 这也是TCP协议理解起来最为困难的地方。

我在回答TCP连接为什么是可靠的这个问题上花了很长的时间, 也找了很多资料, 没有什么特别好的博客把这个问题讲的非常清楚和完整。 在不断的阅读计算机网络-自顶向下方法可靠数据传输原理这一小节之后, 算是有一些理解。 所以这一小节主要是对书本上内容的一个解读和图例的展示, 图例会以我所理解的方式重新进行绘制。 对这部分内容不感兴趣或者有些难以理解的小伙伴儿可以跳过这一小节, 直接看下面的内容。

4.1 可靠通信链路

如果我们的通信链路是非常可靠的, 不存在丢包或者是字节损坏的问题, 那么这个时候发送方只管发送, 接收方只管接受。 在这种状态下, 发送方知道自己的包一定会被接收方接受, 接收方也知道自己接受到的信息一定是无损的。

4.2 具有字节损坏的通信链路

完全可靠的通信链路在现实生活中是不存在的, 我们将通信链路的不可靠性一点一点向前推进, 在这里, 我们假定通信链路会对包的字节造成损坏, 但是不会有丢包的情况。

在这个条件下, 我们可以定义一个ACK包和NAK包。 当接受方接受到完整无错的数据时, 会给发送方一个ACK包, 当接受的包出现了损坏时, 回给发送方一个NAK包。 发送方在接收到了NAK包之后, 就知道刚才发的包出错了, 进行一次重传。

在这种解决方案下, 发送方只有在接受到了前一个包的NAK或者是NAK包之后, 才会对下一个数据包进行发送(如果是NAK则对上一个包进行重传), 这个我们称之为停等协议, 发送方完全停止工作, 等待接收接收方所发送的确认包。

比较遗憾的是, 虽然这个解决方案看起来能够work, 但是这是建立在ACK, NAK包不会遭到损坏的条件下。 确认包也是通过通信链路发送的, 那么就有可能遭受损坏。

在网络数据传输时, 我们无法对一个数据包进行标记, 如果要标记一个数据包, 只能在包内部的某一个位置进行标记。 通常来讲ACK标记为1, NAK包标记为0。 当ACK包在传输时, 数值1被损坏, 变成了2, 那么这个时候发送方就傻眼儿了, 这不按套路出牌啊, 给我个2是什么鬼, 2是个啥? 那我是不管呢, 还是重新发一个包过去? 重新发一个包会不会造成数据重复?

在上面的分析我们可看到, 发送方在接受到了收到损坏的确认包之后, 如果进行重新发送, 很有可能造成数据重复的问题, 为了解决这个, 我们可以对发送的每一个包进行编号。 这样一来如果发送方在接受到了一个收到损坏的确认包并进行重传时, 接收方就能够知道这个包是重传的了, 如果该序号的包自己已经处理过了, 那么直接回一个ACK包就行, 不会造成数据重复。

那么这个时候ACK或者是NAK包收到损坏的问题就解决了, 反正发送方收到了损坏的确认包, 就直接重传刚才发送的包就好了, 接收方会对数据是否重复进行处理。

这个解决方案还是有一个问题, 就是发送方必须要收到刚才数据包的确认包之后才能发送下一个新的数据包, 因为ACK包没有加上数据包的编号, 就只能通过这种串行的处理来保证这个ACK包是用来确认刚才所发送的数据包的。 所以我们再在ACK包中放一个包的编号。

在这个版本中我们对ACK包和NAK包均进行编号, 这样一来发送方就有能力同时发送多个数据包了。 在此之外, 我们还可以对NAK包进行一下改进。

当我们收到了编号10的数据包并校验失败之后, 不再发送NAK包, 而是再发送一个编号为9的ACK包, 这样一来发送方也能够知道10号包需要重发一次。

到了最后一个版本, 我们已经能够比较好的处理在通信链路上出现的字节丢失问题了, 主要手段就是重传整个数据包, 并通过对数据包进行编号的方法, 解决ACK包不可靠以及接收方所出现的消息重复问题。

4.3 具有丢包的通信链路

这一次通信链路不仅会使得字节遭到损坏, 并且比较过分, 数据包整个都没有了, 简单分析一下这个过程。

发送方发送一个编号为15的数据包, 不幸的是, 这个包在传输时不小心丢了, 接收方没有接到这个数据包, 自然也不会回送一个编号为15的ACK包。 发送方等啊等, 迟迟没有收到编号为15的ACK包, 所以这个时候我们需要一个倒计时定时器。

当发送方在一定时间内没有收到某个特定编号的ACK包时, 对该数据包进行重传。 所以, 发送方定时器要做这些事情:

  1. 每次发送一个数据包时(包括重传的包), 启动一个定时器。
  2. 当定时器运行到终点时, 重发该数据包; 当定时器未结束便收到了ACK包, 则终止该计时器。

那么到这里, 我们通过使用校验和, 包编号, 定时器, 肯定确认(单个ACK)和否定确认(重复ACK)建立了一个能够在不可靠信道上进行可靠数据传输的机制, 不管通信信道会造成字节损坏还是丢包, 该机制都能够正常应对。 在这里总结一个每个小技术的作用。

  • 校验和: 用于判断数据包是否发生了字节丢失。
  • 包编号: 接下来, 我们将编程称之为序号, 序号的作用有2个, 一是使得发送方能够同时发送多个数据包, 另一个作用就是解决数据重复的问题。
  • 定时器: 当发生丢包时, 发送方能够对丢失的包进行重传。
  • 肯定确认: 包含一个序号的ACK包, 用于确认某序号的数据包被成功接收。
  • 否定确认: 一个重复的ACK包, 告知数据包验证失败。
4.4 具有更高效率的传输协议

在4.3小结中, 我们得到了具有可靠传输的最终版本, 也就是添加定时器的版本。 但是这个版本仍然是使用的停等协议, 一次只能发送一个数据包, 收到ACK之后发送第二个, 效率实在是太低了。 在我们为包添加了序号之后, 其实是可以一次传输多个数据包的。

如上图所示, 一次性的发送了5个数据包。 其中13号数据包接收方返回的ACK序号为12, 说明13号包校验失败, 重新发送13号数据包。 接收方在发送14号数据包的ACK时意外丢失, 发送发在接收14号ACK包发生超时, 对14号数据包进行超时重传。 该模型已经比较的接近真实TCP协议了。

5. TCP协议

TCP协议是一种面向连接的, 可靠的, 具有拥塞机制的传输层协议, 相较于UDP协议, TCP协议能够保证数据包的有序性以及可靠性, 即使在使用不可靠的底层通信链路传输数据的情况下, TCP协议依然能够保证数据的完整性。 基于这些特性, 也就决定了TCP协议在传输层的统治地位。

5.1 TCP报文段结构

这里对其进行一个基本的划分:

  1. 序号和确认号是TCP头部最为重要的两个数据, 和校验和一起提供可靠数据传输
  2. RST, SYN, FIN主要用于连接的建立与关闭, 在三次握手和四次挥手中将会发挥作用。 ACK用于确认接收方已成功接收数据, 这4个数据均只占一个bit。
  3. 接收窗口是TCP协议所提供的拥塞机制的具体体现, 稍后会提到。
  4. URG, PSH以及紧急数据指针似乎在实际中并没有用到, 所以直接划掉, 不管这些。
5.2 三次握手

既然TCP协议是面向连接的传输协议, 那么就必然会有通道连接的过程, 并且该过程必须可靠, 所以是三次握手, 而不是两次握手。

SYN, 全称为Synchronized Sequence Number, 同步序列编号, 为三次握手发起的信号, 所有的握手都是以SYN为起点的。 需要特别注意的是, SYN, ACK等都是标志位, 只占一个bit。 发起连接的客户端会随机的选取一个序列号, 这个我们写为client_isn, 在上图中写为0是因为大多数的客户端起始序列号都是0, 并不代表起始序列号一定是0。

当服务端接收到SYN标志位的包以后, 也随机的生成一个序列号, 写为server_isn。 与发起方一样, 大多数接收方的其实序列号也为0。 确认号(Acknowledge Number)的生成规则是将收到的序列号加1, 所以这里的值为1, 并将ACK标志位置为1, 返回给发送方。

发送方接收到了这个包之后得回一个ACK包, 这是发送方发的第2个包, 由于前一个SYN包所发送的数据为0字节, 所以此时的序号直接加1, 确认号为接收到的序列号加1, 所以返回的Seq和确认号均为1。

更加通用的三次握手图示:

一定要区分ACK标志位和ACK确认号, 这两个不是同一个东西。 ACK标志位仅仅只是一个标志, 取值只有0和1; ACK确认号才是可靠传输的关键, 只不过在实际情况中并没有直接将收到的包序号返回, 在握手阶段由于SYN包和AYNACK包不会携带具体信息, 所以说确认号只会进行加1操作。 而在正常的数据传输过程中, ACK确认号为数据包的序号+数据字节数。

5.3 拥塞机制

TCP协议要比UDP协议更加”智能”一些, 当接收方所能够接收的信息有限时, TCP会降低数据包的发送速率, 使得接收方不会因为大量数据的到来而被淹没。

拥塞机制是一个很大的话题, 再加上目前能力有限, 所以在这一小节中只会对滑动窗口进行一个简单的介绍。

假设在三次握手时, 通过AYNACK包得知接收方的窗口大小为4, 那么发送方每一次最多发送4个数据分组。

如上图所示, 其中7, 8编号为已经发送并收到确认号的分组, 9, 10, 11, 12号分组为即将发送的分组。 当这4个分组发送出去之后, 发送方不会再发送13号分组(窗口大小限制), 除非收到了9号分组的ACK。 这个假设我们的每个分组大小均为1, 所以9号包将会返回一个ACK=10。 9号包返回之后, 13号包发送, 10号包返回, 14号包发送。

在发送分组以及接收分组的ACK包的过程中, 窗口不断的向前移动, 所以称之为滑动窗口。 这是一个非常简易的图例, 实际上, 在接收方, 同样也有接收窗口。

窗口大小在三次握手阶段就得到了确认, 包括双方的窗口大小。

5.4 四次挥手

四次挥手要比三次握手稍微复杂一些, 原因也很简单。 想象一下你过年从外婆家离开, 外婆和你肯定要絮叨一下, 并且还会给你塞点儿东西才放你走。 四次挥手的过程与这个差不多。

四次挥手的图例以及过程有一个博客我觉得讲的非常好, 还做了动图进行展示, 虽然平台为CSDN。

https://blog.csdn.net/qzcsu/article/details/72861891

扪心自问我没办法做的这么好, 值得学习, 所以这一块儿的内容请移步该作者博客。

6. 小结

我认为在网络协议中, 更为重要的是理解TCP连接为什么是可靠的, 为了保证可靠性和性能, TCP协议有哪些具体的实现, 这些思想在日常的编程过程非常值得借鉴。