TCP网络状态分析
date
Mar 8, 2024
slug
tcp-network-status-analysis
status
Published
tags
编程开发
summary
后台开发中,大多数工作都是建立在网络通信这个过程中,客户端发起连接的请求后,服务端接收客户端的请求数据,服务端处理后给客户端返回响应,然后关闭连接。在这个过程中,网络可能会存在各式各样的问题,导致我们无法正常返回响应,也可能有一些异常的场景导致服务器性能受到影响。因此本文想整理下服务器端在可能会存在的异常场景,以及应对的方式。
type
Post
后台开发中,大多数工作都是建立在网络通信这个过程中,客户端发起连接的请求后,服务端接收客户端的请求数据,服务端处理后给客户端返回响应,然后关闭连接。在这个过程中,网络可能会存在各式各样的问题,导致我们无法正常返回响应,也可能有一些异常的场景导致服务器性能受到影响。因此本文想整理下服务器端在可能会存在的异常场景,以及应对的方式。
首先需要放出老生常谈的一张网络通信图。

我们都知道客户端和服务端并没有所谓的连接,靠的的是在两端维护一些状态从而构造出来存在连接的现象。正因如此,理解这些状态至关重要,它能够帮助我们定位服务器当前的网络状况。
查看状态
当我们需要去服务器上查看当前的网络连接的状态时,通常有两个命令netstat和tcpdump。
netstat
用这个命令我们可以清楚的看到连接的ip和端口,也能看到连接的状态。
tcpdump
tcpdump是一个非常常用的抓包工具,以上的命令就可以对指定ip和端口的tcp连接进行抓包。
SYN_SENT
客户端通过发送SYN包发起对服务端的TCP连接请求,在等待服务端回复的ACK包前,客户端此时便会进入SYN_SENT状态。如果长时间没有收到ACK包,则会重发SYN报文,这个重试的次数由tcp_syn_retries参数控制,默认是6次。
每一次重试的时间间隔是1s、2s、4s、8s、16s、32s,最后一次重试会等待64s,总共耗时1+2+4+8+16+32+64=127秒,也就是超过2分钟后客户端才会终止握手请求。
SYN_RCVD
SYN_RCVD状态是客户端对服务端发起连接后,服务端则会回复SYN+ACK包,之后便会处于这个中间状态,在这个状态下的服务端一方面会建立SYN半连接队列,用来维护未完成的握手信息,当队列溢出后,服务器端则无法再建立新的连接。另一方面则开始等待客户端发来的ACK包,当收到ACK包后,则会将连接从SYN半连接队列中移除并加入到accept连接队列中,等待应用程序调用accept函数处理这个连接。

SYN半连接队列的大小可以通过tcp_max_syn_backlog参数来控制,在请求数较高时可以通过调大这个值来优化性能。
如果服务端一直没有等到客户端发来的ACK包,同样会重发SYN+ACK包给到客户端,这个重试的次数通过tcp_synack_retries参数控制,默认是5次。
每次重发同样都会有一定的间隔,时间间隔分别是1s、2s、4s、8s、16s,在第5次发出后还要再等待32s才知道第5次也超时了。加起来总共63s,此时服务端才会真的认为这个连接无效了,断开这个连接。
如果在服务器端通过抓包工具看到大量连接处于这个状态,或者发现服务器无法接收新的连接时,就得怀疑是否受到了SYN Flood攻击了。
所谓的SYN Flood攻击,就是攻击者通过发起大量的连接建立请求,也就是发送大量的SYN包后就停止了。由于服务器端需要等待63s才能断开连接,大量的SYN连接便把连接的队列沾满了,导致即使是正常的连接请求也无法处理。
为了解决这个问题,Linux提供了一个叫tcp_syncookies的参数。当SYN的队列满了之后,tcp会使用源端口、目标端口和时间戳构造一个特别的Sequence Number发送给客户端,如果对方无法回应,则可以认为是攻击者。如果对方回应了,则可以利用这个SYN Cookie建立连接,即使它没有在队列中。
tcp_syncookies的值可以是如下三个:
- 0:表示关闭该功能
- 1:表示仅当SYN半连接队列满时才开启这个功能
- 2:表示无条件启用该功能。
由于采用tcp_syncookies建立的连接,会导致很多tcp特性无法使用,因此应该将该值设置为1最好。
被移入到accept队列的连接,会等待应用程序调用accept函数来获取,如果获取不及时,同样会导致accept队列溢出,导致连接被丢弃。一方面我们可以通过somaxconn参数来修改队列的长度。
另外也可以通过修改tcp_abort_on_overflow参数的值为1,向客户端发送RST包告知客户端连接建立失败。
这个值默认为0,也建议不要去修改这个值,因为这样设置会更有利于应对突发流量。客户端其实在收到服务端的SYN和ACK包后,状态就已经变成ESTABLISHED了,虽然这时候客户端还会回复一个ACK包给服务端,但其实这个时候客户端已经可以发送请求的数据给服务端了。
此时如果服务端因为accept队列满了,导致客户端发来的这个ACK被丢弃,当系统的tcp_abort_on_overflow值为0时,客户端发来的请求无非就是收不到对应的ACK,会触发客户端的请求重发。等到accept队列有空位时,再次发来的请求报文中由于包含有ACK,则会触发服务端的连接建立。因此在tcp_abort_on_overflow=0下,可以提高连接建立的成功率。除非你非常肯定accept队列会长期溢出,才应该将其设置为1尽快通知客户端。
FIN_WAIT_1、FIN_WAIT_2
主动发起断开的一方,会发送FIN包到对端,此时主动方就会处于FIN_WAIT_1状态,通常来说,被动断开方的内核会自动回复ACK,因此主动方会快速的进入FIN_WAIT_2状态,这个过程一般就几十毫秒,所以我们一般观察不到FIN_WAIT_1状态。
关于断开连接的操作,会分为close和shutdown。
- 两者都会发送FIN报文。
- 调用close后,对端在半断开的状态下发来的数据,主动断开方也不能再接收。
- 采用close断开的连接,被称为孤儿连接,这种连接将不会有进程名。
如果主动断开迟迟没有收到ACK,则会进行重发FIN包,这个重发的次数由tcp_orphan_retries参数控制,默认值是0,代表8次。
我们知道TCP通过滑动窗口进行流量控制,如果接收方的接收窗口被置为0了,发送方也无法发送数据过来。如果此时攻击者发起一个大文件下载的请求,然后将接收窗口设置为0。当服务器端发送FIN报文来断开连接时,此时服务器端会进入FIN_WAIT_1状态,可是这个FIN包却无法发送出去。
这种情况,可以通过tcp_max_orphans参数来调整。它定义了孤儿连接的最大数量,如果孤儿连接数量大于这个值,则新增的孤儿连接将不再走四次挥手,而是直接回复RST复位报文来强制关闭连接。
当主动断开方收到被动方的ACK后,则会进入FIN_WAIT_2状态,但如果主动方是采用shutdown断开的连接,此时可以一直处于FIN_WAIT_2状态,直到对端也发来FIN报文。但如果是采用close方式来断开连接,上面说到这个连接将会是孤儿连接,这个FIN_WAIT_2状态则不可以持续太久,保持一定时间如果还是没有收到对端的FIN报文,则连接就会强制关闭。这个时间通过tcp_fin_timeout参数来控制,默认和time_wait时间相同。
CLOSE_WAIT
CLOSE_WAIT是被动断开方接收到主动断开方连接断开请求FIN包后处于的一个状态,前面也提到此时的被动断开方的内核会自动回复ACK。
为什么会需要有这么一个状态呢?
原因是tcp是全双工通信,当客户端发起断开连接的请求时,只是客户端这边认为自己不再发送数据给服务器端了,但此时服务器端可能还有数据在发送给客户端,所以在这之前需要先有一个状态。等到服务器端也认为自己没有数据要发送给客户端了,也会发送一个连接断开的请求FIN包给到客户端,这个状态才会变成LAST_ACK。
如果我们的服务器出现大量的CLOSE_WAIT状态,长时间处于这个状态说明服务器端在确认没有数据要发给客户端后,也没有发送FIN包给客户端,那么就应该盘查我们的代码是否存在问题了。是不是read()函数调用后忘记调用close()函数关闭连接了。或者是程序负载太高,close函数所在的回调函数被延迟执行了。
TIME_WAIT
主动断开的一方在接收到被动断开方的连接断开请求FIN包后,会回复一个ACK包给对端,此时主动断开方会处于TIME_WAIT的状态,被动端开放则处于LAST_ACK状态。
处于LAST_ACK状态的被动断开方会等待主动方发来最后的ACK报文,如果长时间没有收到也会进行重发FIN报文给主动方,这个重发的次数也是通过上文提到的tcp_orphan_retries参数控制。这也是为什么主动方需要TIME_WAIT状态的其中一个原因。
- 确保最后的ACK包被对方接收:如果对方没有收到最后的ACK包,它将重传最终的FIN包。在TIME_WAIT状态期间,连接仍然可以接收这个重传的FIN包并响应ACK包,确保连接可靠地关闭。
- 允许旧的重复分段过期:等待时间允许网络中延迟的数据包(可能是属于这个连接的旧数据包)在网络中消失。这样可以避免这些旧数据包被误认为是新建立的同一对端口之间的连接的数据。
TIME_WAIT状态持续的时间通常是连接关闭序列中最后一个ACK发送时间的两倍最大段生命周期(Maximum Segment Lifetime,缩写MSL)。MSL是一个时间常量,用于估算一个数据包在网络中能存活的最长时间。如果第一次发送ACK后在1个MSL内丢失了,则被动方重发的FIN报文会在第2个MSL内到达。通常来说第二个ACK也丢失的概率很小,因此2个MSL时间是最好的。
在Linux中,MSL的值固定为30s,2个MSL则为60s,因此TIME_WAIT的时间通常维持60s。
现在我们知道TIME_WAIT是主动断开一方会处于的其中一个状态,如果我们抓包后发现服务器端大量的连接都处于这个状态,是说明什么问题呢?
很显然,说明我们的服务器端正在处理大量的短连接或频繁的连接建立和终止。这是我们应该排查的第一问题,为什么我们的服务器在主动的断开连接。
那如果大量的TIME_WAIT存在于服务器端又会引发什么问题呢?
由于处于TIME_WAIT状态下的连接是不会被回收的,如果服务器端大量的连接都处于这个状态,将会导致端口资源紧张、降低新连接的接受能力,或者在极端情况下,耗尽可用的网络端口,导致新的连接无法建立。
Linux提供了tcp_max_tw_buckets参数,当TIME_WAIT的连接数量超过该参数定义的值时,新关闭的连接就不再经历TIME_WAIT了,而是直接关闭。另外如果服务器处于高并发连接请求,则更应该提高这个参数。
除了调高tcp_max_tw_buckets参数,也可以通过配置tcp_tw_reuse参数(需要双端同时都配置tcp_timestamps)来复用服务器的端口。
在旧版本的Linux中还提供了tcp_tw_recycle参数,它会让系统不再遵循TIME_WAIT应该存在2MSL的原则,可能会导致数据错乱,所以不推荐使用。而在Linux后续的版本中也移除了这个参数。
不过最好的方式还是让服务器端不要主动断开连接,避免建立短连接,并且给连接配置上keepAlive,让客户端主动断开,这样服务器端就不会有TIME_WAIT状态了。
以上就是TCP协议中几个关键的状态的分析了,不得不说TCP真的是一个很复杂的协议啊。