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,原生协程安全,能少踩很多这类底层协议相关的坑。

相关推荐
cc5725026531 小时前
2027校招:大数据管理与应用转财务数字化,会计业务、SQL与BI学习路线
数据库·sql·学习
梦想不只是梦与想2 小时前
SQLAlchemy:异步、连接池 (四)
数据库·fastapi·sqlalchemy
j7~2 小时前
【仿 RabbitMQ 项目】发布订阅消息队列 (篇八):交换机数据管理模块,分层类设计、SQLite 回调原理与代码实现
服务器·网络·数据库·消息队列·交换机·交换机数据管理·sqlite回调原理
PHP实战开发录2 小时前
PHP上传文件为什么会被覆盖
数据库·mysql·php
SelectDB技术团队2 小时前
VARIANT 列接入网关 JSON 日志:建表、导入、子列查询与物化的完整步骤
大数据·数据库·人工智能·elasticsearch·搜索引擎·json·apache doris
涛思数据(TDengine)2 小时前
TDengine IDMP 2.0.2 发布:元素关系可视化、数据建模与联邦查询三大升级
大数据·数据库·人工智能·时序数据库·tdengine·工业互联网·工业ai
小米里的大麦2 小时前
17 MySQL 视图
数据库·mysql
ss2732 小时前
PG实战笔记 | 07 PostgreSQL常见编译项及其作用
数据库·笔记·postgresql
丨白色风车丨3 小时前
LangChain 1.0 核心用法速查:创建模型、创建 Agent、调用
数据库·langchain