MongoDB 4.2——查询

查询

1、find简介

我们在 MongoDB 中使用 find 方法来进行查询。查询就是返回集合中文档的一个子集,子集的范围从 0 个文档到整个集合。要返回哪些文档由 find 的第一个参数决定,该参数是一个用于指定查询条件的文档。

空的查询文档({})会匹配集合中的所有内容。如果 find没有给定查询文档,则默认为 {}。例如:

js 复制代码
> db.c.find()
//将匹配集合 c 中的所有文档(并批量返回)​。

当开始向查询文档中添加键--值对时,就意味着限定了查询条件。这对于大多数类型来说是一种简单明了的方式:数值匹配数值,布尔类型匹配布尔类型,字符串匹 配字符串。查询简单类型只要指定要查找的值就可以了。例如,要查找 "age" 值为 27 的所有文档,可以将该键--值对添加到查询文档中:

js 复制代码
> db.users.find({"age" : 27})

如果有一个要匹配的字符串,比如键 "username" 和它的值 "joe",则可以使用该键--值对:

js 复制代码
> db.users.find({"username" : "joe"})

可以在查询文档中加入多个键--值对,以将多个查询条件组合在一起,这样的查询条件会被解释为"条件 1 AND条件 2 AND...AND 条件 N "​。例如,要查询所有用户名为 joe 并且年龄为 27 岁的用户,可以进行如下请求。

js 复制代码
> db.users.find({"username" : "joe", "age" : 27})

1.1、指定要返回的键

有时候并不需要返回文档中的所有键--值对。遇到这种情况时,可以通过 find(或者 findOne)的第二个参数来指定需要的键。这样做既可以节省网络传输的数据量,也可以减少客户端解码文档的时间和内存消耗。

如果你有一个用户集合,并且你只对其中的 "username"键和 "email" 键感兴趣,那么可以使用如下查询:

js 复制代码
> db.users.find({}, {"username" : 1, "email" : 1})
{
    "_id" : ObjectId("4ba0f0dfd22aa494fd523620"),
    "username" : "joe",
    "email" : "joe@example.com"
}

从以上输出可以看到,默认情况下 "_id" 键总是会被返回,即使没有指定要返回这个键。

也可以用第二个参数来剔除查询结果中的某些键--值对。例如,可能在文档中有很多键,而你不希望结果中包含"fatal_weakness" 键:

js 复制代码
> db.users.find({}, {"fatal_weakness" : 0})

这种方式同样可以将 "_id" 键从返回结果中剔除。

js 复制代码
> db.users.find({}, {"username" : 1, "_id" : 0})
{
    "username" : "joe"
}

1.2、限制

查询在使用上是有一些限制的。传递给数据库的查询文档的值必须是常量。​(在你自己的代码里可以是普通的变量。​)也就是说,不能引用文档中其他键的值。如果想维护库存,并且有 "in_stock" 和 "num_sold" 这两个键,就不能通过下面的查询来比较它们的值:

js 复制代码
> db.stock.find({"in_stock" : "this.num_sold"}) // 不能这样做

有一些方法可以做到这一点)​,但是通常可以通过对文档结构进行略微的调整来获得更好的性能,这样一个"普通"的查询就足够了。在这个例子中,可以在文档中使用 "initial_stock" 和 "in_stock" 这两个键。然后每当有人购买物品时,就把 "in_stock" 键的值减 1。这样,只需用一条简单的查询语句就能检查出哪些商品处于缺货状态了。

js 复制代码
> db.stock.find({"in_stock" : 0})

2、查询条件

查询不仅能像上一节描述的那样进行精确匹配,还可以匹配更加复杂的条件,比如范围、OR 子句以及取反。

2.1、查询条件

"$lt"、"$lte"、"$gt" 和 "$gte" 都属于比较运算符,分别对应 <、<=、> 和 >=。可以将它们组合使用以查找一个范围内的值。例如,要查询 18 到 30 岁的用户,可以进行如下操作:

js 复制代码
> db.users.find({"age" : {"$gte" : 18, "$lte" : 30}})

这样就可以查找到 "age" 字段大于等于 18 并且小于等于30 的所有文档了。

这类范围查询通常对日期非常有用。例如,要查找在2007 年 1 月 1 日前注册的用户,可以这样做:

js 复制代码
> start = new Date("01/01/2007")
> db.users.find({"registered" : {"$lt" : start}})

根据创建和存储日期的方式,精确匹配可能用处不大,因为日期是以毫秒精度存储的。通常,你需要的是一整天、一周或一个月的数据,因此需要使用范围查询。

对于文档键值不等于某个特定值的情况,就要使用另外一种条件运算符 "$ne" 了,它表示"不相等"​。如果想找到所有用户名不为 joe 的用户,可以像下面这样查询:

js 复制代码
> db.users.find({"username" : {"$ne" : "joe"}})

"$ne" 可以用于任何类型的数据。

2.2、OR查询

MongoDB 中有两种方式可以进行 OR 查询。"$in" 可以用来查询一个键的多个值。"$or" 则更通用一些,可以在多个键中查询任意的给定值。

如果一个键需要与多个值进行匹配,那么可以将一个条件数组与 "$in" 一起使用。假设我们正在进行一个抽奖活动,中奖号码是 725、542 和 390。要找出全部 3 个文档,可以构建如下查询:

js 复制代码
> db.raffle.find({"ticket_no" : {"$in" : [725, 542, 390]}})

"$in" 的用法非常灵活,可以指定不同类型的条件和值。例如,在逐步将用户的 ID 编号迁移成用户名的过程中,可以同时对其进行查询:

js 复制代码
> db.users.find({"user_id" : {"$in" : [12345, "joe"]}})

这既会匹配 "user_id" 等于 12345 的文档,也会匹配"user_id" 等于 "joe" 的文档。

如果 "$in" 对应的数组只有一个值,那么和直接匹配这个值的效果是一样的。例如,{ticket_no : {$in : [725]}}{ticket_no : 725} 将匹配相同的文档。

与 "$in" 相反的是 "$nin",此运算符会返回与数组中所有条件都不匹配的文档。如果想返回所有没有中奖的人,可以这样进行查询:

js 复制代码
> db.raffle.find({"ticket_no" : {"$nin" : [725, 542, 390]}})

这条查询语句会返回所有没有这些号码的人。

"$in" 能够对单个键进行 OR 查询,但如果想找到"ticket_no" 为 725 或者 "winner" 为 true 的文档该怎么办呢?对于这类查询,需要使用 "$or" 条件运算符。"$or" 会接受一个包含所有可能条件的数组作为参数。在上面的抽奖活动例子中,使用 "$or" 会是这样:

js 复制代码
> db.raffle.find({"$or" : [{"ticket_no" : {"$in" : [725, 542, 390]}},
...                        {"winner" : true}]})

对于普通的 AND 类型查询,我们总是希望尽可能用最少的参数来限定结果的范围。OR 类型的查询则相反:如果第一个参数能够匹配尽可能多的文档,则其效率最高。

虽然总是可以使用 "$or",但只要有可能就应该使用"$in",因为查询优化器可以更高效地对其进行处理。

2.3、$not

"$not" 是一个元条件运算符:可以用于任何其他条件之上。以取模运算符 " m o d " 为例, " mod" 为例," mod"为例,"mod" 会将查询的值除以第一个给定值,如果余数等于第二个给定值,则匹配成功:

js 复制代码
> 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":

js 复制代码
> db.users.find({"id_num" : {"$not" : {"$mod" : [5, 1]}}})

在与正则表达式联合使用以查找那些与特定模式不匹配的文档时,"$not" 尤其有用。

3、特定类型的查询

3.1、null

null 的行为有一些特别。它可以与自身匹配,所以如果有一个包含如下文档的集合:

js 复制代码
> db.c.find()
{ "_id" : ObjectId("4ba0f0dfd22aa494fd523621"), "y" : null }
{ "_id" : ObjectId("4ba0f0dfd22aa494fd523622"), "y" : 1 }
{ "_id" : ObjectId("4ba0f148d22aa494fd523623"), "y" : 2 }

那么可以按照预期的方式查询 "y" 键为 null 的文档:

js 复制代码
> db.c.find({"y" : null})
{ "_id" : ObjectId("4ba0f0dfd22aa494fd523621"), "y" : null }

不过,null 同样会匹配"不存在"这个条件。因此,对一个键进行 null 值的请求还会返回缺少这个键的所有文档:

js 复制代码
> db.c.find({"z" : null})
{ "_id" : ObjectId("4ba0f0dfd22aa494fd523621"), "y" : null }
{ "_id" : ObjectId("4ba0f0dfd22aa494fd523622"), "y" : 1 }
{ "_id" : ObjectId("4ba0f148d22aa494fd523623"), "y" : 2 }

如果仅想匹配键值为 null 的文档,则需要检查该键的值是否为 null,并且通过 "$exists" 条件确认该键已存在。

js 复制代码
> db.c.find({"z" : {"$eq" : null, "$exists" : true}})

3.2、正则表达式

"$regex" 可以在查询中为字符串的模式匹配提供正则表达式功能。正则表达式对于灵活的字符串匹配非常有用。如果要查找所有用户名为 Joe 或 joe 的用户,那么可以使用正则表达式进行不区分大小写的匹配:

js 复制代码
> db.users.find( {"name" : {"$regex" : /joe/i } })

可以在正则表达式中使用标志(如 i)​,但这没有强制要求。如果除了匹配各种大小写组合形式的"joe"之外,还希望匹配如"joey"这样的键,那么可以改进一下刚刚的正则表达式:

js 复制代码
> db.users.find({"name" : /joey?/i})

MongoDB 会使用 Perl 兼容的正则表达式(PCRE)库来对正则表达式进行匹配。任何 PCRE 支持的正则表达式语法都能被 MongoDB 接受。在查询中使用正则表达式之前,最好先在 JavaScript shell 中检查一下语法,这样可以确保匹配与预想的一致。

MongoDB 可以利用索引来查询前缀正则表达式(如/^joey/)​。索引不能用于不区分大小写的搜索(/^joey/i)​。当正则表达式以插入符号(^)或左锚点(\A)开头时,它就是"前缀表达式"​。如果正则表达式使用了区分大小写的查询,那么当字段存在索引时,则可以对索引中的值进行匹配。如果它也是一个前缀表达式,那么可以将搜索限制在由该索引的前缀所形成的范围内的值。

正则表达式也可以匹配自身。虽然很少有人会将正则表达式插入数据库中,但是如果你这么做了,那么它也可以匹配到自身。

js 复制代码
> db.foo.insertOne({"bar" : /baz/})
> db.foo.find({"bar" : /baz/})
{
    "_id" : ObjectId("4b23c3ca7525f35f94b60a2d"),
    "bar" : /baz/
}

3.3、查询数组

查询数组元素的方式与查询标量值相同。假设有一个数组是水果列表,如下所示:

js 复制代码
> db.food.insertOne({"fruit" : ["apple", "banana", "peach"]})

则下面的查询可以成功匹配到该文档:

js 复制代码
> db.food.find({"fruit" : "banana"})

这个查询就好像对一个这样的(不合法)文档进行查询:{"fruit" : "apple", "fruit" : "banana", "fruit" :"peach"}

3.3.1、"$all"

如果需要通过多个元素来匹配数组,那么可以使用"$all"。这允许你匹配一个元素列表。假设我们创建了一个包含 3 个元素的集合:

js 复制代码
> db.food.insertOne({"_id" : 1, "fruit" : ["apple", "banana", "peach"]})
> db.food.insertOne({"_id" : 2, "fruit" : ["apple", "kumquat", "orange"]})
> db.food.insertOne({"_id" : 3, "fruit" : ["cherry", "banana", "apple"]})

可以使用 "$all" 查询来找到同时包含元素 "apple"和 "banana" 的文档:

js 复制代码
> db.food.find({fruit : {$all : ["apple", "banana"]}})
{"_id" : 1, "fruit" : ["apple", "banana", "peach"]}
{"_id" : 3, "fruit" : ["cherry", "banana", "apple"]}

这里的顺序无关紧要。注意,上面第二个结果中的"banana" 在 "apple" 之前。如果在 " a l l " 中使用只有一个元素的数组,那么这个效果和不使用 " all" 中使用只有一个元素的数组,那么这个效果和不使用 " all"中使用只有一个元素的数组,那么这个效果和不使用"all"是一样的。例如,{fruit : {$all : ['apple']} 和 {fruit :'apple'} 会匹配相同的文档。

也可以使用整个数组进行精确匹配。不过,精确匹配无法匹配上元素丢失或多余的文档。例如,下面这样可以匹配之前的第一个文档:

js 复制代码
> db.food.find({"fruit" : ["apple", "banana", "peach"]})

但是下面这样就不行:

js 复制代码
> db.food.find({"fruit" : ["apple", "banana"]})

这样也无法匹配:

js 复制代码
> db.food.find({"fruit" : ["banana", "apple", "peach"]})

如果想在数组中查询特定位置的元素,可以使用key.index 语法来指定下标:

js 复制代码
> db.food.find({"fruit.2" : "peach"})

数组下标都是从 0 开始的,因此这个语句会用数组的第 3 个元素与字符串 "peach" 进行匹配。

3.3.2、"$size"

"$size" 条件运算符对于查询数组来说非常有用,可以用它查询特定长度的数组,如下所示。

js 复制代码
> db.food.find({"fruit" : {"$size" : 3}})

一种常见的查询是指定一个长度范围。"$size" 并不能与另一个 条件运算符(如 "`gt`")组合使用,但这种查询可以通过在文档中添加一个 "size" 键的方式来实现。之后每次向指定数组添加元素时,同时增加 "size" 的值。如果原本的更新是这样:

js 复制代码
> db.food.update(criteria, {"$push" : {"fruit" : "strawberry"}})

那么可以很容易地转换成这样:

js 复制代码
> db.food.update(
	criteria,
	{"$push" : {"fruit" : "strawberry"}, "$inc" : {"size" : 1}}
)

自增操作的速度非常快,因此任何性能损失都可以忽略不计。这样存储文档后就可以执行如下查询:

js 复制代码
> db.food.find({"size" : {"$gt" : 3}})

很遗憾,这种技巧无法与 "$addToSet" 运算符联合使用。

3.3.3、"$slice"

find 的第二个参数是可选的,可以指定需要返回的键。这个特别的 "$slice"运算符可以返回一个数组键中元素的子集。

假设现在有一个关于博客文章的文档,我们希望返回前 10 条评论:

js 复制代码
> db.blog.posts.findOne(criteria, {"comments" : {"$slice" : 10}})

同样,如果想返回后 10 条评论,则可以使用 -10:

js 复制代码
> db.blog.posts.findOne(criteria, {"comments" : {"$slice" : -10}})

"$slice" 也可以指定偏移量和返回的元素数量来获取数组中间的结果:

js 复制代码
> db.blog.posts.findOne(criteria, {"comments" : {"$slice" : [23, 10]}})

这个操作会略过前 23 个元素,返回第 24~33 个元素。如果数组中的元素少于 33 个,则会返回尽可能多的元素。

除非特别指定,否则在使用 "$slice" 时会返回文档中的所有键。这与其他的键指定符不同,后者不会返回未指定的键。例如,有这样一个关于博客文章的文档:

js 复制代码
{
    "_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" 来获取最后一条评论,如下所示:

js 复制代码
> 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" 没有显式地被包含在键指定符中,但它们依然都被返回了。

3.3.4、返回一个匹配的数组元素

如果知道数组元素的下标,那么 "slice" 非常有用。但有时我们希望返回与查询条件匹配的任意数组元素。这时可以使用 运算符来返回匹配的元素。对于前面的博客文章示例,可以这样获得 Bob 的评论:

js 复制代码
> db.blog.posts.find({"comments.name" : "bob"}, {"comments.$" : 1})
{
    "_id" : ObjectId("4b2d75476cc613d5ee930164"),
    "comments" : [
        {
            "name" : "bob",
            "email" : "bob@example.com",
            "content" : "good post."
        }
    ]
}

注意,这种方式只会返回每个文档中第一个匹配的元素:如果 Bob 在这篇博客中留下了多条评论,那么只有 "comments" 数组中的第一个会被返回。

3.3.5、数组与范围查询的相互作用

文档中的标量(非数组元素)必须与查询条件中的每一条子句相匹配。如果使用 {"x" : {"$gt" : 10, "$lt" :20}} 进行查询,那么 "x" 必须同时满足大于 10 且小于 20。然而,如果文档中的 "x" 字段是一个数组,那么当 "x" 键的某一个元素与查询条件的任意一条语句相匹配(查询条件中的每条语句可以匹配不同的数组元素)时,此文档也会被返回。

理解这种行为最好的方式就是来看一个例子。假设现在有如下几个文档:

js 复制代码
{"x" : 5}
{"x" : 15}
{"x" : 25}
{"x" : [5, 25]}

如果想找出 "x" 的值在 10 和 20 之间的所有文档,那么你可能会本能地构建这样的查询,即db.test.find({"x" : {"$gt" : 10, "$lt" : 20}}),然后期望它会返回一个文档:{"x" : 15}。然而,当实际运行时,我们得到了两个文档,如下所示:

js 复制代码
> db.test.find({"x" : {"$gt" : 10, "$lt" : 20}})
{"x" : 15}
{"x" : [5, 25]}

5 和 25 都不在 10 和 20 之间,但由于 25 与查询条件中的第一个子句("x" 的值大于 10)相匹配,5与查询条件中的第二个子句("x" 的值小于 20)相匹配,因此这个文档会被返回。

这样就使得针对数组的范围查询基本上失去了作用:一个范围会匹配任何多元素数组。有几种方法可以获得预期的行为。

可以使用 "$elemMatch" 强制 MongoDB 将这两个子句与单个数组元素进行比较。不过,这里有一个问题,"$elemMatch" 不会匹配非数组元素:

js 复制代码
> db.test.find({"x" : {"$elemMatch" : {"$gt" : 10, "$lt" : 20}}})
> // 没有结果

文档 {"x" : 15} 不再与查询条件匹配了,因为它的"x" 字段不是一个数组。也就是说,你应该有充分的理由在一个字段中混合数组和标量值,而这在很多场景中并不需要。对于这样的情况,"$elemMatch" 为数组元素的范围查询提供了一个很好的解决方案。

如果在要查询的字段上有索引​,那么可以使用 min 和 max 将查询条件遍历的索引范围限制为 "$gt" 和 "$lt" 的值:

js 复制代码
> 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" 进行查询是非常低效的。它基本上接受任何值,因此会搜索每个索引项,而不仅仅是索引范围内的值。

3.4、查询内嵌文档

查询内嵌文档的方法有两种:查询整个文档或针对其单个键--值对进行查询。

查询整个内嵌文档的工作方式与普通查询相同。假设有这样一个文档:

js 复制代码
{
    "name" : {
        "first" : "Joe",
        "last" : "Schmoe"
    },
    "age" : 45
}

可以像下面这样查询姓名为 Joe Schmoe 的人:

js 复制代码
> db.people.find({"name" : {"first" : "Joe", "last" : "Schmoe"}})

然而,如果要查询一个完整的子文档,这个子文档就必须精确匹配。如果 Joe 决定添加一个代表中间名的字段,这个查询就无法工作了,因为查询条件不再与整个内嵌文档相匹配。而且这种查询还是与顺序相关的:{"last" : "Schmoe", "first" : "Joe"} 就无法匹配。

如果可能,最好只针对内嵌文档的特定键进行查询。这样,即使数据模式变了,也不会导致所有查询因为需要精确匹配而无法使用。可以使用点表示法对内嵌文档的键进行查询:

js 复制代码
> db.people.find({"name.first" : "Joe", "name.last" : "Schmoe"})

这时,如果 Joe 增加了更多的键,那么这个查询仍然可以匹配他的姓和名。

这种点表示法是查询文档和其他文档类型的主要区别。查询文档可以包含点,表示"进入内嵌文档内部"的意思。点表示法也是待插入文档不能包含 . 字符的原因。当人们试图将 URL 保存为键时,常常会遇到这种限制。解决这个问题的一种方法是在插入前或者提取后始终执行全局替换,用点字符替换 URL 中不合法的字符。

随着文档结构变得越来越复杂,内嵌文档的匹配可能会变得有点儿棘手。假设我们正在存储博客文章,要找到Joe 发表的 5 分以上的评论。可以按照以下方式对文章进行建模:

js 复制代码
> 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" : "joe" 和第二条评论中的 "score" : 6。

要正确指定一组条件而无须指定每个键,请使用"$elemMatch"。这种模糊的命名条件允许你在查询条件中部分指定匹配数组中的单个内嵌文档。正确的查询如下所示:

js 复制代码
> db.blog.find({"comments": {"$elemMatch": {"author": "joe", "score": {"$gte": 5}}}})

"$elemMatch" 允许你将限定条件进行"分组"​。仅当需要对一个内嵌文档的多个键进行操作时才会用到它。

4、$where查询

键--值对是一种相当有表现力的查询方式,但有些查询依然无法表示。对于无法以其他方式执行的查询,可以使用 "$where" 子句,它允许你在查询中执行任意的JavaScript 代码。这样就能在查询中做大部分事情了。为安全起见,应该严格限制或消除 "$where" 子句的使用。应该禁止终端用户随意使用 "$where" 子句。

使用 "$where" 最常见的情况是比较文档中两个键的值。假设有如下文档:

js 复制代码
> db.foo.insertOne({"apple" : 1, "banana" : 6, "peach" : 3})
> db.foo.insertOne({"apple" : 8, "spinach" : 4, "watermelon" : 4})

我们希望返回任意两个字段相等的文档。例如,在第二个文档中,"spinach" 和 "watermelon" 的值相同,所以需要返回该文档。MongoDB 不太可能为此类查询提供一个 条件运算符,因此只能使用 "where" 子句并结合JavaScript 来实现:

js 复制代码
> 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" 表达式仅用于对结果进行进一步微调。MongoDB 3.6 增加了$expr 运算符,它允许在 MongoDB 查询语句中使用聚合表达式。因为它不需要执行 JavaScript,所以速度比$where 快,建议尽可能使用此运算符作为替代。

5、游标

数据库会使用游标返回 find 的执行结果。游标的客户端实现通常能够在很大程度上对查询的最终输出进行控制。你可以限制结果的数量,跳过一些结果,按任意方向的任意键组合对结果进行排序,以及执行许多其他功能强大的操作。

要使用 shell 创建游标,首先要将一些文档放入集合中,对它们执行查询,然后将结果分配给一个局部变量(用"var" 定义的变量就是局部变量)​。在这里,先创建一个非常简单的集合并对其进行查询,然后将结果存储在cursor 变量中:

js 复制代码
> for(i=0; i<100; i++) {
    db.collection.insertOne({x : i});
}
> var cursor = db.collection.find();

这样做的好处是可以一次查看一个结果。如果将结果存储到全局变量中或根本不存储到变量中,那么 MongoDBshell 将自动遍历并显示最开始的几个文档。这是到目前为止我们一直看到的种种例子,通常大家也只是希望看到集合中的内容,而不是使用 shell 进行编程。

要遍历结果,可以在游标上使用 next 方法。可以使用hasNext 检查是否还有其他结果。典型的结果遍历如下所示:

js 复制代码
> while (cursor.hasNext()) {
	obj = cursor.next();
	// 执行任务
}

cursor.hasNext() 会检查是否有后续结果存在,而cursor.next() 用来对其进行获取。

cursor 类还实现了 JavaScript 的迭代器接口,因此可以在 forEach 循环中使用:

js 复制代码
> var cursor = db.people.find();
> cursor.forEach(function(x) {
	print(x.name);
});
adam
matt
zak

调用 find 时,shell 并不会立即查询数据库,而是等到真正开始请求结果时才发送查询,这样可以在执行之前给查询附加额外的选项。cursor 对象的大多数方法会返回游标本身,这样就可以按照任意顺序将选项链接起来了。例如,以下这些是等价的:

js 复制代码
> 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});

这时,查询还没有真正执行。所有这些函数只会构造查询。现在,假设执行以下调用:

js 复制代码
> cursor.hasNext()

这时,查询会被发往服务器端。shell 会立刻获取前 100个结果或者前 4MB 的数据(两者之中较小者)​,这样下次调用 next 或者 hasNext 时就不必再次连接服务器端去获取结果了。在客户端遍历完第一组结果后,shell 会再次连接数据库,使用 getMore 请求更多的结果。getMore 请求包含一个游标的标识符,它会向数据库询问是否还有更多的结果,如果有则返回下一批结果。这个过程会一直持续,直到游标耗尽或者结果被全部返回。

5.1、limit、skip和sort

最常用的查询选项是限制返回结果的数量、略过一定数量的结果以及排序。所有这些选项必须在查询被发送到数据库之前指定。

要限制结果数量,可以在 find 之后链式调用 limit 函数。如果只返回 3 个结果,那么可以这样做:

js 复制代码
> db.c.find().limit(3)

如果集合中所匹配的文档不到 3 个,则仅返回匹配数量的结果。limit 指定的是上限,而不是下限。

skip 与 limit 类似:

js 复制代码
> db.c.find().skip(3)

这个操作会略过前 3 个匹配的文档,然后返回剩下的文档。如果集合中匹配的文档少于 3 个,则不会返回任何文档。

sort 会接受一个对象作为参数,这个对象是一组键--值对,键对应文档的键名,值对应排序的方向。排序方向可以是 1(升序)或 -1(降序)​。如果指定了多个键,则结果会按照这些键被指定的顺序进行排序。例如,要按照 "username" 升序及 "age" 降序排列,可以这样做:

js 复制代码
> db.c.find().sort({"username" : 1, "age" : -1})

这 3 个方法可以结合使用。对于分页来说,这非常方便。假设你正在运营一个在线商店,有人想搜索 mp3。如果想每页返回 50 个结果并按照价格从高到低排序,可以像下面这样做:

js 复制代码
> db.stock.find({"desc" : "mp3"}).limit(50).sort({"price" : -1})

如果顾客单击了"下一页"以获取更多的结果,可以通过对查询添加 skip 来实现,这样就可以略过前 50 个结果了(这些结果已经显示在第 1 页了)​:

js 复制代码
> db.stock.find({"desc" : "mp3"}).limit(50).skip(50).sort({"price" : -1})

然而,略过大量的结果会导致性能问题。下一节会介绍有关避免此问题的一些建议。

比较顺序

MongoDB 对于类型的比较有一个层次结构。有时一个键的值可能有多种类型:整型和布尔型,或者字符串和null。如果对混合类型的键进行排序,那么会有一个预定义的排序顺序。从最小值到最大值,顺序如下。

  1. 最小值
  2. null
  3. 数字(整型、长整型、双精度浮点型、小数型)
  4. 字符串
  5. 对象/文档
  6. 数组
  7. 二进制数据
  8. 对象 ID
  9. 布尔型
  10. 日期
  11. 时间戳
  12. 正则表达式
  13. 最大值

5.2、避免略过大量结果

使用 skip 来略过少量的文档是可以的。但对于结果非常多的情况,skip 会非常慢,因为需要先找到被略过的结果,然后再丢弃这些数据。大多数数据库会在索引中保存更多的元数据以处理 skip,但 MongoDB 目前还不支持这样做,所以应该避免略过大量的数据。通常下一次查询的条件可以基于上一次查询的结果计算出来。

5.2.1、不使用skip对结果进行分页

最简单的分页方式是使用 limit 返回结果的第 1 页,然后将每个后续页面作为相对于开始的偏移量进行返回:

js 复制代码
> // 不要这么做:略过大量数据会非常慢
> 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" 降序显示文档。可以通过以下方式来获得结果的第 1 页:

js 复制代码
> var page1 = db.foo.find().sort({"date" : -1}).limit(100)

然后,假设日期是唯一的,可以使用最后一个文档的"date" 值作为获取下一页的查询条件:

js 复制代码
var latest = null;

// 显示第1页
while (page1.hasNext()) {
    latest = page1.next();
    display(latest);
}

// 获取下一页
var page2 = db.foo.find({"date" : {"$lt" : latest.date}});
page2.sort({"date" : -1}).limit(100);

这样查询中就没有 skip 了。

5.2.2、查找一个随机文档

从集合中随机选择文档是一个常见的问题。最简单(但很慢)的解决方案是先计算文档总数,然后略过0 和集合大小之间的一个随机的文档数量来执行一次find 查询:

js 复制代码
> // 不要这样做
> var total = db.foo.count()
> var random = Math.floor(Math.random()*total)
> db.foo.find().skip(random).limit(1)

以这种方式获取随机元素实际上非常低效:必须先计算总数(如果使用查询条件,这会是一个昂贵的操作)​,并且略过大量的元素也会非常耗时。

这需要一些提前规划,但如果你知道要在集合中查找随机元素,那么有一种高效得多的方法可以执行此操作。诀窍就是在插入文档时为每个文档添加一个额外的随机键。如果正在使用 shell,那么可以使用Math.random() 函数(这会产生 0 和 1 之间的一个随机数)​:

js 复制代码
> db.people.insertOne({"name" : "joe", "random" : Math.random()})
> db.people.insertOne({"name" : "john", "random" : Math.random()})
> db.people.insertOne({"name" : "jim", "random" : Math.random()})

这样,当从集合中查找一个随机文档时,可以计算一个随机数并将其作为查询条件,而不再使用 skip:

js 复制代码
> var random = Math.random()
> result = db.people.findOne({"random" : {"$gt" : random}})

random 可能会大于集合中的任何 "random" 值,并且不会返回任何结果。可以简单地从另一个方向返回文档以避免这种情况:

js 复制代码
> if (result == null) {
	result = db.people.findOne({"random" : {"$lte" : random}})
}

如果集合中没有任何文档,那么这种方式会返回null,这也是合理的。

这种方式可以用于任意复杂的查询,只需确保有一个包含随机键的索引。如果要在加利福尼亚州随机找一个水管工,那么可以在 "profession"、"state" 和"random" 上创建一个索引:

js 复制代码
> db.people.ensureIndex({"profession" : 1, "state" : 1, "random" : 1})

这便可以快速地找到一个随机结果

5.3、游标生命周期

游标包括两个部分:面向客户端的游标和由客户端游标所表示的数据库游标。

在服务器端,游标会占用内存和资源。一旦游标遍历完结果之后,或者客户端发送一条消息要求终止,数据库就可以释放它正在使用的资源。释放这些资源可以让数据库将其用于其他用途,这是非常有益的,因此要确保可以尽快(在合理的范围内)释放游标。

还有一些情况可能导致游标终止以及随后的清理。首先,当游标遍历完匹配的结果时,它会清除自身。其次,当游标超出客户端的作用域时,驱动程序会向数据库发送一条特殊的消息,让数据库知道它可以"杀死"该游标。最后,即使用户没有遍历完所有结果而且游标仍在作用域内,如果 10 分钟没有被使用的话,数据库游标也将自动"销毁"​。这样,如果客户端崩溃或者出错,MongoDB 就不需要维护上千个被打开的游标了。

这种"超时销毁"的机制通常是用户所期望的:很少有用户愿意花几分钟坐在那里等待结果。然而,有时可能的确需要一个游标维持很长时间。在这种情况下,许多驱动程序实现了一个称为 immortal 的函数,或者类似的机制,它告诉数据库不要让游标超时。如果关闭了游标超时,则必须遍历完所有结果或主动将其销毁以确保游标被关闭。否则,它会一直占用数据库的资源,直到服务器重新启动。

相关推荐
xieliyu.3 小时前
MySQL 存储过程详解:概念、创建与删除全教程
开发语言·数据库·mysql
ttwuai3 小时前
AI 生成后台删除按钮后,MySQL 软删除和唯一索引怎么验
数据库·mysql·golang
张人玉3 小时前
基于 Vue 3 + ECharts + Express + SQLite 构建的新能源汽车销量数据分析与可视化平台——新能源汽车销量数据分析系统
数据库·vue.js·sqlite·echarts
笨笨饿5 小时前
#102_Codex无在VSCold无法打开
java·c语言·数据库·笔记
向夏威夷 梦断明暄5 小时前
从架构特点到功能缺陷,重新认识分析型分布式数据库
数据库·分布式·架构
不知疲倦的仄仄5 小时前
MySQL/Read View快照/MVCC/串行化
java·数据库·mysql
向夏威夷 梦断明暄6 小时前
C# 弃元模式:从语法糖到性能利器的深度解析
服务器·数据库·c#
糖果店的幽灵6 小时前
大模型测评DeepEval快速入门-RAG指标详解
数据库·人工智能·langgraph·大模型测评·deepeval