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 <?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 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 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 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 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 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 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 ; * 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 ) { 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 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 // zend.c void zend_shutdown(TSRMLS_D) /* {{{ */ { #ifdef ZEND _SIGNALS zend _signal _shutdown (TSRMLS _C ); #endif #ifdef ZEND _WIN 32 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 // 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 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) { 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 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 ) { 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 设定的最大连接数。
小结 PHP 中如果使用了 Redis/MySQL 的持久连接功能,PHP 内核会通过将资源对象存储到 MSHUTDOWN 阶段才会清理的全局 HashTable 中,实现针对同一个服务器进行请求时,在 PHP 执行的整个生命周期之内保证了正常情况下不会重新创建连接。
相关