最近在生产环境发现了一条 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:
- 连接了错误的 redis 端口或者地址
- redis 服务太繁忙(listen backlog 满了或者闲置连接满了拒绝新连接)
- 网络抖动导致连接失败
- 本机端口不够用或者带宽不够
- 没有先执行 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 的异常,这种情况基本是不会发生的,谁会没有先连接就去执行命令呢?