Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Druid 获取连接异常导致应用挂起原因分析 #62

Open
nodejh opened this issue Dec 20, 2021 · 0 comments
Open

Druid 获取连接异常导致应用挂起原因分析 #62

nodejh opened this issue Dec 20, 2021 · 0 comments
Labels

Comments

@nodejh
Copy link
Owner

nodejh commented Dec 20, 2021

1. 背景

1.1 现象

2021.12.16 凌晨,我们的应用数据库因故发生了主备切换,之后某个 Pod 就持续报错 GetConnectionTimeoutException,并且该 Pod 的进程一直挂起,无法正常提供服务。

不过比较奇怪的是,当时连接同一数据库的其他 Pod 虽然也有几条报错(主要是数据库连接超时的报错),但很快其他 Pod 都恢复了正常,就只有这一个 Pod 一直没有恢复。

1.2 报错信息

异常 Pod 的部分报错信息如下:

[2021-12-16 02:10:06.617] [ERROR] [thread-30849] com.alibaba.druid.pool.DruidPooledStatement errorCheck:367 - CommunicationsException, druid version 1.2.5, jdbcUrl : jdbc:mysql://xxx:3306/xxx?useUnicode=true&characterEncoding=UTF-8&allowMultiQueries=true, testWhileIdle true, idle millis 1785, minIdle 5, poolingCount 0, timeBetweenEvictionRunsMillis 300000, lastValidIdleMillis 1785, driver com.mysql.cj.jdbc.Driver, exceptionSorter com.alibaba.druid.pool.vendor.MySqlExceptionSorter
 
 ......
 
2021-12-16 02:10:06.617] [ERROR] thread-30849] Caused by: com.alibaba.druid.pool.GetConnectionTimeoutException: wait millis 100000, active 399, maxActive 100, creating 0
	at com.alibaba.druid.pool.DruidDataSource.getConnectionInternal(DruidDataSource.java:1745)
	at com.alibaba.druid.pool.DruidDataSource.getConnectionDirect(DruidDataSource.java:1415)
	at com.alibaba.druid.pool.DruidDataSource.getConnection(DruidDataSource.java:1395)
	at com.alibaba.druid.pool.DruidDataSource.getConnection(DruidDataSource.java:1385)
	at com.alibaba.druid.pool.DruidDataSource.getConnection(DruidDataSource.java:100)
	at org.springframework.jdbc.datasource.DataSourceUtils.fetchConnection(DataSourceUtils.java:158)
	at org.springframework.jdbc.datasource.DataSourceUtils.doGetConnection(DataSourceUtils.java:116)
	at org.springframework.orm.ibatis.SqlMapClientTemplate.execute(SqlMapClientTemplate.java:182)
	... 26 common frames omitted

从报错信息来看, GetConnectionTimeoutException 即获取连接超时。但数据库和网络都是正常的,为什么获取数据库连接超时呢?

此外错误栈中的 active 居然比 maxActive 大,最大连接数 maxActive 是 100,为什么活跃连接数能达到 399?

在 Druid 的 issues 中, GetConnectionTimeoutException 也是一个非常常见的问题,那这个异常到底是怎么产生的呢,又该如何避免呢?

接下来就让我们带着这些疑问,通过阅读 Druid 源码进行深入的分析。

2.1.3 Druid 配置

在进行问题分析前,先看一下当前的 Druid 配置。Druid 的版本为 1.2.5,因此在接下来的内容中,我都将根据 1.2.5 版本的源码进行分析。Druid 的具体配置如下:

<bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource" init-method="init" destroy-method="close"> 
    <property name="url" value="${jdbc_url}" />
    <property name="username" value="${jdbc_user}" />
    <property name="password" value="${jdbc_password}" />
    <property name="maxActive" value="100"/>
    <property name="initialSize" value="1"/>
    <property name="maxWait" value="100000"/>
    <property name="minIdle" value="1"/>
    <property name="timeBetweenEvictionRunsMillis" value="300000"/>
    <property name="validationQuery" value="SELECT 1"/>
    <property name="testWhileIdle" value="true"/>
    <property name="removeAbandoned" value="false"/>
    <property name="removeAbandonedTimeout" value="600"/>
</bean>

2. 关键变量

在错误栈中,我们看到了 pollingCountactivemaxActive 等变量,那这些变量到底是什么含义呢?

这一章节就先来了解一下。同时了解了这些变量的含义,也更利于后续阅读源码分析问题。

2.1 配置项

这里列出一些本文会涉的及关键配置,更多配置可参考 DruidDataSource配置属性列表

2.1.1 minIdle

连接池中至少需要保持的连接数。

2.1.2 maxActive

连接池中最大连接数。

2.1.3 maxWait

单次获取连接的最长等待时间。

2.2 DruidDataSource

DruidDataSource 是数据源,每个数据源都有一个对应的 DruidDataSource 实例。

2.2.1 activeCount

当前活跃连接数。每当成功获取到一个连接后,activeCount 都会加一,同时也会将 holderactive 属性设置为 true。

private DruidPooledConnection getConnectionInternal(long maxWait) {
    // ...
    if (holder != null) {
        if (holder.discard) {
            continue;
        }
        activeCount++;
        holder.active = true;
    }
}

2.2.2 connections

当前所数据源拥有的连接池。其类型是 DruidConnectionHolder[]

2.2.3 poolingCount

连接池中的的连接数。

每当放入连接到连接池中,就会将 poolingCount 加一,例如:

private boolean put(DruidConnectionHolder holder, long createTaskId) {
    // ...
    connections[poolingCount] = holder;
    incrementPoolingCount();
    // ...
}

private final void incrementPoolingCount() {
    poolingCount++;
}

每当将连接从连接池中取出,就会将 poolingCount 减一,例如:

private DruidConnectionHolder pollLast(long nanos) throws InterruptedException, SQLException {
    // ...
    decrementPoolingCount();
    DruidConnectionHolder last = connections[poolingCount];
    connections[poolingCount] = null;
    // ...
    return last;
}

private final void decrementPoolingCount() {
    poolingCount--;
}

2.3 DruidConnectionHolder

数据库连接以及相关属性。

2.3.1 conn

数据库连接,类型为 Connection。

2.3.2 active

用来标记连接是否活跃。

2.3.3 lastActiveTimeMillis

连接上次活跃时间。

2.3.4 lastKeepTimeMillis

连接上次保持活跃的时间。每次通过 keepAlive 保持连接活跃,就会更新此时间。

2. 源码分析

接下来,就让我们从错误栈开始深入源码,一层一层寻找问题的根源。

2.2.1 异常是如何抛出的?

首先 GetConnectionTimeoutException 是由 getConnectionInternal 方法抛出的,该方法的主要作用就是获取数据库连接,当前没有获取到连接也就是 holdernull 的时候,就会抛出异常,同时打印出当前  activemaxActive 的值。

if (holder == null) {
  // ...
  StringBuilder buf = new StringBuilder(128);
  buf.append("wait millis ")//
    .append(waitNanos / (1000 * 1000))//
    .append(", active ").append(activeCount)//
    .append(", maxActive ").append(maxActive)//
    .append(", creating ").append(creatingCount)//
    ;

  // ...
  String errorMessage = buf.toString();

  if (this.createError != null) {
    throw new GetConnectionTimeoutException(errorMessage, createError);
  } else {
    throw new GetConnectionTimeoutException(errorMessage);
  }
}

holderDruidConnectionHolder 的实例。那为什么 holdernull 呢?接下来就让我们再看一下 holder 是如何创建的。

2.2 连接是如何创建的?

接下来就看  holder 是如何创建的。

getConnectionInternal 中, holder 的创建是在 for 循环中进行的,一旦创建成功则退出循环。

在创建 holder 时,有同步和异步两种方式。

2.2.1 同步创建连接

if (createScheduler != null
    && poolingCount == 0
    && activeCount < maxActive
		&& creatingCountUpdater.get(this) == 0
    && createScheduler instanceof ScheduledThreadPoolExecutor) {
  ScheduledThreadPoolExecutor executor = (ScheduledThreadPoolExecutor) createScheduler;
  if (executor.getQueue().size() > 0) {
    createDirect = true;
    continue;
  }
}

如果需要同步创建连接,需要同时满足以下几个条件:

  • 不配置创建连接的线程池,即 createScheduler != null
  • 目前连接池中没有可用连接,即 poolingCount == 0
  • 目前活跃的连接数小于最大连接数,即 activeCount < maxActive
  • 目前没有正在同步创建的连接,也就是统一时刻只能存在一个线程在同步创建连接,即 creatingCountUpdater.get(this) == 0

满足上述条件后,createDirect 会被设置为 true,然后在下一次循环中就会同步创建连接。也正因为有上述条件在,所以只有很少的线程会进入到同步创建连接的流程中。

private DruidPooledConnection getConnectionInternal(long maxWait) {
	// ...
    if (createDirect) {
        if (creatingCountUpdater.compareAndSet(this, 0, 1)) {
            PhysicalConnectionInfo pyConnInfo = DruidDataSource.this.createPhysicalConnection();
            holder = new DruidConnectionHolder(this, pyConnInfo);
            holder.lastActiveTimeMillis = System.currentTimeMillis();
            // ...
            boolean discard = false;
            lock.lock();
            try {
                if (activeCount < maxActive) {
                    activeCount++;
                    holder.active = true;
                    if (activeCount > activePeak) {
                        activePeak = activeCount;
                        activePeakTime = System.currentTimeMillis();
                    }
                    break;
                } else {
                    discard = true;
                }
            } finally {
                lock.unlock();
            }
        }
    }
}

2.2.2 异步创建连接

如果没有同步创建连接,则接下来会通过 pollLast(nanos)takeLast() 异步创建连接。

nanos 是根据 maxWait 计算出来的,如果设置了 maxWait 就会调用 pollLast(nanos),否则调用 takeLast()

private DruidPooledConnection getConnectionInternal(long maxWait) {

    final long nanos = TimeUnit.MILLISECONDS.toNanos(maxWait);
    // ...

    if (maxWait > 0) {
        holder = pollLast(nanos);
    } else {
        holder = takeLast();
    }
}

2.2.3 pollLasttakeLast

pollLast 方法中,如果当前连接池中的连接数 poolingCount 为 0,则调用 emptySignal 使用信号量 empty,并创建异步线程去创建连接。

private DruidConnectionHolder pollLast(long nanos) throws InterruptedException, SQLException {
    long estimate = nanos;

    for (;;) {
        if (poolingCount == 0) {
            emptySignal(); // send signal to CreateThread create connection
            // ...
        }
    }
}

然后,pollLast 自己再使用 notEmpty 信号量进入等待:

estimate = notEmpty.awaitNanos(estimate); // signal by
                                        // recycle or
                                        // creator

本次等待的最长时间 estimate 就是根据 maxWait 计算出来的。

在有连接被回收,会新创建出来后,就会被唤醒。如果唤醒后本线程没有成功创建连接,并且目前总的等待时间还没到maxWait,就会再次进入 await。

如果等待时间到了 maxWait ,但依旧没有成功创建连接,即 poolingCount 为 0,则会返回 null。

if (poolingCount == 0) {
    if (estimate > 0) {
        continue;
    }

    return null;
}

通过信号创建的连接会被放入到连接池 connections 中,然后 pollLast(nanos) 收到创建成功的信号后,就可以从连接池中取出连接并返回了:

private DruidConnectionHolder pollLast(long nanos) throws InterruptedException, SQLException {
    // ...
    decrementPoolingCount();
    DruidConnectionHolder last = connections[poolingCount];
    connections[poolingCount] = null;
    // ...
    return last;
}

无参的 takeLast() 就简单很多了,它的流程和 pollLast(nanos) 几乎一致,唯一不同的是它没有等待超时时间,只有等待回收线程,会创建线程唤醒后,才会继续执行。

DruidConnectionHolder takeLast() throws InterruptedException, SQLException {
    try {
        while (poolingCount == 0) {
            emptySignal(); // send signal to CreateThread create connection

            try {
                notEmpty.await(); // signal by recycle or creator
            } 
        }
    }

    decrementPoolingCount();
    DruidConnectionHolder last = connections[poolingCount];
    connections[poolingCount] = null;
    return last;
}

2.2.4 emptySignal

使用信号异步创建连接的 emptySignal() 方法代码如下:

private void emptySignal() {
    if (createScheduler == null) {
        empty.signal();
        return;
    }

    if (createTaskCount >= maxCreateTaskCount) {
        return;
    }

    if (activeCount + poolingCount + createTaskCount >= maxActive) {
        return;
    }
    submitCreateTask(false);
}

如果设置的创建线程池 createScheduler 为 null,则会发送一个信号去创建连接。否则创建一个 CreateConnectionTask 来创建连接。

这里发出的创建信号会被 CreateConnectionThread 接收,CreateConnectionThread 会在数据源初始化的时候创建。

public void init() throws SQLException {
    // ...
    createAndStartCreatorThread();
    createAndStartDestroyThread();
}

收到信号后,会判断当前活跃连接数以及连接池中的连接数是否大于 maxActive,如果大于则不创建,避免创建出超过 maxActive 数量的连接;如果小于,则创建了物理连接,然后在调用 DruidDataSource 的 put 方法将连接放入连接池中。

public void run() {

    for (;;) {
        // ...
        try {
            boolean emptyWait = true;

            if (createError != null
                && poolingCount == 0
                && !discardChanged) {
                emptyWait = false;
            }

            if (emptyWait
                && asyncInit && createCount < initialSize) {
                emptyWait = false;
            }

            if (emptyWait) {
                // 必须存在线程等待,才创建连接
                if (poolingCount >= notEmptyWaitThreadCount //
                    && (!(keepAlive && activeCount + poolingCount < minIdle))
                    && !isFailContinuous()
                   ) {
                    empty.await();
                }

                // 防止创建超过maxActive数量的连接
                if (activeCount + poolingCount >= maxActive) {
                    empty.await();
                    continue;
                }
            }

        } 
        // ...
        PhysicalConnectionInfo connection = null;

        try {
            connection = createPhysicalConnection();
        } 
        // ...
        boolean result = put(connection);

    }
}

CreateConnectionTask 也是类似的逻辑。

2.2.5 DruidDataSource#put

在 put 方法中,首先会判断当前连接数是否大于最大连接数,如果小于,则会将连接放入到 connections 中,然后将 poolingCount 的值加一。

private boolean put(DruidConnectionHolder holder, long createTaskId) {
    if (poolingCount >= maxActive) {
        if (createScheduler != null) {
            clearCreateTask(createTaskId);
        }
        return false;
    }
    // ...
    connections[poolingCount] = holder;
    incrementPoolingCount();
    //... 
}

2.2.6 小结

以上便是便是大致的连接创建流程。

总的来说就是,应用需要执行 SQL 的时候,就通过 getConnectionInternal 获取连接,其中创建连接分为同步和异步,异步则通过信号量创建连接。成功获取连接后还会将 activeCount 加一。

结合 1.2.1 中的异常信息,报错时 poolingCount 为 0, 所以会调用 emptySignal() 创建连接。但activeCount + poolingCount + createTaskCount 显然是大于 maxActive 的,因此 pollLast 最终会等待 maxWait 然后超时。所以最终 getConnectionInternal 抛出了 GetConnectionTimeoutException 异常。

所以接下来问题的关键就是,为什么 activeCount 会大于 maxActive?

2.3 连接是如何释放的?

要解答这个问题,我们就需要先知道 activeCount 什么时候减少。

前面已经知道了 activeCount 会在获取到连接后增加,那相应的,activeCount 也会在释放连接时减少。所以接下来就详细了解连接时如何释放的。

2.3.1 应用主动关闭连接

提到连接释放,首先想到的应该是我们在应用中主动调用 connection.close()方法来释放连接。

connection.close() 会立即释放物理连接吗?显然是不会的。

public void close() throws SQLException {
    // ...
    recycle();
}

public void recycle() throws SQLException {
    // ...
    if (!this.abandoned) {
        DruidAbstractDataSource dataSource = holder.getDataSource();
        dataSource.recycle(this);
    }
}

connection.close() 会最终会调用 dataSource.recycle(DruidPooledConnection pooledConnection)释放连接。 那 dataSource.recycle 又是怎么实现的呢?

2.3.2 recycle

在 recyle 中会有一些列的连接状态判断,如物理连接是否已关闭等,然后决定是否立即释放连接。不过大部分情况下会运行到下面的逻辑,即如果连接状态是活跃的,则将 activeCount 减一,并将状态再设置为 false。最后再调用 putLast

/**
     * 回收连接
     */
protected void recycle(DruidPooledConnection pooledConnection) throws SQLException {
    // ...
    if (holder.active) {
        activeCount--;
        holder.active = false;
    }
    result = putLast(holder, currentTimeMillis);
    // ...
}

在 putLast 中则会更新连接的上次活跃时间,然后将连接再度放回到连接池中,并将 poolingcount 加一。

boolean putLast(DruidConnectionHolder e, long lastActiveTimeMillis) {
    if (poolingCount >= maxActive || e.discard) {
        return false;
    }

    e.lastActiveTimeMillis = lastActiveTimeMillis;
    connections[poolingCount] = e;
    incrementPoolingCount();
    // ...
    return true;
}

也就是说,通过 .close 方法主动释放连接,不会真的释放,而是将连接还回到连接池中。

2.3.3 连接释放

既然前面的 recycle 不会关闭连接,但连接池也不可能无限大,那连接到底是如何回收的呢?这就要提到 Druid 的连接释放任务了。

CreateConnectionThread 一样, Druid 在初始化数据源时会创建释放连接的线程 DestroyThread ,然后创建释放任务 DestroyTask。

当应用设置的释放线程池 destroyScheduler 不为空时,就会在 destroyScheduler 中运行 DestroyTask;否则会创建一个消费连接的线程池,然后在该线程池中运行释放连接的任务。与 createScheduler 类似,大部分情况下我们不会设置 destroyScheduler。

protected void createAndStartDestroyThread() {
    destroyTask = new DestroyTask();

    if (destroyScheduler != null) {
        // ...
        destroySchedulerFuture = destroyScheduler.scheduleAtFixedRate(destroyTask, period, period,
                                                                      TimeUnit.MILLISECONDS);
        return;
    }
    // ...
    destroyConnectionThread = new DestroyConnectionThread(threadName);
    destroyConnectionThread.start();
}

DestroyConnectionThread 本质上就是一个死循环,在循环中每隔 timeBetweenEvictionRunsMillis 执行一次释放连接的任务。

public class DestroyConnectionThread extends Thread {
    // ...
    public void run() {
        for (;;) {
            // 从前面开始删除
            try {
                // ...
                if (timeBetweenEvictionRunsMillis > 0) {
                    Thread.sleep(timeBetweenEvictionRunsMillis);
                } else {
                    Thread.sleep(1000); //
                }

                destroyTask.run();
            } catch (InterruptedException e) {
                break;
            }
        }
    }
}

释放连接的关键逻辑就在 DestroyTask 中。

2.3.4 DestroyTask

在 DestroyTask 中,主要是通过 shrink 方法来释放连接。

    public class DestroyTask implements Runnable {
        public DestroyTask() {

        }

        @Override
        public void run() {
            shrink(true, keepAlive);

            if (isRemoveAbandoned()) {
                removeAbandoned();
            }
        }

    }

所以接下来的关键就是 shrink(boolean checkTime, boolean keepAlive)� 方法。

2.3.5 DestroyTask#shrink

shrink 接收两个参数:

  • checkTime 是否校验连接的时间,如连接空闲时间等;
  • keepAlive 是否开启了 keepAlive

shrink 中,首先会通过 for 循环,依次遍历连接池中的所有连接并进行处理。

在循环中,首先会判断当前是否有 fatalError,如果有,则会将连接放入 keepAliveConnections 列表中,keepAliveConnections 存储了需要保持活跃的连接。什么时候会出现 fatalError 呢?主要是执行 SQL 时可能出现,比如数据库断网、数据库重启等。

for (int i = 0; i < poolingCount; ++i) {
    DruidConnectionHolder connection = connections[i];
    if ((onFatalError || fatalErrorIncrement > 0) && (lastFatalErrorTimeMillis > connection.connectTimeMillis))  {
        keepAliveConnections[keepAliveCount++] = connection;
        continue;
    }
}

如果没有 fatalError 并且开启了 checkTime 为 true,则会继续根据连接时间判断连接应该被放入 keepAliveConnections 还是 evictConnections 队列。

具体逻辑如下:

  1. 首先判断物理连接是否超时,如果超时则放入 evictConnections,默认 phyTimeoutMillis 为 -1
  2. 然后判断连接空闲时间是否同时小于 minEvictableIdleTimeMillis 和 keepAliveBetweenTimeMillis,如果不满足条件,则退出本次释放任务,也就是连接池中的其他连接都不会被释放了;
  3. 如果连接空闲时间大于 minEvictableIdleTimeMillis,则判断该连接释放应该被释放,判断条件是 i < checkCount ,也就是如果当前连接池数量小于 minIdle,则连接池前面的连接不会被释放,而是从连接池后面的连接开始释放;
  4. 如果不满足条件 3,并且开启了 keepAlive,连接空闲时间也大于 keepAliveBetweenTimeMillis,则将连接放入 keepAliveConnections 队列。结合条件 3,也就是说,连接池前面的连接会被保持活跃。
final int checkCount = poolingCount - minIdle;
// ...
for (int i = 0; i < poolingCount; ++i) {
    // ...
    if (phyTimeoutMillis > 0) {
	    long phyConnectTimeMillis = currentTimeMillis - connection.connectTimeMillis;
    	if (phyConnectTimeMillis > phyTimeoutMillis) {
        	evictConnections[evictCount++] = connection;
	        continue;
        }
    }

    long idleMillis = currentTimeMillis - connection.lastActiveTimeMillis;

    if (idleMillis < minEvictableIdleTimeMillis
        && idleMillis < keepAliveBetweenTimeMillis
    ) {
        break;
    }

    if (idleMillis >= minEvictableIdleTimeMillis) {
        if (checkTime && i < checkCount) {
            evictConnections[evictCount++] = connection;
            continue;
        } else if (idleMillis > maxEvictableIdleTimeMillis) {
            evictConnections[evictCount++] = connection;
            continue;
        }
    }

    if (keepAlive && idleMillis >= keepAliveBetweenTimeMillis) {
        keepAliveConnections[keepAliveCount++] = connection;
    }
}

正常情况下,connections 中的连接都是按 lastActiveTimeMillis 先后顺序排列的,因此释放连接时,从前往后释放,一旦发现连接空闲时间小于 minEvictableIdleTimeMillis 和 keepAliveBetweenTimeMillis 就结束本次释放流程,这样就可以减少循环次数,提高性能。并且连接保活时,也是从前往后进行保活,因为前面的连接更可能超时。这也正是 Druid 设计的巧妙之处。

当然,这里是“正常情况”,那必然也会有异常情况,异常情况是什么样呢?这里暂时先不展开,后面会详细讲解。

在确定了需要释放和保持活跃的连接数量之后,下一步就是从连接池中将连接删除,删除的方式就是将 connections 中前 removeCount 个连接删掉,然后将剩余连接再依次从前往后放入到 connections,最后再用 null 填充 connections,以便后续存储保活的连接。

int removeCount = evictCount + keepAliveCount;
if (removeCount > 0) {
    System.arraycopy(connections, removeCount, connections, 0, poolingCount - removeCount);
    Arrays.fill(connections, poolingCount - removeCount, poolingCount, null);
    poolingCount -= removeCount;
}

接下来对于 evictConnections 中的连接,就可以直接关闭了。

DruidConnectionHolder item = evictConnections[i];
Connection connection = item.getConnection();
JdbcUtils.close(connection);
destroyCountUpdater.incrementAndGet(this);

对于 keepAliveConnections 中的连接,则先判断连接是否可用,如果连接可用,则调用 put 方法将连接再放回连接池。和前面应用主动释放连接时调用的 putLast 不同的是,put 不会更新连接的 lastActiveTimeMillis 。

如果连接不可用,则�再调用 emptySignal() 通过信号创建一个新的连接。

DruidConnectionHolder holer = keepAliveConnections[i];
Connection connection = holer.getConnection();
holer.incrementKeepAliveCheckCount();

boolean validate = false;
try {
    this.validateConnection(connection);
    validate = true;
} catch (Throwable error) {
    // skip
}

boolean discard = !validate;
if (validate) {
    holer.lastKeepTimeMillis = System.currentTimeMillis();
    boolean putOk = put(holer, 0L);
    if (!putOk) {
        discard = true;
    }
}

if (discard) {
    try {
        connection.close();
    }
    
    // ...
    try {

        if (activeCount + poolingCount <= minIdle) {
            emptySignal();
        }
    }
    // ...
}

进行连接保活时,优先将时间更早的连接进行保活。但有时候我们可能期望一个连接不要太长时间空闲,比如连接空闲 1 分钟后,即使连接可用也要释放,这时应该怎么办呢?

Druid 也考虑到了这个问题,可通过设置 testWhileIdle 为 true 进行空闲连接释放。

2.3.6 空闲连接释放

在应用中我们会通过 getConnection 获取连接,getConnection 会调用 getConnectionDirectgetConnectionDirect 继续调用 getConnectionInternal

public DruidPooledConnection getConnectionDirect(long maxWaitMillis) throws SQLException {
    	// ...
        for (;;) {
            // handle notFullTimeoutRetry
            DruidPooledConnection poolableConnection;
            try {
                poolableConnection = getConnectionInternal(maxWaitMillis);
            }
            
            if (testOnBorrow) {
                // ...
            } else {
                // ...
                if (testWhileIdle) {
                    final DruidConnectionHolder holder = poolableConnection.holder;
                    long currentTimeMillis             = System.currentTimeMillis();
                    long lastActiveTimeMillis          = holder.lastActiveTimeMillis;
                    long lastExecTimeMillis            = holder.lastExecTimeMillis;
                    long lastKeepTimeMillis            = holder.lastKeepTimeMillis;

                    if (checkExecuteTime
                            && lastExecTimeMillis != lastActiveTimeMillis) {
                        lastActiveTimeMillis = lastExecTimeMillis;
                    }

                    if (lastKeepTimeMillis > lastActiveTimeMillis) {
                        lastActiveTimeMillis = lastKeepTimeMillis;
                    }

                    long idleMillis                    = currentTimeMillis - lastActiveTimeMillis;

                    long timeBetweenEvictionRunsMillis = this.timeBetweenEvictionRunsMillis;

                    if (timeBetweenEvictionRunsMillis <= 0) {
                        timeBetweenEvictionRunsMillis = DEFAULT_TIME_BETWEEN_EVICTION_RUNS_MILLIS;
                    }

                    if (idleMillis >= timeBetweenEvictionRunsMillis
                            || idleMillis < 0 // unexcepted branch
                            ) {
                        boolean validate = testConnectionInternal(poolableConnection.holder, poolableConnection.conn);
                        if (!validate) {
                            discardConnection(poolableConnection.holder);
                             continue;
                        }
                    }
                }
            }   

            return poolableConnection;
        }
    }

getConnectionDirect 中,获取到连接后,如果开启了 testWhileIdle ,则会继续判断连接是否空闲,如果是,则会调用 discardConnection� 释放连接,然后再进行下一次循环创建新的连接。连接是否空闲,主要根据 lastActiveTimeMillistimeBetweenEvictionRunsMillis 进行判断。

在 discardConnection 中,如果连接 active 状态为 true,则会将 activeCount 减一,并将 active 设置为 false。

public void discardConnection(DruidConnectionHolder holder) {
    // ...
    Connection conn = holder.getConnection();
    if (conn != null) {
        JdbcUtils.close(conn);
    }

    lock.lock();
    try {
        if (holder.discard) {
            return;
        }

        if (holder.active) {
            activeCount--;
            holder.active = false;
        }
        discardCount++;

        holder.discard = true;

        if (activeCount <= minIdle) {
            emptySignal();
        }
    } finally {
        lock.unlock();
    }
}

2.3.7 小结

以上便是便是连接释放的流程。

当应用主动调用 .close 时,并不会真的是否连接,而是更新连接的 lastActiveTimeMillis,然后将连接返回到连接池,然后将 activeCount 减一。

然后 DestroyTask 再定期清理连接,判断连接应该被释放还是被保活。如果连接应该被保活,则再将连接放回到连接池。

此外应用获取连接时,也会判断连接的空闲时间,如果连接是空闲的,也会释放连接,并将 activeCount 减一。

那为什么 activeCount 会大于 maxActive 呢?可能唯一的解释就是 activeCount 不准确。

这里先说结论,的确如此。这是 Druid 1.2.8 之前版本的一个 BUG。

通过前面的分析,我们可以大概推测:当连接池中存在两个相同连接时,activeCount 就会少减一。因为当应用第一次释放连接时,会将连接的 active 设置为 false 并执行 activeCount--,第二次释放时,由于 active 已经为 false 了,因此就不会再执行 activeCount-- 了。

那么如何证明推测的正确性呢?

3. 问题复现

为了证明前面的推测,我写了一段代码来验证。

3.1 复现代码

代码如下,Druid 版本为 1.2.5 :

public class Test {
    public static void main(String[] args) throws Exception {
        String jdbcUrl = "jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=UTF-8&allowMultiQueries=true";
        String user = "root";
        String password = "11111111";

        DruidDataSource druidDataSource = new DruidDataSource();
        druidDataSource.setUsername(user);
        druidDataSource.setPassword(password);
        druidDataSource.setUrl(jdbcUrl);
        druidDataSource.setValidationQuery("select 1");
        druidDataSource.setMinEvictableIdleTimeMillis(10 * 1000);
        druidDataSource.setMaxEvictableIdleTimeMillis(DEFAULT_MAX_EVICTABLE_IDLE_TIME_MILLIS);
        druidDataSource.setKeepAliveBetweenTimeMillis(12 * 1000);
        druidDataSource.setMinIdle(2);
        druidDataSource.setMaxWait(1000);
        druidDataSource.setMaxActive(4);
        druidDataSource.setTimeBetweenEvictionRunsMillis(7 * 1000);
        druidDataSource.setKeepAlive(true);
        DruidPooledConnection connection1 = druidDataSource.getConnection();
        DruidPooledConnection connection2 = druidDataSource.getConnection();
        connection2.close();
        Thread.sleep(9 * 1000);
        connection1.close();
        Thread.sleep(14 * 1000);
        DruidPooledConnection connection3 = druidDataSource.getConnection();
        DruidPooledConnection connection4 = druidDataSource.getConnection();
        System.out.println(connection3.getConnectionHolder() == connection4.getConnectionHolder());
        connection3.close();
        connection4.close();
    }
}

最终会输出 true,也就是 connection3connection4 获取到的连接是一样的。

为什么为 true 呢?接下来就结合前面的源码理解,梳理一下整个时间线。

3.2 时间线

3.2.1 时间点 00:初始化并关闭 connection2

获取 connection1 和 connection2,其 holder 如下:

  • connection1 {id: 110, useCount: 1, lastActiveTime: 00, active: true}
  • connection2 {id: 100, useCount: 1, lastActiveTime: 00, active: true}

然后关闭 connection2,此时 connections 为 [connection2]

  • connection2 {id: 100, useCount: 1, lastActiveTime: 00, active: false}

最终 activeAcount 为 1。

3.2.2 时间点 07:第一次回收

DestroyTask 第一次执行,此时 connection2 的空闲时间均小于 minEvictableIdleTimeMilliskeepAliveBetweenTimeMillis,跳过回收。

3.2.3 时间点 09:关闭 connection1

关闭 connection1,connection1 返回连接池,此时 connections 为 [connection2,connection1],connections 中的连接空闲时间是递减的:

  • connection2 {id: 100, useCount: 1, lastActiveTime: 00, active: false}
  • connection1 {id: 110, useCount: 1, lastActiveTime: 09, active: false}

最终 activeAcount 为 0。

3.2.4 时间点 14:第二次回收

DestroyTask 执行,此时 connection2 空闲时间大于 minEvictableIdleTimeMillis,但 checkCount 为 0(poolingCount - minIdle�),不满足 i < checkCountconnection2 会被放入 keepAliveConnections 列表。

connection1 的空闲时间均小于 minEvictableIdleTimeMilliskeepAliveBetweenTimeMillis ,不处理。

最终 connections 为 [connection1,connection2]

  • connection1 {id: 110, useCount: 1, lastActiveTime: 09, active: false}
  • connection2 {id: 100, useCount: 1, lastActiveTime: 00, lastKeepTime: 14, active: false}

此时 activeAcount 依旧为 0。但 connections 中的 lastActiveTime 就不是按时间顺序排列的了。

3.2.5 时间点 21:第三次回收

DestroyTask 再次执行,此时 connection1 空闲时间为 11s,由于不满足条件,即不放入 evictConnections, 也不放入 keepAliveConnections,connection2 空闲时间为 21s,大于 keepAliveBetweenTimeMillis,因此放入 keepAliveConnections。

由于 removeCount 为 1( removeCount = evictCount + keepAliveCount),所以接下来会删掉 connections 的第 0 个元素,也就是 connection1,然后将 keepAliveConnections 添加到 connections 中。

最终 connections 为 [connection2,connection2]

  • connection2 {id: 100, useCount: 1, lastActiveTime: 00, lastKeepTime: 14, active: false}
  • connection2 {id: 100, useCount: 1, lastActiveTime: 00, lastKeepTime: 14, active: false}

3.2.6 时间点 23:获取 connection3

这时继续获取 conenction3,优先从 connections 末尾获取。

获取到 id 为 100 的连接后,继续判断连接的空闲时间。由于 lastActiveTime 小于 lastKeepTime,所以会用 lastKeepTime 来计算空闲时间,空闲时间为 9,小于 timeBetweenEvictionRunsMillis,因此认为连接可用,并返回。

所以 conenction3 为 {id: 100, useCount: 2, lastActiveTime: 00, lastKeepTime: 14, active: true}

同时也会 activeCount 加一,最终 activeCount 为 1。

此时 connections 为:

  • connection2 {id: 100, useCount: 1, lastActiveTime: 00, lastKeepTime: 14, active: false}

3.2.7 时间点 23:获取 connection4

同理,获取到 conenction4 为 {id: 100, useCount: 3, lastActiveTime: 00, lastKeepTime: 14, active: true} ,并且也会将 activeCount 加一,最终 activeCount 为 2。

所以 connection3 和 connection4 本质上是同一个 holder。

另外,此时 connections 为空。

3.2.8 时间点 23:关闭 connection3

紧接着调用 close 方法关闭 connection3,将连接返回连接池,同时将 activeCount 减一。执行完毕后connections 为 [connections3]

  • connection3 {id: 100, useCount: 3, lastActiveTime: 23, lastKeepTime: 14, active: false}

最终 activeCount 为 1,并且 holder 的 active 属性为 false。

3.2.9 时间点 23:关闭 connection4

在关闭 connection3 之后,立即又关闭 connection4, 但由于其 holder 的 active 已经是 false 了,所以 activeCount 不会再减一了。

最终虽然连接会被释放,但 activeCount 少减了一次。

3.3 小结

由此可见,在某些特定条件下,由于 keepAlive 机制会将连接再放回连接池,可能会导致 connections 中会存在多个完全相同的连接 holder,并且该 holder 又可能刚好被多个应用线程获取到了,然后关闭连接时,由于最早关闭的线程已经将 holder 的 active 标记为 false 了,所以其他线程关闭连接时,就不会再把 activeCount 减一。最终导致了 activeCount 不准确。

那什么时候会触发这个 BUG 呢?这个 BUG 是个小概率事件,以下几种情况会提升 BUG 出现的概率:

  • 数据库异常,如网络中断、主备切换、异常重启等,这时可能就会导致 fatalError,进而导致连接回收的 shrink 方法中所有链接都会被放入 keepAliveConnections,进而造成连接池中有多个相同的 holder;
  • timeBetweenEvictionRunsMillis 大于 keepAliveBetweenTimeMillis,可能导致获取连接时,连接空闲时间判断错误,最终导致连接不能正常回收;
  • minEvictableIdleTimeMillis 小于 keepAliveBetweenTimeMillis,可能导致获取连接时,连接空闲时间判断错误,最终导致连接不能正常回收。

4. 问题修复

那这个问题如何修复呢?只要保证 connections 中不能存在两个完全相同的 holder 就可以了。由于问题是有 keepAlive 机制在放回连接时触发的,所以可以在此加个判断,如果连接已经存在连接池中,就不放入。

官方在 1.2.8 这个版本也修复了该问题,详细修复代码参考 druid/compare/1.2.5...1.2.8

image.png

因此我们应用中的终极修复方式就是,将 Druid 升级到 1.2.8。

5. 总结

通过对源码的一层层深入分析,终于弄清楚了为什么会应用报错后会一直挂起,为什么 activeCount 不准确,以及如何解决。

表面上看是获取连接超时,实际上时 activeCount 计算不准确导致无法创建出新的连接。

导致 activeCount 计算不准确的原因是连接池中存在多个相同的连接,因此连接释放时,多次释放相同连接只会执行一次 activeCount--。

之所以连接池可能存在多个相同连接,主要是数据库异常或 Druid 参数配置有误时,刚好导致 keepAlive 将连接重复放入连接池。并且再次获取该连接时,刚好连接空闲时间判断错误,导致连接不被释放而是返回给了应用使用。

本质上是 Druid 1.2.8 之前的一个 BUG,数据库异常以及配置导致的空闲时间判断错误等种种巧合,触发了这个 BUG。但出现问题后通过重启又能解决问题,所以很难定位。

最后,将 Druid 升级到 1.2.8 吧,这样才能彻底解决问题。另外也需要注意以下 Druid 配置:

  • JDBC URL 增加 connectTimeout 和 socketTimeout 配置
  • 设置 maxWait
  • timeBetweenEvictionRunsMillis 小于 keepAliveBetweenTimeMillis
  • keepAliveBetweenTimeMillis 小于 minEvictableIdleTimeMillis

至此,本文结束。感谢阅读!

参考

@nodejh nodejh added the Java label Dec 22, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant