PHP进程卡死和MySQL超时时间的设置方法

前言

最近线上一台服务器的nginx总是会有一部分请求(不是所有请求)报upstream timed out (110: Connection timed out) while connecting to upstream的错误,看起来像是后端的phpcgi进程出问题了,但如果phpcgi进程有问题,不是应该所有请求都会报错才对么,于是展开排查。

排查原因

在我们服务器上,PHP是使用9006端口进行监听的,执行netstat -an | grep 9006命令查看相关连接的网络状态,看到有一部分连接处于CLOSE_WAIT状态:

tcp        0      0 10.0.0.188:9006          10.0.0.52:37316         CLOSE_WAIT  
tcp        0      0 10.0.0.188:9006          10.0.0.52:37292         CLOSE_WAIT  
tcp        0      0 10.0.0.188:9006          10.0.0.52:37300         CLOSE_WAIT  
tcp        0      0 10.0.0.188:9006          10.0.0.52:37302         CLOSE_WAIT  
tcp        0      0 10.0.0.188:9006          10.0.0.52:37234         CLOSE_WAIT

奇怪的是,这些连接一直停留在CLOSE_WAIT的状态,不会变化,所以应该就是这些连接导致PHP进程被卡死,无法处理新的请求,从而导致部分请求报Connection timed out错误的。

根据TCP连接的四次挥手过程,CLOSE_WAIT状态是连接被关闭方才会出现的。nginx调用PHP,PHP响应太慢导致nginx超时,继而nginx主动关闭跟PHP的TCP连接,因此PHP进程是被关闭方,这个没啥问题。但PHP进程在收到nginx的关闭请求后,应该也跟着关闭才对,但实际没有,而是停留在了CLOSE_WAIT状态,说明PHP被某些东西卡住了,没办法关闭连接。

于是使用strace -p PHP进程ID查看PHP卡在了什么地方,等了一两分钟,strace命令什么东西都没输出,说明PHP进程是卡在了某个系统调用:
PHP进程卡死和MySQL超时时间的设置方法_第1张图片

再使用lsof -np PHP进程ID命令查看进程打开了哪些文件资源,发现有一个状态为ESTABLISHED的mysql的连接:
PHP进程卡死和MySQL超时时间的设置方法_第2张图片

猜想会不会是mysql导致PHP卡死呢,于是根据显示的连接端口号10465,到mysql服务器上查看这个端口的连接情况,发现居然没有这个端口的TCP连接。这就神奇了,这里我只能猜测是:PHP成功跟mysql服务器建立TCP连接,但由于丢包或者防火墙拦截等奇怪的原因,PHP没有收到mysql的greeting packet,于是导致PHP一直在这里空等。后面mysql服务器主动断开TCP连接,但发送的FIN包也被拦截了,导致收不到PHP的ACK回应,于是mysql继续释放了这个连接。于是就出现了这个连接在PHP端是ESTABLISHED状态,但在mysql端却不存在这个连接的情况。

确认和模拟测试

为了确认到底是不是mysql连接卡死了PHP,使用gdb -p PHP进程ID进行调试,上面lsof命令显示mysql的连接对应的文件描述符是5u,gdb里输入命令call close(5)强制把这个连接关闭,关闭后PHP进程的CLOSE_WAIT状态马上消失了,证明确实是这个连接卡住了PHP。

接着我尝试模拟“成功建立TCP连接,但收不到mysql greeting packet”的这种情景,看PHP会不会卡死,测试代码如下:

$mysql = new mysqli();
$mysql->real_connect('45.113.192.102', 'root', 'xxx', 'xxx', 80);

45.113.192.102是百度的一台web服务器,跟80端口肯定是能建立TCP连接的,但它不是mysql服务器,所以建立连接后,也肯定不会发送greeting packet给PHP,正符合我要测试的场景。执行这段代码后,PHP确实会卡住在real_connect处,而且不会超时,跟线上情况一模一样。

解决方法

那怎样才能针对这种场景设置一个超时时间呢,在这里我走了弯路,曾尝试使用set_time_limitdefault_socket_timeoutMYSQLI_OPT_CONNECT_TIMEOUT等参数来设置超时时间,但均没有生效,一度使我十分苦恼。后面才意识到这种场景的超时是属于读写超时,不是连接超时,因此使用MYSQLI_OPT_CONNECT_TIMEOUT来设置是无效的,这参数控制的是连接超时。而set_time_limit控制的仅是PHP脚本自身的执行时间,不包括系统调用、数据库操作所消耗的时间,因此也不会生效,这一点在PHP文档里也有说明:
image.png

下面是正确答案,有两种方法:
1)修改mysqlnd.net_read_timeout配置项
php.ini里添加一个mysqlnd.net_read_timeout的配置项即可,例如mysqlnd.net_read_timeout = 60就代表将mysql的读写超时时间设置为60秒

mysqlnd.net_read_timeout配置项在PHP 7.2(含)之后的版本,可以在代码里通过ini_set函数来设置,旧的版本只能在php.ini里设置

2)通过MYSQLI_OPT_READ_TIMEOUT常量来配置,这个常量在PHP 7.2(含)版本才添加,因此需要PHP版本大于等于7.2,代码示例:

$mysql = new mysqli();
$mysql->options(MYSQLI_OPT_CONNECT_TIMEOUT, 5); // 设置连接超时未5秒
$mysql->options(MYSQLI_OPT_READ_TIMEOUT, 60); // 设置读写超时为60秒
$mysql->real_connect('45.113.192.102', 'root', 'xxx', 'xxx', 80);

这种方法相比第一种影响范围更小,它只影响当前连接,而第一种因为是通过修改配置文件实现的,因此它会影响所有使用同一配置文件的其它PHP程序。

设置好读写超时后,重新执行测试代码,也确实没有卡死了,60秒后就会超时报错。

总结

针对mysql连接,一个完整的超时设置,应该同时设置连接超时和读写超时,如果仅仅设置了连接超时,那么在一些特殊情景下,PHP进程是有可能会被卡死的

你可能感兴趣的