数据库连接泄漏排查实践:从现象到根因

举报
数据库小学妹 发表于 2026/10/22 10:01:10 2026/10/22
【摘要】 异步导出打满连接池,加连接无效。讲清池对象与会话的关系,先分清容量不足还是借了不还,用 Threads_connected/Threads_running 和 processlist 定位,覆盖异常分支、异步跨线程、ResultSet 未关三类高发点,给出 KILL 边界与 try-with-resources 根治。

大家好,我是数据库小学妹👋 我踩过的坑,你别再踩。

有回报表导出改了异步,接口秒回「任务已提交」。业务侧看着挺好,可连接池的告警开始响。我以为导出量大,先把它往上调了一截。加了还是满,业务量也没涨。

最想不通的是,很多连接挂在库里。状态是 Sleep,时间越来越长。业务说早就结束了,连接却没回来。后来才想明白,池子满不一定是池子小,也可能是有人借了没还。

先分清:不够用,还是不归还

连接池满,只有两种可能。一种是真不够用。并发上来,或者慢查询占着连接不放。这时连接都在跑,Threads_running 也高。另一种是借了不还。连接被拿走,代码没走到归还那一步。业务逻辑早结束了,连接还在池外漂着。这时 Threads_connected 高,Threads_running 却很低。

两种现象长得像,处置完全相反。不够用要扩容或优化 SQL。不归还,加多少池子都只是拖时间。

信号 不够用(容量/慢 SQL) 借了不还(泄漏)
Threads_connected 高 高
Threads_running 也高 低,大量 Sleep
连接空闲时间 短 越来越长
加连接池 有效 短暂缓解,很快又满
重启应用 会好,根因仍在 SQL 或流量 会好,但复发的时点没变

连接池里漏的到底是什么

这里得先掰开一个概念。连接池里的「连接」,和数据库里的「会话」,不是一回事。

应用 getConnection(),拿到的是池里的一个对象。它背后握着一条真实的数据库会话。用完 close(),对象被收回,会话留着复用。平时这两步是配对的。泄漏就卡在这一步。池对象被借走,close() 没执行。数据库那边一条会话一直开着,池这边这条也一直算「借出中」。两头都不释放。

所以加池子大小只是加了分母。泄漏还在,只是从五十条漏到一百条,满了的时间往后挪。这也解释了为什么重启管用。重启把进程里的池对象清空,数据库会话跟着断。可代码不改,过一阵又漏回来。

怎么确认是泄漏

先看两个数。

SHOW STATUS LIKE 'Threads_connected';
SHOW STATUS LIKE 'Threads_running';

前者是已建立连接数,后者是正在执行 SQL 的连接数。前者高、后者低,差值一大,基本就是泄漏。两个都高,先往慢查询和突发流量查。

接着看连接都挂在哪。

SELECT host, command, COUNT(*) AS cnt, MAX(time) AS max_idle
FROM information_schema.processlist
WHERE user = 'app_user'
GROUP BY host, command
ORDER BY cnt DESC;

command 是 Sleep 或 Query,time 是连接在当前状态的秒数。Sleep 的 time 是空闲了多久,Query 的 time 是这条 SQL 跑了多久。两个语义别混,一个是占着不用,一个是正在用。

光看状态还不够,得看那会话到底在干什么。

SELECT t.processlist_id, t.processlist_host, t.processlist_command,
       t.processlist_time, e.sql_text
FROM performance_schema.threads t
LEFT JOIN performance_schema.events_statements_current e
  ON e.thread_id = t.thread_id
WHERE t.processlist_user = 'app_user'
ORDER BY t.processlist_time DESC
LIMIT 20;

Sleep 的连接,这里能看到它最后一条 SQL。往往就是泄漏前借它的那次操作,直接指向问题代码。

有长事务挂着,连接也回不去。

SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started ASC;

trx_state 是 RUNNING,trx_query 是空,trx_started 又很早,说明事务开了没提交,连接被它锁着。这种不算严格意义的泄漏,但表现一样,连接回不去。

国产数据库里面,金仓KES也有对应的视图。sys_stat_activity里能看到每个会话的状态、空闲时长和最后执行的SQL,用法和processlist类似。如果手上有KES Studio或KEMCC,还能直接下钻到会话级别看连接到底卡在哪一步,不用自己拼多个系统表。

三个高发点

第一类,异常分支没归还。最常见。

// 错误:异常抛出时,conn 不会关
Connection conn = dataSource.getConnection();
try {
    PreparedStatement ps = conn.prepareStatement(sql);
    ps.executeQuery();
} catch (SQLException e) {
    log.error("query failed", e);
    throw new BizException(e);
}
// finally 没写,异常路径更没有

正常路径忘了关,异常路径更不会关。一次两次看不出来,累积起来就是泄漏。

第二类,连接被交给异步线程。这正是我踩的那个。

// 错误:连接借给异步线程,请求已返回,连接还占着
public void export(Long id) {
    Connection conn = dataSource.getConnection();
    CompletableFuture.runAsync(() -> {
        // 长时间查数据、写文件
        // 从头到尾没 close
    });
    // 接口秒回,业务「结束」了
}

接口返回了,业务看着结束了。可异步线程还攥着连接。一个导出任务占一条,跑得越久占得越死。

第三类,ResultSet 或游标没关。很多人记得关连接,忘了关结果集。

// 错误:ResultSet 没关,等于连接一直被占
ResultSet rs = ps.executeQuery();
while (rs.next()) { ... }
rs = null;  // 置空不等于关闭

流式查询尤其危险。结果集没读完或没关,连接就一直被占着。ORM 里的懒加载、游标,本质也是这一类。

止血和根治

止血先看能不能扛到低峰。能扛就等低峰重启。扛不住,再考虑在库里处理。

处理前先分清两条命令。KILL QUERY id 只终止这条连接正在跑的语句,连接还在。KILL CONNECTION id 才把整个会话断掉。

-- 先查,再决定杀谁
SELECT id, user, host, command, time, info
FROM information_schema.processlist
WHERE command = 'Sleep' AND time > 3600;

kill 之前一定先确认来源。还有个点容易忽略。你把数据库会话杀了,应用池那边还以为连接是好的。下次拿出来用,可能直接报 Communications link failure。所以真要杀,杀完最好配合应用重启或连接验证,别让池子继续发死连接。

根治在代码。连接的借和还,必须成对。

// 正确:try-with-resources,异常也会归还
try (Connection conn = dataSource.getConnection();
     PreparedStatement ps = conn.prepareStatement(sql);
     ResultSet rs = ps.executeQuery()) {
    while (rs.next()) { ... }
} catch (SQLException e) {
    log.error("query failed", e);
    throw new BizException(e);
}

异步任务里也一样。连接要么在任务内部借、内部还,要么别跨线程传。别让一个连接活过它该活的作用域。

再配一个泄漏探针。HikariCP 的 leak-detection-threshold,连接被借走超时没还,就打警告带调用栈。

spring.datasource.hikari:
  leak-detection-threshold: 60000  # 60 秒未归还就打日志

它只报警,不帮你关。阈值设成业务正常最长用时的两三倍,太长没意义,太短全是误报。生产上开着,成本很低。

这里有个常被误解的点。maxLifetime 不是给泄漏兜底的。它主要回收池里空闲的连接。一条被借走一直没还的连接,它管不着。所以别指望配个 maxLifetime 就能治泄漏。

加连接之前,先问一句

池子满了,第一反应是加参数。这条我踩过。加连接能把故障往后拖,但拖不掉根。

判断顺序其实很简单。先看 Threads_running,高就往慢 SQL 查;低就往泄漏查。低的时候再加池子,只是把泄漏藏得更深。下一次来得更晚,也更猛。

监控也提前定好。盯四个数:活跃连接、空闲连接、等待线程、获取超时次数。空闲长期是 0,等待开始出现,超时计数抬头,就是池子在喊救命。

连接池不是容量越大越安全。它是一笔账,借出去的每一条都得记着还。漏掉的那条,不会自己回来。

避坑清单

池子满了,别急着加连接。先分清是不够用,还是借了不还。前者扩容,后者改代码,方向反了白忙。

连接一定要在借它的那个作用域里还。别跨线程传,别塞进异步任务,别指望 GC 帮你关。ResultSet 和游标也一样。

最后一条是我自己踩的。我以前觉得连接池满就是容量问题,加参数最省事。直到一次异步导出把连接耗光,才发现池子开多大,都填不满一个看不见的无底洞。现在我看连接告警,先看 Threads_running,再看来源 IP,最后才动参数。这一步多花十分钟,少一晚救火。

写在最后

连接泄漏最难的地方,是它不喊疼。业务照常返回,接口照常 200。坏的地方在池子深处,一条一条地漏。等灯亮起来,往往已经漏了很久。

参数能调,代码得改。连接池满这件事,池子大小只是表象,借还成对才是根。你把它当成一笔账来管,它才不会在某天晚上,突然把账翻给你看。

你们的连接池监控了哪些指标?有没有被连接泄漏坑过?评论区聊聊。

我是数据库小学妹,帮你少走弯路少踩坑,咱们下篇见👋

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。