数据库连接池配多大:三个误配与泄漏排查

数据库连接池是一个听起来很基础、但在小站里经常被配错的东西。它的存在意义是复用已经建立的 TCP 连接和认证状态,避免每个请求都重新握手、重新鉴权、重新分配后端资源。但配小了会在高峰排队,配大了会把数据库压垮,还有一种更隐蔽的情况是连接泄漏——池子慢慢被占满,最后所有请求都卡在「获取连接超时」,而数据库侧的监控看起来却一切正常。

这篇从「一个请求经过连接池会发生什么」讲起,然后是三个最常见的误配,最后落到怎么判断自己该配多大。

一、一次请求在连接池里的完整路径

以 PHP 的 PDO 长连接为例。开启 PDO::ATTR_PERSISTENT => true 之后,请求结束时连接不会被关闭,而是留在 FPM 的进程里,等下一次这个进程处理请求时继续用。这是最原始的一种「连接池」——池子的边界就是进程的边界,池子大小等于 FPM 的 pm.max_children。

很多人的第一反应是「那我把 persistent 打开,连接数就降下来了」。实际效果往往相反:因为每个 FPM worker 都会持有一条独立连接,而 worker 数量可能是 50,于是 MySQL 上就常驻 50 条连接,即使当前只有 3 个请求在处理。数据库的 max_connections 默认 151,看起来够,但一旦同时跑着两个 FPM 池(一个主站、一个后台),再加上 cron 脚本和手工 mysql 客户端,很容易超过上限,然后看到那个经典报错:

SQLSTATE[HY000] [1040] Too many connections

真正的连接池(比如 Java 的 HikariCP、Go 的 database/sql、Node 的 pg.Pool)是一个独立于进程的结构:固定数量的连接,请求来的时候借,用完归还。它有两个关键参数——最大连接数和最大等待时间。前者决定并发上限,后者决定「借不到时等多久再报错」。

二、误配一:把 max_connections 当成「越多越好」

MySQL 每条连接都要占内存。基础开销在 5.7/8.0 上是每线程几百 KB 到 1 MB 之间(取决于排序缓冲、join 缓冲、binlog cache 等会话级变量)。100 条连接不算什么,1000 条就可能吃掉几百 MB 甚至上 GB。更糟的是,连接数多并不等于吞吐高——单机 MySQL 的有效并发往往远低于这个数字,超过之后只会增加上下文切换和锁竞争。

一个经验判据:活跃连接数(Threads_running,不是 Threads_connected)持续超过核数的 2~4 倍,就说明瓶颈不在连接数上,再加只会更慢。查询方式:

SHOW GLOBAL STATUS WHERE Variable_name IN
  ('Threads_connected','Threads_running','Max_used_connections','Aborted_connects');

Max_used_connections 是历史峰值,这是个特别有用的数。如果它一直只有 15,而你把池子配到 100,那多出来的 85 条连接纯属白占资源,而且掩盖了真实的容量问题。

三、误配二:池子大小 ≥ 数据库上限

应用侧池子上限如果等于或接近数据库的 max_connections,就永远不会有「等一等」的缓冲。一旦突发流量把所有连接占满,应用会不停重试创建新连接,数据库侧看起来是新连接不断被拒绝,日志里刷 Aborted_connects,而实际上是应用侧没有做排队。

正确的关系应该留出余量,大致像这样:

数据库 max_connections (比如 200)
  - 运维保留(手工排查、备份、监控)      10
  - cron / 后台任务                       15
  - 主站连接池上限                        80
  - 另一个应用(论坛/商城)               40
  -------------------------------------------
  合计                                    145  → 还有 55 的突发余量

另一个容易忽略的点是「池子数量」。一台服务器上跑两三个应用,每个应用各配一个池子,各自的上限加起来才是真实压力。很多人只盯着单个应用的配置,忘了总量。

四、误配三:等待超时比查询超时还长

连接池的获取超时(例如 30 秒)如果比业务层的请求超时还长,就会出现「请求已经被用户放弃、但仍在池子前面排队」的资源浪费。合理的关系是:获取连接超时 < 单次查询超时 < HTTP 请求超时。三层依次放宽,任何一层超时都能干净地释放资源。

反过来的配置会制造雪崩:连接获取等 60 秒,HTTP 超时 30 秒,那么每个卡住的请求都会在用户侧已经断开之后,还在服务端占着一个位置,进一步加剧连接不足。

五、怎么判断该配多大

不用复杂的公式,三个观测就够:

第一,看峰值并发请求数。用访问日志算每秒请求数(QPS),再看每个请求的平均数据库耗时(比如 20ms),理论上需要的并发是 QPS × 耗时。100 QPS × 0.02s = 2 条连接就够。但实际上有长尾请求,所以取 3~5 倍余量,也就是 6~10 条。

第二,看历史峰值 Max_used_connections,取它和第一项的最大值。

第三,看获取连接的等待情况。多数池子都暴露了「等待次数」和「等待时长」指标,如果等待次数很少,说明配置是够的;如果每次高峰都有等待,才需要加。

举个实际数字:一个日 PV 两万左右的博客,MySQL 峰值 Threads_running 是 6,Max_used_connections 是 23(其中一半是 cron 和备份)。这种情况下把连接池配成 20 完全够用,配成 100 只是把「潜在问题」藏起来。

六、连接泄漏:池子被慢慢吃光

泄漏的表现很规律:服务刚启动时一切正常,跑几小时后开始变慢,一天后彻底卡死,重启又好了。日志里的报错是「获取连接超时」,但数据库的 SHOW PROCESSLIST 里看到的连接都很正常。

成因通常是代码路径里有分支没有归还连接——比如异常处理里漏了 finally,或者在循环里创建了连接但只在成功时释放。在 PHP 里,如果用持久连接,泄漏的后果不是「池子满」而是「FPM worker 各自持有连接不还」,数据库侧表现为连接数等于 worker 数一直不降。

排查方法:在池子层打开「泄漏检测」(多数实现支持「连接借出超过 N 秒未归还就打日志」),或者定时采样 /proc/PID/fd 看 socket 数量是否单调增长。一旦确认增长曲线,再回头看代码里的异常分支。

七、连接被中间设备悄悄掐断

还有一种故障和配置无关,但表现和配置错误几乎一样:连接池里的连接「看起来还在」,实际已经死了。成因是链路上有设备会清理空闲连接——云厂商的安全组会话表、负载均衡的空闲超时、或者数据库前面那台代理。它们通常在几分钟到几十分钟不等的时间后,把长时间没有流量的 TCP 会话从自己的表里删掉,而两端并不知道。

后果是:池子借出一条「僵尸连接」,应用往里面写数据,第一次可能成功(TCP 缓冲区收下了),第二次才收到 RST,于是报 MySQL server has gone away 或者 Lost connection during query。这种错误的特点是偶发、集中在「空闲一段时间后的第一个请求」,重启应用服务后就消失。

三个应对手段,按成本从低到高:第一,在池子里设置连接最大存活时间(比链路上最短的空闲超时更短,比如链路上是 350 秒,就设 300 秒),到点主动回收重建;第二,开启连接有效性检查(借出时 ping 一次,代价很小);第三,把 MySQL 的 wait_timeout 调到比链路空闲超时更短,让服务端主动先关,客户端就不会拿到一个「半死」的连接。最后这条最省事,但要注意 wait_timeout 是会话级变量,长短连接需要分别考虑。

八、小站要不要上连接池

有个务实的判断:如果你的站点只有一个 PHP 应用、没有常驻的后台服务,那么「连接池」这个概念在这一层其实不适用——FPM 的进程模型本身就是池子的替代品,真正要调的是 pm.max_children 和 MySQL 的 max_connections 的关系,以及是否该关掉 persistent 来换取「连接数随负载伸缩」。

只有当你有常驻进程(Go/Java/Node 服务、队列消费者、定时任务框架)时,独立连接池才真正有价值。这时候要关心的是池子上限、获取超时、连接最大存活时间(避免使用被中间设备断开的死连接)和健康检查(SELECT 1 或更轻的 ping)。

最后提醒一点:无论哪种模型,连接数都不该是「配一次就不管」的参数。它是业务规模变化的直接反映——PV 翻倍、加了缓存、拆了读写分离,都会改变合适的值。把它写进季度回顾,比背一堆公式更有用。

Last modification:October 23rd, 2026 at 07:25 pm

Leave a Comment