一、问题分析
前段时间有同事和我说我们自测环境新增了几个小服务第一此访问的时候总是卡卡的,后面就好了,想让我排查一下。心里想了几种可能
- Apache代理服务器与后台服务器首次握手慢,后续请求复用长链接无卡顿现象。我们自测环境前端服务器用的是apache,配置了反向代理到后端小服务。如果客户端请求超过了代理服务器设置的最大链接数,当前请求时不会被转发到后台服务器的。
- 一个服务第一次被调用时会触发很多类的加载和初始化,但这个往往是第一次访问卡,后续访问就不卡了。因为类的卸载是比较苛刻的所以大多数类加载到jvm后等进程结束时才会被卸载。所以这种情况不会出现间隔性卡顿(即:第一次请求卡紧接着请求不卡,等半小时或一小时再请求又卡),仅仅第一次访问卡顿。
- 由于后台服务用到了数据库连接池、线程池、redis连接池可能由于参数配置不合理导致,链接长时间没被使用导致过期销毁。
首先尝试跳过代理服务器用postman直接访问后台服务器接口看是否可以复现,结果是复现了,那就排除了情况1。虽然看现象更贴近3,直接去排查连接池、线程池、redis连接池参数可能可以直接定位问题。但万一是这个后台服务器网络IO层面的问题呢?是为了避免忽略细节走弯路,首先要判断下这个请求是否能及时被服务端接收到。我用Arthas对这个后台服务进行一个监控,监控一下web层Handler方法执行时间。
如上图可以发现postman方法执行耗时6s,而在labelStatics中调用了staticsService.staticsLabel()方法耗时5.7s,所以可以彻底排查服务器网络IO问题了,完完全全是服务本身问题(我干~~)。使用Arthas继续对调用链路进行追踪最后发现卡顿之处在与数据库交互的方法上。
然后我理所当然去监控了一下数据池获取数据库链接的DruidDataSource.getConnection()方法,果然罪魁祸首就是它。
至此我们已经定位到了卡顿原因,由于线程池参数配置有误导致线程失效。这个小服务还在开发阶段线程池的配置就是随便网上copy的如下。
spring.datasource.primary.url=****
spring.datasource.primary.username=***
spring.datasource.primary.password=***
spring.datasource.primary.driver-class-name=org.postgresql.Driver
spring.datasource.primary.max-active=50
spring.datasource.primary.initial-size=15
spring.datasource.primary.min-idle=0
对于Druid数据池我也没有深入了解过仅仅知道简单参数配置,以及它是一个生产消费者模式的连接池,里面包含了创建连接线程、销毁连接线程、消费连接线程(请求对getConnection方法的调用)。也趁此机会翻了一下Druid数据池的源码,Druid数据池的源码帖子网上很多,这里只探讨下导致卡顿问题出现的源码部分。
先说结论:因为当前服务在自测环境调用方不多,且没有操作数据库的定时任务,在min-idle=0的情况下初始化的15个连接在半小时内如果没有被使用到将会被销毁连接线程销毁掉。
二、Druid销毁连接线程源码分析
Druid销毁连接线程为DestroyConnectionThread
public class DestroyConnectionThread extends Thread {
public DestroyConnectionThread(String name){
super(name);
this.setDaemon(true);
}
public void run() {
initedLatch.countDown();
while (true) {
try {
if (closed) {
break;
}
// timeBetweenEvictionRunsMillis默认值为60s。即60s调用一次destroyTask.run();方法
// timeBetweenEvictionRunsMillis可以通过配置修改
if (timeBetweenEvictionRunsMillis > 0) {
Thread.sleep(timeBetweenEvictionRunsMillis);
} else {
Thread.sleep(1000); //
}
if (Thread.interrupted()) {
break;
}
destroyTask.run();
} catch (InterruptedException e) {
break;
}
}
}
}
看下核心逻辑destroyTask.run();
public class DestroyTask implements Runnable {
@Override
public void run() {
// 回收未被使用的连接逻辑
shrink(true, keepAlive);
// 如果remove-abandoned=true则会调用回收正在被使用的连接逻辑,防止出现代码逻辑出现死锁占用连接不释放的情况
if (isRemoveAbandoned()) {
removeAbandoned();
}
}
}
引发我们卡顿的逻辑在shrink(true, keepAlive)中,连接尚未被使用就过期了。在分析这块代码之前先说一下Druid中有三个存放连接的关键数组:
- connections[] 存放可以使用的连接,当有请求调用getConnection获取连接时就是从这里面取的。
- keepAliveConnections[] 在DestroyConnectionThread在扫描connections[]中的连接进行回收时,满足某些条件的连接会被放入keepAliveConnections[]中后续会对keepAliveConnections[]中的连接进行有效性验证如果有效还可以复活更新其最后活跃时间并重新放入connections[]中,如果无效则关闭销毁连接
- evictConnections[] 在DestroyConnectionThread在扫描connections[]中的连接进行回收时,满足某些条件的连接会被放入evictConnections[]中,后续全部关闭销毁。
public void shrink(boolean checkTime, boolean keepAlive) {
try {
lock.lockInterruptibly();
} catch (InterruptedException e) {
return;
}
int evictCount = 0;
int keepAliveCount = 0;
try {
if (!inited) {
return;
}
// poolingCount为connections[]数组的有效长度,minIdle对应配置项中的min-idle,我们当前项目配置的0.所以checkCount=poolingCount。
final int checkCount = poolingCount - minIdle;
final long currentTimeMillis = System.currentTimeMillis();
// 遍历connections[]中的连接
for (int i = 0; i < poolingCount; ++i) {
DruidConnectionHolder connection = connections[i];
// 是否开始时间筛选,如果不开启则直接将连接回收到数量=min-idle
if (checkTime) {
// phyTimeoutMillis为连接的允许存活时间,默认为-1,如果配置了,只要 '当前时间-连接创建时间>phyTimeoutMillis' 则直接将连接添加到evictConnections[]数组中后续关闭
if (phyTimeoutMillis > 0) {
long phyConnectTimeMillis = currentTimeMillis - connection.connectTimeMillis;
if (phyConnectTimeMillis > phyTimeoutMillis) {
evictConnections[evictCount++] = connection;
continue;
}
}
// idleMillis为连接的空闲时间
long idleMillis = currentTimeMillis - connection.lastActiveTimeMillis;
// 如果连接空闲时间小于minEvictableIdleTimeMillis最小空闲时间则不做处理,minEvictableIdleTimeMillis默认为半小时
if (idleMillis < minEvictableIdleTimeMillis) {
break;
}
// 到这里说明当前连接 '空闲时间>minEvictableIdleTimeMillis',在这个项目中checkCount==poolingCount,
// 'checkTime && i < checkCount'恒成立,所以只要空闲时间超过minEvictableIdleTimeMillis的连接就会被回收
// 我吐了。我理解这个配置存在的意义是将空闲线程回收到数量=minIdle
if (checkTime && i < checkCount) {
evictConnections[evictCount++] = connection;
} else if (idleMillis > maxEvictableIdleTimeMillis) {
// 当 '空闲时间>maxEvictableIdleTimeMillis最大空闲时间' 时添加到evictConnections数组直接关闭,maxEvictableIdleTimeMillis的默认值是7小时
evictConnections[evictCount++] = connection;
} else if (keepAlive) {
// 当 'minEvictableIdleTimeMillis<空闲时间<maxEvictableIdleTimeMillis'且keep-alive=true
// 时将连接添加到keepAliveConnections数组中进行后续有效性验证
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();
}
// 关闭evictConnections[]数组中的连接
if (evictCount > 0) {
for (int i = 0; i < evictCount; ++i) {
DruidConnectionHolder item = evictConnections[i];
Connection connection = item.getConnection();
JdbcUtils.close(connection);
destroyCount.incrementAndGet();
}
Arrays.fill(evictConnections, null);
}
// 判断keepAliveConnections[]数组中的连接是否有效,如果有效更新其最后活跃时间lastActiveTime并放入connections[]数组中,无效的关闭连接
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.setLastActiveTimeMillis(System.currentTimeMillis());
put(holer);
} else {
JdbcUtils.close(connection);
}
}
Arrays.fill(keepAliveConnections, null);
}
}
至此我们彻底警情定位了问题所在就是我们Druid参数配置不合理导致的。
三、Druid 参数修改
然后我重新修改了Druid 参数,问题也是顺利解决
- spring.datasource.druid.initial-size=15
初始化连接数 - spring.datasource.druid.max-active=100
最大活跃连接数,包括池内和池外 max-active>=activeCount(池外) + poolingCount(connections数组的有效长度) - spring.datasource.druid.min-idle=15
连接池内最小可用连接数,回收空闲连接时,将保证至少有minIdle个连接. - spring.datasource.druid.max-wait=60000
获取连接超时的时间,即getConnection方法的超时时间 - spring.datasource.druid.time-between-eviction-runs-millis=60000
Druid销毁连接线程为DestroyConnectionThread的调用间隔 - spring.datasource.druid.min-evictable-idle-time-millis=300000
连接最小空闲时间 - spring.datasource.druid.test-on-borrow=false
如果为true则获取连接时会验证一下连接的有效性,生产环境开启影响性能 - spring.datasource.druid.test-on-return=false
如果为true则返回链接时会验证一下连接的有效性,生产环境开启影响性能 - spring.datasource.druid.test-while-idle=true
如果为true则获取连接时且当前连接的空闲时间>time-between-eviction-runs-millis时会验证连接的有效性,生产环境开启防止DestroyConnectionThread由于系统资源紧张得不到cpu执行时连接未被回收失效 - spring.datasource.druid.validation-query=SELECT 1
验证连接有效性时执行的语句 - spring.datasource.druid.validation-query-timeout=1000
验证有效性语句SELECT 1的超时时间,超过这个时间则认为无效 - spring.datasource.druid.keep-alive=true
对于空闲时间超过min-evictable-idle-time-millis的连接如果keep-alive为true时,则会调用validation-query对其进行验证并更新最后活跃时间保证连接池内最小可用连接数不小于 min-idle - spring.datasource.druid.remove-abandoned=true
如果remove-abandoned=true则会调用回收正在被使用的连接逻辑,防止出现代码逻辑出现死锁占用连接不释放的情况 - spring.datasource.druid.remove-abandoned-timeout=180
设置druid 强制回收连接的时限,当程序从池中get到连接开始算起,超过此 值后,druid将强制回收该连接,单位秒。应大于业务运行最长时间 - spring.datasource.druid.log-abandoned=true
当druid强制回收连接后,是否将stack trace 记录到日志中