数据库连接池调优笔记:从 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 MySQL | 100 |
| 8C/16G MySQL | 200 |
| 16C/32G MySQL | 400 |
| 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,立刻触发告警。
避坑列表:连接池常见错误
- 把 max 设成与每个 app 实例一样大。结果是扩容 = 故障。
- 没有设置 maxLifetime。中间网络设备的 NAT 表老化导致连接静默失效。
- Prepared statement 在 Transaction Pool 下没有禁用。导致应用启动就报错。
- 故障切换(failover)时连接没有健康检查。需开启
keepaliveTime。 - 连接泄漏。代码里
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 分钟才能进生产。
- 把连接池与网关连接池统一视角看,不要各顾各的。
连接到数据库是系统工程里「最朴素也最容易做错」的一环。它既不会上技术媒体头条,也不会出现在架构图重点,但只要出错一定是大事故。把它做好是工程师的真功夫。