TCP 三次握手与四次挥手

转自:
https://xiaolincoding.com/network/3_tcp/tcp_interview.html#_4-1-tcp-%E4%B8%89%E6%AC%A1%E6%8F%A1%E6%89%8B%E4%B8%8E%E5%9B%9B%E6%AC%A1%E6%8C%A5%E6%89%8B%E9%9D%A2%E8%AF%95%E9%A2%98

在这里插入图片描述

TCP 基本认识

先来看看 TCP 头的格式,标注颜色的字段表示与本文关联比较大,其他字段不做详细阐述。

在这里插入图片描述

  • 序列号:在建立连接时由计算机生成的随机数,作为其初始值,通过 SYN 包传给接收端主机,每发送一次数据,就「累加」一次该「数据字节数」的大小。用来解决网络包乱序的问题

  • 确认应答号:指下一次「期望」收到的数据的序列号,发送端收到这个确认应答后,可以认为在这个序号以前的数据都已经被正常接收。用来解决丢包的问题

  • 控制位:

    • ACK:该位为 1 时,「确认应答」的字段变为有效,TCP 规定除了最初建立连接时的 SYN 包外,该位必须设置为 1
    • RST:该位为 1 时,表示 TCP 连接中出现异常,必须强制断开连接
    • SYN:该位为 1 时,表示希望建立连接,并在其「序列号」的字段进行序列号初始值的设定
    • FIN:该位为 1 时,表示今后不会再有数据发送,希望断开连接。当通信结束,希望断开连接时,通信双方的主机之间就可以相互交换 FIN 位为 1 的 TCP 段

为什么需要 TCP 协议? TCP 工作在哪一层?

IP 层是「不可靠」的,它不保证网络包的交付、不保证网络包的按序交付、也不保证网络包中数据的完整性。

在这里插入图片描述

如果需要保障网络数据包的可靠性,就需要由上层(传输层)的 TCP 协议来负责。

因为 TCP 是一个工作在传输层的可靠数据传输的服务,它能确保接收端接收到的网络包是无损坏、无间隔、非冗余和按序的。

什么是 TCP?

TCP 是面向连接的、可靠的、基于字节流的传输层通信协议。

  • 面向连接:一定是「一对一」才能连接,不能像 UDP 协议那样,可以一个主机同时向多个主机发送消息,即一对多是无法做到的
  • 可靠的:无论网络链路中出现了怎样的链路变化,TCP 都可以保证一个报文一定能够到达接收端
  • 字节流:消息是「没有边界」的,所以无论我们的消息有多大,都可以进行传输。并且消息是「有序的」,当「前一个」消息没有收到,即使先收到了后面的字节,那么也不能扔给应用层去处理,同时对「重复」的报文也会自动丢弃

什么是 TCP 连接?

先来看看 RFC 793 是如何定义「连接」的。

Connections: The reliability and flow control mechanisms described above require that TCPs initialize and maintain certain status information for each data stream. The combination of this information, including sockets, sequence numbers, and window sizes, is called a connection.

简单来说,用于保证可靠性和流量控制维护的某些状态信息,这些信息的组合(包括 Socket、序列号和窗口大小)称为连接。

在这里插入图片描述

所以建立一个 TCP 连接是需要客户端与服务器达成上述三个信息的共识。

  • Socket:由 IP 地址和端口号组成
  • 序列号:用来解决乱序问题等
  • 窗口大小:用来做流量控制

如何唯一确定一个 TCP 连接?

TCP 四元组可以唯一确定一个连接,四元组包括:源地址、源端口、目标地址、目标端口。

在这里插入图片描述

源地址和目标地址的字段(32 位)是在 IP 头部中,作用是通过 IP 协议发送报文给对方主机。

源端口和目标端口的字段(16 位)是在 TCP 头部中,作用是告诉 TCP 协议应该把报文发给哪个进程。

有一个 IP 的服务器监听了一个端口,它的 TCP 的最大连接数是多少?

服务器通常固定在某个本地端口上监听,等待客户端的连接请求。

客户端 IP 和端口是可变的,其理论值计算公式如下。

在这里插入图片描述

对 IPv4,客户端的 IP 数最多为 2 的 32 次方,客户端的端口数最多为 2 的 16 次方,即服务器单机最大 TCP 连接数约为 2 的 48 次方。

当然,服务器最大并发 TCP 连接数远不能达到理论上限,它会受以下因素的影响。

  • 文件描述符限制。每个 TCP 连接都是一个文件,如果文件描述符被占满了,会发生 too many open files。Linux 对可打开的文件描述符的数量分别作了三个方面的限制:

    • 系统级:当前系统可打开的最大数量,通过 cat /proc/sys/fs/file-max 查看
    • 用户级:指定用户可打开的最大数量,通过 cat /etc/security/limits.conf 查看
    • 进程级:单个进程可打开的最大数量,通过 cat /proc/sys/fs/nr_open 查看
  • 内存限制。每个 TCP 连接都要占用一定的内存,而操作系统的内存是有限的,如果内存资源被占满,会发生 OOM。

UDP 和 TCP 有什么区别呢?分别有什么应用场景?

UDP 不提供复杂的控制机制,利用 IP 提供面向「无连接」的通信服务。

UDP 协议非常简单,头部只有 8 个字节( 64 位),UDP 的头部格式如下。

在这里插入图片描述

  • 目标端口和源端口:主要是告诉 UDP 协议应该把报文发给哪个进程
  • 包长度:该字段保存了 UDP 首部的长度与数据的长度之和
  • 校验和:校验和是为了提供可靠的 UDP 首部和数据而设计的,防止收到在网络传输中受损的 UDP 包

TCP 和 UDP 的区别

  • 连接。TCP 是面向连接的传输层协议,在传输数据前要先建立连接。UDP 是不需要连接的,即刻传输数据
  • 服务对象。TCP 是一对一的两点服务,即一条连接只有两个端点。UDP 支持一对一、一对多、多对多的交互通信
  • 可靠性。TCP 是可靠交付数据的,数据可以无差错、不丢失、不重复、按需到达。UDP 是尽最大努力交付的,不保证可靠交付数据
  • 拥塞控制、流量控制。TCP 有拥塞控制和流量控制机制,保证数据传输的安全性。UDP 则没有,即使网络非常拥堵,也不会影响 UDP 的发送速率
  • 首部开销。TCP 首部长度较长,会有一定的开销,首部在没有使用「选项」字段时是 20 个字节,如果使用了「选项」字段则会变长。UDP 首部只有 8 个字节,并且是固定不变的,开销较小
  • 传输方式。TCP 是流式传输,没有边界,但保证顺序和可靠。UDP 是一个包一个包地发送,是有边界的,可能会丢包和乱序
  • 分片不同。TCP 的数据大小如果大于 MSS,则会在传输层进行分片,目标主机收到后,也同样在传输层组装 TCP 数据包,如果中途丢失了一个分片,只需要传输丢失的这个分片即可。UDP 的数据大小如果大于 MTU,则会在 IP 层进行分片,目标主机收到后,在 IP 层组装完数据,再传给传输层

TCP 和 UDP 的应用场景

由于 TCP 面向连接,能保证数据的可靠性交付,因此经常用于:FTP 文件传输;HTTP | HTTPS。

由于 UDP 面向无连接,可以随时发送数据,再加上 UDP 本身的处理既简单又高效,因此经常用于:包总量较少的通信,如 DNS 、SNMP 等;音视频等多媒体通信;广播通信。

TCP 和 UDP 可以复用端口吗?

TCP 和 UDP 可以同时绑定相同的端口。

在传输层中,需要通过端口进行寻址,来识别同一计算机中同时通信的不同应用程序。所以,传输层的端口号的作用,是为了区分同一个主机上不同应用程序的数据包。

传输层有两个传输协议分别是 TCP 和 UDP,在内核中是两个完全独立的软件模块。 当主机收到数据包后,可以在 IP 包头的协议号字段知道该数据包是 TCP/UDP,所以可以根据这个信息确定送给哪个模块(TCP/UDP)处理,送给 TCP/UDP 模块的报文根据端口号确定送给哪个应用程序处理 。

TCP 和 UDP 共用端口的好处?

  • 灵活性:通过共用端口,TCP 和 UDP 可以共享同一套接字。这使得应用程序能够根据需要动态选择使用 TCP 还是 UDP,而无需绑定到一个特定的端口
  • 节省资源:在某些情况下,应用程序可能需要同时使用 TCP 和 UDP 进行数据传输。通过共用端口,可以减少维护多个端口所需的系统资源和管理开销
  • 简化配置:对于服务器应用程序,共用端口可以简化配置过程。不同的传输协议可以监听同一个端口,从而减少了配置文件的复杂性
  • 支持多种协议:共用端口使得应用程序能够同时支持 TCP 和 UDP,这对于需要同时处理两种类型的流量的应用程序来说非常有用

TCP 和 UDP 共用端口的劣势?

  • 复杂性:共用端口可能增加了应用程序的复杂性。由于 TCP 和 UDP 在工作方式和特性上存在差异,需要在应用程序中进行适当的处理和区分,以确保正确地使用 TCP 或 UDP 来处理传入的数据
  • 安全性:共用端口可能增加了安全方面的风险。如果在共用端口上同时监听 TCP 和 UDP,可能会使得应用程序更容易受到网络攻击或恶意访问
  • 性能:共用端口可能对性能产生一定的影响。TCP 和 UDP 的传输特性不同,应用程序需要根据实际需求选择使用 TCP 或 UDP,如果在共用端口上同时处理大量的 TCP 和 UDP 流量,可能会影响传输的效率和速度

为什么 UDP 头部没有「首部长度」字段,而 TCP 头部有「首部长度」字段呢?

原因是 TCP 有可变长的「选项」字段,而 UDP 头部长度是不会变化的,无需多一个字段去记录 UDP 的首部长度。

为什么 UDP 头部有「包长度」字段,而 TCP 头部没有「包长度」字段呢?

先说说 TCP 是如何计算负载数据长度的。

在这里插入图片描述

其中 IP 总长度 和 IP 首部长度,在 IP 首部格式中是已知的。TCP 首部长度,则是在 TCP 首部格式中已知的,所以就可以求得 TCP 数据的长度。

UDP 也是基于 IP 层的呀,那 UDP 的数据长度也可以通过这个公式计算呀? 为何还要有「包长度」呢?

确实感觉 UDP 「包长度」是冗余的。但是为了网络设备硬件设计和处理方便,首部长度需要是 4 字节的整数倍。如果去掉了 UDP 「包长度」字段,那 UDP 首部长度就不是 4 字节的整数倍了,所以这可能是为了补全 UDP 首部长度是 4 字节的整数倍,才补充了「包长度」字段。

TCP 连接建立

TCP 是面向连接的协议,所以在使用 TCP 前必须先建立连接,而建立连接是通过三次握手来进行的。

在这里插入图片描述

  1. 一开始,客户端和服务端都处于 CLOSED 状态。首先服务端主动监听某个端口,处于 LISTEN 状态
  2. 客户端随机初始化序号(client_isn),将此序号置于 TCP 首部的「序列号」字段中,同时把 SYN 标志位置为 1 ,表示是 SYN 报文。接着把第一个 SYN 报文发送给服务端,表示向服务端发起连接。该报文不包含应用层数据,之后客户端处于 SYN-SENT 状态

在这里插入图片描述

  1. 服务端收到客户端的 SYN 报文后,首先服务端也随机初始化自己的序号(server_isn),将此序号填入 TCP 首部的「序列号」字段中,其次把 TCP 首部的「确认应答号」字段填入 client_isn + 1,接着再把 SYN 和 ACK 标志位置为 1,最后把该报文发给客户端。该报文也不包含应用层数据,之后服务端处于 SYN-RCVD 状态

在这里插入图片描述

  1. 客户端收到服务端报文后,还要再向服务端回应最后一个应答报文,首先将该应答报文 TCP 首部的 ACK 标志位置为 1 ,其次「确认应答号」字段填入 server_isn + 1 ,最后把报文发送给服务端。这次报文可以携带客户端到服务端的数据,之后客户端处于 ESTABLISHED 状态

在这里插入图片描述

  1. 服务端收到客户端的应答报文后,也进入 ESTABLISHED 状态

从上面的过程中可以发现第三次握手是可以携带数据的,前两次握手是不可以携带数据的。

一旦完成三次握手,双方都处于 ESTABLISHED 状态,此时连接就建立完成,客户端和服务端就可以互相发送数据了。

如何在 Linux 系统中查看 TCP 状态?

在 Linux 中可以通过 netstat -napt 命令查看 TCP 的连接状态。

在这里插入图片描述

为什么是三次握手?而不是两次、四次?

相信比较常回答的是:“因为三次握手才能保证双方都具有接收和发送的能力。”

这样回答是没问题,但这是片面的,并没有说出主要的原因。

在前面我们知道了什么是 TCP 连接:用于保证可靠性和流量控制维护的某些状态信息,这些信息的组合(包括 Socket、序列号和窗口大小)称为连接。

所以,重要的是为什么三次握手才可以初始化 Socket、序列号和窗口大小并建立 TCP 连接。

接下来,以三个方面分析三次握手的原因。

  • 三次握手才可以阻止重复历史连接的初始化(主要原因)
  • 三次握手才可以同步双方的初始序列号
  • 三次握手才可以避免资源浪费

避免历史连接

先来看看 RFC 793 指出的 TCP 连接使用三次握手的首要原因。

The principle reason for the three-way handshake is to prevent old duplicate connection initiations from causing confusion.

简单来说,三次握手的首要原因是为了防止旧的重复连接初始化而造成混乱。

考虑这样一个场景,客户端先发送了 SYN(seq = 90) 报文,但是被网络阻塞了,服务端并没有收到。

接着客户端又重新发送了 SYN(seq = 100) 报文,注意不是重传 SYN,重传的 SYN 的序列号是一样的,来看看三次握手是如何阻止历史连接的。

在这里插入图片描述

在网络拥堵的情况下,客户端连续多次发送 SYN 建立连接的报文。

  • 「旧 SYN 报文」比「新 SYN 报文」 提早到达服务端,此时服务端会返回一个 SYN + ACK 报文给客户端
  • 客户端收到后,根据自身的上下文,判断这是一个历史连接(序列号过期或者超时),客户端就会发送 RST 报文给服务端,表示中止这一次连接

为什么 TCP 两次握手为什么无法阻止历史连接呢?

主要是因为在两次握手的情况下,「被动发起方」没有中间状态给「主动发起方」来阻止历史连接,导致「被动发起方」可能会建立一个历史连接,造成资源浪费。

试想,在两次握手的情况下,「被动发起方」在收到 SYN 报文后,就进入 ESTABLISHED 状态,表示可以给对方发送数据,但是「主动发起方」此时还没有进入 ESTABLISHED 状态。假设这次是历史连接,「主动发起方」判断此次连接为历史连接,就会返回 RST 报文来断开连接,而「被动发起方」在第一次握手时就进入了 ESTABLISHED 状态,所以它是可以发送数据的,但是它并不知道这个是历史连接,它只有在后续收到 RST 报文后,才会断开连接。

在这里插入图片描述

在上面这种情况下,「被动发起方」在向「主动发起方」发送数据前,并没有阻止掉历史连接,导致「被动发起方」建立了一个历史连接,白白发送了数据,浪费了「被动发起方」的资源。

因此,要解决这种现象,最好就是在「被动发起方」发送数据前,即在建立连接之前,要阻止掉历史连接,这样就不会造成资源浪费。而要实现这个功能,就需要三次握手。

所以,TCP 使用三次握手建立连接的最主要的原因是为了防止「历史连接」初始化了连接。

同步双方初始序列号

TCP 协议的通信双方, 都必须维护一个「序列号」, 序列号是可靠传输的一个关键因素,它有以下几个作用。

  • 接收方可以去除重复的数据
  • 接收方可以根据数据包的序列号按序接收
  • 可以标识发送出去的数据包中, 哪些是已经被对方收到的(通过 ACK 报文中的序列号知道)

可见,序列号在 TCP 连接中占据着非常重要的作用,所以当客户端发送携带「初始序列号」的 SYN 报文的时候,需要服务端返回一个 ACK 应答报文,表示客户端的 SYN 报文已被服务端成功接收了。当服务端发送「初始序列号」给客户端的时候,同理也要得到客户端的应答。经过这样一来一回,才能确保双方的初始序列号都能被可靠地同步。

在这里插入图片描述

四次握手其实也能够可靠地同步双方的初始化序号,但是由于第二步和第三步可以优化成同一步,所以就成了「三次握手」。

而两次握手只能保证一方的初始序列号能被对方成功接收,没办法保证双方的初始序列号都能被确认接收。

避免资源浪费

如果只有「两次握手」,当客户端的 SYN 请求连接在网络中阻塞时,客户端没有接收到 ACK 报文,就会重新发送 SYN。由于没有第三次握手,服务端不清楚客户端是否收到了自己发送的建立连接的 ACK 确认信号,所以每收到一个 SYN 就只能先主动建立一个连接。

如果客户端的 SYN 阻塞了,重复发送了多次 SYN 报文,那么服务端在收到请求后就会建立多个冗余的无效连接,造成不必要的资源浪费。

在这里插入图片描述

所以两次握手会造成在消息滞留的情况下,服务端会重复接收无用的连接请求 SYN 报文,从而造成重复地分配资源。

小结

TCP 建立连接时,通过三次握手能防止历史连接的建立,减少双方不必要的资源开销,帮助双方同步初始化序列号(序列号能够保证数据包不重复、不丢弃和按序传输)。

不使用「两次握手」和「四次握手」的原因如下。

  • 「两次握手」:无法防止历史连接的建立,会造成双方资源的浪费,也无法可靠地同步双方序列号
  • 「四次握手」:三次握手理论上已经是最少的可靠连接建立,所以不需要再使用更多的通信次数

为什么每次建立 TCP 连接时,初始化的序列号都要求不一样呢?

  • 主要原因是为了防止历史报文被下一个相同四元组的连接所接收
  • 安全性,防止黑客伪造相同序列号的 TCP 报文被对方接收

假设每次建立连接,客户端和服务端的初始化序列号都是从 0 开始。

在这里插入图片描述

  1. 客户端和服务端建立一个 TCP 连接,客户端发送数据包时被网络阻塞了,而此时服务端的进程重启了,于是就会发送 RST 报文来断开连接
  2. 紧接着,客户端又与服务端建立了与上一个连接相同的四元组的连接
  3. 新连接建立完成后,上一个连接中被网络阻塞的数据包正好抵达了服务端,刚好该数据包的序列号也是在服务端的接收窗口内,所以该数据包会被服务端正常接收,造成数据错乱

如果每次建立连接,客户端和服务端的初始化序列号都是一样的,很容易出现历史报文被下一个相同四元组的连接所接收的问题。

而如果每次建立连接,客户端和服务端的初始化序列号都「不一样」,就有很大概率因为历史报文的序列号「不在」对方的接收窗口内,从而可以一定程度上避免历史报文(注意并不是完全避免,因为序列号会有回绕问题,所以需要用时间戳机制来判断历史报文)。

在这里插入图片描述

初始序列号 ISN 是如何随机产生的?

起始 ISN 是基于时钟的,每 4 微秒 +1,转一圈要 4.55 个小时。

RFC 793 提到初始化序列号 ISN 随机生成算法如下。

  • ISN = M + F(localhost, localport, remotehost, remoteport)
  • M 是一个计时器,这个计时器每隔 4 微秒加 1
  • F 是一个 Hash 算法,根据源 IP、目标 IP、源端口、目标端口生成一个随机数值。要保证 Hash 算法不能被外部轻易推算得出,用 MD5 算法是一个比较好的选择

可以看到,随机数是基于时钟计时器递增的,基本上不可能会随机成一样的初始化序列号。

既然 IP 层会分片,为什么 TCP 层还需要 MSS 呢?

先来认识下 MTU 和 MSS。

在这里插入图片描述

  • MTU:一个网络包的最大长度,在以太网中一般为 1500 字节
  • MSS:除去 IP 头部和 TCP 头部后,一个网络包所能容纳的 TCP 数据的最大长度

如果 TCP 的整个报文(头部 + 数据)都交给 IP 层进行分片,会有什么异常?

当 IP 层有一个超过 MTU 大小的数据(TCP 头部 + TCP 数据)要发送,那么 IP 层就要进行分片,把数据分片成若干片,保证每一个分片都小于 MTU。把一份 IP 数据报进行分片以后,由目标主机的 IP 层来进行重新组装,再交给上一层 TCP 传输层。

看起来这样井然有序,但其实这是存在隐患的:如果一个 IP 分片丢失,整个 IP 报文的所有分片都得重传。

因为 IP 层本身没有超时重传机制,它是由传输层的 TCP 来负责超时和重传的。

当接收方发现 TCP 报文(头部 + 数据)的某一片丢失后,则不会响应 ACK 给对方,那么发送方的 TCP 在超时后,就会重发「整个 TCP 报文(头部 + 数据)」。

因此,由 IP 层进行分片传输,是非常没有效率的。

所以,为了达到最佳的传输效能,TCP 协议在建立连接的时候通常要协商双方的 MSS 值,当 TCP 层发现数据超过 MSS 时,就会先进行分片,由它形成的 IP 包的长度也就不会大于 MTU ,自然也就不用 IP 层分片了。

在这里插入图片描述

经过 TCP 层分片后,一个 TCP 分片丢失后,即使进行重传,也是以 MSS 为单位,而不用重传所有的分片,大大提高了重传效率。

如果第一次握手丢失了,会发生什么?

当客户端想和服务端建立 TCP 连接时,首先第一个发的就是 SYN 报文,然后进入到 SYN_SENT 状态。

在这之后,如果客户端迟迟收不到服务端的 SYN-ACK 报文(第二次握手),就会触发「超时重传」机制,重传 SYN 报文。

不同版本的操作系统的超时时间可能不同,有的是 1 秒,也的是 3 秒,这个超时时间是写死在内核里的,如果想要更改则需要重新编译内核,比较麻烦。

假设当客户端在 1 秒后没收到服务端的 SYN-ACK 报文,客户端就会重发 SYN 报文,那这到底重发几次呢?

在 Linux 里,客户端的 SYN 报文的最大重传次数是由 tcp_syn_retries 内核参数控制的,这个参数可以自定义,默认值一般是 5。

通常,第一次超时重传是在 1 秒后,第二次超时重传是在 2 秒后,第三次超时重传是在 4 秒后,第四次超时重传是在 8 秒后,第五次超时重传是在 16 秒后。

没错,每次超时的时间是上一次的 2 倍。

当第五次超时重传后,会继续等待 32 秒,如果服务端仍然没有回应 ACK, 那么客户端就不再发送 SYN 包,然后断开 TCP 连接。

所以,总耗时是 1 + 2 + 4 + 8 + 16 + 32 = 63 秒,大约 1 分钟左右。

如果第二次握手丢失了,会发生什么?

当服务端收到客户端的第一次握手后,就会返回 SYN-ACK 报文给客户端,即第二次握手,此时服务端进入 SYN_RCVD 状态。

第二次握手的 SYN-ACK 报文其实有两个目的 。

  • 第二次握手里的 ACK,是对第一次握手的确认报文
  • 第二次握手里的 SYN,是服务端发起建立 TCP 连接的报文

所以,如果第二次握手丢了,就会发生比较有意思的事情。

因为第二次握手报文里是包含对客户端的第一次握手的 ACK 确认报文,所以如果客户端迟迟没有收到第二次握手,那么客户端就会觉得可能是自己的 SYN 报文(第一次握手)丢失了,于是客户端就会触发超时重传机制,重传 SYN 报文。

然后,因为第二次握手中包含服务端的 SYN 报文,当客户端收到后,需要给服务端发送 ACK 确认报文(第三次握手),服务端才会认为该 SYN 报文被客户端收到了。

那么如果第二次握手丢失了,服务端就收不到第三次握手,于是服务端这边也会触发超时重传机制,重传 SYN-ACK 报文。

在 Linux 里,SYN-ACK 报文的最大重传次数由 tcp_synack_retries 内核参数决定,默认值是 5。

因此,当第二次握手丢失了,客户端和服务端都会进行重传。

  • 客户端会重传 SYN 报文,也就是第一次握手,最大重传次数由 tcp_syn_retries 内核参数决定
  • 服务端会重传 SYN-ACK 报文,也就是第二次握手,最大重传次数由 tcp_synack_retries 内核参数决定

如果第三次握手丢失了,会发生什么?

客户端收到服务端的 SYN-ACK 报文后,就会给服务端返回一个 ACK 报文,即第三次握手,此时客户端进入 ESTABLISH 状态。

因为第三次握手的 ACK 是对第二次握手的 SYN 的确认报文,所以当第三次握手丢失了,服务端迟迟收不到这个确认报文,就会触发超时重传机制,重传 SYN-ACK 报文,直到收到第三次握手为止,或者达到了最大重传次数。

注意,ACK 报文是不会有重传的,当 ACK 丢失了,就由对方重传对应的报文。

什么是 SYN 攻击?如何避免 SYN 攻击?

TCP 连接建立需要三次握手,假设攻击者短时间内伪造了不同 IP 地址的 SYN 报文,服务端每接收到一个 SYN 报文,就进入 SYN_RCVD 状态,但是服务端发送出去的 ACK + SYN 报文,却始终无法得到未知 IP 主机的 ACK 应答,久而久之服务端的半连接队列就会被占满,使得服务端不能再为正常用户服务。

在这里插入图片描述

避免 SYN 攻击的方式一

其中一种解决方式是通过修改 Linux 内核参数,控制队列大小和当队列满时应做什么处理。

  • 当网卡接收数据包的速度大于内核处理的速度时,会有一个队列保存这些数据包。控制该队列的最大值的参数:net.core.netdev_max_backlog
  • SYN_RCVD 状态连接的最大个数:net.ipv4.tcp_max_syn_backlog
  • 超出处理能力时,对新的 SYN 直接返回 RST 报文,丢弃连接:net.ipv4.tcp_abort_on_overflow

避免 SYN 攻击的方式二

先来看下 Linux 内核的 SYN 队列(半连接队列)与 Accpet 队列(全连接队列)是如何工作的?

在这里插入图片描述

  1. 当服务端接收到客户端的 SYN 报文时,会将其加入到内核的「 SYN 队列」
  2. 接着发送 SYN + ACK 给客户端,等待客户端回应 ACK 报文
  3. 服务端接收到 ACK 报文后,从「 SYN 队列」移除并放入到「 Accept 队列」
  4. 应用通过调用 accpet() socket 接口,从「 Accept 队列」中取出连接

不管是半连接队列还是全连接队列,都有最大长度限制,超过限制时,默认情况都会丢弃报文。

SYN 攻击方式最直接的表现就是会把 TCP 半连接队列打满,这样当 TCP 半连接队列满了,后续再收到 SYN 报文就会丢弃,导致客户端无法和服务端建立连接。

在这里插入图片描述

如果应用程序过慢,就会导致「 Accept 队列」被占满。

在这里插入图片描述

如果不断受到 SYN 攻击,就会导致 SYN 队列(半连接队列)被占满,导致无法再建立新的连接。可以用 tcp_syncookies 的方式应对 SYN 攻击:net.ipv4.tcp_syncookies = 1。

在这里插入图片描述

  1. 当 「 SYN 队列」被占满后,后续服务器再收到 SYN 包时,不再进入「 SYN 队列」
  2. 计算出一个 cookie 值,再以 SYN + ACK 中的「序列号」返回给客户端
  3. 服务端接收到客户端的应答报文时,服务端会检查这个 ACK 包的合法性。如果合法,直接放入到「 Accept 队列」
  4. 最后应用通过调用 accpet() socket 接口,从「 Accept 队列」取出连接

TCP 连接断开

天下没有不散的宴席,对于 TCP 连接来说也是这样, TCP 断开连接是通过四次挥手的方式。

双方都可以主动断开连接,断开连接后主机中的「资源」将被释放。

在这里插入图片描述

  1. 客户端打算关闭连接,此时会发送一个 TCP 首部 FIN 标志位置为 1 的报文,即 FIN 报文,此时客户端进入 FIN_WAIT_1 状态
  2. 服务端收到该报文后,向客户端发送 ACK 应答报文,接着服务端进入 CLOSED_WAIT 状态
  3. 客户端收到服务端的 ACK 应答报文后,进入 FIN_WAIT_2 状态
  4. 等待服务端处理完数据后,向客户端发送 FIN 报文,服务端进入 LAST_ACK 状态
  5. 客户端收到服务端的 FIN 报文后,返回一个 ACK 应答报文,进入 TIME_WAIT 状态
  6. 服务端收到 ACK 应答报文后,进入 CLOSED 状态,至此服务端已经完成连接的关闭
  7. 客户端在经过 2MSL 的一段时间后,自动进入 CLOSED 状态,至此客户端也完成连接的关闭

可以看到,每个方向都需要一个 FIN 和一个 ACK,因此通常被称为四次挥手。

注意:只有主动关闭连接的,才有 TIME_WAIT 状态。

为什么需要四次挥手?

再来回顾下四次挥手双方发 FIN 包的过程,就能理解为什么需要四次了。

  1. 关闭连接时,客户端向服务端发送 FIN 时,仅仅是表示客户端不再发送数据了,但是还能接收数据
  2. 服务端收到客户端的 FIN 报文后,先返回一个 ACK 应答报文,然而服务端可能还有数据需要处理和发送,等服务端不再发送数据时,才发送 FIN 报文给客户端,表示同意现在关闭连接

从上面的过程可知,服务端通常需要等待完成数据的发送和处理,所以服务端的 ACK 和 FIN 一般都会分开发送,从而会比三次握手多了一次。

如果第一次挥手丢失了,会发生什么?

当客户端(主动关闭方)调用 close 函数后,就会向服务端发送 FIN 报文,试图与服务端断开连接,此时客户端进入 FIN_WAIT_1 状态。

正常情况下,如果能及时收到服务端(被动关闭方)的 ACK,则很快会变为 FIN_WAIT_2 状态。

如果第一次挥手丢失了,那么客户端迟迟收不到被动方的 ACK,就会触发超时重传机制,重传 FIN 报文,重发次数由 tcp_orphan_retries 参数控制。

当客户端重传 FIN 报文的次数超过 tcp_orphan_retries 后,就不再发送 FIN 报文,直接进入 CLOSED 状态。

如果第二次挥手丢失了,会发生什么?

当服务端收到客户端的第一次挥手后,就会先返回一个 ACK 确认报文,此时服务端的连接进入 CLOSE_WAIT 状态。

前面提到过,ACK 报文是不会重传的,所以如果服务端的第二次挥手丢失了,那么客户端就会触发超时重传机制,重传 FIN 报文,直到收到服务端的第二次挥手为止,或者达到最大重传次数。

注意,当客户端收到第二次挥手,即收到服务端发送的 ACK 报文后,客户端就会处于 FIN_WAIT_2 状态,在这个状态下,需要等待服务端发送第三次挥手,即服务端的 FIN 报文。

对于 close 函数关闭的连接,由于无法再发送和接收数据,所以 FIN_WAIT_2 状态不可以持续太久,tcp_fin_timeout 控制了在这个状态下连接的持续时长,默认是 60 秒。

这就意味着对于调用 close 函数关闭的连接而言,如果在 60 秒后还没有收到 FIN 报文,客户端(主动关闭方)的连接就会直接关闭。

注意,如果主动关闭方使用 shutdown 函数关闭连接,且指定了只关闭发送方向,而接收方向并没有关闭,那么这意味着主动关闭方还是可以接收数据的。如果主动关闭方一直没收到第三次挥手,主动关闭方的连接将会一直处于 FIN_WAIT_2 状态(tcp_fin_timeout 无法控制 shutdown 关闭的连接)。

如果第三次挥手丢失了,会发生什么?

当服务端(被动关闭方)收到客户端(主动关闭方)的 FIN 报文后,内核会自动回复 ACK,同时连接处于 CLOSE_WAIT 状态,表示等待应用进程调用 close 函数关闭连接。

此时,内核是没有权利代替进程关闭连接的,必须由进程主动调用 close 函数来触发服务端发送 FIN 报文。

服务端处于 CLOSE_WAIT 状态时,调用了 close 函数,内核就会发出 FIN 报文,同时连接进入 LAST_ACK 状态,等待客户端返回 ACK 来确认连接关闭。

如果迟迟收不到这个 ACK,服务端就会重发 FIN 报文,重发次数仍然由 tcp_orphan_retries 参数控制,这与客户端重发 FIN 报文的重传次数的控制方式是一样的。

如果第四次挥手丢失了,会发生什么?

当客户端收到服务端第三次挥手的 FIN 报文后,就会返回 ACK 报文,即第四次挥手,此时客户端连接进入 TIME_WAIT 状态。

在 Linux 系统中,TIME_WAIT 状态会持续 2MSL 后才会进入关闭状态。

然后,服务端(被动关闭方)在没有收到 ACK 报文前,还是处于 LAST_ACK 状态。

如果第四次挥手的 ACK 报文没有到达服务端,服务端就会重发 FIN 报文,重发次数仍然由 tcp_orphan_retries 参数控制。

为什么 TIME_WAIT 等待的时间是 2MSL?

MSL 是 Maximum Segment Lifetime,即报文最大生存时间,它是任何报文在网络上存在的最长时间,超过这个时间,报文将被丢弃。因为 TCP 报文基于是 IP 协议的,而 IP 头中有一个 TTL 字段,是 IP 数据报可以经过的最大路由数,每经过一个处理它的路由器,此值就减 1,当此值为 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 状态的连接还是可以应对的。

为什么不是 4MSL 或者 8MSL 的时长呢?

可以想象一个丢包率达到百分之一的糟糕网络,连续两次丢包的概率只有万分之一,这个概率实在是太小了,忽略它比解决它更具性价比。

2MSL 的时间是从客户端接收到 FIN 后发送 ACK 开始计时的。如果在 TIME_WAIT 时间内,因为客户端的 ACK 没有传输到服务端,客户端又接收到了服务端重发的 FIN 报文,那么 2MSL 时间将重新计时。

在 Linux 系统里 2MSL 默认是 60 秒,那么一个 MSL 也就是 30 秒。Linux 系统停留在 TIME_WAIT 的时间为固定的 60 秒。

其定义在 Linux 内核代码里的名称为 TCP_TIMEWAIT_LEN。

/* how long to wait to destroy TIME-WAIT state, about 60 seconds  */
# define TCP_TIMEWAIT_LEN (60*HZ)

如果要修改 TIME_WAIT 的时间长度,只能修改 Linux 内核代码里 TCP_TIMEWAIT_LEN 的值,并重新编译 Linux 内核。

为什么需要 TIME_WAIT 状态?

只有主动发起关闭连接的一方,才会有 TIME_WAIT 状态。

需要 TIME_WAIT 状态,主要是有两个原因。

  • 防止历史连接中的数据,被后面相同四元组的连接错误地接收
  • 保证「被动关闭连接」的一方,能被正确地关闭

防止历史连接中的数据,被后面相同四元组的连接错误地接收

为了能更好地理解这个原因,先来了解序列号(SEQ)和初始序列号(ISN)。

序列号,是 TCP 的一个头部字段,标识了 TCP 发送端到 TCP 接收端的数据流的一个字节,因为 TCP 是面向字节流的可靠协议,为了保证消息的顺序性和可靠性,TCP 会为每个传输方向上的每个字节都赋予一个编号,以便于传输成功后确认、丢失后重传以及在接收端保证不会乱序。

序列号是一个 32 位的无符号数,因此在到达 4G 之后再循环回到 0。

初始序列号,在 TCP 建立连接的时候,客户端和服务端都会各自生成一个初始序列号,它是基于时钟生成的一个随机数,来保证每个连接都拥有不同的初始序列号。初始化序列号可被视为一个 32 位的计数器,该计数器的数值每 4 微秒加 1,循环一次需要 4.55 小时。

下图中的 Seq 就是序列号,其中红色框住的分别是客户端和服务端各自生成的初始序列号。

在这里插入图片描述

序列号和初始化序列号并不是无限递增的,会发生回绕为初始值的情况,这就意味着无法根据序列号来判断新老数据。

假设 TIME_WAIT 没有等待时间或者等待时间过短,那么被延迟的数据包抵达后会发生什么呢?

在这里插入图片描述

  1. 服务端在关闭连接之前发送的 SEQ = 301 报文,被网络延迟了;
  2. 接着,服务端以相同的四元组重新打开了新连接,前面被延迟的 SEQ = 301 这时抵达了客户端,而且该数据报文的序列号刚好在客户端接收窗口内,因此客户端会正常接收这个数据报文,但是这个数据报文是上一个连接残留下来的,这样就产生数据错乱等严重问题

为了防止历史连接中的数据,被后面相同四元组的连接错误地接收,因此 TCP 设计了 TIME_WAIT 状态,状态会持续 2MSL 时长,这个时间足以让两个方向上的数据包都被丢弃,使得原来连接的数据包在网络中都自然消失,再出现的数据包一定都是新建立连接所产生的。

保证「被动关闭连接」的一方,能被正确的关闭

在 RFC 793 中指出 TIME_WAIT 的另一个重要的作用是。

TIME-WAIT - represents waiting for enough time to pass to be sure the remote TCP received the acknowledgment of its connection termination request.

也就是说,TIME-WAIT 的作用是等待足够的时间,以确保最后的 ACK 能让被动关闭方接收,从而帮助其正常关闭。

如果客户端(主动关闭方)最后一次 ACK 报文(第四次挥手)在网络中丢失了,那么按照 TCP 可靠性原则,服务端(被动关闭方)会重发 FIN 报文。

假设客户端没有 TIME_WAIT 状态,而是在发完最后一次 ACK 报文后就直接进入 CLOSED 状态。如果该 ACK 报文丢失了,服务端重传 FIN 报文,而这时客户端已经进入关闭状态了,在收到服务端重传的 FIN 报文后,就会返回 RST 报文。

在这里插入图片描述

服务端收到这个 RST 并将其解释为一个错误(Connection reset by peer),这对于一个可靠的协议来说不是一个优雅的终止方式。

为了防止这种情况的出现,客户端必须等待足够长的时间,以确保对端收到 ACK,如果对端没有收到 ACK,那么就会触发 TCP 重传机制,服务端会重新发送一个 FIN,这样一去一来刚好两个 MSL 的时间。

在这里插入图片描述

客户端在收到服务端重传的 FIN 报文时,TIME_WAIT 状态的等待时间,会重置回 2MSL。

但是你可能会说重新发送的 ACK 还是有可能丢失啊,没错,但 TCP 已经等待了那么长的时间了,已经算仁至义尽了。

如果 TIME_WAIT 过多,会有什么危害?

过多的 TIME-WAIT 状态的主要危害有两种。

  • 内存资源占用
  • 对端口资源的占用,一个 TCP 连接至少消耗「发起连接方」的一个本地端口

第二个危害会造成严重后果,因为端口资源也是有限的,一般可以开启的端口为 32768~61000,也可以通过参数设置指定:net.ipv4.ip_local_port_range。

如果「发起连接方」的 TIME_WAIT 状态过多,占满了所有的端口资源,会导致无法创建新连接。

客户端(发起连接方)受端口资源的限制:客户端 TIME_WAIT 过多,就会导致端口资源被占用,因为端口就 65536 个,被占满就会导致无法创建新的连接。

服务端(被动连接方)受系统资源的限制:由于一个四元组表示 TCP 连接,理论上服务端可以建立很多的连接,因为服务端只监听一个端口,不会因为 TCP 连接过多而导致端口资源受限。但是 TCP 连接过多,会占用系统资源,比如文件描述符、内存资源、CPU 资源、线程资源等。

如何优化 TIME_WAIT?

这里给出优化 TIME_WAIT 的几个方式,都是有利有弊。

  • 打开 net.ipv4.tcp_tw_reuse 和 net.ipv4.tcp_timestamps 选项
  • net.ipv4.tcp_max_tw_buckets
  • 程序中使用 SO_LINGER ,应用强制使用 RST 关闭

net.ipv4.tcp_tw_reuse 和 tcp_timestamps

上述的 Linux 内核参数开启后,则可以复用处于 TIME_WAIT 的 socket 为新连接所用。

注意,tcp_tw_reuse 功能只能用于客户端(连接发起方)。因为开启了该功能,在调用 connect() 函数时,内核会随机找一个 TIME_WAIT 状态超过 1 秒的连接给新连接复用:net.ipv4.tcp_tw_reuse = 1。

使用这个选项还有一个前提,就是需要打开对 TCP 时间戳的支持,即:net.ipv4.tcp_timestamps=1(默认即为 1)。

这个时间戳的字段是在 TCP 头部的「选项」里的,它由一共 8 个字节来表示时间戳,其中第一个 4 字节字段用来保存发送该数据包的时间,第二个 4 字节字段用来保存最近一次接收对方发送到达数据的时间。

由于引入了时间戳,前面提到的 2MSL 问题就不复存在了,因为重复的数据包会因为时间戳过期而被自然丢弃。

net.ipv4.tcp_max_tw_buckets

这个值默认为 18000,当系统中处于 TIME_WAIT 的连接数一旦超过这个值,系统就会将后面的 TIME_WAIT 连接状态重置,这个方法比较暴力。

程序中使用 SO_LINGER

可以通过设置 socket 选项,来设置调用 close 关闭连接行为。

struct linger so_linger;
so_linger.l_onoff = 1;
so_linger.l_linger = 0;
setsockopt(s, SOL_SOCKET, SO_LINGER, &so_linger,sizeof(so_linger));

如果 l_onoff 为非 0, 并且 l_linger 值为 0,那么调用 close 后,会立刻发送一个 RST 标志给对端,该 TCP 连接将跳过四次挥手,也就是跳过了 TIME_WAIT 状态,直接关闭。

但这为跨越 TIME_WAIT 状态提供了一个可能,是一个非常危险的行为,不值得提倡。

前面介绍的方法都是试图越过 TIME_WAIT 状态,这样其实不太好。虽然 TIME_WAIT 状态持续的时间是有一点长,显得很不友好,但是它被设计出来就是为了避免发生乱七八糟的事情。

《UNIX 网络编程》一书中说道:TIME_WAIT 是我们的朋友,它是有助于我们的,不要试图避免这个状态,而是应该弄清楚它。

如果服务端要避免过多的 TIME_WAIT 状态的连接,就永远不要主动断开连接,让客户端去断开,由分布在各处的客户端去承受 TIME_WAIT。

服务器出现大量 TIME_WAIT 状态的原因有哪些?

首先要知道 TIME_WAIT 状态是主动关闭连接方才会出现的状态,所以如果服务器出现大量的 TIME_WAIT 状态的 TCP 连接,就是说明服务器主动断开了很多 TCP 连接。

问题来了,什么场景下服务端会主动断开连接呢?

HTTP 没有使用长连接

先来看看 HTTP 长连接(Keep-Alive)机制是怎么开启的。

在 HTTP/1.0 中默认是关闭的,如果浏览器要开启 Keep-Alive,必须在请求的 header 中添加:Connection: Keep-Alive。

然后当服务器收到请求,作出回应的时候,它也被添加到响应中 header 里:Connection: Keep-Alive。

这样做,TCP 连接就不会中断,而是一直保持连接。当客户端发送另一个请求时,它会使用同一个 TCP 连接,一直继续到客户端或服务端主动提出断开连接。

从 HTTP/1.1 开始, 默认是开启了 Keep-Alive,现在大多数浏览器也都默认是使用 HTTP/1.1,所以 Keep-Alive 都是默认打开的。一旦客户端和服务端达成协议,那么长连接就建立好了。

如果要关闭 HTTP Keep-Alive,需要在 HTTP 请求或者响应的 header 里添加 Connection: close 信息。即只要客户端和服务端任意一方的 HTTP header 中有 Connection: close 信息,那么就无法使用 HTTP 长连接的机制。

关闭 HTTP 长连接机制后,每次请求都要经历:建立 TCP -> 请求资源 -> 响应资源 -> 释放连接,此方式就是 HTTP 短连接。

在这里插入图片描述

只要任意一方的 HTTP header 中有 Connection: close 信息,就无法使用 HTTP 长连接机制,这样在完成一次 HTTP「请求 - 处理」后,就会关闭连接。那么这时是客户端还是服务端主动关闭连接的呢?

在 RFC 文档中,并没有明确由谁来关闭连接,请求和响应的双方都可以主动关闭 TCP 连接。

不过,根据大多数 Web 服务的实现,不管哪一方禁用了 HTTP Keep-Alive,都是由服务端主动关闭连接,那么此时服务端上就会出现 TIME_WAIT 状态的连接。

如果客户端禁用了 HTTP Keep-Alive,服务端开启 HTTP Keep-Alive,此时谁是主动关闭方?

当客户端禁用了 HTTP Keep-Alive,这时候 HTTP 请求的 header 就会有 Connection: close 信息,服务端在发完 HTTP 响应后,就会主动关闭连接。

为什么要这么设计呢?

因为 HTTP 是「请求 - 响应」模型,发起方一直是客户端,HTTP Keep-Alive 的初衷是为客户端后续的请求重用连接,如果我们在某次 HTTP 「请求 - 响应」模型中,请求的 header 定义了 connection: close 信息,那不再重用这个连接的时机就只有在服务端了,所以我们在 HTTP 「请求 - 响应」这个周期的「末端」关闭连接是合理的。

如果客户端开启了 HTTP Keep-Alive,服务端禁用了 HTTP Keep-Alive,此时谁是主动关闭方?

当客户端开启了 HTTP Keep-Alive,而服务端禁用了 HTTP Keep-Alive,服务端在发完 HTTP 响应后,服务端也会主动关闭连接。

为什么要这么设计呢?

在服务端主动关闭连接的情况下,只要调用一次 close() 就可以释放连接,剩下的工作由内核 TCP 栈直接进行了处理,整个过程只有一次 syscall。

如果是要求客户端关闭,则服务端在写完最后一个 response 之后需要把这个 socket 放入 readable 队列,调用 select/epoll 去等待事件。然后调用一次 read() 才能知道连接已经被关闭,这其中是两次 syscall,多一次用户态程序被激活执行,而且 socket 保持时间也会更长。

因此,当服务端出现大量的 TIME_WAIT 状态连接的时候,可以排查下是否客户端和服务端都开启了 HTTP Keep-Alive。因为只要任意一方没有开启 HTTP Keep-Alive,都会导致服务端在处理完一个 HTTP 请求后,就主动关闭连接,此时服务端上就会出现大量的 TIME_WAIT 状态的连接。

针对这个场景,解决的方式也很简单,让客户端和服务端都开启 HTTP Keep-Alive 机制即可。

HTTP 长连接超时

HTTP 长连接的特点是:只要任意一端没有明确提出断开连接,则保持 TCP 连接状态。

HTTP 长连接可以在同一个 TCP 连接上接收和发送多个 HTTP 请求/应答,避免了连接建立和释放的开销。

在这里插入图片描述

如果使用了 HTTP 长连接,客户端完成一个 HTTP 请求后,就不再发送新的请求,此时这个 TCP 连接会一直占用着,浪费资源。

所以为了避免资源浪费的情况,Web 服务软件一般都会提供一个参数,用来指定 HTTP 长连接的超时时间,比如 Nginx 提供的 keepalive_timeout 参数。

假设设置了 HTTP 长连接的超时时间是 60 秒,Nginx 就会启动一个「定时器」,如果客户端在完后一个 HTTP 请求后,在 60 秒内都没有再发送新的请求,等待定时器的时间一到,Nginx 就会触发回调函数来关闭该连接,那么此时服务端上就会出现 TIME_WAIT 状态的连接。

在这里插入图片描述

当服务端出现大量 TIME_WAIT 状态的连接时,如果现象是有大量的客户端在建立完 TCP 连接后,很长一段时间都没有发送数据,那么大概率就是因为 HTTP 长连接超时,导致服务端主动关闭连接,产生大量处于 TIME_WAIT 状态的连接。

可以往网络问题的方向排查,比如是否是因为网络问题,导致客户端发送的数据一直没有被服务端接收到,以至于 HTTP 长连接超时。

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 服务端的流程。

  1. 创建服务端 socket,bind 绑定端口、listen 监听端口
  2. 将服务端 socket 注册到 epoll
  3. epoll_wait 等待连接到来,连接到来时,调用 accpet 获取已连接的 socket
  4. 将已连接的 socket 注册到 epoll
  5. epoll_wait 等待事件发生
  6. 对方连接关闭时,调用 close

可能导致服务端没有调用 close 函数的原因,如下。

  • 第一个原因:第 2 步没有做,没有将服务端 socket 注册到 epoll,导致有新连接到来时,服务端无法感知这个事件,也就无法获取到已连接的 socket,自然就不会对 socket 调用 close 函数了。不过这种原因发生的概率比较小,属于明显的代码逻辑 bug,在前期 code review 阶段就能发现的了
  • 第二个原因: 第 3 步没有做,有新连接到来时,没有调用 accpet 获取该连接的 socket,导致有大量客户端主动断开了连接时,服务端无法对这些 socket 调用 close 函数,从而导致服务端出现大量 CLOSE_WAIT 状态的连接。发生这种情况可能是因为服务端在执行 accpet 函数前,代码卡在某一个逻辑或者提前抛出了异常
  • 第三个原因:第 4 步没有做,通过 accpet 获取已连接的 socket 后,没有将其注册到 epoll,导致后续收到 FIN 报文时,服务端无法感知这个事件,自然就不会调用 close 函数了。发生这种情况可能是因为服务端在将已连接的 socket 注册到 epoll 之前,代码卡在某一个逻辑或者提前抛出了异常
  • 第四个原因:第 6 步没有做,当发现客户端关闭连接时,服务端没有执行 close 函数,可能是因为代码漏处理,或者是在执行 close 函数前,代码卡在某一个逻辑,比如发生死锁等

简单来说,当服务端出现大量 CLOSE_WAIT 状态的连接时,通常都是因为代码的问题,这时候需要针对具体的代码一步一步地进行排查和定位,主要分析的方向就是服务端为什么没有调用 close。

如果已经建立了连接,但是客户端突然出现故障了怎么办?

TCP 有一个保活机制。

定义一个时间段,在这个时间段内如果没有任何连接相关的活动,TCP 保活机制就会开始作用。每隔一个时间间隔,发送一个探测报文,该探测报文包含的数据非常少,如果连续发送几个探测报文都没有得到响应,则认为当前的 TCP 连接已经死亡,系统内核将错误信息通知给上层应用程序。

在 Linux 内核中有对应的参数可以设置保活时间、保活探测的次数、保活探测的时间间隔,以下都为默认值。

  • net.ipv4.tcp_keepalive_time=7200,表示保活时间是 7200 秒(2 小时),即 2 小时内如果没有任何连接相关的活动,则会启动保活机制
  • net.ipv4.tcp_keepalive_intvl=75,表示每次检测间隔 75 秒
  • net.ipv4.tcp_keepalive_probes=9,表示检测 9 次无响应,则认为对方是不可达的,从而中断本次的连接

也就是说,在 Linux 系统中,最少需要经过 2 小时 11 分 15 秒才可以发现一个「死亡」连接。

在这里插入图片描述

注意,应用程序若想使用 TCP 保活机制,需要通过 socket 接口设置 SO_KEEPALIVE 选项才能够生效。如果没有设置,那么就无法使用 TCP 保活机制。

如果开启了 TCP 保活,还需要考虑以下几种情况。

  • 对端程序是正常工作的。当 TCP 保活的探测报文发送给对端,对端会正常响应,这样 TCP 保活时间会被重置,等待下一个 TCP 保活时间的到来
  • 对端程序崩溃并重启。当 TCP 保活的探测报文发送给对端后,对端是可以响应的,但是由于没有该连接的有效信息,会产生一个 RST 报文,这样很快就会发现 TCP 连接已经被重置
  • 对端程序崩溃,或者对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给对端后,石沉大海没有响应,连续几次至达到最大保活探测次数后,TCP 会报告该 TCP 连接已经死亡

TCP 保活的机制检测的时间是有点长,我们可以自己在应用层实现一个心跳机制。

比如,Web 服务软件一般都会提供 keepalive_timeout 参数,用来指定 HTTP 长连接的超时时间。如果设置了 HTTP 长连接的超时时间是 60 秒,Web 服务软件就会启动一个定时器,如果客户端在完成一个 HTTP 请求后,在 60 秒内都没有再发起新的请求,等待定时器的时间一到,就会触发回调函数来释放该连接。

在这里插入图片描述

如果已经建立了连接,但是服务端的进程崩溃会发生什么?

TCP 的连接信息是由内核维护的,所以当服务端的进程崩溃后,内核需要回收该进程的所有 TCP 连接资源,于是内核会发送第一次挥手 FIN 报文,后续的挥手过程也都是在内核完成,并不需要进程的参与,所以即使服务端的进程退出了,还是能与客户端完成 TCP 四次挥手的过程。

使用 kill -9 来模拟进程崩溃的情况,发现在 kill 掉进程后,服务端会发送 FIN 报文,与客户端进行四次挥手。

Socket 编程

针对 TCP 应该如何 Socket 编程?

在这里插入图片描述

  1. 服务端和客户端初始化 socket,得到文件描述符
  2. 服务端调用 bind,将 socket 绑定在指定的 IP 地址和端口
  3. 服务端调用 listen,进行监听
  4. 服务端调用 accept,等待客户端连接
  5. 客户端调用 connect,向服务端的地址和端口发起连接请求
  6. 服务端 accept 返回用于传输的 socket 的文件描述符
  7. 客户端调用 write 写入数据
  8. 服务端调用 read 读取数据
  9. 客户端断开连接时,会调用 close,那么服务端 read 读取数据时,就会读取到 EOF,待处理完数据后,服务端调用 close,表示连接关闭

注意,服务端调用 accept 时,连接成功会返回一个已完成连接的 socket,用于后续传输数据。

所以,监听的 socket 和真正用来传输数据的 socket,是「两个」 socket,一个叫作监听 socket,一个叫作已完成连接 socket。

成功建立连接之后,双方开始通过 read 和 write 函数来读写数据,就像往一个文件流里面写东西一样。

listen 时,参数 backlog 的意义?

Linux 内核中会维护以下两个队列。

  • 半连接队列(SYN 队列):接收到一个 SYN 建立连接请求,处于 SYN_RCVD 状态
  • 全连接队列(Accpet 队列):已完成 TCP 三次握手过程,处于 ESTABLISHED 状态

在这里插入图片描述

int listen (int socketfd, int backlog)
  • 参数一 socketfd 为 socketfd 文件描述符
  • 参数二 backlog,这参数在历史版本有一定的变化

在早期 Linux 内核中,backlog 是 SYN 队列大小,也就是未完成的队列大小。

在 Linux 内核 2.2 之后,backlog 变成 accept 队列,也就是已完成连接建立的队列长度,所以现在通常认为 backlog 是 accept 队列。

其上限值是内核参数 somaxconn 的大小,也就说 accpet 队列长度 = min(backlog, somaxconn)。

accept 发生在三次握手的哪一步?

先来看看客户端连接服务端时,发送了什么?

在这里插入图片描述

  1. 客户端的协议栈向服务端发送了 SYN 包,并告诉服务端当前发送序列号 client_isn,客户端进入 SYN_SENT 状态
  2. 服务端的协议栈收到这个包之后,和客户端进行 ACK 应答,应答的值为 client_isn + 1,表示对 SYN 包 client_isn 的确认,同时服务端也发送一个 SYN 包,告诉客户端当前的发送序列号为 server_isn,服务端进入 SYN_RCVD 状态
  3. 客户端协议栈收到 ACK 之后,使得应用程序从 connect 调用返回,表示客户端到服务端的单向连接建立成功,客户端的状态为 ESTABLISHED,同时客户端协议栈也会对服务端的 SYN 包进行应答,应答数据为 server_isn + 1
  4. 应答包到达服务端后,服务端协议栈使得 accept 阻塞调用返回,这个时候服务端到客户端的单向连接也建立成功,服务端也进入 ESTABLISHED 状态

从上面的描述过程,可以得知客户端 connect 成功返回是在第二次握手,服务端 accept 成功返回是在三次握手成功之后。

客户端调用 close 了,连接断开的流程是什么?

先来看看客户端主动调用了 close,会发生什么?

在这里插入图片描述

  1. 客户端调用 close,表明客户端没有数据需要发送了,此时会向服务端发送 FIN 报文,进入 FIN_WAIT_1 状态
  2. 服务端接收到 FIN 报文,TCP 协议栈会为 FIN 包插入一个文件结束符 EOF 到接收缓冲区中,应用程序可以通过 read 调用来感知这个 FIN 包。这个 EOF 会被放在已排队等候的其他已接收的数据之后,意味着服务端需要处理这种异常情况,因为 EOF 表示在该连接上已再无额外数据到达。此时,服务端进入 CLOSE_WAIT 状态
  3. 当处理完数据后,自然就会读到 EOF,于是也调用 close 关闭它的套接字,使得服务端发出一个 FIN 包,之后处于 LAST_ACK 状态
  4. 客户端接收到服务端的 FIN 包,并发送 ACK 确认包给服务端,此时客户端将进入 TIME_WAIT 状态
  5. 服务端收到 ACK 确认包后,就进入了最后的 CLOSE 状态
  6. 客户端经过 2MSL 时间之后,也进入 CLOSE 状态

没有 accept,能建立 TCP 连接吗?

可以的。

accpet 系统调用并不参与 TCP 三次握手过程,它只是负责从 TCP 全连接队列中,取出一个已经建立连接的 socket,用户层通过 accpet 系统调用拿到了已经建立连接的 socket,就可以对该 socket 进行读写操作了。

在这里插入图片描述

没有 listen,能建立 TCP 连接吗?

可以的。

客户端是可以自己连自己形成连接的(TCP 自连接),也可以两个客户端同时向对方发出请求建立连接(TCP 同时打开)。这两个情况都有个共同点,就是没有服务端参与,也就是没有 listen 也能建立 TCP 连接。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值