第一次近距离的接触TIME_WAIT

  之前有一个做教师的朋友跟我说,学习网络协议真是死板且没有技术含量的事情了,照着RFC抓抓包比对比对也就完事了,感觉也没啥用就当是消遣了。不过我倒一直没有否认学习TCP/IP的价值,虽然现代开发迭代速度越来越高效,各种框架、上层协议都帮忙把该做的事情、该避开的坑都处理好了,但实际上要知道开发一个稳定、高效的网络服务是很有挑战性的事情,而且通常网络问题都比较难跟踪调试,此时TCP/IP知识作为基本计算机素养的价值就立马体现出来了,了解相关知识和特性在某种程度上可以帮助快速定位和解决问题。

一、事故缘由

  先把事情的缘由道来:某个业务通过Apache CGI的方式请求服务,之前运行的妥妥地也一直没有出现任何问题。最近通过服务优化(采用多线程增加并发数,数据库增加索引),然后五万多笔交易之前需要一个多小时处理完的,现在3~4分钟就立马搞定了。不过尝到优化后提高效率的喜悦的同时,发现每次批量请求持续两分多钟之后就会有几百上千笔的高频率失败,表现为:客户端libcurl返回Cound’t connect to server,而且这种错误是请求开始后就立刻返回的,期间几乎没有延时,Apache也没有相关的请求日志和错误报告,而且离奇的是约过几秒之后这个问题又离奇地自动消失了。
  线索就像上面描述的这么多,无论是Apache日志还是系统dmesg都没有什么异常输出,那么这个时候就只能凭求经验感觉(连蒙带猜)了。因为之前小组在讨论系统优化的时候就留意过socket短链接导致大量TIME_WAIT端口的问题,而这次事故也就恰巧联想到这个问题了。虽然之前讨论的是针对我们自研的用socket直连开发的服务端和客户端,不过TIME_WAIT是TCP协议中四次挥手的标配,以致于标准的libcurl和Apache也不能躲避这个问题了。
  有了上面的提示,分析问题来就思路清晰了。我们CentOS6服务器使用的默认配置,在生产系统中TIME_WAIT默认时长就是等待120s,同时服务器默认的本地端口范围是32768~61000,合计起来约摸也就是28k左右。这跟我们故障的现象也恰巧是吻合的:系统出现异常大概在两分钟左右,而我们计算问题出现的时候大概请求了近3万笔订单,所以按照这个速度估计这个时间段中所有的本地端口都处于TIME_WAIT状态,没有办法再新建TCP请求了,等过了几秒钟之后先前的TIME_WAIT端口超时结束后就释放出来作为可用端口,所以服务又慢慢地恢复正常了。不过这里不禁要感叹的是:其实客户端的请求速度和local_port_range / 120s还是比较匹配的,毕竟出现异常的笔数只有几百上千笔,设想如果提交的速度再低一点,那么这个问题很有可能就不会暴露;如果提交的速度再快一点,那么肯定会出现更多的异常请求,而且该问题应该会持续堆积发生而不是只出现3~4秒就立马恢复正常了。还要就是我们用的标准libcurl发起客户端的,而日志中也没有做响应的检查输出,设想如果我们手动socket创建连接,那么这个系统调用应该是会出错有一个特定错误码返回的。
  不过同样的CentOS系统有的机器TIME_WAIT时长是60s,有的机器时长是120s,而查看内核头文件TCP_TIMEWAIT_LEN宏定义都是60,感觉比较奇怪。还有网上很多说修改tcp_fin_timeout可以影响这个时长是不对的,这个tcp_fin_timeout表示主动关闭端等待LAST FIN的时长,其实也就是FIN_WAIT_2的最大时长。总之,如果TIME_WAIT是60s,默认机器客户端请求超过470tps就会有问题,如果是120s这个值再减半,这个值算是系统短连接请求的理论极限了。

二、TIME_WAIT介绍

  在TCP中只有主动关闭的那一方会经历TIME_WAIT状态,这个状态的持续时间是2MSL(最长分节生命周期),对于MSL这个值RFC推荐是120s,而BSD的实现上TCP_TIMEWAIT_LEN一般是30s,不过实践中甚至很多激进分子将其改成了1s。
  这里我们先将TCP关闭连接的正常过程描述一下。这里假设客户端发起了主动关闭,那么四次挥手的顺序就是:客户端发送FIN并进入FIN_WAIT_1状态;服务端收到FIN后被动关闭并处于CLOSE_WAIT状态,同时发送ACK确认;客户端收到ACK后切换到FIN_WAIT_2的状态;此时一个方向的关闭已经完成了,如果此时服务端想要关闭连接,则再向客户端发送FIN,自己转换成LAST_ACK状态;服务端收到这个FIN后发送ACK,此时进入TIME_WAIT状态并执行2MSL计时;服务端收到ACK后直接进入CLOSE状态。
  如果通信的双方在收到对方ACK之前同时发送FIN,则双方都算是主动关闭,两端都会进入TIME_WAIT状态。
  TCP中让主动断开的一方保持TIME_WAIT状态的理由是:
  1. 可靠地实现TCP全双工连接的完美终止
  如果服务器(被动关闭端)没有收到确认自己FIN的最终ACK,则服务端可以重新发送那个FIN,所以客户端必须维持这个连接以允许服务端重新发送FIN后可以正确返回ACK,否则如果该连接已经不存在了,而服务端重新发送FIN的话默认系统将会响应一个RST,这对服务端来说是不够友好的,将会被识别成一个错误。
  2. 允许老的重复分组在网络中消逝,即防止串话
  如果客户端关闭后,客户端系统立即使用相同的IP:Port再次连接服务端相同的地址,造成了相同的四元组连接,此时后一个连接称为前一个连接的化生。为了防止之前连接的某些分组在新连接建立后再次出现,而被误认为是这个新连接的数据(防止串话),TCP不允许处于TIME_WAIT状态的连接发起新的化生,即该四元组处于暂时不可用状态,而约定的2MSL时间足以让某个方向上的报文被最终丢弃掉。虽然通常来说串话的情形只有在旧的报文序列号刚好落在接收端窗口中才可能导致问题,因为报文如果接收窗口之外那么接收端会重新ACK告知发送端自己的序列号,这种情况下不会有任何问题,但是在高速网络、大接收窗口下还是有可能会导致问题发生的。通过这种方式,就保证每次成功建立新连接的时候,来至该连接先前化生的所有老的分组都已经在网络中消逝了,他们是不会作为新连接的报文来解读的。
  在通常的应用开发中,都是客户端执行主动关闭会进入TIME_WAIT,而服务端执行被动关闭不会进入TIME_WAIT,客除户端一般都是操作系统自动分配临时端口号的,所以新建连接不会有什么问题,除非客户端机器关闭和建立连接的速度太快(我们上面遇到的问题)。如果客户端和服务端在同一台机器,那么处于TIME_WAIT的连接的服务端地址和端口也会被考量,此时如果立即重启服务端则系统不会允许该服务端再绑定这个端口,服务端开发通常或者说必须使用SO_REUSEADDR来绕过这个限制。
  在实践中TIME_WAIT也有可能被提前终止,除了下面将要描述的SO_LINGER选项可以通过RST来让对端退出TIME_WAIT的情况之外,如果处于TIME_WAIT的socket再度收到了之前分段的重复到达,而恰好这个分段是该连接无法接受的(比如在接收窗口之外),那么按照协议TCP会以一个ACK响应告知对端期待的接收序列号,但是此时被动关闭端已经不存在了,系统就会以一个RST来响应这个ACK,而当处于TIME_WAIT状态的连接收到RST后会立即进行关闭,从而退出TIME_WAIT状态。

三、处理方法

  发现上面问题的根源后,解决方式当然也就有针对性了,最终要实现的目标就是不要让处于TIME_WAIT的端口占满所有本地端口,导致没有新的本地端口用来创建新的客户端。
  1. 别让客户端的速率太快
  似乎上面的案例告诉我们别优化用力过猛,否则容易扯到蛋……将客户端请求的速率降下来就可以避免端时间占用大量的端口,吞吐量限制就是470tps或者235tps,具体根据系统TIME_WAIT默认时长决定,如果考虑到其他服务正常运行这个值还要保守一些才行;此外还需要注意,如果客户端和服务端增加了一层NAT或者L7负载均衡,那么这个限制可能会在负载均衡器上面;
  2. 客户端改成长连接的形式
  长连接效率高又不会产生大量TIME_WAIT端口。目前对我们来说还是不太现实的,虽然HTTP支持长连接,但是CGI调用应该是不可能的了,除非用之前的介绍的方式将CGI的请求转换成HTTP服务来实现。对于一般socket直连的程序来说,短连接改成长连接就需要额外的封装来标识完整请求在整个字节流中的起始位置,需要做一些额外的工作;
  3. SO_LINGER选项
  通常我们关闭socket的时候,即使该连接的缓冲区有数据要发送,close调用也会立即返回,TCP本身会尝试发送这些未发送出去的数据,只不过应用程序不知道也无法知道是否发送成功过了。如果我们将套接字设置SO_LINGER这个选项,并填写linger结构设置参数,就可以控制这种行为:
  如果linger结构的l_onoff==0,则linger选项就被关闭,其行为就和默认的close相同;如果打开,那么具体行为依据另外一个成员l_linger的值来确定:如果l_linger!=0,则内核会将当前close调用挂起,直到数据都发送完毕,或者设置的逗留时间超时返回,前者调用会返回0并且正常进入TIME_WAIT状态,后者调用会返回EWOULDBLOCK,所有未发送出去的数据可能会丢失(此处可能会向对端发送一个RST而快速关闭连接);如果l_linger==0,则直接将缓冲区中未发送的数据丢弃,且向对等实体发送一个RST,自己不经过TIME_WAIT状态立即关闭连接。
  我们都认为TIME_WAIT是TCP机制的正常组成部分,应用程序中不应该依赖设置l_linger=0这种机制避免TIME_WAIT。
  4. 修改系统参数
  (a). 增加本地端口范围,修改net.ipv4.ip_local_port_range,虽然不能解决根本问题但情况可以得到一定的缓解;
  (b). 缩短TIME_WAIT的时间。这个时长在书中描述到RFC推荐是2min,而BSD实现通常是30s,也就说明这个值是可以减小的,尤其我们用在内网通信的环境,数据包甚至都流不出路由器,所以根本不需要设置那么长的TIME_WAIT。这个很多资料说不允许修改,因为是写死在内核中的;也有说可以修改netfilter.ip_conntrack_tcp_timeout_time_wait(新版本nf_conntrack_tcp_timeout_time_wait)的,他们依赖于加载nf_conntract_ipv4模块,不过我试了一下好像不起作用。
  (c). 像之前在项目中推荐的,做出如下调整

1
2
3
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_timestamps=1
net.ipv4.tcp_tw_recycle=1

  很多文献说这种设置是不安全的,所以在测试环境以外就别尝试了,因为这些选项还涉及到timestamp特性,我还不清楚什么回事,后面有时间再看什么吧。
  我们在开发服务端的时候,通常都会设置SO_REUSEADDR这个选项。其实像上面描述到的,该选项也牵涉到侦听socket端口处于TIME_WAIT的情况,设置这个选项将允许处于TIME_WAIT的端口进行绑定,更详细信息请看更新过的《再说socket的SO_REUSEPORT选项》

本文完!

参考