近期遇到线上出现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多...妥妥的内存刹器。

发现了问题所在,修改起来很容易:

  1. 不使用plus这种查询方式,自己写sql进行查询
  2. 不抽取wrapper对象,每批查询new一个新的wrapper并设置in参数

PS:内存排查工具为 eclipse mat

Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐