PHP 常驻 Worker 中 MySQL 连接中断问题报告

PHP 常驻 Worker 中 MySQL 连接中断问题报告

一、背景

项目里新增了一个异步任务链路,技术模型很简单:

bash 复制代码
Web 接口投递任务 (Kafka)  ──►  PHP CLI 常驻进程 (Worker)
                                  ├─ 1. 调外部大模型 HTTP 接口(耗时 1~5 分钟)
                                  └─ 2. 拿到结果后 UPDATE 写回 MySQL

和普通的 Web 请求(毫秒级结束、随用随销)不同,这个 Worker 有两个特点:

  1. 进程常驻 :通过 CLI 启动,在 while (true) 循环里持续消费 Kafka 消息。
  2. 单次外部 I/O 极慢:调外部 AI 接口处理长文本,超时时间设到了 300 秒(5 分钟)。

上线测试后发现:AI 接口明明已经跑成功了,最后一步写 MySQL 却经常报错失败。

flowchart LR subgraph WEB[&#34;Web 服务层&#34;] A[&#34;Web API&#34;] end subgraph MQ[&#34;消息队列层&#34;] B[(&#34;Kafka&#34;)] end subgraph WORKER[&#34;PHP CLI Worker(常驻进程)&#34;] C[&#34;WorkerController&#34;] D[&#34;KafkaConsumer&#34;] E[&#34;接收任务&#34;] F[&#34;调用 AI 接口&#34;] G[&#34;获取 AI 处理结果&#34;] H[&#34;ClerkAiReviewService&#34;] I[&#34;ClerkService::update&#34;] J[(&#34;Db::$instance<br/>PDO 单例连接&#34;)] C --> D --> E --> F --> G --> H --> I J -. &#34;持有数据库连接&#34; .-> I end subgraph EXTERNAL[&#34;外部服务&#34;] K[&#34;AI 大模型 HTTP API&#34;] end subgraph DATABASE[&#34;数据存储层&#34;] L[(&#34;MySQL&#34;)] end A -->|&#34;投递异步任务&#34;| B B -->|&#34;消费消息&#34;| D F <-->|&#34;HTTP 请求 / 响应<br/>耗时 1~5 分钟&#34;| K I -->|&#34;UPDATE SQL&#34;| J J <-->|&#34;TCP / PDO&#34;| L style WORKER fill:#eaf2ff,stroke:#3974c6,stroke-width:2px style DATABASE fill:#eaf8ef,stroke:#39945c style EXTERNAL fill:#fff5e5,stroke:#d99b35

二、问题现象

Worker 刚启动时一切正常。但只要间隔一段时间没任务,或者单次 AI 请求耗时较长,写库时就会抛异常:

bash 复制代码
PDOException: SQLSTATE[HY000]: General error: 2006 MySQL server has gone away

这带来两个后果:

  1. 单次任务白跑:等了几分钟拿到的 AI 结果写不进库,任务直接标为失败。
  2. 后续任务全挂 :只要报过一次 2006,这个 Worker 进程后面消费的所有任务,写库全部报错,必须手动重启 Worker 才能恢复。

三、关键排查过程

1. 连接为什么会断?

MySQL 服务端和云数据库网关都有空闲连接回收机制(如 wait_timeout)。

在 Worker 进程里,数据库连接是保存在静态属性 Db::$instance 里的单例。一旦建连后:

  • 要么队列空闲,Worker 一直在等新消息;
  • 要么正在调外部 AI 接口,同步阻塞等待 HTTP 响应长达几分钟。

这期间 MySQL 连接没有任何数据交互,超过空闲阈值后被服务端主动断开。等 Worker 终于拿到 AI 结果去执行 UPDATE 时,底层的 TCP 连接其实已经死了。

2. 为什么断了一次,后面的任务全挂?

看了一眼 Worker 的消费主循环(WorkerController.php):

php 复制代码
while (true) {
    try {
        (new KafkaConsumer($topic, $config[$topic]))->execute();
    } catch (Throwable $e) {
        // 记录日志
    }
    sleep($restartDelay);
}

虽然 KafkaConsumer::execute() 内部有生命周期限制(跑满一定时间或条数会退出方法),但外层是同一个 PHP 进程的 while (true)。

PHP 进程不退出,静态单例 Db::$instance 就永远不会释放。 死掉的 PDO 连接一直留在内存里,后续每次任务拿到的都是同一个失效连接。

3. 最奇怪的点:框架明明有自动重连,为什么没生效?

查底层数据库封装 Db.php,发现框架其实早就写了断连重连逻辑 robustExecute:

php 复制代码
// QFrame\Lib\Mysql\Db.php
public function execute($sql)
{
    return $this->robustExecute(function () use ($sql) {
        $result = $this->pdo->exec($sql);
        if ($result === false) {
            $this->logErr($this->pdo->errorInfo(), $sql);
            return false;
        }
        return $result;
    });
}

private function robustExecute(callable $func)
{
    $result = $func();
    // 如果错误原因是断开连接(2006 / 2013)则重连并重试一次
    if ($result === false && $this->shouldReconnect()) {
        $this->reConnect();
        $result = $func();
    }
    return $result;
}

按理说,连接断了会自动走 $this->reConnect() 重连再执行一次,为什么现场直接抛异常崩出来了?

根因在 PHP 版本升级带来的 PDO 默认行为变化:

  • 这套框架代码是 PHP 7 时代写的。PHP 7 下 PDO 的默认错误模式是 PDO::ERRMODE_SILENT------SQL 执行失败不抛异常,只返回 false。所以 $result === false 能成立,自动重连正常工作。
  • 但当前项目运行在 PHP 8.1 ,而创建 PDO 时没有显式指定 ATTR_ERRMODE。从 PHP 8.0 开始,PDO 默认错误模式改成了 PDO::ERRMODE_EXCEPTION。

于是链路变成了这样:

bash 复制代码
$this->pdo->exec($sql) 发现连接已断 (2006)
  └── PHP 8.1 直接抛出 PDOException
        └── robustExecute() 里没有 try-catch,异常直接穿透出去!
              └── 下方的 if ($result === false && $this->shouldReconnect()) 根本没机会执行

框架自带的自动重连,被 PHP 8 的默认异常直接跳过了。

四、解决方案

为了不动公共底层框架、快速止血,修复方案在 Worker 业务层做了异常捕获 + 主动重连 + 二次重试 (见 ClerkAiReviewService.php):

php 复制代码
// 1. 首次尝试写库
try {
    $dbSaved = ClerkService::getIns()->update($id, [
        'ai_review_version' => $revisedContent,
    ]) !== false;
} catch (Throwable $e) {
    $dbSaved = false;
    error_log('database save exception: ' . $e->getMessage());
}

// 2. 写库失败时,主动检查连接、重连并重试一次
if (!$dbSaved) {
    try {
        Db::checkConnection();
    } catch (Throwable $e) {
        error_log('database reconnect exception: ' . $e->getMessage());
    }

    try {
        $dbSaved = ClerkService::getIns()->update($id, [
            'ai_review_version' => $revisedContent,
        ]) !== false;
    } catch (Throwable $e) {
        $dbSaved = false;
        error_log('database retry exception: ' . $e->getMessage());
    }
}

这里复用了框架 Db 类里现成的静态方法 Db::checkConnection():

php 复制代码
public static function checkConnection()
{
    foreach (self::$instance as $key => $val) {
        if (empty($val) || !($val instanceof Db)) {
            continue;
        }
        $errStr = $val->error();
        if ($errStr == '2006 : MySQL server has gone away') {
            $val->reConnect();
        }
    }
}

为什么这样能通:

  1. 第一次 update() 抛 PDOException 时,PDO 实例内部的 errorInfo 已经被写成了 2006 : MySQL server has gone away。
  2. 外层 catch 住异常后调用 Db::checkConnection(),刚好读到这个错误码,触发 $val->reConnect() 重新 new PDO(...) 替换掉坏连接。
  3. 紧接着第二次 update() 用新连接重试,写库成功,且后续任务也能继续使用这个恢复后的连接。

五、复盘与彻底根治建议

这次在业务层加 try -> checkConnection() -> retry 解决了当前 Worker 的写库问题,但从架构视角还需要注意以下几点:

  1. 长耗时外部调用前后的数据库操作都要防断连

    在耗时几分钟的 HTTP 请求之后,第一次 碰数据库的任何操作(哪怕只是个前置 SELECT 查询)都会撞上断连异常。 如果前置查询自己 catch 了异常但没调 Db::checkConnection(),那个查询就会被静默跳过。

  2. 框架层应适配 PHP 8 的异常模式(治本)

    业务层到处写重试终究是补丁。最干净的做法是在底层 Db::robustExecute() 中把 PDOException 接住,让框架原有的自动重连逻辑在 PHP 8 下重新生效:

    php 复制代码
         /**
      * 健壮地执行sql(带有重试机制)
      * @param callable $func
      * @return mixed
      */
     private function robustExecute(callable $func)
     {
         $result = $func();
         // 如果错误原因是断开连接则重试一次
         if ($result === false && $this->shouldReconnect()) {
             Logger::error('try to reconnect, ErrMsg: ' . $this->error());
             $this->reConnect();
             $result = $func();
         }
    
         return $result;
     }

    ⇄

    PHP 复制代码
    private function robustExecute(callable $func)
    {
       try {
           $result = $func();
       } catch (\PDOException $e) {
           $result = false;
           if (!$this->shouldReconnect()) {
               throw $e;
           }
       }
    
       if ($result === false && $this->shouldReconnect()) {
           $this->reConnect();
           $result = $func();
       }
       return $result;
    }
相关推荐
写码跑山的老艾1 小时前
优惠券为何能反复使用?一次算价与提单不一致的资损复盘
后端
RAG学徒loading1 小时前
我把 RAG 召回从 57.6% 做到 78.8%,然后发现 75% 的真实问题它答不了
后端
用户42569399996201 小时前
我的 HashMap 查找全废了,但 36 个测试全绿
后端
小番茄程序猿1 小时前
Redis 编码压测 59 轮:HSET 快 23 倍是假象,SMEMBERS 慢 30 倍是真的
redis·后端
Moment2 小时前
代码都能让 Agent 写了,我们为什么还要学前端?
前端·后端·面试
国科安芯2 小时前
人在回路中的总线:商业空间站环境控制与生命保障系统CANFD网络的可靠性与功能安全
网络·嵌入式硬件·安全·架构·php·抗辐射
AI入职记2 小时前
Spring AI 实测:不挂工具,模型真的会瞎编吗?
后端·ai编程
wei_shuo2 小时前
SQL Server数据迁移实战:内置故障诊断工具与根因分析实践
后端