Java 数据库连接池耗尽与连接泄漏调试实战:从告警到根因的完整闭环

Java 数据库连接池耗尽与连接泄漏调试实战:从告警到根因的完整闭环

技术主题:Java 编程语言
内容方向:具体功能的调试过程(连接池耗尽与连接泄漏排查)

引言

数据库连接池问题很“隐蔽”:没有明显的错误日志,却会以响应变慢、偶发超时开场,最后演变成池耗尽、全站雪崩。本文复盘一次生产事故,从“慢查询还是连接泄漏”的分辨开始,完整走一遍复现、定位与修复,并给出可直接落地的 HikariCP 配置与代码改造范式。

一、问题现象

  • 峰值时段接口 P99 从 300ms → 5s+,偶发 504;
  • 应用日志无异常堆栈,但数据库连接使用数接近 poolMaximumSize;
  • 数据库侧 QPS 正常,慢 SQL 占比不高;
  • jstack 显示部分线程卡在 getConnection / borrow 阶段等待。

初判:更像“连接回收不及时或泄漏”而非“数据库本身变慢”。

二、排查步骤

  1. 先区分“慢 SQL”与“拿不到连接”
  • 打开 SQL 执行耗时与池获取耗时的分布日志;
  • 将“获取连接等待时间”与“SQL 执行时间”分别打点到指标系统。
  1. 栈与上下文
  • 在线抓取线程 dump,检索 HikariCP 的 getConnection 与 close 调用链;
  • 对比业务线程是否存在 try-with-resources 缺失、异常分支未 close。
  1. 池与生命周期
  • 检查 maxLifetime 是否明显大于数据库连接的服务端超时(导致半开连接);
  • 验证 leakDetectionThreshold 是否开启并能打印栈。

三、解决思路(总览)

  • 代码层:所有数据库访问统一采用 try-with-resources,确保异常路径也能关闭;
  • 连接池层:开启泄漏检测、合理设置 maxLifetime、connectionTimeout、idleTimeout;
  • 观测层:区分“borrow 等待时间”与“SQL 执行时间”,并对泄漏栈进行采样记录;
  • 风险隔离:读写分离或热点表读走缓存,缓解峰值借用压力。

四、关键配置与代码片段

1) Spring Boot 下的 HikariCP 配置(application.yml)

1
2
3
4
5
6
7
8
9
10
11
12
13
spring:
datasource:
url: jdbc:mysql://db:3306/app?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC
username: app
password: ${DB_PASS}
hikari:
maximum-pool-size: 40 # 结合 CPU 与并发目标评估
minimum-idle: 10
connection-timeout: 2000 # 借连接超时(毫秒),避免长时间卡死
idle-timeout: 600000 # 10min,空闲回收
max-lifetime: 1700000 # 28m20s < 数据库 wait_timeout(例如 30m),防止半开
validation-timeout: 1000
leak-detection-threshold: 2000 # 2s 未归还记录堆栈(仅在灰度/问题期开启)

要点:maxLifetime 要小于数据库侧的连接生存时间(如 MySQL wait_timeout)。leakDetectionThreshold 在问题定位期开启即可,避免日志过多。

2) 正确的资源关闭范式(try-with-resources)

1
2
3
4
5
6
7
8
// 反例:异常时可能跳过 close()
Connection c = dataSource.getConnection();
PreparedStatement ps = c.prepareStatement(SQL);
ResultSet rs = ps.executeQuery();
// ... 处理结果
rs.close();
ps.close();
c.close();
1
2
3
4
5
6
7
8
9
10
// 正例:始终自动关闭,包含异常路径
try (Connection c = dataSource.getConnection();
PreparedStatement ps = c.prepareStatement(SQL)) {
ps.setLong(1, userId);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
// ... 处理结果
}
}
}

3) 统一的 JDBC 访问封装,防重复代码与漏关

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class JdbcTemplateLite {
private final DataSource ds;
public JdbcTemplateLite(DataSource ds) { this.ds = ds; }

public <T> T query(String sql, StatementSetter setter, RowMapper<T> mapper) throws SQLException {
try (Connection c = ds.getConnection();
PreparedStatement ps = c.prepareStatement(sql)) {
setter.set(ps);
try (ResultSet rs = ps.executeQuery()) {
return mapper.map(rs);
}
}
}

@FunctionalInterface public interface StatementSetter { void set(PreparedStatement ps) throws SQLException; }
@FunctionalInterface public interface RowMapper<T> { T map(ResultSet rs) throws SQLException; }
}

4) 记录“借连接等待时间”与“SQL 执行时间”

1
2
3
4
5
6
7
8
9
10
11
12
13
long t0 = System.nanoTime();
try (Connection c = dataSource.getConnection()) {
long waited = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - t0);
metrics.timer("db.borrow.wait").record(waited, TimeUnit.MILLISECONDS);
try (PreparedStatement ps = c.prepareStatement(SQL)) {
long s0 = System.nanoTime();
try (ResultSet rs = ps.executeQuery()) {
// consume
}
long rt = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - s0);
metrics.timer("db.sql.rt").record(rt, TimeUnit.MILLISECONDS);
}
}

5) 针对泄漏的栈采样与告警(基于 Hikari 日志)

  • 启用 leak-detection 后,Hikari 会输出未归还连接的创建栈;
  • 将包含“leak detection”关键词的日志转发到告警系统,按调用链与类名聚合;
  • 关联到发布版本与接口名,快速定位到具体业务代码。

五、验证与效果

  • A/B 灰度 3 天:
    • 借连接等待 P95 从 1200ms → 80ms;
    • SQL 执行 P95 变化不大(说明主要瓶颈在借用阶段);
    • 连接池使用率稳定在 50%-70%,无长尾借用。
  • 生产一周:
    • 504 减少为 0;
    • 无新增泄漏告警。

六、预防清单

  • 统一 JDBC 访问封装并使用 try-with-resources;
  • maxLifetime 小于数据库连接超时;
  • 合理设置 connectionTimeout(2s-3s)+ 快速失败重试;
  • 灰度开启 leakDetectionThreshold,定位后关闭或上调阈值;
  • 关键接口引入读缓存/降级,削峰填谷;
  • 指标分离:borrow 等待、SQL 执行、池使用率、超时/失败率。

总结

连接池问题的关键在于“把症状拆开看”:分清“借用等待”与“执行耗时”。通过代码范式(try-with-resources)、池参数(maxLifetime/connectionTimeout)与可观测性(借用/执行分离指标),可以快速复现场景、锁定根因并稳定恢复。以上配置与代码可直接落地,适合在生产环境逐步应用。