先遭DruidDataSource
袭击
事发
一个平凡的工作日,我像往常一样完成产品提出的需求的业务代码,突然收到了监控平台发出的告警信息。本以为又是一些业务上的bug导致的报错,一看报错发现日志写着java.lang.OutOfMemoryError: Java heap space
。
接着我远程到那台服务器上,但是卡的不行。于是我就用top
命令查了一下cpu信息,占用都快要到99%了。再看看GC的日志发现程序一直在Full GC,怪不得cpu占用这么高。
这里就推测是有内存泄漏的问题导致GC无法回收内存导致OOM
。为了先不影响业务,就先让运维把这个服务重启一下,果然重启后服务就正常了。
分析日志
先看一下报错日志详细写了一些什么错误信息,虽然一般OOM
问题日志不能准确定位到问题,但是已经打开日志平台了,看一下作为参考也是不亏的。
看到日志中写的OOM
事发场景是在计算多个用户的总金额的时候出现的,大致伪代码如下:
/**
* OrderService.java
*/
// 1. 根据某些参数获取符合条件的用户id列表
List customerIds = orderService.queryCustomerIdByParam(param);
// 2. 计算这些用户id的金额总和
long principal = orderMapper.countPrincipal(customerIds);
这部分感觉出问题的原因是由于计算金额总额时,查询参数customerIds
太多了。由于前段时间业务的变更,导致在参数不变的情况下,查询出的customerIds
列表由原来的几十几百个id变成了上万个,就我看的报错信息这里的日志打印出来这个list的大小就有三万多个customerId
。不过就算查询条件为三万多个而导致sql执行的比较慢,但是这个方法只有内部的财务系统才会调用,业务量没那么大,也不应该导致OOM
的出现啊。
所以接着再看一下JVM
打印出来的Dump文件来定位到具体的问题。
分析Dump文件
得益于在JVM参数中加了-XX:+HeapDumpOnOutOfMemoryError
参数,在发生OOM
的时候系统会自动生成当时的Dump文件,这样我们可以完整的分析“案发现场”。这里我们使用Eclipse Memory Analyzer工具来帮忙解析Dump文件。
从Overview中的饼图可以很明显的看到有个蓝色区域占了最大头,这个类占了245.6MB的内存。再看左侧的说明写着DruidDataSource
,好的,罪魁祸首就是他了。
再通过Domainator_Tree界面可以看到是com.alibaba.druid.pool.DruidDataSource
类下的com.alibaba.druid.stat.JdbcDataSourceStat$1
对象里面有个LinkedHashMap,这个Map持有了600多个Entry,其中大约有100个Entry大小为2000000多字节(约2MB)。而Entry的key是String对象,看了一下String的内容大约都是select IFNULL(sum remain_principal),0) from t_loan where customer_id in (?, ?, ?, ? ...
,果然就是刚才错误日志所提示的代码的功能。
问题分析
由于计算这些用户金额的查询条件有3万多个所以这个SQL语句特别长,然后这些SQL都被JdbcDataSourceStat
中的一个HashMap对象所持有导致无法GC,从而导致OOM
的发生。
嗯,简直是教科书般的OOM
事件。
处理
接下来去看了一下JdbcDataSourceStat
的源码,发现有个变量为LinkedHashMap
的Map。并且还有个静态变量和静态代码块:
private static JdbcDataSourceStat global;
static {
String dbType = null;
{
String property = System.getProperty("druid.globalDbType");
if (property != null && property.length() > 0) {
dbType = property;
}
}
global = new JdbcDataSourceStat("Global", "Global", dbType);
}
这就意味着除非手动在代码中释放global
对象或者remove
掉sqlStatMap
里的对象,否则sqlStatMap
就会一直被持有不能被GC释放。
已经定位到问题所在了,不过简单的从代码上看无法判定这个sqlStatMap
具体是有什么作用,以及如何使其释放掉,于是到网上搜索了一下,发现在其Github的Issues里就有人提出过这个问题了。每个sql语句都会长期持有引用,加快FullGC频率。
sqlStatMap
这个对象是用于Druid
的监控统计功能的,所以要持有这些SQL用于展示在页面上。由于平时不使用这个功能,且询问其他同事也不清楚为何开启这个功能,所以决定先把这个功能关闭。
根据文档写这个功能默认是关闭的,不过被我们在配置文件中开启了,现在去掉这个配置就可以了
...
修改完上线后一段时间后没有发生OOM
了,这时再通过jmap -dump:format=b,file=文件名 [pid]
命令生成Dump文件,会发现内存占用恢复正常,并且也不会看到com.alibaba.druid.pool.DruidDataSource
类下有com.alibaba.druid.stat.JdbcDataSourceStat$1
的占用。证明这个OOM
问题已经被成功解决了。
再遭Mybatis
暗算
事发
又是一个平凡的工作日,线上告警又出现报错,我一看日志又是OOM
。我以为上次DruidDataSource
问题还没解决干净,但是这次的现象却有点不一样。首先是这次只告警了一次,不像上次一直在告警。然后远程到服务器看cpu和内存占用正常,业务也没有受影响,所以这次也不用重启服务了。
分析
这次告警的错误日志还是指向着上次DruidDataSource
导致OOM
异常的位置,由于对其印象深刻,所以这次直接看看Dump文件(由于Dump文件比较大,线上的被清除了,而我也忘记备份,所以这份文件是我时候场景还原的时候生成的)。
这次没有明显的一个特别大的占用对象了,看来这次的问题确实和上次有所不一样。再去看看Domainator_Tree界面的具体分析。
虽然没有出现一个对象占用内存,但是可以看到有十几个线程都占用近20M的内存大小,加起来就要占用300多M的内存了。再看一下这些线程中内存占用是怎样的。
从这个线程的高占用内存情况来看,有几个是String类型的,是拼接SQL后的语句,这些是必不可少的。
还有两个高内存占用对象是org.apache.ibatis.scripting.xmltags.DynamicContext$ContextMap
和org.apache.ibatis.builder.SqlSourceBuilder$ParameterMappingTokenHandler
。
从这两个对象的内容看似乎是Mybatis
拼接SQL的时候生成的占位符和参数对象。就比如下面的这个查询语句
List customerIds = orderService.queryCustomerIdByParam(param);
long principal = orderMapper.countPrincipal(customerIds);
所以虽然用于查询的参数为Long
的类型,即使这个List
有三万多个其本身也不会占用很大的内存,但是Mybatis
在拼接SQL的时候,会把Long
类型的对象包装成一个通用的对象类型(类似于AbstractItem的感觉),并且会给每一个通用对象类型起一个别名(比如__frch_item_1
, __frch_item_2
这样),然后存放在Map中在拼接SQL的时候使用。又由于拼接SQL字符串还是比较消耗资源,当参数多SQL长的时候还是需要一定的时间的,这时候Map就会持有较长时间,一旦有较多线程同时做这种操作,内存占用就高,很容易发生OOM
。
查看Mybatis
源码分析
首先看org.apache.ibatis.scripting.xmltags.DynamicContext$ContextMap
,他是DynamicContext
的一个变量,变量名为bindings
,是DynamicContext
的内部类,继承了HashMap
。并且DynamicContext
类里用了bind()
方法包装了HashMap
的put
()方法。
再利用IDEA的Usages of
查看功能,看看哪些方法调用了bind()
方法。
可以看到有三个类调用bind()
方法,这里只用关注org.apache.ibatis.scripting.xmltags.ForEachSqlNode
这个类,因为我们在Mybatis
的xml
里用了foreach
关键字来实现SQL的in查询功能。那我们大致来看一下ForEachSqlNode
这个类有什么特别的地方可能导致oom
的。
ForEachSqlNode
实现了SqlNode
接口并实现了apply()
方法,这个方法是拼接SQL语句的核心,下面是apply()
方法的代码,我为一些关键步骤加了中文注释。
@Override
public boolean apply(DynamicContext context) {
// bindings就是上面说到的占用大内存的对象
Map bindings = context.getBindings();
final Iterable> iterable = evaluator.evaluateIterable(collectionExpression, bindings);
if (!iterable.iterator().hasNext()) {
return true;
}
boolean first = true;
// SQL的开始字符串
applyOpen(context);
int i = 0;
// 遍历参数
for (Object o : iterable) {
DynamicContext oldContext = context;
if (first || separator == null) {
context = new PrefixedContext(context, "");
} else {
context = new PrefixedContext(context, separator);
}
int uniqueNumber = context.getUniqueNumber();
// Issue #709
if (o instanceof Map.Entry) {
// 如果是Map对象则用key value的形式
@SuppressWarnings("unchecked")
Map.Entry
在每个遍历的时候applyIndex()
和applyItem()
方法就会将参数和参数的占位符,以及参数SQL前后缀调用上面说的bind()
方法存在bindings
里。
private void applyIndex(DynamicContext context, Object o, int i) {
if (index != null) {
context.bind(index, o);
context.bind(itemizeItem(index, i), o);
}
}
private void applyItem(DynamicContext context, Object o, int i) {
if (item != null) {
context.bind(item, o);
context.bind(itemizeItem(item, i), o);
}
}
接着用FilteredDynamicContext
处理占位符,这是ForEachSqlNode
的一个内部类,继承了DynamicContext
类,主要重写了appendSql()
方法。
private static class FilteredDynamicContext extends DynamicContext {
...
@Override
public void appendSql(String sql) {
GenericTokenParser parser = new GenericTokenParser("#{", "}", content -> {
String newContent = content.replaceFirst("^\\s*" + item + "(?![^.,:\\s])", itemizeItem(item, index));
if (itemIndex != null && newContent.equals(content)) {
newContent = content.replaceFirst("^\\s*" + itemIndex + "(?![^.,:\\s])", itemizeItem(itemIndex, index));
}
return "#{" + newContent + "}";
});
delegate.appendSql(parser.parse(sql));
}
appendSql()
用正则来查找替换#{}
占位符里的内容,但这里也不是真正的绑定参数,只是替换刚才存在bindings
里面的占位符号,例如__frch_item_1
, __frch_item_2
(在Dump文件中看到的)。
问题分析
由此可以看出问题是,Mybatis
的foreach
拼接SQL的性能较差,尤其是通过正则之类的操作匹配占位符时需要较多的时间。同时又持有查询参数和占位符在ContextMap
中无法被GC释放,所以一旦并发量上去就很容易内存占用过多导致OOM
。
场景复现
这个问题在本地很容易复现,我们先创建数据库表
CREATE TABLE user
(
id int(11) PRIMARY KEY NOT NULL,
name varchar(50)
);
创建一个SpringBoot+Mybatis
的工程项目。并且模拟线上的JVM配置,在IDEA设置这个工程的VM Option
参数-Xmx512m -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
写出对应模拟线上的foreach
语句
再写单元测试
@Test
public void count() {
AtomicInteger count = new AtomicInteger(0);
for (int threadId = 0; threadId < 50; threadId++) {
// 起50个线程并发调用countUser()方法
int finalThreadId = threadId;
new Thread(() -> {
long userCount = userMapper.countUser(createIds(10000 + finalThreadId));
log.info("thread:{}, userCount:{}", finalThreadId, userCount);
count.getAndAdd(1);
}).start();
}
// 等待50个查询线程跑完
while (count.get() < 50) {
}
log.info("end!!!!");
}
private List createIds(int size) {
List ids = new ArrayList<>();
for (int i = 0; i < size; i++) {
ids.add((long) i);
}
return ids;
}
接着运行单元测试。由于在JVM配置加了-XX:+PrintGCDetails
参数,所以在控制台会显示GC日志,不一会就会看见很多的Full GC,然后程序就会出现OOM
报错。
处理
由于问题是Mybatis
通过foreach
拼接长SQL字符串性能太差导致的,所以解决思路有两种
- 通过拆分in的查询条件来减少
foreach
每次拼接SQL的长度
@Test
public void count2() {
AtomicInteger count = new AtomicInteger(0);
for (int threadId = 0; threadId < 50; threadId++) {
// 起50个线程并发调用countUser()方法
int finalThreadId = threadId;
new Thread(() -> {
List totalIds = createIds(100000 + finalThreadId);
long totalUserCount = 0;
//使用guava对list进行分割,按每1000个一组分割
List> parts = Lists.partition(totalIds, 1000);
for (List ids : parts) {
totalUserCount += userMapper.countUser(ids);
}
log.info("thread:{}, userCount:{}", finalThreadId, totalUserCount);
count.getAndAdd(1);
}).start();
}
// 等待50个查询线程跑完
while (count.get() < 50) {
}
log.info("end!!!!");
}
这样每次拼接查询SQL的时候只用循环1000次,很快就可以把资源释放掉,就不会引起OOM
,但是这种方法还是会生成很多不必要的数据占用内存,频繁触发GC,浪费资源。
- 不使用
Mybatis
的foreach
来拼接in条件的SQL
既然Mybatis
的foreach
性能不好,那我们通过Java层面自己拼接in条件,特别是针对这种查询条件也比较单一的,更适合自己拼接。
@Test
public void count3() {
AtomicInteger count = new AtomicInteger(0);
for (int threadId = 0; threadId < 50; threadId++) {
// 起50个线程并发调用countUser()方法
int finalThreadId = threadId;
new Thread(() -> {
List ids = createIds(100000 + finalThreadId);
StringBuilder sb = new StringBuilder();
for (long i : ids) {
sb.append(i).append(",");
}
// 查询条件使用String字符串
long userCount = userMapper.countUserString(sb.toString());
log.info("thread:{}, userCount:{}", finalThreadId, userCount);
count.getAndAdd(1);
}).start();
}
// 等待50个查询线程跑完
while (count.get() < 50) {
}
log.info("end!!!!");
}
这样就能大大减少使用foreach
而生成的对象,同时减少拼接SQL的时间,避免OOM
发生的同时优化性能。
后记
这两次遇到OOM
问题解决起来还算比较轻松的,除了后续分析得当以外,也离不开预先的环境配置。在服务JVM参数中增加-XX:+HeapDumpOnOutOfMemoryError
和-XX:+PrintGCDetails
参数,可以在发生OOM
的时候输出dump文件,并且能有GC日志查看GC的具体情况,这些都是对于OOM
问题非常有帮助的。