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

0 阅读3分钟

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

一、背景

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

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["Web 服务层"]
        A["Web API"] 
    end

    subgraph MQ["消息队列层"]
        B[("Kafka")]
    end

    subgraph WORKER["PHP CLI Worker(常驻进程)"]
        C["WorkerController"]
        D["KafkaConsumer"]
        E["接收任务"]
        F["调用 AI 接口"]
        G["获取 AI 处理结果"]
        H["ClerkAiReviewService"]
        I["ClerkService::update"]
        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 请求耗时较长,写库时就会抛异常:

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 连接其实已经死了。

mermaid-diagram (1).png

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

看了一眼 Worker 的消费主循环(WorkerController.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:

// 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。

于是链路变成了这样:

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

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

mermaid-diagram (2).png

四、解决方案

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

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() 用新连接重试,写库成功,且后续任务也能继续使用这个恢复后的连接。

mermaid-diagram (3).png

五、复盘与彻底根治建议

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

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

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

         /**
      * 健壮地执行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;
     }
    

    ⇄

    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;
    }
    

mermaid-diagram (4).png