1. 问题描述
有时候用redis客户端(php或者java客户端)连接Redis服务器,报错:“Cannot assign requested address。”
原因是客户端频繁的连接服务器,由于每次连接都在很短时间内结束,导致很多的TIME_WAIT。所以新的连接没办法绑定端口,即“Cannot
assign requested address”。
我们可以通过netstat -nat | grep 127.0.0.1:6380
查看连接127.0.0.1:6380的状态。你会发现很多TIME_WAIT。
很多人想到要用修改内核参数来解决:
执行命令修改如下2个内核参数
sysctl -w net.ipv4.tcp_timestamps=1 开启对于TCP时间戳的支持,若该项设置为0,则下面一项设置不起作用
sysctl -w net.ipv4.tcp_tw_recycle=1 表示开启TCP连接中TIME-WAIT sockets的快速回收
其实不然,根本没有理解出现这个问题的本质原因。首先我们了解Redis处理客户端连接的机制和TCP的TIME_WAIT.
2. Redis处理客户端连接机制
Redis 通过监听一个 TCP 端口或者 Unix socket 的方式来接收来自客户端的连接,当一个连接建立后,Redis 内部会进行以下一些操作:
- 首先,客户端 socket 会被设置为非阻塞模式,因为 Redis 在网络事件处理上采用的是非阻塞多路复用模型。
- 然后为这个socket 设置 TCP_NODELAY 属性,禁用 Nagle 算法
- 然后创建一个 readable 的文件事件用于监听这个客户端 socket 的数据发送
当客户端连接被初始化后,Redis 会查看目前的连接数,然后对比配置好的 maxclients 值,如果目前连接数已经达到最大连接数 maxclients 了,那么说明这个连接不能再接收,Redis 会直接返回客户端一个连接错误,并马上关闭掉这个连接。
如果有多个客户端连接上 Redis,并且都向 Redis 发送命令,那么 Redis 服务端会先处理哪个客户端的请求呢?答案其实并不确定,主要与两个因素有关,一是客户端对应的 socket 对应的数字的大小,二是 kernal 报告各个客户端事件的先后顺序。
Redis 处理一个客户端传来数据的步骤如下:
- 它对触发事件的 socket 调用一次 read(),只读一次(而不是把这个 socket 上的消息读完为止),是为了防止由于某个别客户端持续发送太多命令,导致其它客户端的请求长时间得不到处理的情况。
- 当然,当这一次 read() 调用完成后,它里面无论包含多少个命令,都会被一次性顺序地执行。这样就保证了对各个客户端命令的公平对待。
- 3、关于最大连接数 maxclients
在 Redis2.4 中,最大连接数是被直接硬编码在代码里面的,而在2.6版本中这个值变成可配置的。maxclients 的默认值是 10000,你也可以在 redis.conf 中对这个值进行修改。
当然,这个值只是 Redis 一厢情愿的值,Redis 还会照顾到系统本身对进程使用的文件描述符数量的限制。在启动时 Redis 会检查系统的 soft limit,以查看打开文件描述符的个数上限。如果系统设置的数字,小于咱们希望的最大连接数加32,那么这个 maxclients 的设置将不起作用,Redis 会按系统要求的来设置这个值。(加32是因为 Redis 内部会使用最多32个文件描述符,所以连接能使用的相当于所有能用的描述符号减32)。
当上面说的这种情况发生时(maxclients 设置后不起作用的情况),Redis 的启动过程中将会有相应的日志记录。比如下面命令希望设置最大客户端数量为100000,所以 Redis 需要 100000+32 个文件描述符,而系统的最大文件描述符号设置为10144,所以 Redis 只能将 maxclients 设置为 10144 – 32 = 10112。
$ ./redis-server --maxclients 100000
[41422] 23 Jan 11:28:33.179 # Unable to set the max number of files limit to 100032 (Invalid argument), setting the max clients configuration to 10112.
所以说当你想设置 maxclients 值时,最好顺便修改一下你的系统设置,当然,养成看日志的好习惯也能发现这个问题。
具体的设置方法就看你个人的需求了,你可以只修改此次会话的限制,也可以直接通过sysctl 修改系统的默认设置。如:
ulimit -Sn 100000 # This will only work if hard limit is big enough.
sysctl -w fs.file-max=100000
4、输出缓冲区大小限制
对于 Redis 的输出(也就是命令的返回值)来说,其大小经常是不可控的,可能是一个简单的命令,能够产生体积庞大的返回数据。另外也有可能因为执行命令太多,产生的返回数据的速率超过了往客户端发送的速率,这时也会产生消息堆积,从而造成输出缓冲区越来越大,占用过多内存,甚至导致系统崩溃。
所以 Redis 设置了一些保护机制来避免这种情况的出现,这些机制作用于不同种类的客户端,有不同的输出缓冲区大小限制,限制方式有两种:
- 一种是大小限制,当某一个客户端的缓冲区超过某一大小时,直接关闭掉这个客户端连接
- 另一种是当某一个客户端的缓冲区持续一段时间占用空间过大时,也直接关闭掉客户端连接
对于不同客户端的策略如下:
- 对普通客户端来说,限制为0,也就是不限制,因为普通客户端通常采用阻塞式的消息应答模式,如:发送请求,等待返回,再发请求,再等待返回。这种模式通常不会导致输出缓冲区的堆积膨胀。
- 对于 Pub/Sub 客户端来说,大小限制是32m,当输出缓冲区超过32m时,会关闭连接。持续性限制是,当客户端缓冲区大小持续60秒超过8m,也会导致连接关闭。
- 而对于 Slave 客户端来说,大小限制是256m,持续性限制是当客户端缓冲区大小持续60秒超过64m时,关闭连接。
上面三种规则都是可配置的。可以通过 CONFIG SET 命令或者修改 redis.conf 文件来配置。
5、输入缓冲区大小限制
Redis 对输入缓冲区大小的限制比较暴力,当客户端传输的请求大小超过1G时,服务端会直接关闭连接。这种方式可以有效防止一些客户端或服务端 bug 导致的输入缓冲区过大的问题。
6、Client超时
对当前的 Redis 版本来说,服务端默认是不会关闭长期空闲的客户端的。但是你可以修改默认配置来设置你希望的超时时间。比如客户端超过多长时间无交互,就直接关闭。同理,这也可以通过 CONFIG SET 命令或者修改 redis.conf 文件来配置。
值得注意的是,超时时间的设置,只对普通客户端起作用,对 Pub/Sub 客户端来说,长期空闲状态是正常的。
另外,实际的超时时间可能不会像设定的那样精确,这是因为 Redis 并不会采用计时器或者轮训遍历的方法来检测客户端超时,而是通过一种渐近式的方式来完成,每次检查一部分。所以导致的结果就是,可能你设置的超时时间是10s,但是真实执行的时间是超时12s后客户端才被关闭。
式。
3. TCP的TIME_WAIT状态
主动关闭的Socket端会进入TIME_WAIT状态,并且持续2MSL时间长度,MSL就是maximum segment lifetime(最大分节生命期),在windows下默认240秒,MSL是一个IP数据包能在互联网上生存的最长时间,超过这个时间将在网络中消失。MSL在RFC
1122上建议是2分钟,而源自berkeley的TCP实现传统上使用30秒,因而,TIME_WAIT状态一般维持在1-4分钟。
TIME_WAIT状态存在的理由:
1)可靠地实现TCP全双工连接的终止:(即在TIME_WAIT下等待2MSL,只是为了尽最大努力保证四次握手正常关闭)。
TCP协议规定,对于已经建立的连接,网络双方要进行四次握手才能成功断开连接,如果缺少了其中某个步骤,将会使连接处于假死状态,连接本身占用的资源不会被释放。
在进行关闭连接四路握手协议时,最后的ACK是由主动关闭端发出的,如果这个最终的ACK丢失,服务器将重发最终的FIN,因此客户端必须维护状态信息允许它重发最终的ACK。如果不维持这个状态信息,那么客户端将响应RST分节,因而,要实现TCP全双工连接的正常终止,必须处理终止序列四个分节中任何一个分节的丢失情况,主动关闭的客户端必须维持状态信息进入TIME_WAIT状态。
我们看客户端主动关闭服务器被动关闭四次握手的流程:
1、 客户端发送FIN报文段,进入FIN_WAIT_1状态。
2、 服务器端收到FIN报文段,发送ACK表示确认,进入CLOSE_WAIT状态。
3、 客户端收到FIN的确认报文段,进入FIN_WAIT_2状态。
4、 服务器端发送FIN报文端,进入LAST_ACK状态。
5、 客户端收到FIN报文端,发送FIN的ACK,同时进入TIME_WAIT状态,启动TIME_WAIT定时器,超时时间设为2MSL。
6、 服务器端收到FIN的ACK,进入CLOSED状态。
7、 客户端在2MSL时间内没收到对端的任何响应,TIME_WAIT超时,进入CLOSED状态。
如果不考虑报文延迟、丢失,确认延迟、丢失等情况,TIME_WAIT的确没有存在的必要。当网络在不理想的情况下通常会有报文的丢失延迟发生,让我们看下面的一个特例:
客户端进入发送收到四次握手关闭的最后一个ACK后,进入TIME_WAIT同时发送ACK,如果其不停留2MSL时间,而是马上关闭连接,销毁连接上的资源,当发送如下情况时,将不能正常的完成四次握手关闭:
客户端发送的ACK在网路上丢失,这样服务器端收不到最后的ACK,重传定时器超时,将重传FIN到客户端,由于客户端关于该连接的所有资源都释放,收到重传的FIN后,它没有关于这个FIN的任何信息,所以向服务器端发送一个RST报文端,服务器端收到RST后,认为搞连接出现了异常(而非正常关闭)。
所以,在TIME_WAIT状态下等待2MSL时间端,是为了能够正确处理第一个ACK(最长生存时间为MSL)丢失的情况下,能够收到对端重传的FIN(最长生存时间为MSL),然后重传ACK。
是否只要主动关闭方在TIME_WAIT状态下停留2MSL,四次握手关闭就一定正常完成呢?
答案是否定的?可以考虑如下的情况,
TIME_WAIT状态下发送的ACK丢失,LAST_ACK时刻设定的重传定时器超时,发送重传的FIN,很不幸,这个FIN也丢失,主动关闭方在TIME_WAIT状态等待2MSL没收到任何报文段,进入CLOSED状态,当此时被动关闭方并没有收到最后的ACK。所以即使要主动关闭方在TIME_WAIT状态下停留2MSL,也不一定表示四次握手关闭就一定正常完成。
2)确保老的报文段在网络中消失,不会影响新建立的连接
考虑如下的情况,主动关闭方在TIME_WAIT状态下发送的ACK由于网络延迟的原因没有按时到底(但并没有超过MSL的时间),导致被动关闭方重传FIN,在FIN重传后,延迟的ACK到达,被动关闭方进入CLOSED状态,如果主动关闭方在TIME_WAIT状态下发送ACK后马上进入CLOSED状态(也就是没有等待)2MSL时间,则上述的连接已不存在:
现在考虑下面的情况,假设客户端(192.186.0.1:23) 到服务器192.168.1.1:6380)的TCP连接, 由于连接已关闭,我们可以马上建立一个相同的IP地址和端口之间的TCP连接,并且这个连接也是客户端(192.186.0.1:23) 到服务器192.168.1.1:6380),那么当上一个连接的重传FIN到达主动关闭方时,被新的连接所接受,这将导致新的连接被复位,很显然,这不是我们希望看到的事情。
新的连接要建立,必须是在主动关闭方和被动关闭方都进入到CLOSED状态之后才有可能。所以,最有可能导致旧的报文段影响新的连接的情况是:
在TIME_WAIT状态之前,主动关闭方发送的报文端在网络中延迟,但是TIME_WAIT设定为2MSL时,这些报文端必然会在网络中消失(最大生存时间为MSL)。被动关闭方最有可能影响新连接的报文段就是我们上面讨论的情况,对方ACK延迟到达,在此之前重传的FIN,这个报文端发送之后,TIME_WAIT的定时器超时时间肯定大于MSL,在1MSL时间内,这个FIN要么在网络中因为生成时间到达而消失,要么到达主动关闭方被这确的处理,不会影响新建立的连接。
新的SCTP协议通过在消息头部添加验证标志避免了TIME_WAIT状态。
3)有关内核级别的keepalive和time_wait的优化调整
有关内核级别的keepalive和time_wait的优化调整
vi /etc/sysctl
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_tw_recycle = 1
net.ipv4.tcp_keepalive_time = 1800
net.ipv4.tcp_fin_timeout = 30
net.core.netdev_max_backlog =8096
修改完记的使用sysctl -p 让它生效
以上参数的注解
/proc/sys/net/ipv4/tcp_tw_reuse
该文件表示是否允许重新应用处于TIME-WAIT状态的socket用于新的TCP连接。
/proc/sys/net/ipv4/tcp_tw_recycle
recyse是加速TIME-WAIT sockets回收
对tcp_tw_reuse和tcp_tw_recycle的修改,可能会出现.warning, got duplicate tcp line warning, got BOGUS tcp line.上面这二个参数指的是存在这两个完全一样的TCP连接,这会发生在一个连接被迅速的断开并且重新连接的情况,而且使用的端口和地址相同。但基本 上这样的事情不会发生,无论如何,使能上述设置会增加重现机会。这个提示不会有人和危害,而且也不会降低系统性能,目前正在进行工作
/proc/sys/net/ipv4/tcp_keepalive_time
表示当keepalive起用的时候,TCP发送keepalive消息的频度。缺省是2小时
/proc/sys/net/ipv4/tcp_fin_timeout 最佳值和BSD一样为30
fin_wait1状态是在发起端主动要求关闭tcp连接,并且主动发送fin以后,等待接收端回复ack时候的状态。对于本端断开的socket连接,TCP保持在FIN-WAIT-2状态的时间。对方可能会断开连接或一直不结束连接或不可预料的进程死亡。
/proc/sys/net/core/netdev_max_backlog
该文件指定了,在接口接收数据包的速率比内核处理这些包的速率快时,允许送到队列的数据包的最大数目
4)time_wait的优化处理
net.ipv4.tcp_keepalive_probes = 3
net.ipv4.tcp_keepalive_time = 60
在程序中设置如下:
int keepIdle = 60; // 如该连接在60秒内没有任何数据往来,则进行探测
int keepInterval = 5; // 探测时发包的时间间隔为5 秒
int keepCount = 3; // 探测尝试的次数.如果第1次探测包就收到响应了,则后2次的不再发.
setsockopt(rs, SOL_SOCKET, SO_KEEPALIVE, (void *)&keepAlive, sizeof(keepAlive));
setsockopt(rs, SOL_TCP, TCP_KEEPIDLE, (void*)&keepIdle, sizeof(keepIdle));
setsockopt(rs, SOL_TCP, TCP_KEEPINTVL, (void *)&keepInterval, sizeof(keepInterval));
setsockopt(rs, SOL_TCP, TCP_KEEPCNT, (void *)&keepCount, sizeof(keepCount));
4. 解决问题
我们了解Redis处理客户端连接的机制和TCP的TIME_WAIT.我们可以重现上述问题,我们快速建立2000个连接,
<?php
$num = 2000;
for($i=0; $i<$num; $i++) {
$redis = new Redis();
$redis->connect('127.0.0.1',6379);
//sleep(1);
}
sleep(10);
然后查看状态:netstat -nat | grep 127.0.0.1:6379你会发现很多TIME_WAIT。
如果$num加大到40000或者,报错:Cannot assign requested address。
因此如果客户端(php)连接redis出现这个问题,说明你程序出现bug了。你某个循环里面实例化Redis了(即每次都new Redis),造成每一次循环都建立一个连接。
解决这个问题不是修改内核参数,而是把连接redis封装成单实例,确保在同一进程内,连接redis是唯一实例。
class Class_Redis { private $_redis;
private static $_instance = null; private function __construct() {
$this->_redis = new Redis();
$this->_redis->connect('127.0.0.1',6379); } public static function getInstance() {
if(self::$_instance === null) {
self::$_instance = new self();
}
return self::$_instance; } public function getRedis() {
return $this->_redis;
} }