本節繼續探討上一章中給出的電子商務數據模型。我們已經為產品、分類、用戶、訂單和產品評論定義了文檔結構,有了這一結構,讓我們來看看如何在一個典型的電子商務應用程序裡查詢這些實體。其中的一些查詢非常簡單,舉例來說,_id
查找應該毫無秘密可言。但我們還會看到一些較複雜的模式,包括查詢並顯示分類層級,以及為產品列表提供過濾視圖。除此之外,要將效率問題牢記於心,針對這些查詢還要尋找可能的索引。
5.1.1 產品、分類與評論
大多數電子商務應用程序都提供至少兩種基本的產品和分類視圖。第一種是產品主頁,突出某個指定的產品,顯示其評論,給出一些與產品分類相關的信息。第二種是產品列表頁面,允許用戶瀏覽分類層級,查看所選分類中所有產品的縮略圖。讓我們先從產品主頁入手,多數情況下這是兩者中比較容易的一個。
假設產品頁面URL是以產品的短名稱作為鍵的,這樣就能通過以下三個查詢獲得產品頁面中所需的所有信息:
db.products.findOne({\'slug\': \'wheel-barrow-9092\'}) db.categories.findOne({\'_id\': product[\'main_cat_id\']}) db.reviews.find({\'product_id\': product[\'_id\']})
第一個查詢通過短名稱wheel-barrow-9092
找到了產品。一旦有了產品,就能從categorie
集合裡用簡單的_id
查詢找到其分類信息。最後,再發起一次簡單查詢,獲得與該產品相關的所有評論。
相信你已經注意到了,頭兩個查詢用的是findOne
方法,但最後一個查詢卻用了find
方法。所有的MongoDB驅動都提供了這兩個方法,很有必要溫習一下兩者的區別。正如第3章中所說的那樣,find
返回的是游標對象,而findOne
返回的是一個文檔。上面用到的findOne
和下面這條語句是等價的:
db.products.find({\'slug\': \'wheel-barrow-9092\'}).limit(1)
如果僅僅想要一個文檔,只要它存在,findOne
就能返回它。如果需要返回多個文檔,就需要使用find
了,該方法會返回一個游標,你需要在應用程序裡對它進行迭代。
現在再來看看產品頁面的查詢,還有什麼問題嗎?如果覺得評論的查詢有點粗放,那就對了。該查詢會返回指定產品的所有評論,但這種做法在產品擁有成百上千條評論時顯然不夠嚴謹。大多數應用程序都會對評論進行分頁,為此,MongoDB提供了skip
和limit
選項。可以像下面這樣用它們對評論文檔進行分頁:
db.reviews.find({\'product_id\': product[\'_id\']}).skip(0).limit(12)
如果還希望以一致的順序顯示評論,就需要對查詢結果進行排序。如果想要按照每條評論收到的投票數排序,方法很簡單:
db.reviews.find({\'product_id\': product[\'id\']}).sort( {helpful_votes: -1}).limit(12)
簡而言之,這條查詢告訴MongoDB按照投票總數降序排列,返回前12條評論。有了skip、limit
和sort
,只需在開始時決定是否需要分頁。為此,可以發起一次count
查詢。隨後結合count
的結果和想要的評論頁碼再進行查詢。完整的產品頁面查詢是這樣的:
product = db.products.findOne({\'slug\': \'wheel-barrow-9092\'}) category = db.categories.findOne({\'_id\': product[\'main_cat_id\']}) reviews_count = db.reviews.count({\'product_id\': product[\'_id\']}) reviews = db.reviews.find({\'product_id\': product[\'_id\']}). skip((page_number - 1) * 12). limit(12). sort({\'helpful_votes\': -1})
這些查詢語句都應該使用索引。因為短名稱也可以當做主鍵來用,所以應該為它們加上唯一性索引。而且你應該也知道所有標準集合的_id
字段都會自動加上唯一性索引,對於任何充當引用的字段也都應該為它們加上索引。在本例中,這些字段還包括評論集合中的user_id
和product_id
字段。
完成了產品主頁的查詢,現在可以將視線轉向產品列表頁面了。此類頁面會展現一個指定的分類,頁面中帶有可瀏覽的產品列表,還有指向上級分類和同級分類的鏈接。
產品列表頁面是根據產品分類來定義的,因此針對該頁面的請求將使用分類的短名稱:
category = db.categories.findOne({\'slug\': \'outdoors\'}) siblings = db.categories.find({\'parent_id\': category[\'_id\']}) products = db.products.find({\'category_id\': category[\'_id\']}). skip((page_number - 1) * 12). limit(12). sort({helpful_votes: -1})
同級分類是指擁有相同parent_id
的其他分類,因此對它的查詢非常簡單。既然產品都包含一個分類ID的數組,那麼查詢指定分類裡的所有產品也同樣很簡單。還是需要使用與之前評論相同的分頁模式,不同的只是按照平均產品評分進行排序,我們還可以提供其他排序方法(根據名稱、價格等),改變排序字段即可。1
1. 考慮這些排序是否高效是很重要的。可以依靠索引來處理排序,但隨著排序選項的增加,索引數量也會相應增加,維護這些索引的成本就可能超出可接受的範圍。如果每個分類的產品數量很少,這種情況尤為突出。我們將在第8章中深入討論這一話題,但你可以先考慮起來了。
產品列表頁面還有一種基本情況,就是查詢頂級分類,沒有產品。只需在分類集合中查找parent_id
是nil
的分類就可以了:
categories = db.categories.find({\'parent_id\': nil})
5.1.2 用戶與訂單
上一節裡的查詢僅限於_id
查找和排序,對於用戶與訂單,由於希望為訂單生成基本的報表,我們的查詢會更進一步。
先從稍微簡單一些的查詢入手:用戶身份驗證。用戶提供用戶名和密碼登錄到應用程序中,因此經常會使用以下查詢:
db.users.findOne({username: \'kbanker\', hashed_password: \'bd1cfa194c3a603e7186780824b04419\'})
如果用戶存在且密碼正確,會返回完整的用戶文檔;否則就沒有返回結果。這條查詢是可接受的。但如果要考慮性能,可以只返回_id
字段,用它就能發起會話了。畢竟在用戶文檔裡保存了地址、支付方法和其他諸多個人信息。如果需要的只是一個字段,又何必在網絡上傳輸那些數據,並在驅動端反序列化它們呢?可以通過投影來限制返回的字段:
db.users.findOne({username: \'kbanker\', hashed_password: \'bd1cfa194c3a603e7186780824b04419\'}, {_id: 1})
現在的響應裡只有文檔的_id
字段了:
{ _id: ObjectId(\"4c4b1476238d3b4dd5000001\") }
還有很多其他對用戶集合users
的查詢。舉例來說,你有一個管理後台,允許根據不同條件查詢用戶。通常會查詢某個字段,比如last_name
:
db.users.find({last_name: \'Banker\'})
這條查詢可以執行,但僅限於精確匹配的場景。也許你並不知道如何拼寫某個用戶的名字,這時就需要部分匹配的查詢。假設知道用戶的姓氏是以Ba開頭的,在SQL裡可以使用LIKE
條件來進行查詢:
SELECT * from users WHERE last_name LIKE \'Ba%\'
MongoDB中語義上與其等價的是一個正則表達式:
db.users.find({last_name: /^Ba/})
和RDBMS一樣,像這樣的前綴搜索可以用上索引。2
2. 如果不熟悉正則表達式,請注意:正則表達式/^Ba/可以解讀為「行首以B打頭隨後是a」。
在面向用戶進行市場營銷之前,可能希望明確用戶範圍,舉例來說,想要獲得所有居住在Upper Manhattan3的用戶,可以針對用戶的郵政編碼發起範圍查詢:
db.users.find({\'addresses.zip\': {$gte: 10019, $lt: 10040}})
3. 曼哈頓上城,指紐約市曼哈頓的北部區域。——譯者注
每個用戶文檔都包含一個地址數組,其中有一到多個地址。如果這些地址中有哪個郵政編碼落在指定的範圍裡,那麼這個用戶文檔就會被匹配到。要讓該查詢更高效,可以在address.zip
上定義一個索引。
根據地域來尋找目標用戶未必是提升轉化率的最好途徑,根據用戶買過的東西來進行分組會更有意義。這會要求執行兩步查詢:首先,基於特定產品獲得一個訂單集合,一旦有了訂單,就能查詢關聯的用戶了。4假設想找到所有購買過大型手推車的用戶,可以使用MongoDB的點符號深入line_items
數組,查詢指定SKU:
db.orders.find({\'line_items.sku\': \"9092\"})
4. 如果之前用過關係型數據庫,此處無法對訂單和用戶表進行關連查詢可能會讓你覺得不便,但大可不必如此,在MongoDB裡執行這樣的客戶端關聯是很常見的。
還可以針對結果集做限制,將訂單限定在某個時間段裡。只需簡單地添加一個查詢條件,指定最小的訂單日期:
db.orders.find({\'line_items.sku\': \"9092\", \'purchase_date\': {$gte: new Date(2009, 0, 1)}})
如果這些查詢很頻繁,需要一個復合索引,先按照SKU排序,然後再按照購買日期排序。可以像下面這樣創建索引:
db.orders.ensureIndex({\'line_items.sku\': 1, \'purchase_date\': 1}
在查詢orders
集合時,所尋找的就是用戶ID的列表。因此,使用投影會更高效一些。下面這段代碼中,先規定只要user_id
字段,然後將查詢結果轉換為一個簡單的ID數組,隨後再用$in
操作符查詢users
集合:
user_ids = db.orders.find({\'line_items.sku\': \"9092\", purchase_date: {\'$gt\': new Date(2009, 0, 1)}}, {user_id: 1, _id: 0}).toArray.map(function(doc) { return doc[\'_id\'] }) users = db.users.find({_id: {$in: user_ids}})
在ID數組有上千個元素時,這種使用ID數組和$in
來查詢集合的做法會更高效。對於更大的數據集,比如有100萬用戶購買了手推車,最好是將那些用戶ID寫到臨時集合中,然後再順序查詢。
在下一章裡你會看到更多針對該數據的查詢,還會瞭解到如何用MongoDB的聚合函數分析數據。但為了充實你的知識,接下來要深入介紹MongoDB的查詢語言,特別是說明其中每個操作符的語法。