你的数据库连接池正在悄悄排队,而你的代码里一个字都没错
先讲一个真实场景:服务上线时一切正常,QPS 稳定在 300,数据库 CPU 才 20%。某天业务推了个活动,流量没涨多少,接口 P99 却从 30ms 飙到了 8 秒,监控上看数据库的 QPS 反而掉了一半。
更诡异的是,数据库监控面板一片祥和:没有慢查询、没有锁等待、没有 CPU 打满,甚至连接数只用了 30 个(池子配的是 100)。运维同学问了句「是不是数据库有问题」, DBA 回了句「数据库什么都没干啊,它在等」。
它在等什么?等连接池给你放行。
这就是连接池耗尽(Connection Pool Exhaustion)——一个不会报错、不会打日志、不会触发任何告警的性能黑洞。今天我们把它彻底拆开。
一、先算一笔账:你以为的并发,和真实的并发
新手最容易犯的错,是把「接口 QPS」直接当成「需要的数据库连接数」。
假设每个接口请求会查 3 次库,平均每次查询 10ms。很多人拍脑袋说:「300 QPS × 10ms = 3,所以 10 个连接绰绰有余!」
这个计算只算了纯查询时间,忽略了连接池真正残酷的部分:等待。
用 Little's Law(利特尔法则)算一遍就明白了:
系统中的平均并发数 = 到达率 × 平均停留时间(L = λ × W)
如果线程池有 200 个工作线程,每个线程处理完请求还要做序列化、写日志、调用下游,单请求的总停留时间是 100ms,其中只有 10ms 在真正查数据库。数据库实际需要的连接数是:
需要的连接数 ≈ QPS × 数据库耗时占比内的占用
= 300 × 0.01 = 3 个并发在库上
但线程池能同时发起多少请求? 200 个线程 × 100ms = 2000 QPS
当流量从 300 涨到 800 QPS 时:
库上的并发占用 = 800 × 0.01 = 8 个连接
看起来 100 个连接绰绰有余?问题在于等待的那些时间不算在 10ms 里。当连接池被占满,剩下的线程全部排队:
实际响应时间 = 连接获取时间 + 查询时间
获取连接耗时 = 0(池里有空闲)→ 10ms
获取连接耗时 = 500ms(池子满了,排队)→ 510ms
而因为线程被阻塞,吞吐反而下降:
连接池满 → 线程全在排队 → 没有线程去发新请求 → DB 看到 QPS 掉一半
这就是那个诡异现象的来源:流量涨了,数据库 QPS 反而跌了。所有的请求都堵在「等连接」这一步,数据库当然闲着。
核心结论:数据库看到的 QPS 下降 + 应用侧延迟暴涨 = 连接池排队,这是它的经典指纹。
二、连接池不是越大越好,这是最反直觉的真相
新手方案二:那我直接把池子调到 500 不就行了?
这是把问题从「排队」升级成「雪崩」的经典操作。理由有三:
1. 数据库的并发处理能力有上限。MySQL 的并发处理能力不由连接数决定,而由 innodb_buffer_pool 大小、磁盘 IOPS、CPU 核数决定。100 个连接同时对一个 8 核的库发起查询,每个查询都要拿 CPU,结果就是上下文切换和排队——你以为的并发,实际是内核在帮你轮转。
2. 内存被连接数放大。每个 MySQL 连接在后端就要占 1MB 以上的会话内存,几百个连接轻松吃掉几百 MB,还全都是「空闲但占着」的状态。数据库最怕的不是忙,是内存被连接撑爆了却什么都干不了。
3. 慢查询会指数级放大问题。一个跑了 5 秒的查询,在 100 个连接下同时出现,就是 100 个线程各自握着 5 秒。你以为池子大能扛住,实际上你只是把「一个人堵在门口」变成了「一百个人堵在门口」。
经验公式(PostgreSQL 社区常见建议):
连接数 ≈ CPU核数 × 2 + 有效磁盘数
MySQL 场景通常:
连接数 ≈ CPU核数 × 2 ~ 4,并受 max_connections 上限约束
剩下的并发需求 → 用连接池排队解决
排队是可控的,数据库过载是不可控的
连接池的意义从来不是「无限放大并发」,而是把并发的尖峰削平成数据库能吃下的稳定流量。
三、找到那个偷走连接的元凶:连接泄漏
连接池耗尽最常见的根因不是池子小,而是连接泄漏——代码拿了连接但没还回去。
Java 的典型现场:
// 一眼看过去没问题对吧?
public List<Order> listOrders(Long userId) {
Connection conn = dataSource.getConnection();
try {
// do something
return query(conn, userId);
} catch (Exception e) {
return Collections.emptyList(); // ← 异常路径 return 了,conn.close() 被跳过!
} finally {
conn.close(); // ← 这里其实执行了,但异常路径下 return 在 finally 之前…
}
}
上面的代码其实还行(finally 会执行)。真正的事故现场长这样:
public void exportBigFile() {
Connection conn = dataSource.getConnection();
Statement st = conn.createStatement();
ResultSet rs = st.executeQuery("SELECT ... 500万行");
// 忘了 close,甚至没有 try-finally
// 业务异常一抛,连接永久滞留在池中,标记为 in-use 但永不归还
}
更隐蔽的版本:业务异常被 catch 吞掉了。
try {
conn = dataSource.getConnection();
doBusiness(conn);
} catch (Exception e) {
log.error("业务失败", e);
// 没关!只记了日志
}
每调用一次,就泄漏一个连接。跑上几千次,池子就见底了。而应用层面看不到任何异常——因为业务异常已经被 catch 掉了,接口照常返回 200,只是慢。
排查口诀:连接池耗尽时,第一件事是查「谁在持有连接超过 N 秒」,而不是第一时间调大池子。
四、实战:用 HikariCP 的自带工具抓现行
HikariCP 是 Java 生态里最主流的连接池,它自带的诊断能力比大多数人用到的都多。
第一步:开启泄漏检测。
# application.yml
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 20 # ← 见下文,这个参数比你想的重要
leak-detection-threshold: 5000 # 5 秒未归还就打印堆栈
connection-timeout: 3000 # 3 秒拿不到连接就抛异常
max-lifetime: 1800000 # 30 分钟,务必小于数据库的 wait_timeout
idle-timeout: 600000
leak-detection-threshold 开启后,一旦某条连接被持有超过阈值,HikariCP 会打印持有它的完整调用栈:
Connection leak detection triggered for com.zaxxer.hikari.pool.ProxyConnection
com.zaxxer.hikari.pool.ProxyConnection.checkLeak
com.example.service.OrderService.exportBigFile(OrderService.java:87)
com.example.controller.ExportController.download(ExportController.java:42)
...
Stack trace below leaks:
java.lang.Thread.State: RUNNABLE
at com.example.dao.OrderDao.scanInBatches(OrderDao.java:31)
at com.example.service.ExportService.doExport(ExportService.java:55)
这行栈直接告诉你:哪个方法、哪一行、哪条线程把它扣住了。这就是抓现行。
第二步:看那四个指标。HikariCP 通过 Micrometer 暴露的指标,排查时必须全看:
hikaricp_connections_active # 当前活跃连接数
hikaricp_connections_pending # 正在等待连接的线程数 ← 关键!
hikaricp_connections_max # 池子上限
hikaricp_connections_idle # 空闲连接数
hikaricp_connections_acquire_seconds # 获取连接的耗时分布
hikaricp_connections_usage_seconds # 连接被持有的时长分布
判断逻辑非常清晰:
pending > 0 → 池子已经排队了,进入告警级别
active == max 且 idle == 0 → 完全打满
usage_seconds 持续偏高 → 存在持有过久的连接,疑似泄漏或长事务
acquire_seconds 的 p99 暴涨 → 池子不够,或有慢查询占着连接不放
没有指标的服务,等于没有仪表盘的飞机。把这六个指标先接进监控,比你读一百行代码都有用。
第三步:警惕 minimum-idle 这个反直觉参数。
很多人把 minimum-idle 设成 0(懒加载),理由是「省资源」。但这会带来一个致命问题:流量到来时,池子需要现场创建连接,而创建连接要走一次 TCP 握手 + 认证 + TLS 握手,可能耗时几十到几百毫秒。这段创建时间全部算在请求延迟里。
更糟的是,如果数据库此时正好在抖动,创建连接会失败,你的接口直接 500。
// 推荐的实践
minimum-idle = maximum-pool-size // 启动时一次性把池子填满
// 代价是启动需要预热时间,可以用连接池的 init-fail-timeout 调整
还有一条铁律:max-lifetime 必须小于数据库的 wait_timeout,并且留出余量。否则数据库那边已经把连接悄悄掐了,你的池子还以为自己拿着一条活连接,第一次用它就拿到「Communications link failure」。这个坑我见过至少三个团队踩。
五、别忘了:长事务是隐形的连接杀手
有一类问题特别隐蔽:连接确实还回去了,但持有了太久。
@Transactional
public void processBatch(List<Long> ids) {
for (Long id : ids) {
orderDao.update(id);
// 这里做了一次 HTTP 调用,或者一个 for 循环十万次
}
// 整个方法执行期间,连接一直被事务持有
}
在事务里做远程调用是经典反模式:数据库连接被一个可能长达数秒的 HTTP 请求占着,而它对数据库没有任何意义。100 个并发请求,每个都在事务里发一次 HTTP,池子瞬间见底,而数据库 CPU 依然是 0——因为大家都在等 HTTP 返回,不是在等数据库。
解法很朴素:把远程调用挪出事务。先查出来,再事务外调接口,最后开个小事务写回。
// ❌ 反模式
@Transactional
public void handle(Order o) {
repo.save(o);
remoteClient.notify(o); // 网络抖动 = 连接被扣住 3 秒
}
// ✅ 正确姿势
public void handle(Order o) {
repo.save(o);
remoteClient.notify(o); // 事务外,无连接占用
}
// 如果必须事务内,用 TransactionSynchronization 把远程调用挂到 afterCommit
六、Go 和 Node.js 的用户也别笑
这不是 Java 独有的问题。
Go:
db, err := sql.Open("mysql", dsn)
db.SetMaxOpenConns(100)
db.SetMaxIdleConns(100)
db.SetConnMaxLifetime(time.Minute * 30)
// 致命写法:
rows, _ := db.Query("SELECT ...")
defer rows.Close() // ← 如果 Query 后 rows 为 nil,这里会 panic
for rows.Next() {
// 处理中 panic 了,rows.Close() 还能执行(defer 兜住了)
}
// 但如果 rows 迭代中途 return 且没有 defer,连接永久泄漏
Go 的 database/sql 在连接耗尽时不阻塞,而是直接返回 context deadline exceeded,报错极具误导性——很多人以为是数据库挂了,其实是池子满了。
Node.js(以 mysql2/pg 为例):
const pool = mysql.createPool({ connectionLimit: 10 });
async function query() {
const conn = await pool.getConnection();
const rows = await conn.query('SELECT ...');
conn.release(); // ← 一旦上面抛错,这行永远执行不到
return rows;
}
// ✅ 用 finally 或 try/finally 包住
const conn = await pool.getConnection();
try {
return await conn.query('SELECT ...');
} finally {
conn.release();
}
顺带提一个高频误区:connectionLimit: 10 的池子,配一个 200 并发的 Node 服务,看起来「反正会排队」,但如果 release() 漏了,10 个连接会在几十秒内全部消失,然后服务开始以 Error: Pool is closed 或超时集体阵亡。
七、我的连接池配置检查清单
把这张表贴在工位上,新服务上线前对一遍:
- maximum-pool-size:按 CPU 核数 × 2~4 起步,再根据监控调,不要拍脑袋写 100
- minimum-idle == maximum-pool-size:避免流量来时现场创建连接
- connection-timeout:设成秒级,不要设成 30 秒。快速失败比慢慢排队好
- leak-detection-threshold:开发环境设 5~10 秒,生产环境设 30~60 秒
- max-lifetime:小于数据库 wait_timeout,留 10 分钟余量
- 所有指标接入监控:至少 active / pending / idle / acquire_seconds / usage_seconds
- 告警规则:pending > 0 持续 1 分钟就报警,usage p99 超阈值报警
- 禁止在事务里做远程调用:这条比前面七条加起来都重要
- 每次故障先看 usage_seconds 分布,而不是先调池子大小
八、一句话总结
连接池不是「越宽越宽裕的马路」,而是水库。水坝修得太小,下游旱死;水坝修得太大,下游直接被冲垮。真正决定系统健康的,从来不是坝体的宽度,而是闸门的开合节奏。
而那个节奏,在你的代码里——在每一处 getConnection() 之后有没有跟上一句 close(),在每一个事务里有没有夹带一次网络请求。
池子打满从来不是池子的错,是持有连接的人太久。先抓贼,再修坝。
最后一句忠告:如果你现在正打算把 maximum-pool-size 从 20 改成 200 来「解决这个问题」,请先关掉这个标签页,把上面那张检查清单从头到尾读一遍。