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

《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.newdoc['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})  

第二条查询扫描的文档远少于第一条。唯一的问题是如果每个文档的日期不唯一,相同的文档可能会显示多次。有很多应对这种情况的策略,寻找解决方案的任务就留给读者了。