数据库连接池泄漏的典型场景是:应用运行一段时间后突然所有请求卡死,日志里开始刷“Connection is not available, request timed out after 30000ms”。这不是连接池配置太小的问题,而是连接被借出去后没有归还,池子被活活掏空。

排查这种故障最忌讳的就是重启。一旦重启,所有连接瞬间回收,现场被破坏,下次出问题你还是不知道根因在哪。正确的做法是保留现场,从外部和内部两个维度同时下手,先定位是哪里在泄漏,再反查代码逻辑。

第一步:确认是否真的发生了连接池泄漏

很多团队一看到“获取连接超时”就喊泄漏,其实大量情况只是并发突增或者慢SQL把连接占住了。判断真泄漏的核心指标是:活跃连接数持续单调递增,且没有回落的趋势。

以HikariCP为例,直接访问/actuator/metrics/hikaricp.connections.active,观察这个值是否在业务低峰期仍然维持高位。如果凌晨三点没请求了,活跃连接还顶着池子最大值不放,那基本就是泄漏。另一个关键信号是hikaricp.connections.pending,这个值一旦大于零,说明有线程正在排队等连接,而池子里的连接迟迟不释放。

如果是Druid数据源,直接登录监控页面看“活跃连接数”曲线。泄漏的曲线特征非常明显:每次GC或业务低谷时,连接数只升不降,呈阶梯状上涨,直到触及池子上限后平台化,然后所有新请求开始报错。

第二步:快速止血与锁定嫌疑线程

线上系统已经卡死的情况下,首要目标是恢复服务,同时保留证据。执行以下操作:

1. 通过JMX或数据源自带的监控接口,强行关闭一部分长时间占用的连接。Druid可以在监控页直接点“关闭连接”,HikariCP没有直接关闭单个连接的API,但可以通过HikariMXBean的softEvictConnections方法触发驱逐。这一步能让部分请求先活过来。

2. 立刻抓取线程堆栈,连续抓三次,间隔5秒。命令是jstack -l <pid> > thread_dump_1.txt。三次堆栈对比着看,找出那些三次都停在同一个位置的线程,这些线程大概率就是持有连接不释放的元凶。

3. 在堆栈中搜索数据源相关的关键字,比如“getConnection”“borrowConnection”“HikariPool”“DruidDataSource”。找到那些处于RUNNABLE或WAITING状态,且调用栈顶不是等待获取连接,而是卡在业务逻辑中间的线程。这些线程已经拿到了连接,但没走到finally块的归还逻辑。

第三步:从代码层面追溯泄漏点

拿到线程堆栈后,排查方向非常明确。绝大多数连接泄漏都逃不出下面几种模式:

模式一:连接在事务中途被遗弃

最常见的情况是开启了事务,但中间某个分支抛了异常,导致commit或rollback没执行,连接一直挂着。看堆栈里有没有类似这样的调用链:

at com.mysql.cj.jdbc.ConnectionImpl.setAutoCommit
at org.springframework.jdbc.datasource.DataSourceTransactionManager.doBegin
at org.springframework.transaction.support.AbstractPlatformTransactionManager.getTransaction

如果线程卡在业务代码的某一行,而它的上层调用栈里有事务切面的影子,那说明事务已经开启,连接已经被该线程独占。接下来查这个业务方法是否在try块外声明了@Transactional,或者方法内部调用了自身类的另一个@Transactional方法导致代理失效,事务没回滚。

模式二:ResultSet或Statement未关闭

很多人以为关闭了Connection就万事大吉,但在连接池模式下,Connection.close()只是把连接归还给池子。如果Statement或ResultSet没有显式关闭,部分JDBC驱动实现会在Connection归还时自动清理,但某些版本或特定配置下不会。这些未关闭的资源会持有游标,导致连接在池子里被标记为“正在使用”,实际已经泄漏。

排查方法是在堆栈里搜“ResultSetImpl”“StatementImpl”,看是否有大量线程停留。如果发现某个线程的堆栈里有“com.mysql.cj.jdbc.result.ResultSetImpl.next()”,但线程状态是WAITING,说明这个ResultSet可能正在等待数据库返回数据,而对应的连接已经超时或被数据库端杀掉,客户端却感知不到。

模式三:流式查询忘记消费完毕

使用了setFetchSize(Integer.MIN_VALUE)开启流式查询后,连接会一直保持与数据库的通信,直到ResultSet被完全消费或关闭。如果业务代码在处理过程中提前return或抛异常,流没有读完,连接就永远不会释放。这种泄漏在堆栈上特征明显:线程卡在ResultSet.next()或者SocketInputStream.read()上,调用链深处是MysqlIO.unpackBinaryResultSetRow。

模式四:异步线程池里的连接逃逸

在CompletableFuture或@Async方法中获取的连接,如果异步任务没有设置超时,或者超时后线程被中断但连接归还代码在catch块里没有正确处理InterruptedException,连接就会随着线程一起被扔回线程池。下次这个线程被复用执行其他任务时,它身上可能还挂着一个旧的数据库连接,而新任务又去借一个新连接,旧连接就彻底丢失了。

排查这种场景需要结合线程名。Spring的@Async默认使用SimpleAsyncTaskExecutor,线程名里带“SimpleAsyncTaskExecutor-”前缀。如果堆栈里这些线程都卡在数据库操作上,且线程数量远超连接池大小,基本可以断定是异步任务导致的连接逃逸。

第四步:利用连接池自身的泄漏检测机制

HikariCP提供了leakDetectionThreshold参数,单位毫秒。设置为2000后,任何连接被借出超过2秒未归还,HikariCP会在日志里打印警告,包含借出时的线程堆栈。这个堆栈是定位泄漏点的黄金证据,因为它记录了是谁、在什么时间、从哪一行代码借走了这个连接。

配置方式:

spring.datasource.hikari.leak-detection-threshold=2000

Druid的泄漏检测更直观,配置removeAbandoned=true,设置removeAbandonedTimeout=180,意思是连接借出超过180秒未归还,Druid自动回收并在日志打印借出时的堆栈。同时开启logAbandoned=true,回收时会记录详细日志。

spring.datasource.druid.remove-abandoned=true
spring.datasource.druid.remove-abandoned-timeout=180
spring.datasource.druid.log-abandoned=true

注意,removeAbandoned在生产环境要谨慎开启。如果业务中有合法的长时间事务,比如批量处理或报表导出,这个参数会误杀正常连接,导致数据不一致。建议先在预发环境验证,或者只开启logAbandoned而不开启removeAbandoned,只记录不回收。

第五步:数据库端的辅助排查

应用端找不到线索时,直接登录数据库服务器执行show full processlist,观察Time字段很大的连接。重点关注Sleep状态但Time超过几百秒的连接,这些很可能是应用端已经认为关闭了,但数据库端还保持着TCP连接。

更有效的方式是查询information_schema.processlist,按Host和Time排序:

SELECT id, user, host, db, command, time, state, info 
FROM information_schema.processlist 
WHERE command != 'Sleep' 
ORDER BY time DESC;

如果发现大量Query状态且Time很长的连接,而应用日志里没有对应的慢SQL记录,说明这些连接在等待锁或者被数据库端的某个操作阻塞了。这时候连接在应用端是活跃状态,但实际没有执行任何有效工作,属于“假性泄漏”。排查方向要转向数据库锁竞争或网络层面的问题。

另外检查数据库的wait_timeout和interactive_timeout参数。如果这两个值设置得比连接池的maxLifetime小,数据库会主动断开空闲连接,而连接池不知道,下次取出来用就会报CommunicationsException。虽然这不属于泄漏,但表现出来的症状和泄漏几乎一样:获取连接正常,一执行SQL就报错,然后连接被池子剔除,活跃连接数看起来很高但实际很多是死连接。

第六步:建立长效机制防止再次泄漏

排查出根因并修复后,必须加上防御措施,否则下次换个人写代码还会踩同样的坑。

1. 代码层面强制使用try-with-resources。任何获取Connection、Statement、ResultSet的地方,都用try-with-resources包裹。这是JDK7就提供的语法,但大量老项目还在手动close。

2. 在Code Review阶段重点检查@Transactional方法内部是否有try-catch吞掉了异常,是否有提前return,是否有调用异步方法。事务边界必须清晰,事务内不要做RPC调用、不要做文件IO、不要做消息队列发送,这些操作耗时长且失败率高,是连接泄漏的重灾区。

3. 统一配置连接池参数。maxLifetime必须比数据库的wait_timeout短2到3分钟,idleTimeout设置合理值让空闲连接及时回收,minimumIdle根据业务低谷期的实际需求设置,不要盲目调大。泄漏检测阈值在预发和灰度环境保持开启,生产环境视性能影响决定是否打开。

4. 建立连接池监控告警。活跃连接数达到池子最大值的80%时触发预警,pending连接数大于0时触发紧急告警。这两个指标比CPU和内存更能提前发现连接池问题。Druid的监控数据可以接入Prometheus,HikariCP的metrics也可以通过Micrometer暴露出去,关键是有人看、有人响应。

5. 定期进行故障演练。在预发环境故意写一段不释放连接的代码部署上去,看监控告警是否及时触发,看团队能否在5分钟内定位到具体代码行。这种演练做过一次之后,团队对连接池的理解会上升一个层次,再遇到线上问题也不会手忙脚乱。

连接池泄漏本质上是资源管理问题,不是配置问题。堆栈不会说谎,线程卡在哪里,连接就泄漏在哪里。排查时保持耐心,顺着堆栈一层层剥开,最终一定会定位到那一行没有执行close的代码。