搞了快一个小时,终于把连接池打满的根因找到了。记录一下,免得下次又踩。
凌晨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 跑的数据归档脚本。逻辑看起来很简单:
// 归档脚本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;}看到这段代码,我血压上来了。ResultSet、PreparedStatement、Connection,一个都没关。在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,每次发版前过一遍:
- 资源关闭检查:所有
Connection、Statement、ResultSet、InputStream是否都在try-with-resources里? - 连接池监控:是否配置了
leakDetectionThreshold?是否有活跃连接数告警? - 内存安全:是否一次性加载全表数据?数据量超过 1 万条就必须用流式处理。
- 超时配置:定时任务是否有整体超时机制?防止单任务 hung 住整个线程池。
- 回滚方案:如果任务执行失败,是否有幂等性保证?避免重复归档或重复通知。
把 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 秒对于业务请求来说太长了,对于定时任务可能刚好能抓出来。
另外,归档、对账、统计这类定时任务,最容易出资源泄漏。写完之后多问自己一句:连接关了没?流关了没?文件句柄关了没?
折腾了这么久,总算搞明白了。希望这篇文章能帮到你。有问题欢迎评论区交流。