redis报错:read error on connection的两种原因分析

最近线上php模块偶现 read error on connection;具体报错日志如下

Uncaught exception 'RedisException' with message 'read error on connection'

通过分析和学习之后,发现两种原因可能导致 phpredis 返回 'read error on connection':

  • 执行超时
  • 使用已经断开的连接

下面将对这两种情况进行具体的分析。

一、执行超时

超时又可以分两种情况:一种是客户端设置的超时时间过短导致的;另外一种是客户端未设置超时时间,但是服务端执行时间超过了默认超时时间设置。

1.1 模拟复现

1.1.1 客户端设置超时时间过短

测试环境的 get 操作 执行耗时约 0.1ms 数量级;因此客户端设置执行超时时间为0.01ms, 测试脚本如下:

<?php
$rds = new Redis();
try {
    $ret = $rds->pconnect("127.0.0.1", 6390);
    if ($ret == false) {
        echo "Connect return false";
        exit;
    }
    //设置超时时间为 0.1ms
    $rds->setOption(3,0.0001);
    $rds->get("aa");
} catch (Exception $e) {
    var_dump ($e);
}

手动执行该脚本会捕获'read error on connection'异常;

image

1.1.2 客户端未设置超时时间,使用默认超时时间

客户端未设置超时时间,但是在命令执行的过程中,超时达到php设置的默认值,详见 phpredis subscribe超时问题及解决 分析

1.2 原因分析

1.2.1 strace 分析

通过strace 查看执行过程可以发现发送 get aa 指令后,poll 想要拉取 POLLIN 事件的时候等待超时:

image

1.2.2 代码逻辑分析

php连接redis 使用的是phpredis扩展,在phpredis源码中全文搜索 'read error on connection' 可以发现 此错误位于 phpredis/library.c 文件的 redis_sock_gets 函数,详见 phpredis ;

phpredis 的 library.c 文件的 redis_sock_gets 函数

/*
 * Processing for variant reply types (think EVAL)
 */

PHP_REDIS_API int
redis_sock_gets(RedisSock *redis_sock, char *buf, int buf_size,
                size_t *line_size)
{
    // Handle EOF
    if(-1 == redis_check_eof(redis_sock, 0)) {
        return -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, "read error on connection to %s", ZSTR_VAL(redis_sock->host));
        } else {
            spprintf(&errmsg, 0, "read error on connection to %s:%d", ZSTR_VAL(redis_sock->host), redis_sock->port);
        }
        // Close our socket
        redis_sock_disconnect(redis_sock, 1);

        // Throw a read error exception
        REDIS_THROW_EXCEPTION(errmsg, 0);
        efree(errmsg);
        return -1;
    }

    /* We don't need \r\n */
    *line_size-=2;
    buf[*line_size]='\0';

    /* Success! */
    return 0;
}

附: 这个msg 看着比线上的msg 多了 host 和 port , 是因为最近合并分支的原因,如图

image

从源码中可以发现如果php_stream_get_line读取stream数据为NUll的时候就会抛出read error on connection这个错误。那么什么时候php_stream_get_line会返回NULL呢, 对应于php源码的php-src/main/streams/streams.c 文件 , 详见php-src;

/* If buf == NULL, the buffer will be allocated automatically and will be of an
 * appropriate length to hold the line, regardless of the line length, memory
 * permitting */
PHPAPI char *_php_stream_get_line(php_stream *stream, char *buf, size_t maxlen,
        size_t *returned_len)
{
    size_t avail = 0;
    size_t current_buf_size = 0;
    size_t total_copied = 0;
    int grow_mode = 0;
    char *bufstart = buf;

    if (buf == NULL) {
        grow_mode = 1;
    } else if (maxlen == 0) {
        return NULL;
    }

    /*
     * If the underlying stream operations block when no new data is readable,
     * we need to take extra precautions.
     *
     * If there is buffered data available, we check for a EOL. If it exists,
     * we pass the data immediately back to the caller. This saves a call
     * to the read implementation and will not block where blocking
     * is not necessary at all.
     *
     * If the stream buffer contains more data than the caller requested,
     * we can also avoid that costly step and simply return that data.
     */

    for (;;) {
        avail = stream->writepos - stream->readpos;

        if (avail > 0) {
            size_t cpysz = 0;
            char *readptr;
            const char *eol;
            int done = 0;

            readptr = (char*)stream->readbuf + stream->readpos;
            eol = php_stream_locate_eol(stream, NULL);

            if (eol) {
                cpysz = eol - readptr + 1;
                done = 1;
            } else {
                cpysz = avail;
            }

            if (grow_mode) {
                /* allow room for a NUL. If this realloc is really a realloc
                 * (ie: second time around), we get an extra byte. In most
                 * cases, with the default chunk size of 8K, we will only
                 * incur that overhead once.  When people have lines longer
                 * than 8K, we waste 1 byte per additional 8K or so.
                 * That seems acceptable to me, to avoid making this code
                 * hard to follow */
                bufstart = erealloc(bufstart, current_buf_size + cpysz + 1);
                current_buf_size += cpysz + 1;
                buf = bufstart + total_copied;
            } else {
                if (cpysz >= maxlen - 1) {
                    cpysz = maxlen - 1;
                    done = 1;
                }
            }

            memcpy(buf, readptr, cpysz);

            stream->position += cpysz;
            stream->readpos += cpysz;
            buf += cpysz;
            maxlen -= cpysz;
            total_copied += cpysz;

            if (done) {
                break;
            }
        } else if (stream->eof) {
            break;
        } else {
            /* XXX: Should be fine to always read chunk_size */
            size_t toread;

            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);
        }
        return NULL;
    }

    buf[0] = '\0';
    if (returned_len) {
        *returned_len = total_copied;
    }

    return bufstart;
}

从 php_stream_get_line方法中可以看出 只有 bufstart=NULL的时候才会返回NULL,bufstart=NULL说明并未在buf缓冲和stream中接收到任何数据,包括终止符。

1.3 解决方案

客户端设置合理的超时时间,有两种方式:

1.3.1 int_set

ini_set('default_socket_timeout', -1);

1.3.2 setOption

$redis->setOption(Redis::OPT_READ_TIMEOUT, -1);

注: -1均表示不超时,也可以将超时设置为自己希望的时间, 前面复现时就是设为为0.01ms

二、重新使用已经断开的连接

使用已经断开的连接也有可能导致 'read error on connection', 这里需要区分 'Connection closed' 和 'Connection lost'。

2.1 连接断开

2.1.1 Connection closed

测试脚本如下,客户端主动关闭连接,但是下文接着使用该断开的链接,然后抛出异常返回 connection closed

<?php
$rds = new Redis();
try {
    $ret = $rds->pconnect("127.0.0.1", 6390);
    if ($ret == false) {
        echo "Connect return false";
        exit;
    }

    $rds->close();

    var_dump($rds->get("aa"));
} catch (Exception $e) {
    var_dump ($e);
}

测试结果如下:

image

2.1.2 Connection lost

参考Work around PHP bug of liveness checking 编写测试脚本 test.php 如下,连接上redis之后,在执行命令前kill redis 进程:

<?php
$rds = new Redis();
try {
    $ret = $rds->pconnect("127.0.0.1", 6390);
    if ($ret == false) {
        echo "Connect return false";
        exit;
    }

    echo "Press any key to continue ...";
    fgetc(STDIN);
    var_dump($rds->get("aa"));
} catch (Exception $e) {
    var_dump ($e);
}

如果

执行步骤如下

  1. 终端执行 php test.php 脚本
  2. 另开一个终端 kill redis 进程
  3. 第一个终端任意输入、回车

此时会出现 'Connection lost'

image

2.1.3 read error on connection

连接上redis之后,不断执行命令的过程中,如果连接断开,会返回 read error on connection。测试脚本如下:

<?php
$rds = new Redis();
try {
    $ret = $rds->pconnect("127.0.0.1", 6390);
    if ($ret == false) {
        echo "Connect return false";
        exit;
    }

    while(1){
       $rds->get("aa");
    }

} catch (Exception $e) {
    var_dump ($e);
}

如果

执行步骤如下

  1. 终端执行 php test.php 脚本
  2. 另开一个终端 kill redis 进程

此时抛出异常:

image

或者新打开终端连接上redis服务端,执行client kill ,如下:

image

正在执行的php脚本同样会捕获该异常read error on connection。

2.2 php-fpm & pconnect

在cli 模式下, 通过php通过 pconnect 连接redis服务端,虽然业务代码,显示调用close, 但是实际上该连接并未断开,fpm 会维护到redis 的连接,下个请求再次执行pconnect 的时候并不会真正请求redis 建立连接。这样同样会带来一个问题,假如这个连接已经断开了,下个请求可能直接使用上个断开的连接,对此,phpredis 在其源码也有注释,详见php-src

image

因此php-fpm reuse 一个断开的连接可能导致此类错误。

此种情况最简单的解决方案就是改长链接为短链接了

三、小结

网上有很多关于 执行超时及其解决方案的分析,但是对于连接断开重新使用的分析较少,故此分析之,一方面用作记录,另一方面希望能够给面临同样问题的小伙伴一点帮助。

四、参考

[1] redis read error on connection和Redis server went away错误排查

[2] Work around PHP bug of liveness checking

[3] phpredis subscribe超时问题及解决

[4] php-src

[5] phpredis

</pre>

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,607评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,047评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,496评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,405评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,400评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,479评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,883评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,535评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,743评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,544评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,612评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,309评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,881评论 3 306
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,891评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,136评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,783评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,316评论 2 342

推荐阅读更多精彩内容