PHP中Redis/MySQL的长连接

TL;DR

PHP 中针对 Redis / MySQL 的长连接是生命周期级别的长连接,对于同一个进程的每一次请求都不会释放当前连接对象。而针对 TCP Socket 级别的连接是否已断开,则交给操作系统维持。

使用 PDO 对 MySQL 开启持久连接,要注意 PHP 执行的进程数量,不能超过 MySQL 设定的最大连接数。

上述结论的前提是使用 phpredis 扩展,PHP 版本为 5.4.41。

背景

假设某同学使用 PHP 开发了一个队列消费 daemon,某天业务压力较大。实现上每次与 Redis 通信都新建连接,同时本地端口范围过小,导致用尽了本地端口,无法建立连接。

这个时候使用 pconnect 可以有效的减少重复建立连接的成本。使用 ss 等工具可以看到相关的连接数目。

编写如下脚本进行测试:

<?php
    $persist = isset($argv[1]) ? true : false;
    $cmd = "ss -ant | grep ESTAB | awk  'BEGIN{conn=0;}{if($5 == \"127.0.0.1:6379\"){++conn;}}END{print conn}'";

    echo "Before:\n";
    echo shell_exec($cmd) . "\n";

    $rs = [];

    for ($i = 1; $i <= 5; ++$i) {
        $r = new Redis();
        $rs[] = $r;

        if ($persist) {
            $r->pconnect('127.0.0.1', 6379);
        } else {
            $r->connect('127.0.0.1', 6379);
        }
    }

    echo "After:\n";
    echo shell_exec($cmd) . "\n";

结果:

root@vm:~# php test_redis_connect.php persist
Before:
0

After:
1

root@vm:~# php test_redis_connect.php
Before:
0

After:
5

可以看到使用了 pconnect 可以有效减少连接数。

Redis 连接的实现

直接翻看 phpredis 扩展源码(2.2.7)。

总体而言,phpredis 通过给定的参数产生一个 RedisSock*,在这一个结构中包含一个 php_stream 结构,利用这一个流式成员,完成与服务端之间的网络通信。

连接时使用 pconnectconnect 的区别在于对于流式对象的管理方式。

pconnect 实现

参数传递

扩展中连接方法的入口是 redis.c 文件中的 redis_connect 函数。解析参数列表部分的逻辑为:

if (zend_parse_method_parameters(ZEND_NUM_ARGS() TSRMLS_CC, getThis(), "Os|ldsl",
            &object, redis_ce, &host, &host_len, &port,
            &timeout, &persistent_id, &persistent_id_len,
            &retry_interval) == FAILURE) {
    return FAILURE;
}

可以看到有一个可选的字符串参数(|之后的spersistent_id,此处先留意这一个变量,后续会使用到。同时,也可以看到 pconnect 方法是可以在调用时指定这个参数的值的。

在后续的 redis_sock_create 函数中,会将当前的 persistent_id 赋值给返回 RedisSock 对象,以供后续创建连接使用。

连接

建立与 Redis 服务器之间的连接工作实际上是由 redis_sock_server_open 函数完成的,即在这一个方法中,通过 php_stream_xport_create 函数将网络连接创建并赋值给 RedisSock 中的 stream 变量,实际上,后续的所有网络读写操作,都会通过这一个变量完成。

if (redis_sock->persistent) {
  if (redis_sock->persistent_id) {
    spprintf(&persistent_id, 0, "phpredis:%s:%s", host, redis_sock->persistent_id);
  } else {
    spprintf(&persistent_id, 0, "phpredis:%s:%f", host, redis_sock->timeout);
  }
}

redis_sock->stream = php_stream_xport_create(host, host_len, ENFORCE_SAFE_MODE,
                         STREAM_XPORT_CLIENT
                         | STREAM_XPORT_CONNECT,
                         persistent_id, tv_ptr, NULL, &errstr, &err
                        );

这里判断 persistent 的逻辑,就用到了传递进来的 persistent_id 参数,如果没有设定,会根据 Redis 服务器的 IP 和当前设定的超时创建出一个字符串作为值。

这个 ID 用于标记这是一个需要保持的对象(持久性资源)。

PHP 持久性资源

首先,对于Socket/文件等对象,在 PHP 中都是资源对象,而 PHP 扩展中在实现上会将这一个资源对象记录到哈希表 EG(regular_list) 中,通过 zend_list_delete 等函数完成资源引用计数的操作,当引用计数为 0 时,认为资源已无效,删除资源。

redis.c 中的 redis_connect 方法中,当通过 redis_sock_create 创建成功资源对象后,通过 zend_list_insert 完成了当前资源对象的记录:

// redis.c
    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;
    }

#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
// zend_list.c
ZEND_API int zend_list_insert(void *ptr, int type TSRMLS_DC)
{
    int index;
    zend_rsrc_list_entry le;

    le.ptr=ptr;
    le.type=type;
    le.refcount=1;

    index = zend_hash_next_free_element(&EG(regular_list));

    zend_hash_index_update(&EG(regular_list), index, (void *) &le, sizeof(zend_rsrc_list_entry), NULL);
    return index;
}

回到 phpredis 扩展连接创建过程,在创建过程中的 _php_stream_xport_create 函数中,当设定了 persistent_id 之后,会首先在另一个全局哈希表 EG(persistent_list) 中通过 persistent_id 查找到对应的资源对象是否存在,如果存在还会将其尝试注册到 EG(regular_list) 中,减少了无谓的创建过程。

PHPAPI int php_stream_from_persistent_id(const char *persistent_id, php_stream **stream TSRMLS_DC)
{
    zend_rsrc_list_entry *le;

// 注意
    if (zend_hash_find(&EG(persistent_list), (char*)persistent_id, strlen(persistent_id)+1, (void*) &le) == SUCCESS) {
        if (Z_TYPE_P(le) == le_pstream) {
            if (stream) {
                HashPosition pos;
                zend_rsrc_list_entry *regentry;
                ulong index = -1; /* intentional */

                /* see if this persistent resource already has been loaded to the
                 * regular list; allowing the same resource in several entries in the
                 * regular list causes trouble (see bug #54623) */
                zend_hash_internal_pointer_reset_ex(&EG(regular_list), &pos);
                while (zend_hash_get_current_data_ex(&EG(regular_list),
                        (void **)&regentry, &pos) == SUCCESS) {
                    if (regentry->ptr == le->ptr) {
                        zend_hash_get_current_key_ex(&EG(regular_list), NULL, NULL,
                            &index, 0, &pos);
                        break;
                    }
                    zend_hash_move_forward_ex(&EG(regular_list), &pos);
                }
                
                *stream = (php_stream*)le->ptr;
                if (index == -1) { /* not found in regular list */
                    le->refcount++;
                    (*stream)->rsrc_id = ZEND_REGISTER_RESOURCE(NULL, *stream, le_pstream);
                } else {
                    regentry->refcount++;
                    (*stream)->rsrc_id = index;
                }
            }
            return PHP_STREAM_PERSISTENT_SUCCESS;
        }
        return PHP_STREAM_PERSISTENT_FAILURE;
    }
    return PHP_STREAM_PERSISTENT_NOT_EXIST;
}

随后会检查连接的可用性,由于对 Redis 服务器是一个 TCP 连接,会通过 xp_socket.c 文件中的 php_sockop_set_optionPHP_STREAM_OPTION_CHECK_LIVENESS 对应分支的逻辑进行连接可用性的检查:

static int php_sockop_set_option(php_stream *stream, int option, int value, void *ptrparam TSRMLS_DC)
{
    int oldmode, flags;
    php_netstream_data_t *sock = (php_netstream_data_t*)stream->abstract;
    php_stream_xport_param *xparam;
    
    switch(option) {
        case PHP_STREAM_OPTION_CHECK_LIVENESS:
            {
                struct timeval tv;
                char buf;
                int alive = 1;

                if (value == -1) {
                    if (sock->timeout.tv_sec == -1) {
                        tv.tv_sec = FG(default_socket_timeout);
                        tv.tv_usec = 0;
                    } else {
                        tv = sock->timeout;
                    }
                } else {
                    tv.tv_sec = value;
                    tv.tv_usec = 0;
                }

                if (sock->socket == -1) {
                    alive = 0;
                } else if (php_pollfd_for(sock->socket, PHP_POLLREADABLE|POLLPRI, &tv) > 0) {
                    if (0 >= recv(sock->socket, &buf, sizeof(buf), MSG_PEEK) && php_socket_errno() != EWOULDBLOCK) {
                        alive = 0;
                    }
                }
                return alive ? PHP_STREAM_OPTION_RETURN_OK : PHP_STREAM_OPTION_RETURN_ERR;
            }
            //...

实际上是通过 poll 系统调用判断 socket 对应的 fd 是否可读并且 recv 系统调用返回可读长度小于等于0实现的。

对于 EG(persistent_list) 这一个哈希表,为什么能够实现针对于PHP应用程序持久性的连接呢?答案很简单,这个哈希表只有在 MSHUTDOWN 阶段时才会清理:

// zend.c
void zend_shutdown(TSRMLS_D) /* {{{ */
{
#ifdef ZEND_SIGNALS
    zend_signal_shutdown(TSRMLS_C);
#endif
#ifdef ZEND_WIN32
    zend_shutdown_timeout_thread();
#endif
    zend_destroy_rsrc_list(&EG(persistent_list) TSRMLS_CC);
    // ...

EG(regular_list)RSHUTDOWN 阶段就会被清理(这一阶段进程尚未退出):

// zend.c
void zend_deactivate(TSRMLS_D) /* {{{ */
{
    /* we're no longer executing anything */
    EG(opline_ptr) = NULL;
    EG(active_symbol_table) = NULL;

    zend_try {
        shutdown_scanner(TSRMLS_C);
    } zend_end_try();

    /* shutdown_executor() takes care of its own bailout handling */
    shutdown_executor(TSRMLS_C);

    zend_try {
        shutdown_compiler(TSRMLS_C);
    } zend_end_try();

    zend_destroy_rsrc_list(&EG(regular_list) TSRMLS_CC);
    // ...

哪怕是显式的调用了 close 方法,也只是向服务器发送 QUIT 指令并将当前对象的状态设定为已断开状态,实际上并未清理对应的流:

// redis.c
PHP_REDIS_API int redis_sock_disconnect(RedisSock *redis_sock TSRMLS_DC)
{
    if (redis_sock == NULL) {
        return 1;
    }

    redis_sock->dbNumber = 0;
    if (redis_sock->stream != NULL) {
            if (!redis_sock->persistent) {
                redis_sock_write(redis_sock, "QUIT" _NL, sizeof("QUIT" _NL) - 1 TSRMLS_CC);
            }

            redis_sock->status = REDIS_SOCK_STATUS_DISCONNECTED;
            redis_sock->watching = 0;
            if(redis_sock->stream && !redis_sock->persistent) { /* still valid after the write? */
                php_stream_close(redis_sock->stream);
            }
            redis_sock->stream = NULL;

            return 1;
    }

    return 0;
}

MySQL PDO的连接实现

MySQL PDO的连接实现与 Redis 类似,也是在传递了持久连接的参数后,会将连接对象保存到持久性资源对象的哈希表中。

构建持久化标记ID的方式看起来比 phpredis 扩展稍好一些,用到了 服务器Host/服务器端口/用户名/密码 等元素。

// pdo_dbh.c
// ...
        if (SUCCESS == zend_hash_index_find(Z_ARRVAL_P(options), PDO_ATTR_PERSISTENT, (void**)&v)) {
            if (Z_TYPE_PP(v) == IS_STRING && !is_numeric_string(Z_STRVAL_PP(v), Z_STRLEN_PP(v), NULL, NULL, 0) && Z_STRLEN_PP(v) > 0) {
                /* user specified key */
                plen = spprintf(&hashkey, 0, "PDO:DBH:DSN=%s:%s:%s:%s", data_source,
                        username ? username : "",
                        password ? password : "",
                        Z_STRVAL_PP(v));
                is_persistent = 1;
            } else {
                convert_to_long_ex(v);
                is_persistent = Z_LVAL_PP(v) ? 1 : 0;
                plen = spprintf(&hashkey, 0, "PDO:DBH:DSN=%s:%s:%s", data_source,
                        username ? username : "",
                        password ? password : "");
            }
        }
// ...      

然而如果启用了持久连接,PDO并没有给出主动清理 EG(persistent_list) 的方法,所以,如果要使用这一个特性,需要格外注意不要启动过多的进程,以至于超过 MySQL 设定的最大连接数。

小结

对于开启 Redis/MySQL 的持久连接,PHP 通过将资源对象通过一定的标志存储到 MSHUTDOWN 阶段才会清理的全局哈希表中实现,在针对同一个服务器进行请求时,在 PHP 执行的整个生命周期之内保证了不会重新创建连接。

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*

您可以使用这些HTML标签和属性: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code class="" title="" data-url=""> <del datetime=""> <em> <i> <q cite=""> <strike> <strong> <pre class="" title="" data-url=""> <span class="" title="" data-url="">

This site uses Akismet to reduce spam. Learn how your comment data is processed.