一、问题
在笔者最近参与的一个在线考试项目中,我们采用了MongoDB取代关系数据库作为项目的数据存储系统。我们将所有试题形成一个集合存储在Questions集中,每一试题成为Questions的一个个文档来存储。在为生成考生试卷时,需要从满足一定条件试题集中随机选取几道试题来作为某一考生的考试试题。在以前我们采用Sql Server关系数据库时,对于类似于这种随机获取数据的功能通过一个简单的SQL语句就能完成:
通过该语句就可以随机得到5条试题的信息。
而在MongoDB中却没有类似功能可以利用,于是我们只能换其它的思路来解决该问题。
二、解决方案
在MongoDB中要解决从一个集合中随机获取数据的功能,一般有三种解决方法。其一,保存随机数查询法。在文档插入时给每个文档添加一个额外的随机数,在每次获取时指定一个新的随机数randValue作为查询条件与存储的随机数进行比较,从而得到一组随机的试题。其二,$where查询法。文档本身可以不存在随机数,在查询时事先生成一个随机数randValue,查询采用$where子句,在$where子句的函数中为集合每个文档生成随机数,并与randValue进行比较。其三,将试题的随机选取的任务交由其它语言来完成。比如,通过MongoDb的Java驱动程序来得到集合,然后在Java中对集合中的试题随机选取。
由于第三种方法中需要将集合传送到本地,然后再从中随机选取,在性能上是这三者中是最差的,并且在数据较大的情况下,也容易出现内存泄漏等诸多的问题,所以本文将舍弃该方法,不做详细的讨论。其它的二个方法都有各自的优势,适用于不同的情况下。
1)保存随机数查询法
我们在mongo中采用JavaScript插入100万个模拟的试题文档
for(var j=0;j<10000;j++){
db.Questions.insert({"Title":"试题题面"+i+","+j,"Random":Math.random()})
}
}
查询
* 根据文档中随机数的取值来随机得到指定数量的文档
* @param dbCollection 查询的数据集合
* @param query 查询的条件
* @param nlimit 要获取的随机文档的个数
* @return
*/
public DBCursor GetRandResult(DBCollection dbCollection,BasicDBObject query,int nlimit){
if(query==null){
query=new BasicDBObject();
}
//得到总数
long length=dbCollection.count(query);
RndScope rndScope=new RndScope(length,nlimit);
//查询条件中添加对于随机数据条件
query.put("Rand",new BasicDBObjectBuilder()
.add("$gte", rndScope.getGteVal())
.add("$lte",rndScope.getLteVal())
.get());
DBCursor result=dbCollection.find(query).limit(nlimit);
return result;
}
在查询中,考虑到更为真实得到随机文档,在其中综合了总记录数和要得到的记录数,从而产生一个随机数的取值范围。通过这种方法得到的随机文档在分布上更具有随机性,避免了分布上的“扎堆儿”现象。其中的RndScope用来根据总数和要获取的值,得到随机的最大值与最小值范围的类。其实现如下:
private double gteVal;
private double lteVal;
private long length;
private int limit;
public RndScope(long length,int limit){
double rndVal=Math.random()*length;
//根据要获取的记录的条数,对范围值进行修改
gteVal=rndVal-limit;
lteVal=rndVal+limit;
if(gteVal<0){
lteVal+=Math.abs(gteVal);
gteVal=0;
}else if(lteVal>length){
lteVal-=(length-lteVal);
gteVal=length;
}
gteVal=gteVal/length;
lteVal=lteVal/length;
}
/**
* @return the gteVal
*/
public double getGteVal() {
return gteVal;
}
/**
* @return the lteVal
*/
public double getLteVal() {
return lteVal;
}
}
2)$where查询法
在真实的项目中用户的需求总是在不断变化的,随着迭代的推进,原来并没有得到随机个数的集合可能会因为需求的变化而需要添加此的功能。我们知道MongoDB中无模式的,我们可以很容易为一个文档添加新的键/值数据。但当已运行的系统中存在着很多数据的时候,进行这种改变也是一件麻烦的事情。因此,我们常常还需要一种较为通用的方法,在不依赖于文档本身已存在的随机数的情况下,从满足某一条件的集合中随机得到指定个数的文档。这里我们采用了MongoDB的$where查询,MongoDB中的$where查询子句具有很强大的能力,它可以执行任意的JavaScript作为查询的一部分,从而使得查询能做更多的事情。
* 从数据集中,随机得到指定个数的文档。
* @param nLimit 随机得到文档个数
* @param query 先决条件
* @param dbCollection 数据集
* @return
* @throws Exception
*/
public DBCursor GetRandomResult(int nLimit,BasicDBObject query,DBCollection dbCollection)
throws Exception{
if(query==null){
query=new BasicDBObject();
}
if(query.containsKey("$where")){
throw new Exception("查询中不能包含$where项");
}
//通过ObjectId.get()得到二个唯一的变量名。
String sgteArgs="_OpenTmp"+ObjectId.get().toString();
String slteArgs="_OpenTmp"+ObjectId.get().toString();
//得到数据的总数
long nLength=dbCollection.count(query);
System.out.println("记录总数:"+nLength);
//根据要获取的记录的条数,对范围值进行修改
RndScope rndScope=new RndScope(nLength,nLimit);
//将创建时间太久的给删除掉
DBCollection dbSystem=dbCollection.getDB().getCollection("system.js");
dbSystem.remove(new BasicDBObjectBuilder()
.add("createDate",
new BasicDBObjectBuilder()
.add("$lt",new Date((new Date()).getTime()-24*60*1000)).get())
.add("_id",Pattern.compile("_OpenTmp*"))
.get()
);
//向system.js集合中添加二个全局的变量
dbSystem.findAndModify(
new BasicDBObjectBuilder().add("_id", sgteArgs).get(),
null,null, false, new BasicDBObjectBuilder()
.add("_id", sgteArgs)
.add("value",rndScope.getGteVal())
.add("createDate",new Date())
.get(), false, true);
dbSystem.findAndModify(new BasicDBObjectBuilder().add("_id",slteArgs).get()
,null,null,false,new BasicDBObjectBuilder()
.add("_id",slteArgs)
.add("value",rndScope.getLteVal())
.add("createDate",new Date())
.get(),false,true);
//查询中添加对于随机数的判断
query.put("$where", "function(){var rnd=Math.random();return (rnd>="
+sgteArgs+" && rnd<="+slteArgs+");}");
DBCursor result=dbCollection.find(query).limit(nLimit);
return result;
}
由于要查询的文档中不存在随机数,这样就需要我们在进行查询的时候产生随机数,而这正是$where查询子句所擅长的。为了能完成查询,就需要将随机数的范围作为条件传递给$where查询子句的JavaScript函数,但是该函数却不能接受任何参数。要给$where子句的函数传值必须通过在同一数据库中的system.js集合中添加(_id为参数名称,value键的值为参数值)的文档方式。
我们首先定义了二个全局唯一名称的参数作为随机数范围的最大值和最小值的参数。并且对system.js集合中存在的过期的这些随机数范围变量进行清理。然后,我们根据满足条件的集合的总数量与要随机获取的数据量,将随机数的范围保存到system.js集合中。最后,我们将随机数范围的查询作为$where查询的部分添加到查询条件中。从而实现了从任一数据集合中,随机得到指定个数文档的功能。
性能分析
$where查询在速度上比常规查询要慢,并且$where查询也无法使用索引等来提高查询效率,所以方法1)比2)具有更好的执行效率。但是,如果$where的查询的前置条件已过滤了很多数据,再根据$where在小范围内获取随机数此方法也不失为一种通用的方法。众所周知,在MongoDB中使用的是Sprider引擎,有测试表明在将V8引擎引入MongoDB后,对于JavaScript的运行将会提高6倍左右。MongoDB已有V8引入的计划。所以,我们有理由相信随着V8的引用,JavaScript运行效率得到提升后,2)的方法适用性会更大。
MongoDB作为NoSQL的后起之秀在项目中有了非常广的应用。我们在这里将应用的一些情况与大家分享,也是希望能抛砖引玉。只要我们改变以前传统的思路,理解MongoDB的设计哲学,深入透彻地理解了MongoDB,它在我们的系统中才能应用的更好。