PDO 持久连接陷阱

解读

国内一线/二线互联网公司的 PHP 岗位面试里,数据库连接池与长连接几乎是必考点。PDO 的 PDO::ATTR_PERSISTENT 看似“一键加速”,却常被候选人误当成“性能银弹”。面试官真正想听的是:你是否理解 PHP-FPM 进程模型下“持久连接”的生命周期、何时会泄漏、何时会阻塞、何时会被 MySQL 拒绝,以及怎样在 Laravel/ThinkPHP/Yii 等主流框架里安全开启。回答不出“连接脏状态”“MySQL max_user_connections”“FPM 进程数配比”这三点,基本会被判定为“只写过业务代码,没扛过高并发”。

知识点

  1. PHP-FPM 进程模型:一个 worker 进程生命周期内复用一条 TCP 连接,请求结束不 close,但 MySQL 端连接仍在。
  2. 连接脏状态:上一个请求若显式设置了 SET NAMES utf8mb4SET sql_modeSET @user_var 或执行了事务未回滚,下一个请求会“继承”该上下文,导致字符集错乱、SQL 失败或脏读。
  3. 文件描述符耗尽:FPM worker 数 * 库实例数 若大于系统 ulimit -n,会触发 “Too many open files”,直接 502。
  4. MySQL 端上限:单个账号的 max_user_connections 或实例的 max_connections 被占满后,新请求报 “User xxx has more than max_user_connections active connections”,前端表现为 500 或空页面。
  5. 死连接与 wait_timeout:网络抖动导致 MySQL 已关闭,但 PDO 端仍以为可用,首次执行 SQL 时报 “MySQL server has gone away”,若未重试则直接抛异常。
  6. 事务与锁继承:持久连接里忘记 rollback() 会导致下一个请求拿到“已开启事务”的连接,行锁/表锁持续占用,造成线上死锁。
  7. PHP 7+ 的修复:官方在持久连接上增加了“自动重置字符集”与“ROLLBACK 未提交事务”机制,但仍不重置用户变量、临时表、PREPARE 语句。
  8. 框架封装差异:Laravel 默认关闭持久连接;ThinkPHP 5/6 在 database.php 里把 params 下的 PDO::ATTR_PERSISTENT 暴露给开发者,误开比例最高。
  9. 监控与排障:通过 show processlist 观察 Sleep 状态、通过 lsof -p $fpm_pid 看 TCP 连接数、通过 strace -e trace=network 抓是否复用 fd。
  10. 替代方案:使用 Swoole/WorkerMan 连接池、阿里云 RDS 读写分离代理、或者 Envoy/ProxySQL 做真正的连接复用,而非依赖 PDO 持久连接。

答案

“持久连接并不是连接池,它只是把 TCP 连接挂在 PHP-FPM 进程上复用,带来的陷阱主要有三类:
第一,状态污染。前一个请求如果设置了字符集、用户变量或者忘了回滚事务,下一个请求会继承,出现乱码或死锁。解决方法是:开启 PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4" 并在代码里总是 try {...} catch {...} finally { $pdo->rollBack(); },保证每次归还连接时状态干净。
第二,连接数反噬。FPM worker 数一旦飙到 200,再连两个 MySQL 实例,每个实例就可能有 400 个持久连接,很容易打满 MySQL 的 max_user_connections。国内大厂的做法是:让 FPM 池子不超过 CPU*2,线上实例开启 max_connections=2000 并配合连接池代理,而不是盲开持久连接。
第三,脏连接导致 ‘MySQL server has gone away’。网络抖动或 DB 主从切换后,PDO 以为连接还在,首次执行才发现失效。我的处理是:封装一个 retryGoneAway() 方法,捕获 2006/2013 错误码后重试一次,重试失败再抛异常,保证用户无感。
总结:在 PHP-FPM 模式下,我只有在后台低并发、短 SQL、无事务的只读场景才开启持久连接,并且通过 INIT_COMMANDrollback() 保证状态干净;线上高并发业务统一走 Swoole 连接池或 RDS 代理,彻底规避 PDO 持久连接的陷阱。”

拓展思考

  1. 如果公司已经把持久连接误开到线上,如何灰度关闭?
    答:先调低 pm.max_requests,让 FPM 进程快速重启回收连接;再分批注释配置项,观察 show processlistSleep 数量是否下降,同时对比 QPS 与响应时间,确保无抖动。
  2. 在 K8s 场景下,Pod 漂移会导致持久连接瞬间失效,怎样保证业务无损?
    答:给镜像里增加 pdo_mysql.default_socket=/var/run/mysqld/mysqld.sock 走 Unix domain socket,并启用 proxy_protocol,让 Sidecar 容器在 Pod 重建时自动重连,上层业务仍复用同一个 DSN。
  3. 国内金融级项目要求事务强一致,持久连接是否一无是处?
    答:可以把持久连接限定在只读从库,且关闭自动提交,通过 SET TRANSACTION READ ONLY 保证无写操作;主库仍用短连接或连接池,兼顾性能与安全。