先遭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);
select
IFNULL(sum(remain_principal),0)
from
t_loan where
customer_id in
close=")" separator=",">
#{item}
这部分感觉出问题的原因是由于计算金额总额时,查询参数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 sqlStatMap的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用于展示在页面上。由于平时不使用这个功能,且询问其他同事也不清楚为何开启这个功能,所以决定先把这个功能关闭。
根据文档写这个功能默认是关闭的,不过被我们在配置文件中开启了,现在去掉这个配置就可以了
init-method="init" destroy-method="close">
...
修改完上线后一段时间后没有发生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 mapEntry = (Map.Entry) o;
applyIndex(context, mapEntry.getKey(), uniqueNumber);
applyItem(context, mapEntry.getValue(), uniqueNumber);
} else {
// 以数量i作为key
applyIndex(context, i, uniqueNumber);
applyItem(context, o, uniqueNumber);
}
// FilteredDynamicContext动态生成SQL
contents.apply(new FilteredDynamicContext(configuration, context, index, item, uniqueNumber));
if (first) {
first = !((PrefixedContext) context).isPrefixApplied();
}
context = oldContext;
i++;
}
// SQL的结束字符串
applyClose(context);
context.getBindings().remove(item);
context.getBindings().remove(index);
return true;
}
在每个遍历的时候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语句
select
IFNULL(sum(1),0)
from user where
id in
close=")" separator=",">
#{item}
再写单元测试
@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!!!!");
}
select
IFNULL(sum(1),0)
from user where
id in (#{ids})
这样就能大大减少使用foreach而生成的对象,同时减少拼接SQL的时间,避免OOM发生的同时优化性能。
后记
这两次遇到OOM问题解决起来还算比较轻松的,除了后续分析得当以外,也离不开预先的环境配置。在服务JVM参数中增加-XX:+HeapDumpOnOutOfMemoryError和-XX:+PrintGCDetails参数,可以在发生OOM的时候输出dump文件,并且能有GC日志查看GC的具体情况,这些都是对于OOM问题非常有帮助的。