最近线上php模块偶尔会出现readerroronconnection;具体的错误日志如下:Uncaughtexception'RedisException'withmessage'readerroronconnection'经过分析研究,有两种原因可能导致phpredis返回'readerroronconnection':Executiontimeoutusingdisconnectedconnection以下两种情况将详细分析。1、执行超时超时分为两种情况:一种是客户端设置的超时时间太短;另一种是客户端没有设置超时时间,但是服务端的执行时间超过了默认的超时时间设置。1.1模拟与复现1.1.1客户端设置的超时时间过短。测试环境中的get操作执行时间约为0.1ms;因此,客户端将执行超时时间设置为0.01ms。测试脚本如下:pconnect("127.0.0.1",6390);如果($ret==false){echo“连接返回false”;出口;}//设置超时为0.1ms$rds->setOption(3,0.0001);$rds->get("aa");}catch(Exception$e){var_dump($e);}手动执行脚本会捕获'readerroronconnection'异常;1.1.2客户端没有设置超时时间,使用默认超时时间客户端没有设置超时时间,但是在执行命令的过程中,超时达到了php设置的默认值,参见phpredis订阅超时问题及解决方案分析1.2原因分析1.2.1strace分析通过strace查看执行过程,可以发现发送getaa命令后,poll要拉取POLLIN事件,等待超时:1.2.2代码逻辑分析php使用phpredis扩展连接redis,phpredis源码全文搜索'readerroronconnection'可以发现这个错误位于phpredis/library.c文件的redis_sock_gets函数中,详见phpredis;phpredis的library.c文件的redis_sock_gets函数/**处理变体回复类型(想想EVAL)*/PHP_REDIS_APIintredis_sock_gets(RedisSock*redis_sock,char*buf,intbuf_size,size_t*line_size){//处理EOFif(-1==redis_check_eof(redis_sock,0)){返回-1;}if(php_stream_get_line(redis_sock->stream,buf,buf_size,line_size)==NULL){char*errmsg=NULL;if(redis_sock->port<0){spprintf(&errmsg,0,"连接到%s时读取错误",ZSTR_VAL(redis_sock->host));}else{spprintf(&errmsg,0,"连接到%s:%d时出现读取错误",ZSTR_VAL(redis_sock->host),redis_sock->port);}//关闭我们的套接字redis_sock_disconnect(redis_sock,1);//抛出读取错误异常REDIS_THROW_EXCEPTION(errmsg,0);efree(errmsg);返回-1;}/*我们不需要\r\n*/*line_size-=2;buf[*line_size]='\0';/*成功!*/return0;}onlinemsg中多了host和port,因为最近合并了分支,如源码所示,如??果php_stream_get_line读取流数据为NUll,会在connectionerror时抛出readerror那什么时候php_stream_get_line会返回NULL呢,对应于php源码的php-src/main/streams/streams.c文件,详见php-src;/*如果buf==NULL,缓冲区会自动分配*适当的长度来保持行,不管行的长度,内存*允许*/PHPAPIchar*_php_stream_get_line(php_stream*stream,char*buf,size_tmaxlen,size_t*returned_len){size_tavail=0;size_tcurrent_buf_size=0;size_ttotal_copied=0;intgrow_mode=0;字符*bufstart=buf;如果(buf==NULL){grow_mode=1;}elseif(maxlen==0){返回NULL;}/**如果在没有新数据可读时底层流操作阻塞,*我们需要采取额外的预防措施。**如果有可用的缓冲数据,我们会检查EOL。如果它存在,我们立即将数据传回给调用者。这节省了对读取实现的调用*并且不会在阻塞*不是n的地方阻塞根本没有必要。**如果流缓冲区包含的数据多于调用者请求的数据,*我们也可以避免这个代价高昂的步骤并简单地返回该数据。*/for(;;){avail=stream->writepos-stream->readpos;if(avail>0){size_tcpysz=0;字符*读指针;常量字符*eol;int完成=0;readptr=(char*)stream->readbuf+stream->readpos;eol=php_stream_locate_eol(stream,NULL);如果(eol){cpysz=eol-readptr+1;完成=1;}else{cpysz=有用;}if(grow_mode){/*为NUL留出空间。如果这个realloc真的是一个realloc*(即:第二次),我们会得到一个额外的字节。在大多数*情况下,默认块大小为8K,我们只会*招致一次开销。当人们如果行长*超过8K,我们每增加8K左右就浪费1个字节。*这对我来说似乎可以接受,以避免使这段代码*难以理解*/bufstart=errealloc(bufstart,current_buf_size+cpysz+1);current_buf_size+=cpysz+1;buf=bufstart+total_copied;}else{if(cpysz>=maxlen-1){cpysz=maxlen-1;完成=1;}}memcpy(buf,readptr,cpysz);流->位置+=cpysz;stream->readpos+=cpysz;buf+=cpysz;最大长度-=cpysz;total_copied+=cpysz;如果(完成){休息;}}elseif(stream->eof){中断;}else{/*XXX:总是读取chunk_size应该没问题*/size_ttoread;if(grow_mode){toread=stream->chunk_size;}else{toread=maxlen-1;if(toread>stream->chunk_size){toread=stream->chunk_size;}}php_stream_fill_read_buffer(stream,toread);if(stream->writepos-stream->readpos==0){break;}}}if(total_copied==0){if(grow_mode){assert(bufstart==NULL);}返回空值;}buf[0]='\0';if(returned_len){*returned_len=total_copied;}returnbufstart;}从php_stream_get_line方法可以看出,只有当bufstart=NULL时才返回NULL,bufstart=NULL表示不接收到bufbuffer中,stream到任何数据,包括终止符1.3解决方案客户端设置合理的超时时间有两种方式:1.3.1int_setini_set('default_socket_timeout',-1);1.3.2setOption$redis->setOption(Redis::OPT_READ_TIMEOUT,-1);注意:-1表示不超时,你也可以设置超时时间为你想要的时间。前一次重现设置为0.01ms。2.重新使用断开的连接使用断开的连接也可能导致'连接读取错误'',这里需要区分'连接关闭'和'连接丢失'。2.1连接断开2.1.1连接关闭测试脚本如下,客户端主动关闭连接,但是下面使用断开的链接,然后抛出异常返回connectionclosedpconnect("127.0.0.1",6390);如果($ret==false){echo“连接返回false”;出口;$rds->close();var_dump($rds->get("aa"));}catch(Exception$e){var_dump($e);}测试结果如下:2.1.2Connectionlost参考WorkaroundPHPbugoflivenesschecking编写测试脚本test.php如下,连接redis后,执行命令前kill掉redis进程:pconnect("127.0.0.1",6390);如果($ret==false){echo“连接返回false”;出口;}echo"按任意键继续...";fgetc(标准输入);var_dump($rds->get("aa"));}catch(Exception$e){var_dump($e);}如果执行步骤如下,终端执行phptest.php脚本,打开另一个终端杀死redis进程。第一个终端任意输入,回车。这时会出现“连接丢失”。2.1.3readerroronconnectionconnection连接到redis后,在连续执行命令的过程中,如果连接断开,会返回readerroronconnection。测试脚本如下:pconnect("127.0.0.1",6390);if($ret==false){echo"连接返回false";出口;}while(1){$rds->get("aa");}}catch(Exception$e){var_dump($e);}如果执行步骤如下,则终端执行phptest.php脚本,打开另一个终端killredis进程,此时抛出异常:ornewopen终端连接redis服务器,执行clientkill,如下:执行的php脚本也会catchexceptionreaderroronconnection。2.2php-fpm&pconnectcli方式下,使用php通过pconnect连接redis服务器。虽然业务代码显示调用close,但是并没有断开连接。fpm会维护与redis的连接,下次请求会重复执行pconnect时,实际上并没有请求redis建立连接。这也会带来一个问题。如果连接已经断开,下一次请求可以直接使用之前断开的连接。对此,phpredis在其源码中也有注释。具体见php-src,所以php-fpm重用一个Brokenconnections会导致这样的错误。在这种情况下最简单的解决方案是将长链接更改为短链接。3.总结网上关于执行超时及其解决方案的分析很多,但是关于连接断开和重用的分析却很少。所以,分析一下,一方面作为记录,另一方面也希望能帮助到一些面临同样问题的朋友。4.参考[1]RedisreaderroronconnectionandRedisserverwoneawayerrortroubleshooting[2]WorkaroundPHPbugoflivenesschecking[3]phpredissubscribetimeout问题及解决方案[4]php-src[5]phpredis
