一粟 · yisu.app
· 12 min read
数据库

数据库连接池调优笔记:从 OOM 到稳定服务

连接池配大了数据库爆炸,配小了上游拥堵。本文以 HikariCP 与 PgBouncer 为样本,拆解连接池几个关键参数以及它们之间不为人知的关系,附上一份可量化的调优流程。

连接池到底解决什么问题

数据库连接是高成本资源,建立它要做 TCP 握手、TLS 握手、身份认证,还要分配后端进程(或 worker)。一个 PG 实例默认只支持 100 个连接;一个 MySQL 在 8 核机器上稳定能服务的活跃连接大约 100–200 个。如果每个上游实例都创建 N 个连接,且实例数又很多,最终会超过数据库能承受的临界点。

连接池的核心价值是把「应用实例 × 每实例连接数」收敛到一个合理值。常见的有三类池:

  • 应用侧池:每个 app 内部维护。代表 HikariCP、Druid、c3p0。
  • 中间层池:独立进程代理。代表 PgBouncer、ProxySQL、MaxScale。
  • 数据库侧池:连接复用 + 排队,主要解决短连接问题。

HikariCP 调优的几个真正关键参数

HikariCP 是 Java 生态最流行的连接池。它看似只有十几条配置,但每条都有含义。下面把影响最大的几个拿出来:

maximumPoolSize

最大连接数。经验值是「上游并发 QPS × P99 耗时 ÷ 1000 + 余量」。但更保守的做法是按 DB 实例 CPU 与连接数关系反推:

DB 实例规格建议并发连接上限
4C/8G MySQL100
8C/16G MySQL200
16C/32G MySQL400
PG 16 (默认 100)需要调整 max_connections

连接数并不线性提升性能,过了某个临界点后会因上下文切换而下降。

minimumIdle

保留的最小空闲连接数。HikariCP 默认等于 max,这意味着一启动就建立所有连接。生产中我们调成 max / 4,让启动更快、错峰扩容更平滑。

connectionTimeout / idleTimeout / maxLifetime

  • connectionTimeout:拿不到连接的最大等待,默认 30 秒。我改成 3 秒,让上游快速失败由网关兜底。
  • idleTimeout:空闲连接多久回收。我们用 5 分钟。
  • maxLifetime:连接存活上限,30 分钟。避免长连接被网络中间设备悄悄断开。
spring:
  datasource:
    hikari:
      maximum-pool-size: 40
      minimum-idle: 8
      connection-timeout: 3000
      idle-timeout: 300000
      max-lifetime: 1800000
      pool-name: shop-cp

PgBouncer:把数据库连接数压到 1/N

HikariCP 解决的是「应用内复用」,但 10 个应用 × 40 个连接还是 400 个连接。PgBouncer 在前面再套一层,把所有上游连接复用为几条「长连接到 PG」。

PgBouncer 有三种模式:

  • Session:每个客户端会话独占后端连接。最安全。
  • Transaction:连接按事务分配。最省,但不支持 prepared statement。
  • Statement:连接按语句分配。最激进,几乎不支持长事务。

我们用 Transaction 模式 + 禁用 prepared statement,把 600 个连接压缩到 50,效果是 P99 抖动几乎消失。

任何讲完连接池不提 PgBouncer / ProxySQL 的文章,都是只画了半张图。

监控:连接池失效的早期信号

连接池出问题前都有早期信号。常用的指标:

  • Active Connections / Max:长期高于 80% 意味着快要失败。
  • Pending Threads:拿不到连接的线程排队数。
  • Connection Acquire Time P99:> 100ms 值得排查。

我们的 Dashboard 上专门有一行叫「DB Pool Saturation」,从采集到告警链路打通。当 P99 拿连接时间超过 50ms,立刻触发告警。

避坑列表:连接池常见错误

  1. 把 max 设成与每个 app 实例一样大。结果是扩容 = 故障。
  2. 没有设置 maxLifetime。中间网络设备的 NAT 表老化导致连接静默失效。
  3. Prepared statement 在 Transaction Pool 下没有禁用。导致应用启动就报错。
  4. 故障切换(failover)时连接没有健康检查。需开启 keepaliveTime
  5. 连接泄漏。代码里 conn.close() 写在 finally 之外。

每个坑都对应一段历史事故,我见过其中一个让某业务每年「莫名其妙挂掉一次」。

迁移到 PgBouncer 的一次真实经历

我们去年做了一次把 RDS Proxy 替换为自建 PgBouncer 的迁移。原因是要避开云厂商 Proxy 的同步阻塞特性。

过程里有几个让我们花时间调试的点:

  • prepared statement 报错:默认 Java 应用都开了,PgBouncer Transaction 模式一接就报错。需要在 JDBC URL 加 ?prepareThreshold=0 或在代码改用 unnamed statement。
  • 长事务被回收:报表查询 30s,PgBouncer 把它掐掉。解决办法是在程序端把报表查询标记为 long_query 走另一个池。
  • DNS 解析:PgBouncer 配置 host 时最好用 IP。如果用域名,每次切换都会有惊喜。

迁移后 TTFB 减少了 8–12ms(来自 connect 阶段的减少),同时 DB 实例 CPU 从 70% 降到 50%。

几条非数字的经验

数字是「做完之后的检验」,经验才是「避免走错路」。我的几个习惯:

  • 任何业务上线必须标注「DB Pool QPS」。
  • 连接池变更必须在压测平台跑 30 分钟才能进生产。
  • 把连接池与网关连接池统一视角看,不要各顾各的。

连接到数据库是系统工程里「最朴素也最容易做错」的一环。它既不会上技术媒体头条,也不会出现在架构图重点,但只要出错一定是大事故。把它做好是工程师的真功夫。