讀古今文學網 > MongoDB實戰 > 5.2 MongoDB查詢語言 >

5.2 MongoDB查詢語言

是時候瞭解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才會返回文檔。如果想查找所有標記為giftgarden的產品,$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。例如,假設想查詢所有標記有giftholiday,同時還有gardeninglandscaping的產品。表示該查詢的唯一途徑是關聯兩個$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}"})
  

假定用戶能控制attributevalue的值,他就能以任意屬性對來查詢集合。雖然這不是最壞情況的入侵,但還是應該盡量避免它。

8. 正則表達式

本章開篇的地方,我們看到在查詢中有使用正則表達式,在那個例子裡,我演示了前綴表達式/^Ba/,用它來查找以Ba開頭的姓氏,並且指出這條查詢能用上索引。實際上,我們還能使用更多的正則表達式。MongoDB編譯時用了PCRE(http://mng.bz/hxmh),它支持大量的正則表達式。

除了之前提到的前綴查詢,正則表達式都用不上索引。因此,我建議在使用時和JavaScript表達式一樣,結合至少一個其他查詢項。下面的例子中,將查詢指定用戶的包含bestworst文字的評論。請注意,這裡使用了正則表達式標記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

skiplimit的語義很容易理解,這兩個查詢選項的作用總能滿足預期。

但在向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})
  

第二條查詢掃瞄的文檔遠少於第一條。唯一的問題是如果每個文檔的日期不唯一,相同的文檔可能會顯示多次。有很多應對這種情況的策略,尋找解決方案的任務就留給讀者了。