目录
网络通信中, 协议是一个非常重要的概念. 协议进行了分层, 此处就是按照这几层顺序来介绍每一层中的核心协议.
1. 应用层
应用层, 通俗来说, 就是对着应用程序, 是程序员打交道最多的一层. 例如调用系统提供的网络api写出的代码都是应用层的.
应用层中有很多现成的协议, 程序员也可以根据实际场景, 自定义协议.
以点外卖平台来举例子, 打开点餐软件, 显示出主页, 主页里就要显示出商家列表, 显示的商家列表中, 也会包含一些信息,如: 商家的名称, 图片, 商家评分, 商家评价......
上述的数据按照怎样的格式来组织, 是有一些固定的套路的.
一个简单粗暴的例子:
请求: 约定使用行文本的格式来表示
响应也是使用行文本来表示
上述给的例子里, 约定的格式太过于简单粗暴了, 虽然能解决问题, 但实际中, 很少会这么约定. 这样约定不太适合扩展, 可读性也不高. 为了让程序员更方便的去约定这里的协议格式, 业界也给出了几个比较好用的方案, 可以拿过来直接套上用:
常见协议:
1. XML(可扩展标记语言)协议格式是一种用于表示结构化数据的协议格式,它使用标记和元素来描述数据的组织和层次关系。下是一个示例XML协议格式的简单样:
优点: 可读性和扩展性提高了, 标签的名字能够对数据起到描述说明效果, 后续要增加一些属性, 就新增一个标签即可, 对已有代码的影响不大.
缺点: 整个数据冗余信息变多了, 可能标签占用的空间反而比数据本身更多了. 通过网络传输会消耗很多带宽.
2. JSON(JavaScript Object Notation)是一种轻量级的数据交换格式,广泛用于前后端之间的数据传输和存储。与XML类似,JSON协议也是一种结构化的数据格式,通过键值对的方式表示数据。
{
"order": {
"customer": {
"name": "顾客姓名",
"address": "顾客地址",
"phone": "顾客电话"
},
"restaurant": {
"name": "餐厅名称",
"address": "餐厅地址",
"phone": "餐厅电话"
},
"items": [
{
"name": "菜品1",
"quantity": 2,
"price": 20.00
},
{
"name": "菜品2",
"quantity": 1,
"price": 15.50
}
],
"delivery": {
"type": "外卖方式",
"time": "送达时间"
},
"payment": {
"method": "支付方式",
"amount": 35.50
}
}
}
3. Protocol Buffers(简称ProtoBuf)协议 .ProtoBuf使用二进制编码,相比于文本格式协议如JSON和XML,序列化后的数据体积更小,传输效率更高。
虽然 ProtoBuf 运行效率高, 但是使用并没有比 json 广泛. 只是针对哪些性能要求高的场景才会使用.
2. 传输层
传输层虽然是系统内核已经实现好了的, 但是也需要重点关注. 我们所使用的Socket api都是传输层提供的.
2.1 端口号
端口号是用于标识计算机网络中特定进程或服务的数字标识符。在TCP/IP网络中,每个网络应用程序都需要通过指定端口号来与其他应用程序进行通信。
端口号范围从0到65535。其中,0到1023的端口号被称为"知名端口". 这些端口号在大多数操作系统中都有预留,并且需要管理员权限才能使用。
2.2 UDP协议
UDP协议端格式
各个字段的含义如下:
- 源端口号:占用2个字节,表示发送方的端口号。
- 目标端口号:占用2个字节,表示接收方的端口号。
- 长度:占用2个字节,表示UDP数据报的长度,包括报文头和数据部分。
- 校验和:占用2个字节,用于检测数据传输过程中是否出现错误。
- 数据:占用不定长度,即实际传输的数据部分。
在UDP协议中,校验和(Checksum)是一种用于检测数据传输过程中是否出现错误的机制。它通过对UDP数据报的各个字段进行计算,生成一个校验和值,并将该值附加到UDP报文头中
UDP中, 校验和可以使用比较简单的方式CRC算法来完成校验.
例如, 要产生一个两个字节的校验和:
上述CRC算法中, 如果只有一个比特位发生翻转, 此时%100能够发现问题, 如果有两个或多个比特位发生翻转, 有可能恰好校验和和之前一样(概率很小)
因此业界还有一个很常用的高精度校验和算法---md5算法
MD5(Message Digest Algorithm 5)校验和算法具有以下特点:
-
不可逆性:MD5算法是一种哈希算法,其生成的校验值无法通过逆向计算得到原始数据。也就是说,无法从校验值反推出原始数据。
-
高度唯一性:即使输入数据的微小改动,也会导致MD5校验值的巨大变化。对于不同的输入数据,MD5算法生成的校验值几乎是唯一的,极小的数据变动也会导致校验值的巨大差异。
-
高速性:相对于其他加密算法,如SHA-1和SHA-256,MD5算法具有较快的计算速度。这使得它在对大量数据进行校验和计算时更加高效。
-
固定长度:无论输入数据的长度是多少,MD5算法生成的校验值始终为128位(16字节)。这种固定长度的校验值使得MD5算法的应用更加灵活和便利。
发送方在发送UDP数据报之前,会计算数据部分的校验和,并将计算得到的校验和值填入UDP报文头的校验和字段中。接收方在接收到UDP数据报之后,会重新计算接收到的数据部分的校验和,并将计算得到的校验和值与接收到的校验和字段进行比较。如果两者相等,则说明数据在传输过程中没有出现错误;如果不相等,则说明数据在传输过程中可能发生了错误。
2.3 TCP协议
- 源端口号(16位):指示发送方应用程序的端口号。
- 目的端口号(16位):指示接收方应用程序的端口号。
- 序列号(32位):用于对发送的数据进行编号,保证数据的有序传输。
- 确认号(32位):表示接收方期望接收的下一个数据段的序列号。
- 首部长度(4位):指示TCP头部的长度,以4字节为单位。
- 保留(标志位):6个标志位,分别为URG、ACK、PSH、RST、SYN、FIN。用于控制连接的建立、断开和数据的传输。
- 窗口大小(16位):表示发送方的接收窗口大小,用于流量控制。
- 校验和(16位):用于校验TCP头部和数据的完整性。
- 紧急指针(16位):用于指示紧急数据的位置。
- 选项(可选):可选字段,用于扩展TCP的功能和性能。
6个标志位的具体作用:
-
URG(Urgent):表示紧急指针字段是否有效。当该标志位被设置为1时,表示紧急指针字段指示的数据具有优先级,需要尽快传输。
-
ACK(Acknowledgment):表示确认号字段是否有效。当该标志位被设置为1时,表示确认号字段有效,表示接收方期望接收的下一个数据段的序列号。
-
PSH(Push):表示数据是否应该立即交付给接收方的应用程序。当该标志位被设置为1时,表示发送方希望接收方立即交付数据,而不是缓存起来等待更多的数据。
-
RST(Reset):用于重置连接。当该标志位被设置为1时,表示连接出现错误,需要立即终止连接。
-
SYN(Synchronize):用于连接建立过程。当该标志位被设置为1时,表示发送方请求建立连接,并在序列号字段中发送一个初始序列号。
-
FIN(Finish):用于连接断开过程。当该标志位被设置为1时,表示发送方希望关闭连接,不再发送数据。
1.确认应答
TCP用来确保可靠性, 最核心的机制是确认应答
TCP将每个字节的数据都进行了编号. 即为序列号.
每⼀个ACK都带有对应的确认序列号, 意思是告诉发送者, 我已经收到了哪些数据; 下⼀次你从哪⾥开始发.
应答报文中的确认序号, 是按照发送过去的最后一个字节的序号再加1来进行设定的:
2.超时重传
在TCP协议中,超时重传是一种用于保证数据可靠传输的机制。当发送方发送数据段后,如果在一定的时间内未收到接收方的确认应答,发送方会认为数据丢失或损坏,触发超时重传的操作。
如果一切顺利, 通过应答报文就可以告诉发送方当前数据是不是成功送到, 但是,网上可能存在"丢包"的情况, 如果数据包丢了, 没有达到对方, 对方自然也没有ack报文了. 这个情况下, 就需要超时重传了.
发送方发了数据之后, 由于数据在网络上传输需要时间, 所以接收方需要等待ack发送过来. 如果等了很久, ack还没有到, 此时发送方就认为数据在传输的过程中出现丢包了.
在上述过程中, 丢包分为两种情况, 可能是数据丢了, 也有可能是ack丢了. 但是从发送方的角度是无法区分这是属于哪种情况.
如果是数据丢了, 这种情况接收方本身就没有收到数据, 此时发送方应该重传数据. 但是如果只是ack丢了, 说明数据已经收到了, 不需要发送方重传.
因此, 为了防止数据被重复接收, TCP socket 在内核中存在接收缓冲区, 当数据到达接收缓冲区的时候, 接收方首先会判定一下看当前缓冲区是否已有这个数据, 判定的依据就是序列号:
-
发送方使用序列号(Sequence Number):发送方在每个数据段中都会设置一个唯一的序列号,用于标识发送的数据。接收方在收到数据段后,会检查序列号,如果收到的数据段的序列号与之前已经接收过的数据段的序列号相同,即表示接收到的是重复的数据段,应该丢弃。
-
接收方使用确认号(Acknowledgment Number):接收方在发送确认应答时,会将确认号设置为下一个期望接收的数据段的序列号。发送方在收到确认应答后,会根据确认号判断接收方已经成功接收到数据。如果发送方收到的确认应答的确认号与之前已经接收到的确认应答的确认号相同,即表示接收方重复发送了确认应答,发送方会忽略重复的确认应答。
3.连接管理
在正常情况下, TCP要经过三次握手建立连接, 四次挥手断开连接.
客户端执行 socket = new Socket(serverIp, serverPort) 这段代码, 这个操作就是在建立连接. 这里只是调用Socket的api, 真正建立连接的过程是在操作系统内核完成的.
三次握手
内核完成建立连接的过程称为"三次握手":
-
客户端向服务器发送一个SYN(同步)包,其中包含自己的初始序列号(ISN)。
-
服务器接收到SYN包后,向客户端发送一个SYN-ACK(同步-确认)包作为响应,其中包含自己的初始序列号(ISN)和确认号(ACK),确认号是客户端的初始序列号加1。
-
客户端接收到服务器的SYN-ACK包后,向服务器发送一个ACK(确认)包,其中的确认号是服务器的初始序列号加1。
上述流程上讲, 是有四次交互, 但是实际过程中, 其中有两次交互能够合二为一, 最终形成了"三次握手".
在TCP三次握手建立连接的过程中,每次握手的作用如下:
-
第一次握手(SYN):
- 客户端向服务器发送一个SYN包,请求建立连接。
- 客户端选择一个初始序列号(ISN)并发送给服务器。
- 这次握手的作用是客户端向服务器发出连接请求,告知服务器自己的存在和欲建立连接的意图。
-
第二次握手(SYN-ACK):
- 服务器接收到客户端的SYN包后,对其进行确认。
- 服务器向客户端发送一个SYN-ACK包,表示接收到了请求,并准备好建立连接。
- 服务器也选择一个初始序列号(ISN)并发送给客户端。
- 这次握手的作用是服务器确认客户端的连接请求,并告知客户端自己已接收到请求,并同意建立连接。
-
第三次握手(ACK):
- 客户端接收到服务器的SYN-ACK包后,对其进行确认。
- 客户端向服务器发送一个ACK包,表示已收到服务器的确认,并准备好传输数据。
- 这次握手的作用是客户端告知服务器已收到确认,并可以开始实际的数据传输。
三次握手, 可以先针对通信路径, 进行投石问路, 初步确认一下通信链路是否畅通
三次握手, 也是在验证通信双方发送能力和接收能力是否正常
三次握手的过程中也会协商一些必要的参数.
TCP三次握手如果是两次或者四次可以吗?
如果只有两次握手的过程, 是肯定不行的, 比如说少了最后一次握手的过程, 服务器这边就无法确认自己的发送能力和对端的接收能力是否正常. 服务器不知道这个结论, 此时就缺少了"可靠传输"前提, 如果只有两次握手,可能会导致连接不稳定,数据传输的可靠性降低
如果是四次握手, 就显得多余了.
问题:
我们已经知道, 互联网中广泛存在"后发先至"的情况, 当第一次连接的时候, 传输的有一个数据包在路上堵车了, 迟迟没有到达对端, 等终于到了对端的时候, 可能之前建立的连接已经不存在了, 而是重新建立了新的连接. 此时, 这份数据应该丢弃掉, 那么如何识别出这份数据是之前连接过程中的呢?
在TCP协议中,每个数据包都会有一个序列号(Sequence Number)字段,用于标识数据包的顺序。当一个新的连接建立时,客户端和服务器会交换初始序列号(ISN),并在数据包中携带序列号信息。
当数据包在路上堵车导致连接丢失后,新的连接建立时,序列号会发生变化。因此,服务器可以通过比较接收到的数据包的序列号与当前连接的初始序列号来判断是否为之前的连接遗留的数据包。
如果接收到的数据包的序列号比当前连接的初始序列号小,或者比当前连接的初始序列号大但已经被使用过,则可以判定为之前连接遗留的数据包,应当丢弃。
了解更多TCP的相关知识可以查看RFC标准文档 RFC 9293: Transmission Control Protocol (TCP)
四次挥手
TCP断开连接的过程是一个四次挥手的过程,具体步骤如下:
-
主动关闭方发送FIN报文:主动关闭方(一般是客户端)发送一个FIN(Finish)报文给被动关闭方(一般是服务器),表示主动关闭方没有数据要发送给被动关闭方了,希望关闭连接。
-
被动关闭方确认收到FIN报文:被动关闭方收到FIN报文后,发送一个ACK(Acknowledgment)报文给主动关闭方,表示已经确认收到了关闭请求。
-
被动关闭方发送FIN报文:被动关闭方(一般是服务器)也没有数据要发送给主动关闭方了,它发送一个FIN报文给主动关闭方,表示也希望关闭连接。
-
主动关闭方确认收到FIN报文:主动关闭方收到FIN报文后,发送一个ACK报文给被动关闭方,表示已经确认收到了关闭请求。
在这个过程中,每个方向都需要发送一个FIN报文和收到一个ACK报文,所以总共需要四次挥手。这样做的目的是确保双方都能安全地关闭连接,避免数据丢失或者连接处于半开状态。
状态转换
连接管理的过程涉及TCP状态转换:
正常情况下, CLOSE_WAIT 不太容易观察到. 代码中会比较快速的关闭 socket 这个时候状态就从CLOSE_WAIT进入到LAST_ACK. 但是如果发现服务器/客户端上出现大量的CLOSE_WAIT, 意味着代码很可能是出现bug, 比如忘记关闭 socket.
4.滑动窗口
之前我们讨论了确认应答策略, 对每⼀个发送的数据段, 都要给⼀个ACK确认应答. 收到ACK后再发送下⼀个数据段. 这样做有⼀个比较大的缺点, 就是性能较差. 尤其是数据往返的时间较长的时候.
既然这样⼀发⼀收的⽅式性能较低, 那么我们⼀次发送多条数据, 就可以⼤⼤的提高性能(其实是将多个段的等待时间重叠在⼀起了)
- 窗口大小指的是无需等待确认应答而可以继续发送数据的最大值. 上图的窗口大小就是4000个字节(四个段).
- 发送前四个段的时候, 不需要等待任何ACK, 直接发送;
- 收到第一个ACK后, 滑动窗口向后移动, 继续发送第五个段的数据; 依次类推;
- 操作系统内核为了维护这个滑动窗口, 需要开辟发送缓冲区来记录当前还有哪些数据没有应答; 只有确认应答过的数据, 才能从缓冲区删掉;
- 窗口越大, 则网络的吞吐率就越高;
那么如果出现了丢包, 如何进行重传? 这里分两种情况讨论
情况一: 数据包已经抵达, ACK被丢了.
这种情况下, 部分ACK丢了并不要紧, 因为可以通过后续的ACK进行确认;
情况二: 数据包就直接丢了.
- 当某一段报文段丢失之后,发送端会一直收到 1001 这样的 ACK, 就像是在提醒发送端 "我想要的是1001" 一样;
- 如果发送端主机连续三次收到了同样一个 "1001" 这样的应答,就会将对应的数据 1001 - 2000重新发送;
- 这个时候接收端收到了1001 之后,再次返回的ACK就是7001了 (因为2001 - 7000) 接收端其实之前就已经收到了, 被放到了接收端操作系统内核的接收缓冲区中;
5.流量控制
接收端处理数据的速度是有限的. 如果发送端发的太快, 导致接收端的缓冲区被打满, 这个时候如果发送端继续发送,就会造成丢包,继而引起丢包重传等一系列连锁反应.
因此TCP支持根据接收端的处理能力, 来决定发送端的发送速度. 这个机制就叫做流量控制(Flow Control);
- 接收端将自己可以接收的缓冲区大小放入TCP首部中的"窗口大小"字段,通过ACK端通知发送端;
- 窗口大小字段越大, 说明网络的吞吐量越高;
- 接收端一旦发现自己的缓冲区快满了, 就会将窗口大小设置成一一个更小的值通知给发送端; 发送端接受到这个窗口之后,就会减慢自己的发送速度;
- 如果接收端缓冲区满了, 就会将窗口置为0; 这时发送方不再发送数据,但是需要定期发送一一个窗口探测数据段, 使接收端把窗口大小告诉发送端.
接收端如何把窗口大小告诉发送端呢? 回忆我们的TCP首部中, 有⼀个16位窗口字段, 就是存放了窗口大小信息;
那么问题来了, 16位数字最大表示65535, 那么TCP窗口最大就是65535字节么?
实际上, TCP首部40字节选项中还包含了⼀个窗口扩大因⼦M, 实际窗口大小是窗口字段的值左移 M 位;
6.拥塞控制
虽然TCP有了滑动窗口这个大杀器, 能够高效可靠的发送大量的数据. 但是如果在刚开始阶段就发送大量的数据, 仍然可能引发问题.
因为网络上有很多的计算机,可能当前的网络状态就已经比较拥堵. 在不清楚当前网络状态下,贸然发送大量的数据,是很有可能引起雪上加霜的.
TCP引入慢启动机制,先发少量的数据, 探探路, 摸清当前的网络拥堵状态, 再决定按照多大的速度传输数据;
当TCP通信开始后,网络吞吐量会逐渐上升; 随着网络发生拥堵,吞吐量会立刻下降;
拥塞控制,归根结底是TCP协议想尽可能快的把数据传输给对方,但是又要避免给网络造成太大压力的折中方案.
7.延迟应答
具体的数量和超时时间, 依操作系统不同也有差异; ⼀般N取2, 超时时间取200ms;
8.捎带应答
捎带应答是指在发送确认应答的同时,也可以将之前接收到的多个数据包的确认应答一起发送。这样可以减少网络传输的开销,提高数据传输的效率。
正常情况下, 服务器收到请求后发送ack和发送响应之间有一定的时间间隔, 此时就得分两个包发送了, 由于有了延时应答机制, 此时就可以把ack和应答的响应数据合并成一个tcp数据报.
本身ack也不携带载荷, 只是把报头中的ack标志位设为1, 并且设置确认序号和窗口大小等, 这几个属性本身一个正常的response报文也用不到, 因此也不会有冲突.
9.面向字节流
创建一个TCP的socket, 同时在内核中创建一个 发送缓冲区 和一个 接收缓冲区;
- 调用write时, 数据会先写入发送缓冲区中;
- 如果发送的字节数太长, 会被拆分成多个TCP的数据包发出;
- 如果发送的字节数太短,就会先在缓冲区里等待,等到缓冲区长度差不多了, 或者其他合适的时机发送出去;
- 接收数据的时候, 数据也是从网卡驱动程序到达内核的接收缓冲区;
- 然后应用程序可以调用read从接收缓冲区拿数据;
- 另一方面, TCP的一个连接, 既有发送缓冲区, 也有接收缓冲区, 那么对于这一个连接, 既可以读数据,
- 也可以写数据. 这个概念叫做全双工
由于缓冲区的存在, TCP程序的读和写不需要一一匹配, 例如:
- 写100个字节数据时, 可以调用一次write写100个字节,也可以调用100次write, 每次写一个字节;
- 读100个字节数据时,也完全不需要考虑写的时候是怎么写的, 既可以一次read 100个字节,也可以一次read一个字节,重复100次;
粘包问题
那么如何避免粘包问题呢? 归根结底就是⼀句话, 明确两个包之间的边界.
- 对于定长的包,保证每次都按固定大小读取即可; 例如上面的Request结构, 是固定大小的,那么就从缓冲区从头开始按sizeof(Request)依次读取即可;
- 对于变长的包,可以在包头的位置,约定一个包总长度的字段,从而就知道了包的结束位置;
- 对于变长的包,还可以在包和包之间使用明确的分隔符(应用层协议,是程序猿自己来定的,只要保证分隔符不和正文冲突即可);
思考: 对于UDP协议来说, 是否也存在 "粘包问题" 呢?
- 对于UDP, 如果还没有上层交付数据, UDP的报文长度仍然在. 同时,UDP是一一个一个把数据交付给应用层. 就有很明确的数据边界.
- 站在应用层的站在应用层的角度,使用UDP的时候, 要么收到完整的UDP报文, 要么不收不会出现"半个"的情况.
10.异常情况
1.其中有一方出现了进程崩溃
进程无论是正常结束, 还是异常崩溃, 都会触发文件资源回收, 起到关闭文件这样的效果(系统自动完成的), 就会触发四次挥手.
TCP 连接的生命周期, 可以比进程更长一些, 虽然进程已经退出了, 但是 TCP 连接还在, 仍然可以继续进行四次挥手.
2.其中一方出现了关机(按照正常流程关机)
点了关机之后, 此时四次挥手的过程不一定能够完成, 系统马上就关闭了.
如果能够顺利完成四次挥手的过程, 此时本端和对端都能正确的删掉保存的连接信息.
如果不能够完成四次挥手的过程, 至少也能把第一个fin发给对端(至少告诉对方, 我这边要结束了), 对端收到fin之后, 对端也要进入释放连接的流程了, 返回ack, 并且也发fin(这里发的fin就不会收到ack了), 没有收到ack之后, 就会进行超时重传, 当重传几次之后, 这个时候就会单方面的释放连接信息.
3.其中一方面出现突然断电
如果是直接断电, 机器瞬间关机, 此时肯定来不及发送fin
(a) 断电的是接收方, 发送发就会发现没有收到ack了, 就会进行重传, 重传几次后, 还是不行, TCP就会尝试"复位连接" (相当于清除原来TCP中的各种临时数据, 重新开始), 需要用到TCP 中的一个 "复位报文段"
(b) 断电的是发送方, 此时接收方本来就是在阻塞等待发送发的消息, 结果迟迟没来消息, 该怎么办呢?
这个情况下, 接收方需要区分出, 发送方是挂了, 还是好着只是暂时没发.
接收方一段时间之后没有收到对方的消息, 就会触发"心跳包" 来询问对方的情况. 如果对端没心跳了, 此时本端也就会尝试复位且单方面释放连接了.
4.网线断开
这种情况就是上面3中(a),(b)两种情况同时发生了.
用UDP实现可靠传输(经典面试题)
参考TCP的可靠性机制, 在应用层实现类似的逻辑;