近期遇到线上出现504报错,查看gc情况发现频繁oldgc,于是尽快回滚线上代码,然后开始排查问题原因。
本期需求中优化了一些数据缓存组件,一度怀疑是这里导致的问题,几经排查还是不能确定。于是在运维老师那里启动了一个容器来部署本期最新代码的服务,并把nacos下线,避免线上流量进入。
在容器内部查看内存、CPU信息完全正常,定时任务执行也很正常,于是模拟调用线上接口,一段时间后终于发生了oldGC,查看占用cpu较高的线程为GC线程,dump堆内存进行分析,终于发现了问题所在:
前四个线程占用内存百分比较高,进入查看详情:
可以发现LambdaQueryWrapper中存在一个超大的HashMap对象,这期代码中参数很多而且使用Wrapper进行查询的只有这里了:
@Override
public Set<Long> filterIntroducedUser(Long appId, Collection<Long> userIds) {
if (CollectionUtil.isEmpty(userIds)) {
return Collections.emptySet();
}
LambdaQueryWrapper<SwitchIntroduceUser> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.select(SwitchIntroduceUser::getUserId);
queryWrapper.eq(SwitchIntroduceUser::getIsDeleted, DataStateEnum.EFFECTIVE.getState());
if (Objects.nonNull(appId)) {
queryWrapper.eq(SwitchIntroduceUser::getAppId, appId);
}
return Lists.partition(ListUtil.of(userIds), config.getCommonQueryBatchSize()).stream()
.map(ids -> switchIntroduceUserMapper.selectList(queryWrapper.in(SwitchIntroduceUser::getUserId, ids)))
.flatMap(Collection::stream).map(SwitchIntroduceUser::getUserId).collect(Collectors.toSet());
}
这次为了不写sql尝试使用了下MybatisPlus这种查询方式,为了减少对象创建,将queryWrapper提取出来,分组查询的时候每次都设置一批in查询条件,问题也就出现在了这里。
in函数的前期调用链如下:
> com.baomidou.mybatisplus.core.conditions.interfaces.Func#in(R, java.util.Collection<?>)
> com.baomidou.mybatisplus.core.conditions.AbstractWrapper#in
>> com.baomidou.mybatisplus.core.conditions.AbstractWrapper#inExpression
>>> com.baomidou.mybatisplus.core.conditions.AbstractWrapper#formatSql
>>>> com.baomidou.mybatisplus.core.conditions.AbstractWrapper#formatSqlIfNeed
> com.baomidou.mybatisplus.core.conditions.AbstractWrapper#doIt
> com.baomidou.mybatisplus.core.conditions.segments.MergeSegments#add
> com.baomidou.mybatisplus.core.conditions.segments.AbstractISegmentList#addAll
AbstractWrapper#in方法调用doIt时,传入了一个函数inExpression的返回值作为参数
com.baomidou.mybatisplus.core.conditions.AbstractWrapper#in
public Children in(boolean condition, R column, Collection<?> coll) {
return doIt(condition, () -> columnToString(column), IN, inExpression(coll));
}
inExpression方法返回一个回调函数,这个函数中会调用formatSql方法
com.baomidou.mybatisplus.core.conditions.AbstractWrapper#inExpression
private ISqlSegment inExpression(Collection<?> value) {
return () -> value.stream().map(i -> formatSql("{0}", i))
.collect(joining(StringPool.COMMA, StringPool.LEFT_BRACKET, StringPool.RIGHT_BRACKET));
}
formatSql方法调用formatSqlIfNeed方法
com.baomidou.mybatisplus.core.conditions.AbstractWrapper#formatSql
protected final String formatSql(String sqlStr, Object... params) {
return formatSqlIfNeed(true, sqlStr, params);
}
formatSqlIfNeed方法会将传入的sql进行替换并生成name对应参数加入到paramNameValuePairs中,这就是那个超大的map,Map<String, Object>,这个map会作为之后进行sql查询的入参对象,所以这个方法其实是在拼装SQL参数片段及对应的参数值
com.baomidou.mybatisplus.core.conditions.AbstractWrapper#formatSqlIfNeed
protected final String formatSqlIfNeed(boolean need, String sqlStr, Object... params) {
if (!need || StringUtils.isBlank(sqlStr)) {
return null;
}
if (ArrayUtils.isNotEmpty(params)) {
for (int i = 0; i < params.length; ++i) {
String genParamName = Constants.WRAPPER_PARAM + paramNameSeq.incrementAndGet();
sqlStr = sqlStr.replace(String.format("{%s}", i),
String.format(Constants.WRAPPER_PARAM_FORMAT, Constants.WRAPPER, genParamName));
paramNameValuePairs.put(genParamName, params[i]);
}
}
return sqlStr;
}
接下来看下前面的doIt方法做了什么
com.baomidou.mybatisplus.core.conditions.AbstractWrapper#doIt
protected Children doIt(boolean condition, ISqlSegment... sqlSegments) {
if (condition) {
expression.add(sqlSegments);
}
return typedThis;
}
调用add方法将上面的回调函数加入到了normal对象中,记住这个normal对象,后面要用
com.baomidou.mybatisplus.core.conditions.segments.MergeSegments#add
private final NormalSegmentList normal = new NormalSegmentList();
...
public void add(ISqlSegment... iSqlSegments) {
List<ISqlSegment> list = Arrays.asList(iSqlSegments);
ISqlSegment firstSqlSegment = list.get(0);
if (MatchSegment.ORDER_BY.match(firstSqlSegment)) {
orderBy.addAll(list);
} else if (MatchSegment.GROUP_BY.match(firstSqlSegment)) {
groupBy.addAll(list);
} else if (MatchSegment.HAVING.match(firstSqlSegment)) {
having.addAll(list);
} else {
normal.addAll(list);
}
cacheSqlSegment = false;
}
那么什么时候会触发inExpression返回的回调方法呢?
inExpression返回的回调方法是作为接口函数ISqlSegment的匿名实现来返回的
com.baomidou.mybatisplus.core.conditions.ISqlSegment
@FunctionalInterface
public interface ISqlSegment extends Serializable {
/**
* SQL 片段
*/
String getSqlSegment();
}
mybatisplus会对mybatis进行代理,在我们执行selectList查询操作的时候,最终会调用到这个com.baomidou.mybatisplus.core.conditions.AbstractWrapper#getSqlSegment方法
com.baomidou.mybatisplus.core.conditions.AbstractWrapper#getSqlSegment
public String getSqlSegment() {
return expression.getSqlSegment() + lastSql.getStringValue();
}
方法中又调用getSqlSegment方法
com.baomidou.mybatisplus.core.conditions.segments.MergeSegments#getSqlSegment
public String getSqlSegment() {
if (cacheSqlSegment) {
return sqlSegment;
}
cacheSqlSegment = true;
if (normal.isEmpty()) {
if (!groupBy.isEmpty() || !orderBy.isEmpty()) {
sqlSegment = groupBy.getSqlSegment() + having.getSqlSegment() + orderBy.getSqlSegment();
}
} else {
sqlSegment = normal.getSqlSegment() + groupBy.getSqlSegment() + having.getSqlSegment() + orderBy.getSqlSegment();
}
return sqlSegment;
}
看到了熟悉的身影normal对象,我们看下normal对象的getSqlSegment方法在干嘛
com.baomidou.mybatisplus.core.conditions.segments.AbstractISegmentList#getSqlSegment
public String getSqlSegment() {
if (cacheSqlSegment) {
return sqlSegment;
}
cacheSqlSegment = true;
sqlSegment = childrenSqlSegment();
return sqlSegment;
}
调用了childrenSqlSegment方法,见名知意,这个方法会调用之前调用add方法加入到normal对象的所有ISQLSegment的实现,包含我们之前提到的回调函数
com.baomidou.mybatisplus.core.conditions.segments.NormalSegmentList#childrenSqlSegment
protected String childrenSqlSegment() {
if (MatchSegment.AND_OR.match(lastValue)) {
removeAndFlushLast();
}
final String str = this.stream().map(ISqlSegment::getSqlSegment).collect(Collectors.joining(SPACE));
return (LEFT_BRACKET + str + RIGHT_BRACKET);
}
normal对象作为ArrayList的实现类,遍历触发了之前传入normal对象的回调函数(ISqlSegment::getSqlSegment)并且收集SQL片段结果进行拼接、返回。
上面crm的程序代码中是在循环中调用selectList方法,带来的结果就是,每执行一次selectList方法就会调用一次回调函数,就会把之前所有的回调函数全部执行一次,也就是把之前in函数设置的所有参数全部再次往paramNameValuePairs中放一次,带来的结果将是灾难性的。
比如:1万个用户ID,每批次查询500个,需要分20次查询,第一次往paramNameValuePairs中放置500个键值对,第二次1000个,第三次1500个…第二十次就是10000个,执行完这20次查询,paramNameValuePairs中有20*(10000+500)/2 = 105000 个键值对,如果查询十万个用户paramNameValuePairs中会存在10050000个键值对…
回过头来看下上面的图片,占用内存最高的线程持有的paramNameValuePairs中有500多万的键值对,normal列表大小为600多…妥妥的内存刹器。
发现了问题所在,修改起来很容易:
- 不使用plus这种查询方式,自己写sql进行查询
- 不抽取wrapper对象,每批查询new一个新的wrapper并设置in参数
PS:内存排查工具为 eclipse mat