第4章 查询
本章将详细介绍查询。主要会涵盖以下几个方面:
- 使用
find
或者findOne
函数和查询文档对数据库执行查询; - 使用
$
条件查询实现范围查询、数据集包含查询、不等式查询,以及其他一些查询; - 查询将会返回一个数据库游标,游标只会在你需要时才将需要的文档批量返回;
- 还有很多针对游标执行的元操作,包括忽略一定数量的结果,或者限定返回结果的数量,以及对结果排序。
4.1 find简介
MongoDB中使用find
来进行查询。查询就是返回一个集合中文档的子集,子集合的范围从0个文档到整个集合。find
的第一个参数决定了要返回哪些文档,这个参数是一个文档,用于指定查询条件。
空的查询文档(例如{})会匹配集合的全部内容。要是不指定查询文档,默认就是{}
。例如:
> db.c.find()
将批量返回集合c中的所有文档。
开始向查询文档中添加键/值对时,就意味着限定了查询条件。对于绝大多数类型来说,这种方式很简单明了。数值匹配数值,布尔类型匹配布尔类型,字符串匹配字符串。查询简单的类型,只要指定想要查找的值就好了,十分简单。例如,想要查找"age"值为27的所有文档,直接将这样的键/值对写进查询文档就好了:
> db.users.find({"age" : 27})
要是想匹配一个字符串,比如值为"joe"
的"username"
键,那么直接将键/值对写在查询文档中即可:
> db.users.find({"username" : "joe"})
可以向查询文档加入多个键/值对,将多个查询条件组合在一起,这样的查询条件会被解释成“条件1AND条件2AND … AND条件N”。例如,要想查询所有用户名为joe且年龄为27岁的用户,可以像下面这样:
> db.users.find({"username" : "joe", "age" : 27})
4.1.1 指定需要返回的键
有时并不需要将文档中所有键/值对都返回。遇到这种情况,可以通过find
(或者findOne
)的第二个参数来指定想要的键。这样做既会节省传输的数据量,又能节省客户端解码文档的时间和内存消耗。
例如,如果只对用户集合的"username"
和"email"
键感兴趣,可以使用如下查询返回这些键:
> db.users.find({}, {"username" : 1, "email" : 1})
{
"_id" : ObjectId("4ba0f0dfd22aa494fd523620"),
"username" : "joe",
"email" : "joe@example.com"
}
可以看到,默认情况下"_id"
这个键总是被返回,即便是没有指定要返回这个键。
也可以用第二个参数来剔除查询结果中的某些键/值对。例如,文档中有很多键,但是我们不希望结果中含有"fatal_weakness"
键:
> db.users.find({}, {"fatal_weakness" : 0})
使用这种方式,也可以把"_id"
键剔除掉:
> db.users.find({}, {"username" : 1, "_id" : 0})
{
"username" : "joe",
}
4.1.2 限制
查询的使用上有些限制。传递给数据库的查询文档的值必须是常量。(在你自己的代码里可以是正常的变量。)也就是不能引用文档中其他键的值。例如,要想保持库存,有"in_stock"
(剩余库存)和"num_sold"
(已出售)两个键,想通过下列查询来比较两者的值是行不通的:
> db.stock.find({"in_stock" : "this.num_sold"}) // 这样是行不通的
的确有办法实现类似的操作(详见4.4节),但通常需要略微修改一下文档结构,就能通过普通查询来完成这样的操作了,这种方式性能更好。在这个例子中,可以在文档中使用"initial_stock"
(初始库存)和"in_stock"
两个键。这样,每当有人购买物品,就将"in_stock"
减去1。这样,只需要用一个简单的查询就能知道哪种商品已脱销:
> db.stock.find({"in_stock" : 0})
4.2 查询条件
查询不仅能像前面说的那样精确匹配,还能匹配更加复杂的条件,比如范围、OR子句和取反。
4.2.1 查询条件
"$lt"
、"$lte"
、"$gt"
和"$gte"
就是全部的比较操作符,分别对应<、<=、>和>=。可以将其组合起来以便查找一个范围的值。例如,查询18~30岁(含)的用户,就可以像下面这样:
> db.users.find({"age" : {"$gte" : 18, "$lte" : 30}})
这样就可以查找到"age"
字段大于等于18、小于等于30的所有文档。
这样的范围查询对日期尤为有用。例如,要查找在2007年1月1日前注册的人,可以像下面这样:
> start = new Date("01/01/2007")
> db.users.find({"registered" : {"$lt" : start}})
可以对日期进行精确匹配,但是用处不大,因为文档中的日期是精确到毫秒的。而我们通常是想得到一天、一周或者是一个月的数据,这样的话,使用范围查询就很有必要了。
对于文档的键值不等于某个特定值的情况,就要使用另外一种条件操作符"$ne"
了,它表示“不相等”。若是想要查询所有名字不为joe的用户,可以像下面这样查询:
> db.users.find({"username" : {"$ne" : "joe"}})
"$ne"
能用于所有类型的数据。
4.2.2 OR查询
MongoDB中有两种方式进行OR查询:"$in"
可以用来查询一个键的多个值;"$or"
更通用一些,可以在多个键中查询任意的给定值。
如果一个键需要与多个值进行匹配的话,就要用"$in"
操作符,再加一个条件数组。例如,抽奖活动的中奖号码是725、542和390。要找出全部的中奖文档的话,可以构建如下查询:
> db.raffle.find({"ticket_no" : {"$in" : [725, 542, 390]}})
"$in"
非常灵活,可以指定不同类型的条件和值。例如,在逐步将用户的ID号迁移成用户名的过程中,查询时需要同时匹配ID和用户名:
> db.users.find({"user_id" : {"$in" : [12345, "joe"]})
这会匹配"user_id"
等于12345的文档,也会匹配"user_id"
等于"joe"
的文档。
要是"$in"
对应的数组只有一个值,那么和直接匹配这个值效果一样。例如,{ticket_no : {$in:[725]}}
和{ticket_no : 725}
的效果一样。
与"$in"
相对的是"$nin"
,"$nin"
将返回与数组中所有条件都不匹配的文档。要是想返回所有没有中奖的人,就可以用如下方法进行查询:
> db.raffle.find({"ticket_no" : {"$nin" : [725, 542, 390]}})
该查询会返回所有没有中奖的人。
"$in"
能对单个键做OR查询,但要是想找到"ticket_no"
为725或者"winner"
为true
的文档该怎么办呢?对于这种情况,应该使用"$or"
。"$or"
接受一个包含所有可能条件的数组作为参数。上面中奖的例子如果用"$or"
改写将是下面这个样子:
> db.raffle.find({"$or" : [{"ticket_no" : 725}, {"winner" : true}]})
"$or"
可以包含其他条件。例如,如果希望匹配到中奖的"ticket_no"
,或者"winner"
键的值为true
的文档,就可以这么做:
> db.raffle.find({"$or" : [{"ticket_no" : {"$in" : [725, 542, 390]}},
{"winner" : true}]})
使用普通的AND型查询时,总是希望尽可能用最少的条件来限定结果的范围。OR型查询正相反:第一个条件应该尽可能匹配更多的文档,这样才是最为高效的。
"$or"
在任何情况下都会正常工作。如果查询优化器可以更高效地处理"$in"
,那就选择使用它。
4.2.3 $not
"$not"
是元条件句,即可以用在任何其他条件之上。就拿取模运算符"$mod"
来说。"$mod"
会将查询的值除以第一个给定值,若余数等于第二个给定值则匹配成功:
> db.users.find({"id_num" : {"$mod" : [5, 1]}})
上面的查询会返回"id_num"
值为1、6、11、16等的用户。但要是想返回"id_num"
为2、3、4、5、7、8、9、10、12等的用户,就要用"$not"
了:
> db.users.find({"id_num" : {"$not" : {"$mod" : [5, 1]}}})
"$not"
与正则表达式联合使用时极为有用,用来查找那些与特定模式不匹配的文档(4.3.2节会详细讲述正则表达式的使用)。
4.2.4 条件语义
如果比较一下上一章的更新修改器和前面的查询文档,会发现以$开头的键位于在不同的位置。在查询中,"$lt"
在内层文档,而更新中"$inc"
则是外层文档的键。基本可以肯定:条件语句是内层文档的键,而修改器则是外层文档的键。
可以对一个键应用多个条件。例如,要查找年龄为20~30的所有用户,可以在"age"
键上使用"$gt"
和"$lt"
:
> db.users.find({"age" : {"$lt" : 30, "$gt" : 20}})
一个键可以有任意多个条件,但是一个键不能对应多个更新修改器。例如,修改器文档不能同时含有{"$inc" : {"age" : 1}, "$set" : {age : 40}}
,因为修改了"age"
两次。但是对于查询条件句就没有这种限定。
有一些“元操作符”(meta-operator)也位于外层文档中,比如"$and"
、"$or"
和"$nor"
。它们的使用形式类似:
> db.users.find({"$and" : [{"x" : {"$lt" : 1}}, {"x" : 4}]})
这个查询会匹配那些"x"
字段的值小于等于1并且等于4的文档。虽然这两个条件看起来是矛盾的,但是这是完全有可能的,比如,如果"x"
字段的值是这样一个数组{"x" : [0, 4]}
,那么这个文档就与查询条件相匹配。注意,查询优化器不会对"$and"
进行优化,这与其他操作符不同。如果把上面的查询改成下面这样,效率会更高:
> db.users.find({"x" : {"$lt" : 1, "$in" : [4]}})
4.3 特定类型的查询
如第2章所述,MongoDB的文档可以使用多种类型的数据。其中有一些在查询时会有特别的表现。
4.3.1 null
null
类型的行为有点奇怪。它确实能匹配自身,所以要是有一个包含如下文档的集合:
> db.c.find()
{ "_id" : ObjectId("4ba0f0dfd22aa494fd523621"), "y" : null }
{ "_id" : ObjectId("4ba0f0dfd22aa494fd523622"), "y" : 1 }
{ "_id" : ObjectId("4ba0f148d22aa494fd523623"), "y" : 2 }
就可以按照预期的方式查询"y"
键为null
的文档:
> db.c.find({"y" : null})
{ "_id" : ObjectId("4ba0f0dfd22aa494fd523621"), "y" : null }
但是,null
不仅会匹配某个键的值为null
的文档,而且还会匹配不包含这个键的文档。所以,这种匹配还会返回缺少这个键的所有文档:
> db.c.find({"z" : null})
{ "_id" : ObjectId("4ba0f0dfd22aa494fd523621"), "y" : null }
{ "_id" : ObjectId("4ba0f0dfd22aa494fd523622"), "y" : 1 }
{ "_id" : ObjectId("4ba0f148d22aa494fd523623"), "y" : 2 }
如果仅想匹配键值为null
的文档,既要检查该键的值是否为null
,还要通过"$exists"
条件判定键值已存在:
> db.c.find({"z" : {"$in" : [null], "$exists" : true}})
很遗憾,没有"$eq"
操作符,所以这条查询语句看上去有些令人费解,但是使用只有一个元素的"$in"
操作符效果是一样的。
4.3.2 正则表达式
正则表达式能够灵活有效地匹配字符串。例如,想要查找所有名为Joe或者joe的用户,就可以使用正则表达式执行不区分大小写的匹配:
> db.users.find({"name" : /joe/i})
系统可以接受正则表达式标志(i
),但不是一定要有。现在已经匹配了各种大小写组合形式的joe,如果还希望匹配如"joey"
这样的键,可以略微修改一下刚刚的正则表达式:
> db.users.find({"name" : /joey?/i})
MongoDB使用Perl兼容的正则表达式(PCRE)库来匹配正则表达式,任何PCRE支持的正则表达式语法都能被MongoDB接受。建议在查询中使用正则表达式前,先在JavaScript shell中检查一下语法,确保匹配与设想的一致。
MongoDB可以为前缀型正则表达式(比如
/^joey/
)查询创建索引,所以这种类型的查询会非常高效。
正则表达式也可以匹配自身。虽然几乎没有人直接将正则表达式插入到数据库中,但要是万一你这么做了,也可以匹配到自身:
> db.foo.insert({"bar" : /baz/})
> db.foo.find({"bar" : /baz/})
{
"_id" : ObjectId("4b23c3ca7525f35f94b60a2d"),
"bar" : /baz/
}
4.3.3 查询数组
查询数组元素与查询标量值是一样的。例如,有一个水果列表,如下所示:
> db.food.insert({"fruit" : ["apple", "banana", "peach"]})
下面的查询:
> db.food.find({"fruit" : "banana"})
会成功匹配该文档。这个查询好比我们对一个这样的(不合法)文档进行查询:{"fruit" : "apple", "fruit" : "banana", "fruit" : "peach"}
。
1. $all
如果需要通过多个元素来匹配数组,就要用"$all"
了。这样就会匹配一组元素。例如,假设创建了一个包含3个元素的集合:
> db.food.insert({"_id" : 1, "fruit" : ["apple", "banana", "peach"]})
> db.food.insert({"_id" : 2, "fruit" : ["apple", "kumquat", "orange"]})
> db.food.insert({"_id" : 3, "fruit" : ["cherry", "banana", "apple"]})
要找到既有"apple"
又有"banana"
的文档,可以使用"$all"
来查询:
> db.food.find({fruit : {$all : ["apple", "banana"]}})
{"_id" : 1, "fruit" : ["apple", "banana", "peach"]}
{"_id" : 3, "fruit" : ["cherry", "banana", "apple"]}
这里的顺序无关紧要。注意,第二个结果中"banana"
在"apple"
之前。要是对只有一个元素的数组使用"$all"
,就和不用"$all"
一样了。例如,{fruit : {$all : ['apple']}
和{fruit : 'apple'}
的查询结果完全一样。
也可以使用整个数组进行精确匹配。但是,精确匹配对于缺少元素或者元素冗余的情况就不大灵了。例如,下面的方法会匹配之前的第一个文档:
> db.food.find({"fruit" : ["apple", "banana", "peach"]})
但是下面这个就不会匹配:
> db.food.find({"fruit" : ["apple", "banana"]})
这个也不会匹配:
> db.food.find({"fruit" : ["banana", "apple", "peach"]})
要是想查询数组特定位置的元素,需使用key.index
语法指定下标:
> db.food.find({"fruit.2" : "peach"})
数组下标都是从0开始的,所以上面的表达式会用数组的第3个元素和"peach"
进行匹配。
2. $size
"$size"
对于查询数组来说也是非常有用的,顾名思义,可以用它查询特定长度的数组。例如:
> db.food.find({"fruit" : {"$size" : 3}})
得到一个长度范围内的文档是一种常见的查询。"$size"
并不能与其他查询条件(比如"$gt"
)组合使用,但是这种查询可以通过在文档中添加一个"size"
键的方式来实现。这样每一次向指定数组添加元素时,同时增加"size"
的值。比如,原本这样的更新:
> db.food.update(criteria, {"$push" : {"fruit" : "strawberry"}})
就要变成下面这样:
> db.food.update(criteria,
... {"$push" : {"fruit" : "strawberry"}, "$inc" : {"size" : 1}})
自增操作的速度非常快,所以对性能的影响微乎其微。这样存储文档后,就可以像下面这样查询了:
> db.food.find({"size" : {"$gt" : 3}})
很遗憾,这种技巧并不能与"$addToSet"
操作符同时使用。
3. $slice
操作符
本章前面已经提及,find
的第二个参数是可选的,可以指定需要返回的键。这个特别的"$slice"
操作符可以返回某个键匹配的数组元素的一个子集。
例如,假设现在有一个博客文章的文档,我们希望返回前10条评论,可以这样做:
> db.blog.posts.findOne(criteria, {"comments" : {"$slice" : 10}})
也可以返回后10条评论,只要在查询条件中使用-10就可以了:
> db.blog.posts.findOne(criteria, {"comments" : {"$slice" : -10}})
"$slice"
也可以指定偏移值以及希望返回的元素数量,来返回元素集合中间位置的某些结果:
> db.blog.posts.findOne(criteria, {"comments" : {"$slice" : [23, 10]}})
这个操作会跳过前23个元素,返回第24~33个元素。如果数组不够33个元素,则返回第23个元素后面的所有元素。
除非特别声明,否则使用"$slice"
时将返回文档中的所有键。别的键说明符都是默认不返回未提及的键,这点与"$slice"
不太一样。例如,有如下博客文章文档:
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"title" : "A blog post",
"content" : "...",
"comments" : [
{
"name" : "joe",
"email" : "joe@example.com",
"content" : "nice post."
},
{
"name" : "bob",
"email" : "bob@example.com",
"content" : "good post."
}
]
}
用"$slice"
来获取最后一条评论,可以这样:
> db.blog.posts.findOne(criteria, {"comments" : {"$slice" : -1}})
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"title" : "A blog post",
"content" : "...",
"comments" : [
{
"name" : "bob",
"email" : "bob@example.com",
"content" : "good post."
}
]
}
"title"
和"content"
都返回了,即便是并没有显式地出现在键说明符中。
4. 返回一个匹配的数组元素
如果知道元素的下标,那么"$slice"
非常有用。但有时我们希望返回与查询条件相匹配的任意一个数组元素。可以使用$
操作符得到一个匹配的元素。对于上面的博客文章示例,可以用如下的方式得到Bob的评论:
> db.blog.posts.find({"comments.name" : "bob"}, {"comments.$" : 1})
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"comments" : [
{
"name" : "bob",
"email" : "bob@example.com",
"content" : "good post."
}
]
}
注意,这样只会返回第一个匹配的文档。如果Bob在这篇博客文章下写过多条评论,只有"comments"
数组中的第一条评论会被返回。
5. 数组和范围查询的相互作用
文档中的标量(非数组元素)必须与查询条件中的每一条语句相匹配。例如,如果使用{"x" : {"$gt" : 10, "$lt" : 20}}
进行查询,只会匹配"x"
键的值大于等于10并且小于等于20的文档。但是,假如某个文档的"x"
字段是一个数组,如果"x"
键的某一个元素与查询条件的任意一条语句相匹配(查询条件中的每条语句可以匹配不同的数组元素),那么这个文档也会被返回。
下面用一个例子来详细说明这种情况。假如有如下所示的文档:
{"x" : 5}
{"x" : 15}
{"x" : 25}
{"x" : [5, 25]}
如果希望找到"x"
键的值位于10和20之间的所有文档,直接想到的查询方式是使用db.test.find({"x" : {"$gt" : 10, "$lt" : 20}})
,希望这个查询的返回文档是{"x" : 15}
。但是,实际返回了两个文档:
> db.test.find({"x" : {"$gt" : 10, "$lt" : 20}})
{"x" : 15}
{"x" : [5, 25]}
5和25都不位于10和20之间,但是这个文档也返回了,因为25与查询条件中的第一个语句(大于10)相匹配,5与查询条件中的第二个语句(小于20)相匹配。
这使对数组使用范围查询没有用:范围会匹配任意多元素数组。有几种方式可以得到预期的行为。
首先,可以使用"$elemMatch"
要求MongoDB同时使用查询条件中的两个语句与一个数组元素进行比较。但是,这里有一个问题,"$elemMatch"
不会匹配非数组元素:
> db.test.find({"x" : {"$elemMatch" : {"$gt" : 10, "$lt" : 20}})
> // 查不到任何结果
{"x" : 15}
这个文档与查询条件不再匹配了,因为它的"x"
字段是个数组。
如果当前查询的字段上创建过索引(第5章会讲述索引相关内容),可以使用min()
和max()
将查询条件遍历的索引范围限制为"$gt"
和"$lt"
的值:
> db.test.find({"x" : {"$gt" : 10, "$lt" : 20}).min({"x" : 10}).max({"x" : 20})
{"x" : 15}
现在,这个查询只会遍历值位于10和20之间的索引,不再与5和25进行比较。只有当前查询的字段上建立过索引时,才可以使用min()
和max()
,而且,必须为这个索引的所有字段指定min()
和max()
。
在可能包含数组的文档上应用范围查询时,使用min()
和max()
是非常好的:如果在整个索引范围内对数组使用"$gt"/"$lt"
查询,效率是非常低的。查询条件会与所有值进行比较,会查询每一个索引,而不仅仅是指定索引范围内的值。
4.3.4 查询内嵌文档
有两种方法可以查询内嵌文档:查询整个文档,或者只针对其键/值对进行查询。
查询整个内嵌文档与普通查询完全相同。例如,有如下文档:
{
"name" : {
"first" : "Joe",
"last" : "Schmoe"
},
"age" : 45
}
要查寻姓名为Joe Schmoe的人可以这样:
> db.people.find({"name" : {"first" : "Joe", "last" : "Schmoe"}})
但是,如果要查询一个完整的子文档,那么子文档必须精确匹配。如果Joe决定添加一个代表中间名的键,这个查询就不再可行了,因为查询条件不再与整个内嵌文档相匹配。而且这种查询还是与顺序相关的,{"last" : "Schmoe","first" : "Joe"}
什么都匹配不到。
如果允许的话,通常只针对内嵌文档的特定键值进行查询,这是比较好的做法。这样,即便数据模式改变,也不会导致所有查询因为要精确匹配而一下子都挂掉。我们可以使用点表示法查询内嵌文档的键:
> db.people.find({"name.first" : "Joe", "name.last" : "Schmoe"})
现在,如果Joe增加了更多的键,这个查询依然会匹配他的姓和名。
这种点表示法是查询文档区别于其他文档的主要特点。查询文档可以包含点来表达“进入内嵌文档内部”的意思。点表示法也是待插入的文档不能包含“.”的原因。将URL作为键保存时经常会遇到此类问题。一种解决方法就是在插入前或者提取后执行一个全局替换,将“.”替换成一个URL中的非法字符。
当文档结构变得更加复杂以后,内嵌文档的匹配需要些许技巧。例如,假设有博客文章若干,要找到由Joe发表的5分以上的评论。博客文章的结构如下例所示:
> db.blog.find()
{
"content" : "...",
"comments" : [
{
"author" : "joe",
"score" : 3,
"comment" : "nice post"
},
{
"author" : "mary",
"score" : 6,
"comment" : "terrible post"
}
]
}
不能直接用db.blog.find({"comments" : {"author" : "joe","score" : {"$gte" : 5}}})
来查寻。内嵌文档的匹配,必须要整个文档完全匹配,而这个查询不会匹配"comment"
键。使用db.blog.find({"comments.author" : "joe","comments.score" : {"$gte" : 5}}
也不行,因为符合author
条件的评论和符合score
条件的评论可能不是同一条评论。也就是说,会返回刚才显示的那个文档。因为"author" : "joe"
在第一条评论中匹配了,"score" : 6
在第二条评论中匹配了。
要正确地指定一组条件,而不必指定每个键,就需要使用"$elemMatch"
。这种模糊的命名条件句能用来在查询条件中部分指定匹配数组中的单个内嵌文档。所以正确的写法应该是下面这样的:
> db.blog.find({"comments" : {"$elemMatch" : {"author" : "joe",
"score" : {"$gte" : 5}}}})
"$elemMatch"
将限定条件进行分组,仅当需要对一个内嵌文档的多个键操作时才会用到。
4.4 $where查询
键/值对是一种表达能力非常好的查询方式,但是依然有些需求它无法表达。其他方法都败下阵时,就轮到"$where"
子句登场了,用它可以在查询中执行任意的JavaScript。这样就能在查询中做(几乎)任何事情。为安全起见,应该严格限制或者消除"$where"
语句的使用。应该禁止终端用户使用任意的"$where"
语句。
"$where"
语句最常见的应用就是比较文档中的两个键的值是否相等。假如我们有如下文档:
> db.foo.insert({"apple" : 1, "banana" : 6, "peach" : 3})
> db.foo.insert({"apple" : 8, "spinach" : 4, "watermelon" : 4})
我们希望返回两个键具有相同值的文档。第二个文档中,"spinach"
和"watermelon"
的值相同,所以需要返回该文档。MongoDB似乎从来没有提供过一个$
条件语句来做这种查询,所以只能用"$where"
子句借助JavaScript来完成了:
> db.foo.find({"$where" : function () {
... for (var current in this) {
... for (var other in this) {
... if (current != other && this[current] == this[other]) {
... return true;
... }
... }
... }
... return false;
... }});
如果函数返回true
,文档就做为结果集的一部分返回;如果为false
,就不返回。
不是非常必要时,一定要避免使用"$where"
查询,因为它们在速度上要比常规查询慢很多。每个文档都要从BSON转换成JavaScript对象,然后通过"$where"
表达式来运行。而且"$where"
语句不能使用索引,所以只在走投无路时才考虑"$where"
这种用法。先使用常规查询进行过滤,然后再使用"$where"
语句,这样组合使用可以降低性能损失。如果可能的话,使用"$where"语句前应该先使用索引进行过滤,"$where"
只用于对结果进行进一步过滤。
进行复杂查询的另一种方法是使用聚合工具,第7章会详细介绍。
服务器端脚本
在服务器上执行JavaScript时必须注意安全性。如果使用不当,服务器端JavaScript很容易受到注入攻击,与关系型数据库中的注入攻击类似。不过,只要在接受输入时遵循一些规则,就可以安全地使用JavaScript。也可以在运行mongod时指定--noscripting
选项,完全关闭JavaScript的执行。
JavaScript的安全问题都与用户在服务器上提供的程序相关。如果希望避免这些风险,那么就要确保不能直接将用户输入的内容传递给mongod。例如,假如你希望打印一句“Hello, name
!”,这里的name
是由用户提供的。使用如下所示的JavaScript函数是非常容易想到的:
> func = "function() { print('Hello, "+name+"!'); }"
如果这里的name
是一个用户定义的变量,它可能会是"'); db.dropDatabase();print('"
这样一个字符串,因此,上面的代码会被转换成如下代码:
> func = "function() { print('Hello, '); db.dropDatabase(); print('!'); }"
如果执行这段代码,你的整个数据库就会被删除!
为了避免这种情况,应该使用作用域来传递name
的值。以Python为例:
func = pymongo.code.Code("function() { print('Hello, '+username+'!'); }",
{"username": name})
现在,数据库会输出如下的内容,不会有任何风险:
Hello, '); db.dropDatabase(); print('!
由于代码实际上可能是字符串和作用域的混合体,所以大多数驱动程序都有一种特殊类型,用于向数据库传递代码。作用域是用于表示变量名和值的映射的文档。对于要被执行的JavaScript函数来说,这个映射就是一个局部作用域。因此,在上面的例子中,函数可以访问username
这个变量,这个变量的值就是用户传进来的字符串。
shell中没有包含作用域的代码类型,所以作用域只能在字符串或者JavaScript函数中使用。
4.5 游标
数据库使用游标返回find
的执行结果。客户端对游标的实现通常能够对最终结果进行有效的控制。可以限制结果的数量,略过部分结果,根据任意键按任意顺序的组合对结果进行各种排序,或者是执行其他一些强大的操作。
要想从shell中创建一个游标,首先要对集合填充一些文档,然后对其执行查询,并将结果分配给一个局部变量(用var
声明的变量就是局部变量)。这里,先创建一个简单的集合,而后做个查询,并用cursor
变量保存结果:
> for(i=0; i<100; i++) {
... db.collection.insert({x : i});
... }
> var cursor = db.collection.find();
这么做的好处是可以一次查看一条结果。如果将结果放在全局变量或者就没有放在变量中,MongoDB shell会自动迭代,自动显示最开始的若干文档。也就是在这之前我们看到的种种例子,一般大家只想通过shell看看集合里面有什么,而不是想在其中实际运行程序,这样设计也就很合适。
要迭代结果,可以使用游标的next
方法。也可以使用hasNext
来查看游标中是否还有其他结果。典型的结果遍历如下所示:
> while (cursor.hasNext()) {
... obj = cursor.next();
... // do stuff
... }
cursor.hasNext()
检查是否有后续结果存在,然后用cursor.next()
获得它。
游标类还实现了JavaScript的迭代器接口,所以可以在forEach
循环中使用:
> var cursor = db.people.find();
> cursor.forEach(function(x) {
... print(x.name);
... });
adam
matt
zak
调用find
时,shell并不立即查询数据库,而是等待真正开始要求获得结果时才发送查询,这样在执行之前可以给查询附加额外的选项。几乎游标对象的每个方法都返回游标本身,这样就可以按任意顺序组成方法链。例如,下面几种表达是等价的:
> var cursor = db.foo.find().sort({"x" : 1}).limit(1).skip(10);
> var cursor = db.foo.find().limit(1).sort({"x" : 1}).skip(10);
> var cursor = db.foo.find().skip(10).limit(1).sort({"x" : 1});
此时,查询还没有真正执行,所有这些函数都只是构造查询。现在,假设我们执行如下操作:
> cursor.hasNext()
这时,查询被发往服务器。shell立刻获取前100个结果或者前4 MB数据(两者之中较小者),这样下次调用next
或者hasNext
时就不必再次连接服务器取结果了。客户端用光了第一组结果,shell会再一次联系数据库,使用getMore
请求提取更多的结果。getMore
请求包含一个查询标识符,向数据库询问是否还有更多的结果,如果有,则返回下一批结果。这个过程会一直持续到游标耗尽或者结果全部返回。
4.5.1 limit、skip和sort
最常用的查询选项就是限制返回结果的数量、忽略一定数量的结果以及排序。所有这些选项一定要在查询被发送到服务器之前指定。
要限制结果数量,可在find
后使用limit
函数。例如,只返回3个结果,可以这样:
> db.c.find().limit(3)
要是匹配的结果不到3个,则返回匹配数量的结果。limit
指定的是上限,而非下限。
skip
与limit
类似:
> db.c.find().skip(3)
上面的操作会略过前三个匹配的文档,然后返回余下的文档。如果集合里面能匹配的文档少于3个,则不会返回任何文档。
sort
接受一个对象作为参数,这个对象是一组键/值对,键对应文档的键名,值代表排序的方向。排序方向可以是1(升序)或者-1(降序)。如果指定了多个键,则按照这些键被指定的顺序逐个排序。例如,要按照"username"
升序及"age"
降序排序,可以这样写:
> db.c.find().sort({username : 1, age : -1})
这3个方法可以组合使用。这对于分页非常有用。例如,你有个在线商店,有人想搜索mp3。若是想每页返回50个结果,而且按照价格从高到低排序,可以这样写:
> db.stock.find({"desc" : "mp3"}).limit(50).sort({"price" : -1})
点击“下一页”可以看到更多的结果,通过skip
也可以非常简单地实现,只需要略过前50个结果就好了(已经在第一页显示了):
> db.stock.find({"desc" : "mp3"}).limit(50).skip(50).sort({"price" : -1})
然而,略过过多的结果会导致性能问题,下一小节会讲述如何避免略过大量结果。
比较顺序
MongoDB处理不同类型的数据是有一定顺序的。有时一个键的值可能是多种类型的,例如,整型和布尔型,或者字符串和null
。如果对这种混合类型的键排序,其排序顺序是预先定义好的。优先级从小到大,其顺序如下:
最小值;
null
;数字(整型、长整型、双精度);
字符串;
对象/文档;
数组;
二进制数据;
对象ID;
布尔型;
日期型;
时间戳;
正则表达式;
最大值 。
4.5.2 避免使用skip略过大量结果
用skip
略过少量的文档还是不错的。但是要是数量非常多的话,skip
就会变得很慢,因为要先找到需要被略过的数据,然后再抛弃这些数据。大多数数据库都会在索引中保存更多的元数据,用于处理skip
,但是MongoDB目前还不支持,所以要尽量避免略过太多的数据。通常可以利用上次的结果来计算下一次查询条件。
1. 不用skip
对结果分页
最简单的分页方法就是用limit
返回结果的第一页,然后将每个后续页面作为相对于开始的偏移量返回。
> // 不要这么用:略过的数据比较多时,速度会变得很慢
> var page1 = db.foo.find(criteria).limit(100)
> var page2 = db.foo.find(criteria).skip(100).limit(100)
> var page3 = db.foo.find(criteria).skip(200).limit(100)
...
然而,一般来讲可以找到一种方法在不使用skip
的情况下实现分页,这取决于查询本身。例如,要按照"date"
降序显示文档列表。可以用如下方式获取结果的第一页:
> var page1 = db.foo.find().sort({"date" : -1}).limit(100)
然后,可以利用最后一个文档中"date"
的值作为查询条件,来获取下一页:
var latest = null;
// 显示第一页
while (page1.hasNext()) {
latest = page1.next();
display(latest);
}
// 获取下一页
var page2 = db.foo.find({"date" : {"$gt" : latest.date}});
page2.sort({"date" : -1}).limit(100);
这样查询中就没有skip
了。
2. 随机选取文档
从集合里面随机挑选一个文档算是个常见问题。最笨的(也很慢的)做法就是先计算文档总数,然后选择一个从0到文档数量之间的随机数,利用find
做一次查询,略过这个随机数那么多的文档,这个随机数的取值范围为0到集合中文档的总数:
> // 不要这么用
> var total = db.foo.count()
> var random = Math.floor(Math.random()*total)
> db.foo.find().skip(random).limit(1)
这种选取随机文档的做法效率太低:首先得计算总数(要是有查询条件就会很费时),然后用skip
略过大量结果也会非常耗时。
略微动动脑筋,从集合里面查找一个随机元素还是有好得多的办法的。秘诀就是在插入文档时给每个文档都添加一个额外的随机键。例如在shell中,可以用Math.random()
(产生一个0~1的随机数):
> db.people.insert({"name" : "joe", "random" : Math.random()})
> db.people.insert({"name" : "john", "random" : Math.random()})
> db.people.insert({"name" : "jim", "random" : Math.random()})
这样,想要从集合中查找一个随机文档,只要计算一个随机数并将其作为查询条件就好了,完全不用skip
:
> var random = Math.random()
> result = db.foo.findOne({"random" : {"$gt" : random}})
偶尔也会遇到产生的随机数比集合中所有随机值都大的情况,这时就没有结果返回了。遇到这种情况,那就将条件操作符换一个方向:
> if (result == null) {
... result = db.foo.findOne({"random" : {"$lt" : random}})
... }
要是集合里面本就没有文档,则会返回null
,这说得通。
这种技巧还可以和其他各种复杂的查询一同使用,仅需要确保有包含随机键的索引即可。例如,想在加州随机找一个水暖工,可以对"profession"
、"state"
和"random"
建立索引:
> db.people.ensureIndex({"profession" : 1, "state" : 1, "random" : 1})
这样就能很快得出一个随机结果(关于索引,详见第5章)。
4.5.3 高级查询选项
有两种类型的查询:简单查询(plain query)和封装查询(wrapped query)。简单查询就像下面这样:
> var cursor = db.foo.find({"foo" : "bar"})
有一些选项可以用于对查询进行“封装”。例如,假设我们执行一个排序:
> var cursor = db.foo.find({"foo" : "bar"}).sort({"x" : 1})
实际情况不是将{"foo" : "bar"}
作为查询直接发送给数据库,而是先将查询封装在一个更大的文档中。shell会把查询从{"foo" : "bar"}
转换成{"$query" : {"foo" : "bar"},"$orderby" : {"x" : 1}}
。
绝大多数驱动程序都提供了辅助函数,用于向查询中添加各种选项。下面列举了其他一些有用的选项。
$maxscan : integer
指定本次查询中扫描文档数量的上限。
> db.foo.find(criteria)._addSpecial("$maxscan", 20)
如果不希望查询耗时太多,也不确定集合中到底有多少文档需要扫描,那么可以使用这个选项。这样就会将查询结果限定为与被扫描的集合部分相匹配的文档。这种方式的一个坏处是,某些你希望得到的文档没有扫描到。
$min : document
查询的开始条件。在这样的查询中,文档必须与索引的键完全匹配。查询中会强制使用给定的索引。
在内部使用时,通常应该使用"$gt"
代替"$min"
。可以使用"$min"
强制指定一次索引扫描的下边界,这在复杂查询中非常有用。
$max : document
查询的结束条件。在这样的查询中,文档必须与索引的键完全匹配。查询中会强制使用给定的索引。
在内部使用时,通常应该使用"$lg"
而不是"$max"
。可以使用"$max"
强制指定一次索引扫描的上边界,这在复杂查询中非常有用。
$showDiskLoc : true
在查询结果中添加一个"$diskLoc"
字段,用于显示该条结果在磁盘上的位置。例如:
> db.foo.find()._addSpecial('$showDiskLoc',true)
{ "_id" : 0, "$diskLoc" : { "file" : 2, "offset" : 154812592 } }
{ "_id" : 1, "$diskLoc" : { "file" : 2, "offset" : 154812628 } }
文件号码显示了这个文档所在的文件。如果这里使用的是test数据库,那么这个文档就在test.2文件中。第二个字段显示的是该文档在文件中的偏移量。
4.5.4 获取一致结果
数据处理通常的做法就是先把数据从MongoDB中取出来,然后做一些变换,最后再存回去:
cursor = db.foo.find();
while (cursor.hasNext()) {
var doc = cursor.next();
doc = process(doc);
db.foo.save(doc);
}
结果比较少,这样是没问题的,但是如果结果集比较大,MongoDB可能会多次返回同一个文档。为什么呢?想象一下文档究竟是如何存储的吧。可以将集合看做一个文档列表,如图4-1所示。雪花代表文档,因为每一个文档都是美丽且唯一的。
图4-1 待查询的集合
这样,进行查找时,从集合的开头返回结果,游标不断向右移动。程序获取前100个文档并处理。将这些文档保存回数据库时,如果文档体积增加了,而预留空间不足,如图4-2所示,这时就需要对体积增大后的文档进行移动。通常会将它们挪至集合的末尾处(如图4-3所示)。
图4-2 体积变大的文档,可能无法保存回原先的位置
图4-3 MongoDB会为更新后无法放回原位置的文档重新分配存储空间
现在,程序继续获取大量的文档,如此往复。当游标移动到集合末尾时,就会返回因体积太大无法放回原位置而被移动到集合末尾的文档,如图4-4所示。
图4-4 游标可能会返回那些由于体积变大而被移动到集合末尾的文档
应对这个问题的方法就是对查询进行快照(snapshot)。如果使用了这个选项,查询就在"_id"
索引上遍历执行,这样可以保证每个文档只被返回一次。例如,将db.foo.find()
改为:
> db.foo.find().snapshot()
快照会使查询变慢,所以应该只在必要时使用快照。例如,mongodump
(用于备份,第22章会介绍)默认在快照上使用查询。
所有返回单批结果的查询都被有效地进行了快照。当游标正在等待获取下一批结果时,如果集合发生了变化,数据才可能出现不一致。
4.5.5 游标生命周期
看待游标有两种角度:客户端的游标以及客户端游标表示的数据库游标。前面讨论的都是客户端的游标,接下来简要看看服务器端发生了什么。
在服务器端,游标消耗内存和其他资源。游标遍历尽了结果以后,或者客户端发来消息要求终止,数据库将会释放这些资源。释放的资源可以被数据库另作他用,这是非常有益的,所以要尽量保证尽快释放游标(在合理的前提下)。
还有一些情况导致游标终止(随后被清理)。首先,游标完成匹配结果的迭代时,它会清除自身。另外,如果客户端的游标已经不在作用域内了,驱动程序会向服务器发送一条特别的消息,让其销毁游标。最后,即便用户没有迭代完所有结果,并且游标也还在作用域中,如果一个游标在10分钟内没有使用的话,数据库游标也会自动销毁。这样的话,如果客户端崩溃或者出错,MongoDB就不需要维护这上千个被打开却不再使用的游标。
这种“超时销毁”的行为是我们希望的:极少有应用程序希望用户花费数分钟坐在那里等待结果。然而,有时的确希望游标持续的时间长一些。若是如此的话,多数驱动程序都实现了一个叫immortal
的函数,或者类似的机制,来告知数据库不要让游标超时。如果关闭了游标的超时时间,则一定要迭代完所有结果,或者主动将其销毁,以确保游标被关闭。否则它会一直在数据库中消耗服务器资源。
4.6 数据库命令
有一种非常特殊的查询类型叫作数据库命令(database command)。前面已经介绍过文档的创建、更新、删除以及查询。这些都是数据库命令的使用范畴,包括管理性的任务(比如关闭服务器和克隆数据库)、统计集合内的文档数量以及执行聚合等。
本节主要讲述数据库命令,在数据操作、管理以及监控中,数据库命令都是非常有用的。例如,删除集合是使用"drop"
数据库命令完成的:
> db.runCommand({"drop" : "test"});
{
"nIndexesWas" : 1,
"msg" : "indexes dropped for collection",
"ns" : "test.test",
"ok" : true
}
也许你对shell辅助函数比较熟悉,这些辅助函数封装数据库命令,并提供更加简单的接口:
> db.test.drop()
通常,只使用shell辅助函数就可以了,但是了解它们底层的命令很有帮助。尤其是当使用旧版本的shell连接到新版本的数据库上时,这个shell可能不支持新版数据库的一些命令,这时候就不得不直接使用runCommand()
。
在前面的章节中已经看到过一些命令了,比如,第3章使用getLastError
来查看更新操作影响到的文档数量:
> db.count.update({x : 1}, {$inc : {x : 1}}, false, true)
> db.runCommand({getLastError : 1})
{
"err" : null,
"updatedExisting" : true,
"n" : 5,
"ok" : true
}
本节会更深入地介绍数据库命令,一起来看看这些数据库命令到底是什么,到底是怎么实现的。本节也会介绍MongoDB提供的一些非常有用的命令。在shell中运行db.listCommands()
可以看到所有的数据库命令。
数据库命令工作原理
数据库命令总会返回一个包含"ok"
键的文档。如果"ok"
的值是1,说明命令执行成功了;如果值是0,说明由于一些原因,命令执行失败。
如果"ok"
的值是0,那么命令的返回文档中就会有一个额外的键"errmsg"
。它的值是一个字符串,用于描述命令的失败原因。例如,如果试着在上一节已经删除的集合上再次执行drop
命令:
> db.runCommand({"drop" : "test"});
{ "errmsg" : "ns not found", "ok" : false }
MongoDB中的命令被实现为一种特殊类型的查询,这些特殊的查询会在$cmd
集合上执行。runCommand
只是接受一个命令文档,并且执行与这个命令文档等价的查询。于是,drop
命令会被转换为如下代码:
db.$cmd.findOne({"drop" : "test"});
当MongoDB服务器得到一个在$cmd
集合上的查询时,不会对这个查询进行通常的查询处理,而是会使用特殊的逻辑对其进行处理。几乎所有的MongoDB驱动程序都会提供一个类似runCommand
的辅助函数,用于执行命令,而且命令总是能够以简单查询的方式执行。
有些命令需要有管理员权限,而且要在admin
数据库上才能执行。如果在其他数据库上执行这样的命令,就会得到一个"access denied"
(访问被拒绝)错误。如果当前位于其他的数据库,但是需要执行一个管理员命令,可以使用adminCommand
而不是runCommand
:
> use temp
switched to db temp
> db.runCommand({shutdown:1})
{ "errmsg" : "access denied; use admin db", "ok" : 0 }
> db.adminCommand({"shutdown" : 1})
MongoDB中,数据库命令是少数与字段顺序相关的地方之一:命令名称必须是命令中的第一个字段。因此, {"getLastError" : 1, "w" : 2}
是有效的命令,而{"w" : 2, "getLastError" : 1}
不是。