【Bug】协程数据库连接池在 Crontab / 队列长驻进程中不会归还连接

lax1024

【Bug】协程数据库连接池在 Crontab / 队列长驻进程中不会归还连接

反馈对象:webman / webman-database / workerman
建议提交:GitHub walkor/webman 或 webman-php/database,社区 https://wenda.workerman.net


标题(Issue Title)

【Bug】协程数据库连接池在 Crontab / 队列长驻进程中不会归还连接,导致心跳失效并出现 PDO errno=110


环境

项 版本
PHP 8.2.x
事件驱动 Swoole(enable_coroutine + hook)
workerman/webman-framework v2.2.4
webman/database v2.1.10
workerman/coroutine v1.1.6
workerman/crontab v1.0.7
illuminate/database v11.51.0

问题描述

使用 webman/database 连接池时:

  • HTTP 请求结束会调用 Context::destroy(),连接能 pool->put(),行为正常。
  • Workerman\Crontab\Crontab 回调、Redis Queue / Streams 等长驻消费者执行完业务后,不会调用 Context::destroy()。

因此 Db 连接被借出后一直占着 Context,出现:

  1. 连接无法归还到池 Channel
  2. 池定时心跳(Pool::checkConnections)只检查 Channel 内空闲连接,借出连接吃不到心跳
  3. 空闲一段时间后(NAT/LB/云防火墙静默掐连接),再次查库报错,例如:
PDO::prepare(): Send of 714 bytes failed with errno=110 Connection timed out

或同类半开连接错误。


根因分析(源码)

1. 归还绑在 Context 销毁上

webman/database DatabaseManager::connection() 逻辑等价于:

$connection = static::$pools[$name]->get();
Context::set($key, $connection);
Context::onDestroy(function () use ($connection, $name) {
    $connection && static::$pools[$name]->put($connection);
});

只有 Context::destroy() 时才会 put 回池。

2. 框架只在 HTTP 响应路径 destroy

workerman/webman-framework 的 App::send() 中有:

Context::destroy();

Crontab / 队列消费者没有等价调用。

3. 心跳覆盖不到已借出连接

Workerman\Coroutine\Pool::checkConnections() 只对 Channel 里的连接做 idle / heartbeat,正在被 Context 持有的连接不会被探测。

生命周期对比:

HTTP:     借连接 → 查库 → 响应 → Context::destroy → put 回池 → 心跳可覆盖
Crontab:  借连接 → 查库 → 回调结束(无 destroy)→ 连接一直占着 → 心跳管不到
队列:     借连接 → Job 消费 → 结束(无 destroy)→ 同上

4.(附带)Illuminate 断线识别不全

文案:

PDO::prepare(): Send of N bytes failed with errno=110 Connection timed out

不在 DetectsLostConnections(Laravel 12+ 为 LostConnectionDetector)列表中。
官方列表有 SQLSTATE[HY000] [2002] Connection timed out、Error while sending 等,但没有裸的 errno=110 / Send of。
因此不会自动 reconnect 重试,错误直接抛给业务。
(即使升级到 Laravel master,截至反馈时仍未包含上述文案。)


复现步骤

  1. 开启 Swoole 协程 + webman/database 连接池(配置 pool.heartbeat_interval 等)。
  2. 自定义 process 中注册 Crontab,回调内执行任意 Db:: / Model 查询。
  3. 不要在回调里调用 Webman\Context::destroy()。
  4. 等待超过中间网络空闲超时(常见数分钟~十几分钟;也可人为长时间不触发任务)。
  5. 再次触发同一 Crontab(或队列 Job)访问数据库。

期望: 连接已归还并由心跳保活,或断线后自动重连重试。

实际: 连接仍占在 Context 中无心跳,随后出现 errno=110 / Connection timed out;Illuminate 也不将其识别为 lost connection。

队列同理:subscribe($queue, [$consumer, 'consume']) 消费结束后无 destroy。


期望官方改进(建议)

任选或组合:

  1. 文档明确说明:非 HTTP 场景(Crontab、自定义 process、队列)必须在业务单元结束时调用 Context::destroy(),否则连接池无法归还。
  2. 框架侧兜底:
    • 为 Crontab 提供带自动 Context::destroy() 的封装;或
    • 在 workerman/crontab / webman 进程模型中,Timer 回调结束自动 destroy(需评估与嵌套协程兼容性)。
  3. 连接池增强:对「借出超过 N 秒」的连接做回收/强制归还,或对粘在 Context 上的连接做过期重建策略(可配置)。
  4. 与 Illuminate 协同:在文档或 webman/database 层补充对 errno=110、Send of 的断线识别(或提供可扩展的 detector)。

临时规避(业务侧,供参考)

  1. Crontab 使用包装类:回调 finally 中 Context::destroy()。
  2. 队列消费者在每条消息 finally 中 Context::destroy()。
  3. 自定义 MySqlConnection:扩展 lost-connection 文案 + 空闲超时主动 reconnect(防止半开连接卡死)。

这些能缓解,但属于业务补丁;希望官方在框架层补齐长驻进程的连接池生命周期。


相关代码位置

组件 路径 / 说明
借还连接 vendor/webman/database/src/DatabaseManager.php → connection() / Context::onDestroy
HTTP 销毁 Context vendor/workerman/webman-framework/src/App.php → send() 内 Context::destroy()
池心跳 vendor/workerman/coroutine/src/Pool.php → checkConnections()
Crontab vendor/workerman/crontab/src/Crontab.php → Timer 触发 callback,无 Context 处理
断线识别 vendor/illuminate/database/DetectsLostConnections.php(或 12+ 的 LostConnectionDetector)

补充说明

  • 换 think-orm 不能从根上解决:同样是协程连接池 + 长驻进程无自动归还。
  • 半开 TCP / NAT 空闲超时是通用网络问题;webman 的缺口在于非 HTTP 场景缺少与连接池配套的生命周期管理。

文档信息

项 内容
整理日期 2026-10-15
项目侧规避参考 app/common/crontab/SafeCrontab.php、app/common/db/PoolContext.php、app/common/db/ResilientMySqlConnection.php
89 1 0
1个回答

walkor 打赏

Crontab 由定时器触发,每次回调在独立协程里执行。回调返回后协程退出,Context 销毁,数据库连接自动放回连接池,再由池里的空闲心跳保活。

如果在一个协程里写 while 循环且不退出或者很久才退出,连接会一直留在这个协程的 Context 中。这条连接当时还属于该协程,其它协程不能同时使用,所以连接池的心跳碰不到它,swoole/swow等也不允许跨协程使用数据库连接,因为不安全。云厂商或中间网络会清掉长时间没有报文的 TCP,下次查询就会在发送时出现 errno=110。

Illuminate 有断线重连,但当前文案列表不包含 Send of N bytes failed with errno=110 Connection timed out,所以这种失败不会重连。事务中的语句也不会自动重试。

请避免让一个协程长时间占着连接。长循环要在每轮任务结束时调用 Context::destroy(),把连接还回池中,并让 pool.heartbeat_interval 小于云上的空闲超时。

严格来说这个不是webman或者crontab问题, 普通 PHP CLI 常驻脚本如果一直握着同一个 PDO,也会遇到同样的超时。

  • 暂无评论
🔝