连接池满了、业务早结束的3个高发点
大家好,我是数据库小学妹👋 我踩过的坑,你别再踩。 有回报表导出改了异步,接口秒回「任务已提交」。业务侧看着挺好,可连接池的告警开始响。我以为导出量大,先把它往上调了一截。加了还是满,业务量也没涨。 最想不通的是,很多连接挂在库里。状态是 Sleep,时间越来越长。业务说早就结束了,连接却没回来。后来才想明白,池子满不一定是池子小,也可能是有人借了没还。 ## 先分清:不够用,还是不归还 连接池满,只有两种可能。一种是真不够用。并发上来,或者慢查询占着连接不放。这时连接都在跑,`Threads_running` 也高。另一种是借了不还。连接被拿走,代码没走到归还那一步。业务逻辑早结束了,连接还在池外漂着。这时 `Threads_connected` 高,`Threads_running` 却很低。 两种现象长得像,处置完全相反。不够用要扩容或优化 SQL。不归还,加多少池子都只是拖时间。 | 信号 | 不够用(容量/慢 SQL) | 借了不还(泄漏) | | --- | --- | --- | | `Threads_connected` | 高 | 高 | | `Threads_running` | 也高 | 低,大量 Sleep | | 连接空闲时间 | 短 | 越来越长 | | 加连接池 | 有效 | 短暂缓解,很快又满 | | 重启应用 | 会好,根因仍在 SQL 或流量 | 会好,但复发的时点没变 | ## 连接池里漏的到底是什么 这里得先掰开一个概念。连接池里的「连接」,和数据库里的「会话」,不是一回事。 应用 `getConnection()`,拿到的是池里的一个对象。它背后握着一条真实的数据库会话。用完 `close()`,对象被收回,会话留着复用。平时这两步是配对的。泄漏就卡在这一步。池对象被借走,`close()` 没执行。数据库那边一条会话一直开着,池这边这条也一直算「借出中」。两头都不释放。 所以加池子大小只是加了分母。泄漏还在,只是从五十条漏到一百条,满了的时间往后挪。这也解释了为什么重启管用。重启把进程里的池对象清空,数据库会话跟着断。可代码不改,过一阵又漏回来。 ## 怎么确认是泄漏 先看两个数。 ```sql SHOW STATUS LIKE 'Threads_connected'; SHOW STATUS LIKE 'Threads_running'; ``` 前者是已建立连接数,后者是正在执行 SQL 的连接数。前者高、后者低,差值一大,基本就是泄漏。两个都高,先往慢查询和突发流量查。 接着看连接都挂在哪。 ```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 跑了多久。两个语义别混,一个是占着不用,一个是正在用。 光看状态还不够,得看那会话到底在干什么。 ```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。往往就是泄漏前借它的那次操作,直接指向问题代码。 有长事务挂着,连接也回不去。 ```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,还能直接下钻到会话级别看连接到底卡在哪一步,不用自己拼多个系统表。 ## 三个高发点 第一类,异常分支没归还。最常见。 ```java // 错误:异常抛出时,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 没写,异常路径更没有 ``` 正常路径忘了关,异常路径更不会关。一次两次看不出来,累积起来就是泄漏。 第二类,连接被交给异步线程。这正是我踩的那个。 ```java // 错误:连接借给异步线程,请求已返回,连接还占着 public void export(Long id) { Connection conn = dataSource.getConnection(); CompletableFuture.runAsync(() -> { // 长时间查数据、写文件 // 从头到尾没 close }); // 接口秒回,业务「结束」了 } ``` 接口返回了,业务看着结束了。可异步线程还攥着连接。一个导出任务占一条,跑得越久占得越死。 第三类,ResultSet 或游标没关。很多人记得关连接,忘了关结果集。 ```java // 错误:ResultSet 没关,等于连接一直被占 ResultSet rs = ps.executeQuery(); while (rs.next()) { ... } rs = null; // 置空不等于关闭 ``` 流式查询尤其危险。结果集没读完或没关,连接就一直被占着。ORM 里的懒加载、游标,本质也是这一类。 ## 止血和根治 止血先看能不能扛到低峰。能扛就等低峰重启。扛不住,再考虑在库里处理。 处理前先分清两条命令。`KILL QUERY id` 只终止这条连接正在跑的语句,连接还在。`KILL CONNECTION id` 才把整个会话断掉。 ```sql -- 先查,再决定杀谁 SELECT id, user, host, command, time, info FROM information_schema.processlist WHERE command = 'Sleep' AND time > 3600; ``` kill 之前一定先确认来源。还有个点容易忽略。你把数据库会话杀了,应用池那边还以为连接是好的。下次拿出来用,可能直接报 `Communications link failure`。所以真要杀,杀完最好配合应用重启或连接验证,别让池子继续发死连接。 根治在代码。连接的借和还,必须成对。 ```java // 正确: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`,连接被借走超时没还,就打警告带调用栈。 ```yaml spring.datasource.hikari: leak-detection-threshold: 60000 # 60 秒未归还就打日志 ``` 它只报警,不帮你关。阈值设成业务正常最长用时的两三倍,太长没意义,太短全是误报。生产上开着,成本很低。 这里有个常被误解的点。`maxLifetime` 不是给泄漏兜底的。它主要回收池里空闲的连接。一条被借走一直没还的连接,它管不着。所以别指望配个 `maxLifetime` 就能治泄漏。 ## 加连接之前,先问一句 池子满了,第一反应是加参数。这条我踩过。加连接能把故障往后拖,但拖不掉根。 判断顺序其实很简单。先看 `Threads_running`,高就往慢 SQL 查;低就往泄漏查。低的时候再加池子,只是把泄漏藏得更深。下一次来得更晚,也更猛。 监控也提前定好。盯四个数:活跃连接、空闲连接、等待线程、获取超时次数。空闲长期是 0,等待开始出现,超时计数抬头,就是池子在喊救命。 连接池不是容量越大越安全。它是一笔账,借出去的每一条都得记着还。漏掉的那条,不会自己回来。 ## 避坑清单 池子满了,别急着加连接。先分清是不够用,还是借了不还。前者扩容,后者改代码,方向反了白忙。 连接一定要在借它的那个作用域里还。别跨线程传,别塞进异步任务,别指望 GC 帮你关。ResultSet 和游标也一样。 最后一条是我自己踩的。我以前觉得连接池满就是容量问题,加参数最省事。直到一次异步导出把连接耗光,才发现池子开多大,都填不满一个看不见的无底洞。现在我看连接告警,先看 `Threads_running`,再看来源 IP,最后才动参数。这一步多花十分钟,少一晚救火。 ## 写在最后 连接泄漏最难的地方,是它不喊疼。业务照常返回,接口照常 200。坏的地方在池子深处,一条一条地漏。等灯亮起来,往往已经漏了很久。 参数能调,代码得改。连接池满这件事,池子大小只是表象,借还成对才是根。你把它当成一笔账来管,它才不会在某天晚上,突然把账翻给你看。 你们的连接池监控了哪些指标?有没有被连接泄漏坑过?评论区聊聊。 我是数据库小学妹,帮你少走弯路少踩坑,咱们下篇见👋