PHP 8.1 下 Swoole 协程 PDO 偶尔出现 MySQL 连接丢失问题排查

最近重构内部的消息推送服务,把原来同步阻塞的逻辑改成 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 实例,这种写法问题更大,相当于全局单例连接,并发协程全部争抢同一个句柄,基本必现连接错乱。

顺便区分两个容易混淆的坑:

  1. 单纯 MySQL server has gone away:空闲超时、数据库重启、连接被 kill;
  2. 协程下随机出现的 MySQL server has gone away,压测才复现:大概率是连接并发复用,PDO 非协程安全。

排查顺序后面固定下来了:

  1. 先看报错是不是只在协程并发压测下出现;
  2. 检查连接池有没有占用状态控制,是否存在同一个 PDO 并发使用;
  3. 取出连接时按需做探活,失效连接直接丢弃重建;
  4. 核对 MySQL wait_timeout,和业务最大空闲时间匹配;
  5. 检查 SQL 有没有长耗时、锁等待,长时间占用连接不释放。

最后上线的版本,除了状态标记、按需探活,还加了定时巡检,后台协程遍历池子,把长时间空闲的连接主动关闭重建,避免堆一堆死连接。连续跑了两天压测,这个报错再也没有出现。

之前写同步 PHP 写久了,很容易把同步那套连接池思路直接搬到 Swoole 协程里。PDO 本身不感知协程调度,很多坑不是代码语法错了,是没有理解协程切换带来的资源竞争。如果新项目没有历史包袱,优先考虑 Swoole Coroutine MySQL,原生协程安全,能少踩很多这类底层协议相关的坑。

相关推荐
你看我名字17 小时前
Redis 高可用、持久化与故障切换:从缓存组件到云上基础设施
数据库·redis·缓存
风翼靓崽17 小时前
centos7以mysql为例自建yum源
数据库·mysql·adb
码农阿豪18 小时前
MongoDB迁移到金仓数据库:商品属性继续灵活存,订单报表交给 SQL
数据库·sql·mongodb
秋饼18 小时前
Redis 全面解析:从数据结构到主从/哨兵/集群、分布式锁与面试高频考点,代码到原理一次讲透
数据库·redis·分布式·面试·后端开发
秋名RG18 小时前
一条 SQL 语句在 MySQL 中的执行过程详解
数据库·sql·mysql
Canmag 兴隆磁性18 小时前
钕铁硼磁片取向方向检测自动化方案:从人工目检到流水线在线分选的工程实践
运维·数据库·自动化·工业自动化·钕铁硼·自动化检测·磁材料
yangmu320318 小时前
安全审批与日志审计:从“谁批准”到“谁执行”的全链路闭环
数据库
R²AIN SUITE18 小时前
多云或私有云环境怎么选项目管理工具?RainSuite等5种产品的8项部署验证
服务器·网络·数据库
隔振降噪研究员18 小时前
裁断机一响,楼板为什么会跟着抖?
大数据·linux·数据库·人工智能