因项目需要,要对mongodb中的数据,做排序再做group还要做总数统计还要对结果筛选,而且数据量又是百万级别的,看了整整一天的spring-data-mongo的源码、mongo-driver的源码、还逛了大半天国外论坛,总算是把功能搞出来了,在此做一下笔记。

一、遇到的坑

1、对大数据量的东西,首先实现起来还要考虑性能考虑内存,这是坑一。

2、项目选用了的框架,spring-data-mongo,相对于我们以往习惯的SSH框架,还比较不成熟,所以一直一直在更新,悲催的是,俺们项目用了比较旧的版本,很多接口没有封装,这是坑二。

二、填坑之路

1、对于问题1,性能、内存,要在两者之间权衡,于是我看上了mongo中提供的Cursor光标聚合读取。

2、对于聚合查询,mongoTemplate提供了4个aggregate接口

(这里我涉及到的项目是用的spring-data-mongo1.4.2.jar+mongo-java-driver-2.14.2.jar的组合,所以主要是针对1.4.2版本做描述,很多接口在2.0以上都是有提供的)

<O> AggregationResults<O>

aggregate(Aggregation aggregation, Class<?> inputType, Class<O> outputType)


Execute an aggregation operation.

<O> AggregationResults<O>

aggregate(Aggregation aggregation, String collectionName, Class<O> outputType)


Execute an aggregation operation.

protected <O> AggregationResults<O>

aggregate(Aggregation aggregation, String collectionName, Class<O> outputType,AggregationOperationContext context)

<O> AggregationResults<O>

aggregate(TypedAggregation<?> aggregation, Class<O> outputType)


Execute an aggregation operation.

<O> AggregationResults<O>

aggregate(TypedAggregation<?> aggregation, String inputCollectionName, Class<O> outputType)


Execute an aggregation operation.

可以看出,这4个接口都是返回AggregationResults,也不是说不可以用,但是,面对的数据量非常大,也就是每次聚合出来的返回结果会有很多,如果直接返回,mongodb会报内存不足的错误:

Exceeded memory limit for $group, but didn't allow external sort. Pass allowDiskUse:true to opt in

这个错误是由于Mongodb规定了aggregate管道聚合的返回数据不能超过16M,超过16M就会报异常错误。解决方法就是设置allowDiskUse:true,即允许使用磁盘缓存。

这个对应的Mongodb语句为:

db.stocks.aggregate( [
    { $project : { 
          cusip: 1, 
          date: 1, 
          price: 1, 
          _id: 0 }
     },
    { $sort : 
         { cusip : 1, date: 1 }
}],{ allowDiskUse: true })

在spring-data-mongo中的接口写法为:

AggregationOptions.builder().allowDiskUse(true).build()

然而,低版本的spring-data-mongo并没有提供AggregationOptions接入Aggregation的方式,也就是你能声明得到allowDiskUse(true),但是如果想通过mongoTemplate的这4个接口来调用aggregate,在构造成Aggregation的时候,并没有提供任何入口给你。这里我涉及到的项目是用的spring-data-mongo1.4.2.jar+mongo-java-driver-2.14.2.jar的组合,但在spring-data-mongo2.0.x以上的版本都是有提供的,具体对应的写法请看api,这里不做描述。

这个网址里面什么版本的api都有了https://docs.spring.io/spring-data/mongodb/docs/

除了上面说的这个1.4.2版本没有提供allowDiskUse(true)的设置入口之外,这个版本在这里还有一个缺陷就是没有提供Cursor光标数据流,但是Mongo本身的aggregate是可以返回Cursor的。

这个接口在spring-data-mongo2.0.x以上的版本也是有提供的。

spring-data-mongo2.0.x以上的版本提供的aggregate接口有:

<O> AggregationResults<O>

aggregate(Aggregation aggregation, Class<?> inputType, Class<O> outputType)


Execute an aggregation operation.

<O> AggregationResults<O>

aggregate(Aggregation aggregation, String collectionName, Class<O> outputType)


Execute an aggregation operation.

protected <O> AggregationResults<O>

aggregate(Aggregation aggregation, String collectionName, Class<O> outputType,AggregationOperationContext context)

<O> AggregationResults<O>

aggregate(TypedAggregation<?> aggregation, Class<O> outputType)


Execute an aggregation operation.

<O> AggregationResults<O>

aggregate(TypedAggregation<?> aggregation, String inputCollectionName,Class<O> outputType)


Execute an aggregation operation.

<T> ExecutableAggregationOperation.ExecutableAggregation<T>

aggregateAndReturn(Class<T> domainType)


Start creating an aggregation operation that returns results mapped to the given domain type.

<O> org.springframework.data.util.CloseableIterator<O>

aggregateStream(Aggregation aggregation, Class<?> inputType, Class<O> outputType)

Cursor.


<O> org.springframework.data.util.CloseableIterator<O>

aggregateStream(Aggregation aggregation, String collectionName, Class<O> outputType)

Cursor.


protected <O> org.springframework.data.util.CloseableIterator<O>

aggregateStream(Aggregation aggregation, String collectionName, Class<O> outputType,AggregationOperationContext context)

<O> org.springframework.data.util.CloseableIterator<O>

aggregateStream(TypedAggregation<?> aggregation, Class<O> outputType)

Cursor.


<O> org.springframework.data.util.CloseableIterator<O>

aggregateStream(TypedAggregation<?> aggregation, String inputCollectionName,Class<O> outputType)

Cursor.


aggregateStream返回的就是一个被封装过的Cursor。

小结:综上,在此次的开发中,遇到两个问题,数据量大,需要使用mongo返回的Cursor,并且需要设置allowDiskUse(true)允许使用磁盘缓存,但是在项目中使用的spring-data-mongo版本过低,很多接口没有提供。

三、填坑方案

1、更新jar包

2、手敲原生代码

1)、既然有了需求,仅仅是因为jar包版本太低而被限制,那很多人第一想法就是,换个包不就行了。是的升级个包就行了,但是事情并没有看上去那么简单,这里有个隐藏的坑,spring-data-mongo和mongo-java-driver是具有强关联的,也就是表面上看着好像是更一个包,其实要更的话,所有包都要跟着更新,spring-data-mongo2.0.x以上版本,都需要对应mongo-java-driver3.x版本,否则会报异常错误,因为有些spring-data-mongo使用到的方法对象,低版本的mongo-java-driver并没有提供,说到底就是一个兼容问题。

并且,从产品的角度来说,更包是一件很慎重的事情,需要考虑到的,是更包之后的后果,假如要更包,必须做好全面的白盒、黑盒测试,从全方位的照顾到更包后的兼容问题。

当然,作为发展中的产品,各方面的jar包框架等都要及时的做好更新,与时俱进。

但是,因为这次我任务紧,时间急,所以并没有太多的时间精力做如此需要消耗时间的分析以及风险评估,所以,我采用了原生的写法。

2)、原生的写法,参照mongodb语句,使用BasicDBObject构建对象

mongodb的写法

db.getCollection('resourceFingerprint').aggregate([
    { "$group" : { 
        "_id" : { "heapNum" : "$heapNum"} , 
        "publishDate" : { "$max" : "$publishDate"} , 
        "heapNum" : { "$first" : "$heapNum"} , 
        "count" : { "$sum" : 1}
        }}, 
    { "$sort" : { "count" : -1}}, 
    { "$match" : { "count" : { "$gt" : 1}}}, 
    { "$skip" : 0}, 
    { "$limit" : 10}],
    { "allowDiskUse" : true}
    )

参数解释:

$group:分组聚合

$sort:根据分组聚合后的count做倒序

$match:对分组聚合后的结果做查询筛选

$skip:分页跳过条数

$limit:返回多少条数据

allowDiskUse:true 允许使用磁盘缓存,默认为false

$group:分组聚合中参数(这里面的参数可以根据需要构建返回的字段)

_id:根据heapNum字段做groupby分组

publishDate:使用$max获取同组成员中,日期最大最新的那个

heapNum:使用$first获取同组成员中,第一条数据的heapNum

count:使用$sum统计同组成员数量

注意,这里的$match并不是只能出现在这里,可以出现2次,在$group之前出现的,是先做查询,再对查询的数据做分组,出现在$group之后,是对分组后的结果做筛选。

用到的一些参数api:https://docs.mongodb.com/v3.4/reference/operator/aggregation/

对应的java代码

List<DBObject> aggregateQuery = new ArrayList<DBObject>();
BasicDBObject groupHeapNum = new BasicDBObject("heapNum","$heapNum");
BasicDBObjectBuilder groupBuilder = new BasicDBObjectBuilder();
groupBuilder.add("_id", groupHeapNum);
//获取组内发布时间最新的数据
groupBuilder.add("publishDate", new BasicDBObject("$max","$publishDate"));
//获取组内发布时间最新的数据
groupBuilder.add("heapNum", new BasicDBObject("$first","$heapNum"));
groupBuilder.add("count", new BasicDBObject("$sum",1));
// 分組 每组的重复次数
aggregateQuery.add(new BasicDBObject("$group",groupBuilder.get()));
aggregateQuery.add(new BasicDBObject("$sort",new BasicDBObject("count",-1)));//排序
		
//分组后 总数大于1的,且含查询条件
BasicDBObjectBuilder matchBuilder = new BasicDBObjectBuilder();
matchBuilder.add("count", new BasicDBObject("$gt",1));
if(beginDate!=null && endDate!=null) {
	BasicDBObjectBuilder publishDateBuilder = new BasicDBObjectBuilder();
	publishDateBuilder.add("$gte",beginDate);
	publishDateBuilder.add("$lte",endDate);
	matchBuilder.add("publishDate", publishDateBuilder.get());
}
aggregateQuery.add(new BasicDBObject("$match",matchBuilder.get()));
aggregateQuery.add(new BasicDBObject("$skip",pageSize*(currentPage-1)));
aggregateQuery.add(new BasicDBObject("$limit",pageSize));

AggregationOptions aggregationOptions = AggregationOptions.builder().allowDiskUse(true).build();

DBCollection collection = mongoTemplate.getDb().getCollection(collectionName);
Cursor cursor = collection.aggregate(aggregateQuery,aggregationOptions);

while(cursor .hasNext()){//查出每个的信息
DBObject next = cursor .next();
String heapNum = next.get("heapNum").toString();
。。。。。。
}
if(aggregateCursor!=null) {
aggregateCursor.close();
}

查出分组后的总条数count()

mongo的话很简单,直接在尾部加上.count()就可以得出分组后总条数了,但是java的话,没法直接.count()去统计聚合后的数量。于是,使用对聚合结果再聚合的方式。

对应的mongo语句为:

db.getCollection('resourceFingerprint').aggregate([
    { "$group" : { 
        "_id" : { "heapNum" : "$heapNum"} , 
        "publishDate" : { "$max" : "$publishDate"} , 
        "heapNum" : { "$first" : "$heapNum"} , 
        "count" : { "$sum" : 1}
        }
    }, 
    { "$sort" : { "count" : -1}}, 
    { "$match" : { "count" : { "$gt" : 1}}}, 
    { "$group" : { 
        "_id" :  null  , 
        "sum" : { "$sum" : 1}
        }
    }],
    { "allowDiskUse" : true}
    )

关键在分组后的$group的_id设置为null,意思为,对分组后的结果再做分组,id为null,也就结果分为一组,再使用$sum统计组内成员数,返回的结果就是在第一次$group之后的结果总数了。

对应的部分java代码:

List<DBObject> countQuery = new ArrayList<DBObject>(aggregateQuery);
BasicDBObjectBuilder countGropuBuilder = new BasicDBObjectBuilder();
countGropuBuilder.add("_id", null);
countGropuBuilder.add("sum", new BasicDBObject("$sum",1));
countQuery.add(new BasicDBObject("$group",countGropuBuilder.get()));// 分組 每组的重复次数

小结:java-mongo的原生写法很简单,其实就是跟着mongo的语句,一步步构建对象,而mongo的执行顺序是由上至下的,不像mySQL需要各种子查询。