大家好,我是数据库小学妹👋 我踩过的坑,你别再踩。
有回报表导出改了异步,接口秒回「任务已提交」。业务侧看着挺好,可连接池的告警开始响。我以为导出量大,先把它往上调了一截。加了还是满,业务量也没涨。
最想不通的是,很多连接挂在库里。状态是 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。坏的地方在池子深处,一条一条地漏。等灯亮起来,往往已经漏了很久。
参数能调,代码得改。连接池满这件事,池子大小只是表象,借还成对才是根。你把它当成一笔账来管,它才不会在某天晚上,突然把账翻给你看。
你们的连接池监控了哪些指标?有没有被连接泄漏坑过?评论区聊聊。
我是数据库小学妹,帮你少走弯路少踩坑,咱们下篇见👋