最近重构内部的消息推送服务,把原来同步阻塞的逻辑改成 Swoole 协程版本,PHP 版本升级到 8.1。压测的时候发现一个很随机的问题:大部分请求都正常,每隔一段时间就会抛出 MySQL server has gone away,报错没有固定复现时机,本地开发环境几乎碰不到,只有测试环境持续压测才会冒出来。
一开始我先怀疑是 MySQL 侧的超时配置,wait_timeout 设置的是 8 小时,理论上短连接不会这么快断。服务里用的是长连接池,封装了一层 PDO,协程切换的时候复用连接。第一反应先去搜这个报错,网上大部分答案都是调大 MySQL 的超时参数,或者执行 ping 检测连接有效性,加在取连接的时候。
照着方案改了,从连接池拿连接之前执行 SELECT 1 探活。压测跑了一会儿,报错消失了,本来以为搞定,结果第二天测试又反馈复现了,报错信息一模一样。这个时候意识到不是单纯的空闲超时问题。
把日志打细一点,记录每次拿到的连接 ID、释放时间、探活返回结果。慢慢看出规律:报错基本都出现在协程切换后,同一个 PDO 连接,在上一个协程还没执行完 SQL,就被另一个协程取走复用了。
Swoole 的协程本身不是多进程,是用户态切换,原生 PDO 不是协程安全的。我之前写连接池的时候图省事,直接把同一个 PDO 对象放回池子里,没有做占用标记。协程 A 拿到连接,发送 SQL 之后还没拿到结果,调度切到协程 B,B 又拿到同一个 PDO 去执行新 SQL,MySQL 协议流直接乱掉,服务端断开连接,就抛 MySQL server has gone away。这个和连接超时完全是两回事。
贴一下当时有问题的简化代码:
class MysqlPool
{
private array $pool = [];
public function get(): PDO
{
if (!empty($this->pool)) {
return array_shift($this->pool);
}
return new PDO('mysql:host=127.0.0.1;dbname=test', 'root', 'xxx');
}
public function put(PDO $pdo)
{
$this->pool[] = $pdo;
}
}
这段代码在同步模式下没问题,放到协程环境就是定时炸弹。PDO 的底层句柄、缓冲区是共享的,协程没有隔离,并发复用直接协议错乱。
这里有两个选择,要么改用 Swoole 自带的协程 MySQL Client,要么给 PDO 连接池增加状态锁,连接被取出之后标记占用,归还之前不允许其他协程拿到。
考虑项目里很多老业务代码都是基于 PDO 写的,大量依赖 prepare、事务,迁移到 Swoole MySQL Client 改动量太大,最后选了加状态标记的方案。改造思路很简单,池子里不直接存 PDO 对象,存一个包装对象,里面带上是否占用的标记,配合协程通道做等待。
改造后的简易结构:
class PoolItem
{
public PDO $pdo;
public bool $used = false;
}
class MysqlPool
{
private SplQueue $queue;
private int $maxNum;
public function __construct(int $maxNum = 10)
{
$this->maxNum = $maxNum;
$this->queue = new SplQueue();
for ($i = 0; $i < $maxNum; $i++) {
$pdo = new PDO('mysql:host=127.0.0.1;dbname=test', 'root', 'xxx');
$item = new PoolItem();
$item->pdo = $pdo;
$item->used = false;
$this->queue->enqueue($item);
}
}
public function get(): PoolItem
{
while (true) {
$item = $this->queue->dequeue();
if (!$item->used) {
$item->used = true;
return $item;
}
$this->queue->enqueue($item);
co::sleep(0.001);
}
}
public function put(PoolItem $item)
{
$item->used = false;
$this->queue->enqueue($item);
}
}
改完之后压测,gone away 的报错频次明显下降,但还没有彻底根除。继续看日志,发现有部分连接空闲太久,MySQL 主动断开,池子里保存的已经是失效的句柄,标记虽然是空闲,但底层连接已经挂了。
所以探活逻辑还是要保留,只是时机要放对:取出连接、标记占用成功之后,先探活,探活失败直接销毁这个 PDO,新建连接返回,而不是只在空闲的时候 ping 一次 。探活不要随便写 SELECT 1,高并发下频繁 ping 会给数据库增加不必要压力,可以加一个过期时间,距离上次使用超过一段时间才检测。
还有一个容易忽略的点:PDO 的超时属性。ATTR_TIMEOUT 只是连接超时,不是 SQL 执行超时。协程环境下如果 SQL 卡住,会一直占用连接。我额外给每个取出的连接增加执行超时控制,SQL 执行超时就直接销毁句柄,不归还到池子里。
踩坑的时候还看到有些同事的写法,在协程里面静态保存 PDO 实例,这种写法问题更大,相当于全局单例连接,并发协程全部争抢同一个句柄,基本必现连接错乱。
顺便区分两个容易混淆的坑:
- 单纯
MySQL server has gone away:空闲超时、数据库重启、连接被 kill; - 协程下随机出现的
MySQL server has gone away,压测才复现:大概率是连接并发复用,PDO 非协程安全。
排查顺序后面固定下来了:
- 先看报错是不是只在协程并发压测下出现;
- 检查连接池有没有占用状态控制,是否存在同一个 PDO 并发使用;
- 取出连接时按需做探活,失效连接直接丢弃重建;
- 核对 MySQL wait_timeout,和业务最大空闲时间匹配;
- 检查 SQL 有没有长耗时、锁等待,长时间占用连接不释放。
最后上线的版本,除了状态标记、按需探活,还加了定时巡检,后台协程遍历池子,把长时间空闲的连接主动关闭重建,避免堆一堆死连接。连续跑了两天压测,这个报错再也没有出现。
之前写同步 PHP 写久了,很容易把同步那套连接池思路直接搬到 Swoole 协程里。PDO 本身不感知协程调度,很多坑不是代码语法错了,是没有理解协程切换带来的资源竞争。如果新项目没有历史包袱,优先考虑 Swoole Coroutine MySQL,原生协程安全,能少踩很多这类底层协议相关的坑。