最近在生产环境发现了一条 Redis server went away 的异常错误。因为我们服务用的是长连接pconnect,看到错误信息首先想到的是phpredis扩展的连接断了。

然后写了段简单的代码来复现,发现事实并不是想象的那么简单。

<?php
error_reporting(E_ALL);
try {
    $redis = new Redis();
    $redis->pconnect('127.0.0.1', 6379);
    $redis->config("SET", "timeout", "5"); // 设置连接空闲5秒就自动关闭
    $redis->set('name', 'sinchie', 120);
    $res = $redis->get('name');
    var_dump($res);
    sleep(10); // 模拟空闲10秒 连接自动关闭
    // 连接断了的情况下 再次操作redis
    $redis->set('name', 'sinchie2', 120);
    $res = $redis->get('name');
    var_dump($res);
} catch(Exception $e) {
    var_dump($e->getMessage());
}

按照预期效果来看,这时候应该会报类似redis连接断的了异常。然而并不是这样,这段代码没有抛出任何异常,几次redis操作都正常执行了。感觉很诡异,难道是设置的超时断开没有生效?于是我又运行了多次,并使用 lsof 监控了进程的redis连接情况。发现在每次运行5秒的时候连接确实是断了,但是到10秒的时候程序自动换了个端口重新连接了redis服务。这里要给phpredis点赞,它做到了用户无感的断线自动重连。为了一探究竟,我去翻阅了phpredis扩展的源码,来一步步梳理。

发现一

在源码中的 redis_connect 函数,除了参数校验以外是不会抛出别的异常的。所以在我们执行 $redis->pconnect() 或者 $redis->connect() 的时候失败了,是不会抛出异常只会返回false。如果是连接成功,socket对象会储存起来以供后面使用。源码如下:

PHPAPI int redis_connect(INTERNAL_FUNCTION_PARAMETERS, int persistent) {
    // 校验参数部分省略。。。
    // 创建socket对象
    redis_sock = redis_sock_create(host, host_len, port, timeout, persistent, persistent_id, retry_interval, 0);
        // 如果连接失败 没有抛出异常 直接返回
    if (redis_sock_server_open(redis_sock, 1 TSRMLS_CC) < 0) {
        redis_free_socket(redis_sock);
        return FAILURE;
    }
    // 连接成功 保存socket对象
    #if PHP_VERSION_ID >= 50400
    id = zend_list_insert(redis_sock, le_redis_sock TSRMLS_CC);
    #else
    id = zend_list_insert(redis_sock, le_redis_sock);
    #endif
    add_property_resource(object, "socket", id);
    return SUCCESS;
}

发现二

下面执行 $redis->set(“name”,“sinchie”) 或者任何redis命令的时候,在源码中都会先执行了 redis_sock_get 函数,这个函数是来检查并获取已有的socket对象。如果没有socket对象,就会抛出 Redis server went away 的异常错误。很明显我们在执行 $redis->connect() 的时候如果失败了是不会有socket对象的,导致 redis_sock_get 必然会抛出 Redis server went away 的异常。这样也会出现一个很不合理的现象,不管你是地址写错了还是端口写错了,还是网络不通等所有原因,都会统一抛出 Redis server went away 异常 。源码如下:

PHPAPI int redis_sock_get(zval *id, RedisSock **redis_sock TSRMLS_DC, int no_throw)
{
    // 省略
    if (Z_TYPE_P(id) != IS_OBJECT || zend_hash_find(Z_OBJPROP_P(id), "socket",
                                sizeof("socket"), (void **) &socket) == FAILURE) {
    // Throw an exception unless we've been requested not to
        if(!no_throw) {
        zend_throw_exception(redis_exception_ce, "Redis server went away", 0 TSRMLS_CC);
        }
        return -1;
    }
    *redis_sock = (RedisSock *) zend_list_find(Z_LVAL_PP(socket), &resource_type);
    if (!*redis_sock || resource_type != le_redis_sock) {
    // Throw an exception unless we've been requested not to
    if(!no_throw) {
        zend_throw_exception(redis_exception_ce, "Redis server went away", 0 TSRMLS_CC);
    }
    return -1;
    }

    // 省略
}

这好像和我们预期的情况不太一样,因为我们的 $redis->pconnect(‘127.0.0.1’, 6379) 是肯定能连通的,socket对象肯定会有,所以不应该报这个异常。而且上面我们还试验了 phpredis是具有断线重连功能的,那就只能继续看源码还有没有别的原因会导致 Redis server went away 。 调用完 redis_sock_get 函数之后,下面就该进行socket读写了,立马就有了新发现。

发现三

每次对socket进行读写的时候都会执行 redis_check_eof 函数,这个函数是用来检测 socket 连接是否已经断了,如果断了就进行重新连接,源码如下:

/* TODO: configurable max retry count */
for (count = 0; count < 10; ++count) {
    /* close existing stream before reconnecting */
    if (redis_sock->stream) {
        redis_stream_close(redis_sock TSRMLS_CC);
        redis_sock->stream = NULL;
    }
    // Wait for a while before trying to reconnect
    if (redis_sock->retry_interval) {
        // Random factor to avoid having several (or many) concurrent connections trying to reconnect at the same time
        long retry_interval = (count ? redis_sock->retry_interval : (php_rand(TSRMLS_C) % redis_sock->retry_interval));
        usleep(retry_interval);
    }
    /* reconnect */
    if (redis_sock_connect(redis_sock TSRMLS_CC) == 0) {
        /* check for EOF again. */
        errno = 0;
        if (php_stream_eof(redis_sock->stream) == 0) {
            /* If we're using a password, attempt a reauthorization */
            if (redis_sock->auth && resend_auth(redis_sock TSRMLS_CC) != 0) {
                break;
            }
            /* If we're using a non-zero db, reselect it */
            if (redis_sock->dbNumber && reselect_db(redis_sock TSRMLS_CC) != 0) {
                break;
            }
            /* Success */
            return 0;
        }
    }
}
/* close stream if still here */
if (redis_sock->stream) {
    REDIS_STREAM_CLOSE_MARK_FAILED(redis_sock);
}
if (!no_throw) {
    zend_throw_exception(redis_exception_ce, "Connection lost", 0 TSRMLS_CC);
}

可以看到如果连接断了,会用 redis_sock_connect 函数尝试重新连接10次,假如10次都不成功的话,会抛出 Connection lost 异常并不是 Redis server went away 。那么可以确定 Redis server went away 不是在连接断开的时候抛出的。对源码进行全局搜索后并没有发现还有别的地方会抛出Redis server went away 。 那么可以总结出以下几种情况会抛出Redis server went away:

  1. 连接了错误的redis端口或者地址
  2. redis服务太繁忙(listen backlog满了或者闲置连接满了拒绝新连接)
  3. 网络抖动导致连接失败
  4. 本机端口不够用或者带宽不够
  5. 没有先执行connect()或者pconnect()获得socket对象,就直接执行redis命令

因为我们的redis服务没有压力报警,我们的闲置连接超时时间设置的也比较短,也没有重试10次之后的Connection lost异常。那么只有一种情况会抛出 Redis server went away, 在php-fpm进程重启重新执行pconnect()的时候,网络发生了抖动导致连接失败,然后抛出了Redis server went away。

这么多种原因都会抛出同一种异常信息,是非常不合理的!

因为历史原因,我们用的phpredis的版本不是最新的。然后我又看了下最新版的 phpredis 4.0 的源码,发现已经更正了connect()失败时不抛出异常的问题,连接失败的时候会原样输出socket报的错误。

if (redis_sock_server_open(redis->sock TSRMLS_CC) < 0) {
    if (redis->sock->err) {
        zend_throw_exception(redis_exception_ce, ZSTR_VAL(redis->sock->err), 0 TSRMLS_CC);
    }
    redis_free_socket(redis->sock);
    redis->sock = NULL;
    return FAILURE;
}

比如端口错误的时候会抛出 Connection refused 的异常,这下世界可算正常了!那么看来在4.0版本中,基本只有在没有执行connect() 或者 pconnect() 的情况下,直接执行redis命令,redis_sock_get 函数才会拿不到socket,导致抛出 Redis server went away的异常,这种情况基本是不会发生的,谁会没有先连接就去执行命令呢?