数据库连接泄漏排查:从线程栈到代码根因的完整过程
2026/4/30大约 8 分钟
数据库连接泄漏排查:从线程栈到代码根因的完整过程
适读人群:Java后端开发、DBA、对连接池问题感兴趣的工程师 | 阅读时长:约22分钟
开篇故事
2022年,我们的服务每隔约3天就需要重启一次。
不重启的话,应用会逐渐变慢,直到无法响应新请求。线上告警是:
Unable to acquire JDBC Connection;
nested exception is java.sql.SQLTimeoutException:
Timeout waiting for connection from pool连接池满了,新请求获取不到连接。
但HikariCP配置的最大连接数是20,MySQL上实际的连接数也是20,没有超限。问题是:20个连接全被「占用」了,但实际的SQL查询早就执行完了。
这就是连接泄漏:连接借出去了,但没有归还到连接池。
今天把连接泄漏的定位过程完整记录下来,从告警到找到代码根因,一步都不少。
一、连接泄漏的典型症状
症状表现:
1. 应用运行时间越长,连接池可用连接越少
2. 最终触发连接获取超时:Timeout waiting for connection from pool
3. 重启后短暂恢复,然后再次逐渐恶化
4. MySQL上的连接数稳定在最大值(全被占用)
监控指标:
hikaricp.connections.active → 持续增长,趋近maximum-pool-size
hikaricp.connections.pending → 出现等待(正常情况应该为0)
hikaricp.connections.timeout.total → 开始计数(出现连接超时)二、底层原理:连接泄漏的成因分类
2.1 代码层面的泄漏
// 类型1:Connection没有被关闭(最常见)
public void leakExample1() throws SQLException {
Connection conn = dataSource.getConnection(); // 借出连接
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT ...");
// 正常路径处理rs...
// 某个条件导致提前return,或者抛出异常
// conn.close() 永远没有被执行!← 泄漏!
}
// 类型2:异常路径未关闭连接
public void leakExample2() throws SQLException {
Connection conn = dataSource.getConnection();
try {
// 业务操作
doSomething(conn);
conn.close(); // 正常路径关闭
} catch (Exception e) {
// 异常路径!conn.close()没有执行 ← 泄漏!
throw e;
}
}
// 类型3:长事务 + 事务未提交/回滚(逻辑上的泄漏)
@Transactional
public void leakExample3() {
// @Transactional的连接绑定到当前线程
// 如果方法异常结束但@Transactional没有正确处理
// 或者手动管理的事务忘记commit/rollback
// 连接会一直被持有
}2.2 框架层面的泄漏
Spring @Async + @Transactional 组合陷阱:
@Async方法在线程池中执行
如果线程池的线程数 > 数据库连接池大小
→ 并发请求时,多个线程争抢连接
→ 如果有连接泄漏,更快耗尽连接池
第三方库使用不当:
某些报表/导出库会直接获取Connection
执行完后忘记关闭三、完整解决方案与代码
3.1 HikariCP连接泄漏检测
# application.yml
spring:
datasource:
hikari:
# 开启连接泄漏检测
# 借出的连接超过5秒未归还,打印泄漏警告(含调用栈)
leak-detection-threshold: 5000
# 其他关键配置
connection-timeout: 3000 # 获取连接超时3秒
max-lifetime: 1800000 # 连接最大存活30分钟
maximum-pool-size: 20泄漏检测触发后的日志:
WARN HikariPool-1 - Connection leak detection triggered for
com.example.service.ReportService.generateReport(ReportService.java:45),
stack trace follows
java.lang.Exception: Apparent connection leak detected
at com.zaxxer.hikari.HikariDataSource.getConnection(HikariDataSource.java:128)
at com.example.service.ReportService.generateReport(ReportService.java:45)
at com.example.controller.ReportController.download(ReportController.java:67)
...根据这个栈可以精确定位到泄漏代码:ReportService.java:45。
3.2 通过jstack分析线程栈
当leak-detection日志不够用(比如泄漏时间超过threshold才检测到),可以用jstack:
# 1. 获取进程PID
jps -l
# 输出:12345 com.example.Application
# 2. 生成线程dump
jstack 12345 > thread_dump.txt
# 3. 分析持有数据库连接的线程
grep -A 30 "HikariPool" thread_dump.txt
# 典型的泄漏线程栈:
# "http-nio-8080-exec-23" #88 daemon prio=5 os_prio=0 tid=0x... nid=0x... waiting on condition
# java.lang.Thread.State: TIMED_WAITING (sleeping)
# at java.lang.Thread.sleep(Native Method)
# at com.example.service.ExportService.generateLargeReport(ExportService.java:123)
# at com.example.controller.ExportController.download(ExportController.java:45)
#
# "http-nio-8080-exec-24" #89 daemon prio=5 os_prio=0 tid=0x... nid=0x... sleeping
# 同样的调用栈...
#
# 发现多个线程都卡在 ExportService.generateLargeReport,说明这里有连接泄漏3.3 通过MySQL查询分析长时连接
-- 查看MySQL上的所有连接
SHOW PROCESSLIST;
-- 找出长时间未执行SQL的连接(连接被借出但在空闲中,可能泄漏)
SELECT
ID AS connection_id,
USER,
HOST,
DB,
COMMAND,
TIME AS idle_seconds,
STATE,
INFO AS current_sql
FROM information_schema.PROCESSLIST
WHERE COMMAND = 'Sleep' -- Sleep状态 = 连接被应用持有但没有执行SQL
AND TIME > 60 -- 空闲超过60秒
ORDER BY TIME DESC;
-- 输出示例:
-- connection_id | USER | HOST | COMMAND | TIME | STATE | INFO
-- 101 | app | 192.168.1.100 | Sleep | 3600 | NULL | NULL
-- 102 | app | 192.168.1.100 | Sleep | 3598 | NULL | NULL
-- ...
-- 大量Sleep时间很长的连接 → 强烈怀疑连接泄漏
-- 强制关闭泄漏的连接(临时处理)
KILL 101;
KILL 102;3.4 定位到代码根因:常见的错误模式
/**
* 错误示例1:直接获取Connection,未在finally中关闭
*/
public class BadConnectionUsage {
@Autowired
private DataSource dataSource;
// 错误:没有用try-with-resources,异常时泄漏
public void badExample1() throws Exception {
Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement("SELECT ...");
ResultSet rs = ps.executeQuery();
if (someCondition) {
return; // 提前return!conn/ps/rs未关闭 → 泄漏
}
// 处理结果...
rs.close();
ps.close();
conn.close(); // 可能永远执行不到
}
// 错误:catch中忘记关闭
public void badExample2() throws Exception {
Connection conn = dataSource.getConnection();
try {
// 执行查询...
conn.close(); // 正常路径关闭
} catch (SQLException e) {
// 异常路径未关闭 conn ← 泄漏!
throw new RuntimeException(e);
}
}
}
/**
* 正确示例:使用try-with-resources确保关闭
*/
public class GoodConnectionUsage {
@Autowired
private DataSource dataSource;
// 正确:try-with-resources,无论正常还是异常,都会自动关闭
public void goodExample() throws Exception {
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement("SELECT ...");
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
// 处理结果...
}
} // 自动调用 rs.close(), ps.close(), conn.close()
}
// 正确:JdbcTemplate(Spring管理连接生命周期)
@Autowired
private JdbcTemplate jdbcTemplate;
public List<Order> queryOrders(Long userId) {
// JdbcTemplate内部使用try-with-resources,不会泄漏
return jdbcTemplate.query(
"SELECT * FROM orders WHERE user_id = ?",
ROW_MAPPER, userId
);
}
}
/**
* 隐蔽的泄漏:JPA/Hibernate的StatelessSession未关闭
*/
@Service
public class TrickyLeakService {
@Autowired
private SessionFactory sessionFactory;
// 错误:StatelessSession如果不关闭,其持有的连接也不会释放
public void trickyleak() {
StatelessSession session = sessionFactory.openStatelessSession();
// 执行批量操作...
// 忘记 session.close() ← 连接泄漏!
}
// 正确
public void correctUsage() {
try (StatelessSession session = sessionFactory.openStatelessSession()) {
// 执行批量操作...
} // 自动关闭session,释放连接
}
}3.5 连接泄漏的自动检测与告警
/**
* 基于HikariCP MXBean的连接泄漏监控
*/
@Component
public class ConnectionLeakMonitor {
@Autowired
private HikariDataSource dataSource;
@Autowired
private AlertService alertService;
private final int LEAK_THRESHOLD_PERCENT = 80; // 使用率超过80%告警
@Scheduled(fixedDelay = 30_000) // 每30秒检查
public void monitor() {
HikariPoolMXBean pool = dataSource.getHikariPoolMXBean();
int total = pool.getTotalConnections();
int active = pool.getActiveConnections();
int pending = pool.getThreadsAwaitingConnection();
int maxSize = dataSource.getMaximumPoolSize();
int usagePercent = (int) ((double) active / maxSize * 100);
if (pending > 0) {
// 有线程在等待连接,严重!
alertService.sendCritical("数据库连接池等待",
String.format("有%d个线程等待连接,active=%d, total=%d, max=%d",
pending, active, total, maxSize));
} else if (usagePercent >= LEAK_THRESHOLD_PERCENT) {
// 使用率过高,可能存在泄漏
alertService.sendWarning("数据库连接使用率过高",
String.format("使用率=%d%%,active=%d, max=%d,检查是否有连接泄漏",
usagePercent, active, maxSize));
}
// 记录Metrics,便于趋势分析
Metrics.gauge("db.connections.active", active);
Metrics.gauge("db.connections.usage", usagePercent);
}
}四、踩坑实录
坑1:leak-detection-threshold设置太小,产生大量误报
配置:leak-detection-threshold: 1000 ← 1秒
现象:日志中大量"Apparent connection leak detected"
但实际上没有泄漏,只是某些SQL执行超过了1秒
原因:leak-detection-threshold是"连接被借出后超过该时间未归还才告警"
设置1秒对于有些慢SQL来说太短了解决方案:
spring:
datasource:
hikari:
# 根据业务SQL的最大执行时间来设置
# 如果最慢的SQL执行3秒,那leak-detection-threshold应该>3秒
leak-detection-threshold: 10000 # 10秒(推荐值)坑2:@Transactional + 自定义Connection操作,连接未释放
// 问题:在@Transactional方法内手动获取Connection
@Transactional
public void problematicMethod() {
// 这里获取的Connection和@Transactional管理的Connection是同一个!
// 不要手动关闭,@Transactional会在方法结束时关闭
Connection conn = DataSourceUtils.getConnection(dataSource);
// 但如果不知道这一点,手动close了:
conn.close(); // 提前关闭了@Transactional的连接 → 后续操作失败
// 更糟糕:绕过DataSourceUtils,直接从连接池获取新连接
Connection extraConn = dataSource.getConnection(); // 额外的连接!
// 如果extraConn没有被关闭 → 泄漏!
}正确做法:
@Transactional
public void correctMethod() {
// 在@Transactional方法内,让Spring管理连接
// 通过JdbcTemplate/MyBatis Mapper等使用,不要手动管理Connection
jdbcTemplate.update("UPDATE ..."); // Spring自动使用事务连接
// 如果确实需要直接操作Connection
Connection conn = DataSourceUtils.getConnection(dataSource);
try {
// 使用conn...
} finally {
// 使用DataSourceUtils.releaseConnection,而不是conn.close()
DataSourceUtils.releaseConnection(conn, dataSource);
}
}坑3:连接池大小设置过小,高并发时触发伪"泄漏"
场景:maximumPoolSize=5(设置太小)
同时有10个请求需要数据库操作
5个请求得到连接,另5个在等待(pending=5)
5秒后,leak-detection-threshold=5000触发
打印"Connection leak detected"
但实际上不是泄漏,只是连接池太小,并发请求太多!区分泄漏和连接池过小:
// 泄漏的特征:
// - 连接数随时间单调增长(慢慢用完)
// - 应用重启后短暂恢复,再次慢慢变满
// 连接池过小的特征:
// - 并发高峰期瞬间打满,高峰过后恢复正常
// - 重启无法解决(重启后立刻又被打满)
// 诊断方法:在连接告警时查看MySQL的连接数
// SHOW STATUS LIKE 'Threads_connected';
// 如果Threads_connected < maximum-pool-size → 不是泄漏,是连接池设太小
// 如果Threads_connected == maximum-pool-size → 可能是泄漏五、总结与延伸
连接泄漏排查的完整步骤:
1. 确认症状:连接池使用率持续增长,重启后恢复,然后再次增长
2. 开启泄漏检测:leak-detection-threshold=10000(10秒)
3. 观察日志:泄漏日志会打印持有连接的调用栈,直接定位代码
4. 无泄漏日志时:jstack分析线程,找出长时间持有连接的线程
5. MySQL侧验证:查看Sleep时间很长的连接,对应到应用线程
6. 代码修复:确保Connection在finally块中关闭,或者使用try-with-resources预防连接泄漏的最佳实践:
- 所有直接操作Connection的代码必须使用try-with-resources
- 优先使用JdbcTemplate/MyBatis Mapper,让框架管理连接
- 开启leak-detection-threshold(10秒),在生产环境提前发现泄漏
- 不要在非@Transactional方法中混用手动Connection管理
