https://zhuanlan.zhihu.com/p/567088021?utm_id=0
首先我们说下状态 TIME_WAIT 出现的原因
TCP的新建连接,断开连接的流程和各个状态,如下图所示
由上图可知:TIME_WAIT 是主动断开连接的一方会出现的,客户端,服务器都有可能出现
当客户端主动断开连接时,发出最后一个ACK后就会处于 TIME_WAIT状态
当服务器主动断开连接时,发出最后一个ACK后就会处于 TIME_WAIT状态
结论:TIME_WAIT 是必然会出现的状态,是正常现象,且会定时回收
TIME_WAIT 状态持续2MSL时间,MSL就是maximum segment lifetime(最大报文段的生命期),这是一个IP数据包能在互联网上生存的最长时间,超过这个时间将在网络中消失(被丢弃)。RFC 793中规定MSL为2分钟,实际应用中,可能为30S,1分钟,2分钟。
我的系统是ubuntu,输入如下命令后可以看到,时间为60秒
请注意两个状态,一个是TIME_WAIT,一个是CLOSE_WAIT,完全不同的两个状态
TIME_WAIT 出现在主动断开方,发出最后一个ACK后
CLOSE_WAIT 出现在被动断开方,收到主动断开方的FIN,发出自己的ACK后
1. 为了可靠地关闭TCP连接
举例:我们把主动断开连接的一方称为C端,被动断开连接的一方称为S端,由于网络不可靠,C端发送的最后一个ACK报文可能没成功发送到S端,那么S端就会重新发上一个报文即FIN,如果C端处于TIME_WAIT状态下,就可以重新发送报文ACK,然后重新计时2MSL时间才会进入CLOSED状态,S端收到ACK后就可以正常关闭TCP连接了。反之,如果这时C端处于 CLOSED 状态 ,就会响应 RST报文而不是ACK报文,那S端会认为这是一个错误,只能异常关闭TCP连接
2. 防止上一次连接中的包,迷路后重新出现,影响新连接
由于网络的不可靠,TCP分节可能因为路由器异常而“迷途”,在迷途期间,TCP发送端会因确认超时而重发这个分节,这个分节最终被发送到对方时,对方可能已经是一个新的连接了,由此造成混乱。举例:关闭一个TCP链接后,马上又创建了一个相同的IP地址和端口之间的TCP链接,后一个链接被称为前一个链接的化身(incarnation),那么此时有可能出现这种状况,前一个链接的迷途重复分节在前一个链接终止后出现了,从而被误解成从属于新的连接的数据。为了不出现这种混乱,TCP不容许处于TIME_WAIT状态的连接立即启动一个新连接,由于TIME_WAIT状态持续2MSL,就能够保证当成功创建一个TCP链接的时候,来自前一个连接的迷途重复分节已经在网络中消逝
注意close() 和 shutdown()的区别
close()其实只是将socket fd的引用计数减1,只有当该socket fd的引用计数减至0时,TCP传输层才会发起4次握手从而真正关闭连接。而shutdown则可以直接发起关闭连接所需的4次握手,而不用受到引用计数的限制
close()会终止TCP的双工链路。由于TCP连接的全双工特性,可能会存在这样的应用场景:local peer不会再向remote peer发送数据,而remote peer可能还有数据需要发送过来,在这种情况下,如果local peer想要通知remote peer自己不会再发送数据但还会继续收数据这个事实,用close()是不行的,而shutdown()可以完成这个任务
会引发以下问题
1. 由于处于TIME_WAIT状态,连接并未关闭,占据了大量的CPU,内存,文件描述符等,造成新的连接无法建立,客户端表现就是连接失败
2. 如果服务器上同时有nginx,且nginx由于反向代理,那么还会占用很多端口(S端处于TIME_WAIT,该连接的另一方即C端需独占一个端口,C端是由nginx代理建立的),要知道端口是有限的,最多65535,一旦端口占用完,无论服务器配置如何高,新连接都无法建立了,客户端表现仍然是连接失败
1. 业务上使用了持续且大量的短连接,纯属设计缺陷,例如爬虫服务器就有可能出现这样的问题
2. http请求中connection的值被设置成close,因为服务器处理完http请求后会主动断开连接,然后这个连接就处于TIME_WAIT状态了。持续时间长且量级较大的话,问题就显现出来了。http洗衣1.0中,connection默认为close,但在http1.1中connection默认行为是keep-alive,就是因为这个原因
3. 服务器被攻击了,攻击方采用了大量的短连接
重点:解决办法
1. 代码层修改,把短连接改为长连接,但代价较大
2. 修改 ip_local_port_range,增大可用端口范围,比如1024 ~ 65535
3. 客户端程序中设置socket的 SO_LINGER 选项
4. 打开 tcp_tw_recycle 和tcp_timestamps 选项,有一定风险,且linux4.12之后被废弃
5. 打开 tcp_tw_reuse 和 tcp_timestamps 选项
6. 设置 tcp_max_tw_buckets 为一个较小的值
下面我们开始对各个办法进行详细讲解
办法1:代码层修改,把短连接改为长连接
由于TIME_WAIT出现的根本原因是高并发且持续的短连接,所以如果能把短连接改成长连接,就能彻底解决问题。比如http请求中的connection设置为keep-alive。只是代码层的修改往往会比较大,不再啰嗦
办法2. 修改 ip_local_port_range,增大可用端口范围
我这里linux系统是ubuntu,输入如下命令可查看可用的端口范围
默认差不多有3万个,我们可以修改这个值,但是注意最小不能小于1024,最大不能大于65535,也就是说改完之后最多有6万多个可用端口
只是一般线上遇到大量的TIME_WAIT,都是高并发且持续的短连接,单纯扩大端口范围并不能从根本上解决问题,只是能多撑一会儿
办法3. 客户端程序中设置socket的 SO_LINGER 选项
SO_LINGER 选项可以用来控制调用close函数关闭连接后的行为,linger的定义如下
struct linger {
int l_onoff; /* 0 = off, nozero = on */
int l_linger; /* linger time */
有三种情况
1. 设置 l_onoff 为0,l_linger的值会被忽略,也是内核缺省的情况,和不设置没区别。close调用会立即返回给调用者,TCP模块负责尝试发送残留的缓冲区数据,会经过通常四分组终止序列(FIN/ACK/FIN/ACK),不能解决任何问题
2. 设置 l_onoff 为1,l_linger为0,则连接立即终止,TCP将丢弃残留在发送缓冲区中的任何数据并发送一个RST报文给对方,而不是通常的四分组终止序列。对方收到RST报文后直接进入CLOSED状态,从根本上避免了TIME_WAIT状态
3. 设置 l_onoff 为1,l_linger > 0,有两种情况
a. 如果socket为阻塞的,则close将阻塞等待l_linger 秒的时间。如果在l_linger秒时间内TCP模块成功发送完残留在缓冲区的数据,则close返回0,表示成功。如果l_linger时间内TCP模块没有成功发送残留的缓冲区数据,则close返回-1,表示失败,并将errno设置为EWOULDBLOCK
b. 如果socket为非阻塞的,那么close立即返回,此时需要根据close返回值以及errno来判断TCP模块是否成功发送残留在缓冲区的数据
第3中情况,其实就是第1种和第2种的折中处理,且当socket为非阻塞的场景下是没有作用的
综上所述:第2种情况,也就是l_onoff为1,l_linger不为0,可以用于解决服务器大量TIME_WAIT的问题
只是Linux上测试的时候,并未发现发送了RST报文,而是正常进行了四步关闭流程,
初步推断是“只有在丢弃数据的时候才发送RST”,如果没有丢弃数据,则走正常的关闭流程
查看Linux源码,确实有这么一段注释和源码
/* As outlined in RFC 2525, section 2.17, we send a RST here because
* data was lost. To witness the awful effects of the old behavior of
* always doing a FIN, run an older 2.1.x kernel or 2.0.x, start a bulk
* GET in an FTP client, suspend the process, wait for the client to
* advertise a zero window, then kill -9 the FTP client, wheee...
* Note: timeout is always zero in such a case.
*/
if (data_was_unread) {
/* Unread data was tossed, zap the connection. */
NET_INC_STATS_USER(sock_net(sk), LINUX_MIB_TCPABORTONCLOSE);
tcp_set_state(sk, TCP_CLOSE);
tcp_send_active_reset(sk, sk->sk_allocation);
}
从原理上来说,这个选项有一定的危险性,可能导致丢数据,使用的时候要小心一些
从实测情况来看,打开这个选项后,服务器TIME_WAIT连接数为0,且不受网络组网(例如是否虚拟机等)的影响
办法4. 打开 tcp_tw_recycle 和tcp_timestamps 选项,有一定风险,且linux4.12之后被废弃
官方文档中解释如下:
tcp_tw_recycle 选项作用为:Enable fast recycling TIME-WAIT sockets. Default value is 0.
tcp_timestamps 选项作用为:Enable timestamps as defined in RFC1323. Default value is 1
这两个选项是linux内核提供的控制选项,和具体的应用程序没有关系,而且网上也能够查询到大量的相关资料,但信息都不够完整,最主要的几个问题如下;
1)快速回收到底有多快?
2)有的资料说只要打开tcp_tw_recycle即可,有的又说要tcp_timestamps同时打开,到底是哪个正确?
3)为什么从虚拟机NAT出去发起客户端连接时选项无效,非虚拟机连接就有效?为了搞清楚上面的疑问,只能看代码,看出一些相关的代码供大家参考:
=====linux-2.6.37 net/ipv4/tcp_minisocks.c 269======
void tcp_time_wait(struct sock *sk, int state, int timeo)
{
struct inet_timewait_sock *tw = NULL;
const struct inet_connection_sock *icsk = inet_csk(sk);
const struct tcp_sock *tp = tcp_sk(sk);
int recycle_ok = 0;
// 判断是否快速回收,这里可以看出tcp_tw_recycle和tcp_timestamps两个选项都打开的时候才进行快速回收,
//且还有进一步的判断条件,后面会分析,这个进一步的判断条件和第三个问题有关
if (tcp_death_row.sysctl_tw_recycle && tp->rx_opt.ts_recent_stamp)
recycle_ok = icsk->icsk_af_ops->remember_stamp(sk);
if (tcp_death_row.tw_count < tcp_death_row.sysctl_max_tw_buckets)
tw = inet_twsk_alloc(sk, state);
if (tw != NULL) {
struct tcp_timewait_sock *tcptw = tcp_twsk((struct sock *)tw);
//计算快速回收的时间,等于 RTO * 3.5,回答第一个问题的关键是RTO(RetransmissionTimeout)大概是多少
const int rto = (icsk->icsk_rto << 2) - (icsk->icsk_rto >> 1);
//。。。。。。此处省略很多代码。。。。。。
if (recycle_ok)
{
//设置快速回收的时间
tw->tw_timeout = rto;
}
else
{
tw->tw_timeout = TCP_TIMEWAIT_LEN;
if (state == TCP_TIME_WAIT)
timeo = TCP_TIMEWAIT_LEN;
}
//。。。。。。此处省略很多代码。。。。。。
}
这里讲下RTO(Retransmission Time Out):重传超时时间,即从数据发送时刻算起,超过这个时间便执行重传
RFC中有关于RTO计算的详细规定,一共有三个:RFC-793、RFC-2988、RFC-6298,Linux的实现是参考RFC-2988。
对于这些算法的规定和Linux的实现,有兴趣的同学可以自己深入研究,实际应用中我们只要记住Linux如下两个边界值:
=====linux-2.6.37 net/ipv4/tcp.c 126================
#define TCP_RTO_MAX ((unsigned)(120*HZ))
#define TCP_RTO_MIN ((unsigned)(HZ/5))
==========================================
这里的HZ是1s,因此可以得出RTO最大是120s,最小是200ms,对于局域网的机器来说,正常情况下RTO基本上就是200ms,因此3.5 RTO就是700ms
也就是说,快速回收是TIME_WAIT的状态持续700ms,而不是正常的2MSL
实测结果也验证了这个推论,不停的查看TIME_WAIT状态的连接,偶尔能看到1个
最后一个问题是为什么从虚拟机发起的连接即使设置了tcp_tw_recycle和tcp_timestamps,也不会快速回收,继续看代码:
tcp_time_wait函数中的代码行:recycle_ok = icsk->icsk_af_ops->remember_stamp(sk);对应的实现如下:
=====linux-2.6.37 net/ipv4/tcp_ipv4.c 1772=====
int tcp_v4_remember_stamp(struct sock *sk)
{
//。。。。。。此处省略很多代码。。。。。。
//当获取对端信息时,进行快速回收,否则不进行快速回收
if (peer)
{
if ((s32)(peer->tcp_ts - tp->rx_opt.ts_recent) <= 0 ||
((u32)get_seconds() - peer->tcp_ts_stamp > TCP_PAWS_MSL &&
peer->tcp_ts_stamp <= (u32)tp->rx_opt.ts_recent_stamp))
{
peer->tcp_ts_stamp = (u32)tp->rx_opt.ts_recent_stamp;
peer->tcp_ts = tp->rx_opt.ts_recent;
}
if (release_it)
inet_putpeer(peer);
return 1;
}
return 0;
}
上面这段代码应该就是测试的时候虚拟机环境不会释放的原因,当使用虚拟机NAT出去的时候,服务器无法获取隐藏在NAT后的机器信息。
生产环境也出现了设置了选项,但TIME_WAIT连接数达到4W多的现象,可能和虚拟机有关,也可能和组网有关。
总结一下:
1)快速回收到底有多快?
答:局域网环境下,700ms就回收
2)有的资料说只要打开tcp_tw_recycle即可,有的又说要tcp_timestamps同时打开,到底是哪个正确?
答:需要同时打开,但默认情况下tcp_timestamps就是打开的,所以会有人说只要打开tcp_tw_recycle即可
3)为什么从虚拟机发起客户端连接时选项无效,非虚拟机连接就有效?
答:和网络组网有关系,无法获取对端信息时就不进行快速回收。
注意1
NAT环境下,打开 tcp_tw_recycle选项可能会引发其他问题,tcp_tw_recycle是依赖tcp_timestamps参数的。例如办公室的外网地址只有一个,所有人访问后台都会通过路由器做SNAT将内网地址映射为公网IP,由于服务端和客户端都启用了tcp_timestamps,因此TCP头部中增加时间戳信息,而在服务器看来,同一客户端的时间戳必然是线性增长的,但是,由于我的客户端网络环境是NAT,因此每台主机的时间戳都是有差异的,在启用tcp_tw_recycle后,一旦有客户端断开连接,服务器可能就会丢弃那些时间戳较小的客户端的SYN包,这也就导致了网站访问极不稳定。
简单来说就是,Linux会丢弃所有来自远端的timestramp时间戳小于上次记录的时间戳(由同一个远端发送的)的任何数据包。也就是说要使用该选项,则必须保证数据包的时间戳是单调递增的。同时从4.10内核开始,官方修改了时间戳的生成机制,所以导致 tcp_tw_recycle 和新时间戳机制工作在一起不那么友好,同时 tcp_tw_recycle 帮助也不那么的大。
此处的时间戳并不是我们通常意义上面的绝对时间,而是一个相对时间。很多情况下,我们是没法保证时间戳单调递增的,比如业务服务器之前部署了NAT,LVS等情况。相信很多小伙伴上班的公司大概率实用实用各种公有云,而各种公有云的 LVS 网关都是 FullNAT 。所以可能导致在高并发的情况下,莫名其妙的 TCP 建联不是那么顺畅或者丢连接
主机A SIP:P1 (时间戳T0) —> Server
主机A断开后
主机B SIP:P1 (时间戳T1) T1 < T0 —> Server 丢弃
注意2
在linux内核版本从4.12之后,tcp_tw_recycle已经被废弃了
我的linux机器是ubuntu,查看内核版本如下
linux 内核版本已经4.15了,如果开发tcp_tw_recycle,执行命令刷新的话,就会有如下报错
提示得很明确,这个文件不存在
sysctl: cannot stat /proc/sys/net/ipv4/tcp_tw_recycle: No such file or directory
综上
可以看出这种方法不是很保险,在实际应用中可能受到虚拟机、网络组网、防火墙之类的影响从而导致不能进行快速回收。
办法5. 打开tcp_tw_reuse和tcp_timestamps选项
官方文档中解释如下:
tcp_tw_recycle选项:Allow to reuse TIME-WAIT sockets for new connections when it is
safe from protocol viewpoint. Default value is 0
这里的关键在于“协议什么情况下认为是安全的”,由于环境限制,没有办法进行验证,通过看源码简单分析了一下
=====linux-2.6.37 net/ipv4/tcp_ipv4.c 114=====
int tcp_twsk_unique(struct sock *sk, struct sock *sktw, void *twp)
{
const struct tcp_timewait_sock *tcptw = tcp_twsk(sktw);
struct tcp_sock *tp = tcp_sk(sk);
/* With PAWS, it is safe from the viewpoint
of data integrity. Even without PAWS it is safe provided sequence
spaces do not overlap i.e. at data rates <= 80Mbit/sec.
Actually, the idea is close to VJ's one, only timestamp cache is
held not per host, but per port pair and TW bucket is used as state
holder.
If TW bucket has been already destroyed we fall back to VJ's scheme
and use initial timestamp retrieved from peer table.
*/
//从代码来看,tcp_tw_reuse选项和tcp_timestamps选项也必须同时打开;否则tcp_tw_reuse就不起作用
//另外,所谓的“协议安全”,从代码来看应该是收到最后一个包后超过1s
if (tcptw->tw_ts_recent_stamp &&
(twp == NULL || (sysctl_tcp_tw_reuse &&
get_seconds() - tcptw->tw_ts_recent_stamp > 1)))
{
tp->write_seq = tcptw->tw_snd_nxt + 65535 + 2;
if (tp->write_seq == 0)
tp->write_seq = 1;
tp->rx_opt.ts_recent = tcptw->tw_ts_recent;
tp->rx_opt.ts_recent_stamp = tcptw->tw_ts_recent_stamp;
sock_hold(sktw);
return 1;
}
return 0;
}
总结一下:
1. tcp_tw_reuse选项和tcp_timestamps选项也必须同时打开;
2. 重用TIME_WAIT的条件是收到最后一个包后超过1s。
官方手册有一段警告:
It should not be changed without advice/request of technical experts.
对于大部分局域网或者公司内网应用来说,满足条件都是没有问题的,因此官方手册里面的警告其实也没那么可怕
办法6:设置tcp_max_tw_buckets为一个较小的值,要比可用端口范围小,比如可用端口范围为6万,这个值可以设置为5.5万
tcp_max_tw_buckets - INTEGER
官方文档解释如下
Maximal number of timewait sockets held by system simultaneously. If this number is exceeded time-wait socket is immediately destroyed and warning is printed.
翻译一下:内核持有的状态为TIME_WAIT的最大连接数。如果超过这个数字,新的TIME_WAIT的连接会被立即销毁,并打印警告
官方文档没有说明默认值,通过几个系统的简单验证,初步确定默认值是180000
源码如下
void tcp_time_wait(struct sock *sk, int state, int timeo)
{
struct inet_timewait_sock *tw = NULL;
const struct inet_connection_sock *icsk = inet_csk(sk);
const struct tcp_sock *tp = tcp_sk(sk);
int recycle_ok = 0;
if (tcp_death_row.sysctl_tw_recycle && tp->rx_opt.ts_recent_stamp)
recycle_ok = icsk->icsk_af_ops->remember_stamp(sk);
// 这里判断TIME_WAIT状态的连接数是否超过上限
if (tcp_death_row.tw_count < tcp_death_row.sysctl_max_tw_buckets)
tw = inet_twsk_alloc(sk, state);
if (tw != NULL)
{
//分配成功,进行TIME_WAIT状态处理,此处略去很多代码
}
else
{
//分配失败,不进行处理,只记录日志: TCP: time wait bucket table overflow
/* Sorry, if we're out of memory, just CLOSE this
* socket up. We've got bigger problems than
* non-graceful socket closings.
*/
NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_TCPTIMEWAITOVERFLOW);
}
tcp_update_metrics(sk);
tcp_done(sk);
}
官方手册中有一段警告:
This limit exists only to prevent simple DoS attacks, you _must_ not lower the limit artificially,
but rather increase it (probably, after increasing installed memory), if network conditions require more than default value.
基本意思是这个用于防止Dos攻击,我们不应该人工减少,如果网络条件需要的话,反而应该增加。
但其实对于我们的局域网或者公司内网应用来说,这个风险并不大