凌晨告警炸了:订单接口 P99 从 80ms 飙到 8s,HikariCP 数据库连接池 100% 占满,新请求全部卡在「waiting for connection」。这是一次典型的连接泄漏事故——根因不是慢 SQL,而是代码里没正确关闭连接。本文复盘从告警、止血到根治的完整链路,给出可直接套用的排查命令与修复模板。
一、故障现场:接口大面积超时,线程池打满
监控面板上两个信号同时变红:Prometheus 里 hikaricp_connections_active 长时间贴着 maximumPoolSize,而 Tomcat 业务线程池也接近耗尽。应用日志刷屏的是同一句:HikariPool-1 - Connection is not available, request timed out after 30000ms. 数据库本身的 CPU 并不高,说明瓶颈不在计算,而在「拿不到连接」。
| 现象 | 最可能根因 |
|---|---|
| active 连接贴满、不回落 | 连接泄漏(借出未还) |
| 数据库 CPU 低但请求超时 | 连接被闲置事务占用 |
| 重启后恢复、几小时后复发 | 慢路径/低频分支泄漏 |
| 仅某个接口超时 | 该接口手动拿连接未关 |
| 伴随大量 Command=Sleep | 事务边界过大或忘了 commit |
二、快速止血:先恢复,再排查
线上第一目标是恢复吞吐,不是立刻定位。三步止血:① 滚动重启应用,瞬间释放被泄漏占满的连接;② 临时把 maximumPoolSize 调大(如 20→40)争取缓冲;③ 在数据库侧 KILL 掉长时间 Sleep 且来自应用 IP 的空闲连接。止血后保留现场快照(线程栈、连接列表),再进根因分析。
三、定位根因:活跃连接数为何不回落
连接池「借出即应归还」,active 连接只增不减,几乎可以断定是泄漏。HikariCP 自带泄露检测:设置 leakDetectionThreshold 后,任何连接借出超过阈值未归还,都会打印完整调用栈,直接指出是哪一行代码没还连接。这是最快的定位手段,比肉眼 review 可靠得多。栈顶通常是你那个手动 getConnection() 的方法,顺着往下能看到是谁调用了它。
读栈也有技巧:如果栈里出现 PreparedStatement 或 ResultSet 的 close 在 finally 之外,基本就是「关了语句、忘了连接」;如果出现的是你自己封装的 DbUtil.getConnection(),说明封装层把连接交出去后没有任何回收契约。把这条栈贴到代码评审里,根因一目了然。
四、关键证据:泄漏点的代码长什么样
最常见的两种写法都会泄漏。其一是在循环或分支里手动 getConnection() 却没在 finally 中关闭;其二是方法里直接操作 JDBC,却以为 Spring 的 @Transactional 会替自己回收——事务只管理「逻辑连接」,你手动拿的物理连接仍要自己还。
// ❌ 泄漏写法:手动拿连接,异常时跳过 close
public void badSave(Order o) throws SQLException {
Connection conn = dataSource.getConnection(); // 借出
PreparedStatement ps = conn.prepareStatement(INSERT);
ps.setLong(1, o.getId());
ps.executeUpdate();
// 没 close!正常分支也漏,异常分支更漏
}
// ✅ 正确写法:交给 Spring 托管 + try-with-resources
@Transactional
public void goodSave(Order o) {
jdbcTemplate.update(INSERT, o.getId()); // 连接由框架借还
}
// 必须手动 JDBC 时,务必 try-with-resources
public void safeRaw(Order o) throws SQLException {
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(INSERT)) {
ps.setLong(1, o.getId());
ps.executeUpdate();
} // 自动关闭,绝不泄漏
}
连接泄漏和 Java 内存泄漏排查 的内存泄漏、文件描述符耗尽 的文件描述符耗尽本质一样:资源「借了不还」。差别只是泄漏的对象从堆内存、fd 变成了数据库连接。
五、为什么连接会泄漏:事务边界与资源归属
Spring 的声明式事务通过代理在方法进出时借还连接,前提是「你用的是同一个 DataSource 且没绕过它」。一旦在事务方法内部自己 getConnection(),就拿到了事务之外的独立连接,事务提交不会归还它。再叠加长时间事务、或把连接塞进静态字段/缓存,泄漏会被悄悄放大。
几个高频反模式尤其要警惕:其一,把 Connection 存进对象的成员变量,以为方法结束就释放,实则随对象存活;其二,在 finally 里只关了 Statement 却漏了 Connection;其三,用自定义工具类拿连接,却让调用方「约定」去关——约定总有人忘。根治心法是:连接的生命周期只交给一个 owner(框架或 try-with-resources),绝不让它逃逸出方法作用域。
六、连接泄漏 vs 连接池偏小:先分清再动手
并非所有「拿不到连接」都是泄漏。流量上涨时池子本来就小,active 会随 QPS 起伏、流量一停就回落——这是配置问题,调大 maximum-pool-size 即可。真正的泄漏特征是:即便深夜低峰,active 也贴着上限不松手,且 pending_threads(等待连接的线程数)持续大于 0。一句话区分:会回落的是池小,不回落的是漏。先下这个结论,再决定是改配置还是改代码,能省掉一半无效加班。
七、四步根治:代码、配置、监控、压测
第一步改代码,用 JdbcTemplate 或 try-with-resources 收口;第二步加连接池自我保护参数;第三步把连接池指标接入监控;第四步用 慢 SQL 拖垮生产库 同款思路做压测,确认 active 连接能随流量回落。
# application.yml —— HikariCP 关键参数
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000 # 借连接最多等 30s
idle-timeout: 600000 # 空闲连接 10min 回收
max-lifetime: 1800000 # 连接最长存活 30min(小于 DB 超时)
leak-detection-threshold: 5000 # 借出超 5s 未还即告警并打印栈
| 参数 | 默认 | 建议 | 作用 |
|---|---|---|---|
| maximum-pool-size | 10 | 按并发定(常 20–50) | 上限,防打爆 DB |
| connection-timeout | 30s | 保持 30s | 拿不到连接快速失败 |
| max-lifetime | 1800s | 略小于 DB wait_timeout | 避免用到被 DB 单方面关闭的连接 |
| leak-detection-threshold | 0(关) | 5000 | 抓连接泄漏的利器 |
八、防复发的工程化手段
让框架托管连接,禁止业务代码直接 getConnection();把 hikaricp_connections_active / pending_threads 接入 Grafana 并设告警;代码评审把「手动 JDBC 未用 try-with-resources」列为红线。若与 Redis 缓存基础 的缓存、MySQL 主从复制 的主从、MySQL 深度调优 的调优配合,更能从连接层到 SQL 层整体压住数据库压力。
-- 排查:按来源 IP 聚合长时间 Sleep 的连接
SELECT HOST, COUNT(*) AS c, MAX(TIME) AS max_idle
FROM information_schema.PROCESSLIST
WHERE COMMAND='Sleep' AND TIME > 60
GROUP BY HOST ORDER BY c DESC;
-- 紧急止血:杀掉某应用 IP 上空闲超 300s 的连接(按需替换)
SELECT CONCAT('KILL ',ID,';')
FROM information_schema.PROCESSLIST
WHERE HOST LIKE '10.0.1.%' AND COMMAND='Sleep' AND TIME > 300;
九、小结
数据库连接池耗尽听着吓人,根因往往只是一行没关的连接。记住三句话:止血先重启+临时扩池;定位靠 leakDetectionThreshold 的栈;根治靠框架托管 + try-with-resources + 监控告警。把连接当借来的东西,用完必还,事故就不会半夜找上门。




