讀古今文學網 > MongoDB實戰 > 5.1 電子商務查詢 >

5.1 電子商務查詢

本節繼續探討上一章中給出的電子商務數據模型。我們已經為產品、分類、用戶、訂單和產品評論定義了文檔結構,有了這一結構,讓我們來看看如何在一個典型的電子商務應用程序裡查詢這些實體。其中的一些查詢非常簡單,舉例來說,_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提供了skiplimit選項。可以像下面這樣用它們對評論文檔進行分頁:

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、limitsort,只需在開始時決定是否需要分頁。為此,可以發起一次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_idproduct_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_idnil的分類就可以了:

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的查詢語言,特別是說明其中每個操作符的語法。