返回博客

PHP 长驻 Worker + PDO 持久连接 = 云数据库空闲后批量 500

某天早上,一个后台管理接的 C 端小程序,开发环境放了一个多小时没人用。下午重新打开,连续十几个请求全部 500,大概一分半后自动恢复正常。最诡异的是:翻遍 ThinkPHP 的 runtime/log/ 目录,那段时间的日志完全是空的——没有 Fatal,没有 Stack trace,就好像这些请求从未被处理过。

现象

  • PHP CLI server(php think run 启动的多 worker 模式)静置 5 分钟以上后被唤醒
  • 唤醒后的 1~2 分钟内,所有请求批量返回 HTTP 500
  • CLI server 的 stderr 日志里可以看到密集的 [500]: POST /api/xxx
  • ThinkPHP runtime/log/*.log 对应时间窗口:,无任何异常记录
  • 1~2 分钟后,完全自愈,请求全部恢复 200

根因

这是三个独立机制叠加后的必然结果:

第一层:长驻 Worker 持久化了 PDO 句柄

PHP-FPM 的 Worker 在处理完请求后会经历自然的生命周期(pm.max_requests 循环/空闲回收),持久连接在其中会被 GC 掉。但 php think run、Workerman、Swoole 这类长驻进程会把同一个 PDO 句柄跨请求保持数小时:

// config/database.php —— 触发问题的配置
'params' => [
    \PDO::ATTR_PERSISTENT => true,
    \PDO::ATTR_TIMEOUT    => 5,
],

第二层:云托管数据库 SLB 层会主动切断空闲 TCP

阿里云 RDS、AWS RDS、GCP Cloud SQL 的数据库实例前都有一层负载均衡。这层 SLB/CLB 对长时间没有流量的 TCP 连接会静默关闭(典型阈值 5~10 分钟)。MySQL server 本身的 wait_timeout 默认长达 8 小时,和这个问题无关——真正切断连接的是更上层的 SLB。

第三层:break_reconnect: true 并不可靠

PDO 持久连接池按 DSN+用户名+密码 做 key 缓存句柄。SLB 切断后,PHP 侧的 PDO 对象状态看起来一切正常,直到下一次真正执行 Query 才会爆出 MySQL server has gone away。此时 ThinkPHP 的 break_reconnect 逻辑尝试重连,但拿到的仍是持久池里同一个已死的 slot,重试命中成功率不稳定。更糟的是,某些版本只对 SELECT 重试,INSERT/UPDATE 不保证;而且即便重连成功,外层的 serverError() 可能已经短路返回了 500。

"日志为空"之谜

ThinkPHP 的 serverError() 抛出的是 HttpResponseException,这个异常在框架顶层被捕获后直接转成 JSON 响应,不写日志(框架假设 serverError() 是"主动调用的已知错误")。如果 catch-all 把 PDO 异常吞掉后调了 serverError(),原始错误就彻底消失了。

坑:PDO 持久连接 + 云 SLB 空闲超时 = 静默批量 500

长驻 Worker 持有的 PDO 持久句柄在云 SLB 侧被切断后,PHP 端感知不到,直到第一个 Query 才崩。break_reconnect: true 不能可靠救场,框架日志还是空的,极难排查。

正解

核心修法:在长驻 Worker 环境里,通过环境变量关掉 PDO 持久连接。

config/database.php 改为 env 驱动:

'params' => env('database.persistent', true)
    ? [\PDO::ATTR_PERSISTENT => true, \PDO::ATTR_TIMEOUT => 5]
    : [\PDO::ATTR_TIMEOUT => 5],

在使用长驻 Worker 的环境(开发用 php think run、生产用 Workerman/Swoole)的 .env 里追加:

[DATABASE]
PERSISTENT = false

生产若是标准 PHP-FPM,不设这个 env 值即可,默认走 true,保留连接复用的性能收益。

修改后用 PHP 直接验证配置生效(不需要重启):

php -r "
require 'vendor/autoload.php';
\$app = (new \think\App())->initialize();
var_export(\$app->config->get('database.connections.mysql.params'));
"
# 期望输出:array ( 2 => 5, )  ← 只剩 ATTR_TIMEOUT=2,无 ATTR_PERSISTENT=12

修复需要重启 Worker 才生效。已启动的 Worker 仍持有旧的持久句柄;如果当前流量活跃,可以选合适时机重启;如果 Worker 正好是空闲态,建议立即重启,避免下一次冷启动再触发一轮。

正解:env 驱动 ATTR_PERSISTENT,长驻 Worker 关掉它

ATTR_PERSISTENT 提到环境变量控制,长驻 Worker 环境设 PERSISTENT = false,FPM 环境不设(默认 true)。不要用 SELECT 1 ping——每个请求多一次 RTT,还是有竞争窗口,成本不值。

一句话外卖

云托管数据库 + 长驻 Worker + PDO 持久连接,三者叠加必然在空闲唤醒时批量 500;break_reconnect 无效,正解是在长驻模式下彻底关掉持久连接。