TL;DR
PHP 中针对 Redis / MySQL 的长连接是生命周期级别的长连接,对于同一个进程的每一次请求都不会释放当前连接对象。而针对 TCP Socket 级别的连接是否已断开,则交给操作系统维持。
使用 PDO 对 MySQL 开启持久连接,要注意 PHP 执行的进程数量,不能超过 MySQL 设定的最大连接数。
上述结论的前提是使用 phpredis 扩展,PHP 版本为 5.4.41。
背景
假设某同学使用 PHP 开发了一个队列消费 daemon,某天业务压力较大。实现上每次与 Redis 通信都新建连接,同时本地端口范围过小,导致用尽了本地端口,无法建立连接。
这个时候使用 pconnect 可以有效的减少重复建立连接的成本。使用 ss
等工具可以看到相关的连接数目。
编写如下脚本进行测试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
<?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"; |
结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
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
结构,利用这一个流式成员,完成与服务端之间的网络通信。
连接时使用 pconnect
与 connect
的区别在于对于流式对象的管理方式。
pconnect 实现
参数传递
扩展中连接方法的入口是 redis.c
文件中的 redis_connect
函数。解析参数列表部分的逻辑为:
1 2 3 4 5 6 7 |
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; } |
可以看到有一个可选的字符串参数(|
之后的s
)persistent_id
,此处先留意这一个变量,后续会使用到。同时,也可以看到 pconnect
方法是可以在调用时指定这个参数的值的。
在后续的 redis_sock_create
函数中,会将当前的 persistent_id
赋值给返回 RedisSock
对象,以供后续创建连接使用。
连接
建立与 Redis 服务器之间的连接工作实际上是由 redis_sock_server_open
函数完成的,即在这一个方法中,通过 php_stream_xport_create
函数将网络连接创建并赋值给 RedisSock
中的 stream
变量,实际上,后续的所有网络读写操作,都会通过这一个变量完成。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
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
完成了当前资源对象的记录:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// 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 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
// 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)
中,减少了无谓的创建过程。
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 39 40 41 42 |
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 **)®entry, &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_option
中 PHP_STREAM_OPTION_CHECK_LIVENESS
对应分支的逻辑进行连接可用性的检查:
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 |
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 阶段时才会清理:
1 2 3 4 5 6 7 8 9 10 11 12 |
// 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 阶段就会被清理(这一阶段进程尚未退出):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
// 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
指令并将当前对象的状态设定为已断开状态,实际上并未清理对应的流:
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 |
// 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/服务器端口/用户名/密码
等元素。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
// 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 执行的整个生命周期之内保证了不会重新创建连接。