从错误说起
版本信息
- php-7.1.x
- phpredis-4.0.x
一个PHP常驻内存进程
,连上Redis
后,定时做brpop
操作,阻塞时间为10s
。问题出现在,几天(不定时)后,该进程就会僵死
,表现为:
netstat
下,php进程与redis建立的客户端连接仍在(ESTABLISHED)- 在客户机
tcpdump
,没有输出任何数据包信息(没有通信?) strace
该php进程,并没有输出任何系统调用(阻塞在哪了?)- 查看redis-server,发现
client list
中,并不存在该client(被移除了?)
phpredis客户端连接为何不断?
关于phpredis连接,有下面几个地方需要理解清楚
- connect() 函数参数 timeout 为 0
- ini_set(‘default_socket_timeout’, -1)
- setOption(\Redis::OPT_READ_TIMEOUT, -1)
- pconnect
connect 函数参数 timeout
参数:
- host: string. can be a host, or the path to a unix domain socket. Starting from version 5.0.0 it is possible to specify schema
- port: int, optional
- timeout: float, value in seconds (optional, default is 0 meaning unlimited)
- reserved: should be NULL if retry_interval is specified
- retry_interval: int, value in milliseconds (optional)
- read_timeout: float, value in seconds (optional, default is 0 meaning unlimited)
这里的timeout
表示建立连接
时的超时时间,调用此函数时,客户端将与服务端进行三次握手,建立TCP连接。由于网络原因,可以指定一个超时时间,意思是,如果客户端和服务端在该时间限制
内未能建立连接,则返回false
文件:redis.c 行:935
1 | PHP_METHOD(Redis, connect) |
其中,redis_connect的函数原型为
1 | PHP_REDIS_API int redis_connect(INTERNAL_FUNCTION_PARAMETERS, int persistent); |
persistent 为 0
表示不建立持久连接
,下面会聊到等于 1
的情况。说明connect
函数建立的是短连接
,当调用close
函数时,连接就会关闭。看下面的源码确实如此,如果在建立连接前已经存在另一个连接,则关闭。
文件:redis.c 行:1011
1 | redis = PHPREDIS_GET_OBJECT(redis_object, object); |
default_socket_timeout
这个配置可以在php.ini找到,文档注释很简单:基于 socket 的流的默认超时时间(秒)
redis是基于tcp协议
的程序,所以这个配置也会对其造成影响。比如read error on connection
错误,这是phpredis在执行get、brpop等操作时,如果在default_socket_timeout
时间内不返回结果就会报这个错误。php.ini中默认为60s
。可以在程序中使用内置函数ini_set
在运行时修改。
OPT_READ_TIMEOUT
phpredis版本的“default_socket_timeout”,通过这个值,一样可以达到同样的效果。那么如果同时设置了default_socket_timeout
和OPT_READ_TIMEOUT
,优先级是怎样的?
实测发现,如果同时存在两个配置,优先使用OPT_READ_TIMEOUT
的配置,这样是合理的。
文件:redis_commands.c 行:3980
1 | case REDIS_OPT_READ_TIMEOUT: |
pconnect的原理是什么?
文件:redis.c 行:947
1 | PHP_METHOD(Redis, pconnect) |
建立连接时,先到连接池
获取连接(最后一个),并移除最后一个连接实例。如果连接是活跃的(PHP_STREAM_OPTION_CHECK_LIVENESS),则直接返回。如果连接已失效,则建立新的连接。
文件:library.c 行:1828
1 | if (redis_sock->persistent) { |
重点来了,注意看上面代码中这一段,先卖个关子,后面聊tcp_keepalive
的时候会着重分析
1 | /* Attempt to set TCP_NODELAY/TCP_KEEPALIVE if we're not using a unix socket. */ |
redis-server为什么会移除client?
先回顾一下TCP协议是怎么keepalive
(保活)的。
模拟tcp keepalive
- 服务端:nc
- 客户端:netcat-keepalive
开始通信
开启一个TCP服务端
1 | nc -lp 9999 |
启动一个客户端,连接服务端
1 | ./nckl-linux -K -O 15 -I 5 -P 5 127.0.0.1 9999 |
netcat-keepalive的使用参数
- -K Turn on TCP Keepalive
- -O secs TCP keepalive timeout
- -I secs TCP keepalive interval
- -P count TCP keepalive probe count
如果不设置,默认为系统的默认配置
,如linux下
1 | sysctl -a | grep keepalive |
- net.ipv4.tcp_keepalive_time = 7200
- net.ipv4.tcp_keepalive_probes = 9
- net.ipv4.tcp_keepalive_intvl = 75
使用tcpdump查看发包情况
1 | 18:15:24.852471 IP localhost.45698 > localhost.9999: Flags [S], seq 253066745, win 43690, options [mss 65495,sackOK,TS val 23438901 ecr 0,nop,wscale 7], length 0 |
分三段来看,
- 第一段:三次握手,建立连接
- 第二段:客户端发包,服务端应答(这里是我在客户端发了一个数字1)
- 第三段:每隔15秒发一个
keepalive
包
使用docker重现问题
docker-compose建立本地网络
断开服务端容器的网络
1 | docker network disconnect docker_network docker_redis |
phpredis客户端
这里出现了两种情况,分别是「已发完PSH包」和「正在发PSH包」
- 已发完PSH包,过一段时间,然后连续发几次
FIN_WAIT1
包,最后断开与服务端的单边连接 - 正在发PSH包,不断重试,重试几次后,如果没有得到服务端的确认,直接发一个F包,然后断开与服务端的单边连接
无论是哪一种情况,当客户端主动断开与服务端的连接时,都会返回一个异常 —— read error on connection
,这是可以捕获的。但是,如果在执行brpop
操作,当断开后,的确会返回该异常,然而,下一次再执行brpop
的时候,就不走网络了,因为连接已经断开,所以redis客户端会直接返回false
。
网络恢复?
docker模拟
1 | docker network connect docker_network docker_redis |
网络恢复的时机也分为两种情况,分别对应断开的时机
- 已发完PSH包,此时网络中断,客户端等待1分钟,然后开始发F包。这时,网络恢复了!
- 正在发PSH包,此时网络中断,客户端不断重试,在重试结束前,网络恢复了!
第一种情况:
1 | 16:50:53.555004 IP 2388ad577c4b.38658 > web_docker_redis.web_docker_web_network.6379: Flags [F.], seq 155, ack 21, win 229, options [nop,nop,TS val 19885306 ecr 19879304], length 0 |
因为客户端已经发了F包,就算这时候网络恢复了,也会断开连接,最终结果为,客户端异常
第二种情况:
1 | 16:59:45.126281 IP 2388ad577c4b.38666 > web_docker_redis.web_docker_web_network.6379: Flags [P.], seq 123:155, ack 21, win 229, options [nop,nop,TS val 19938525 ecr 19938424], length 32: RESP "BRPOP" "test" "3" |
在客户端重试发PSH包的时候,网络恢复了,连接还在,服务端也会继续返回结果,客户端不再阻塞,继续运行
解决方案:忙连接
- 使用php.ini的
default_socket_timeout
,或者phpredis的OPT_READ_TIMEOUT
,设置一个自定义值,比如60s
- 设置connect函数的
timeout
为一个自定义值,如10s
- 在客户端断开连接并报异常
read error on connection
时,进行异常捕获,开启一个阻塞循环,不断的重连redis,只有连接成功后才返回
代码
1 | class PopData { |
系统与网络情况
tcpdump看下,在定时重连期间,客户端的发包情况
1 | 7:33:18.326086 IP 2388ad577c4b.38700 > web_docker_redis.web_docker_web_network.6379: Flags [F.], seq 91, ack 11, win 229, options [nop,nop,TS val 20140076 ecr 20134070], length 0 |
可以发现,有两个线程正在疯狂的“试探”,一个想要结束,一个想要连接。
netstat看下,在定时重连期间,客户端的连接状态
1 | tcp 0 1 192.168.48.5:38700 192.168.48.4:6379 FIN_WAIT1 - |
由于“连接线程”是通过new Redis
来实现的,所以端口会一直变化。
OPT_TCP_KEEPALIVE 到底是什么?怎么用?
在官方文档中,根本找不到这个选项的说明。查看源码发现,phpredis在建立连接时,tcp_keepalive
参数默认为 0
文件:library.c 行:1783
1 | redis_sock->tcp_keepalive = 0; |
可以通过函数setOption
来设置tcp_keepalive
的值
文件:redis_commands.c 行:3991
1 | case REDIS_OPT_TCP_KEEPALIVE: |
刚刚谈pconnect的时候,聊到下面这个地方,现在着重看看
1 | /* Attempt to set TCP_NODELAY/TCP_KEEPALIVE if we're not using a unix socket. */ |
在连接的时候,会通过判断host
来看是否开启TCP_KEEPALIVE
,前面在说connect函数的时候了解到,host由下面几种:
host: string. can be
- a host(ip/域名)
- or the path to a unix domain socket. (本地域socket)
- Starting from version 5.0.0 it is possible to specify schema
我把这句话拆开来看会比较清晰,上面这段代码中可以看到,如果是unix domain socket
,则不会启用TCP_KEEPALIVE
。然而,在connect
阶段,根本没有这个配置项,也就是说,真正设置该配置的地方在别处..
docker模拟
代码
test.php
1 |
|
通过host
方式连接服务端,并设置选项OPT_TCP_KEEPALIVE
为10s
,通过ping查看连通性,然后进行阻塞
操作。lsof
看下,确实使用TCP
方式。
1 | php 899 root 3u IPv4 741397 0t0 TCP 2388ad577c4b:38782->web_docker_redis.web_docker_web_network:6379 (ESTABLISHED) |
断开服务端容器的网络发现,在设定条件下,并不会发keepalive包,可能与docker的实现机制有关,自动转化为unix domain socket?目前不确定是phpredis
的问题还是docker网络机制
的问题。接下来,先看看phpredis究竟有没有执行到相应的逻辑。
非debug模式
为了看这段代码是否被执行到,我改一下phpredis的源码,在这里打印一下日志,再重新编译。
1 | curl -O http://pecl.php.net/get/redis-4.0.2.tgz |
找到redis_sock_connect
函数,在下面的代码中,加入打印日志
的代码
1 | /* Attempt to set TCP_NODELAY/TCP_KEEPALIVE if we're not using a unix socket. */ |
这样做发现,打印的结果是open keepalive
。要想得到整个调用栈
以及打印变量
,不是很方便。下面使用gdb来调试,设置断点。
debug模式
为了使用gdb
断点调试PHP扩展,需要把PHP编译为debug
模式,然后再把phpredis重新编译一次
编译php
1 | wget -c https://github.com/php/php-src/archive/php-7.1.30.tar.gz |
编译phpredis
1 | curl -O http://pecl.php.net/get/redis-4.0.2.tgz |
编译完成后,会发现安装目录为 /usr/local/php7.1.30/lib/php/extensions/debug-non-zts-20160303
开始gdb调试
1 | gdb /usr/local/php7.1.30/bin/php |
通过上面的gdb调试纪录可以发现,
usocket
的值为0
,说明docker没有做什么“小动作”,host模式没问题。- 在
connect
阶段,tcp_keepalive
默认为0
1 | (gdb) b redis_setoption_handler |
通过上面的调试可以知道,
- 在调用
setOption
函数阶段,成功设置了tcp_keepalive
为1
。
疑问
前面我们通过docker模拟,gdb断点排查,现在进行小结:
- 版本问题:一开始怀疑是phpredis没有
TCP_KEEPALIVE
的配置项,查看源码发现4.0以上的版本都支持了。 - 环境问题:通过gdb断点发现,host是没问题的,并没有采用
unix domain socket
模式,在docker环境下模拟没问题。 - 逻辑问题:通过gdb断点发现,在
connect
阶段,sock->tcp_keepalive默认为0
,在setOption
阶段,sock->tcp_keepalive被设置为1
,逻辑也没问题
到现在,几乎任何关于代码的地方都“似乎”没问题,所以走不通了,只能回头再看看,有什么细节遗漏了。前面,我们在setOption
阶段,把OPT_TCP_KEEPALIVE
设置为10
,当时我说,把时间设置为10s
,因为我把这里理所当然的理解为tcp_keepalive_time
,我希望在断网后10秒内,能给服务端发keepalive
包。可是,查看源码发现,
1 | tcp_keepalive = zval_get_long(val) > 0 ? 1 : 0; |
这里传入的值,似乎被当作了另一种用法,只要是正整数,就把tcp_keepalive设置为1,否则设置为0
。也就是说,这里并没有tcp_keepalive_time
的功能,仅作为开关!!!
但是,我找不到任何提供的API可以设置了…
设置系统默认TCP_KEEPALIVE各参数值
前面我们知道,系统有一个全局默认的TCP_KEEPALIVE配置
1 | sysctl -a | grep keepalive |
上面这个配置是两个小时(7200s)后才发包,现在我把这些设置改一下,改短一点
1 | sysctl -w net.ipv4.tcp_keepalive_time=15 net.ipv4.tcp_keepalive_probes=3 net.ipv4.tcp_keepalive_intvl=10 |
- net.ipv4.tcp_keepalive_time:15
- net.ipv4.tcp_keepalive_probes:3
- net.ipv4.tcp_keepalive_intvl:10
重新跑一遍代码,断开服务端网络,tcpdump看发包情况。
1 | 15:38:24.862503 IP web_docker_php.web_docker_web_network.42480 > ce6e2fa39930.6379: Flags [.], ack 13, win 229, options [nop,nop,TS val 35239808 ecr 35238270], length 0 |
重新试一下发现,竟然没问题了!确实每隔15秒发一次keepalive包
。也就是说,我一直对phpredis的TCP_KEEPALIVE
用法理解错了。先入为主的认为这个就是tcp_keepalive_time
。其实,之前的程序一直没有问题,只不过,因为系统默认的时间太久了,程序一直阻塞着,所以我才觉得这个参数没有正确被设置。
更简单的方案?
前面讨论了解决brpop
在网络抖动的情况下,使用忙连接
的方案。后来,我们了解了OPT_TCP_KEEPALIVE
的用法,能不能有更简单的方案?要是phpredis客户端能定时发keepalive包
,如果网络中断,直接报异常,然后进行异常捕获,重新连接。岂不是更佳?
然而,在实测过程中(使用test.php),当网络中断后,客户端便不再发送keepalive包
,通过netstat看,客户端在短时间内自动断开客户端与服务端的单边连接,然后也没有报异常:(
总结
- 使用nc和netcat-keepalive工具,回顾TCP_KEEPALIVE机制
- 理清redis几个关于timeout的API,以及结合使用时它们的优先级
- 理清phpredis客户端keepalive用法,没有开放TCP_KEEPALIVE的三个关键配置,而是仅作为开关,使用系统环境的参数配置
- 把网络异常当作常态,在应用层做更健壮的长连接检测
最后
本文使用Redis的brpop做消息获取,这只是其中一种情况,还有其他网络API也是需要长连接的,如subscribe,针对其他API,解决方案是否如出一辙呢?留到下一次继续分析~