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

深入解析 Druid 连接池:连接有效性检测与 Keep-Alive 机制

原创 陈臣 2025-03-17
600

背景

在 Java 程序中,下面是一个经常会碰到的错误。

Caused by: com.mysql.cj.exceptions.CJCommunicationsException: Communications link failure The last packet successfully received from the server was 30,027 milliseconds ago. The last packet sent successfully to the server was 30,028 milliseconds ago.

该错误通常是由于 MySQL 连接意外断开导致的,常见原因包括:

  1. 客户端连接池(如 HikariCP、Druid)配置不当,包括:

    • 空闲连接超时时间超过 MySQL wait_timeout(默认是 28800 秒,即 8 小时),导致连接被 MySQL 服务端关闭。
    • 未配置适当的 Keep-Alive 机制,导致连接长时间未使用而被 MySQL 服务器关闭。
    • 未进行连接有效性检查,可能导致客户端获取到失效连接。
  2. 连接未及时释放。

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

  3. 中间层组件的超时限制。

    如果客户端与 MySQL 之间存在代理(如 ProxySQL)或负载均衡器(LB),这些组件可能会有独立的空闲连接超时设置,导致连接被提前断开。

  4. 网络问题,包括高延迟、丢包或短暂网络中断都会影响数据库连接的稳定性。

  5. 连接被 MySQL 服务器主动断开,如 DBA 手动执行KILL操作终止连接。

本文将深入解析 Druid 连接池的连接有效性检测机制,重点探讨以下内容:

  1. Druid 在哪些情况下会检查连接是否可用?
  2. Druid 如何保持连接的活跃状态(Keep-Alive 机制)?
  3. Druid 连接池中常见参数的具体含义及其作用。
  4. 为什么 MySQL 的 general log 看不到validationQuery定义的检测语句执行?

希望通过本篇分析,帮助大家更深入理解 Druid 连接池的运行机制。

什么场景下会检测连接的有效性

Druid 连接池在以下四种场景下会检测连接的有效性:

  1. 申请连接。
  2. 归还连接。
  3. 创建新的物理连接。
  4. 定期检测。

下面我们看看这四种场景的具体实现逻辑。

1. 申请连接

当应用从连接池申请空闲连接时,会检查连接的有效性,与之相关的参数有两个:testOnBorrow 和 testWhileIdle。

申请连接是在getConnectionDirect方法中实现的,下面我们看看该方法的具体实现细节。

public DruidPooledConnection getConnectionDirect(long maxWaitMillis) throws SQLException { int notFullTimeoutRetryCnt = 0; for (; ; ) { DruidPooledConnection poolableConnection; try { // 从连接池中获取空闲连接 poolableConnection = getConnectionInternal(maxWaitMillis); } catch (GetConnectionTimeoutException ex) { ... } // 如果testOnBorrow为true,则会调用testConnectionInternal检测连接的有效性 if (testOnBorrow) { boolean validated = testConnectionInternal(poolableConnection.holder, poolableConnection.conn); if (!validated) { if (LOG.isDebugEnabled()) { LOG.debug("skip not validated connection."); } // 如果连接无效,则会调用discardConnection丢弃该连接,并继续从连接池中获取新的空闲连接。 discardConnection(poolableConnection.holder); continue; } } else { ... // 如果testOnBorrow不为true,且testWhileIdle为true,则判断连接的空闲时间是否超过timeBetweenEvictionRunsMillis,如果超过,也会调用testConnectionInternal检测连接的有效性 if (testWhileIdle) { final DruidConnectionHolder holder = poolableConnection.holder; long currentTimeMillis = System.currentTimeMillis(); long lastActiveTimeMillis = holder.lastActiveTimeMillis; ... long idleMillis = currentTimeMillis - lastActiveTimeMillis; if (idleMillis >= timeBetweenEvictionRunsMillis || idleMillis < 0 // unexcepted branch ) { boolean validated = testConnectionInternal(poolableConnection.holder, poolableConnection.conn); if (!validated) { if (LOG.isDebugEnabled()) { LOG.debug("skip not validated connection."); } // 如果连接无效,则会调用discardConnection丢弃该连接,并继续从连接池中获取新的空闲连接。 discardConnection(poolableConnection.holder); continue; } } } } ... return poolableConnection; } }

该方法的实现逻辑如下:

  1. 首先从连接池中获取一个空闲连接。
  2. 如果testOnBorrow为 true,则调用 testConnectionInternal 检测连接的有效性。若连接有效,则直接返回;若无效,则丢弃该连接并重新获取新的空闲连接。
  3. 如果testOnBorrow为 false 且testWhileIdle为 true,则判断连接的空闲时间是否超过 timeBetweenEvictionRunsMillis。若超过,则调用 testConnectionInternal 进行检测;若未超过或检测通过,则直接返回连接。

testOnBorrow、testWhileIdle、timeBetweenEvictionRunsMillis 这三个参数的默认值分别为 false、true 和 60000(即 60 秒)。

这就意味着,在默认配置下,当从连接池申请一个连接时,如果该连接空闲时间超过 60 秒,系统会对该连接的有效性进行检查。这样的逻辑适用于大多数场景,因为在大多数情况下,连接在 60 秒内被中断的概率较小。

如果应用对连接的可用性要求极高(例如金融、支付等场景),可以考虑将 testOnBorrow 设置为 true,以确保每次获取的连接都是可用的。但需要注意,这会有一定的性能开销。

2. 归还连接

当应用调用connection.close()关闭连接时,连接并不会被立即销毁,而是被归还到连接池中,以便后续复用。

如果 testOnReturn(默认为 false) 为 true,会在连接归还时验证其有效性,确保不会将无效连接放回给连接池。

// DruidDataSource.java protected void recycle(DruidPooledConnection pooledConnection) throws SQLException { final DruidConnectionHolder holder = pooledConnection.holder; ... if (testOnReturn) { boolean validated = testConnectionInternal(holder, physicalConnection); if (!validated) { JdbcUtils.close(physicalConnection); ... } } ... }

3. 创建新的物理连接

创建新的物理连接是在createPhysicalConnection方法中实现的。

public PhysicalConnectionInfo createPhysicalConnection() throws SQLException { String url = this.getUrl(); Properties connectProperties = getConnectProperties(); ... try { // 这里会调用驱动的 connect 方法来建立连接 conn = createPhysicalConnection(url, physicalConnectProperties); connectedNanos = System.nanoTime(); if (conn == null) { throw new SQLException("connect error, url " + url + ", driverClass " + this.driverClass); } ... if (!initSqls(conn, variables, globalVariables)) { validateConnection(conn); } ... } ... return new PhysicalConnectionInfo(conn, connectStartNanos, connectedNanos, initedNanos, validatedNanos, variables, globalVariables); }

在连接建立后,如果initSqls(conn, variables, globalVariables)为 false,则会调用validateConnection来验证连接的有效性。

以下是 initSqls(conn, variables, globalVariables) 为 false 需要满足的条件:

  1. connectionInitSqls 为空(默认值)。connectionInitSqls 常用来设置一些连接初始化语句,如set NAMES 'utf8mb4'
  2. initVariants 为 false(默认值)。如果该参数为 true,则会执行 show variables 获取连接的会话变量。
  3. initGlobalVariants 为 false(默认值)。如果该参数为 true,则会执行 show global variables 获取全局变量。

4. 定期检测

Druid 在初始化连接池时,会启动一个后台守护线程 (DestroyConnectionThread),用于定期销毁连接池中的过期连接。

该线程按照一定的时间间隔(由 timeBetweenEvictionRunsMillis 参数决定,默认为 60秒)调用shrink(true, keepAlive)方法,执行具体的连接销毁操作。

下面,我们看看该方法的具体实现细节。

public void shrink(boolean checkTime, boolean keepAlive) { ... boolean needFill = false; // 是否需要填充连接池中的空闲连接 int evictCount = 0; // 需要销毁的连接数量 int keepAliveCount = 0; // 需要保持活跃的连接数量 ... try { ... final int checkCount = poolingCount - minIdle; // 计算连接池中可以回收的连接数量(总连接数减去最小空闲连接数) final long currentTimeMillis = System.currentTimeMillis(); // 获取系统当前时间 int remaining = 0; int i = 0; for (; i < poolingCount; ++i) { // 遍历连接池中的连接 DruidConnectionHolder connection = connections[i]; ... if (checkTime) { // shrink被DestroyConnectionThread调用时,checkTime默认为true。 if (phyTimeoutMillis > 0) { // 物理超时检查 long phyConnectTimeMillis = currentTimeMillis - connection.connectTimeMillis; // 如果连接的存活时间超过了物理超时时间,则将该连接加入销毁列表。 if (phyConnectTimeMillis > phyTimeoutMillis) { evictConnections[evictCount++] = connection; continue; } } // 计算连接的空闲时间 long idleMillis = currentTimeMillis - connection.lastActiveTimeMillis; // 如果连接的空闲时间小于 minEvictableIdleTimeMillis 和 keepAliveBetweenTimeMillis,则退出当前循环 if (idleMillis < minEvictableIdleTimeMillis && idleMillis < keepAliveBetweenTimeMillis) { break; } // 如果连接的空闲时间大于maxEvictableIdleTimeMillis, // 或者连接的空闲时间大于等于 minEvictableIdleTimeMillis 且连接的序号小于可以回收的连接数量 // 才将该连接加入销毁列表。 if (idleMillis >= minEvictableIdleTimeMillis) { if (i < checkCount) { evictConnections[evictCount++] = connection; continue; } else if (idleMillis > maxEvictableIdleTimeMillis) { evictConnections[evictCount++] = connection; continue; } } // 如果 keepAlive 启用,并且连接空闲时间达到了 keepAliveBetweenTimeMillis,则将其加入 keepAliveConnections,后续会检测连接的有效性 if (keepAlive && idleMillis >= keepAliveBetweenTimeMillis && currentTimeMillis - connection.lastKeepTimeMillis >= keepAliveBetweenTimeMillis) { keepAliveConnections[keepAliveCount++] = connection; } else { if (i != remaining) { // 将不需要销毁的连接移到新的位置 connections[remaining] = connection; } remaining++; } } ... } // 计算需要移除的连接数 int removeCount = evictCount + keepAliveCount; // 将未被检查的连接移动到 remaining 之后的位置,确保有效连接的连续性。 if (removeCount > 0) { int breakedCount = poolingCount - i; if (breakedCount > 0) { System.arraycopy(connections, i, connections, remaining, breakedCount); remaining += breakedCount; } System.arraycopy(nullConnections, 0, connections, remaining, removeCount); poolingCount -= removeCount; } keepAliveCheckCount += keepAliveCount; if (keepAlive && poolingCount + activeCount < minIdle) { needFill = true; } } finally { lock.unlock(); } // 关闭需要销毁的连接 if (evictCount > 0) { for (int i = 0; i < evictCount; ++i) { DruidConnectionHolder item = evictConnections[i]; Connection connection = item.getConnection(); JdbcUtils.close(connection); destroyCountUpdater.incrementAndGet(this); } System.arraycopy(nullConnections, 0, evictConnections, 0, evictConnections.length); } // 检测 keepAliveConnections 中连接的有效性,若有效,将其放回连接池。如果校验失败,则丢弃连接。 if (keepAliveCount > 0) { for (int i = keepAliveCount - 1; i >= 0; --i) { DruidConnectionHolder holder = keepAliveConnections[i]; Connection connection = holder.getConnection(); holder.incrementKeepAliveCheckCount(); boolean validate = false; try { this.validateConnection(connection); validate = true; } catch (Throwable error) { ... } boolean discard = !validate; if (validate) { holder.lastKeepTimeMillis = System.currentTimeMillis(); boolean putOk = put(holder, 0L, true); if (!putOk) { discard = true; } } ... } this.getDataSourceStat().addKeepAliveCheckCount(keepAliveCount); System.arraycopy(nullConnections, 0, keepAliveConnections, 0, keepAliveConnections.length); } if (needFill) { lock.lock(); try { // 如果连接池中的总连接数 (activeCount + poolingCount + createTaskCount) 小于 minIdle,则补充新的连接。 int fillCount = minIdle - (activeCount + poolingCount + createTaskCount); emptySignal(fillCount); } finally { lock.unlock(); } } else if (fatalErrorIncrement > 0) { lock.lock(); try { emptySignal(); } finally { lock.unlock(); } } }

该方法的处理流程如下:

  1. 通过 checkCount = poolingCount - minIdle 计算当前池中可以回收的连接数量。其中minIdle是 Druid 参数,用于指定连接池需保留的最小空闲连接数。

  2. 遍历连接池中的连接,并执行以下检查:

    • 物理连接存活时间检查:如果 phyTimeoutMillis > 0,检查物理连接的存活时长是否超过phyTimeoutMillis,如果超过,则将该连接加入销毁列表(evictConnections)。

    • 空闲时间检查:如果连接的空闲时间大于 maxEvictableIdleTimeMillis,或空闲时间大于等于 minEvictableIdleTimeMillis 且连接的序号小于 checkCount(可回收连接数),则将该连接加入销毁列表。

    • 保持连接活跃:若连接需要保持活跃(keepAlive开启)且空闲时间超过keepAliveBetweenTimeMillis,则将该连接加入 keepAliveConnections 列表。

  3. 将未被检查的连接移动到 remaining 之后的位置,确保有效连接的连续性。

  4. 如果 evictCount 大于 0,表示有连接需要销毁,遍历销毁列表(evictConnections)关闭这些连接。

  5. 如果 keepAliveCount 大于 0,表示有连接需要保持活跃,遍历 keepAliveConnections 列表,检查连接有效性,若有效,将其放回连接池。如果校验失败,则丢弃连接。

  6. 如果 needFill 为 true,表示连接池中空闲连接不足,触发填充信号以创建新连接。

所以,Druid 连接池默认情况下,每 60 秒(由 timeBetweenEvictionRunsMillis 参数控制)执行一次连接回收和维护操作,并保持一定数量的空闲连接。其核心逻辑包括:

  • 回收超时或多余的空闲连接:

    • 连接的空闲时间超过 maxEvictableIdleTimeMillis 或 phyConnectTimeMillis,将被回收。
    • 当连接池的数量超过最小空闲连接数 minIdle 时,如果连接的空闲时间超过 minEvictableIdleTimeMillis,也会被回收。
  • 维护 Keep-Alive 机制(如果keepAlive开启):

    • 当连接的空闲时间超过 keepAliveBetweenTimeMillis,且距离上次 Keep-Alive 检测时间超过 keepAliveBetweenTimeMillis 时,执行有效性检测。
    • 通过 validateConnection 进行检测,合格的连接重新加入池中,不合格的连接被销毁。
  • 必要时补充新的连接:

    若当前连接数(activeCount + poolingCount)低于 minIdle,则触发连接补充机制,创建新的连接。

需要注意的是,即使连接开启了定期探活检测,若发生超时,仍会被回收

接下来,我们看看上述参数的默认值:

  • timeBetweenEvictionRunsMillis:默认 60000 毫秒(60 秒)。
  • minEvictableIdleTimeMillis:默认 1800000 毫秒(30 分钟)。
  • maxEvictableIdleTimeMillis:默认 25200000 毫秒(7 小时)。
  • phyTimeoutMillis:默认 -1。
  • keepAlive:默认为 false。
  • keepAliveBetweenTimeMillis:默认 120000 毫秒(120 秒)。

为什么设置的 validationQuery 没有效果?

在 Druid 连接池中,判断连接是否有效时,通常调用 testConnectionInternalvalidateConnection 方法。这两个方法的核心逻辑基本相同,具体如下:

  1. 优先使用 validConnectionChecker 进行连接校验:

    • validConnectionChecker 是一个接口,定义了 isValidConnection 方法,用于检测数据库连接的有效性。
    • 具体的数据库有对应的实现类,例如:MySQL 由MySqlValidConnectionChecker实现,Oracle 由 OracleValidConnectionChecker 实现。
    • validConnectionChecker 在 initValidConnectionChecker 方法中初始化,并根据数据库驱动类型选择合适的实现类。
  2. 如果 validConnectionChecker 未初始化,则执行默认检查:

    • 通过 validationQuery 执行 SQL 语句,验证连接是否有效。

    • 该方法适用于所有数据库,但会带来一定的性能开销。

以下是 MySQL 实现类(MySqlValidConnectionChecker)中isValidConnection方法的具体实现。

// druid-1.2.24/core/src/main/java/com/alibaba/druid/pool/vendor/MySqlValidConnectionChecker.java public boolean isValidConnection(Connection conn, String validateQuery, int validationQueryTimeout) throws Exception { if (conn.isClosed()) { return false; } if (usePingMethod || StringUtils.isEmpty(validateQuery)) { validateQuery = DEFAULT_VALIDATION_QUERY; } return ValidConnectionCheckerAdapter.execValidQuery(conn, validateQuery, validationQueryTimeout); }

方法中的 usePingMethod 受druid.mysql.usePingMethod参数控制,其默认值为 true。

当 usePingMethod 等于 true 时,validateQuery 将被设置为 DEFAULT_VALIDATION_QUERY,即/* ping */ SELECT 1,而非用户自定义的 validationQuery。

execValidQuery() 方法执行 validateQuery 时,如果查询语句以/* ping */开头,MySQL JDBC 驱动会进行特殊处理。

具体来说,MySQL JDBC 在解析 SQL 语句时,会判断它是否以 PING_MARKER(即/* ping */)开头,如果是,则不会执行 SQL 语句,而是调用doPingInstead(),直接向 MySQL 服务器发送 COM_PING 命令,这样可以减少 SQL 解析和执行的开销,提高性能。

// mysql-connector-j-8.0.33/src/main/user-impl/java/com/mysql/cj/jdbc/StatementImpl.java public java.sql.ResultSet executeQuery(String sql) throws SQLException { synchronized (checkClosed().getConnectionMutex()) { JdbcConnection locallyScopedConn = this.connection; ... if (sql.charAt(0) == '/') { if (sql.startsWith(PING_MARKER)) { doPingInstead(); // 直接发送 COM_PING 命令 return this.results; } } ... } }

关于参数设置的几点建议

  1. minEvictableIdleTimeMillismaxEvictableIdleTimeMillis不宜设置过小,因为频繁销毁和创建连接会带来额外的性能开销。

  2. 建议开启 keepAlive 机制,尤其是在客户端与 MySQL 之间存在代理的情况下,这些组件可能会有独立的空闲连接超时设置,导致连接被提前断开。

  3. 在连接申请时检测连接的有效性(通过设置 testOnBorrow 为 true)是最有效的方式,可以确保每次获取的连接都是可用的。
    但这种方式会对应用性能产生一定影响,尤其是在高并发场景下。

    因此,建议根据业务需求权衡性能与可靠性,选择合适的检测策略。

  4. 考虑到网络可能的故障,即使 Druid 连接池定期检测连接的有效性,也无法 100% 保证所有连接都可用,所以应用端一定要做好容错处理。

  5. 对于代码中未及时归还使用过的连接,一方面可能导致连接泄漏,使连接池耗尽可用连接。另一方面,未释放的连接无法通过 Druid 的 Keep-Alive 机制保持活跃状态,更容易因空闲超时被 MySQL 服务器或中间件关闭。

    为了避免这些问题,建议在应用的测试环境开启以下参数来识别长时间未归还的连接:logAbandonedremoveAbandonedremoveAbandonedTimeoutMillis

总结

  1. Druid 连接池在以下四种场景下会检测连接的有效性:申请连接、归还连接、创建新物理连接以及定期检测。

  2. Druid 通过开启 keepAlive 参数,定期对空闲连接进行有效性检测,确保连接保持活跃状态。

    当连接的空闲时间超过 keepAliveBetweenTimeMillis 时,Druid 会触发 Keep-Alive 检测,验证连接的有效性。如果连接有效,则重新放回连接池;如果无效,则将其销毁。

  3. Druid 默认使用 MySQL 的 COM_PING 命令进行连接有效性检测,这种方式比执行 SQL 语句更高效。

    由于 COM_PING 的优先级高于用户自定义的 validationQuery,因此在默认配置下,validationQuery 不会被执行。

    如果用户希望使用自定义的 validationQuery 进行连接检测,可将 druid.mysql.usePingMethod 参数设置为 false 来实现。

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

评论