PHP 常驻 Worker 中 MySQL 连接中断问题报告
一、背景
项目里新增了一个异步任务链路,技术模型很简单:
bash
Web 接口投递任务 (Kafka) ──► PHP CLI 常驻进程 (Worker)
├─ 1. 调外部大模型 HTTP 接口(耗时 1~5 分钟)
└─ 2. 拿到结果后 UPDATE 写回 MySQL
和普通的 Web 请求(毫秒级结束、随用随销)不同,这个 Worker 有两个特点:
- 进程常驻 :通过 CLI 启动,在
while (true)循环里持续消费 Kafka 消息。 - 单次外部 I/O 极慢:调外部 AI 接口处理长文本,超时时间设到了 300 秒(5 分钟)。
上线测试后发现:AI 接口明明已经跑成功了,最后一步写 MySQL 却经常报错失败。
二、问题现象
Worker 刚启动时一切正常。但只要间隔一段时间没任务,或者单次 AI 请求耗时较长,写库时就会抛异常:
bash
PDOException: SQLSTATE[HY000]: General error: 2006 MySQL server has gone away
这带来两个后果:
- 单次任务白跑:等了几分钟拿到的 AI 结果写不进库,任务直接标为失败。
- 后续任务全挂 :只要报过一次
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();
}
}
}
为什么这样能通:
- 第一次
update()抛PDOException时,PDO 实例内部的errorInfo已经被写成了2006 : MySQL server has gone away。 - 外层
catch住异常后调用Db::checkConnection(),刚好读到这个错误码,触发$val->reConnect()重新new PDO(...)替换掉坏连接。 - 紧接着第二次
update()用新连接重试,写库成功,且后续任务也能继续使用这个恢复后的连接。
五、复盘与彻底根治建议
这次在业务层加 try -> checkConnection() -> retry 解决了当前 Worker 的写库问题,但从架构视角还需要注意以下几点:
-
长耗时外部调用前后的数据库操作都要防断连
在耗时几分钟的 HTTP 请求之后,第一次 碰数据库的任何操作(哪怕只是个前置
SELECT查询)都会撞上断连异常。 如果前置查询自己catch了异常但没调Db::checkConnection(),那个查询就会被静默跳过。 -
框架层应适配 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; }⇄
PHPprivate 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; }


