凌晨3点数据库连接池打满:我定位到一条没关 ResultSet 的定时任务

凌晨3点数据库连接池打满:我定位到一条没关 ResultSet 的定时任务 搞了快一个小时终于把连接池打满的根因找到了。记录一下免得下次又踩。凌晨3点告警炸了被告警短信炸醒的时候我脑子还是懵的。连接池使用率 100%新请求全部卡在获取连接的地方。数据库本身没问题QPS 也没飙高就是连接池被打满了。第一反应是不是有慢 SQL 没释放查了下information_schema.processlist没有长时间挂起的查询。那问题出在应用层。排查时间线每一分钟在做什么03:00告警触发连接池使用率 100%。我登录跳板机拉取线程 dump。03:05jstack输出显示 18 个线程卡在HikariDataSource.getConnection()另外 2 个是业务线程也在等待。这确认了是连接泄漏不是慢查询。03:08查information_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:3000020 个连接对于凌晨的业务量来说完全够用除非有连接泄漏。接下来翻代码。最近上线的只有一个定时任务每天凌晨 2:30 跑的数据归档脚本。逻辑看起来很简单// 归档脚本ListRecordrecordsrecordDao.queryOldRecords();for(Recordr:records){archiveService.archive(r);recordDao.delete(r);}我注意到queryOldRecords返回的是个List但底层实现是用ResultSet逐条读取的。问题可能在这里。真相ResultSet 没关翻到recordDao.queryOldRecords()的实现publicListRecordqueryOldRecords(){ConnectionconndataSource.getConnection();PreparedStatementstmtconn.prepareStatement(SELECT * FROM records WHERE created_at DATE_SUB(NOW(), INTERVAL 90 DAY));ResultSetrsstmt.executeQuery();ListRecordlistnewArrayList();while(rs.next()){list.add(mapRow(rs));}returnlist;}看到这段代码我血压上来了。ResultSet、PreparedStatement、Connection一个都没关。在HikariCP里连接关闭其实是归还连接池。如果连接没关池子就认为这个连接还在被占用。更坑的是这个定时任务跑了 2 个多小时因为数据量大每次循环都新建一个archiveService的内部连接但最外层的查询连接一直没释放。20 个连接就这样被一点一点占满直到凌晨 3 点彻底爆掉。修复方案try-with-resources 连接池监控修复很简单用try-with-resources确保关闭publicListRecordqueryOldRecords(){ListRecordlistnewArrayList();StringsqlSELECT * FROM records WHERE created_at DATE_SUB(NOW(), INTERVAL 90 DAY);try(ConnectionconndataSource.getConnection();PreparedStatementstmtconn.prepareStatement(sql);ResultSetrsstmt.executeQuery()){while(rs.next()){list.add(mapRow(rs));}}catch(SQLExceptione){thrownewRuntimeException(Query failed,e);}returnlist;}但光修复不够还得加监控。我写了个简单的连接池泄漏检测脚本#!/bin/bash# 连接池泄漏检测脚本# 用法./check_pool_leak.sh pidPID$1echo 连接池等待线程 jstack$PID|grep-A5HikariPool.getConnection|head-20echo 活跃连接数 jcmd$PIDGC.class_histogram|grep-ihikari|head-5更靠谱的做法是在代码里加连接池指标上报// 添加 HikariCP 指标监控可集成到 PrometheusHikariPoolMXBeanpoolMXBeanhikariDataSource.getHikariPoolMXBean();intactiveConnectionspoolMXBean.getActiveConnections();inttotalConnectionspoolMXBean.getTotalConnections();intwaitingThreadspoolMXBean.getThreadsAwaitingConnection();if(waitingThreads5){alert(连接池等待线程过多可能存在泄漏);}预防清单定时任务上线前必查的 5 件事这次事故之后我整理了一个定时任务上线 checklist每次发版前过一遍资源关闭检查所有Connection、Statement、ResultSet、InputStream是否都在try-with-resources里连接池监控是否配置了leakDetectionThreshold是否有活跃连接数告警内存安全是否一次性加载全表数据数据量超过 1 万条就必须用流式处理。超时配置定时任务是否有整体超时机制防止单任务 hung 住整个线程池。回滚方案如果任务执行失败是否有幂等性保证避免重复归档或重复通知。把 checklist 做成代码评审的模板每次定时任务 PR 都强制过一遍。其实归档脚本还有一个隐患一次性把 90 天前的数据全部加载到内存。数据量小的时候没事量大了就是 OOM 的前奏。更好的做法是流式处理publicvoidarchiveOldRecords(){StringsqlSELECT * FROM records WHERE created_at DATE_SUB(NOW(), INTERVAL 90 DAY);try(ConnectionconndataSource.getConnection();PreparedStatementstmtconn.prepareStatement(sql,ResultSet.TYPE_FORWARD_ONLY,ResultSet.CONCUR_READ_ONLY)){stmt.setFetchSize(Integer.MIN_VALUE);// MySQL 流式读取try(ResultSetrsstmt.executeQuery()){while(rs.next()){RecordrmapRow(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 秒对于业务请求来说太长了对于定时任务可能刚好能抓出来。另外归档、对账、统计这类定时任务最容易出资源泄漏。写完之后多问自己一句连接关了没流关了没文件句柄关了没折腾了这么久总算搞明白了。希望这篇文章能帮到你。有问题欢迎评论区交流。