为什么单独开辟TCP的学习笔记,因为它实在太重要了。

1、TCP 基本认识

瞧瞧 TCP 头格式
我们先来看看 TCP 头的格式,标注颜色的表示与本文关联比较大的字段,其他字段不做详细阐述。
计算机网络之TCP - 图1
序列号:在建立连接时由计算机生成的随机数作为其初始值,通过 SYN 包传给接收端主机,每发送一次数据,就「累加」一次该「数据字节数」的大小。用来解决网络包乱序问题。
确认应答号:指下一次「期望」收到的数据的序列号,发送端收到这个确认应答以后可以认为在这个序号以前的数据都已经被正常接收。用来解决丢包的问题。
控制位:

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

为什么需要 TCP 协议? TCP 工作在哪一层?
IP 层是「不可靠」的,它不保证网络包的交付、不保证网络包的按序交付、也不保证网络包中的数据的完整性。
计算机网络之TCP - 图2
如果需要保障网络数据包的可靠性,那么就需要由上层(传输层)的 TCP 协议来负责。
因为 TCP 是一个工作在传输层可靠数据传输的服务,它能确保接收端接收的网络包是无损坏、无间隔、非冗余和按序的。
什么是 TCP ?
TCP 是面向连接的、可靠的、基于字节流的传输层通信协议。
计算机网络之TCP - 图3

  • 面向连接:一定是「一对一」才能连接,不能像 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 - 图4
所以我们可以知道,建立一个 TCP 连接是需要客户端与服务器端达成上述三个信息的共识。

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

如何唯一确定一个 TCP 连接呢?
TCP 四元组可以唯一的确定一个连接,四元组包括如下:

  • 源地址
  • 源端口
  • 目的地址
  • 目的端口

计算机网络之TCP - 图5
源地址和目的地址的字段(32位)是在 IP 头部中,作用是通过 IP 协议发送报文给对方主机。
源端口和目的端口的字段(16位)是在 TCP 头部中,作用是告诉 TCP 协议应该把报文发给哪个进程。
有一个 IP 的服务器监听了一个端口,它的 TCP 的最大连接数是多少?
服务器通常固定在某个本地端口上监听,等待客户端的连接请求。
因此,客户端 IP 和 端口是可变的,其理论值计算公式如下:
计算机网络之TCP - 图6
对 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 的头部格式如下:
计算机网络之TCP - 图7

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

TCP 和 UDP 区别:
1. 连接

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

2. 服务对象

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

3. 可靠性

  • TCP 是可靠交付数据的,数据可以无差错、不丢失、不重复、按需到达。
  • UDP 是尽最大努力交付,不保证可靠交付数据。

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本身的处理既简单又高效,因此经常用于:

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

为什么 UDP 头部没有「首部长度」字段,而 TCP 头部有「首部长度」字段呢?
原因是 TCP 有可变长的「选项」字段,而 UDP 头部长度则是不会变化的,无需多一个字段去记录 UDP 的首部长度。
为什么 UDP 头部有「包长度」字段,而 TCP 头部则没有「包长度」字段呢?
先说说 TCP 是如何计算负载数据长度:
计算机网络之TCP - 图8
其中 IP 总长度 和 IP 首部长度,在 IP 首部格式是已知的。TCP 首部长度,则是在 TCP 首部格式已知的,所以就可以求得 TCP 数据的长度。
大家这时就奇怪了问:“ UDP 也是基于 IP 层的呀,那 UDP 的数据长度也可以通过这个公式计算呀? 为何还要有「包长度」呢?”
这么一问,确实感觉 UDP 「包长度」是冗余的。
因为为了网络设备硬件设计和处理方便,首部长度需要是 4字节的整数倍。
如果去掉 UDP 「包长度」字段,那 UDP 首部长度就不是 4 字节的整数倍了,所以小林觉得这可能是为了补全 UDP 首部长度是 4 字节的整数倍,才补充了「包长度」字段。

2、TCP 连接建立

TCP 三次握手过程和状态变迁
TCP 是面向连接的协议,所以使用 TCP 前必须先建立连接,而建立连接是通过三次握手来进行的。三次握手的过程如下图:
image.png
第一次:客户端我要准备给你发数据了
第二次:服务端我准备好了,你发吧
第三次:好的我发给你了。

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

计算机网络之TCP - 图10

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

计算机网络之TCP - 图11

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

计算机网络之TCP - 图12

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

从上面的过程可以发现第三次握手是可以携带数据的,前两次握手是不可以携带数据的,这也是面试常问的题。
一旦完成三次握手,双方都处于 ESTABLISHED 状态,此时连接就已建立完成,客户端和服务端就可以相互发送数据了。
如何在 Linux 系统中查看 TCP 状态?
TCP 的连接状态查看,在 Linux 可以通过 netstat -napt 命令查看。
计算机网络之TCP - 图13
为什么是三次握手?不是两次、四次?
相信大家比较常回答的是:“因为三次握手才能保证双方具有接收和发送的能力。”
这回答是没问题,但这回答是片面的,并没有说出主要的原因。
在前面我们知道了什么是 TCP 连接

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

所以,重要的是为什么三次握手才可以初始化Socket、序列号和窗口大小并建立 TCP 连接。
接下来,以三个方面分析三次握手的原因:

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

3、TCP 连接断开

计算机网络之TCP - 图14

先说一下我个人的简答理解:
四次挥手简单理解
1、客户端:我要关闭连接了(发送FIN)
2、服务器:好的我知道了(回一个ACK) 有可能继续处理数据和发送
3、服务器:我这边处理完事了,不发送数据了(发送FIN)
4、客户端:好的那我知道了(回一个ACK)进入TIME_WAIT 状态 等待2MSL也就是60秒(Linux系统)才进入关闭状态

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

你可以看到,每个方向都需要一个 FIN 和一个 ACK,因此通常被称为四次挥手
这里一点需要注意是:主动关闭连接的,才有 TIME_WAIT 状态。
为什么挥手需要四次?
再来回顾下四次挥手双方发 FIN 包的过程,就能理解为什么需要四次了。

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

从上面过程可知,服务端通常需要等待完成数据的发送和处理,所以服务端的 ACK 和 FIN 一般都会分开发送,从而比三次握手导致多了一次。
第一次挥手丢失了,会发生什么?
当客户端(主动关闭方)调用 close 函数后,就会向服务端发送 FIN 报文,试图与服务端断开连接,此时客户端的连接进入到 FIN_WAIT_1 状态。
正常情况下,如果能及时收到服务端(被动关闭方)的 ACK,则会很快变为 FIN_WAIT2状态。
如果第一次挥手丢失了,那么客户端迟迟收不到被动方的 ACK 的话,也就会触发超时重传机制,重传 FIN 报文,重发次数由 tcp_orphan_retries 参数控制。
当客户端重传 FIN 报文的次数超过 tcp_orphan_retries 后,就不再发送 FIN 报文,直接进入到 close 状态。
第二次挥手丢失了,会发生什么?
当服务端收到客户端的第一次挥手后,就会先回一个 ACK 确认报文,此时服务端的连接进入到 CLOSE_WAIT 状态。
在前面我们也提了,ACK 报文是不会重传的,所以如果服务端的第二次挥手丢失了,客户端就会触发超时重传机制,重传 FIN 报文,直到收到服务端的第二次挥手,或者达到最大的重传次数。
这里提一下,当客户端收到第二次挥手,也就是收到服务端发送的 ACK 报文后,客户端就会处于 FIN_WAIT2 状态,在这个状态需要等服务端发送第三次挥手,也就是服务端的 FIN 报文。
对于 close 函数关闭的连接,由于无法再发送和接收数据,所以FIN_WAIT2 状态不可以持续太久,而 tcp_fin_timeout 控制了这个状态下连接的持续时长,默认值是 60 秒。
这意味着对于调用 close 关闭的连接,如果在 60 秒后还没有收到 FIN 报文,客户端(主动关闭方)的连接就会直接关闭。
但是注意,如果主动关闭方使用 shutdown 函数关闭连接且指定只关闭发送方向,而接收方向并没有关闭,那么意味着主动关闭方还是可以接收数据的。如果主动关闭方一直没收到第三次挥手,那么主动关闭方的连接将会一直处于 FIN_WAIT2 状态(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 状态的连接可以应对。
为什么不是 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 (60HZ) / 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 小时

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

4、Socket 编程

针对 TCP 应该如何 Socket 编程?
计算机网络之TCP - 图15

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

这里需要注意的是,服务端调用 accept 时,连接成功了会返回一个已完成连接的 socket,后续用来传输数据。
所以,监听的 socket 和真正用来传送数据的 socket,是「两个」 socket,一个叫作监听 socket,一个叫作已完成连接 socket
成功连接建立之后,双方开始通过 read 和 write 函数来读写数据,就像往一个文件流里面写东西一样。
listen 时候参数 backlog 的意义?
Linux内核中会维护两个队列:

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

计算机网络之TCP - 图16
int listen (int socketfd, int backlog)

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

在早期 Linux 内核 backlog 是 SYN 队列大小,也就是未完成的队列大小。
在 Linux 内核 2.2 之后,backlog 变成 accept 队列,也就是已完成连接建立的队列长度,所以现在通常认为 backlog 是 accept 队列。
但是上限值是内核参数 somaxconn 的大小,也就说 accpet 队列长度 = min(backlog, somaxconn)。
想详细了解 TCP 半连接队列和全连接队列,可以看这篇:TCP 半连接队列和全连接队列满了会发生什么?又该如何应对?(opens new window)
accept 发生在三次握手的哪一步?
我们先看看客户端连接服务端时,发送了什么?
计算机网络之TCP - 图17

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

从上面的描述过程,我们可以得知客户端 connect 成功返回是在第二次握手,服务端 accept 成功返回是在三次握手成功之后。
客户端调用 close 了,连接是断开的流程是什么?
我们看看客户端主动调用了 close,会发生什么?
计算机网络之TCP - 图18

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

    5、TCP 重传、滑动窗口、流量控制、拥塞控制

    计算机网络之TCP - 图19

    5.1 超时重传

    TCP 实现可靠传输的方式之一,是通过序列号与确认应答。
    在 TCP 中,当发送端的数据到达接收主机时,接收端主机会返回一个确认应答消息,表示已收到消息。
    计算机网络之TCP - 图20
    但在错综复杂的网络,并不一定能如上图那么顺利能正常的数据传输,万一数据在传输过程中丢失了呢?
    所以 TCP 针对数据包丢失的情况,会用重传机制解决。
    接下来说说常见的重传机制:

  • 超时重传
    重传机制的其中一个方式,就是在发送数据时,设定一个定时器,当超过指定的时间后,没有收到对方的 ACK 确认应答报文,就会重发该数据,也就是我们常说的超时重传

  • 快速重传
    TCP 还有另外一种快速重传(Fast Retransmit)机制,它不以时间为驱动,而是以数据驱动重传
    计算机网络之TCP - 图21

在上图,发送方发出了 1,2,3,4,5 份数据:

  • 第一份 Seq1 先送到了,于是就 Ack 回 2;
  • 结果 Seq2 因为某些原因没收到,Seq3 到达了,于是还是 Ack 回 2;
  • 后面的 Seq4 和 Seq5 都到了,但还是 Ack 回 2,因为 Seq2 还是没有收到;
  • 发送端收到了三个 Ack = 2 的确认,知道了 Seq2 还没有收到,就会在定时器过期之前,重传丢失的 Seq2。
  • 最后,收到了 Seq2,此时因为 Seq3,Seq4,Seq5 都收到了,于是 Ack 回 6 。

所以,快速重传的工作方式是当收到三个相同的 ACK 报文时,会在定时器过期之前,重传丢失的报文段。
快速重传机制只解决了一个问题,就是超时时间的问题,但是它依然面临着另外一个问题。就是重传的时候,是重传之前的一个,还是重传所有的问题。
比如对于上面的例子,是重传 Seq2 呢?还是重传 Seq2、Seq3、Seq4、Seq5 呢?因为发送端并不清楚这连续的三个 Ack 2 是谁传回来的。
根据 TCP 不同的实现,以上两种情况都是有可能的。可见,这是一把双刃剑。
为了解决不知道该重传哪些 TCP 报文,于是就有 SACK 方法。

  • SACK
  • D-SACK

    5.2 滑动窗口

    我们都知道 TCP 是每发送一个数据,都要进行一次确认应答。当上一个数据包收到了应答了, 再发送下一个。
    这个模式就有点像我和你面对面聊天,你一句我一句。但这种方式的缺点是效率比较低的。
    如果你说完一句话,我在处理其他事情,没有及时回复你,那你不是要干等着我做完其他事情后,我回复你,你才能说下一句话,很显然这不现实。
    计算机网络之TCP - 图22
    所以,这样的传输方式有一个缺点:数据包的往返时间越长,通信的效率就越低
    为解决这个问题,TCP 引入了窗口这个概念。即使在往返时间较长的情况下,它也不会降低网络通信的效率。
    那么有了窗口,就可以指定窗口大小,窗口大小就是指无需等待确认应答,而可以继续发送数据的最大值
    窗口的实现实际上是操作系统开辟的一个缓存空间,发送方主机在等到确认应答返回之前,必须在缓冲区中保留已发送的数据。如果按期收到确认应答,此时数据就可以从缓存区清除。

接收窗口和发送窗口的大小是相等的吗?
并不是完全相等,接收窗口的大小是约等于发送窗口的大小的。
因为滑动窗口并不是一成不变的。比如,当接收方的应用进程读取数据的速度非常快的话,这样的话接收窗口可以很快的就空缺出来。那么新的接收窗口大小,是通过 TCP 报文中的 Windows 字段来告诉发送方。那么这个传输过程是存在时延的,所以接收窗口和发送窗口是约等于的关系

5.3、流量控制

发送方不能无脑的发数据给接收方,要考虑接收方处理能力。
如果一直无脑的发数据给对方,但对方处理不过来,那么就会导致触发重发机制,从而导致网络流量的无端的浪费。
为了解决这种现象发生,TCP 提供一种机制可以让「发送方」根据「接收方」的实际接收能力控制发送的数据量,这就是所谓的流量控制。
下面举个栗子,为了简单起见,假设以下场景:

  • 客户端是接收方,服务端是发送方
  • 假设接收窗口和发送窗口相同,都为 200
  • 假设两个设备在整个传输过程中都保持相同的窗口大小,不受外界影响

    5.4、拥塞控制

    为什么要有拥塞控制呀,不是有流量控制了吗?
    前面的流量控制是避免「发送方」的数据填满「接收方」的缓存,但是并不知道网络的中发生了什么。
    一般来说,计算机网络都处在一个共享的环境。因此也有可能会因为其他主机之间的通信使得网络拥堵。
    在网络出现拥堵时,如果继续发送大量数据包,可能会导致数据包时延、丢失等,这时 TCP 就会重传数据,但是一重传就会导致网络的负担更重,于是会导致更大的延迟以及更多的丢包,这个情况就会进入恶性循环被不断地放大….
    所以,TCP 不能忽略网络上发生的事,它被设计成一个无私的协议,当网络发送拥塞时,TCP 会自我牺牲,降低发送的数据量。
    于是,就有了拥塞控制,控制的目的就是避免「发送方」的数据填满整个网络。
    为了在「发送方」调节所要发送数据的量,定义了一个叫做「拥塞窗口」的概念。
    什么是拥塞窗口?和发送窗口有什么关系呢?
    拥塞窗口 cwnd是发送方维护的一个的状态变量,它会根据网络的拥塞程度动态变化的
    我们在前面提到过发送窗口 swnd 和接收窗口 rwnd 是约等于的关系,那么由于加入了拥塞窗口的概念后,此时发送窗口的值是swnd = min(cwnd, rwnd),也就是拥塞窗口和接收窗口中的最小值。
    拥塞窗口 cwnd 变化的规则:

  • 只要网络中没有出现拥塞,cwnd 就会增大;

  • 但网络中出现了拥塞,cwnd 就减少;

那么怎么知道当前网络是否出现了拥塞呢?
其实只要「发送方」没有在规定时间内接收到 ACK 应答报文,也就是发生了超时重传,就会认为网络出现了用拥塞。
拥塞控制有哪些控制算法?
拥塞控制主要是四个算法:

  • 慢启动
  • 拥塞避免
  • 拥塞发生
  • 快速恢复

    6、什么是 TCP 半连接队列和全连接队列?

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

  • 半连接队列,也称 SYN 队列;

  • 全连接队列,也称 accept 队列;

服务端收到客户端发起的 SYN 请求后,内核会把该连接存储到半连接队列,并向客户端响应 SYN+ACK,接着客户端会返回 ACK,服务端收到第三次握手的 ACK 后,内核会把连接从半连接队列移除,然后创建新的完全的连接,并将其添加到 accept 队列,等待进程调用 accept 函数时把连接取出来。

计算机网络之TCP - 图23
不管是半连接队列还是全连接队列,都有最大长度限制,超过限制时,内核会直接丢弃,或返回 RST 包。


#实战 - TCP 全连接队列溢出

如何知道应用程序的 TCP 全连接队列大小?
在服务端可以使用 ss 命令,来查看 TCP 全连接队列的情况:
但需要注意的是 ss 命令获取的 Recv-Q/Send-Q 在「LISTEN 状态」和「非 LISTEN 状态」所表达的含义是不同的。从下面的内核代码可以看出区别:
计算机网络之TCP - 图24
在「LISTEN 状态」时,Recv-Q/Send-Q 表示的含义如下:
计算机网络之TCP - 图25

  • Recv-Q:当前全连接队列的大小,也就是当前已完成三次握手并等待服务端 accept() 的 TCP 连接;
  • Send-Q:当前全连接最大队列长度,上面的输出结果说明监听 8088 端口的 TCP 服务,最大全连接长度为 128;

在「非 LISTEN 状态」时,Recv-Q/Send-Q 表示的含义如下:
计算机网络之TCP - 图26

  • Recv-Q:已收到但未被应用进程读取的字节数;
  • Send-Q:已发送但未收到确认的字节数;

如何模拟 TCP 全连接队列溢出的场景?
计算机网络之TCP - 图27
实验环境:

  • 客户端和服务端都是 CentOs 6.5 ,Linux 内核版本 2.6.32
  • 服务端 IP 192.168.3.200,客户端 IP 192.168.3.100
  • 服务端是 Nginx 服务,端口为 8088

这里先介绍下 wrk 工具,它是一款简单的 HTTP 压测工具,它能够在单机多核 CPU 的条件下,使用系统自带的高性能 I/O 机制,通过多线程和事件模式,对目标机器产生大量的负载。
本次模拟实验就使用 wrk 工具来压力测试服务端,发起大量的请求,一起看看服务端 TCP 全连接队列满了会发生什么?有什么观察指标?
客户端执行 wrk 命令对服务端发起压力测试,并发 3 万个连接:
计算机网络之TCP - 图28
在服务端可以使用 ss 命令,来查看当前 TCP 全连接队列的情况:
计算机网络之TCP - 图29
其间共执行了两次 ss 命令,从上面的输出结果,可以发现当前 TCP 全连接队列上升到了 129 大小,超过了最大 TCP 全连接队列。
当超过了 TCP 最大全连接队列,服务端则会丢掉后续进来的 TCP 连接,丢掉的 TCP 连接的个数会被统计起来,我们可以使用 netstat -s 命令来查看:
计算机网络之TCP - 图30
上面看到的 41150 times ,表示全连接队列溢出的次数,注意这个是累计值。可以隔几秒钟执行下,如果这个数字一直在增加的话肯定全连接队列偶尔满了。
从上面的模拟结果,可以得知,当服务端并发处理大量请求时,如果 TCP 全连接队列过小,就容易溢出。发生 TCP 全连接队溢出的时候,后续的请求就会被丢弃,这样就会出现服务端请求数量上不去的现象。
计算机网络之TCP - 图31
Linux 有个参数可以指定当 TCP 全连接队列满了会使用什么策略来回应客户端。
实际上,丢弃连接只是 Linux 的默认行为,我们还可以选择向客户端发送 RST 复位报文,告诉客户端连接已经建立失败。
计算机网络之TCP - 图32
tcp_abort_on_overflow 共有两个值分别是 0 和 1,其分别表示:

  • 0 :如果全连接队列满了,那么 server 扔掉 client 发过来的 ack ;
  • 1 :如果全连接队列满了,server 发送一个 reset 包给 client,表示废掉这个握手过程和这个连接;

如果要想知道客户端连接不上服务端,是不是服务端 TCP 全连接队列满的原因,那么可以把 tcp_abort_on_overflow 设置为 1,这时如果在客户端异常中可以看到很多 connection reset by peer 的错误,那么就可以证明是由于服务端 TCP 全连接队列溢出的问题。
通常情况下,应当把 tcp_abort_on_overflow 设置为 0,因为这样更有利于应对突发流量。
举个例子,当 TCP 全连接队列满导致服务器丢掉了 ACK,与此同时,客户端的连接状态却是 ESTABLISHED,进程就在建立好的连接上发送请求。只要服务器没有为请求回复 ACK,请求就会被多次重发。如果服务器上的进程只是短暂的繁忙造成 accept 队列满,那么当 TCP 全连接队列有空位时,再次接收到的请求报文由于含有 ACK,仍然会触发服务器端成功建立连接。
所以,tcp_abort_on_overflow 设为 0 可以提高连接建立的成功率,只有你非常肯定 TCP 全连接队列会长期溢出时,才能设置为 1 以尽快通知客户端。
如何增大 TCP 全连接队列呢?
是的,当发现 TCP 全连接队列发生溢出的时候,我们就需要增大该队列的大小,以便可以应对客户端大量的请求。
TCP 全连接队列的最大值取决于 somaxconn 和 backlog 之间的最小值,也就是 min(somaxconn, backlog)。从下面的 Linux 内核代码可以得知:
计算机网络之TCP - 图33

  • somaxconn 是 Linux 内核的参数,默认值是 128,可以通过 /proc/sys/net/core/somaxconn 来设置其值;
  • backlog 是 listen(int sockfd, int backlog) 函数中的 backlog 大小,Nginx 默认值是 511,可以通过修改配置文件设置其长度;

前面模拟测试中,我的测试环境:

  • somaxconn 是默认值 128;
  • Nginx 的 backlog 是默认值 511

所以测试环境的 TCP 全连接队列最大值为 min(128, 511),也就是 128,可以执行 ss 命令查看:
计算机网络之TCP - 图34
现在我们重新压测,把 TCP 全连接队列搞大,把 somaxconn 设置成 5000:
计算机网络之TCP - 图35
接着把 Nginx 的 backlog 也同样设置成 5000:
计算机网络之TCP - 图36
最后要重启 Nginx 服务,因为只有重新调用 listen() 函数 TCP 全连接队列才会重新初始化。
重启完后 Nginx 服务后,服务端执行 ss 命令,查看 TCP 全连接队列大小:
计算机网络之TCP - 图37
从执行结果,可以发现 TCP 全连接最大值为 5000。
增大 TCP 全连接队列后,继续压测
客户端同样以 3 万个连接并发发送请求给服务端:
计算机网络之TCP - 图38
服务端执行 ss 命令,查看 TCP 全连接队列使用情况:
计算机网络之TCP - 图39
从上面的执行结果,可以发现全连接队列使用增长的很快,但是一直都没有超过最大值,所以就不会溢出,那么 netstat -s 就不会有 TCP 全连接队列溢出个数的显示:
计算机网络之TCP - 图40
说明 TCP 全连接队列最大值从 128 增大到 5000 后,服务端抗住了 3 万连接并发请求,也没有发生全连接队列溢出的现象了。
如果持续不断地有连接因为 TCP 全连接队列溢出被丢弃,就应该调大 backlog 以及 somaxconn 参数。


#实战 - TCP 半连接队列溢出

如何查看 TCP 半连接队列长度?
很遗憾,TCP 半连接队列长度的长度,没有像全连接队列那样可以用 ss 命令查看。
但是我们可以抓住 TCP 半连接的特点,就是服务端处于 SYN_RECV 状态的 TCP 连接,就是 TCP 半连接队列。
于是,我们可以使用如下命令计算当前 TCP 半连接队列长度:
计算机网络之TCP - 图41
如何模拟 TCP 半连接队列溢出场景?
模拟 TCP 半连接溢出场景不难,实际上就是对服务端一直发送 TCP SYN 包,但是不回第三次握手 ACK,这样就会使得服务端有大量的处于 SYN_RECV 状态的 TCP 连接。
这其实也就是所谓的 SYN 洪泛、SYN 攻击、DDos 攻击。
计算机网络之TCP - 图42
实验环境:

  • 客户端和服务端都是 CentOs 6.5 ,Linux 内核版本 2.6.32
  • 服务端 IP 192.168.3.200,客户端 IP 192.168.3.100
  • 服务端是 Nginx 服务,端口为 8088

注意:本次模拟实验是没有开启 tcp_syncookies,关于 tcp_syncookies 的作用,后续会说明。
本次实验使用 hping3 工具模拟 SYN 攻击:
计算机网络之TCP - 图43
当服务端受到 SYN 攻击后,连接服务端 ssh 就会断开了,无法再连上。只能在服务端主机上执行查看当前 TCP 半连接队列大小:
计算机网络之TCP - 图44
同时,还可以通过 netstat -s 观察半连接队列溢出的情况:
计算机网络之TCP - 图45
上面输出的数值是累计值,表示共有多少个 TCP 连接因为半连接队列溢出而被丢弃。隔几秒执行几次,如果有上升的趋势,说明当前存在半连接队列溢出的现象
大部分人都说 tcp_max_syn_backlog 是指定半连接队列的大小,是真的吗?
很遗憾,半连接队列的大小并不单单只跟 tcp_max_syn_backlog 有关系。
上面模拟 SYN 攻击场景时,服务端的 tcp_max_syn_backlog 的默认值如下:
计算机网络之TCP - 图46
但是在测试的时候发现,服务端最多只有 256 个半连接队列,而不是 512,所以半连接队列的最大长度不一定由 tcp_max_syn_backlog 值决定的
接下来,走进 Linux 内核的源码,来分析 TCP 半连接队列的最大值是如何决定的。
TCP 第一次握手(收到 SYN 包)的 Linux 内核代码如下,其中缩减了大量的代码,只需要重点关注 TCP 半连接队列溢出的处理逻辑:
计算机网络之TCP - 图47
从源码中,我可以得出共有三个条件因队列长度的关系而被丢弃的:
计算机网络之TCP - 图48

  1. 如果半连接队列满了,并且没有开启 tcp_syncookies,则会丢弃;
  2. 若全连接队列满了,且没有重传 SYN+ACK 包的连接请求多于 1 个,则会丢弃;
  3. 如果没有开启 tcp_syncookies,并且 max_syn_backlog 减去 当前半连接队列长度小于 (max_syn_backlog >> 2),则会丢弃;

关于 tcp_syncookies 的设置,后面在详细说明,可以先给大家说一下,开启 tcp_syncookies 是缓解 SYN 攻击其中一个手段。
接下来,我们继续跟一下检测半连接队列是否满的函数 inet_csk_reqsk_queue_is_full 和 检测全连接队列是否满的函数 sk_acceptq_is_full :
计算机网络之TCP - 图49
从上面源码,可以得知:

  • 连接队列的最大值是 sk_max_ack_backlog 变量,sk_max_ack_backlog 实际上是在 listen() 源码里指定的,也就是 min(somaxconn, backlog)
  • 连接队列的最大值是 max_qlen_log 变量,max_qlen_log 是在哪指定的呢?现在暂时还不知道,我们继续跟进;

我们继续跟进代码,看一下是哪里初始化了半连接队列的最大值 max_qlen_log:
计算机网络之TCP - 图50
从上面的代码中,我们可以算出 max_qlen_log 是 8,于是代入到 检测半连接队列是否满的函数 reqsk_queue_is_full :
计算机网络之TCP - 图51
也就是 qlen >> 8 什么时候为 1 就代表半连接队列满了。这计算这不难,很明显是当 qlen 为 256 时,256 >> 8 = 1。
至此,总算知道为什么上面模拟测试 SYN 攻击的时候,服务端处于 SYN_RECV 连接最大只有 256 个。
可见,半连接队列最大值不是单单由 max_syn_backlog 决定,还跟 somaxconn 和 backlog 有关系。
在 Linux 2.6.32 内核版本,它们之间的关系,总体可以概况为:
计算机网络之TCP - 图52

  • 当 max_syn_backlog > min(somaxconn, backlog) 时, 半连接队列最大值 max_qlen_log = min(somaxconn, backlog) * 2;
  • 当 max_syn_backlog < min(somaxconn, backlog) 时, 半连接队列最大值 max_qlen_log = max_syn_backlog * 2;

半连接队列最大值 max_qlen_log 就表示服务端处于 SYN_REVC 状态的最大个数吗?
依然很遗憾,并不是。
max_qlen_log 是理论半连接队列最大值,并不一定代表服务端处于 SYN_REVC 状态的最大个数。
在前面我们在分析 TCP 第一次握手(收到 SYN 包)时会被丢弃的三种条件:

  1. 如果半连接队列满了,并且没有开启 tcp_syncookies,则会丢弃;
  2. 若全连接队列满了,且没有重传 SYN+ACK 包的连接请求多于 1 个,则会丢弃;
  3. 如果没有开启 tcp_syncookies,并且 max_syn_backlog 减去 当前半连接队列长度小于 (max_syn_backlog >> 2),则会丢弃;

假设条件 1 当前半连接队列的长度 「没有超过」理论的半连接队列最大值 max_qlen_log,那么如果条件 3 成立,则依然会丢弃 SYN 包,也就会使得服务端处于 SYN_REVC 状态的最大个数不会是理论值 max_qlen_log。
似乎很难理解,我们继续接着做实验,实验见真知。
服务端环境如下:
计算机网络之TCP - 图53
配置完后,服务端要重启 Nginx,因为全连接队列最大值和半连接队列最大值是在 listen() 函数初始化。
根据前面的源码分析,我们可以计算出半连接队列 max_qlen_log 的最大值为 256:
计算机网络之TCP - 图54
客户端执行 hping3 发起 SYN 攻击:
计算机网络之TCP - 图55
服务端执行如下命令,查看处于 SYN_RECV 状态的最大个数:
计算机网络之TCP - 图56
可以发现,服务端处于 SYN_RECV 状态的最大个数并不是 max_qlen_log 变量的值。
这就是前面所说的原因:如果当前半连接队列的长度 「没有超过」理论半连接队列最大值 max_qlen_log,那么如果条件 3 成立,则依然会丢弃 SYN 包,也就会使得服务端处于 SYN_REVC 状态的最大个数不会是理论值 max_qlen_log。
我们来分析一波条件 3 :
计算机网络之TCP - 图57
从上面的分析,可以得知如果触发「当前半连接队列长度 > 192」条件,TCP 第一次握手的 SYN 包是会被丢弃的。
在前面我们测试的结果,服务端处于 SYN_RECV 状态的最大个数是 193,正好是触发了条件 3,所以处于 SYN_RECV 状态的个数还没到「理论半连接队列最大值 256」,就已经把 SYN 包丢弃了。
所以,服务端处于 SYN_RECV 状态的最大个数分为如下两种情况:

  • 如果「当前半连接队列」没超过「理论半连接队列最大值」,但是超过 max_syn_backlog - (max_syn_backlog >> 2),那么处于 SYN_RECV 状态的最大个数就是 max_syn_backlog - (max_syn_backlog >> 2);
  • 如果「当前半连接队列」超过「理论半连接队列最大值」,那么处于 SYN_RECV 状态的最大个数就是「理论半连接队列最大值」;

每个 Linux 内核版本「理论」半连接最大值计算方式会不同。
在上面我们是针对 Linux 2.6.32 版本分析的「理论」半连接最大值的算法,可能每个版本有些不同。
比如在 Linux 5.0.0 的时候,「理论」半连接最大值就是全连接队列最大值,但依然还是有队列溢出的三个条件:
计算机网络之TCP - 图58
如果 SYN 半连接队列已满,只能丢弃连接吗?
并不是这样,开启 syncookies 功能就可以在不使用 SYN 半连接队列的情况下成功建立连接,在前面我们源码分析也可以看到这点,当开启了 syncookies 功能就不会丢弃连接。
syncookies 是这么做的:服务器根据当前状态计算出一个值,放在己方发出的 SYN+ACK 报文中发出,当客户端返回 ACK 报文时,取出该值验证,如果合法,就认为连接建立成功,如下图所示。
计算机网络之TCP - 图59
syncookies 参数主要有以下三个值:

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

那么在应对 SYN 攻击时,只需要设置为 1 即可:
计算机网络之TCP - 图60
如何防御 SYN 攻击?
这里给出几种防御 SYN 攻击的方法:

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

方式一:增大半连接队列
在前面源码和实验中,得知要想增大半连接队列,我们得知不能只单纯增大 tcp_max_syn_backlog 的值,还需一同增大 somaxconn 和 backlog,也就是增大全连接队列。否则,只单纯增大 tcpmax_syn_backlog 是无效的。
增大 tcp_max_syn_backlog 和 somaxconn 的方法是修改 Linux 内核参数:
计算机网络之TCP - 图61
增大 backlog 的方式,每个 Web 服务都不同,比如 Nginx 增大 backlog 的方法如下:
计算机网络之TCP - 图62
最后,改变了如上这些参数后,要重启 Nginx 服务,因为半连接队列和全连接队列都是在 listen() 初始化的。
方式二:开启 tcpsyncookies 功能
开启 tcpsyncookies 功能的方式也很简单,修改 Linux 内核参数:
计算机网络之TCP - 图63
方式三:减少 SYN+ACK 重传次数_
当服务端受到 SYN 攻击时,就会有大量处于 SYN_REVC 状态的 TCP 连接,处于这个状态的 TCP 会重传 SYN+ACK ,当重传超过次数达到上限后,就会断开连接。
那么针对 SYN 攻击的场景,我们可以减少 SYN+ACK 的重传次数,以加快处于 SYN_REVC 状态的 TCP 连接断开。
计算机网络之TCP - 图64

回顾SYN攻击

SYN 的全称就叫 Synchronize Sequence Numbers(同步序列号)攻击属于DOS攻击的一种它利用TCP协议缺陷,通过发送大量的半连接请求,耗费CPU和内存资源。SYN攻击除了能影响主机外,还可以危害路由器、防火墙等网络系统,事实上SYN攻击并不管目标是什么系统,只要这些系统打开TCP服务就可以实施。服务器接收到连接请求(syn=j),将此信息加入半连接队列,并发送请求包给客户(syn=k,ack=j+1),此时进SYN_RECV状态。当服务器未收到客户端的确认包时,重发请求包,一直到超时,才将此条目从未连接队列删除。配合IP欺骗,SYN攻击能达到很好的效果,通常,客户端在短时间内伪造大量不存在的IP地址,向服务器不断地发送syn包,服务器回复确认包,并等待客户的确认,由于源地址是不存在的,服务器需要不断的重发直至超时,这些伪造的SYN包将长时间占用半连接队列,正常的SYN请求被丢弃,目标系统运行缓慢,严重者引起网络堵塞甚至系统瘫痪。
image.png


参考资料:
[1] 系统性能调优必知必会.陶辉.极客时间.
[2] https://www.cnblogs.com/zengkefu/p/5606696.html
[3] https://blog.cloudflare.com/syn-packet-handling-in-the-wild/


#读者问答

读者问:“咦 我比较好奇博主都是从哪里学到这些知识的呀?书籍?视频?还是多种参考资料”
你可以看我的参考文献呀,知识点我主要是在极客专栏学的,实战模拟实验和源码解析是自己瞎折腾出来的。
读者问:“syncookies 启用后就不需要半链接了?那请求的数据会存在哪里?”
syncookies = 1 时,半连接队列满后,后续的请求就不会存放到半连接队列了,而是在第二次握手的时候,服务端会计算一个 cookie 值,放入到 SYN +ACK 包中的序列号发给客户端,客户端收到后并回 ack ,服务端就会校验连接是否合法,合法就直接把连接放入到全连接队列。

7、如何优化TCP

TCP 性能的提升不仅考察 TCP 的理论知识,还考察了对于操作系统提供的内核参数的理解与应用。
TCP 协议是由操作系统实现,所以操作系统提供了不少调节 TCP 的参数。
计算机网络之TCP - 图66
如何正确有效的使用这些参数,来提高 TCP 性能是一个不那么简单事情。我们需要针对 TCP 每个阶段的问题来对症下药,而不是病急乱投医。
接下来,将以三个角度来阐述提升 TCP 的策略,分别是:

  • TCP 三次握手的性能提升;
  • TCP 四次挥手的性能提升;
  • TCP 数据传输的性能提升;

计算机网络之TCP - 图67


TCP 三次握手的性能提升

TCP 是面向连接的、可靠的、双向传输的传输层通信协议,所以在传输数据之前需要经过三次握手才能建立连接。
计算机网络之TCP - 图68
那么,三次握手的过程在一个 HTTP 请求的平均时间占比 10% 以上,在网络状态不佳、高并发或者遭遇 SYN 攻击等场景中,如果不能有效正确的调节三次握手中的参数,就会对性能产生很多的影响。
如何正确有效的使用这些参数,来提高 TCP 三次握手的性能,这就需要理解「三次握手的状态变迁」,这样当出现问题时,先用 netstat 命令查看是哪个握手阶段出现了问题,再来对症下药,而不是病急乱投医。
计算机网络之TCP - 图69
客户端和服务端都可以针对三次握手优化性能。主动发起连接的客户端优化相对简单些,而服务端需要监听端口,属于被动连接方,其间保持许多的中间状态,优化方法相对复杂一些。
所以,客户端(主动发起连接方)和服务端(被动连接方)优化的方式是不同的,接下来分别针对客户端和服务端优化。

7.1、客户端优化

三次握手建立连接的首要目的是「同步序列号」。
只有同步了序列号才有可靠传输,TCP 许多特性都依赖于序列号实现,比如流量控制、丢包重传等,这也是三次握手中的报文称为 SYN 的原因,SYN 的全称就叫 Synchronize Sequence Numbers(同步序列号)。
计算机网络之TCP - 图70
SYN_SENT 状态的优化
客户端作为主动发起连接方,首先它将发送 SYN 包,于是客户端的连接就会处于 SYN_SENT 状态。
客户端在等待服务端回复的 ACK 报文,正常情况下,服务器会在几毫秒内返回 SYN+ACK ,但如果客户端长时间没有收到 SYN+ACK 报文,则会重发 SYN 包,重发的次数由 tcp_syn_retries 参数控制,默认是 5 次:
计算机网络之TCP - 图71
通常,第一次超时重传是在 1 秒后,第二次超时重传是在 2 秒,第三次超时重传是在 4 秒后,第四次超时重传是在 8 秒后,第五次是在超时重传 16 秒后。没错,每次超时的时间是上一次的 2 倍
当第五次超时重传后,会继续等待 32 秒,如果服务端仍然没有回应 ACK,客户端就会终止三次握手。
所以,总耗时是 1+2+4+8+16+32=63 秒,大约 1 分钟左右。
计算机网络之TCP - 图72
你可以根据网络的稳定性和目标服务器的繁忙程度修改 SYN 的重传次数,调整客户端的三次握手时间上限。比如内网中通讯时,就可以适当调低重试次数,尽快把错误暴露给应用程序。

7.2、服务端优化

当服务端收到 SYN 包后,服务端会立马回复 SYN+ACK 包,表明确认收到了客户端的序列号,同时也把自己的序列号发给对方。
此时,服务端出现了新连接,状态是 SYN_RCV。在这个状态下,Linux 内核就会建立一个「半连接队列」来维护「未完成」的握手信息,当半连接队列溢出后,服务端就无法再建立新的连接。
计算机网络之TCP - 图73
SYN 攻击,攻击的是就是这个半连接队列。
如何查看由于 SYN 半连接队列已满,而被丢弃连接的情况?
我们可以通过该 netstat -s 命令给出的统计结果中, 可以得到由于半连接队列已满,引发的失败次数:
计算机网络之TCP - 图74
上面输出的数值是累计值,表示共有多少个 TCP 连接因为半连接队列溢出而被丢弃。隔几秒执行几次,如果有上升的趋势,说明当前存在半连接队列溢出的现象
如何调整 SYN 半连接队列大小?
要想增大半连接队列,不能只单纯增大 tcp_max_syn_backlog 的值,还需一同增大 somaxconn 和 backlog,也就是增大 accept 队列。否则,只单纯增大 tcp_max_syn_backlog 是无效的。
增大 tcp_max_syn_backlog 和 somaxconn 的方法是修改 Linux 内核参数:
计算机网络之TCP - 图75
增大 backlog 的方式,每个 Web 服务都不同,比如 Nginx 增大 backlog 的方法如下:
计算机网络之TCP - 图76
最后,改变了如上这些参数后,要重启 Nginx 服务,因为 SYN 半连接队列和 accept 队列都是在 listen() 初始化的。
如果 SYN 半连接队列已满,只能丢弃连接吗?
并不是这样,开启 syncookies 功能就可以在不使用 SYN 半连接队列的情况下成功建立连接
syncookies 的工作原理:服务器根据当前状态计算出一个值,放在己方发出的 SYN+ACK 报文中发出,当客户端返回 ACK 报文时,取出该值验证,如果合法,就认为连接建立成功,如下图所示。
计算机网络之TCP - 图77
syncookies 参数主要有以下三个值:

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

那么在应对 SYN 攻击时,只需要设置为 1 即可:
计算机网络之TCP - 图78
SYN_RCV 状态的优化
当客户端接收到服务器发来的 SYN+ACK 报文后,就会回复 ACK 给服务器,同时客户端连接状态从 SYN_SENT 转换为 ESTABLISHED,表示连接建立成功。
服务器端连接成功建立的时间还要再往后,等到服务端收到客户端的 ACK 后,服务端的连接状态才变为 ESTABLISHED。
如果服务器没有收到 ACK,就会重发 SYN+ACK 报文,同时一直处于 SYN_RCV 状态。
当网络繁忙、不稳定时,报文丢失就会变严重,此时应该调大重发次数。反之则可以调小重发次数。修改重发次数的方法是,调整 tcp_synack_retries 参数
计算机网络之TCP - 图79
tcp_synack_retries 的默认重试次数是 5 次,与客户端重传 SYN 类似,它的重传会经历 1、2、4、8、16 秒,最后一次重传后会继续等待 32 秒,如果服务端仍然没有收到 ACK,才会关闭连接,故共需要等待 63 秒。
服务器收到 ACK 后连接建立成功,此时,内核会把连接从半连接队列移除,然后创建新的完全的连接,并将其添加到 accept 队列,等待进程调用 accept 函数时把连接取出来。
如果进程不能及时地调用 accept 函数,就会造成 accept 队列(也称全连接队列)溢出,最终导致建立好的 TCP 连接被丢弃。
计算机网络之TCP - 图80
accept 队列已满,只能丢弃连接吗?
丢弃连接只是 Linux 的默认行为,我们还可以选择向客户端发送 RST 复位报文,告诉客户端连接已经建立失败。打开这一功能需要将 tcp_abort_on_overflow 参数设置为 1。
计算机网络之TCP - 图81
tcp_abort_on_overflow 共有两个值分别是 0 和 1,其分别表示:

  • 0 :如果 accept 队列满了,那么 server 扔掉 client 发过来的 ack ;
  • 1 :如果 accept 队列满了,server 发送一个 RST 包给 client,表示废掉这个握手过程和这个连接;

如果要想知道客户端连接不上服务端,是不是服务端 TCP 全连接队列满的原因,那么可以把 tcp_abort_on_overflow 设置为 1,这时如果在客户端异常中可以看到很多 connection reset by peer 的错误,那么就可以证明是由于服务端 TCP 全连接队列溢出的问题。
通常情况下,应当把 tcp_abort_on_overflow 设置为 0,因为这样更有利于应对突发流量。
举个例子,当 accept 队列满导致服务器丢掉了 ACK,与此同时,客户端的连接状态却是 ESTABLISHED,客户端进程就在建立好的连接上发送请求。只要服务器没有为请求回复 ACK,客户端的请求就会被多次「重发」。如果服务器上的进程只是短暂的繁忙造成 accept 队列满,那么当 accept 队列有空位时,再次接收到的请求报文由于含有 ACK,仍然会触发服务器端成功建立连接。
计算机网络之TCP - 图82
所以,tcp_abort_on_overflow 设为 0 可以提高连接建立的成功率,只有你非常肯定 TCP 全连接队列会长期溢出时,才能设置为 1 以尽快通知客户端。
如何调整 accept 队列的长度呢?
accept 队列的长度取决于 somaxconn 和 backlog 之间的最小值,也就是 min(somaxconn, backlog),其中:

  • somaxconn 是 Linux 内核的参数,默认值是 128,可以通过 net.core.somaxconn 来设置其值;
  • backlog 是 listen(int sockfd, int backlog) 函数中的 backlog 大小;

Tomcat、Nginx、Apache 常见的 Web 服务的 backlog 默认值都是 511。
如何查看服务端进程 accept 队列的长度?
可以通过 ss -ltn 命令查看:
计算机网络之TCP - 图83

  • Recv-Q:当前 accept 队列的大小,也就是当前已完成三次握手并等待服务端 accept() 的 TCP 连接;
  • Send-Q:accept 队列最大长度,上面的输出结果说明监听 8088 端口的 TCP 服务,accept 队列的最大长度为 128;

如何查看由于 accept 连接队列已满,而被丢弃的连接?
当超过了 accept 连接队列,服务端则会丢掉后续进来的 TCP 连接,丢掉的 TCP 连接的个数会被统计起来,我们可以使用 netstat -s 命令来查看:
计算机网络之TCP - 图84
上面看到的 41150 times ,表示 accept 队列溢出的次数,注意这个是累计值。可以隔几秒钟执行下,如果这个数字一直在增加的话,说明 accept 连接队列偶尔满了。
如果持续不断地有连接因为 accept 队列溢出被丢弃,就应该调大 backlog 以及 somaxconn 参数。

7.3、如何绕过三次握手?

以上我们只是在对三次握手的过程进行优化,接下来我们看看如何绕过三次握手发送数据。
三次握手建立连接造成的后果就是,HTTP 请求必须在一个 RTT(从客户端到服务器一个往返的时间)后才能发送。
计算机网络之TCP - 图85
在 Linux 3.7 内核版本之后,提供了 TCP Fast Open 功能,这个功能可以减少 TCP 连接建立的时延。
接下来说说,TCP Fast Open 功能的工作方式。
计算机网络之TCP - 图86
在客户端首次建立连接时的过程:

  1. 客户端发送 SYN 报文,该报文包含 Fast Open 选项,且该选项的 Cookie 为空,这表明客户端请求 Fast Open Cookie;
  2. 支持 TCP Fast Open 的服务器生成 Cookie,并将其置于 SYN-ACK 数据包中的 Fast Open 选项以发回客户端;
  3. 客户端收到 SYN-ACK 后,本地缓存 Fast Open 选项中的 Cookie。

所以,第一次发起 HTTP GET 请求的时候,还是需要正常的三次握手流程。
之后,如果客户端再次向服务器建立连接时的过程:

  1. 客户端发送 SYN 报文,该报文包含「数据」(对于非 TFO 的普通 TCP 握手过程,SYN 报文中不包含「数据」)以及此前记录的 Cookie;
  2. 支持 TCP Fast Open 的服务器会对收到 Cookie 进行校验:如果 Cookie 有效,服务器将在 SYN-ACK 报文中对 SYN 和「数据」进行确认,服务器随后将「数据」递送至相应的应用程序;如果 Cookie 无效,服务器将丢弃 SYN 报文中包含的「数据」,且其随后发出的 SYN-ACK 报文将只确认 SYN 的对应序列号;
  3. 如果服务器接受了 SYN 报文中的「数据」,服务器可在握手完成之前发送「数据」,这就减少了握手带来的 1 个 RTT 的时间消耗
  4. 客户端将发送 ACK 确认服务器发回的 SYN 以及「数据」,但如果客户端在初始的 SYN 报文中发送的「数据」没有被确认,则客户端将重新发送「数据」;
  5. 此后的 TCP 连接的数据传输过程和非 TFO 的正常情况一致。

所以,之后发起 HTTP GET 请求的时候,可以绕过三次握手,这就减少了握手带来的 1 个 RTT 的时间消耗。
开启了 TFO 功能,cookie 的值是存放到 TCP option 字段里的:
计算机网络之TCP - 图87
注:客户端在请求并存储了 Fast Open Cookie 之后,可以不断重复 TCP Fast Open 直至服务器认为 Cookie 无效(通常为过期)。
Linux 下怎么打开 TCP Fast Open 功能呢?
在 Linux 系统中,可以通过设置 tcp_fastopn 内核参数,来打开 Fast Open 功能
计算机网络之TCP - 图88
tcp_fastopn 各个值的意义:

  • 0 关闭
  • 1 作为客户端使用 Fast Open 功能
  • 2 作为服务端使用 Fast Open 功能
  • 3 无论作为客户端还是服务器,都可以使用 Fast Open 功能

TCP Fast Open 功能需要客户端和服务端同时支持,才有效果。

小结

本小结主要介绍了关于优化 TCP 三次握手的几个 TCP 参数。
计算机网络之TCP - 图89
客户端的优化
当客户端发起 SYN 包时,可以通过 tcp_syn_retries 控制其重传的次数。
服务端的优化
当服务端 SYN 半连接队列溢出后,会导致后续连接被丢弃,可以通过 netstat -s 观察半连接队列溢出的情况,如果 SYN 半连接队列溢出情况比较严重,可以通过 tcp_max_syn_backlog、somaxconn、backlog 参数来调整 SYN 半连接队列的大小。
服务端回复 SYN+ACK 的重传次数由 tcp_synack_retries 参数控制。如果遭受 SYN 攻击,应把 tcp_syncookies 参数设置为 1,表示仅在 SYN 队列满后开启 syncookie 功能,可以保证正常的连接成功建立。
服务端收到客户端返回的 ACK,会把连接移入 accpet 队列,等待进行调用 accpet() 函数取出连接。
可以通过 ss -lnt 查看服务端进程的 accept 队列长度,如果 accept 队列溢出,系统默认丢弃 ACK,如果可以把 tcp_abort_on_overflow 设置为 1 ,表示用 RST 通知客户端连接建立失败。
如果 accpet 队列溢出严重,可以通过 listen 函数的 backlog 参数和 somaxconn 系统参数提高队列大小,accept 队列长度取决于 min(backlog, somaxconn)。
绕过三次握手
TCP Fast Open 功能可以绕过三次握手,使得 HTTP 请求减少了 1 个 RTT 的时间,Linux 下可以通过 tcp_fastopen 开启该功能,同时必须保证服务端和客户端同时支持。

TCP 四次挥手的性能提升

接下来,我们一起看看针对 TCP 四次挥手关闭连接时,如何优化性能。
在开始之前,我们得先了解四次挥手状态变迁的过程。
客户端和服务端双方都可以主动断开连接,通常先关闭连接的一方称为主动方,后关闭连接的一方称为被动方。
计算机网络之TCP - 图90
可以看到,四次挥手过程只涉及了两种报文,分别是 FIN 和 ACK

  • FIN 就是结束连接的意思,谁发出 FIN 报文,就表示它将不会再发送任何数据,关闭这一方向上的传输通道;
  • ACK 就是确认的意思,用来通知对方:你方的发送通道已经关闭;

四次挥手的过程:

  • 当主动方关闭连接时,会发送 FIN 报文,此时发送方的 TCP 连接将从 ESTABLISHED 变成 FIN_WAIT1。
  • 当被动方收到 FIN 报文后,内核会自动回复 ACK 报文,连接状态将从 ESTABLISHED 变成 CLOSE_WAIT,表示被动方在等待进程调用 close 函数关闭连接。
  • 当主动方收到这个 ACK 后,连接状态由 FIN_WAIT1 变为 FIN_WAIT2,也就是表示主动方的发送通道就关闭了
  • 当被动方进入 CLOSE_WAIT 时,被动方还会继续处理数据,等到进程的 read 函数返回 0 后,应用程序就会调用 close 函数,进而触发内核发送 FIN 报文,此时被动方的连接状态变为 LAST_ACK。
  • 当主动方收到这个 FIN 报文后,内核会回复 ACK 报文给被动方,同时主动方的连接状态由 FIN_WAIT2 变为 TIME_WAIT,在 Linux 系统下大约等待 1 分钟后,TIME_WAIT 状态的连接才会彻底关闭
  • 当被动方收到最后的 ACK 报文后,被动方的连接就会关闭

你可以看到,每个方向都需要一个 FIN 和一个 ACK,因此通常被称为四次挥手
这里一点需要注意是:主动关闭连接的,才有 TIME_WAIT 状态。
主动关闭方和被动关闭方优化的思路也不同,接下来分别说说如何优化他们。

7.4、主动方的优化

关闭连接的方式通常有两种,分别是 RST 报文关闭和 FIN 报文关闭。
如果进程异常退出了,内核就会发送 RST 报文来关闭,它可以不走四次挥手流程,是一个暴力关闭连接的方式。
安全关闭连接的方式必须通过四次挥手,它由进程调用 close 和 shutdown 函数发起 FIN 报文(shutdown 参数须传入 SHUT_WR 或者 SHUT_RDWR 才会发送 FIN)。
调用 close 函数和 shutdown 函数有什么区别?
调用了 close 函数意味着完全断开连接,完全断开不仅指无法传输数据,而且也不能发送数据。 此时,调用了 close 函数的一方的连接叫做「孤儿连接」,如果你用 netstat -p 命令,会发现连接对应的进程名为空。
使用 close 函数关闭连接是不优雅的。于是,就出现了一种优雅关闭连接的 shutdown 函数,它可以控制只关闭一个方向的连接
计算机网络之TCP - 图91
第二个参数决定断开连接的方式,主要有以下三种方式:

  • SHUT_RD(0):关闭连接的「读」这个方向,如果接收缓冲区有已接收的数据,则将会被丢弃,并且后续再收到新的数据,会对数据进行 ACK,然后悄悄地丢弃。也就是说,对端还是会接收到 ACK,在这种情况下根本不知道数据已经被丢弃了。
  • SHUT_WR(1):关闭连接的「写」这个方向,这就是常被称为「半关闭」的连接。如果发送缓冲区还有未发送的数据,将被立即发送出去,并发送一个 FIN 报文给对端。
  • SHUT_RDWR(2):相当于 SHUT_RD 和 SHUT_WR 操作各一次,关闭套接字的读和写两个方向

close 和 shutdown 函数都可以关闭连接,但这两种方式关闭的连接,不只功能上有差异,控制它们的 Linux 参数也不相同。
FIN_WAIT1 状态的优化
主动方发送 FIN 报文后,连接就处于 FIN_WAIT1 状态,正常情况下,如果能及时收到被动方的 ACK,则会很快变为 FIN_WAIT2 状态。
但是当迟迟收不到对方返回的 ACK 时,连接就会一直处于 FIN_WAIT1 状态。此时,内核会定时重发 FIN 报文,其中重发次数由 tcp_orphan_retries 参数控制(注意,orphan 虽然是孤儿的意思,该参数却不只对孤儿连接有效,事实上,它对所有 FIN_WAIT1 状态下的连接都有效),默认值是 0。
计算机网络之TCP - 图92
你可能会好奇,这 0 表示几次?实际上当为 0 时,特指 8 次,从下面的内核源码可知:
计算机网络之TCP - 图93
如果 FIN_WAIT1 状态连接很多,我们就需要考虑降低 tcp_orphan_retries 的值,当重传次数超过 tcp_orphan_retries 时,连接就会直接关闭掉。
对于普遍正常情况时,调低 tcp_orphan_retries 就已经可以了。如果遇到恶意攻击,FIN 报文根本无法发送出去,这由 TCP 两个特性导致的:

  • 首先,TCP 必须保证报文是有序发送的,FIN 报文也不例外,当发送缓冲区还有数据没有发送时,FIN 报文也不能提前发送。
  • 其次,TCP 有流量控制功能,当接收方接收窗口为 0 时,发送方就不能再发送数据。所以,当攻击者下载大文件时,就可以通过接收窗口设为 0 ,这就会使得 FIN 报文都无法发送出去,那么连接会一直处于 FIN_WAIT1 状态。

解决这种问题的方法,是调整 tcp_max_orphans 参数,它定义了「孤儿连接」的最大数量
计算机网络之TCP - 图94
当进程调用了 close 函数关闭连接,此时连接就会是「孤儿连接」,因为它无法再发送和接收数据。Linux 系统为了防止孤儿连接过多,导致系统资源长时间被占用,就提供了 tcp_max_orphans 参数。如果孤儿连接数量大于它,新增的孤儿连接将不再走四次挥手,而是直接发送 RST 复位报文强制关闭。
FIN_WAIT2 状态的优化
当主动方收到 ACK 报文后,会处于 FIN_WAIT2 状态,就表示主动方的发送通道已经关闭,接下来将等待对方发送 FIN 报文,关闭对方的发送通道。
这时,如果连接是用 shutdown 函数关闭的,连接可以一直处于 FIN_WAIT2 状态,因为它可能还可以发送或接收数据。但对于 close 函数关闭的孤儿连接,由于无法再发送和接收数据,所以这个状态不可以持续太久,而 tcp_fin_timeout 控制了这个状态下连接的持续时长,默认值是 60 秒:
计算机网络之TCP - 图95
它意味着对于孤儿连接(调用 close 关闭的连接),如果在 60 秒后还没有收到 FIN 报文,连接就会直接关闭。
这个 60 秒不是随便决定的,它与 TIME_WAIT 状态持续的时间是相同的,后面我们再来说说为什么是 60 秒。
TIME_WAIT 状态的优化
TIME_WAIT 是主动方四次挥手的最后一个状态,也是最常遇见的状态。
当收到被动方发来的 FIN 报文后,主动方会立刻回复 ACK,表示确认对方的发送通道已经关闭,接着就处于 TIME_WAIT 状态。在 Linux 系统,TIME_WAIT 状态会持续 60 秒后才会进入关闭状态。
TIME_WAIT 状态的连接,在主动方看来确实快已经关闭了。然后,被动方没有收到 ACK 报文前,还是处于 LAST_ACK 状态。如果这个 ACK 报文没有到达被动方,被动方就会重发 FIN 报文。重发次数仍然由前面介绍过的 tcp_orphan_retries 参数控制。
TIME-WAIT 的状态尤其重要,主要是两个原因:

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

原因一:防止历史连接中的数据,被后面相同四元组的连接错误的接收
TIME-WAIT 的一个作用是防止收到历史数据,从而导致数据错乱的问题。
假设 TIME-WAIT 没有等待时间或时间过短,被延迟的数据包抵达后会发生什么呢?
image.png

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

为了防止历史连接中的数据,被后面相同四元组的连接错误的接收,因此 TCP 设计了 TIMEWAIT 状态,状态会持续 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 报文。
image.png
服务端收到这个 RST 并将其解释为一个错误(Connection reset by peer),这对于一个可靠的协议来说不是一个优雅的终止方式。
为了防止这种情况出现,客户端必须等待足够长的时间确保对端收到 ACK,如果对端没有收到 ACK,那么就会触发 TCP 重传机制,服务端会重新发送一个 FIN,这样一去一来刚好两个 MSL 的时间。
image.png
但是你可能会说重新发送的 ACK 还是有可能丢失啊,没错,但 TCP 已经等待了那么长的时间了,已经算仁至义尽了。
我们再回过头来看看,为什么 TIME_WAIT 状态要保持 60 秒呢?
这与孤儿连接 FIN_WAIT2 状态默认保留 60 秒的原理是一样的,因为这两个状态都需要保持 2MSL 时长。MSL 全称是 Maximum Segment Lifetime,它定义了一个报文在网络中的最长生存时间(报文每经过一次路由器的转发,IP 头部的 TTL 字段就会减 1,减到 0 时报文就被丢弃,这就限制了报文的最长存活时间)。
为什么是 2 MSL 的时长呢?这其实是相当于至少允许报文丢失一次。比如,若 ACK 在一个 MSL 内丢失,这样被动方重发的 FIN 会在第 2 个 MSL 内到达,TIME_WAIT 状态的连接可以应对。
为什么不是 4 或者 8 MSL 的时长呢?你可以想象一个丢包率达到百分之一的糟糕网络,连续两次丢包的概率只有万分之一,这个概率实在是太小了,忽略它比解决它更具性价比。
因此,TIME_WAIT 和 FIN_WAIT2 状态的最大时长都是 2 MSL,由于在 Linux 系统中,MSL 的值固定为 30 秒,所以它们都是 60 秒。
虽然 TIME_WAIT 状态有存在的必要,但它毕竟会消耗系统资源。如果发起连接一方的 TIME_WAIT 状态过多,占满了所有端口资源,则会导致无法创建新连接。
客户端(发起连接方)受端口资源限制:

  • 客户端TIME_WAIT过多,就会导致端口资源被占用,因为端口就 65536 个,被占满就会导致无法创建新的连接。

服务端(被动连接方)受系统资源限制:

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

另外,Linux 提供了 tcp_max_tw_buckets 参数,当 TIME_WAIT 的连接数量超过该参数时,新关闭的连接就不再经历 TIME_WAIT 而直接关闭:
计算机网络之TCP - 图99
当服务器的并发连接增多时,相应地,同时处于 TIME_WAIT 状态的连接数量也会变多,此时就应当调大 tcp_max_tw_buckets 参数,减少不同连接间数据错乱的概率。tcp_max_tw_buckets 也不是越大越好,毕竟端口和系统资源都是有限的。
有一种方式可以在建立新连接时,复用处于 TIME_WAIT 状态的连接,那就是打开 tcp_tw_reuse 参数。但是需要注意,该参数是只用于客户端(建立连接的发起方),因为是在调用 connect() 时起作用的,而对于服务端(被动连接方)是没有用的。
计算机网络之TCP - 图100
tcp_tw_reuse 从协议角度理解是安全可控的,可以复用处于 TIME_WAIT 的端口为新的连接所用。
什么是协议角度理解的安全可控呢?主要有两点:

  • 只适用于连接发起方,也就是 C/S 模型中的客户端;
  • 对应的 TIME_WAIT 状态的连接创建时间超过 1 秒才可以被复用。

使用这个选项,还有一个前提,需要打开对 TCP 时间戳的支持(对方也要打开 ):
计算机网络之TCP - 图101
由于引入了时间戳,它能带来了些好处:

  • 我们在前面提到的 2MSL(TIME_WAIT状态的持续时间) 问题就不复存在了,因为重复的数据包会因为时间戳过期被自然丢弃;
  • 同时,它还可以防止序列号绕回,也是因为重复的数据包会由于时间戳过期被自然丢弃;

时间戳是在 TCP 的选项字段里定义的,开启了时间戳功能,在 TCP 报文传输的时候会带上发送报文的时间戳。
计算机网络之TCP - 图102
另外,老版本的 Linux 还提供了 tcp_tw_recycle 参数,但是当开启了它,允许处于 TIME_WAIT 状态的连接被快速回收,但是有个大坑
开启了 recycle 和 timestamps 选项,就会开启一种叫 per-host 的 PAWS(判断TCP 报文中时间戳是否是历史报文) 机制,per-host 是对「对端 IP 做 PAWS 检查」,而非对「IP + 端口」四元组做 PAWS 检查。
但是如果客户端网络环境是用了 NAT 网关,那么客户端环境的每一台机器通过 NAT 网关后,都会是相同的 IP 地址,在服务端看来,就好像只是在跟一个客户端打交道一样,无法区分出来。
Per-host PAWS 机制利用 TCP option 里的 timestamp 字段的增长来判断串扰数据,而 timestamp 是根据客户端各自的 CPU tick 得出的值。
当客户端 A 通过 NAT 网关和服务器建立 TCP 连接,然后服务器主动关闭并且快速回收 TIME-WAIT 状态的连接后,客户端 B 也通过 NAT 网关和服务器建立 TCP 连接,注意客户端 A 和 客户端 B 因为经过相同的 NAT 网关,所以是用相同的 IP 地址与服务端建立 TCP 连接,如果客户端 B 的 timestamp 比 客户端 A 的 timestamp 小,那么由于服务端的 per-host 的 PAWS 机制的作用,服务端就会丢弃客户端主机 B 发来的 SYN 包
因此,tcp_tw_recycle 在使用了 NAT 的网络下是存在问题的,如果它是对 TCP 四元组做 PAWS 检查,而不是对「相同的 IP 做 PAWS 检查」,那么就不会存在这个问题了。
网上很多博客都说开启 tcp_tw_recycle 参数来优化 TCP,我信你个鬼,糟老头坏的很!
所以,不建议设置为 1 ,在 Linux 4.12 版本后,Linux 内核直接取消了这一参数,建议关闭它:
计算机网络之TCP - 图103
另外,我们可以在程序中设置 socket 选项,来设置调用 close 关闭连接行为。
计算机网络之TCP - 图104
如果 l_onoff 为非 0, 且 l_linger 值为 0,那么调用 close 后,会立该发送一个 RST 标志给对端,该 TCP 连接将跳过四次挥手,也就跳过了 TIME_WAIT 状态,直接关闭。
但这为跨越 TIME_WAIT 状态提供了一个可能,不过是一个非常危险的行为,不值得提倡。

7.5、被动方的优化

当被动方收到 FIN 报文时,内核会自动回复 ACK,同时连接处于 CLOSE_WAIT 状态,顾名思义,它表示等待应用进程调用 close 函数关闭连接。
内核没有权利替代进程去关闭连接,因为如果主动方是通过 shutdown 关闭连接,那么它就是想在半关闭连接上接收数据或发送数据。因此,Linux 并没有限制 CLOSE_WAIT 状态的持续时间。
当然,大多数应用程序并不使用 shutdown 函数关闭连接。所以,当你用 netstat 命令发现大量 CLOSE_WAIT 状态。就需要排查你的应用程序,因为可能因为应用程序出现了 Bug,read 函数返回 0 时,没有调用 close 函数。
处于 CLOSE_WAIT 状态时,调用了 close 函数,内核就会发出 FIN 报文关闭发送通道,同时连接进入 LAST_ACK 状态,等待主动方返回 ACK 来确认连接关闭。
如果迟迟收不到这个 ACK,内核就会重发 FIN 报文,重发次数仍然由 tcp_orphan_retries 参数控制,这与主动方重发 FIN 报文的优化策略一致。
还有一点我们需要注意的,如果被动方迅速调用 close 函数,那么被动方的 ACK 和 FIN 有可能在一个报文中发送,这样看起来,四次挥手会变成三次挥手,这只是一种特殊情况,不用在意。
如果连接双方同时关闭连接,会怎么样?
由于 TCP 是双全工的协议,所以是会出现两方同时关闭连接的现象,也就是同时发送了 FIN 报文。
此时,上面介绍的优化策略仍然适用。两方发送 FIN 报文时,都认为自己是主动方,所以都进入了 FIN_WAIT1 状态,FIN 报文的重发次数仍由 tcp_orphan_retries 参数控制。
计算机网络之TCP - 图105
接下来,双方在等待 ACK 报文的过程中,都等来了 FIN 报文。这是一种新情况,所以连接会进入一种叫做 CLOSING 的新状态,它替代了 FIN_WAIT2 状态。接着,双方内核回复 ACK 确认对方发送通道的关闭后,进入 TIME_WAIT 状态,等待 2MSL 的时间后,连接自动关闭。

7.6、小结

针对 TCP 四次挥手的优化,我们需要根据主动方和被动方四次挥手状态变化来调整系统 TCP 内核参数。
计算机网络之TCP - 图106
主动方的优化
主动发起 FIN 报文断开连接的一方,如果迟迟没收到对方的 ACK 回复,则会重传 FIN 报文,重传的次数由 tcp_orphan_retries 参数决定。
当主动方收到 ACK 报文后,连接就进入 FIN_WAIT2 状态,根据关闭的方式不同,优化的方式也不同:

  • 如果这是 close 函数关闭的连接,那么它就是孤儿连接。如果 tcp_fin_timeout 秒内没有收到对方的 FIN 报文,连接就直接关闭。同时,为了应对孤儿连接占用太多的资源,tcp_max_orphans 定义了最大孤儿连接的数量,超过时连接就会直接释放。
  • 反之是 shutdown 函数关闭的连接,则不受此参数限制;

当主动方接收到 FIN 报文,并返回 ACK 后,主动方的连接进入 TIME_WAIT 状态。这一状态会持续 1 分钟,为了防止 TIME_WAIT 状态占用太多的资源,tcp_max_tw_buckets 定义了最大数量,超过时连接也会直接释放。
当 TIME_WAIT 状态过多时,还可以通过设置 tcp_tw_reuse 和 tcp_timestamps 为 1 ,将 TIME_WAIT 状态的端口复用于作为客户端的新连接,注意该参数只适用于客户端。
被动方的优化
被动关闭的连接方应对非常简单,它在回复 ACK 后就进入了 CLOSE_WAIT 状态,等待进程调用 close 函数关闭连接。因此,出现大量 CLOSE_WAIT 状态的连接时,应当从应用程序中找问题。
当被动方发送 FIN 报文后,连接就进入 LAST_ACK 状态,在未等到 ACK 时,会在 tcp_orphan_retries 参数的控制下重发 FIN 报文。


8、TCP 传输数据的性能提升

在前面介绍的是三次握手和四次挥手的优化策略,接下来主要介绍的是 TCP 传输数据时的优化策略。
TCP 连接是由内核维护的,内核会为每个连接建立内存缓冲区:

  • 如果连接的内存配置过小,就无法充分使用网络带宽,TCP 传输效率就会降低;
  • 如果连接的内存配置过大,很容易把服务器资源耗尽,这样就会导致新连接无法建立;

因此,我们必须理解 Linux 下 TCP 内存的用途,才能正确地配置内存大小。

8.1、滑动窗口是如何影响传输速度的?

TCP 会保证每一个报文都能够抵达对方,它的机制是这样:报文发出去后,必须接收到对方返回的确认报文 ACK,如果迟迟未收到,就会超时重发该报文,直到收到对方的 ACK 为止。
所以,TCP 报文发出去后,并不会立马从内存中删除,因为重传时还需要用到它。
由于 TCP 是内核维护的,所以报文存放在内核缓冲区。如果连接非常多,我们可以通过 free 命令观察到 buff/cache 内存是会增大。
如果 TCP 是每发送一个数据,都要进行一次确认应答。当上一个数据包收到了应答了, 再发送下一个。这个模式就有点像我和你面对面聊天,你一句我一句,但这种方式的缺点是效率比较低的。
计算机网络之TCP - 图107
所以,这样的传输方式有一个缺点:数据包的往返时间越长,通信的效率就越低
要解决这一问题不难,并行批量发送报文,再批量确认报文即可。
计算机网络之TCP - 图108
然而,这引出了另一个问题,发送方可以随心所欲的发送报文吗?当然这不现实,我们还得考虑接收方的处理能力。
当接收方硬件不如发送方,或者系统繁忙、资源紧张时,是无法瞬间处理这么多报文的。于是,这些报文只能被丢掉,使得网络效率非常低。
为了解决这种现象发生,TCP 提供一种机制可以让「发送方」根据「接收方」的实际接收能力控制发送的数据量,这就是滑动窗口的由来。
接收方根据它的缓冲区,可以计算出后续能够接收多少字节的报文,这个数字叫做接收窗口。当内核接收到报文时,必须用缓冲区存放它们,这样剩余缓冲区空间变小,接收窗口也就变小了;当进程调用 read 函数后,数据被读入了用户空间,内核缓冲区就被清空,这意味着主机可以接收更多的报文,接收窗口就会变大。
因此,接收窗口并不是恒定不变的,接收方会把当前可接收的大小放在 TCP 报文头部中的窗口字段,这样就可以起到窗口大小通知的作用。
发送方的窗口等价于接收方的窗口吗?如果不考虑拥塞控制,发送方的窗口大小「约等于」接收方的窗口大小,因为窗口通知报文在网络传输是存在时延的,所以是约等于的关系。
计算机网络之TCP - 图109
从上图中可以看到,窗口字段只有 2 个字节,因此它最多能表达 65535 字节大小的窗口,也就是 64KB 大小。
这个窗口大小最大值,在当今高速网络下,很明显是不够用的。所以后续有了扩充窗口的方法:在 TCP 选项字段定义了窗口扩大因子,用于扩大 TCP 通告窗口,其值大小是 2^14,这样就使 TCP 的窗口大小从 16 位扩大为 30 位(2^16 * 2^ 14 = 2^30),所以此时窗口的最大值可以达到 1GB。
计算机网络之TCP - 图110
Linux 中打开这一功能,需要把 tcp_window_scaling 配置设为 1(默认打开):
计算机网络之TCP - 图111
要使用窗口扩大选项,通讯双方必须在各自的 SYN 报文中发送这个选项:

  • 主动建立连接的一方在 SYN 报文中发送这个选项;
  • 而被动建立连接的一方只有在收到带窗口扩大选项的 SYN 报文之后才能发送这个选项。

这样看来,只要进程能及时地调用 read 函数读取数据,并且接收缓冲区配置得足够大,那么接收窗口就可以无限地放大,发送方也就无限地提升发送速度。
这是不可能的,因为网络的传输能力是有限的,当发送方依据发送窗口,发送超过网络处理能力的报文时,路由器会直接丢弃这些报文。因此,缓冲区的内存并不是越大越好。

8.2、如何确定最大传输速度?

在前面我们知道了 TCP 的传输速度,受制于发送窗口与接收窗口,以及网络设备传输能力。其中,窗口大小由内核缓冲区大小决定。如果缓冲区与网络传输能力匹配,那么缓冲区的利用率就达到了最大化。
问题来了,如何计算网络的传输能力呢?
相信大家都知道网络是有「带宽」限制的,带宽描述的是网络传输能力,它与内核缓冲区的计量单位不同:

  • 带宽是单位时间内的流量,表达是「速度」,比如常见的带宽 100 MB/s;
  • 缓冲区单位是字节,当网络速度乘以时间才能得到字节数;

这里需要说一个概念,就是带宽时延积,它决定网络中飞行报文的大小,它的计算方式:
计算机网络之TCP - 图112
比如最大带宽是 100 MB/s,网络时延(RTT)是 10ms 时,意味着客户端到服务端的网络一共可以存放 100MB/s 0.01s = 1MB 的字节。
这个 1MB 是带宽和时延的乘积,所以它就叫「带宽时延积」(缩写为 BDP,Bandwidth Delay Product)。同时,这 1MB 也表示「飞行中」的 TCP 报文大小,它们就在网络线路、路由器等网络设备上。如果飞行报文超过了 1 MB,就会导致网络过载,容易丢包。
*由于发送缓冲区大小决定了发送窗口的上限,而发送窗口又决定了「已发送未确认」的飞行报文的上限。因此,发送缓冲区不能超过「带宽时延积」。

发送缓冲区与带宽时延积的关系:

  • 如果发送缓冲区「超过」带宽时延积,超出的部分就没办法有效的网络传输,同时导致网络过载,容易丢包;
  • 如果发送缓冲区「小于」带宽时延积,就不能很好的发挥出网络的传输效率。

所以,发送缓冲区的大小最好是往带宽时延积靠近。

8.3、怎样调整缓冲区大小?

在 Linux 中发送缓冲区和接收缓冲都是可以用参数调节的。设置完后,Linux 会根据你设置的缓冲区进行动态调节
调节发送缓冲区范围
先来看看发送缓冲区,它的范围通过 tcp_wmem 参数配置;
计算机网络之TCP - 图113
上面三个数字单位都是字节,它们分别表示:

  • 第一个数值是动态范围的最小值,4096 byte = 4K;
  • 第二个数值是初始默认值,87380 byte ≈ 86K;
  • 第三个数值是动态范围的最大值,4194304 byte = 4096K(4M);

发送缓冲区是自行调节的,当发送方发送的数据被确认后,并且没有新的数据要发送,就会把发送缓冲区的内存释放掉。
调节接收缓冲区范围
而接收缓冲区的调整就比较复杂一些,先来看看设置接收缓冲区范围的 tcp_rmem 参数:
计算机网络之TCP - 图114
上面三个数字单位都是字节,它们分别表示:

  • 第一个数值是动态范围的最小值,表示即使在内存压力下也可以保证的最小接收缓冲区大小,4096 byte = 4K;
  • 第二个数值是初始默认值,87380 byte ≈ 86K;
  • 第三个数值是动态范围的最大值,6291456 byte = 6144K(6M);

接收缓冲区可以根据系统空闲内存的大小来调节接收窗口:

  • 如果系统的空闲内存很多,就可以自动把缓冲区增大一些,这样传给对方的接收窗口也会变大,因而提升发送方发送的传输数据数量;
  • 反之,如果系统的内存很紧张,就会减少缓冲区,这虽然会降低传输效率,可以保证更多的并发连接正常工作;

发送缓冲区的调节功能是自动开启的,而接收缓冲区则需要配置 tcp_moderate_rcvbuf 为 1 来开启调节功能
计算机网络之TCP - 图115
调节 TCP 内存范围
接收缓冲区调节时,怎么知道当前内存是否紧张或充分呢?这是通过 tcp_mem 配置完成的:
计算机网络之TCP - 图116
上面三个数字单位不是字节,而是「页面大小」,1 页表示 4KB,它们分别表示:

  • 当 TCP 内存小于第 1 个值时,不需要进行自动调节;
  • 在第 1 和第 2 个值之间时,内核开始调节接收缓冲区的大小;
  • 大于第 3 个值时,内核不再为 TCP 分配新内存,此时新连接是无法建立的;

一般情况下这些值是在系统启动时根据系统内存数量计算得到的。根据当前 tcp_mem 最大内存页面数是 177120,当内存为 (177120 4) / 1024K ≈ 692M 时,系统将无法为新的 TCP 连接分配内存,即 TCP 连接将被拒绝。
根据实际场景调节的策略
在高并发服务器中,为了兼顾网速与大量的并发连接,我们应当保证缓冲区的动态调整的最大值达到带宽时延积,而最小值保持默认的 4K 不变即可。而对于内存紧张的服务而言,调低默认值是提高并发的有效手段。
同时,如果这是网络 IO 型服务器,那么,调大 tcp_mem 的上限可以让 TCP 连接使用更多的系统内存,这有利于提升并发能力。需要注意的是,tcp_wmem 和 tcp_rmem 的单位是字节,而 tcp_mem 的单位是页面大小。而且,*千万不要在 socket 上直接设置 SO_SNDBUF 或者 SO_RCVBUF,这样会关闭缓冲区的动态调整功能。

#小结

本节针对 TCP 优化数据传输的方式,做了一些介绍。
计算机网络之TCP - 图117
TCP 可靠性是通过 ACK 确认报文实现的,又依赖滑动窗口提升了发送速度也兼顾了接收方的处理能力。
可是,默认的滑动窗口最大值只有 64 KB,不满足当今的高速网络的要求,要想提升发送速度必须提升滑动窗口的上限,在 Linux 下是通过设置 tcp_window_scaling 为 1 做到的,此时最大值可高达 1GB。
滑动窗口定义了网络中飞行报文的最大字节数,当它超过带宽时延积时,网络过载,就会发生丢包。而当它小于带宽时延积时,就无法充分利用网络带宽。因此,滑动窗口的设置,必须参考带宽时延积。
内核缓冲区决定了滑动窗口的上限,缓冲区可分为:发送缓冲区 tcp_wmem 和接收缓冲区 tcp_rmem。
Linux 会对缓冲区动态调节,我们应该把缓冲区的上限设置为带宽时延积。发送缓冲区的调节功能是自动打开的,而接收缓冲区需要把 tcp_moderate_rcvbuf 设置为 1 来开启。其中,调节的依据是 TCP 内存范围 tcp_mem。
但需要注意的是,如果程序中的 socket 设置 SO_SNDBUF 和 SO_RCVBUF,则会关闭缓冲区的动态整功能,所以不建议在程序设置它俩,而是交给内核自动调整比较好。
有效配置这些参数后,既能够最大程度地保持并发性,也能让资源充裕时连接传输速度达到最大值。


参考资料:
[1] 系统性能调优必知必会.陶辉.极客时间.
[2] 网络编程实战专栏.盛延敏.极客时间.
[3] http://www.blogjava.net/yongboy/archive/2013/04/11/397677.html
[4] http://blog.itpub.net/31559359/viewspace-2284113/
[5] https://blog.51cto.com/professor/1909022
[6] https://vincent.bernat.ch/en/blog/2014-tcp-time-wait-state-linux


#读者问答

读者问:“小林,请教个问题,somaxconn和backlog是不是都是指的是accept队列?然后somaxconn是内核参数,backlog是通过系统调用间隔地修改somaxconn,比如Linux中listen()函数?”
两者取最小值才是 accpet 队列。
读者问:“小林,还有个问题要请教下,“如果 accept 队列满了,那么 server 扔掉 client 发过来的 ack”,也就是说该TCP连接还是位于半连接队列中,没有丢弃吗?”

  1. 当 accept 队列满了,后续新进来的syn包都会被丢失
  2. 我文章的突发流量例子是,那个连接进来的时候 accept 队列还没满,但是在第三次握手的时候,accept 队列突然满了,就会导致 ack 被丢弃,就一直处于半连接队列。

9、如何理解是 TCP 面向字节流协议?

TCP 是面向字节流的协议,UDP 是面向报文的协议?这里的「面向字节流」和「面向报文」该如何理解。


9.1如何理解字节流?

之所以会说 TCP 是面向字节流的协议,UDP 是面向报文的协议,是因为操作系统对 TCP 和 UDP 协议的发送方的机制不同,也就是问题原因在发送方。
先来说说为什么 UDP 是面向报文的协议?
当用户消息通过 UDP 协议传输时,操作系统不会对消息进行拆分,在组装好 UDP 头部后就交给网络层来处理,所以发出去的 UDP 报文中的数据部分就是完整的用户消息,也就是每个 UDP 报文就是一个用户消息的边界,这样接收方在接收到 UDP 报文后,读一个 UDP 报文就能读取到完整的用户消息。
你可能会问,如果收到了两个 UDP 报文,操作系统是怎么区分开的?
操作系统在收到 UDP 报文后,会将其插入到队列里,队列里的每一个元素就是一个 UDP 报文,这样当用户调用 recvfrom() 系统调用读数据的时候,就会从队列里取出一个数据,然后从内核里拷贝给用户缓冲区。
image.png
再来说说为什么 TCP 是面向字节流的协议?
当用户消息通过 TCP 协议传输时,消息可能会被操作系统分组成多个的 TCP 报文,也就是一个完整的用户消息被拆分成多个 TCP 报文进行传输。
这时,接收方的程序如果不知道发送方发送的消息的长度,也就是不知道消息的边界时,是无法读出一个有效的用户消息的,因为用户消息被拆分成多个 TCP 报文后,并不能像 UDP 那样,一个 UDP 报文就能代表一个完整的用户消息。
举个实际的例子来说明。
发送方准备发送 「Hi.」和「I am Xiaolin」这两个消息。
在发送端,当我们调用 send 函数完成数据“发送”以后,数据并没有被真正从网络上发送出去,只是从应用程序拷贝到了操作系统内核协议栈中。
至于什么时候真正被发送,取决于发送窗口、拥塞窗口以及当前发送缓冲区的大小等条件。也就是说,我们不能认为每次 send 调用发送的数据,都会作为一个整体完整地消息被发送出去。
如果我们考虑实际网络传输过程中的各种影响,假设发送端陆续调用 send 函数先后发送 「Hi.」和「I am Xiaolin」 报文,那么实际的发送很有可能是这几种情况。
第一种情况,这两个消息被分到同一个 TCP 报文,像这样:
image.png
第二种情况,「I am Xiaolin」的部分随 「Hi」 在一个 TCP 报文中发送出去,像这样:
image.png
第三种情况,「Hi.」 的一部分随 TCP 报文被发送出去,另一部分和 「I am Xiaolin」 一起随另一个 TCP 报文发送出去,像这样。
image.png
类似的情况还能举例很多种,这里主要是想说明,我们不知道 「Hi.」和 「I am Xiaolin」 这两个用户消息是如何进行 TCP 分组传输的。
因此,我们不能认为一个用户消息对应一个 TCP 报文,正因为这样,所以 TCP 是面向字节流的协议
当两个消息的某个部分内容被分到同一个 TCP 报文时,就是我们常说的 TCP 粘包问题,这时接收方不知道消息的边界的话,是无法读出有效的消息。
要解决这个问题,要交给应用程序

9.2、如何解决粘包?

粘包的问题出现是因为不知道一个用户消息的边界在哪,如果知道了边界在哪,接收方就可以通过边界来划分出有效的用户消息。
一般有三种方式分包的方式:

  • 固定长度的消息;
  • 特殊字符作为边界;
  • 自定义消息结构。

    9.2.1固定长度的消息

    这种是最简单方法,即每个用户消息都是固定长度的,比如规定一个消息的长度是 64 个字节,当接收方接满 64 个字节,就认为这个内容是一个完整且有效的消息。
    但是这种方式灵活性不高,实际中很少用。

    9.2.2特殊字符作为边界

    我们可以在两个用户消息之间插入一个特殊的字符串,这样接收方在接收数据时,读到了这个特殊字符,就把认为已经读完一个完整的消息。
    HTTP 是一个非常好的例子。
    image.png
    HTTP 通过设置回车符、换行符作为 HTTP 报文协议的边界。
    有一点要注意,这个作为边界点的特殊字符,如果刚好消息内容里有这个特殊字符,我们要对这个字符转义,避免被接收方当作消息的边界点而解析到无效的数据。

    9.2.3自定义消息结构

    我们可以自定义一个消息结构,由包头和数据组成,其中包头包是固定大小的,而且包头里有一个字段来说明紧随其后的数据有多大。
    比如这个消息结构体,首先 4 个字节大小的变量来表示数据长度,真正的数据则在后面。
    struct { u_int32_t message_length; char message_data[]; } message;
    当接收方接收到包头的大小(比如 4 个字节)后,就解析包头的内容,于是就可以知道数据的长度,然后接下来就继续读取数据,直到读满数据的长度,就可以组装成一个完整到用户消息来处理了。

    10、常见的TCP问题

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

    为什么 TCP 每次建立连接时,初始化序列号都要不一样呢?
    接下来,我一步一步给大家讲明白,我觉得应该有不少人会有类似的问题,所以今天在肝一篇!
    为什么 TCP 每次建立连接时,初始化序列号都要不一样呢?
    主要原因是为了防止历史报文被下一个相同四元组的连接接收。
    TCP 四次挥手中的 TIME_WAIT 状态不是会持续 2 MSL 时长,历史报文不是早就在网络中消失了吗?
    是的,如果能正常四次挥手,由于 TIME_WAIT 状态会持续 2 MSL 时长,历史报文会在下一个连接之前就会自然消失。
    但是来了,我们并不能保证每次连接都能通过四次挥手来正常关闭连接。
    假设每次建立连接,客户端和服务端的初始化序列号都是从 0 开始:
    image.png
    过程如下:

  • 客户端和服务端建立一个 TCP 连接,在客户端发送数据包被网络阻塞了,而此时服务端的进程重启了,于是就会发送 RST 报文来断开连接。

  • 紧接着,客户端又与服务端建立了与上一个连接相同四元组的连接;
  • 在新连接建立完成后,上一个连接中被网络阻塞的数据包正好抵达了服务端,刚好该数据包的序列号正好是在服务端的接收窗口内,所以该数据包会被服务端正常接收,就会造成数据错乱。

可以看到,如果每次建立连接,客户端和服务端的初始化序列号都是一样的话,很容易出现历史报文被下一个相同四元组的连接接收的问题。
客户端和服务端的初始化序列号不一样不是也会发生这样的事情吗?
是的,即使客户端和服务端的初始化序列号不一样,也会存在收到历史报文的可能。
但是我们要清楚一点,历史报文能否被对方接收,还要看该历史报文的序列号是否正好在对方接收窗口内,如果不在就会丢弃,如果在才会接收。
如果每次建立连接客户端和服务端的初始化序列号都「不一样」,就有大概率因为历史报文的序列号「不在」对方接收窗口,从而很大程度上避免了历史报文,比如下图:
image.png
相反,如果每次建立连接客户端和服务端的初始化序列号都「一样」,就有大概率遇到历史报文的序列号刚「好在」对方的接收窗口内,从而导致历史报文被新连接成功接收。
所以,每次初始化序列号不一样能够很大程度上避免历史报文被下一个相同四元组的连接接收,注意是很大程度上,并不是完全避免了。
那客户端和服务端的初始化序列号都是随机的,那还是有可能随机成一样的呀?
RFC793 提到初始化序列号 ISN 随机生成算法:ISN = M + F(localhost, localport, remotehost, remoteport)。

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

可以看到,随机数是会基于时钟计时器递增的,基本不可能会随机成一样的初始化序列号。
懂了,客户端和服务端初始化序列号都是随机生成的话,就能避免连接接收历史报文了。
是的,但是也不是完全避免了。
为了能更好的理解这个原因,我们先来了解序列号(SEQ)和初始序列号(ISN)。

  • 序列号,是 TCP 一个头部字段,标识了 TCP 发送端到 TCP 接收端的数据流的一个字节,因为 TCP 是面向字节流的可靠协议,为了保证消息的顺序性和可靠性,TCP 为每个传输方向上的每个字节都赋予了一个编号,以便于传输成功后确认、丢失后重传以及在接收端保证不会乱序。序列号是一个 32 位的无符号数,因此在到达 4G 之后再循环回到 0
  • 初始序列号,在 TCP 建立连接的时候,客户端和服务端都会各自生成一个初始序列号,它是基于时钟生成的一个随机数,来保证每个连接都拥有不同的初始序列号。初始化序列号可被视为一个 32 位的计数器,该计数器的数值每 4 微秒加 1,循环一次需要 4.55 小时

给大家抓了一个包,下图中的 Seq 就是序列号,其中红色框住的分别是客户端和服务端各自生成的初始序列号。
image.png
图片
通过前面我们知道,序列号和初始化序列号并不是无限递增的,会发生回绕为初始值的情况,这意味着无法根据序列号来判断新老数据
不要以为序列号的上限值是 4GB,就以为很大,很难发生回绕。在一个速度足够快的网络中传输大量数据时,序列号的回绕时间就会变短。如果序列号回绕的时间极短,我们就会再次面临之前延迟的报文抵达后序列号依然有效的问题。
为了解决这个问题,就需要有 TCP 时间戳。tcp_timestamps 参数是默认开启的,开启了 tcp_timestamps 参数,TCP 头部就会使用时间戳选项,它有两个好处,一个是便于精确计算 RTT ,另一个是能防止序列号回绕(PAWS)
试看下面的示例,假设 TCP 的发送窗口是 1 GB,并且使用了时间戳选项,发送方会为每个 TCP 报文分配时间戳数值,我们假设每个报文时间加 1,然后使用这个连接传输一个 6GB 大小的数据流。
image.png
32 位的序列号在时刻 D 和 E 之间回绕。假设在时刻B有一个报文丢失并被重传,又假设这个报文段在网络上绕了远路并在时刻 F 重新出现。如果 TCP 无法识别这个绕回的报文,那么数据完整性就会遭到破坏。
使用时间戳选项能够有效的防止上述问题,如果丢失的报文会在时刻 F 重新出现,由于它的时间戳为 2,小于最近的有效时间戳(5 或 6),因此防回绕序列号算法(PAWS)会将其丢弃。
防回绕序列号算法要求连接双方维护最近一次收到的数据包的时间戳(Recent TSval),每收到一个新数据包都会读取数据包中的时间戳值跟 Recent TSval 值做比较,如果发现收到的数据包中时间戳不是递增的,则表示该数据包是过期的,就会直接丢弃这个数据包
懂了,客户端和服务端的初始化序列号都是随机生成,能很大程度上避免历史报文被下一个相同四元组的连接接收,然后又引入时间戳的机制,从而完全避免了历史报文被接收的问题。
嗯嗯,没错。
如果时间戳也回绕了怎么办?
时间戳的大小是 32 bit,所以理论上也是有回绕的可能性的。
时间戳回绕的速度只与对端主机时钟频率有关。
Linux 以本地时钟计数(jiffies)作为时间戳的值,不同的增长时间会有不同的问题:

  • 如果时钟计数加 1 需要1ms,则需要约 24.8 天才能回绕一半,只要报文的生存时间小于这个值的话判断新旧数据就不会出错。
  • 如果时钟计数提高到 1us 加1,则回绕需要约71.58分钟才能回绕,这时问题也不大,因为网络中旧报文几乎不可能生存超过70分钟,只是如果70分钟没有报文收发则会有一个包越过PAWS(这种情况会比较多见,相比之下 24 天没有数据传输的TCP连接少之又少),但除非这个包碰巧是序列号回绕的旧数据包而被放入接收队列(太巧了吧),否则也不会有问题;
  • 如果时钟计数提高到 0.1 us 加 1 回绕需要 7 分钟多一点,这时就可能会有问题了,连接如果 7 分钟没有数据收发就会有一个报文越过 PAWS,对于TCP连接而言这么短的时间内没有数据交互太常见了吧!这样的话会频繁有包越过 PAWS 检查,从而使得旧包混入数据中的概率大大增加;

Linux 在 PAWS 检查做了一个特殊处理,如果一个 TCP 连接连续 24 天不收发数据则在接收第一个包时基于时间戳的 PAWS 会失效,也就是可以 PAWS 函数会放过这个特殊的情况,认为是合法的,可以接收该数据包。
// tcp_paws_check 函数如果返回 true 则 PAWS 通过: static inline bool tcp_paws_check(const struct tcp_options_received *rx_opt, int paws_win) { …… //从上次收到包到现在经历的时间多于24天,返回true if (unlikely(get_seconds() >= rx_opt->ts_recent_stamp + TCP_PAWS_24DAYS)) return true; ….. return false; }
要解决时间戳回绕的问题,可以考虑以下解决方案:
1)增加时间戳的大小,由32 bit扩大到64bit
这样虽然可以在能够预见的未来解决时间戳回绕的问题,但会导致新旧协议兼容性问题,像现在的IPv4与IPv6一样
2)将一个与时钟频率无关的值作为时间戳,时钟频率可以增加但时间戳的增速不变
随着时钟频率的提高,TCP在相同时间内能够收发的包也会越来越多。如果时间戳的增速不变,则会有越来越多的报文使用相同的时间戳。这种趋势到达一定程度则时间戳就会失去意义,除非在可预见的未来这种情况不会发生。
3)暂时没想到

10.2 SYN 报文什么时候情况下会被丢弃?

之前有个读者在秋招面试的时候,被问了这么一个问题:SYN 报文什么时候情况下会被丢弃? image.png
好家伙,现在面试都问那么细节了吗?
不过话说回来,这个问题跟工作上也是有关系的,因为我就在工作中碰到这么奇怪的时候,客户端向服务端发起了连接,但是连接并没有建立起来,通过抓包分析发现,服务端是收到 SYN 报文了,但是并没有回复 SYN+ACK(TCP 第二次握手),说明 SYN 报文被服务端忽略了,然后客户端就一直在超时重传 SYN 报文,直到达到最大的重传次数。
接下来,我就给出我遇到过 SYN 报文被丢弃的两种场景:

  • 开启 tcp_tw_recycle 参数,并且在 NAT 环境下,造成 SYN 报文被丢弃
  • TCP 两个队列满了(半连接队列和全连接队列),造成 SYN 报文被丢弃

    10.2.1坑爹的 tcp_tw_recycle

    TCP 四次挥手过程中,主动断开连接方会有一个 TIME_WAIT 的状态,这个状态会持续 2 MSL 后才会转变为 CLOSED 状态。
    image.png
    在 Linux 操作系统下,TIME_WAIT 状态的持续时间是 60 秒,这意味着这 60 秒内,客户端一直会占用着这个端口。要知道,端口资源也是有限的,一般可以开启的端口为 32768~61000 ,也可以通过如下参数设置指定范围:
    net.ipv4.ip_local_port_range
    那么,如果如果主动断开连接方的 TIME_WAIT 状态过多,占满了所有端口资源,则会导致无法创建新连接。
    但是 TIME_WAIT 状态也不是摆设作用,它的作用有两个:

  • 防止具有相同四元组的旧数据包被收到,也就是防止历史连接中的数据,被后面的连接接受,否则就会导致后面的连接收到一个无效的数据,

  • 保证「被动关闭连接」的一方能被正确的关闭,即保证最后的 ACK 能让被动关闭方接收,从而帮助其正常关闭;

不过,Linux 操作系统提供了两个可以系统参数来快速回收处于 TIME_WAIT 状态的连接,这两个参数都是默认关闭的:

  • net.ipv4.tcp_tw_reuse,如果开启该选项的话,客户端(连接发起方) 在调用 connect() 函数时,内核会随机找一个 time_wait 状态超过 1 秒的连接给新的连接复用,所以该选项只适用于连接发起方。
  • net.ipv4.tcp_tw_recycle,如果开启该选项的话,允许处于 TIME_WAIT 状态的连接被快速回收;

要使得这两个选项生效,有一个前提条件,就是要打开 TCP 时间戳,即 net.ipv4.tcp_timestamps=1(默认即为 1))。
tcp_tw_recycle 在使用了 NAT 的网络下是不安全的!
对于服务器来说,如果同时开启了recycle 和 timestamps 选项,则会开启一种称之为「 per-host 的 PAWS 机制」。
首先给大家说说什么是 PAWS 机制?
tcp_timestamps 选项开启之后, PAWS 机制会自动开启,它的作用是防止 TCP 包中的序列号发生绕回。
正常来说每个 TCP 包都会有自己唯一的 SEQ,出现 TCP 数据包重传的时候会复用 SEQ 号,这样接收方能通过 SEQ 号来判断数据包的唯一性,也能在重复收到某个数据包的时候判断数据是不是重传的。但是 TCP 这个 SEQ 号是有限的,一共 32 bit,SEQ 开始是递增,溢出之后从 0 开始再次依次递增
所以当 SEQ 号出现溢出后单纯通过 SEQ 号无法标识数据包的唯一性,某个数据包延迟或因重发而延迟时可能导致连接传递的数据被破坏,比如:
image.png
上图 A 数据包出现了重传,并在 SEQ 号耗尽再次从 A 递增时,第一次发的 A 数据包延迟到达了 Server,这种情况下如果没有别的机制来保证,Server 会认为延迟到达的 A 数据包是正确的而接收,反而是将正常的第三次发的 SEQ 为 A 的数据包丢弃,造成数据传输错误。
PAWS 就是为了避免这个问题而产生的,在开启 tcp_timestamps 选项情况下,一台机器发的所有 TCP 包都会带上发送时的时间戳,PAWS 要求连接双方维护最近一次收到的数据包的时间戳(Recent TSval),每收到一个新数据包都会读取数据包中的时间戳值跟 Recent TSval 值做比较,如果发现收到的数据包中时间戳不是递增的,则表示该数据包是过期的,就会直接丢弃这个数据包
对于上面图中的例子有了 PAWS 机制就能做到在收到 Delay 到达的 A 号数据包时,识别出它是个过期的数据包而将其丢掉。
那什么是 per-host 的 PAWS 机制呢?
前面我提到,开启了 recycle 和 timestamps 选项,就会开启一种叫 per-host 的 PAWS 机制。per-host 是对「对端 IP 做 PAWS 检查」,而非对「IP + 端口」四元组做 PAWS 检查。
但是如果客户端网络环境是用了 NAT 网关,那么客户端环境的每一台机器通过 NAT 网关后,都会是相同的 IP 地址,在服务端看来,就好像只是在跟一个客户端打交道一样,无法区分出来。
Per-host PAWS 机制利用TCP option里的 timestamp 字段的增长来判断串扰数据,而 timestamp 是根据客户端各自的 CPU tick 得出的值。
当客户端 A 通过 NAT 网关和服务器建立 TCP 连接,然后服务器主动关闭并且快速回收 TIME-WAIT 状态的连接后,客户端 B 也通过 NAT 网关和服务器建立 TCP 连接,注意客户端 A 和 客户端 B 因为经过相同的 NAT 网关,所以是用相同的 IP 地址与服务端建立 TCP 连接,如果客户端 B 的 timestamp 比 客户端 A 的 timestamp 小,那么由于服务端的 per-host 的 PAWS 机制的作用,服务端就会丢弃客户端主机 B 发来的 SYN 包
因此,tcp_tw_recycle 在使用了 NAT 的网络下是存在问题的,如果它是对 TCP 四元组做 PAWS 检查,而不是对「相同的 IP 做 PAWS 检查」,那么就不会存在这个问题了。
网上很多博客都说开启 tcp_tw_recycle 参数来优化 TCP,我信你个鬼,糟老头坏的很!
tcp_tw_recycle 在 Linux 4.12 版本后,直接取消了这一参数。

10.2.2 accpet 队列满了

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

  • 半连接队列,也称 SYN 队列;
  • 全连接队列,也称 accepet 队列;

服务端收到客户端发起的 SYN 请求后,内核会把该连接存储到半连接队列,并向客户端响应 SYN+ACK,接着客户端会返回 ACK,服务端收到第三次握手的 ACK 后,内核会把连接从半连接队列移除,然后创建新的完全的连接,并将其添加到 accept 队列,等待进程调用 accept 函数时把连接取出来。
image.png

10.2.3半连接队列满了

当服务器造成syn攻击,就有可能导致 TCP 半连接队列满了,这时后面来的 syn 包都会被丢弃
但是,如果开启了syncookies 功能,即使半连接队列满了,也不会丢弃syn 包
syncookies 是这么做的:服务器根据当前状态计算出一个值,放在己方发出的 SYN+ACK 报文中发出,当客户端返回 ACK 报文时,取出该值验证,如果合法,就认为连接建立成功,如下图所示。
image.png开启 syncookies 功能
syncookies 参数主要有以下三个值:

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

那么在应对 SYN 攻击时,只需要设置为 1 即可:
image.png
这里给出几种防御 SYN 攻击的方法:

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

方式一:增大半连接队列
要想增大半连接队列,我们得知不能只单纯增大 tcp_max_syn_backlog 的值,还需一同增大 somaxconn 和 backlog,也就是增大全连接队列。否则,只单纯增大 tcpmax_syn_backlog 是无效的。
增大 tcp_max_syn_backlog 和 somaxconn 的方法是修改 Linux 内核参数:
image.png
增大 backlog 的方式,每个 Web 服务都不同,比如 Nginx 增大 backlog 的方法如下:
image.png
最后,改变了如上这些参数后,要重启 Nginx 服务,因为半连接队列和全连接队列都是在 listen() 初始化的。
方式二:开启 tcpsyncookies 功能
开启 tcpsyncookies 功能的方式也很简单,修改 Linux 内核参数:
image.png
方式三:减少 SYN+ACK 重传次数_
当服务端受到 SYN 攻击时,就会有大量处于 SYN_REVC 状态的 TCP 连接,处于这个状态的 TCP 会重传 SYN+ACK ,当重传超过次数达到上限后,就会断开连接。
那么针对 SYN 攻击的场景,我们可以减少 SYN+ACK 的重传次数,以加快处于 SYN_REVC 状态的 TCP 连接断开。
image.png

10.2.4全连接队列满了

在服务端并发处理大量请求时,如果 TCP accpet 队列过小,或者应用程序调用 accept() 不及时,就会造成 accpet 队列满了 ,这时后续的连接就会被丢弃,这样就会出现服务端请求数量上不去的现象。
image.png
我们可以通过 ss 命令来看 accpet 队列大小,在「LISTEN 状态」时,Recv-Q/Send-Q 表示的含义如下:
image.png

  • Recv-Q:当前 accpet 队列的大小,也就是当前已完成三次握手并等待服务端 accept() 的 TCP 连接个数;
  • Send-Q:当前 accpet 最大队列长度,上面的输出结果说明监听 8088 端口的 TCP 服务进程,accpet 队列的最大长度为 128;

如果 Recv-Q 的大小超过 Send-Q,就说明发生了 accpet 队列满的情况。
要解决这个问题,我们可以:

  • 调大 accpet 队列的最大长度,调大的方式是通过调大 backlog 以及 somaxconn 参数。
  • 检查系统或者代码为什么调用 accept() 不及时;

关于 SYN 队列和 accpet 队列,我之前写过一篇很详细的文章:TCP 半连接队列和全连接队列满了会发生什么?又该如何应对?

10.3 已建立连接的TCP,收到SYN会发生什么?

昨晚有位读者问了我这么个问题:
image.png
大概意思是,一个已经建立的 TCP 连接,客户端中途宕机了,而服务端此时也没有数据要发送,一直处于 establish 状态,客户端恢复后,向服务端建立连接,此时服务端会怎么处理?
看过我的图解网络的读者都知道,TCP 连接是由「四元组」唯一确认的。
然后这个场景中,客户端的IP、服务端IP、目的端口并没有变化,所以这个问题关键要看客户端发送的 SYN 报文中的源端口是否和上一次连接的源端口相同。
1. 客户端的 SYN 报文里的端口号与历史连接不相同
如果客户端恢复后发送的 SYN 报文中的源端口号跟上一次连接的源端口号不一样,此时服务端会认为是新的连接要建立,于是就会通过三次握手来建立新的连接。
那旧连接里处于 establish 状态的服务端最后会怎么样呢?
如果服务端发送了数据包给客户端,由于客户端的连接已经被关闭了,此时客户的内核就会回 RST 报文,服务端收到后就会释放连接。
如果服务端一直没有发送数据包给客户端,在超过一段时间后, TCP 保活机制就会启动,检测到客户端没有存活后,接着服务端就会释放掉该连接。
2. 客户端的 SYN 报文里的端口号与历史连接相同
如果客户端恢复后,发送的 SYN 报文中的源端口号跟上一次连接的源端口号一样,也就是处于 establish 状态的服务端收到了这个 SYN 报文。
大家觉得服务端此时会做什么处理呢?

  • 丢掉 SYN 报文?
  • 回复 RST 报文?
  • 回复 ACK 报文?

刚开始我看到这个问题的时候,也是没有思路的,因为之前没关注过,然后这个问题不能靠猜,所以我就看了 RFC 规范和看了 Linux 内核源码,最终知道了答案。
我不卖关子,先直接说答案。
image.png
处于 establish 状态的服务端如果收到了客户端的 SYN 报文(注意此时的 SYN 报文其实是乱序的,因为 SYN 报文的初始化序列号其实是一个随机数),会回复一个携带了正确序列号和确认号的 ACK 报文,这个 ACK 被称之为 Challenge ACK。
接着,客户端收到这个 Challenge ACK,发现序列号并不是自己期望收到的,于是就会回 RST 报文,服务端收到后,就会释放掉该连接。
RST:(Reset the connection)用于复位因某种原因引起出现的错误连接,也用来拒绝非法数据和请求。如果接收到RST位时候,通常发生了某些错误;

#RFC 文档解释

rfc793 文档里的第 34 页里,有说到这个例子。
image.png 原文的解释我也贴出来给大家看看。

  • When the SYN arrives at line 3, TCP B, being in a synchronized state, and the incoming segment outside the window, responds with an acknowledgment indicating what sequence it next expects to hear (ACK 100).
  • TCP A sees that this segment does not acknowledge anything it sent and, being unsynchronized, sends a reset (RST) because it has detected a half-open connection.
  • TCP B aborts at line 5.
  • TCP A willcontinue to try to establish the connection;

我就不瞎翻译了,意思和我在前面用中文说的解释差不多。

#源码分析

处于 establish 状态的服务端如果收到了客户端的 SYN 报文时,内核会调用这些函数:
tcp_v4_rcv -> tcp_v4_do_rcv -> tcp_rcv_established -> tcp_validate_incoming -> tcp_send_ack
我们只关注 tcp_validate_incoming 函数是怎么处理 SYN 报文的,精简后的代码如下:
image.png 从上面的代码实现可以看到,处于 establish 状态的服务端,在收到报文后,首先会判断序列号是否在窗口内,如果不在,则看看 RST 标记有没有被设置,如果有就会丢掉。然后如果没有 RST 标志,就会判断是否有 SYN 标记,如果有 SYN 标记就会跳转到 syn_challenge 标签,然后执行 tcp_send_challenge_ack 函数。
tcp_send_challenge_ack 函数里就会调用 tcp_send_ack 函数来回复一个携带了正确序列号和确认号的 ACK 报文。

#如何关闭一个 TCP 连接?

这里问题大家这么一个问题,如何关闭一个 TCP 连接?
可能大家第一反应是「杀掉进程」不就行了吗?
是的,这个是最粗暴的方式,杀掉客户端进程和服务端进程影响的范围会有所不同:

  • 在客户端杀掉进程的话,就会发送 FIN 报文,来断开这个客户端进程与服务端建立的所有 TCP 连接,这种方式影响范围只有这个客户端进程所建立的连接,而其他客户端或进程不会受影响。
  • 而在服务端杀掉进程影响就大了,此时所有的 TCP 连接都会被关闭,服务端无法继续提供访问服务。

所以,关闭进程的方式并不可取,最好的方式要精细到关闭某一条 TCP 连接。
有的小伙伴可能会说,伪造一个四元组相同的 RST 报文不就行了?
这个思路很好,但是不要忘了还有个序列号的问题,你伪造的 RST 报文的序列号一定能被对方接受吗?
如果 RST 报文的序列号不能落在对方的滑动窗口内,这个 RST 报文会被对方丢弃的,就达不到关闭的连接的效果。
所以,要伪造一个能关闭 TCP 连接的 RST 报文,必须同时满足「四元组相同」和「序列号正好落在对方的滑动窗口内」这两个条件。
直接伪造符合预期的序列号是比较困难,因为如果一个正在传输数据的 TCP 连接,滑动窗口时刻都在变化,因此很难刚好伪造一个刚好落在对方滑动窗口内的序列号的 RST 报文。
办法还是有的,我们可以伪造一个四元组相同的 SYN 报文,来拿到“合法”的序列号!
正如我们最开始学到的,如果处于 establish 状态的服务端,收到四元组相同的 SYN 报文后,会回复一个 Challenge ACK,这个 ACK 报文里的「确认号」,正好是服务端下一次想要接收的序列号,说白了,就是可以通过这一步拿到服务端下一次预期接收的序列号。
然后用这个确认号作为 RST 报文的序列号,发送给服务端,此时服务端会认为这个 RST 报文里的序列号是合法的,于是就会释放连接!
在 Linux 上有个叫 killcx 的工具,就是基于上面这样的方式实现的,它会主动发送 SYN 包获取 SEQ/ACK 号,然后利用 SEQ/ACK 号伪造两个 RST 报文分别发给客户端和服务端,这样双方的 TCP 连接都会被释放,这种方式活跃和非活跃的 TCP 连接都可以杀掉。
使用方式也很简单,只需指明客户端的 IP 和端口号。
./killcx :<端口号>
killcx 工具的工作原理,如下图。
image.png
它伪造客户端发送 SYN 报文,服务端收到后就会回复一个携带了正确「序列号和确认号」的 ACK 报文(Challenge ACK),然后就可以利用这个 ACK 报文里面的信息,伪造两个 RST 报文:

  • 用 Challenge ACK 里的确认号伪造 RST 报文发送给服务端,服务端收到 RST 报文后就会释放连接。
  • 用 Challenge ACK 里的序列号伪造 RST 报文发送给客户端,客户端收到 RST 也会释放连接。

正是通过这样的方式,成功将一个 TCP 连接关闭了!
这里给大家贴一个使用 killcx 工具关闭连接的抓包图,大家多看看序列号和确认号的变化。
image.png 所以,以后抓包中,如果莫名奇妙出现一个 SYN 包,有可能对方接下来想要对你发起的 RST 攻击,直接将你的 TCP 连接断开!