连接泄漏检测与自动回收

解读

国内高并发业务(电商秒杀、直播带货、金融支付)普遍采用 PHP-FPM + 常驻进程(Swoole、WorkerMan)混合架构。
“连接泄漏”指 MySQL/Redis/MQ 连接在使用后未正确 close 或 unset,导致 fd 耗尽、DB 报 “Too many connections” 或 Redis 报 “OOM command not allowed”。
面试官想确认:

  1. 能否在代码层面快速定位哪一段逻辑泄漏;
  2. 能否在不重启服务的前提下自动回收;
  3. 是否了解 PHP-FPM 与常驻进程两种模型的差异与治理方案;
  4. 能否给出可落地的监控告警+代码补丁+运维脚本,而不是空喊“try/catch/finally”。

知识点

  1. PHP 连接资源本质:zend_resource,refcount=0 时由内核关闭,但循环引用或长连接会阻止释放。
  2. 两个主流运行时:
    a) PHP-FPM:请求结束即释放所有资源,泄漏只存在于请求内部,但 p.connect_timeout 设置过大仍可能占满 DB;
    b) Swoole/WorkerMan:连接池跨请求复用,单进程内泄漏会累积。
  3. 检测手段:
    • 进程级:lsof -p $workerPid | grep ESTABLISHED | wc -l;
    • SQL 级:performance_schema.accounts 或 information_schema.processlist 按 user+program_name 聚合;
    • 扩展级:Swoole Tracker、OneAPM、Tideways 持续采样;
    • 代码级:在连接池封装层埋点,记录 borrow/return 时差,超 3s 未还即报警。
  4. 自动回收:
    • 弱引用(WeakMap PHP8.0+)+ 析构函数 __destruct 强制 close;
    • 连接池设置 max_idle_time/evict,定时扫描 idle>8s 的连接;
    • Swoole 4.8+ 开启 heartbeat_check_interval=30s,TCP 探活失败自动 close;
    • 守护脚本:每 60s 扫描 processlist,对 Sleep>300s 且 program_name=php_api 的连接执行 KILL;
    • PDO::MYSQL_ATTR_INIT_COMMAND 设置 SET wait_timeout=60,让 MySQL 主动踢掉空闲连接。
  5. 防御式编码:
    • 统一使用连接池组件(Hyperf/DB、Swoole\Coroutine\MySQL),禁止裸 new PDO;
    • 在 defer 函数或 Swoole\Coroutine\defer() 中释放;
    • 静态代码扫描:phpstan+自定义规则,检测 new PDO 后是否缺失 unset/close;
    • 单元测试:Mock 连接池,断言 borrow 数量 = return 数量。

答案

“我们在 2023 年双 11 前压测发现,优惠券服务 Swoole 进程 MySQL 连接数 10 分钟从 200 涨到 4000,最终触发 RDS 连接打满。排查步骤如下:

  1. 进程级定位:lsof 发现 7 号 worker 持有 1100 条 ESTABLISHED,基本确认单进程泄漏;
  2. 代码级定位:在连接池基类增加数组 tracemap,key 为 spl_object_id($conn),value 为 [borrow_time, coroutine_id, backtrace],发现有一条协程在 Redis 阻塞 5s 后未回包,导致 MySQL 连接未还;
  3. 临时止血:运维脚本每 30s 扫描 information_schema.processlist,对 Sleep>120s 的连接批量 KILL,把连接数压回 300;
  4. 永久修复:
    a) 连接池引入 max_idle_time=60s + 弱引用,__destruct 中自动 close;
    b) 增加 Swoole heartbeat_check_interval=30s;
    c) 上线灰度后,连接数稳定在 200±10%,QPS 提升 18%,CPU 下降 5%。
  5. 监控告警:Prometheus 暴露 mysql_connection_open_total 与 mysql_connection_idle_total,Grafana 面板 idle>60% 且持续增长 5min 即飞书告警,并自动触发 kill_idle_connections 脚本。
    通过上述组合拳,实现了泄漏分钟级发现、秒级自愈,无需凌晨重启,符合国内 4 个 9 的 SLA 要求。”

拓展思考

  1. 如果业务使用读写分离,主从延迟 2s,连接池回收心跳探活刚好把“延迟但正常”的连接误判为失效,如何兼顾探活与延迟容忍?
  2. PHP-FPM 模式下,pm.max_requests=500 会周期性重启 worker,看似天然防泄漏,但重启瞬间 QPS 抖动,如何做到“热重启”且连接不断?
  3. 在 K8s 场景,Pod 水平扩容后连接数线性增长,Sidecar 模式的 Istio 默认 idleTimeout=1h,可能与池化参数冲突,如何统一治理?