很久很久以前,我也是因为工作上的bug,研究了php mysql client的连接驱动mysqlnd 与libmysql之间的区别php与mysql通讯那点事,这次又遇到一件跟他们有联系的事情,mysqli与mysql持久链接的区别。写出这篇文章,用了好一个多月,其一是我太懒了,其二是工作也比较忙。最近才能腾出时间,来做这些事情。每次做总结,都要认真阅读源码,理解含义,测试验证,来确认这些细节。而每一个步骤都需要花费很长的时间,而且,还不能被打断。一旦被打断了,都需要很长时间去温习上下文。也故意强迫自己写这篇总结,改改自己的惰性。
在我和我的小伙伴们如火如荼的开发、测试时发生了“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= newCombatEngine(); | 
 
| 25 | $Role= newRole(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 | classRole extendsAfx_Module_Abstract | 
 
| 04 |     publicfunctiongetModule ($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 | classProperty extendsAfx_Module_Abstract | 
 
| 17 |     publicfunction__construct ($mRole) | 
 
| 19 |         $this->__mRole = $mRole; | 
 
 
 
可以看出getModule方法只是模拟单例,new了一个新对象返回,而他们都继承了Afx_Module_Abstract类。Afx_Module_Abstract类大约代码如下:
| 1 | abstractclassAfx_Module_Abstract | 
 
| 3 |     publicfunctionsetAdapter ($_adapter) | 
 
| 5 |         $this->_adapter = $_adapter; | 
 
 
 
类Afx_Module_Abstract中关键代码如上,跟DB相关的,就setAdapter一个方法,回到“后台进程A”,setAdapter方法是将Afx_Db_Factory::DbDriver($config['mysql']['driver'])的返回,作为参数传了进来。继续看下Afx_Db_Factory类的代码
| 03 |     constDB_MYSQL = 'mysql'; | 
 
| 04 |     constDB_MYSQLI = 'mysqli'; | 
 
| 07 |     publicstaticfunctionDbDriver ($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 | classAfx_Db_Mysqli_Adapter implementsAfx_Db_Adapter | 
 
| 03 |     publicstaticfunctionInstance () | 
 
| 05 |         if(! self::$__instanceinstanceof Afx_Db_Mysqli_Adapter) | 
 
| 07 |             self::$__instance= newself();     | 
 
| 09 |         returnself::$__instance; | 
 
| 12 |     publicfunctionsetConfig ($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 |     privatefunction__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 |             thrownewAfx_Db_Exception($this->__link->error, $this->__link->errno); | 
 
 
 
从上面的代码可以看到,我们已经启用长链接了啊,为何频繁建立了这么多链接呢?为了模拟重现这个问题,我在本地开发环境进行测试,无论如何也重现不了,对比了下环境,我的开发环境是windows7、php5.3.x、mysql、libmysql,跟服务器上的不一致,问题很可能出现在mysql跟mysqli的API上,或者是libmysql跟mysqlnd的问题上。为此,我又小心翼翼的翻开PHP源码(5.3.x最新的),终于功夫不负有心人,找到了这些问题的原因。
| 03 | staticvoidphp_mysql_do_connect(INTERNAL_FUNCTION_PARAMETERS, intpersistent) | 
 
| 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 | voidphp_mysqli_close(MY_MYSQL * mysql, intclose_type, intresource_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 |     constDB_MYSQL = 'mysql'; | 
 
| 05 |     constDB_MYSQLI = 'mysqli'; | 
 
| 08 |     static$drivers= array( | 
 
| 09 |         'mysql'=>array(),'mysqli'=>array(),'pdo'=>array() | 
 
| 13 |     publicstaticfunctionDbDriver ($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 | classAfx_Db_Mysqli_Adapter implementsAfx_Db_Adapter | 
 
| 38 |     publicstaticfunctionInstance ($create= FALSE) | 
 
| 44 |         if(! self::$__instanceinstanceof Afx_Db_Mysqli_Adapter) | 
 
| 46 |             self::$__instance= newself(); | 
 
| 48 |         returnself::$__instance; | 
 
 
 
看来,开发环境跟运行环境一致是多么的重要,否则就不会遇到这些问题了。不过,如果没遇到这么有意思的问题,岂不是太可惜了?
		
		
			觉得文章有用?立即:
			
			和朋友一起 共学习 共进步!
		
		
		
			
			
			
			
		
		猜想失败,您看看下面的文章有用吗?