Druid连接池的监控stat造成内存泄漏
阿里的Druid连接池可以对sql进行监控。但是监控信息会存储在内存中,某些场景下会造成内存泄漏。
1. 起因
线上某台机器报警(堆内存使用率高),登录服务器将堆dump下来,进行分析:
image.png发现:JdbcDataSourceStat
中的sqlStatMap
比较消耗内存。
因为就是Druid
开启stat监控,所以sql信息就会存储到该Map中,占用内存,造成内存泄漏。
stat监控sql信息页面:可以看到会持有sql信息。
image.png当然也有人在github的Druid的Issues上提出了这个问题。每个sql语句都会长期持有引用,加快FullGC频率。
2. 实际分析
sql信息存储到sqlStatMap
的源码如下所示:
public class JdbcDataSourceStat implements JdbcDataSourceStatMBean {
private final LinkedHashMap<String, JdbcSqlStat> sqlStatMap;
public JdbcSqlStat createSqlStat(String sql) {
lock.writeLock().lock();
try {
JdbcSqlStat sqlStat = sqlStatMap.get(sql);
if (sqlStat == null) {
sqlStat = new JdbcSqlStat(sql);
sqlStat.setDbType(this.dbType);
sqlStat.setName(this.name);
sqlStatMap.put(sql, sqlStat);
}
return sqlStat;
} finally {
lock.writeLock().unlock();
}
}
}
我们发现若是sql(key)相同,那么不会put到Map中,那么key是什么样子呢?
经过本地debug分析:
image.png可以知道,sql并没有携带参数,是原始的sql信息。
image.png但在进行分析时,发现sqlStatMap
中存储的key好像都是一个sql???
进行分析后发现:此sql是一个批量语句!
案例复现:当批量操作参数个数不同时,对于sqlStatMap
是不同的key。
分析结论:批量操作,由于参数个数不同,导致
sqlStatMap
存储的数据量大。
3. SpringBoot2.x会自动开启Druid的stat
有同学发现,自己的SpringBoot项目的配置文件中并没有开启stat配置,但是还是出现上面现象。
需要注意的是:SpringBoot2.x可以自动装配Druid。且会自动开启stat监控。
public class DruidFilterConfiguration {
@Bean
@ConfigurationProperties(FILTER_STAT_PREFIX)
@ConditionalOnProperty(prefix ="spring.datasource.druid.filter.stat", name = "enabled", matchIfMissing = true)
@ConditionalOnMissingBean
public StatFilter statFilter() {
return new StatFilter();
}
}
matchIfMissing = true
意思是没有配置spring.datasource.druid.filter.stat=true
,那么会加载该Bean。
解决方案:是在配置类中使用spring.datasource.druid.filter.stat=false
,或者在自己的Configuration
配置StatFilter
这个bean。
当然也会自动开启监控台:
@ConditionalOnWebApplication
@ConditionalOnProperty(name = "spring.datasource.druid.stat-view-servlet.enabled", havingValue = "true", matchIfMissing = true)
public class DruidStatViewServletConfiguration {
@Bean
public ServletRegistrationBean statViewServletRegistrationBean(DruidStatProperties properties) {
DruidStatProperties.StatViewServlet config = properties.getStatViewServlet();
ServletRegistrationBean registrationBean = new ServletRegistrationBean();
registrationBean.setServlet(new StatViewServlet());
registrationBean.addUrlMappings(config.getUrlPattern() != null ? config.getUrlPattern() : "/druid/*");
if (config.getAllow() != null) {
registrationBean.addInitParameter("allow", config.getAllow());
}
if (config.getDeny() != null) {
registrationBean.addInitParameter("deny", config.getDeny());
}
if (config.getLoginUsername() != null) {
registrationBean.addInitParameter("loginUsername", config.getLoginUsername());
}
if (config.getLoginPassword() != null) {
registrationBean.addInitParameter("loginPassword", config.getLoginPassword());
}
if (config.getResetEnable() != null) {
registrationBean.addInitParameter("resetEnable", config.getResetEnable());
}
return registrationBean;
}
}
可访问http://ip:端口/druid/sql.html
查看控制台,默认密码没有配置。
4. 解决方案
Druid的监控统计功能是通过filter-chain扩展实现,如果你要打开监控统计功能,配置StatFilter,具体看这里:https://github.com/alibaba/druid/wiki/配置_StatFilter
4.1 方案一:直接关闭Druid的stat
显式的在配置文件使用spring.datasource.druid.filter.stat=false
。
4.2 方案二:开启sql合并
结构重复的sql语句的sql比较多,可以开启sql合并。例如:批量操作导致sqlStatMap过大可以采用这种方案。
SQL监控的LinkedHashMap<String, JdbcSqlStat> sqlStatMap
是以SQL语句作为键的。针对上面批量处理导致大量的sql存储到sqlStatMap
的问题,可以开启sql
合并。
可以看到,只保留sql的结构,忽略sql的参数。
image.png解决方案:
或者通过增加JVM的参数配置:
-Ddruid.stat.mergeSql=true
或者
spring:
druid:
connectionProperties: druid.stat.mergeSql=true
或者
@Configuration
public class DruidConfig {
@Bean
public StatFilter statFilter() {
StatFilter statFilter = new StatFilter();
statFilter.setMergeSql(true);
return statFilter;
}
}
4.3 方案三:控制sqlStatMap大小
有业务需求不能合并sql或者合并了sql也没有太大效果(结构重复的sql语句不多),也可以不设置sql合并而是设置druid.stat.sql.MaxSize(默认1000个)。
源码:
public class JdbcDataSourceStat implements JdbcDataSourceStatMBean {
sqlStatMap = new LinkedHashMap<String, JdbcSqlStat>(16, 0.75f, false) {
protected boolean removeEldestEntry(Map.Entry<String, JdbcSqlStat> eldest) {
boolean remove = (size() > maxSqlSize);
if (remove) {
JdbcSqlStat sqlStat = eldest.getValue();
if (sqlStat.getRunningCount() > 0 || sqlStat.getExecuteCount() > 0) {
skipSqlCount.incrementAndGet();
}
}
return remove;
}
};
}
LinkedHashMap有一个 removeEldestEntry(Map.Entry eldest)方法,通过覆盖这个方法,加入一定的条件,满足条件返回true。当put进新的值方法返回true时,便移除该map中最老的键和值。
sqlStatMap
重写了removeEldestEntry
方法,来控制最大数量。
解决方案:
或者通过增加JVM的参数配置:
-Ddruid.stat.sql.MaxSize=100
或者
spring:
druid:
connectionProperties: druid.stat.sql.MaxSize=100