在我和我的小伙伴们如火如荼的开发、测试时发生了“mysql server too many connections”的错误,稍微排查了一下,发现是php后台进程建立了大量的链接,而没有关闭。服务器环境大约如下php5.3.x 、mysqli API、mysqlnd 驱动。代码情况是这样:
17 | $config =Yaf_Registry::get( 'config' ); |
18 | $driver = Afx_Db_Factory::DbDriver( $config [ 'mysql' ][ 'driver' ]); |
19 | $driver ::debug( $config [ 'debug' ]); |
20 | $driver ->setConfig( $config [ 'mysql' ]); |
21 | Afx_Module::Instance()->setAdapter( $driver ); |
23 | $queue =Afx_Queue::Instance(); |
24 | $combat = new CombatEngine(); |
25 | $Role = new Role(1,true); |
26 | $idle_max =isset( $config [ 'idle_max' ])? $config [ 'idle_max' ]:1000; |
29 | $data = $queue ->pop(MTypes::ECTYPE_COMBAT_QUEUE, 1); |
33 | if ( $idle_count >= $idle_max ) |
36 | Afx_Db_Factory::ping(); |
41 | $Role ->setId( $data [ 'attacker' ][ 'role_id' ]); |
42 | $Property = $Role ->getModule( 'Property' ); |
43 | $Mounts = $Role ->getModule( 'Mounts' ); |
45 | unset( $Property , $Mounts ); |
从这个后台进程代码中,可以看出“$Property”变量以及“$Mounts”变量频繁被创建,销毁。而ROLE对象的getModule方法是这样写的
02 | class Role extends Afx_Module_Abstract |
04 | public function getModule ( $member_class ) |
06 | $property_name = '__m' . ucfirst( $member_class ); |
07 | if (! isset( $this -> $property_name )) |
09 | $this -> $property_name = new $member_class ( $this ); |
11 | return $this -> $property_name ; |
15 | class Property extends Afx_Module_Abstract |
17 | public function __construct ( $mRole ) |
19 | $this ->__mRole = $mRole ; |
可以看出getModule方法只是模拟单例,new了一个新对象返回,而他们都继承了Afx_Module_Abstract类。Afx_Module_Abstract类大约代码如下:
1 | abstract class Afx_Module_Abstract |
3 | public function setAdapter ( $_adapter ) |
5 | $this ->_adapter = $_adapter ; |
类Afx_Module_Abstract中关键代码如上,跟DB相关的,就setAdapter一个方法,回到“后台进程A”,setAdapter方法是将Afx_Db_Factory::DbDriver($config[‘mysql’][‘driver’])的返回,作为参数传了进来。继续看下Afx_Db_Factory类的代码
03 | const DB_MYSQL = 'mysql' ; |
04 | const DB_MYSQLI = 'mysqli' ; |
07 | public static function DbDriver ( $type = self::DB_MYSQLI) |
12 | $driver = Afx_Db_Mysql_Adapter::Instance(); |
15 | $driver = Afx_Db_Mysqli_Adapter::Instance(); |
18 | $driver = Afx_Db_Pdo_Adapter::Instance(); |
一看就知道是个工厂类,继续看真正的DB Adapter部分代码
01 | class Afx_Db_Mysqli_Adapter implements Afx_Db_Adapter |
03 | public static function Instance () |
05 | if (! self:: $__instance instanceof Afx_Db_Mysqli_Adapter) |
07 | self:: $__instance = new self(); |
09 | return self:: $__instance ; |
12 | public function setConfig ( $config ) |
14 | $this ->__host = $config [ 'host' ]; |
16 | $this ->__user = $config [ 'user' ]; |
17 | $this ->__persist = $config [ 'persist' ]; |
18 | if ( $this ->__persist == TRUE) |
20 | $this ->__host = 'p:' . $this ->__host; |
22 | $this ->__config = $config ; |
25 | private function __init () |
28 | $this ->__link = mysqli_init(); |
29 | $this ->__link->set_opt(MYSQLI_OPT_CONNECT_TIMEOUT, $this ->__timeout); |
30 | $this ->__link->real_connect( $this ->__host, $this ->__user, $this ->__pass, $this ->__dbname, $this ->__port, $this ->__socket); |
31 | if ( $this ->__link->errno == 0) |
33 | $this ->__link->set_charset( $this ->__charset); |
36 | throw new Afx_Db_Exception( $this ->__link->error, $this ->__link->errno); |
从上面的代码可以看到,我们已经启用长链接了啊,为何频繁建立了这么多链接呢?为了模拟重现这个问题,我在本地开发环境进行测试,无论如何也重现不了,对比了下环境,我的开发环境是windows7、php5.3.x、mysql、libmysql,跟服务器上的不一致,问题很可能出现在mysql跟mysqli的API上,或者是libmysql跟mysqlnd的问题上。为此,我又小心翼翼的翻开PHP源码(5.3.x最新的),终于功夫不负有心人,找到了这些问题的原因。
03 | static void php_mysql_do_connect(INTERNAL_FUNCTION_PARAMETERS, int persistent) |
06 | Z_TYPE(new_le) = le_plink; |
09 | if (zend_hash_update(&EG(persistent_list), hashed_details, hashed_details_length+1, ( void *) &new_le, sizeof (zend_rsrc_list_entry), NULL)==FAILURE) { |
11 | efree(hashed_details); |
12 | MYSQL_DO_CONNECT_RETURN_FALSE(); |
14 | MySG(num_persistent)++; |
从mysql_pconnect的代码中,可以看到,当php拓展mysql api与mysql server建立TCP链接后,就立刻将这个链接存入persistent_list中,下次建立链接是,会先从persistent_list里查找是否存在同IP、PORT、USER、PASS、CLIENT_FLAGS的链接,存在则用它,不存在则新建。
而php的mysqli拓展中,不光用了一个persistent_list来存储链接,还用了一个free_link来存储当前空闲的TCP链接。当查找时,还会判断是否在空闲的free_link链表中存在,存在了才使用这个TCP链接。而在mysqli_closez之后或者RSHUTDOWN后,才将这个链接push到free_links中。(mysqli会查找同IP,PORT、USER、PASS、DBNAME、SOCKET来作为同一标识,跟mysql不同的是,没了CLIENT,多了DBNAME跟SOCKET,而且IP还包括长连接标识“p”)
03 | if (zend_ptr_stack_num_elements(&plist->free_links)) { |
04 | mysql->mysql = zend_ptr_stack_pop(&plist->free_links); |
06 | MyG(num_inactive_persistent)--; |
09 | #ifndef MYSQLI_NO_CHANGE_USER_ON_PCONNECT |
10 | if (!mysqli_change_user_silent(mysql->mysql, username, passwd, dbname, passwd_len)) { |
12 | if (!mysql_ping(mysql->mysql)) { |
14 | #ifdef MYSQLI_USE_MYSQLND |
15 | mysqlnd_restart_psession(mysql->mysql); |
20 | void php_mysqli_close(MY_MYSQL * mysql, int close_type, int resource_status TSRMLS_DC) |
22 | if (resource_status > MYSQLI_STATUS_INITIALIZED) { |
26 | if (!mysql->persistent) { |
27 | mysqli_close(mysql->mysql, close_type); |
29 | zend_rsrc_list_entry *le; |
30 | if (zend_hash_find(&EG(persistent_list), mysql->hash_key, strlen (mysql->hash_key) + 1, ( void **)&le) == SUCCESS) { |
31 | if (Z_TYPE_P(le) == php_le_pmysqli()) { |
32 | mysqli_plist_entry *plist = (mysqli_plist_entry *) le->ptr; |
33 | #if defined(MYSQLI_USE_MYSQLND) |
34 | mysqlnd_end_psession(mysql->mysql); |
36 | zend_ptr_stack_push(&plist->free_links, mysql->mysql); |
38 | MyG(num_active_persistent)--; |
39 | MyG(num_inactive_persistent)++; |
42 | mysql->persistent = FALSE; |
46 | php_clear_mysql(mysql); |
MYSQLI为什么要这么做?为什么同一个长连接不能在同一个脚本中复用?
在C函数mysqli_common_connect中看到了有个mysqli_change_user_silent的调用,如上代码,mysqli_change_user_silent对应这libmysql的mysql_change_user或mysqlnd的mysqlnd_change_user_ex,他们都是调用了C API的mysql_change_user来清理当前TCP链接的一些临时的会话变量,未完整写的提交回滚指令,锁表指令,临时表解锁等等(这些指令,都是mysql server自己决定完成,不是php 的mysqli 判断已发送的sql指令然后做响应决定),见手册的说明The mysqli Extension and Persistent Connections。这种设计,是为了这个新特性,而mysql拓展,不支持这个功能。
从这些代码的浅薄里理解上来看,可以理解mysqli跟mysql的持久链接的区别了,这个问题,可能大家理解起来比较吃力,我后来搜了下,也发现了一个因为这个原因带来的疑惑,大家看这个案例,可能理解起来就非常容易了。Mysqli persistent connect doesn’t work回答者没具体到mysqli底层实现,实际上也是这个原因。 代码如下:
3 | for ( $i = 0; $i < 15; $i ++) { |
4 | $links [] = mysqli_connect( 'p:192.168.1.40' , 'USER' , 'PWD' , 'DB' , 3306); |
查看进程列表里是这样的结果:
01 | netstat -an | grep 192.168.1.40:3306 |
02 | tcp 0 0 192.168.1.6:52441 192.168.1.40:3306 ESTABLISHED |
03 | tcp 0 0 192.168.1.6:52454 192.168.1.40:3306 ESTABLISHED |
04 | tcp 0 0 192.168.1.6:52445 192.168.1.40:3306 ESTABLISHED |
05 | tcp 0 0 192.168.1.6:52443 192.168.1.40:3306 ESTABLISHED |
06 | tcp 0 0 192.168.1.6:52446 192.168.1.40:3306 ESTABLISHED |
07 | tcp 0 0 192.168.1.6:52449 192.168.1.40:3306 ESTABLISHED |
08 | tcp 0 0 192.168.1.6:52452 192.168.1.40:3306 ESTABLISHED |
09 | tcp 0 0 192.168.1.6:52442 192.168.1.40:3306 ESTABLISHED |
10 | tcp 0 0 192.168.1.6:52450 192.168.1.40:3306 ESTABLISHED |
11 | tcp 0 0 192.168.1.6:52448 192.168.1.40:3306 ESTABLISHED |
12 | tcp 0 0 192.168.1.6:52440 192.168.1.40:3306 ESTABLISHED |
13 | tcp 0 0 192.168.1.6:52447 192.168.1.40:3306 ESTABLISHED |
14 | tcp 0 0 192.168.1.6:52444 192.168.1.40:3306 ESTABLISHED |
15 | tcp 0 0 192.168.1.6:52451 192.168.1.40:3306 ESTABLISHED |
16 | tcp 0 0 192.168.1.6:52453 192.168.1.40:3306 ESTABLISHED |
这样看代码,就清晰多了,验证我的理解对不对也比较简单,这么一改就看出来了
01 | for ( $i = 0; $i < 15; $i ++) { |
02 | $links [ $i ] = mysqli_connect( 'p:192.168.1.40' , 'USER' , 'PWD' , 'DB' , 3306); |
03 | var_dump(mysqli_thread_id( $links [ $i ])); |
04 | mysqli_close( $links [ $i ]) |
如果你担心被close掉了,这是新建的TCP链接,那么你可以打印下thread id,看看是不是同一个ID,就清楚了。(虽然我没回复这个帖子,但不能证明我很坏。)以上是CLI模式时的情况。在FPM模式下时,每个页面请求都会由单个fpm子进程处理。这个子进程将负责维护php与mysql server建立的长链接,故当你多次访问此页面,来确认是不是同一个thread id时,可能会分别分发给其他fpm子进程处理,导致看到的结果不一样。但最终,每个fpm子进程都会分别维持这些TCP链接。
总体来说,mysqli拓展跟mysql拓展的区别是下面几条
- 持久链接建立方式,mysqli是在host前面增加“p:”两个字符;mysql使用mysql_pconnect函数;。
- mysqli建立的持久链接,必须在mysqli_close之后,才会下面的代码复用,或者RSHOTDOWN之后,被下一个请求复用;mysql的长连接,可以立刻被复用
- mysqli建立持久链接时,会自动清理上一个会话变量、回滚事务、表解锁、释放锁等操作;mysql不会。
- mysqli判断是否为同一持久链接标识是IP,PORT、USER、PASS、DBNAME、SOCKET;mysql是IP、PORT、USER、PASS、CLIENT_FLAGS
好了,知道这个原因,那我们文章开头提到的问题就好解决了,大家肯定第一个想到的是在类似Property的类中,__destruct析构函数中增加一个mysqli_close方法,当被销毁时,就调用关闭函数,把持久链接push到free_links里。如果你这么想,我只能恭喜你,答错了,最好的解决方案就是压根不让它创建这么多次。同事dietoad同学给了个解决方案,对DB ADAPTER最真正单例,并且,可选是否新创建链接。如下代码:
04 | const DB_MYSQL = 'mysql' ; |
05 | const DB_MYSQLI = 'mysqli' ; |
08 | static $drivers = array ( |
09 | 'mysql' => array (), 'mysqli' => array (), 'pdo' => array () |
13 | public static function DbDriver ( $type = self::DB_MYSQLI, $create = FALSE) |
19 | $driver = Afx_Db_Mysql_Adapter::Instance( $create ); |
22 | $driver = Afx_Db_Mysqli_Adapter::Instance( $create ); |
25 | $driver = Afx_Db_Pdo_Adapter::Instance( $create ); |
30 | self:: $drivers [ $type ][] = $driver ; |
36 | class Afx_Db_Mysqli_Adapter implements Afx_Db_Adapter |
38 | public static function Instance ( $create = FALSE) |
44 | if (! self:: $__instance instanceof Afx_Db_Mysqli_Adapter) |
46 | self:: $__instance = new self(); |
48 | return self:: $__instance ; |
看来,开发环境跟运行环境一致是多么的重要,否则就不会遇到这些问题了。