很久很久以前,我也是因为工作上的bug,研究了php mysql client的连接驱动mysqlnd 与libmysql之间的区别 php与mysql通讯那点事 ,这次又遇到一件跟他们有联系的事情,mysqli与mysql持久链接的区别。写出这篇文章,用了好一个多月,其一是我太懒了,其二是工作也比较忙。最近才能腾出时间,来做这些事情。每次做总结,都要认真阅读源码,理解含义,测试验证,来确认这些细节。而每一个步骤都需要花费很长的时间,而且,还不能被打断。一旦被打断了,都需要很长时间去温习上下文。也故意强迫自己写这篇总结,改改自己的惰性。
在我和我的小伙伴们如火如荼的开发、测试时发生了“mysql server too many connections”的错误,稍微排查了一下,发现是php后台进程建立了大量的链接,而没有关闭。服务器环境大约如下php5.3.x 、mysqli API、mysqlnd 驱动。代码情况是这样:
01
|
//后台进程A
|
02
|
/*
|
03
|
配置信息
|
04
|
'mysql'=>array(
|
05
|
'driver'=>'mysqli',
|
06
|
// 'driver'=>'pdo',
|
07
|
// 'driver'=>'mysql',
|
08
|
'host'=>'192.168.111.111',
|
09
|
'user'=>'root',
|
10
|
'port'=>3306,
|
11
|
'dbname'=>'dbname',
|
12
|
'socket'=>'',
|
13
|
'pass'=>'pass',
|
14
|
'persist'=>true, //下面有提到哦,这是持久链接的配置
|
15
|
),
|
16
|
*/
|
17
|
$config
=Yaf_Registry::get(
'config'
);
|
18
|
$driver
= Afx_Db_Factory::DbDriver(
$config
[
'mysql'
][
'driver'
]);
//mysql mysqli
|
19
|
$driver
::debug(
$config
[
'debug'
]);
//注意这里
|
20
|
$driver
->setConfig(
$config
[
'mysql'
]);
//注意这里
|
21
|
Afx_Module::Instance()->setAdapter(
$driver
);
//注意这里,哪里不舒服,就注意看哪里。
|
22
|
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;
|
27
|
while
(true)
|
28
|
{
|
29
|
$data
=
$queue
->pop(MTypes::ECTYPE_COMBAT_QUEUE, 1);
|
30
|
if
(!
$data
){
|
31
|
usleep(50000);
//休眠0.05秒
|
32
|
++
$idle_count
;
|
33
|
if
(
$idle_count
>=
$idle_max
)
|
34
|
{
|
35
|
$idle_count
=0;
|
36
|
Afx_Db_Factory::ping();
|
37
|
}
|
38
|
continue
;
|
39
|
}
|
40
|
$idle_count
=0;
|
41
|
$Role
->setId(
$data
[
'attacker'
][
'role_id'
]);
|
42
|
$Property
=
$Role
->getModule(
'Property'
);
|
43
|
$Mounts
=
$Role
->getModule(
'Mounts'
);
|
44
|
//............
|
45
|
unset(
$Property
,
$Mounts
/*.....*/
);
|
46
|
}
|
从这个后台进程代码中,可以看出“$Property”变量以及“$Mounts”变量频繁被创建,销毁。而ROLE对象的getModule方法是这样写的
01
|
//ROLE对象的getModule方法
|
02
|
class
Role
extends
Afx_Module_Abstract
|
03
|
{
|
04
|
public
function
getModule (
$member_class
)
|
05
|
{
|
06
|
$property_name
=
'__m'
. ucfirst(
$member_class
);
|
07
|
if
(! isset(
$this
->
$property_name
))
|
08
|
{
|
09
|
$this
->
$property_name
=
new
$member_class
(
$this
);
|
10
|
}
|
11
|
return
$this
->
$property_name
;
|
12
|
}
|
13
|
}
|
14
|
//Property 类
|
15
|
class
Property
extends
Afx_Module_Abstract
|
16
|
{
|
17
|
public
function
__construct (
$mRole
)
|
18
|
{
|
19
|
$this
->__mRole =
$mRole
;
|
20
|
}
|
21
|
}
|
可以看出getModule方法只是模拟单例,new了一个新对象返回,而他们都继承了Afx_Module_Abstract类。Afx_Module_Abstract类大约代码如下:
1
|
abstract
class
Afx_Module_Abstract
|
2
|
{
|
3
|
public
function
setAdapter (
$_adapter
)
|
4
|
{
|
5
|
$this
->_adapter =
$_adapter
;
|
6
|
}
|
7
|
}
|
类Afx_Module_Abstract中关键代码如上,跟DB相关的,就setAdapter一个方法,回到“后台进程A”,setAdapter方法是将Afx_Db_Factory::DbDriver($config['mysql']['driver'])的返回,作为参数传了进来。继续看下Afx_Db_Factory类的代码
01
|
class
Afx_Db_Factory
|
02
|
{
|
03
|
const
DB_MYSQL =
'mysql'
;
|
04
|
const
DB_MYSQLI =
'mysqli'
;
|
05
|
const
DB_PDO =
'pdo'
;
|
06
|
07
|
public
static
function
DbDriver (
$type
= self::DB_MYSQLI)
|
08
|
{
|
09
|
switch
(
$type
)
|
10
|
{
|
11
|
case
self::DB_MYSQL:
|
12
|
$driver
= Afx_Db_Mysql_Adapter::Instance();
|
13
|
break
;
|
14
|
case
self::DB_MYSQLI:
|
15
|
$driver
= Afx_Db_Mysqli_Adapter::Instance();
//走到这里了
|
16
|
break
;
|
17
|
case
self::DB_PDO:
|
18
|
$driver
= Afx_Db_Pdo_Adapter::Instance();
|
19
|
break
;
|
20
|
default
:
|
21
|
break
;
|
22
|
}
|
23
|
return
$driver
;
|
24
|
}
|
25
|
}
|
一看就知道是个工厂类,继续看真正的DB Adapter部分代码
01
|
class
Afx_Db_Mysqli_Adapter
implements
Afx_Db_Adapter
|
02
|
{
|
03
|
public
static
function
Instance ()
|
04
|
{
|
05
|
if
(! self::
$__instance
instanceof Afx_Db_Mysqli_Adapter)
|
06
|
{
|
07
|
self::
$__instance
=
new
self();
//这里是单例模式,为何新生成了一个mysql的链接呢?
|
08
|
}
|
09
|
return
self::
$__instance
;
|
10
|
}
|
11
|
12
|
public
function
setConfig (
$config
)
|
13
|
{
|
14
|
$this
->__host =
$config
[
'host'
];
|
15
|
//...
|
16
|
$this
->__user =
$config
[
'user'
];
|
17
|
$this
->__persist =
$config
[
'persist'
];
|
18
|
if
(
$this
->__persist == TRUE)
|
19
|
{
|
20
|
$this
->__host =
'p:'
.
$this
->__host;
//这里为持久链接做了处理,支持持久链接
|
21
|
}
|
22
|
$this
->__config =
$config
;
|
23
|
}
|
24
|
25
|
private
function
__init ()
|
26
|
{
|
27
|
28
|
$this
->__
|
29
|
$this
->__
$this
->__timeout);
|
30
|
$this
->__
$this
->__host,
$this
->__user,
$this
->__pass,
$this
->__dbname,
$this
->__port,
$this
->__socket);
|
31
|
if
(
$this
->__
|
32
|
{
|
33
|
$this
->__
$this
->__charset);
|
34
|
}
else
|
35
|
{
|
36
|
throw
new
Afx_Db_Exception(
$this
->__
$this
->__
|
37
|
}
|
38
|
}
|
39
|
}
|
从上面的代码可以看到,我们已经启用长链接了啊,为何频繁建立了这么多链接呢?为了模拟重现这个问题,我在本地开发环境进行测试,无论如何也重现不了,对比了下环境,我的开发环境是windows7、php5.3.x、mysql、libmysql,跟服务器上的不一致,问题很可能出现在mysql跟mysqli的API上,或者是libmysql跟mysqlnd的问题上。为此,我又小心翼翼的翻开PHP源码(5.3.x最新的),终于功夫不负有心人,找到了这些问题的原因。
01
|
//在文件ext\mysql\php_mysql.c的907-916行
|
02
|
//mysql_connect、mysql_pconnect都调用它,区别是持久链接标识就是persistent为false还是true
|
03
|
static
void
php_mysql_do_connect(INTERNAL_FUNCTION_PARAMETERS,
int
persistent)
|
04
|
{
|
05
|
/* hash it up */
|
06
|
Z_TYPE(new_le) = le_p
|
07
|
new_le.ptr = mysql;
|
08
|
//注意下面的if里面的代码
|
09
|
if
(zend_hash_update(&EG(persistent_list), hashed_details, hashed_details_length+1, (
void
*) &new_le,
sizeof
(zend_rsrc_list_entry), NULL)==FAILURE) {
|
10
|
free
(mysql);
|
11
|
efree(hashed_details);
|
12
|
MYSQL_DO_CONNECT_RETURN_FALSE();
|
13
|
}
|
14
|
MySG(num_persistent)++;
|
15
|
MySG(num_
|
16
|
}
|
从mysql_pconnect的代码中,可以看到,当php拓展mysql api与mysql server建立TCP链接后,就立刻将这个链接存入persistent_list中,下次建立链接是,会先从persistent_list里查找是否存在同IP、PORT、USER、PASS、CLIENT_FLAGS的链接,存在则用它,不存在则新建。
而php的mysqli拓展中,不光用了一个persistent_list来存储链接,还用了一个free_
01
|
//文件ext\mysqli\mysqli_nonapi.c 172行左右 mysqli_common_connect创建TCP链接(mysqli_connect函数调用时)
|
02
|
do
{
|
03
|
if
(zend_ptr_stack_num_elements(&plist->free_
|
04
|
mysql->mysql = zend_ptr_stack_pop(&plist->free_
//直接pop出来,同一个脚本的下一个mysqli_connect再次调用时,就找不到它了
|
05
|
06
|
MyG(num_inactive_persistent)--;
|
07
|
/* reset variables */
|
08
|
09
|
#ifndef MYSQLI_NO_CHANGE_USER_ON_PCONNECT
|
10
|
if
(!mysqli_change_user_silent(mysql->mysql, username, passwd, dbname, passwd_len)) {
//(让你看时,你再看)注意看这里mysqli_change_user_silent
|
11
|
#else
|
12
|
if
(!mysql_ping(mysql->mysql)) {
|
13
|
#endif
|
14
|
#ifdef MYSQLI_USE_MYSQLND
|
15
|
mysqlnd_restart_psession(mysql->mysql);
|
16
|
#endif
|
17
|
}
|
18
|
//文件ext\mysqli\mysqli_api.c 585-615行
|
19
|
/* {{{ php_mysqli_close */
|
20
|
void
php_mysqli_close(MY_MYSQL * mysql,
int
close_type,
int
resource_status TSRMLS_DC)
|
21
|
{
|
22
|
if
(resource_status > MYSQLI_STATUS_INITIALIZED) {
|
23
|
MyG(num_
|
24
|
}
|
25
|
26
|
if
(!mysql->persistent) {
|
27
|
mysqli_close(mysql->mysql, close_type);
|
28
|
}
else
{
|
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);
|
35
|
#endif
|
36
|
zend_ptr_stack_push(&plist->free_
//这里在push回去,下次又可以用了
|
37
|
38
|
MyG(num_active_persistent)--;
|
39
|
MyG(num_inactive_persistent)++;
|
40
|
}
|
41
|
}
|
42
|
mysql->persistent = FALSE;
|
43
|
}
|
44
|
mysql->mysql = NULL;
|
45
|
46
|
php_clear_mysql(mysql);
|
47
|
}
|
48
|
/* }}} */
|
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底层实现,实际上也是这个原因。 代码如下:
1
|
<?php
|
2
|
$
=
array
();
|
3
|
for
(
$i
= 0;
$i
< 15;
$i
++) {
|
4
|
$
[] = mysqli_connect(
'p:192.168.1.40'
,
'USER'
,
'PWD'
,
'DB'
, 3306);
|
5
|
}
|
6
|
sleep(15);
|
查看进程列表里是这样的结果:
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
|
$
[
$i
] = mysqli_connect(
'p:192.168.1.40'
,
'USER'
,
'PWD'
,
'DB'
, 3306);
|
03
|
var_dump(mysqli_thread_id(
$
[
$i
]));
//如果你担心被close掉了,这是新建的TCP链接,那么你可以打印下thread id,看看是不是同一个ID,就区分开了
|
04
|
mysqli_close(
$
[
$i
])
|
05
|
}
|
06
|
/*
|
07
|
结果如下:
|
08
|
root@cnxct:/home/cfc4n# netstat -antp |grep 3306|grep -v "php-fpm"
|
09
|
tcp 0 0 192.168.61.150:55148 192.168.71.88:3306 ESTABLISHED 5100/php5
|
10
|
root@cnxct:/var/www# /usr/bin/php5 4.php
|
11
|
int(224218)
|
12
|
int(224218)
|
13
|
int(224218)
|
14
|
int(224218)
|
15
|
int(224218)
|
16
|
int(224218)
|
17
|
int(224218)
|
18
|
int(224218)
|
19
|
int(224218)
|
20
|
int(224218)
|
21
|
int(224218)
|
22
|
int(224218)
|
23
|
int(224218)
|
24
|
int(224218)
|
25
|
int(224218)
|
26
|
*/
|
如果你担心被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_
01
|
// DB FACTORY
|
02
|
class
Afx_Db_Factory
|
03
|
{
|
04
|
const
DB_MYSQL =
'mysql'
;
|
05
|
const
DB_MYSQLI =
'mysqli'
;
|
06
|
const
DB_PDO =
'pdo'
;
|
07
|
08
|
static
$drivers
=
array
(
|
09
|
'mysql'
=>
array
(),
'mysqli'
=>
array
(),
'pdo'
=>
array
()
|
10
|
);
|
11
|
12
|
13
|
public
static
function
DbDriver (
$type
= self::DB_MYSQLI,
$create
= FALSE)
//新增$create 参数
|
14
|
{
|
15
|
$driver
= NULL;
|
16
|
switch
(
$type
)
|
17
|
{
|
18
|
case
self::DB_MYSQL:
|
19
|
$driver
= Afx_Db_Mysql_Adapter::Instance(
$create
);
|
20
|
break
;
|
21
|
case
self::DB_MYSQLI:
|
22
|
$driver
= Afx_Db_Mysqli_Adapter::Instance(
$create
);
|
23
|
break
;
|
24
|
case
self::DB_PDO:
|
25
|
$driver
= Afx_Db_Pdo_Adapter::Instance(
$create
);
|
26
|
break
;
|
27
|
default
:
|
28
|
break
;
|
29
|
}
|
30
|
self::
$drivers
[
$type
][] =
$driver
;
|
31
|
return
$driver
;
|
32
|
}
|
33
|
}
|
34
|
35
|
//mysqli adapter
|
36
|
class
Afx_Db_Mysqli_Adapter
implements
Afx_Db_Adapter
|
37
|
{
|
38
|
public
static
function
Instance (
$create
= FALSE)
|
39
|
{
|
40
|
if
(
$create
)
|
41
|
{
|
42
|
return
new
self();
//新增$create参数的判断
|
43
|
}
|
44
|
if
(! self::
$__instance
instanceof Afx_Db_Mysqli_Adapter)
|
45
|
{
|
46
|
self::
$__instance
=
new
self();
|
47
|
}
|
48
|
return
self::
$__instance
;
|
49
|
}
|
50
|
}
|
看来,开发环境跟运行环境一致是多么的重要,否则就不会遇到这些问题了。不过,如果没遇到这么有意思的问题,岂不是太可惜了?