暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

如何定位 Druid & HikariCP 连接池的连接泄漏问题?

原创 陈臣 2025-03-31
40

摘要:在数据库连接池的使用中,连接泄漏是一个常见且严重的问题。本文通过分析一个实际的案例,探讨了连接泄漏的危害、产生原因以及如何在 Druid 和 HikariCP 这两种常见的连接池中定位和解决连接泄漏问题。

背景

最近碰到一个 case,一个 Java 应用无法获取新的数据库连接,日志中出现了以下错误:

com.alibaba.druid.pool.GetConnectionTimeoutException: wait millis 5001, active 20, maxActive 20, creating 0 at com.alibaba.druid.pool.DruidDataSource.getConnectionInternal(DruidDataSource.java:1894) at com.alibaba.druid.pool.DruidDataSource.getConnectionDirect(DruidDataSource.java:1502) at com.alibaba.druid.pool.DruidDataSource.getConnection(DruidDataSource.java:1482) at com.alibaba.druid.pool.DruidDataSource.getConnection(DruidDataSource.java:1463)
复制

active 等于 maxActive,说明连接池中的连接已耗尽。

分析报错时间段的数据库连接情况,发现数据库的连接数(Threads_connected)显著增加,但活跃线程数(Threads_running)较低且平稳。活跃线程数低且平稳意味着没有慢查询占用连接。但连接数增加明显,说明连接未被及时释放回连接池。

对于这种在一定时间内没有进行任何操作,但又未及时归还到连接池的连接,其实有个专用名词,即泄漏连接(Leaked Connection)。

下面,我们聊聊泄漏连接的相关问题,包括:

  1. 泄漏连接的危害。
  2. 泄漏连接的产生原因。
  3. Druid 中如何定位泄漏连接。
  4. HikariCP 中如何定位泄漏连接。

泄漏连接的危害

泄漏连接可能引发以下问题:

  1. 连接池耗尽:泄漏的连接会持续占用连接池中的资源,导致可用连接逐渐减少,最终耗尽连接池。

  2. 应用性能下降:当连接池中的连接被耗尽时,新的数据库操作无法获取连接,导致请求阻塞或失败,这可能导致应用程序无法正常运行。

  3. 数据库资源浪费:泄漏的连接会占用数据库的连接资源,可能导致数据库的连接数达到上限。

  4. 连接失效风险:长时间未释放的连接无法通过连接池的 Keep-Alive 机制保持活跃,更容易因空闲超时被 MySQL 服务端或中间件关闭。

    当使用这些已关闭的连接执行数据库操作时,会触发经典的 “Communications link failure. The last packet successfully received from the server was xxx milliseconds ago.” 错误。

泄漏连接的产生原因

泄漏连接通常由以下原因导致:

1.长事务或长连接。

事务长时间未提交或连接长时间未释放。

2.未关闭连接。

在使用完连接后,未调用close()方法将连接归还到连接池。如,

Connection conn = dataSource.getConnection(); // 执行数据库操作 // 忘记调用 conn.close();
复制

3.异常未处理。

在数据库操作过程中发生异常,导致连接未正常关闭。如,

Connection conn = null; try { conn = dataSource.getConnection(); // 执行数据库操作 throw new RuntimeException("模拟异常"); } catch (SQLException e) { e.printStackTrace(); } finally { if (conn != null) { try { conn.close(); // 异常发生后,可能不会执行到此处 } catch (SQLException e) { e.printStackTrace(); } } }
复制

Druid 中如何定位泄漏连接

在 Druid 连接池中,可以通过以下参数开启未归还连接的检测:

  • removeAbandoned:是否回收超时未归还的连接,默认值为 false,表示不回收。
  • removeAbandonedTimeoutMillis:未归还连接的超时时间(单位:毫秒)。默认值为 300000(即 300 秒)。
  • logAbandoned:是否将超时未归还的连接信息打印到日志中。默认值为 false,表示不打印。

需要注意的是,logAbandoned 仅在 removeAbandoned 为 true 时生效。也就是说,Druid 连接池不支持仅打印,但不回收超时未归还连接的功能。

实现细节

在从连接池获取连接时,如果removeAbandoned为 true,则会记录连接的堆栈信息和创建时间,用于检测未归还连接。

public DruidPooledConnection getConnectionDirect(long maxWaitMillis) throws SQLException { ... for (; ; ) { DruidPooledConnection poolableConnection; try { poolableConnection = getConnectionInternal(maxWaitMillis); } catch (GetConnectionTimeoutException ex) { ... } ... if (removeAbandoned) { // 记录堆栈信息,方便调试,找出未及时关闭连接的代码位置 StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace(); poolableConnection.connectStackTrace = stackTrace; // 设置连接的connectedTimeNano为当前时间 poolableConnection.setConnectedTimeNano(); poolableConnection.traceEnable = true; // 将连接加入活跃连接列表,用于后续的未归还连接检测。 activeConnectionLock.lock(); try { activeConnections.put(poolableConnection, PRESENT); } finally { activeConnectionLock.unlock(); } } ... return poolableConnection; } }
复制

什么时候会检测连接是否超时呢?

这个实际上是在DestroyConnectionThread的周期任务中进行的,在上一篇文章中,我们提到过DestroyConnectionThread按照一定的时间间隔(由 timeBetweenEvictionRunsMillis 参数决定,默认为 60秒)调用shrink(true, keepAlive)方法,销毁连接池中的过期连接。其实,除了 shrink 方法,它还会调用removeAbandoned()来关闭那些超时未归还的连接。

public class DestroyTask implements Runnable { public DestroyTask() { } @Override public void run() { shrink(true, keepAlive); if (isRemoveAbandoned()) { removeAbandoned(); } } }
复制

下面,我们看看removeAbandoned()具体的实现细节。

public int removeAbandoned() { int removeCount = 0; // 如果当前没有活跃连接(activeConnections 为空),则直接返回 if (activeConnections.size() == 0) { return removeCount; } long currrentNanos = System.nanoTime(); List<DruidPooledConnection> abandonedList = new ArrayList<DruidPooledConnection>(); activeConnectionLock.lock(); try { Iterator<DruidPooledConnection> iter = activeConnections.keySet().iterator(); // 遍历活跃连接 for (; iter.hasNext(); ) { DruidPooledConnection pooledConnection = iter.next(); // 如果连接正在运行(isRunning()),则跳过 if (pooledConnection.isRunning()) { continue; } // 计算连接的使用时间(timeMillis),即当前时间减去连接的借出时间。 long timeMillis = (currrentNanos - pooledConnection.getConnectedTimeNano()) / (1000 * 1000); // 如果连接的使用时间超过了 removeAbandonedTimeoutMillis,则将其从活跃连接列表中移除,并加入 abandonedList if (timeMillis >= removeAbandonedTimeoutMillis) { iter.remove(); pooledConnection.setTraceEnable(false); abandonedList.add(pooledConnection); } } } finally { activeConnectionLock.unlock(); } // 遍历 abandonedList,对每个未归还的连接调用 JdbcUtils.close() 关闭连接 if (abandonedList.size() > 0) { for (DruidPooledConnection pooledConnection : abandonedList) { ... JdbcUtils.close(pooledConnection); pooledConnection.abandond(); removeAbandonedCount++; removeCount++; // 如果 logAbandoned 为 true,则记录未归还连接的详细信息 if (isLogAbandoned()) { StringBuilder buf = new StringBuilder(); buf.append("abandon connection, owner thread: "); buf.append(pooledConnection.getOwnerThread().getName()); buf.append(", connected at : "); ... } LOG.error(buf.toString()); } } } return removeCount; }
复制

该方法的处理流程如下:

  1. 遍历当前活跃连接(activeConnections),检查每个连接的使用时间。连接的使用时间等于当前时间减去连接的借出时间(即borrow时刻的时间戳)。
  2. 如果某个连接的使用时间超过了removeAbandonedTimeoutMillis,则将其加入 abandonedList。
  3. 遍历 abandonedList,关闭这些未归还的连接。如果logAbandoned为 true,则会在日志中打印未归还连接的详细信息。通过分析日志,可以定位泄漏连接的代码位置。

HikariCP 中如何定位泄漏连接

在 HikariCP 连接池中,可以通过以下参数开启连接泄漏检测:

  • leakDetectionThreshold:连接泄漏检测阈值(单位:毫秒)。如果一个连接在从连接池获取后超过指定时间未被关闭,则认为是泄漏连接。默认为 0,表示禁用连接泄漏检测。最小可设置为 2000(2 秒)。

当出现泄漏连接时,HikariCP 日志中会打印以下信息

Connection leak detection triggered for com.mysql.cj.jdbc.ConnectionImpl@5dd31d98 on thread com.example.HikariCPTest.main(), stack trace follows java.lang.Exception: Apparent connection leak detected at com.zaxxer.hikari.HikariDataSource.getConnection(HikariDataSource.java:100) at com.example.HikariCPTest.main(HikariCPTest.java:27) ...
复制

实现细节

在从连接池获取连接后,系统会调用leakTaskFactory.schedule(poolEntry)启动一个 ProxyLeakTask 定时任务。该任务将在leakDetectionThreshold毫秒后触发run()方法,用于检测并打印连接泄漏信息。

public Connection getConnection(final long hardTimeout) throws SQLException { suspendResumeLock.acquire(); final var startTime = currentTime(); try { var timeout = hardTimeout; do { // 从连接池中获取空闲连接 var poolEntry = connectionBag.borrow(timeout, MILLISECONDS); if (poolEntry == null) { break; // We timed out... break and throw exception } final var now = currentTime(); // 若连接已被标记为驱逐 (evict) 或检测到无效,则关闭该连接 if (poolEntry.isMarkedEvicted() || (elapsedMillis(poolEntry.lastAccessed, now) > aliveBypassWindowMs && isConnectionDead(poolEntry.connection))) { closeConnection(poolEntry, poolEntry.isMarkedEvicted() ? EVICTED_CONNECTION_MESSAGE : DEAD_CONNECTION_MESSAGE); timeout = hardTimeout - elapsedMillis(startTime); } else { ... // 返回一个代理连接,并启动连接泄漏检测任务 return poolEntry.createProxyConnection(leakTaskFactory.schedule(poolEntry)); } } while (timeout > 0L); ... }
复制

如果连接在leakDetectionThreshold时间内被归还(即调用了close()方法),系统会调用leakTask.cancel()取消定时任务,从而避免触发run()方法。

如果连接超时未归还,系统将执行 run() 方法,打印连接泄漏信息。

以下是 ProxyLeakTask 的具体实现。

class ProxyLeakTask implements Runnable { ... ProxyLeakTask(final PoolEntry poolEntry) { this.exception = new Exception("Apparent connection leak detected"); this.threadName = Thread.currentThread().getName(); this.connectionName = poolEntry.connection.toString(); } ... void schedule(ScheduledExecutorService executorService, long leakDetectionThreshold) { scheduledFuture = executorService.schedule(this, leakDetectionThreshold, TimeUnit.MILLISECONDS); } /** {@inheritDoc} */ @Override public void run() { isLeaked = true; final var stackTrace = exception.getStackTrace(); final var trace = new StackTraceElement[stackTrace.length - 5]; System.arraycopy(stackTrace, 5, trace, 0, trace.length); exception.setStackTrace(trace); LOGGER.warn("Connection leak detection triggered for {} on thread {}, stack trace follows", connectionName, threadName, exception); } void cancel() { scheduledFuture.cancel(false); if (isLeaked) { LOGGER.info("Previously reported leaked connection {} on thread {} was returned to the pool (unleaked)", connectionName, threadName); } } }
复制

总结

泄漏连接是指在使用完数据库连接后未及时归还连接池的连接。泄漏连接的主要危害包括连接池耗尽、应用性能下降、数据库资源浪费以及潜在的连接失效风险。泄漏连接的产生原因通常包括未正确关闭连接、未处理异常或长事务等。

Druid 和 HikariCP 两大常用连接池提供了相应的泄漏连接检测机制。Druid 通过DestroyConnectionThread周期性检测未归还的连接,并在超时后关闭这些连接。如果logAbandoned为 true,还会打印未归还连接的详细信息。HikariCP 则通过leakDetectionThreshold参数开启连接泄漏检测。当连接在指定时间内未被归还时,HikariCP 会触发ProxyLeakTask,打印连接泄漏信息。

在开发和测试环境中,建议开启连接泄漏检测功能,以便尽早发现问题并进行修复。

「喜欢这篇文章,您的关注和赞赏是给作者最好的鼓励」
关注作者
【版权声明】本文为墨天轮用户原创内容,转载时必须标注文章的来源(墨天轮),文章链接,文章作者等基本信息,否则作者和墨天轮有权追究责任。如果您发现墨天轮中有涉嫌抄袭或者侵权的内容,欢迎发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论