是時候瞭解MongoDB那無與倫比的查詢語言了,我會先從查詢的描述、語義和類型開始講述,然後討論游標,因為每條MongoDB查詢本質上來看都是實例化了一個游標並獲取它的結果集。掌握了這些基礎知識之後,我再分類介紹MongoDB查詢操作符。1
1. 除非你十分關心細節,否則在初次閱讀時可以跳過這部分內容。
5.2.1 查詢選擇器
我們先大致瞭解一下查詢選擇器,尤其要關注所有能用它們表示的查詢類型。
1. 選擇器匹配
要指定一條查詢,最簡單的方法就是使用選擇器,其中的鍵值對直接匹配要找的文檔。下面是兩個例子:
db.users.find({last_name: "Banker"}) db.users.find({first_name: "Smith", age: 40})
第二條查詢的意思是「查找所有first_name
是Smith,並且age
是40的用戶」。請注意,無論傳入多少個鍵值對,它們必須全部匹配;查詢條件之間相當於用了布爾運算符AND。如果想要表示布爾運算符OR,可以閱讀後面關於布爾操作符的部分。
2. 範圍查詢
我們經常需要查詢某些值在一個特定範圍內的文檔。在SQL中,可以使用<、<=、>
和>=;
在MongoDB中有類似的一組操作符$lt、$lte、$gt
和$gte
。貫穿全書,我們都在使用這些操作符,它們的行為與預期的一樣。但初學者在組合使用這些操作符時偶爾會很費力,常見的錯誤是重複搜索鍵:
db.users.find({age: {$gte: 0}, age: {$lte: 30})
因為同一文檔中同一級不能有兩個相同的鍵,所以這個查詢選擇器是無效的,兩個範圍操作符只會應用其中之一。可以用下面的方式來表示該查詢:
db.users.find({age: {$gte: 0, $lte: 30}})
還有一個值得注意的地方:範圍操作符涉及了類型。僅當文檔中的值與要比較的值類型相同時2,範圍查詢才會匹配該值。例如,假設有一個集合,其中包含以下文檔:
{ "_id" : ObjectId("4caf82011b0978483ea29ada"), "value" : 97 } { "_id" : ObjectId("4caf82031b0978483ea29adb"), "value" : 98 } { "_id" : ObjectId("4caf82051b0978483ea29adc"), "value" : 99 } { "_id" : ObjectId("4caf820d1b0978483ea29ade"), "value" : "a" } { "_id" : ObjectId("4caf820f1b0978483ea29adf"), "value" : "b" } { "_id" : ObjectId("4caf82101b0978483ea29ae0"), "value" : "c" }
2. 請注意,數字類型(整型、長整型和雙精度浮點數)對這些查詢而言在類型上是等價的。
然後執行如下查詢:
db.items.find({value: {$gte: 97}})
你可能覺得這條查詢應該把六個文檔全部返回,因為那幾個字符串在數值上跟整數97、98和99是等價的。但事實並非如此,該查詢只會返回整數結果。如果想讓結果是字符串,就應該改用字符串來進行查詢:
db.items.find({value: {$gte: "a"}})
只要同一集合中永遠不會為同一個鍵保存多種類型,就可以不用擔心這條類型限制。這是一個很好的實踐,你應該遵守它。
3. 集合操作符
$in、$all
和$nin
這三個查詢操作符接受一到多個值的列表,將其作為謂詞。如果任意給定值匹配搜索鍵,$in
就返回該文檔。我們可以使用該操作符返回所有屬於某些離散分類集的產品。請看以下分類ID列表:
[ObjectId("6a5b1476238d3b4dd5000048"), ObjectId("6a5b1476238d3b4dd5000051"), ObjectId("6a5b1476238d3b4dd5000057") ]
如果它們分別對應割草機、手持工具和工作服分類,可以像下面這樣查詢所有屬於這些分類的產品:
db.products.find({main_cat_id: { $in: [ObjectId("6a5b1476238d3b4dd5000048"), ObjectId("6a5b1476238d3b4dd5000051"), ObjectId("6a5b1476238d3b4dd5000057") ] } } )
也可以把$in
操作符想像成對單個屬性的布爾運算符OR,之前的查詢可以解釋為「查找所有分類是割草機或手持工具或工作服的產品」。請注意,如果需要對多個屬性進行布爾型OR運算,需要使用下一節裡介紹的$or
操作符。
$in
經常被用於ID列表,本章之前有一個例子,使用$in
來返回所有購買過特定產品的用戶。
$nin
僅在與給定元素都不匹配時才返回該文檔。可以用$nin
來查找所有不是黑色或藍色的產品:
db.products.find('details.color': { $nin: ["black", "blue"] })
最後,當搜索鍵與每個給定元素都匹配時,$all
才會返回文檔。如果想查找所有標記為gift和garden的產品,$all
是個不錯的選擇:
db.products.find(tags: { $all: ["gift", "garden"] })
當然,這條查詢只有在以標籤數組的形式保存tags
屬性時才有效,比如下面這樣:
{ name: "Bird Feeder", tags: [ "gift", "birds", "garden" ] }
在使用集合操作符時請牢記$in
和$all
能利用索引,但$nin
不能,所以它需要做集合掃瞄。如果要用$nin
,試著和一個能用上索引的查詢條件一起使用,最好是換種方式來表示這條查詢。舉個例子,可以再保存一個屬性,其中的內容和$nin
查詢等價。例如,假設經常會查詢{timeframe: {$nin: ['morning', 'afternoon']}},
這時可以換種更直接的方式{timeframe: 'evening'}
。
4. 布爾操作符
MongoDB的布爾操作符包括$ne
、$not
、$or
、$and
和$exists
。
不等於操作符$ne
的用法可以想像。在實踐中,最好和其他操作符結合使用;否則查詢效率可能不高,因為它無法利用索引。例如,可以使用$ne
查找所有由ACME生產並且沒有gardening標籤的產品:
db.products.find('details.manufacturer': 'ACME', tags: {$ne: "gardening"})
$ne
可以作用於單個值和數組,正如示例所示,可以匹配tags
數組。
$ne
匹配特定值以外的值,而$not
則是對另一個MongoDB操作符或正則表達式查詢的結果求反。在使用$not
前,請記住大多數查詢操作符已經有否定形式了($in
和$nin、$gt
和 $lte
等),$not
不該和它們搭配使用。當你所使用的操作符或正則表達式沒有否定形式時,才應使用$not
。例如,如果想查詢所有姓氏不是B打頭的用戶,可以這樣使用$not
:
db.users.find(last_name: {$not: /^B/} )
$or
表示兩個不同鍵對應的值的邏輯或關係。其中重要的一點是:如果可能的值限定在同一個鍵裡,使用$in
代替。一般而言,查找所有藍色或綠色產品的語句是這樣的:
db.products.find('details.color': {$in: ['blue', 'green']} )
但是,查找所有藍色的或者是由ACME生產的產品,就要用$or
了:
db.products.find({ $or: [{'details.color': 'blue'}, 'details.manufacturer': 'ACME'}] })
$or
接受一個查詢選擇器數組,每個選擇器的複雜度隨意,而且可以包含其他查詢操作符3。
3. 不包括$or
。
和$or
一樣,$and
操作符同樣接受一個查詢選擇器數組。對於包含多個鍵的查詢選擇器,MongoDB會對條件進行與運算,因此只有在不能簡單地表示AND關係時才應使用$and
。例如,假設想查詢所有標記有gift或holiday,同時還有gardening或landscaping的產品。表示該查詢的唯一途徑是關聯兩個$in
查詢:
db.products.find({$and: [ {tags: {$in: ['gift', 'holiday']}}, {tags: {$in: ['gardening', 'landscaping']}} ] } )
本節要討論的最後一個操作符是$exists
。該操作符的存在很有必要,因為集合沒有一個固定的Schema,所以偶爾需要查詢包含特定鍵的文檔。你是否記得我們計劃在每個產品的details
屬性裡保存特定的字段?舉例來說,假設要在details
屬性裡保存一個color
字段。但是,如果只有一部分產品中定義了顏色,可以像下面這樣將未定義顏色的產品找出來:
db.products.find({'details.color': {$exists: false}})
也可以查找定義了顏色的產品:
db.products.find({'details.color': {$exists: true}})
上面只是檢查了存在性,還有另一種檢查存在性的方式,兩者幾乎是等價的:用null
來匹配屬性。可以修改上述查詢,第一個查詢可以這樣表示:
db.products.find({'details.color': null})
第二個是這樣的:
db.products.find({'details.color': {$ne: null}})
5. 匹配子文檔
本書的電子商務數據模型中,有些條目裡的鍵指向一個內嵌對象。產品的details
屬性就是一個很好的例子。以下是一個相關文檔的片段,用JSON表示:
{ _id: ObjectId("4c4b1476238d3b4dd5003981"), slug: "wheel-barrow-9092", sku: "9092", details: { model_num: 4039283402, manufacturer: "Acme", manufacturer_id: 432, color: "Green" } }
可以通過.(點)來分隔相關的鍵,查詢這些對象。舉例來說,假設想查找所有由ACME生成的產品,可以這樣做:
db.products.find({'details.manufacturer_id': 432});
此類查詢裡可以指定任意的深度,假設稍微修改一下表述:
{ _id: ObjectId("4c4b1476238d3b4dd5003981"), slug: "wheel-barrow-9092", sku: "9092", details: { model_num: 4039283402, manufacturer: { name: "Acme", id: 432 }, color: "Green" } }
可以在查詢選擇器的鍵裡包含兩個點:
db.products.find({'details.manufacturer.id': 432});
除了匹配單個子文檔屬性,還可以匹配整個對象。例如,假設正使用MongoDB保存股市價位,為了節省空間,放棄了標準的對象ID,用一個包含股票代碼和時間戳的復合鍵取而代之。文檔的表述大致是這樣的:4
{ _id: {sym: 'GOOG', date: 20101005} open: 40.23, high: 45.50, low: 38.81, close: 41.22 }
4. 在潛在的高吞吐量場景下,我們希望盡可能地限制文檔大小。可以使用較短的鍵名部分實現該目的,比如用o代替open。
接下來可以通過如下_id
查詢獲取GOOG於2010年10月5日的價格匯總:
db.ticks.find({_id: {sym: 'GOOG', date: 20101005} });
一定要注意,像這樣匹配整個對象的查詢會執行嚴格的字節比較,也就是說鍵的順序很重要。下面的查詢與其並不等價,不會匹配到示例文檔:
db.ticks.find({_id: {date: 20101005, sym: 'GOOG'} });
雖然Shell中輸入的JSON文檔的鍵順序會被保留,但並不是所有語言驅動的文檔表述都是如此。例如,Ruby 1.8里的散列並不會保留順序,要在Ruby 1.8中保留鍵順序,必須使用BSON::OrderedHash
類:
doc = BSON::OrderedHash.new doc['sym'] = 'GOOG' doc['date'] = 20101005 @ticks.find(doc)
一定要檢查正使用的語言是否支持有序字典;如果不支持的話,該語言的MongoDB驅動會提供一個有序的替代品。
6. 數組
數組使得文檔模型更加強大,如你所見,數組可以用來存儲字符串列表、對像ID列表,甚至是其他文檔的列表。數組能帶來更豐富、更易理解的文檔;按照常理,MongoDB能輕鬆地查詢並索引數組類型。事實也是如此:最簡單的數組查詢就和其他文檔類型的查詢一樣。仍以產品標籤為例,用簡單的字符串數組來表示標籤:
{ _id: ObjectId("4c4b1476238d3b4dd5003981"), slug: "wheel-barrow-9092", sku: "9092", tags: ["tools", "equipment", "soil"] }
查詢帶有soil標籤的產品很簡單,使用的語法就和查詢單個文檔值時一樣:
db.products.find({tags: "soil"})
重要的是,這條查詢能利用tags
字段上的索引。如果在該字段上構建了索引,並且explain
該查詢,可以看到使用了B樹游標:
db.products.ensureIndex({tags: 1}) db.products.find({tags: "soil"}).explain
在需要對數組查詢擁有更多掌控時,可以使用點符號來查詢數組特定位置上的值。下面是如何對之前的查詢進行限制,只查詢產品的第一個標籤:
db.products.find({'tags.0': "soil"})
如此查詢標籤可能意義不大,但假設正在處理用戶地址,可以用子文檔數組來表示地址:
{ _id: ObjectId("4c4b1476238d3b4dd5000001") username: "kbanker", addresses: [ {name: "home", street: "588 5th Street", city: "Brooklyn", state: "NY", zip: 11215}, {name: "work", street: "1 E. 23rd Street", city: "New York", state "NY", zip 10010}, ] }
我們可以規定數組的第0個元素始終是用戶的首選送貨地址。因此,要找到所有首選送貨地址在紐約的用戶,可以指定第0個位置,並用點來明確state
字段:
db.users.find({'addresses.0.state': "NY"})
我們還可以忽略位置,直接指定字段。如果列表中的任意地址在紐約範圍內,下面的查詢就會返回用戶文檔:
db.users.find({'addresses.state': "NY"})
與之前一樣,我們希望為帶點的字段加上索引:
db.users.ensureIndex({'addresses.state': 1})
請注意,無論字段是指向子文檔,還是子文檔數組,都使用相同的點符號。點符號很強大,而且這種一致性很可靠。但在查詢子對像數組中的多個屬性時會帶來歧義,例如假設想獲取所有家庭地址在紐約的用戶列表,該如何表示這條查詢呢?
db.users.find({'addresses.name': 'home', 'addresses.state': 'NY'})
上述查詢的問題在於所引用的字段並不局限於單個地址;換言之,只要有一個地址被設置為「home」,一個地址是在紐約,這條查詢就能匹配上了,但我們希望將兩個屬性都應用到同一個地址上。幸好有一個針對這種情況的查詢操作符,要將多個條件限制在同一個子文檔上,可以使用$elemMatch
操作符,可以這樣進行查詢:
db.users.find({addresses: {$elemMatch: {name: 'home', state: 'NY'}}})
從邏輯上來看,只有在需要匹配子文檔中的多個屬性時才會使用$elemMatch
。
唯一還沒討論的數組操作符是$size
,該操作符能讓我們根據數組大小進行查詢。例如,假設希望找出所有帶三個地址的用戶,可以這樣使用$size
操作符:
db.users.find({addresses: {$size: 3}})
在本書編寫時,$size
操作符是不使用索引的,而且僅限於精確匹配(不能指定數組大小範圍)5。因此,如果需要基於數組的大小進行查詢,應該將大小緩存在文檔的屬性中,當數組變化時手動更新該值。舉例來說,可以考慮為用戶文檔添加一個address_length
字段,並為該字段添加索引,隨後再發起範圍查詢和精確查詢。
5. 關於這個問題,更新內容參見https://jira.mongodb.org/browse/SERVER-478。
7. JavaScript
如果目前為止的工具都無法表示你的查詢,那就可能需要寫一些JavaScript了。我們可以使用特殊的$where
操作符,向任意查詢中傳入一個JavaScript表達式。在JavaScript上下文裡,關鍵字this
指向當前文檔,讓我們來看一個例子:
db.reviews.find({$where: "function { return this.helpful_votes > 3; }"})
該查詢還有一個簡化形式:
db.reviews.find({$where: "this.helpful_votes > 3"})
這個查詢能正常使用,但你永遠也不會想去使用它,因為可以用標準查詢語言輕鬆表示該查詢。問題是JavaScript表達式無法使用索引,由於必須在JavaScript解釋器上下文中運算,還帶來了額外的大量開銷。出於這些原因,應該只在無法通過標準查詢語言表示查詢時才使用JavaScript查詢。如果確實有需要,請嘗試為JavaScript表達式帶上至少一個標準查詢操作符。標準查詢操作符可以縮小結果集,減少必須加載到JS上下文裡的文檔。讓我們看個簡單的例子,看看為什麼需要這麼做。
假設為每個用戶都計算了一個評分可靠性因子,這是一個整數,與用戶的評分相乘之後可以得到一個更標準化的評分。假設後續想查詢某個特定用戶的評論,並且只返回標準化評分大於3的記錄。查詢語句是這樣的:
db.reviews.find({user_id: ObjectId("4c4b1476238d3b4dd5000001"), $where: "(this.rating * .92) > 3"})
這條查詢滿足了之前的兩條建議:在user_id
字段上使用了標準查詢,這個字段一般是有索引的;在超出標準查詢語言能力的情況下使用了JavaScript表達式。
除了要識別出額外的性能開銷,還要意識到JavaScript注入攻擊的可能性。當用戶可以直接向JavaScript查詢中輸入代碼時就有可能發生注入攻擊。雖然用戶無法通過這種方式修改或刪除數據,但卻能獲取敏感數據。Ruby中的不安全JavaScript查詢可能是這個樣子的:
@users.find({$where => "this.#{attribute} == #{value}"})
假定用戶能控制attribute
和value
的值,他就能以任意屬性對來查詢集合。雖然這不是最壞情況的入侵,但還是應該盡量避免它。
8. 正則表達式
本章開篇的地方,我們看到在查詢中有使用正則表達式,在那個例子裡,我演示了前綴表達式/^Ba/
,用它來查找以Ba開頭的姓氏,並且指出這條查詢能用上索引。實際上,我們還能使用更多的正則表達式。MongoDB編譯時用了PCRE(http://mng.bz/hxmh),它支持大量的正則表達式。
除了之前提到的前綴查詢,正則表達式都用不上索引。因此,我建議在使用時和JavaScript表達式一樣,結合至少一個其他查詢項。下面的例子中,將查詢指定用戶的包含best或worst文字的評論。請注意,這裡使用了正則表達式標記i6來表示忽略大小寫:
6. 使用了忽略大小寫的選項就無法在查詢中使用索引,就算是在前綴匹配時也是如此。
db.reviews.find({user_id: ObjectId("4c4b1476238d3b4dd5000001"), text: /best|worst/i })
如果所使用的語言擁有原生的正則表達式類型,我們也可以使用原生的正則表達式對像執行查詢。在Ruby中相同的查詢語句是這樣的:
@reviews.find({:user_id => BSON::ObjectId("4c4b1476238d3b4dd5000001") :text => /best|worst/i })
如果我們的環境中不支持原生的正則表達式類型,可以使用特殊的$regex
和$options
操作符。Shell中通過這些操作符可以這樣來表示上述查詢:
"db.reviews.find({user_id:ObjectId("4c4b1476238d3b4dd5000001"), text:{$regex:"best|worst", $options:"i" }})"
9. 其他查詢操作符
還有兩個查詢操作符難以歸類,所以單獨進行討論。第一個是$mod
,允許查詢匹配指定取模操作的文檔。舉例來說,可以通過下列查詢找出所有小計能被3整除的訂單:
db.orders.find({subtotal: {$mod: [3, 0]}})
我們看到$mod
操作符接受兩個值組成的數組,第一個值是除數,第二個值是期望的餘數。因此,可以這樣來理解該查詢:找出所有小計除以3後余0的文檔。這個例子是故意做出來的,但它能體現出背後的思想。如果要使用$mod
操作符,請牢記它無法使用索引。
第二個操作符是$type
,根據BSON類型來匹配值。我不建議為一個集合的同一個字段保存多種類型,但是如果發生這樣的情況,可以用這個操作符來檢查類型。我最近發現某個用戶的_id
查詢總是匹配不上數據,而實際上不應該發生這樣的情況,這時$type
操作符就能派上用場了。問題的原因是他既將ID保存為字符串,又將其保存為對像ID,它們的BSON類型分別是2和7,對於新用戶而言,很容易就會忽略兩者的區別。
要修正這個問題,首先要找出所有以字符串形式保存ID的文檔。使用$type
操作符就可以了:
db.users.find({_id: {$type: 2}})
5.2.2 查詢選項
所有的查詢都要有一個查詢選擇器。就算沒有提供,查詢本身實際就是由查詢選擇器定義的。但在發起查詢時,有多種查詢選項可供選擇,它們能進一步約束結果集。本節將介紹這些選項。
1. 投影
在查詢結果集的文檔中,可以使用投影來選擇字段的子集進行返回。當有大文檔時就更應該使用投影,這能最小化網絡延時和反序列化的開銷。通常是用要返回的字段集合來定義投影:
db.users.find({}, {username: 1})
該查詢返回的用戶文檔只包含兩個字段:username
和_id
。默認情況下,_id
字段總是包含在返回結果內。
在某些情況下,你可能還會希望排除特定字段。舉例來說,本書的用戶文檔中包含送貨地址和支付方式,但通常並不需要這些信息,為了將其排除掉,可以在投影中添加這些字段,並將其值設置為0:
db.users.find({}, {addresses: 0, payment_methods: 0})
除了包含和排除字段,還能返回保存在數組裡的某個範圍內的值。例如,我們可能想在產品文檔中保存產品評論,同時還希望能對那些評論進行分頁,為此可以使用$slice
操作符。要返回頭12篇評論或者倒數5篇評論,可以像這樣使用$slice
:
db.products.find({}, {reviews: {$slice: 12}}) db.products.find({}, {reviews: {$slice: -5}})
$slice
還能接受兩個元素的數組,分別表示跳過的元素數和返回元素個數限制。下面演示如何跳過頭24篇評論,並限制僅返回12篇評論:
db.products.find({}, {reviews: {$slice: [24, 12]}})
最後,注意$slice
並不會阻止返回其他字段。如果希望限制文檔中的其他字段,必須顯式地進行控制。例如,修改上述查詢,僅返回評論及其評分:
db.products.find({}, {reviews: {$slice: [24, 12]}, 'reviews.rating': 1})
2. 排序
所有的查詢結果都能按照一個或多個字段進行升序或降序排列。例如,根據評分對評論做排序,從高到低降序排列:
db.reviews.find({}).sort({rating: -1})
顯然,先根據有用程度排序,隨後再是評分,這樣的排序可能更有價值:
db.reviews.find({}).sort({helpful_votes:-1, rating: -1})
在類似的組合排序裡,順序至關重要。正如書中其他地方所說的,Shell中鍵入的JSON是有順序的。因為Ruby的散列是無序的,所以可以用數組的數組來指定排序順序,數組是有序的:
@reviews.find({}).sort([['helpful_votes', -1], [rating, -1]])
在MongoDB中指定排序非常簡單,但書中其他章節裡討論到的兩個主題對理解排序來說必不可少。其一,瞭解如何使用$natural
操作符根據插入順序進行排序,這是在第4章裡討論的。其二,這點就更有關係了,即瞭解如何保證排序能有效利用到索引,第8章裡會討論這個主題。如果正在大量使用排序,可以先閱讀第8章。
3. skip與limit
skip
與limit
的語義很容易理解,這兩個查詢選項的作用總能滿足預期。
但在向skip
傳遞很大的值(比如大於10 000的值)時需要注意,因為執行這種查詢要掃瞄和skip
值等量的文檔。例如,假設正根據日期降序對100萬個文檔進行分頁,每頁10條結果。這意味著顯示第50 000頁的查詢要跳過500 000個文檔,這樣做的效率太低了。更好的策略是省略skip
,添加一個範圍條件,指明下一結果集從何處開始。如此一來,這條查詢:
db.docs.find({}).skip(500000).limit(10).sort({date: -1})
就變成了:
db.docs.find({date: {$gt: previous_page_date}}).limit(10).sort({date: -1})
第二條查詢掃瞄的文檔遠少於第一條。唯一的問題是如果每個文檔的日期不唯一,相同的文檔可能會顯示多次。有很多應對這種情況的策略,尋找解決方案的任務就留給讀者了。