0%

PDO中MySQL gone away重连处理

背景

最近工作中有个项目,用 Phalcon 框架开发,涉及 RabbitMQ,所以自己写了个类来处理消息。
部署时用 Supervisor 监管,但发现一段时间没有业务触发就会出现 SQLSTATE[HY000]: General error: 2006 MySQL server has gone away 的错误。

分析

为什么会出现这个错误?官方给出了解释 https://dev.mysql.com/doc/refman/5.7/en/gone-away.html

The most common reason for the MySQL server has gone away error is that the server timed out and closed the connection.
In this case, you normally get one of the following error codes (which one you get is operating system-dependent).

也就是说,出现 gone away,大部分是因为服务超时或断连了,而且错误码不一定是 2006(CR_SERVER_GONE_ERROR),也可能是 2013(CR_SERVER_LOST)。
常见的原因大致有:

  • 运行线程被杀掉了。
  • 关闭连接后又发出了SQL查询。
  • 运行环境没有授权连接MySQL。
  • 客户端的TCP/IP连接超时。
    比如做了这样的设置 mysql_options(..., MYSQL_OPT_READ_TIMEOUT,...)mysql_options(..., MYSQL_OPT_WRITE_TIMEOUT,...)
  • wait_timeout,即,服务端主动关闭超时未交互的连接。
  • 异常查询请求导致服务端主动关闭连接。
    比如 超出了 max_allowed_packet 变量设定的查询限制。

所以,大概率是 wait_timeout 导致的。

解决

show variables like '%timeout%'; 查看 MySQL 的 wait_timeoutinteractive_timeout
如果 my.ini 中没有配置,则默认 wait_timeout 为 28800,即,8小时。
为了测试,修改本地 MySQL 的 my.ini 配置:

1
2
3
4
[mysqld]
...
wait_timeout=5
interactive_timeout=5

直接上代码(一段实现异常重连的示例代码):

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
// 创建数据库连接
$_connect = static function () {
$dsn = 'mysql:dbname=test;host=127.0.0.1';
$user = 'root';
$password = '123456';

$dbh = null;
try {
$dbh = new \PDO($dsn, $user, $password, [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION
]);
} catch (\PDOException $e) {
echo 'Connection failed: ' . $e->getMessage();
}
return $dbh;
};
$dbh = $_connect();

// 执行SQL
$_execute = static function ($dbh, $sql) {
$sth = $dbh->prepare($sql);
$sth->execute([':id' => 1]);
$res = $sth->fetchAll();
print_r($res);
};

$i = 0;
do {
$i ++;
$sql = 'SELECT name FROM user WHERE id = :id';
try {
$_execute($dbh, $sql);
} catch (\PDOException $e) {
// 关闭连接
$dbh = null;
echo '重连了'.PHP_EOL;
$dbh = $_connect();
$_execute($dbh, $sql);
}
sleep(6);
} while ($i < 10);

PDO::ATTR_ERRMODE

PDO 的错误报告方式有 PDO::ERRMODE_SILENT(静默)、PDO::ERRMODE_WARNING(引发 E_WARNING)、PDO::ERRMODE_EXCEPTION(抛出 Exception 异常)。
所以,需设置错误报告方式为 PDO::ERRMODE_EXCEPTION,否则默认抛出 WARNING。

PDO::ATTR_EMULATE_PREPARESprepare

细心的你可能会发现:gone away 异常是在 $sth->execute 处抛出的,为什么不是 $sth->prepare 呢?
PDO 默认启用预处理语句的模拟,即 PDO::ATTR_EMULATE_PREPAREStrue
https://www.php.net/manual/zh/pdo.setattribute.php

PDO::ATTR_EMULATE_PREPARES 启用或禁用预处理语句的模拟。有些驱动不支持或有限度地支持本地预处理。使用此设置强制PDO总是模拟预处理语句(如果为 true),或试着使用本地预处理语句(如果为 false)。如果驱动不能成功预处理当前查询,它将总是回到模拟预处理语句上。

而在此情况下,prepare 语句不会和数据库服务器交互。
https://www.php.net/manual/zh/pdo.prepare.php

模拟模式下的 prepare 语句不会和数据库服务器交互,所以 PDO::prepare() 不会检查语句。

所以,重连后重新执行预处理方式的SQL语句时,需要重新运行 prepare 来返回新的 PDOStatement 对象。

PDO::ATTR_PERSISTENT

当然,可以通过长连接来实现不超时断连。
PDO 提供了 PDO::ATTR_PERSISTENT 来设置建立的连接是否为长连接。
备注:PHP中实现的数据库长连接,实际上是通过复用与数据库建连的子进程来实现的,如果某个请求占用了子进程,则新的请求会触发创建新的子进程来与数据库建连。(注意区分传统意义上通过线程实现的数据库连接池)
更多了解可以参考官方文档:https://www.php.net/manual/zh/features.persistent-connections.php

这种方式虽然简洁,但没那么健壮,更好的方式还是捕获 gone away 异常并重新建连。

Phalcon 框架下实现重连

Phalcon 的数据库操作有很多种方式:原生模型PHQL
所以要实现重连,我们只能在 Db Adapter 上实现。
通过继承 Phalcon\Db\Adapter\Pdo\Mysql 来扩展自己的 MySQL 逻辑:

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
use Phalcon\Db\Adapter\Pdo\Mysql as BaseMysql;

class Mysql extends BaseMysql
{
/**
* MySQL server has gone away
* https://dev.mysql.com/doc/mysql-errors/5.7/en/client-error-reference.html#error_cr_server_gone_error
*/
private const CR_SERVER_GONE_ERROR = 2006;

/**
* Lost connection to MySQL server during query
* https://dev.mysql.com/doc/mysql-errors/5.7/en/client-error-reference.html#error_cr_server_lost
*/
private const CR_SERVER_LOST = 2013;

public function _callExec($fun, $args = [])
{
for ($i = 0; $i < 2; $i ++) {// 控制只重试一次
try {
return call_user_func_array($fun, $args);
} catch (\PDOException $e) {
$error = $e->errorInfo;
if (isset($error[1]) && in_array($error[1], [self::CR_SERVER_LOST, self::CR_SERVER_GONE_ERROR], true)) {
$this->close();
if ($this->connect()) {// 建立新的连接
// 得用新的 PDOStatement 对象来执行预处理SQL
if ($fun[1] === 'executePrepared') {
$args[0] = $this->prepare($args[0]->queryString);
}
continue;
}
}
throw $e;
}
}
}

public function prepare($sqlStatement)
{
return $this->_callExec(['parent', 'prepare'], [$sqlStatement]);
}

public function executePrepared(\PDOStatement $statement, array $placeholders, $dataTypes)
{
return $this->_callExec(['parent', 'executePrepared'], [$statement, $placeholders, $dataTypes]);
}

public function query($sqlStatement, $bindParams = null, $bindTypes = null)
{
return $this->_callExec(['parent', 'query'], [$sqlStatement, $bindParams, $bindTypes]);
}

public function execute($sqlStatement, $bindParams = null, $bindTypes = null)
{
return $this->_callExec(['parent', 'execute'], [$sqlStatement, $bindParams, $bindTypes]);
}
}

关于 PDO 中的 errorInfo

Phalcon\Db\Adapter\Pdo\AbstractPdo 中的 getErrorInfo 实际上是 PDO::errorInfo()
https://github.com/phalcon/cphalcon/blob/master/phalcon/Db/Adapter/Pdo/AbstractPdo.zep#L593

1
2
3
4
public function getErrorInfo()
{
return this->pdo->errorInfo();
}

而官方手册上有这么一段说明:
https://www.php.net/manual/zh/pdo.errorinfo.php

PDO::errorInfo() only retrieves error information for operations performed directly on the database handle. If you create a PDOStatement object through PDO::prepare() or PDO::query() and invoke an error on the statement handle, PDO::errorInfo() will not reflect the error from the statement handle. You must call PDOStatement::errorInfo() to return the error information for an operation performed on a particular statement handle.

也就是说,PDO::errorInfo() 只获取直接在数据库句柄上执行操作的错误信息。
如果错误是发生在通过 PDO::prepare()PDO::query() 创建的 PDOStatement 对象句柄上,则必须用 PDOStatement::errorInfo() 来获取。
而我们很难在封装的 Phalcon 里获取 PDOStatement 对象,怎么办?
https://www.php.net/manual/zh/class.pdoexception.php#pdoexception.props.errorinfo
PDOException 的 errorInfo 包含了 PDO::errorInfo()PDOStatement::errorInfo() 的情况,Nice!

Phalcon\Db\Adapter\Pdo\AbstractPdo 中的 executePrepared

Phalcon\Db\Adapter\Pdo\AbstractPdoquery 方法调用了自身的 executePrepared 方法
https://github.com/phalcon/cphalcon/blob/master/phalcon/Db/Adapter/Pdo/AbstractPdo.zep#L719
executePrepared 方法传入的 statement 是用 pdo->prepare(sqlStatement) 创建的
https://github.com/phalcon/cphalcon/blob/master/phalcon/Db/Adapter/Pdo/AbstractPdo.zep#L754
根据之前的梳理,我们知道,异常是在 statement->execute() 上抛出的,但重新执行 execute 必须创建新的 PDOStatement 对象。

1
2
3
4
if ($fun[1] === 'executePrepared') {
// PDOStatement 的 queryString 属性记录了所用的查询字符串,即 SQL 语句
$args[0] = $this->prepare($args[0]->queryString);
}

至此,问题搞定,写段代码来测试一下吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$i = 0;
do {
$i ++;
$message = '第 '.$i.' 次:';
try {
$model = UserModel::findFirst();
if ($model) {
$user = $model->toArray();
$message .= $user['name'].PHP_EOL;
}
} catch (\Exception $e) {
$message .= $e->getMessage();
}
echo $message;
$sleepTime = $i % 2 ? 1 : 6;// 分别试试正常和超时的情况
sleep($sleepTime);
} while ($i < 10);

几个问题

为什么 $dbh = null 就可以关闭 PDO 建立的 MySQL 连接?

https://www.php.net/manual/zh/pdo.connections.php

连接在 PDO 对象的生存周期中保持活动。
要想关闭连接,需要销毁对象以确保所有剩余到它的引用都被删除,可以赋一个 null 值给对象变量。
如果不明确地这么做,PHP 在脚本结束时会自动关闭连接。

参考资料

完全理解 PDO ATTR_PERSISTENT
Phalcon 中文文档
Phalcon 源码