是时候了解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
才会返回文档。如果想查找所有标记为gift和garden的产品,$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
。例如,假设想查询所有标记有gift或holiday,同时还有gardening或landscaping的产品。表示该查询的唯一途径是关联两个$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}"})
假定用户能控制attribute
和value
的值,他就能以任意属性对来查询集合。虽然这不是最坏情况的入侵,但还是应该尽量避免它。
8. 正则表达式
本章开篇的地方,我们看到在查询中有使用正则表达式,在那个例子里,我演示了前缀表达式/^Ba/
,用它来查找以Ba开头的姓氏,并且指出这条查询能用上索引。实际上,我们还能使用更多的正则表达式。MongoDB编译时用了PCRE(http://mng.bz/hxmh),它支持大量的正则表达式。
除了之前提到的前缀查询,正则表达式都用不上索引。因此,我建议在使用时和JavaScript表达式一样,结合至少一个其他查询项。下面的例子中,将查询指定用户的包含best或worst文字的评论。请注意,这里使用了正则表达式标记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
skip
与limit
的语义很容易理解,这两个查询选项的作用总能满足预期。
但在向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})
第二条查询扫描的文档远少于第一条。唯一的问题是如果每个文档的日期不唯一,相同的文档可能会显示多次。有很多应对这种情况的策略,寻找解决方案的任务就留给读者了。