小林tcp笔记

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 协议传输时,消息可能会被操作系统「分组」成多个的 TCP 报文,如果接收方的程序如果不知道「消息的边界」,是无法读出一个有效的用户消息的。并且 TCP 报文是「有序的」,当「前一个」TCP 报文没有收到的时候,即使它先收到了后面的 TCP 报文,那么也不能扔给应用层去处理,同时对「重复」的 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和端口是可变的,理论计算公式如下

最大TCP连接数=客户端IP数*客户端端口数

对 IPv4,客户端的 IP 数最多为 232 次方,客户端的端口数最多为 216 次方,也就是服务端单机最大 TCP 连接数,约为 248 次方。

当然,服务端最大并发 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的区别

    1. 连接

    • tcp是面向连接的传输层协议,传输数据先要建立连接
    • UDP是不需要连接,即可传输数据。

    2.服务对象

    • TCP是一对一的俩点服务,即一条连接只有俩个端点。
    • UDP支持一对一、一对多、多对多的交互通信

    3.可靠性

    • TCP是做可靠交付数据的,数据可以无差错、不丢失、不重复、按序到达。
    • UDP是尽最大努力交付,不保证可靠交付数据。但是我们可以给予UDP协议实现一个可靠协议,比如QUIC协议

    4.拥塞控制流量控制

    • TCP有拥塞控制和流量控制机制,保证数据传输的安全性。
    • UDP则没有,即使网络非常拥塞了,也不会影响UDP的发送效率。

    5.首部开销

    • TCP的首部较长,会有一定的开销,首部在没有使用选项字段时是20个字节,如果使用选项字段则是会变长的。
    • UDP首部固定8个字节,并且是固定不变的,开销较小。

    6.传输方式

    • TCP是流式传输,没有边界,但保证顺序可靠。
    • UDP是一个包一个包发送的,是有边界的,但是可能会丢包和乱序。

    7.分片不同

    • TCP的数据包大小如果小于MSS大小,则会在传输层进行分片,目标主机收到后,也同样在传输层组装TCP数据包,如果中途丢了一个分片,只需要传输丢失的这个分片。
    • UDP的数据大小如果大于MTU大小,则会在IP层进行分片,目标主机收到后,在IP层组装完数据,接着再传给传输层。

    TCP和UDP应用场景

    由于 TCP 是面向连接,能保证数据的可靠性交付,因此经常用于:

    • FTP 文件传输;
    • HTTP / HTTPS;

    由于 UDP 面向无连接,它可以随时发送数据,再加上 UDP 本身的处理既简单又高效,因此经常用于:

    • 包总量较少的通信,如 DNSSNMP 等;
    • 视频、音频等多媒体通信;
    • 广播通信;

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

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

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

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

    TCP数据的长度=IP总长度-IP首部长度-TCP首部长度

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

    大家这时就奇怪了问:“UDP 也是基于 IP 层的呀,那 UDP 的数据长度也可以通过这个公式计算呀? 为何还要有「包长度」呢?”

    这么一问,确实感觉 UDP 的「包长度」是冗余的。

    我查阅了很多资料,我觉得有两个比较靠谱的说法:

    • 第一种说法:因为为了网络设备硬件设计和处理方便,首部长度需要是 4 字节的整数倍。如果去掉 UDP 的「包长度」字段,那 UDP 首部长度就不是 4 字节的整数倍了,所以我觉得这可能是为了补全 UDP 首部长度是 4 字节的整数倍,才补充了「包长度」字段。
    • 第二种说法:如今的 UDP 协议是基于 IP 协议发展的,而当年可能并非如此,依赖的可能是别的不提供自身报文长度或首部长度的网络层协议,因此 UDP 报文首部需要有长度字段以供计算。

    TCP和UDP可以使用同一个端口吗

    答案:可以的

    在数据链路层中,通过 MAC 地址来寻找局域网中的主机。在网际层中,通过 IP 地址来寻找网络中互连的主机或路由器。在传输层中,需要通过端口进行寻址,来识别同一计算机中同时通信的不同应用程序。

    所以,传输层的「端口号」的作用,是为了区分同一个主机上不同应用程序的数据包。

    传输层有两个传输协议分别是 TCP 和 UDP,在内核中是两个完全独立的软件模块。

    当主机收到数据包后,可以在 IP 包头的「协议号」字段知道该数据包是 TCP/UDP,所以可以根据这个信息确定送给哪个模块(TCP/UDP)处理,送给 TCP/UDP 模块的报文根据「端口号」确定送给哪个应用程序处理。

    因此,TCP/UDP 各自的端口号也相互独立,如 TCP 有一个 80 号端口,UDP 也可以有一个 80 号端口,二者并不冲突。

    关于端口的知识点,还是挺多可以讲的,比如还可以牵扯到这几个问题:

    • 多个 TCP 服务进程可以同时绑定同一个端口吗?
    • 重启 TCP 服务进程时,为什么会出现“Address in use”的报错信息?又该怎么避免?
    • 客户端的端口可以重复使用吗?
    • 客户端 TCP 连接 TIME_WAIT 状态过多,会导致端口资源耗尽而无法建立新的连接吗?

    TCP建立连接

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

    • 一开始客户端和服务端都处于close状态。先是服务端主动监听某个端口,处于listen状态

    客户端会随机初始化序号client_isn,讲次序号置于TCP首部的序号字段中,同时把SYN标志位置为1,表示SYN报文。接着把第一个SYN报文发送给服务端,表示像服务端发起连接,该报文不包含应用层数据,之后客户端处于SYN-SEND状态。

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

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

    从上面过程可以发现第三次握手是可以携带数据的,前俩次握手是不可以携带数据的,这也是面试常问的题。

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

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

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

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

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

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

    在前面我们知道了什么是 TCP 连接

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

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

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

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

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

    主要原因有俩个方面

    • 为了防止历史报文被下一个系统四元组的连接接收(主要方面);
    • 为了安全性,防止黑客伪造的相同序列号的TCP报文被对方接收;

    接下来详细说说第一点。

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

    过程如下

    • 客户端和服务端建立一个 TCP 连接,在客户端发送数据包被网络阻塞了,然后超时重传了这个数据包,而此时服务端设备断电重启了,之前与客户端建立的连接就消失了,于是在收到客户端的数据包的时候就会发送 RST 报文。
    • 紧接着,客户端又与服务端建立了与上一个连接相同四元组的连接;
    • 在新连接建立完成后,上一个连接中被网络阻塞的数据包正好抵达了服务端,刚好该数据包的序列号正好是在服务端的接收窗口内,所以该数据包会被服务端正常接收,就会造成数据错乱。

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

    如果每次建立连接客户端和服务端的初始化序列号都「不一样」,就有大概率因为历史报文的序列号「不在」对方接收窗口,从而很大程度上避免了历史报文,比如下图:

    相反,如果每次建立连接客户端和服务端的初始化序列号都「一样」,就有大概率遇到历史报文的序列号刚「好在」对方的接收窗口内,从而导致历史报文被新连接成功接收。

    所以,每次初始化序列号不一样很大程度上能够避免历史报文被下一个相同四元组的连接接收,注意是很大程度上,并不是完全避免了(因为序列号会有回绕的问题,所以需要用时间戳的机制来判断历史报文)

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

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

    RFC793 提到初始化序列号 ISN 随机生成算法:ISN = M + F(localhost, localport, remotehost, remoteport)。

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

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

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

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

    如果在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 为单位,而不用重传所有的分片,大大增加了重传的效率。

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

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

    先说一下什么是TCP半连接和全连接队列。

    在TCP三次握手的时候,Linux内核会维护俩个队列,分别是:

    • 半连接队列,也成SYN队列
    • 全连接队列也成accept队列

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

    正常流程

    1. 当服务端收到客户端的SYN报文时,会创建一个半连接对象,然后将其加入到内核的SYN队列
    2. 接着发送SYN+ACK给客户端,等待客户端回应ACK报文;
    3. 服务端接收到ACK报文后,从SYN队列去取出一个半连接对象,然后创建一个新的连接对象放入到Accept队列
    4. 应用通过调用accpet()socket接口,从accept队列取出连接对象

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

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

    避免 SYN 攻击方式,可以有以下四种方法:

    • 调大 netdev_max_backlog;
    • 增大 TCP 半连接队列;
    • 开启 tcp_syncookies;
    • 减少 SYN+ACK 重传次数

    方式一:调大 netdev_max_backlog

    当网卡接收数据包的速度大于内核处理的速度时,会有一个队列保存这些数据包。控制该队列的最大值如下参数,默认值是 1000,我们要适当调大该参数的值,比如设置为 10000:

    net.core.netdev_max_backlog = 10000

    增大 TCP 半连接队列,要同时增大下面这三个参数:

    • 增大 net.ipv4.tcp_max_syn_backlog
    • 增大 listen() 函数中的 backlog
    • 增大 net.core.somaxconn

    具体为什么是三个参数决定 TCP 半连接队列的大小,可以看这篇:可以看这篇:TCP 半连接队列和全连接队列满了会发生什么?又该如何应对?

    方式三:开启 net.ipv4.tcp_syncookies

    开启 syncookies 功能就可以在不使用 SYN 半连接队列的情况下成功建立连接,相当于绕过了 SYN 半连接来建立连接。

    具体过程:

    • 当 「 SYN 队列」满之后,后续服务端收到 SYN 包,不会丢弃,而是根据算法,计算出一个 cookie 值;
    • 将 cookie 值放到第二次握手报文的「序列号」里,然后服务端回第二次握手给客户端;
    • 服务端接收到客户端的应答报文时,服务端会检查这个 ACK 包的合法性。如果合法,将该连接对象放入到「 Accept 队列」。
    • 最后应用程序通过调用 accpet() 接口,从「 Accept 队列」取出的连接。

    可以看到,当开启了 tcp_syncookies 了,即使受到 SYN 攻击而导致 SYN 队列满时,也能保证正常的连接成功建立。

    net.ipv4.tcp_syncookies 参数主要有以下三个值:

    • 0 值,表示关闭该功能;
    • 1 值,表示仅当 SYN 半连接队列放不下时,再启用它;
    • 2 值,表示无条件开启功能;

    那么在应对 SYN 攻击时,只需要设置为 1 即可。

    $ echo 1 > /proc/sys/net/ipv4/tcp_syncookies

    方式四:减少 SYN+ACK 重传次数

    当服务端受到 SYN 攻击时,就会有大量处于 SYN_REVC 状态的 TCP 连接,处于这个状态的 TCP 会重传 SYN+ACK ,当重传超过次数达到上限后,就会断开连接。

    那么针对 SYN 攻击的场景,我们可以减少 SYN-ACK 的重传次数,以加快处于 SYN_REVC 状态的 TCP 连接断开。

    SYN-ACK 报文的最大重传次数由 tcp_synack_retries内核参数决定(默认值是 5 次),比如将 tcp_synack_retries 减少到 2 次:

    $ echo 2 > /proc/sys/net/ipv4/tcp_synack_retries

    TCP连接断开

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

    双方都可以主动断开连接,断开连接后主机中的「资源」将被释放,四次挥手的过程如下图:

    • 客户端打算关闭连接,此时会发送一个TCP首部FIN标志位为1的报文,也即FIN报文,之后客户端进入FIN_WAIT_1状态
    • 服务端收到该报文后,就像客户端发送ack应答报文,接着服务端进入CLOSE_WAIT状态
    • 客户端接收到服务端的ACK应答报文后,之后进入FIN_WAIT_2状态
    • 等待服务端处理完数据后,也向客户端发送FIN报文,之后服务端进入LAST_ACK状态
    • 客户端收到服务端的 FIN 报文后,回一个 ACK 应答报文,之后进入 TIME_WAIT 状态
    • 服务端收到了 ACK 应答报文后,就进入了 CLOSE 状态,至此服务端已经完成连接的关闭。
    • 客户端在经过 2MSL 一段时间后,自动进入 CLOSE 状态,至此客户端也完成连接的关闭。

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

    这里一点需要注意是:主动关闭连接的,才有 TIME_WAIT 状态。

    为什么需要四次挥手?

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

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

    从上面过程可知,服务端通常需要等待完成数据的发送和处理,所以服务端的 ACKFIN 一般都会分开发送,因此是需要四次挥手。

    但是在特定情况下,四次挥手是可以变成三次挥手的,具体情况可以看这篇:TCP 四次挥手,可以变成三次吗?

    为什么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 状态的连接可以应对。

    为什么不是 4 或者 8 MSL 的时长呢?你可以想象一个丢包率达到百分之一的糟糕网络,连续两次丢包的概率只有万分之一,这个概率实在是太小了,忽略它比解决它更具性价比。

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

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

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

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

    如果要修改 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 没有等待时间或时间过短,被延迟的数据包抵达后会发生什么呢?

    如上图:

    • 服务端在关闭连接之前发送的 SEQ = 301 报文,被网络延迟了。
    • 接着,服务端以相同的四元组重新打开了新连接,前面被延迟的 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 报文就直接进入 CLOSE 状态,如果该 ACK 报文丢失了,服务端则重传的 FIN 报文,而这时客户端已经进入到关闭状态了,在收到服务端重传的 FIN 报文后,就会回 RST 报文。

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

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

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

    TIME_WAIT 过多有什么危害?

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

    • 第一是占用系统资源,比如文件描述符、内存资源、CPU 资源、线程资源等;
    • 第二是占用端口资源,端口资源也是有限的,一般可以开启的端口为 32768~61000,也可以通过 net.ipv4.ip_local_port_range参数指定范围。

    客户端和服务端 TIME_WAIT 过多,造成的影响是不同的。

    如果客户端(主动发起关闭连接方)的 TIME_WAIT 状态过多,占满了所有端口资源,那么就无法对「目的 IP+ 目的 PORT」都一样的服务端发起连接了,但是被使用的端口,还是可以继续对另外一个服务端发起连接的。具体可以看我这篇文章:客户端的端口可以重复使用吗?(opens new window)

    因此,客户端(发起连接方)都是和「目的 IP+ 目的 PORT 」都一样的服务端建立连接的话,当客户端的 TIME_WAIT 状态连接过多的话,就会受端口资源限制,如果占满了所有端口资源,那么就无法再跟「目的 IP+ 目的 PORT」都一样的服务端建立连接了。

    不过,即使是在这种场景下,只要连接的是不同的服务端,端口是可以重复使用的,所以客户端还是可以向其他服务端发起连接的,这是因为内核在定位一个连接的时候,是通过四元组(源IP、源端口、目的IP、目的端口)信息来定位的,并不会因为客户端的端口一样,而导致连接冲突。

    如果服务端(主动发起关闭连接方)的 TIME_WAIT 状态过多,并不会导致端口资源受限,因为服务端只监听一个端口,而且由于一个四元组唯一确定一个 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 长连接超时
    • 第三个场景: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 函数关闭连接,那么就无法发出 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,在前期 read view 阶段就能发现的了。

    第二个原因: 第 3 步没有做,有新连接到来时没有调用 accpet 获取该连接的 socket,导致当有大量的客户端主动断开了连接,而服务端没机会对这些 socket 调用 close 函数,从而导致服务端出现大量 CLOSE_WAIT 状态的连接。

    发生这种情况可能是因为服务端在执行 accpet 函数之前,代码卡在某一个逻辑或者提前抛出了异常。

    第三个原因:第 4 步没有做,通过 accpet 获取已连接的 socket 后,没有将其注册到 epoll,导致后续收到 FIN 报文的时候,服务端没办法感知这个事件,那服务端就没机会调用 close 函数了。

    发生这种情况可能是因为服务端在将已连接的 socket 注册到 epoll 之前,代码卡在某一个逻辑或者提前抛出了异常。之前看到过别人解决 close_wait 问题的实践文章,感兴趣的可以看看:一次 Netty 代码不健壮导致的大量 CLOSE_WAIT 连接原因分析(opens new window)

    第四个原因:第 6 步没有做,当发现客户端关闭连接后,服务端没有执行 close 函数,可能是因为代码漏处理,或者是在执行 close 函数之前,代码卡在某一个逻辑,比如发生死锁等等。

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

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

    这里需要注意的是,服务端调用 accept 时,连接成功了会返回一个已完成连接的 socket,后续用来传输数据。

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

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

    Last modification:October 17, 2023
    如果觉得我的文章对你有用,请随意赞赏