凌晨3点数据库连接池打满:我定位到一条没关 ResultSet 的定时任务
2026/6/12 0:12:47 网站建设 项目流程

搞了快一个小时,终于把连接池打满的根因找到了。记录一下,免得下次又踩。

凌晨3点,告警炸了

被告警短信炸醒的时候,我脑子还是懵的。连接池使用率 100%,新请求全部卡在获取连接的地方。数据库本身没问题,QPS 也没飙高,就是连接池被打满了。

第一反应:是不是有慢 SQL 没释放?

查了下information_schema.processlist,没有长时间挂起的查询。那问题出在应用层。

排查时间线:每一分钟在做什么

03:00告警触发,连接池使用率 100%。我登录跳板机,拉取线程 dump。

03:05jstack输出显示 18 个线程卡在HikariDataSource.getConnection(),另外 2 个是业务线程,也在等待。这确认了是连接泄漏,不是慢查询。

03:08information_schema.processlist,没有超过 5 秒的查询。排除数据库端问题。

03:12查看连接池监控面板(我们接入了 Prometheus),发现hikaricp_connections_active在 02:30 之后开始缓慢上升,直到 03:00 达到 20。时间点和定时任务完全吻合。

03:15定位到定时任务代码。看到queryOldRecords()的实现时,我意识到问题所在。

我拉了个线程 dump,发现大量线程卡在HikariPool.getConnection()的等待逻辑上。这说明连接确实被占用了,但没有归还。

看了看连接池配置:

hikari:maximum-pool-size:20connection-timeout:30000

20 个连接对于凌晨的业务量来说完全够用,除非有连接泄漏。

接下来翻代码。最近上线的只有一个定时任务:每天凌晨 2:30 跑的数据归档脚本。逻辑看起来很简单:

// 归档脚本List<Record>records=recordDao.queryOldRecords();for(Recordr:records){archiveService.archive(r);recordDao.delete(r);}

我注意到queryOldRecords返回的是个List,但底层实现是用ResultSet逐条读取的。问题可能在这里。

真相:ResultSet 没关

翻到recordDao.queryOldRecords()的实现:

publicList<Record>queryOldRecords(){Connectionconn=dataSource.getConnection();PreparedStatementstmt=conn.prepareStatement("SELECT * FROM records WHERE created_at < DATE_SUB(NOW(), INTERVAL 90 DAY)");ResultSetrs=stmt.executeQuery();List<Record>list=newArrayList<>();while(rs.next()){list.add(mapRow(rs));}returnlist;}

看到这段代码,我血压上来了。ResultSetPreparedStatementConnection,一个都没关。在HikariCP里,连接关闭其实是归还连接池。如果连接没关,池子就认为这个连接还在被占用。

更坑的是,这个定时任务跑了 2 个多小时,因为数据量大,每次循环都新建一个archiveService的内部连接,但最外层的查询连接一直没释放。20 个连接就这样被一点一点占满,直到凌晨 3 点彻底爆掉。

修复方案:try-with-resources + 连接池监控

修复很简单,用try-with-resources确保关闭:

publicList<Record>queryOldRecords(){List<Record>list=newArrayList<>();Stringsql="SELECT * FROM records WHERE created_at < DATE_SUB(NOW(), INTERVAL 90 DAY)";try(Connectionconn=dataSource.getConnection();PreparedStatementstmt=conn.prepareStatement(sql);ResultSetrs=stmt.executeQuery()){while(rs.next()){list.add(mapRow(rs));}}catch(SQLExceptione){thrownewRuntimeException("Query failed",e);}returnlist;}

但光修复不够,还得加监控。我写了个简单的连接池泄漏检测脚本:

#!/bin/bash# 连接池泄漏检测脚本# 用法:./check_pool_leak.sh <pid>PID=$1echo"=== 连接池等待线程 ==="jstack$PID|grep-A5"HikariPool.getConnection"|head-20echo"=== 活跃连接数 ==="jcmd$PIDGC.class_histogram|grep-ihikari|head-5

更靠谱的做法是在代码里加连接池指标上报:

// 添加 HikariCP 指标监控(可集成到 Prometheus)HikariPoolMXBeanpoolMXBean=hikariDataSource.getHikariPoolMXBean();intactiveConnections=poolMXBean.getActiveConnections();inttotalConnections=poolMXBean.getTotalConnections();intwaitingThreads=poolMXBean.getThreadsAwaitingConnection();if(waitingThreads>5){alert("连接池等待线程过多,可能存在泄漏");}

预防清单:定时任务上线前必查的 5 件事

这次事故之后,我整理了一个定时任务上线 checklist,每次发版前过一遍:

  1. 资源关闭检查:所有ConnectionStatementResultSetInputStream是否都在try-with-resources里?
  2. 连接池监控:是否配置了leakDetectionThreshold?是否有活跃连接数告警?
  3. 内存安全:是否一次性加载全表数据?数据量超过 1 万条就必须用流式处理。
  4. 超时配置:定时任务是否有整体超时机制?防止单任务 hung 住整个线程池。
  5. 回滚方案:如果任务执行失败,是否有幂等性保证?避免重复归档或重复通知。

把 checklist 做成代码评审的模板,每次定时任务 PR 都强制过一遍。

其实归档脚本还有一个隐患:一次性把 90 天前的数据全部加载到内存。数据量小的时候没事,量大了就是 OOM 的前奏。

更好的做法是流式处理:

publicvoidarchiveOldRecords(){Stringsql="SELECT * FROM records WHERE created_at < DATE_SUB(NOW(), INTERVAL 90 DAY)";try(Connectionconn=dataSource.getConnection();PreparedStatementstmt=conn.prepareStatement(sql,ResultSet.TYPE_FORWARD_ONLY,ResultSet.CONCUR_READ_ONLY)){stmt.setFetchSize(Integer.MIN_VALUE);// MySQL 流式读取try(ResultSetrs=stmt.executeQuery()){while(rs.next()){Recordr=mapRow(rs);archiveService.archive(r);recordDao.delete(r.getId());}}}catch(SQLExceptione){thrownewRuntimeException("Archive failed",e);}}

这样连接始终只维护一个游标,内存占用也稳定。

写在最后

说实话,这个坑看起来很蠢,但生产环境里还真不少见。特别是那种"跑完就完"的定时任务,大家往往不会注意资源关闭。

如果你也在用HikariCP,建议把leakDetectionThreshold打开:

hikari:leak-detection-threshold:60000# 60秒

这个配置会在连接被占用超过 60 秒时打印堆栈,帮你定位到具体是哪行代码没释放连接。60 秒对于业务请求来说太长了,对于定时任务可能刚好能抓出来。

另外,归档、对账、统计这类定时任务,最容易出资源泄漏。写完之后多问自己一句:连接关了没?流关了没?文件句柄关了没?

折腾了这么久,总算搞明白了。希望这篇文章能帮到你。有问题欢迎评论区交流。

说实话,这个坑看起来很蠢,但生产环境里还真不少见。特别是那种"跑完就完"的定时任务,大家往往不会注意资源关闭。

如果你也在用HikariCP,建议把leakDetectionThreshold打开:

hikari:leak-detection-threshold:60000# 60秒

这个配置会在连接被占用超过 60 秒时打印堆栈,帮你定位到具体是哪行代码没释放连接。60 秒对于业务请求来说太长了,对于定时任务可能刚好能抓出来。

另外,归档、对账、统计这类定时任务,最容易出资源泄漏。写完之后多问自己一句:连接关了没?流关了没?文件句柄关了没?

折腾了这么久,总算搞明白了。希望这篇文章能帮到你。有问题欢迎评论区交流。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询