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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<?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 对象会储存起来以供后面使用。源码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
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 异常 。源码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 连接是否已经断了,如果断了就进行重新连接,源码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/* 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 报的错误。

1
2
3
4
5
6
7
8
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 的异常,这种情况基本是不会发生的,谁会没有先连接就去执行命令呢?