首页 » MongoDB实战 » MongoDB实战全文在线阅读

《MongoDB实战》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的查询语言,特别是说明其中每个操作符的语法。