在给某个陈旧项目的连接池组件替换成Druid,开开心心地用上了网上淘来的一份推荐配置,在本地运行了下没有问题便发布到环境上去试跑看下,随后大概是过了一杯咖啡的时间,环境上就出现了如下报错信息,
[DEBUG][com.alibaba.druid.pool.DruidDataSource:1319][-][-] skip not validate connection.
[DEBUG][com.alibaba.druid.util.JdbcUtils:75][-][-] close connection error
com.mysql.jdbc.exceptions.jdbc4.MySQLNonTransientConnectionException: Communications link failure. Transaction resolution unknown.
再过了一段时间后还看到keepalive有关的报错信息,
[Druid-ConnectionPool-Destroy-228168005][ERROR][com.alibaba.druid.pool.DruidDataSource:2853][-][-] keepAliveErr
com.mysql.jdbc.exceptions.jdbc4.CommunicationsException: Communications link failure
The last packet successfully received from the server was 58,663 milliseconds ago. The last packet sent successfully to the server was 3,008 milliseconds ago.
先贴一份跟这次案发现场有关的Druid池的部分配置——
// 从连接池获取连接后,如果超过被空闲剔除周期,是否做一次连接有效性检查
testWhileIdle=true
// 从连接池获取连接后,是否马上执行一次检查
testOnBorrow=false
// 归还连接到连接池时是否马上做一次检查
testOnReturn=false
// 是否开启连接保活
keepAlive=true
// 周期性剔除长时间呆在池子里未被使用的空闲连接, 1 min 一次
timeBetweenEvictionRunsMillis=60000;
// 设置连接最少存活时长和最大存活时长,超过上限才会被清理
minEvictableIdleTimeMillis=200000
maxEvictableIdleTimeMillis=280000
另外还有一个同样关键的配置,那就是MySQL的超时配置是300秒,超过300秒MySQL会主动关闭链接(我是直连MySQL的,如果是走LVS->RDS这样的链路的话就需要看LVS对连接存活的保留时间了)。一些有经验的同学看到这个配置可能已经发现问题了,还没发现的也不急,接下去看。
线索日志基本都给了,先来看看com.alibaba.druid.pool.DruidDataSource
打印了关键日志的地方,
if (idleMillis >= timeBetweenEvictionRunsMillis
|| idleMillis < 0 // unexcepted branch
) {
boolean validate = testConnectionInternal(poolableConnection.holder, poolableConnection.conn);
if (!validate) {
if (LOG.isDebugEnabled()) {
LOG.debug("skip not validate connection.");
}
discardConnection(realConnection);
continue;
}
}
这里的逻辑是这样的,从池中拿到链接后,如果空闲时间超过了我们设置的空闲剔除周期(timeBetweenEvictionRunsMillis=60000
),就会触发一次链接有效性检查(testConnectionInternal(poolableConnection.holder, poolableConnection.conn)
),从日志打印的结果(skip not validate connection.
)来看,链接被检查出无效,于是触发了discardConnection(realConnection)
方法放弃该条链接,该方法会调用com.alibaba.druid.util.JdbcUtils
的close()
方法,而报错就是这个close()
里面抛出的。
/**
* 抛弃连接,不进行回收,而是抛弃
*
* @param realConnection
*/
public void discardConnection(Connection realConnection) {
JdbcUtils.close(realConnection);
lock.lock();
try {
activeCount--;
discardCount++;
if (activeCount <= minIdle) {
emptySignal();
}
} finally {
lock.unlock();
}
}
/**
* com.alibaba.druid.util.JdbcUtils.close()
*
*/
public static void close(Connection x) {
if (x == null) {
return;
}
try {
x.close();
} catch (Exception e) {
LOG.debug("close connection error", e);
}
}
源码浏览到这里其实基本可以推测出来报错的原因了,在testConnectionInternal()
这个链接有效性检测的范围里面,包含了已经被MySQL主动关闭的链接,即如果链接被MySQL单方面主动关闭,链接有效性检测的结果也是false
,此时再执行链接的close()
方法就会报com.mysql.jdbc.exceptions.jdbc4.MySQLNonTransientConnectionException: Communications link failure. Transaction resolution unknown.
的异常。为了验证这个想法,去MySQL侧查询信息,
发现确实存在有链接一直存活到MySQL的上限300秒后才消失,而不是在我期望的280秒(maxEvictableIdleTimeMillis=280000
)内即被Druid关闭。
但是池里为什么会出现超过了MySQL存活时间上限的链接,是不是清理的线程出现了问题,于是去查看了Druid代码中清理链接的部分(com.alibaba.druid.pool.DruidDataSource.DestroyTask
, 这里也包含了保活检查的逻辑),分析的过程直接在下面的代码里加注释了——
public class DestroyConnectionThread extends Thread {
public DestroyConnectionThread(String name){
super(name);
this.setDaemon(true);
}
public void run() {
initedLatch.countDown();
for (;;) {
// 从前面开始删除
try {
if (closed) { // 默认false
break;
}
if (timeBetweenEvictionRunsMillis > 0) { // 若我们有配置该值,则睡眠该值时长
Thread.sleep(timeBetweenEvictionRunsMillis);
} else {
Thread.sleep(1000); // 默认睡眠1s
}
if (Thread.interrupted()) {
break;
}
destroyTask.run(); // 清理任务每timeBetweenEvictionRunsMillis或者1秒执行一次
} catch (InterruptedException e) {
break;
}
}
}
}
public class DestroyTask implements Runnable {
@Override
public void run() {
shrink(true, keepAlive); // 清除和保活的逻辑主要看这个方法
if (isRemoveAbandoned()) {
removeAbandoned();
}
}
}
public void shrink(boolean checkTime, boolean keepAlive) {
try {
lock.lockInterruptibly();
} catch (InterruptedException e) {
return;
}
int evictCount = 0;
int keepAliveCount = 0;
try {
if (!inited) {
return;
}
final int checkCount = poolingCount - minIdle;
final long currentTimeMillis = System.currentTimeMillis();
for (int i = 0; i < poolingCount; ++i) {
DruidConnectionHolder connection = connections[i];
if (checkTime) { // 在这个场景里checkTime=true
if (phyTimeoutMillis > 0) { // 设置的物理链接超时时间,如MySQL默认的8小时wait_time,超过则放到待清理链接数组
long phyConnectTimeMillis = currentTimeMillis - connection.connectTimeMillis;
if (phyConnectTimeMillis > phyTimeoutMillis) {
evictConnections[evictCount++] = connection;
continue;
}
}
long idleMillis = currentTimeMillis - connection.lastActiveTimeMillis;
if (idleMillis < minEvictableIdleTimeMillis) { // 从这里可以看出,仅当链接空闲时长大于我们配置的minEvictableIdleTimeMillis时长后续逻辑才会执行
break;
}
if (checkTime && i < checkCount) { // 仅清理超出允许存在的空闲链接数量之外的链接
evictConnections[evictCount++] = connection;
} else if (idleMillis > maxEvictableIdleTimeMillis) { // 空闲时长大于最大存活时长时需要被清理
evictConnections[evictCount++] = connection;
} else if (keepAlive) { // 仅当空闲链接未超出允许的存活数量上限且空闲时长未超过最大存活时长时执行保活逻辑
keepAliveConnections[keepAliveCount++] = connection;
}
} else {
if (i < checkCount) {
evictConnections[evictCount++] = connection;
} else {
break;
}
}
}
int removeCount = evictCount + keepAliveCount;
if (removeCount > 0) {
System.arraycopy(connections, removeCount, connections, 0, poolingCount - removeCount);
Arrays.fill(connections, poolingCount - removeCount, poolingCount, null);
poolingCount -= removeCount;
}
keepAliveCheckCount += keepAliveCount;
} 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);
}
Arrays.fill(evictConnections, null);
}
if (keepAliveCount > 0) { // 保活逻辑
this.getDataSourceStat().addKeepAliveCheckCount(keepAliveCount);
// keep order
for (int i = keepAliveCount - 1; i >= 0; --i) {
DruidConnectionHolder holer = keepAliveConnections[i];
Connection connection = holer.getConnection();
holer.incrementKeepAliveCheckCount();
boolean validate = false;
try {
this.validateConnection(connection); // 检测链接有效性
validate = true;
} catch (Throwable error) {
if (LOG.isDebugEnabled()) {
LOG.debug("keepAliveErr", error);
}
// skip
}
if (validate) { // 具体的保活逻辑,就是重新设置链接的最后一次活跃时间
holer.lastActiveTimeMillis = System.currentTimeMillis();
put(holer);
} else {
try {
connection.close();
} catch (Exception e) {
// skip
}
}
}
Arrays.fill(keepAliveConnections, null);
}
}
再回过头看开始的配置分析报错原因,
// 周期性剔除长时间呆在池子里未被使用的空闲连接, 1 min 一次
timeBetweenEvictionRunsMillis=60000;
// 设置连接最少存活时长和最大存活时长,超过上限才会被清理
minEvictableIdleTimeMillis=200000
maxEvictableIdleTimeMillis=280000
原来,我设置的清理任务每60秒执行一次,假设某个时刻清理任务开始执行,而此时被扫描到有一条链接的存活时长已经来到了270秒,此时它未超过280秒的上限所以它没有被清理,而经过了下一个60秒的检查周期,此时这条链接已经存活了340秒(假设它之后没有被业务线程获取过,所以时长不会刷新),在这一轮的清理周期内它会被清理掉,然而此时该链接也已经超过了MySQL的链接wait_time时长(300秒),所以此时去直接close这条链接就会报close connection error
错误。找到了原因之后,只要修改配置保证在两个清理周期内超时链接一定会被清理掉就不会出现上述问题了,比如下面给出的配置——
// 周期性剔除长时间呆在池子里未被使用的空闲连接, 1 min 一次
timeBetweenEvictionRunsMillis=60000;
// 设置连接最少存活时长和最大存活时长,超过上限才会被清理,需要注意满足(maxEvictableIdleTimeMillis-minEvictableIdleTimeMillis>timeBetweenEvictionRunsMillis)的条件
minEvictableIdleTimeMillis=160000
maxEvictableIdleTimeMillis=230000
为了确保Druid的清理线程按照我们期望的执行清理逻辑,我们需要做到,