一、問題
在筆者最近參與的一個在線考試項目中,我們采用了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,它在我們的系統中才能應用的更好。