3、传输层
1、UDP 和 TCP
2、TCP 三次握手
socket 到底是什么
TCP 半连接队列和全连接队列
第三次握手是可以携带数据的,前两次握手是不可以携带数据的
半连接队列:也称 SYN 队列,服务端收到客户端发起的 SYN 请求后,内核会把该连接存储到半连接队列,并向客户端发 SYN + ACK
全连接队列:也称 accept 队列,服务端收到第三次握手的 ACK 后,内核会把连接从半连接队列移除,然后创建新的完全的连接,并将其添加到全连接队列,等待进程调用 accept 函数时把连接取出来
3、TCP 四次挥手
当被动关闭方在 TCP 挥手过程中,「没有数据要发送」并且「开启了 TCP 延迟确认机制」,那么第二和第三次挥手就会合并传输,这样就出现了三次挥手
处于时间等待(TIME-WAIT)状态后要经过 2MSL 时长
1、可以确保 TCP 服务器进程能够收到最后一个 TCP 确认报文段而进入关闭(CLOSED)状态
2、可以使本次连接持续时间内所产生的所有报文段都从网络中消失,这样就可以使下一个新的 TCP 连接中不会出现旧连接中的报文段
TCP 保活计时器
4、TCP 可靠传输的实现
5、TCP 流量控制
6、TCP 拥塞控制
6.1、慢开始和拥塞避免
慢开始:一开始向网络注入的报文段少,而并不是指拥塞窗口 cwnd 的值增长速度慢
拥塞避免:并非完全能够避免拥塞,而是在拥塞避免阶段,将 cwnd 值控制为按线性规律增长,使网络比较不容易出现拥塞
6.2、快重传
快重传:使发送方尽快(尽早)进行重传,而不是等重传计时器超时再重传
这就要求接收方不要等待自己发送数据时才进行捎带确认,而是要立即发送确认,即使收到了失序的报文段也要立即发出对已收到的报文段的重复确认
发送方一旦收到 3 个连续的重复确认,就将相应的报文段立即重传,而不是等该报文段的重传计时器超时再重传
SACK 方法
快重传机制只解决了一个问题,就是超时时间的问题,但是它依然面临着另外一个问题:重传的时候,是重传一个,还是重传所有的问题
举个例子,假设发送方发了 6 个数据,编号的顺序是 Seq1 ~ Seq6 ,但是 Seq2、Seq3 都丢失了
那么接收方在收到 Seq4、Seq5、Seq6 时,都是回复 ACK2 给发送方,但是发送方并不清楚这连续的 ACK2 是接收方收到哪个报文而回复的
那是选择重传 Seq2 一个报文,还是重传 Seq2 之后已发送的所有报文呢(Seq2、Seq3、 Seq4、Seq5、 Seq6)呢
- 如果只选择重传 Seq2 一个报文,那么重传的效率很低,因为对于丢失的 Seq3 报文,还得在后续收到三个重复的 ACK3 才能触发重传
- 如果选择重传 Seq2 之后已发送的所有报文,虽然能同时重传已丢失的 Seq2 和 Seq3 报文
但是 Seq4、Seq5、Seq6 的报文是已经被接收过了,对于重传 Seq4 ~ Seq6 折部分数据相当于做了一次无用功,浪费资源
可以看到,不管是重传一个报文,还是重传已发送的报文,都存在问题,为了解决不知道该重传哪些 TCP 报文,于是就有 SACK 方法
这种方式需要在 TCP 头部「选项」字段里加一个 SACK 的东西
它可以将已收到的数据的信息发送给「发送方」,这样发送方就可以知道哪些数据收到了,哪些数据没收到,知道了这些信息,就可以只重传丢失的数据
如下图,发送方收到了三次同样的 ACK 确认报文,于是就会触发快速重发机制,通过 SACK 信息发现只有 200 ~ 299 这段数据丢失,则重发时,就只选择了这个 TCP 段进行重复
如果要支持 SACK,必须双方都要支持,在 Linux 下,可以通过 net.ipv4.tcp_sack 参数打开这个功能(Linux 2.4 后默认打开)
6.3、快恢复
快恢复:发送方一旦收到 3 个重复确认,就知道现在只是丢失了个别的报文段,于是不启动慢开始算法,而是执行快恢复算法
发送方将慢开始门限 ssthresh 的值和拥塞窗口 cwnd 的值都调整为当前 cwnd 值的一半,并开始执行拥塞避免算法(也有 cwnd = 新 ssthresh + 3)
7、TCP 面试题
7.1、三次握手
为什么每次建立 TCP 连接时,初始化的序列号都要求不一样呢
如果每次建立连接,客户端和服务端的初始化序列号都是一样的话,很容易出现历史报文被下一个相同四元组的连接接收的问题
在新连接建立完成后,上一个连接中被网络阻塞的数据包正好抵达了服务端,刚好该数据包的序列号是在服务端的接收窗口内,所以该数据包会被服务端正常接收,造成数据错乱
如果每次建立连接客户端和服务端的初始化序列号都「不一样」,就有大概率因为历史报文的序列号「不在」对方接收窗口,从而很大程度上避免了历史报文
既然 IP 层会分片,为什么 TCP 层还需要 MSS 呢
- MTU:位于网络层,一个网络包的最大长度,以太网中一般为 1500 字节(Maximum Transmission Unit)
- MSS:位于传输层,除去 IP 和 TCP 头部之后,一个网络包所能容纳的 TCP 数据的最大长度(Maximum Segment Size)
如果在 TCP 的整个报文(头部 + 数据)交给 IP 层进行分片,会有什么异常呢
- 当 IP 层有一个超过 MTU 大小的数据(TCP 头部 + TCP 数据)要发送,那么 IP 层就要进行分片,把数据分片成若干片,保证每一个分片都小于 MTU
- 把一份 IP 数据报进行分片以后,由目标主机的 IP 层来进行重新组装后,再交给上一层 TCP 传输层
- 这看起来井然有序,但这存在隐患的,如果一个 IP 分片丢失,整个 IP 报文的所有分片都得重传
因为 IP 层本身没有超时重传机制,它由传输层的 TCP 来负责超时和重传
当某一个 IP 分片丢失后,接收方的 IP 层就无法组装成一个完整的 TCP 报文(头部 + 数据),也就无法将数据报文送到 TCP 层
所以接收方不会响应 ACK 给发送方,导致发送方迟迟收不到 ACK 确认报文而触发超时重传,就会重发「整个 TCP 报文(头部 + 数据)」
因此可以得知由 IP 层进行分片传输,是非常没有效率的
为了达到最佳的传输效能,TCP 协议在建立连接的时候通常要协商双方的 MSS 值
当 TCP 层发现数据超过 MSS 时,则就先会进行分片,当然由它形成的 IP 包的长度也就不会大于 MTU ,自然也就不用 IP 分片了
经过 TCP 层分片后,如果一个 TCP 分片丢失后,进行重发时也是以 MSS 为单位,而不用重传所有的分片,大大增加了重传的效率
第一次握手丢失了,会发生什么
当客户端想和服务端建立 TCP 连接的时候,首先第一个发的就是 SYN 报文,然后进入到 SYN_SENT 状态
在这之后,如果客户端迟迟收不到服务端的 SYN-ACK 报文(第二次握手),就会触发「超时重传」机制,重传 SYN 报文,而且重传的 SYN 报文的序列号都是一样的
那到底重发几次呢,在 Linux 里,客户端的 SYN 报文最大重传次数由 tcp_syn_retries 内核参数控制,这个参数是可以自定义的,默认值一般是 5
# cat /proc/sys/net/ipv4/tcp_syn_retries
通常第一次超时重传是在 1 秒后,第二次超时重传是在 2 秒,第三次超时重传是在 4 秒后,第四次超时重传是在 8 秒后,第五次是在超时重传 16 秒后
每次超时的时间是上一次的 2 倍,当第五次超时重传后,会继续等待 32 秒,如果服务端仍然没有回应 ACK,客户端就不再发送 SYN 包,然后断开 TCP 连接
所以总耗时是 1 + 2 + 4 + 8 + 16 + 32 = 63 秒,大约 1 分钟左右
第二次握手丢失了,会发生什么
- 因为第二次握手报文里是包含对客户端的第一次握手的 ACK 确认报文
所以如果客户端迟迟没有收到第二次握手,那么客户端就觉得可能自己的 SYN 报文(第一次握手)丢失了,于是客户端就会触发超时重传机制,重传 SYN 报文 - 因为第二次握手中包含服务端的 SYN 报文,所以当客户端收到后,需要给服务端发送 ACK 确认报文(第三次握手),服务端才会认为该 SYN 报文被客户端收到了
如果第二次握手丢失了,服务端就收不到第三次握手,于是服务端这边会触发超时重传机制,重传 SYN-ACK 报文
在 Linux 下,SYN-ACK 报文的最大重传次数由 tcp_synack_retries内核参数决定,默认值是 5
# cat /proc/sys/net/ipv4/tcp_synack_retries
因此当第二次握手丢失了,客户端和服务端都会重传
- 客户端会重传 SYN 报文,也就是第一次握手,最大重传次数由 tcp_syn_retries 内核参数决定
- 服务端会重传 SYN-ACK 报文,也就是第二次握手,最大重传次数由 tcp_synack_retries 内核参数决定
第三次握手丢失了,会发生什么
因为这个第三次握手的 ACK 是对第二次握手的 SYN 的确认报文,所以当第三次握手丢失了
那么服务端那一方将迟迟收不到这个确认报文,就会触发超时重传机制,重传 SYN-ACK 报文,直到收到第三次握手,或者达到最大重传次数
7.2、四次挥手
第 N 次挥手丢失了,会发生什么
第一次挥手和第二次挥手丢失:导致客户端迟迟收不到 FIN 的确认报文,客户端就会触发超时重传机制,重传 FIN 报文,重发次数由 tcp_orphan_retries 参数控制
第三次挥手和第四次挥手丢失:导致服务端迟迟收不到 FIN 的确认报文,服务端就会触发超时重传机制,重传 FIN 报文,重发次数由 tcp_orphan_retries 参数控制
为什么 TIME_WAIT 等待的时间是 2MSL
MSL 是 Maximum Segment Lifetime,报文最大生存时间,它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃
因为 TCP 报文基于是 IP 协议的,而 IP 头中有一个 TTL 字段,是 IP 数据报可以经过的最大路由数
每经过一个处理它的路由器,TTL 就减 1,当 TTL 值为 0 则数据报将被丢弃,同时发送 ICMP 报文通知源主机
MSL 与 TTL 的区别: MSL 的单位是时间,而 TTL 是经过路由跳数,所以 MSL 应该要大于等于 TTL 消耗为 0 的时间,以确保报文已被自然消亡
TTL 的值一般是 64,Linux 将 MSL 设置为 30 秒,意味着 Linux 认为数据报文经过 64 个路由器的时间不会超过 30 秒,如果超过了,就认为报文已经消失在网络中了
TIME_WAIT 等待 2 倍的 MSL,比较合理的解释是
网络中可能存在来自发送方的数据包,当这些发送方的数据包被接收方处理后又会向对方发送响应,所以一来一回需要等待 2 倍的时间
- 如果 "被动关闭方" 没有收到断开连接的最后的 ACK 报文,就会触发超时重发 FIN 报文,另一方接收到 FIN 后,会重发 ACK 给被动关闭方,一来一去正好 2 个 MSL
2MSL 时长相当于至少允许报文丢失一次,比如若 ACK 在一个 MSL 内丢失,"被动方" 重发的 FIN 会在第 2 个 MSL 内到达,TIME_WAIT 状态的连接可以应对 - 为什么不是 4 或者 8 MSL 的时长呢,一个丢包率达到百分之一的糟糕网络,连续两次丢包的概率只有万分之一,这个概率实在是太小了,忽略它比解决它更具性价比
2MSL 的时间是从客户端接收到 FIN 后发送 ACK 开始计时的(收到第三次挥手 / 发送第四次挥手后)
如果在 TIME-WAIT 时间内,因为客户端的 ACK 没有传输到服务端(第四次挥手丢失),客户端又接收到了服务端重发的 FIN 报文,那么 2MSL 时间将重新计时
在 Linux 系统里 2MSL 默认是 60 秒,那么一个 MSL 也就是 30 秒,Linux 系统停留在 TIME_WAIT 的时间为固定的 60 秒
服务器出现大量 TIME_WAIT 状态的原因有哪些
首先要知道 TIME_WAIT 状态是「主动关闭连接方」才会出现的状态,所以如果服务器出现大量的 TIME_WAIT 状态的 TCP 连接,就是说明服务器主动断开了很多 TCP 连接
什么场景下服务端会主动断开连接呢
- 第一个场景:HTTP 没有使用长连接
- 第二个场景:HTTP 长连接超时
- 第三个场景:HTTP 长连接的请求数量达到上限
1、HTTP 没有使用长连接
从 HTTP/1.1 开始默认开启了 Keep-Alive
如果要关闭 HTTP Keep-Alive,需要在 HTTP 请求或者响应的 header 里添加 Connection:close 信息
只要客户端和服务端任意一方的 HTTP header 中有 Connection:close 信息,那么就无法使用 HTTP 长连接的机制
不管哪一方禁用了 HTTP Keep-Alive,都是由「服务端」主动关闭连接,那么此时服务端上就会出现 TIME_WAIT 状态的连接
当服务端出现大量的 TIME_WAIT 状态连接的时候,可以排查下是否客户端和服务端都开启了 HTTP Keep-Alive
因为任意一方没有开启 HTTP Keep-Alive,都会导致服务端在处理完一个 HTTP 请求后主动关闭连接,此时服务端上就会出现大量的 TIME_WAIT 状态的连接
解决的方式也很简单,让客户端和服务端都开启 HTTP Keep-Alive 机制
2、HTTP 长连接超时
HTTP 长连接的特点是,只要任意一端没有明确提出断开连接,则保持 TCP 连接状态
如果客户端完成一个 HTTP 请求后,就不再发起新的请求,此时这个 TCP 连接就会一直占用着
为了避免资源浪费的情况,web 服务软件一般都会提供一个参数,用来指定 HTTP 长连接的超时时间,比如 nginx 提供的 keepalive_timeout 参数
如果设置了 HTTP 长连接的超时时间是 60 秒,nginx 就会启动一个「定时器」,如果客户端在完后一个 HTTP 请求后
在 60 秒内都没有再发起新的请求,定时器的时间一到,nginx 就会触发回调函数来关闭该连接,那么此时服务端上就会出现 TIME_WAIT 状态的连接
当服务端出现大量 TIME_WAIT 状态的连接时,如果有大量的客户端建立完 TCP 连接后,很长一段时间没有发送数据
那么大概率就是因为 HTTP 长连接超时,导致服务端主动关闭连接,产生大量处于 TIME_WAIT 状态的连接
可以往网络问题的方向排查,比如是否是因为网络问题,导致客户端发送的数据一直没有被服务端接收到,以至于 HTTP 长连接超时
3、HTTP 长连接的请求数量达到上限
Web 服务端通常会有个参数,来定义一条 HTTP 长连接上最大能处理的请求数量,当超过最大限制时,就会主动关闭连接
比如 nginx 的 keepalive_requests 这个参数
这个参数是指一个 HTTP 长连接建立之后,nginx 就会为这个连接设置一个计数器,记录这个 HTTP 长连接上已经接收并处理的客户端请求的数量
如果达到这个参数设置的最大值时,则 nginx 会主动关闭这个长连接,那么此时服务端上就会出现 TIME_WAIT 状态的连接
keepalive_requests 参数的默认值是 100,意味着每个 HTTP 长连接最多只能跑 100 次请求
这个参数往往被大多数人忽略,因为当 QPS (每秒请求数) 不是很高时,默认值 100 凑合够用
但是对于一些 QPS 比较高的场景,比如超过 10000 QPS,甚至达到 30000、50000 甚至更高
如果 keepalive_requests 参数值是 100,这时候就 nginx 就会很频繁地关闭连接,那么此时服务端上就会出大量的 TIME_WAIT 状态
解决的方式也很简单,调大 nginx 的 keepalive_requests 参数就行
服务器出现大量 CLOSE_WAIT 状态的原因有哪些
CLOSE_WAIT 状态是「被动关闭方」才会有的状态
而且如果「被动关闭方」没有调用 close 函数关闭连接,那么就无法发出 FIN 报文,从而无法使得 CLOSE_WAIT 状态的连接转变为 LAST_ACK 状态
所以当服务端出现大量 CLOSE_WAIT 状态的连接的时候,说明「服务端的程序没有调用 close 函数关闭连接」
那什么情况会导致服务端的程序没有调用 close 函数关闭连接,这时候通常需要排查代码,我们先来分析一个普通的 TCP 服务端的流程
- 创建服务端 socket、bind 绑定端口、listen 监听端口
- 将服务端 socket 注册到 epoll
- epoll_wait 等待连接到来,连接到来时,调用 accpet 获取已连接的 socket
- 将已连接的 socket 注册到 epoll
- epoll_wait 等待事件发生
- 对方连接关闭时,我方调用 close
可能导致服务端没有调用 close 函数的原因如下
- 第 2 步没有做,没有将服务端 socket 注册到 epoll
这样有新连接到来时,服务端没办法感知这个事件,也就无法获取到已连接的 socket,那服务端自然就没机会对 socket 调用 close 函数了
不过这种原因发生的概率比较小,这种属于明显的代码逻辑 bug,在前期 read view 阶段就能发现的了 - 第 3 步没有做,有新连接到来时没有调用 accpet 获取该连接的 socket
导致当有大量的客户端主动断开了连接,而服务端没机会对这些 socket 调用 close 函数,从而导致服务端出现大量 CLOSE_WAIT 状态的连接
发生这种情况可能是因为:服务端在执行 accpet 函数之前,代码卡在某一个逻辑或者提前抛出了异常 - 第 4 步没有做,通过 accpet 获取已连接的 socket 后,没有将其注册到 epoll
导致后续收到 FIN 报文的时候,服务端没办法感知这个事件,那服务端就没机会调用 close 函数了
发生这种情况可能是因为:服务端在将已连接的 socket 注册到 epoll 之前,代码卡在某一个逻辑或者提前抛出了异常 - 第 6 步没有做,当发现客户端关闭连接后,服务端没有执行 close 函数
可能是因为代码漏处理,或者是在执行 close 函数之前,代码卡在某一个逻辑,比如发生死锁等等
7.3、三次挥手
服务器收到客户端的 FIN 报文时,内核会马上回一个 ACK 应答报文
但是服务端应用程序可能还有数据要发送,所以并不能马上发送 FIN 报文,而是将发送 FIN 报文的控制权交给服务端应用程序
- 如果服务端应用程序有数据要发送的话,发完数据后,才调用关闭连接的函数
- 如果服务端应用程序没有数据要发送的话,可以直接调用关闭连接的函数
是否要发送第三次挥手的控制权不在内核,而是在被动关闭方(服务端)的应用程序,因为应用程序可能还有数据要发送
由应用程序决定什么时候调用关闭连接的函数,当调用了关闭连接的函数,内核就会发送 FIN 报文了,所以服务端的 ACK 和 FIN 一般都会分开发送
FIN 报文一定得调用关闭连接的函数,才会发送吗
不一定,如果进程退出了,不管是正常退出,还是异常退出(如进程崩溃),内核都会发送 FIN 报文,与对方完成四次挥手
当被动关闭方(服务端)在 TCP 挥手过程中,「没有数据要发送」并且「开启了 TCP 延迟确认机制(默认会开启)」,那么第二和第三次挥手就会合并传输,这样就出现了三次挥手
7.4、更多
TCP 快速建立连接
在第一次建立连接的时候,服务端在第二次握手产生一个 Cookie(已加密)并通过 SYN、ACK 包一起发给客户端
于是客户端就会缓存这个 Cookie,所以第一次发起 HTTP Get 请求的时候,还是需要 2 个 RTT 的时延
在下次请求的时候,客户端在 SYN 包带上 Cookie 发给服务端,就提前可以跳过三次握手的过程
因为 Cookie 中维护了一些信息,服务端可以从 Cookie 获取 TCP 相关的信息,这时发起的 HTTP GET 请求就只需要 1 个 RTT 的时延
注:客户端在请求并存储了 Fast Open Cookie 之后,可以不断重复 TCP Fast Open 直至服务器认为 Cookie 无效(通常为过期)
TCP Keep-Alive
TCP Keep-Alive 是 TCP 保活计时器,通常为 2 小时,在每次收到客户端发来的报文都会重置计时器
超时之后服务端就会发送探测报文,每隔 75S 发送一次,如果连续 10 个探测报文都没有收到回复,服务器会认为客户端发生故障,断开此次连接
Nagle 算法
当我们 TCP 报文的承载的数据非常小的时候,例如几个字节,那么整个网络的效率是很低的,这就好像快递员开着大货车送一个小包裹一样浪费
因为每个 TCP 报文中都会有 20 个字节的 TCP 头部,也会有 20 个字节的 IP 头部,而数据只有几个字节,所以在整个报文中有效数据占有的比重就会非常低
使用 Nagle 算法,该算法的思路是延时处理,只有满足下面两个条件中的任意一个条件,才能可以发送数据
- 条件一:要等到窗口大小 >= MSS 并且 数据大小 >= MSS
- 条件二:收到之前发送数据的 ack 回包
- 只要上面两个条件都不满足,发送方一直在囤积数据,直到满足上面的发送条件
Nagle 算法一定会有一个小报文,也就是在最开始的时候
- 一开始由于没有已发送未确认的报文,所以就立刻发了 H 字符
- 在还没收到对 H 字符的确认报文时,发送方就一直在囤积数据
直到收到了确认报文后,此时没有已发送未确认的报文,于是就把囤积后的 ELL 字符一起发给了接收方 - 待收到对 ELL 字符的确认报文后,于是把最后一个 O 字符发送了出去
延迟确认
事实上当没有携带数据的 ACK,它的网络效率也是很低的,因为它也有 40 个字节的 IP 头 和 TCP 头,但却没有携带数据报文
为了解决 ACK 传输效率低问题,所以就衍生出了 TCP 延迟确认
- 当有响应数据要发送时,ACK 会随着响应数据一起立刻发送给对方
- 当没有响应数据要发送时,ACK 将会延迟一段时间,以等待是否有响应数据可以一起发送
- 如果在延迟等待发送 ACK 期间,对方的第二个数据报文又到达了,这时就会立刻发送 ACK
延迟确认 和 Nagle 算法混合使用时,会产生新的问题
发送方使用了 Nagle 算法,接收方使用了 TCP 延迟确认会发生如下的过程
- 发送方先发出一个小报文,接收方收到后,由于延迟确认机制,自己又没有要发送的数据,只能干等着发送方的下一个报文到达
- 发送方由于 Nagle 算法机制,在未收到第一个报文的确认前,是不会发送后续的数据
- 所以接收方只能等待最大时间 200 ms 后,才回 ACK 报文,发送方收到第一个报文的确认报文后,也才可以发送后续的数据
很明显,这两个同时使用会造成额外的时延,这就会使得网络 "很慢" 的感觉
要解决这个问题,只有两个办法
- 要么发送方关闭 Nagle 算法
- 要么接收方关闭 TCP 延迟确认
7.5、粘包拆包
TCP 是字节流协议,没有包的概念,自然就没有粘包和拆包,其实粘包和拆包主要是应用层需要去解决的,不是 TCP 的问题
如果 MSS + TCP 首部 + IP 首部 > MTU,那么数据包将会被拆分为多个发送,这就是拆包现象
由于拆包 / 粘包问题的存在,数据接收方很难界定数据包的边界在哪里,很难识别出一个完整的数据包
所以需要提供一种机制来识别数据包的界限,这也是解决拆包 / 粘包的唯一方法:定义应用层的通信协议,下面我们一起看下主流协议的解决方案
消息长度固定
每个数据报文都需要一个固定的长度
当发送方的数据小于固定长度时,则需要空位补齐
当接收方累计读取到固定长度的报文后,就认为已经获得一个完整的消息
+----+------+------+---+----+
| AB | CDEF | GHIJ | K | LM |
+----+------+------+---+----+
假设我们的固定长度为 4 字节,那么如上所示的 5 条数据一共需要发送 4 个报文
+------+------+------+------+
| ABCD | EFGH | IJKL | M000 |
+------+------+------+------+
消息定长法使用非常简单,但是缺点也非常明显,无法很好设定固定长度的值
如果长度太大会造成字节浪费,长度太小又会影响消息传输,所以在一般情况下消息定长法不会被采用
特定分隔符
既然接收方无法区分消息的边界,那么我们可以在每次发送报文的尾部加上特定分隔符,接收方就可以根据特殊分隔符进行消息拆分
以下报文根据特定分隔符 \n 按行解析,即可得到 AB、CDEF、GHIJ、K、LM 五条原始报文
+-------------------------+
| AB\nCDEF\nGHIJ\nK\nLM\n |
+-------------------------+
由于在发送报文时尾部需要添加特定分隔符,所以对于分隔符的选择一定要避免和消息体中字符相同,否则可能出现错误的消息拆分
比较推荐的做法是将消息进行编码,例如 base64 编码,然后可以选择 64 个编码字符之外的字符作为特定分隔符
特定分隔符法在消息协议足够简单的场景下比较高效,例如大名鼎鼎的 Redis 在通信过程中采用的就是换行分隔符
消息长度 + 消息内容
消息头 消息体
+--------+----------+
| Length | Content |
+--------+----------+
消息长度 + 消息内容是项目开发中最常用的一种协议,如上展示了该协议的基本格式
消息头中存放消息的总长度,例如使用 4 字节的 int 值记录消息的长度,消息体实际的二进制的字节数据
接收方在解析数据时,首先读取消息头的长度字段 Len,然后紧接着读取长度为 Len 的字节数据,该数据即判定为一个完整的数据报文
依然以上述提到的原始字节数据为例,使用该协议进行编码后的结果如下所示
+-----+-------+-------+----+-----+
| 2AB | 4CDEF | 4GHIJ | 1K | 2LM |
+-----+-------+-------+----+-----+
消息长度 + 消息内容的使用方式非常灵活,且不会存在消息定长法和特定分隔符法的明显缺陷
当然在消息头中不仅只限于存放消息的长度,而且可以自定义其他必要的扩展字段,例如消息版本、算法类型等
例如开源中间件 Dubbo、RocketMQ 等都基于该方法自定义了自己的通信协议
7.6、异常断开连接
RST 报文
RST:Reset the connection 用于复位因某种原因引起出现的错误连接,也用来拒绝非法数据和请求
- 发送 RST 包关闭连接时,不必等缓冲区的包都发出去,直接就丢弃缓冲区中的包,发送 RST
- 接收端收到 RST 包后,也不必发送 ACK 包来确认(如果接收到 RST 位时候,通常发生了某些错误)
主机崩溃
客户端主机崩溃了,服务端是无法感知到的
如果服务端没有开启 TCP keepalive,又没有数据交互的情况下,服务端的 TCP 连接将会一直处于 ESTABLISHED 连接状态,直到服务端重启进程
我们可以得知一个点:在没有使用 TCP 保活机制且双方不传输数据的情况下,一方的 TCP 连接处在 ESTABLISHED 状态,并不代表另一方的连接还一定正常
进程崩溃
TCP 的连接信息是由内核维护的,所以当服务端的进程崩溃后,内核需要回收该进程的所有 TCP 连接资源
于是内核会发送第一次挥手 FIN 报文,后续的挥手过程也都是在内核完成,并不需要进程的参与
所以即使服务端的进程退出了,还是能与客户端完成 TCP 四次挥手的过程
我自己做了实验,使用 kill -9 来模拟进程崩溃的情况,发现在 kill 掉进程后,服务端会发送 FIN 报文,与客户端进行四次挥手
所以即使没有开启 TCP keepalive,且双方也没有数据交互的情况下
如果其中一方的进程发生了崩溃,这个过程操作系统是可以感知的到的,于是就会发送 FIN 报文给对方,然后与对方进行 TCP 四次挥手
有数据传输的场景:客户端主机宕机,又迅速重启
在客户端主机宕机后,服务端向客户端发送的报文会得不到任何的响应,在一定时长后,服务端就会触发超时重传机制,重传未得到响应的报文
服务端重传报文的过程中,客户端主机重启完成后,客户端的内核就会接收重传的报文,然后根据报文的信息传递给对应的进程
- 如果客户端主机上无进程绑定该 TCP 报文的目标端口号,那么客户端内核就会回复 RST 报文,重置该 TCP 连接
- 如果客户端主机上有进程绑定该 TCP 报文的目标端口号,由于客户端主机重启后
之前的 TCP 连接的数据结构已经丢失了,客户端内核里协议栈会发现找不到该 TCP 连接的 socket 结构体,于是就会回复 RST 报文,重置该 TCP 连接
所以只要有一方重启完成后,收到之前 TCP 连接的报文,都会回复 RST 报文,以断开连接
有数据传输的场景:客户端主机宕机,一直没有重启
服务端「超时重传」报文的次数达到一定阈值后,内核就会判定出该 TCP 有问题
然后通过 Socket 接口告诉应用程序该 TCP 连接出问题了,于是服务端的 TCP 连接就会断开
拔掉网线
客户端拔掉网线后,并不会直接影响 TCP 连接状态,所以拔掉网线后,TCP 连接是否还会存在,关键要看拔掉网线之后,有没有进行数据传输
有数据传输的情况
- 在客户端拔掉网线后,如果服务端发送了数据报文
那么在服务端重传次数没有达到最大值之前,客户端就插回了网线,那么双方原本的 TCP 连接还是能正常存在,就好像什么事情都没有发生 - 在客户端拔掉网线后,如果服务端发送了数据报文,在客户端插回网线之前,服务端重传次数达到了最大值时,服务端就会断开 TCP 连接
等到客户端插回网线后,向服务端发送了数据,因为服务端已经断开了与客户端相同四元组的 TCP 连接,所以就会回 RST 报文
客户端收到后就会断开 TCP 连接。至此, 双方的 TCP 连接都断开了。
没有数据传输的情况
- 如果双方都没有开启 TCP keepalive 机制,那么在客户端拔掉网线后,如果客户端一直不插回网线,那么客户端和服务端的 TCP 连接状态将会一直保持存在
- 如果双方都开启了 TCP keepalive 机制,那么在客户端拔掉网线后
如果客户端一直不插回网线,TCP keepalive 机制会探测到对方的 TCP 连接没有存活,于是就会断开 TCP 连接
而如果在 TCP 探测期间,客户端插回了网线,那么双方原本的 TCP 连接还是能正常存在
8、UDP 面试题
TCP 和 UDP 有什么区别
连接、可靠性、传输方式
- TCP 是面向连接的协议,在发送数据的时候,需要先建立 TCP 三次握手,而 UDP 无连接的协议,直接就可以发送数据
- TCP 会通过超时重传、流量控制、拥塞控制保证数据的可靠传输,而 UDP 并没有这些特性,UDP 不考虑数据的可靠性
- TCP 发送的数据是以字节流的形式,没有边界,而 UDP 是一个包一个包的发送,是有边界的
优劣势
- TCP 的优势在于可以保证数据的可靠性,但是缺陷就是实时性没有 UDP 协议好
- UDP 的优势在于足够简单,不用建立连接,数据直接丢过去即可
并且 UDP 包头比 TCP 包头小很多,所以 UDP 实时性和速度方面是比 TCP 好的
什么时候用 TCP、什么时候用 UDP
- 如果主要关注数据接收的可靠性和顺序,可以选使用 TCP,比如 FTP 协议、HTTP 协议都是基于 TCP 协议进行传输数据
- 如果主要关注的是速度和实时性,而且并不在意某些数据包的丢失,可以选使用 UDP 协议,比如直播、视频会议、DNS、Ping
UDP 怎么改造变为可靠传输
按照 TCP 协议怎么实现可靠传输的方式,在应用层实现一遍就好了,最后可以补充说明基于 UDP 协议实现的可靠传输相比于 TCP 有什么优势
- 我会在应用层增加序列号字段,用来确保 UDP 的数据可以按序接收
同时还会增加确认号,用来实现超时重传机制,当超过一定时间内没收到已发送数据的确认号,就重传该数据包 - 我还会在应用层开辟一个缓冲区,用来实现滑动窗口,可以先批量发送数据,不需要等上一个数据的确认了才能发送,提高了发送速率
同时还可以基于滑动窗口实现流量控制,用来保证发送方能按接收方的接收能力发送数据,避免发送的数据对方接收不了而导致数据丢失 - 为了保证整个网络的带宽环境,还需要实现拥塞控制,确保发送方的数据不会占满整个带宽
UDP 实现可靠传输相比 TCP 可靠传输有几点优势
- 拥塞控制算法:可以根据不同的应用,选用不同的拥塞控制算法,而 TCP 选用拥塞控制算法的时候,是所有应用都使用这一套拥塞控制算法
- 升级方便:TCP 是在内核实现的,升级 TCP 需要升级操作系统,而 UDP 可靠传输是在应用层实现的,升级协议就像升级软件一样简单
- 可以实现网络连接迁移:在应用层用连接 id 来唯一标识一个连接,不必像 TCP 那样,是通过四元组才确定连接的,只要四元组的信息发生了变化,就需要重新建立连接
本文来自博客园,作者:lidongdongdong~,转载请注明原文链接:https://www.cnblogs.com/lidong422339/p/17682328.html