超高性价比的 MongoDB 快速入门教程
在上一篇 《MongoDB 实战教程:数据库与集合的 CRUD
操作篇》
中,我们学习了MongoDB 与 NoSQL 的关系、 MongoDB
的安装、数据类型、MongoShell、创建数据库、显式创建集合和隐式创建集合,还学习了如何更改集合名称以及删除数据库和集合的方法,并对每种操作都进行了实例演示。
在本篇 chat 中我们将学习流式聚合操作,并深入了解语句的执行效率。然后深入学习能够提高数据服务可用性的复制集。接着了解 MongoDB
的水平扩展能力,学习 MongoDB 数据的备份与还原方法,并为数据服务开启访问控制。
基础篇 一 文档的 CRUD 操作
CRUD 操作指的是对文档进行 create
,read
,update
and delete
操作,即增删改查。文档 CRUD
操作的内容将分为 Create Operations
, Read Operations
, Update Operations
, Delete
Operations
和 Cursor
等 5 个部分进行介绍。
Create Operations
创建操作或者插入操作会向集合添加新的文档。之前有提到过,如果插入时集合不存在,插入操作会创建对应的集合。MongoDB 提供了 3 个插入文档的方法:
插入单个文档
其中,db.collection.insertOne()
用于向集合插入单个文档。而 db.collection.insertMany()
和db.collection.insert()
可以向集合插入多个文档。db.collection.insertOne()
示例如下:
> db.zenrust.insertOne({
… nickname: “Rust 之禅”,
… name: “zenrust”,
… types: “订阅号”,
… descs:”超酷人生,我用 Rust”
… })
自动命令执行后会返回一个结果文档,文档输出如下:
{
“acknowledged” : true,
“insertedId” : ObjectId(“5d157fe26fcb85935e9cb786”)
}
这说明文档插入成功。其中,acknowledged
代表本次操作的操作状态,状态值包括 true
和 false
。insertedId
即该文档的 _id
。
提示:示例中的省略号是 MongoShell
的换行标识符。换行标识符对命令输入和执行并没有影响,所以本文也不会注重风格的统一,即示例中有时会带有换行符,有时则不带有换行符。
插入多个文档
db.collection.insertMany()
示例如下:
> db.zenrust.insertMany([
… {nickname: “Rust 之禅”, name: “zenrust”, types: “订阅号”, descs: “超酷人生,我用 Rust”},
… {nickname: “进击的 Coder”, name: “FightingCoder”, types: “订阅号”, descs: “分享爬虫技术和机器学习方面的编程经验”}
… ])
由于本次插入了 2 个文档,所以返回的结果文档会显示两个 _id
。返回文档内容如下:
{
“acknowledged” : true,
“insertedIds” : [
ObjectId(“5d1582136fcb85935e9cb787”),
ObjectId(“5d1582136fcb85935e9cb788”)
]
}
db.collection.insert()
示例如下:
> db.zenrust.insert({title: “全面认识 RUST,掌控未来的雷电”})
示例演示的是单个文档的插入,实际上插入多个文档也是没问题的。db.collection.insert()
插入单个文档时返回的是一个带有操作状态的WriteResult
对象:WriteResult({ "nInserted" : 1 })
。其中,nInserted
表明了插入文档的总数。但如果插入操作遇到错误,那么 WriteResult
对象将包含错误提示信息。
db.collection.insert()
插入多个文档的示例如下:
> db.zenrust.insert([{nickname: “进击的 Coder”}, {nickname: “Rust 之禅”}])
BulkWriteResult({
“writeErrors” : [ ],
“writeConcernErrors” : [ ],
“nInserted” : 2,
“nUpserted” : 0,
“nMatched” : 0,
“nModified” : 0,
“nRemoved” : 0,
“upserted” : [ ]
})
可以看到,db.collection.insert()
插入多个文档和插入单个文档得到的返回结果是不同的。
Read Operations
MongoDB 提供了 db.collection.find()
方法从集合中读取文档。在开始练习之前,需要准备用于练习的基础数据。在
MongoShell 中执行以下文档插入操作:
> db.inven.insertMany([
{ name: “詹姆斯”, number: 6, attribute: { h: 203, w: 222, p: “前锋” }, status: “A” },
{ name: “韦德”, number: 3, attribute: { h: 193, w: 220, p: “得分后卫” }, status: “R” },
{ name: “科比”, number: 24, attribute: { h: 198, w: 212, p: “得分后卫” }, status: “R” },
{ name: “姚明”, number: 11, attribute: { h: 226, w: 308, p: “中锋” }, status: “R” },
{ name: “乔丹”, number: 23, attribute: { h: 198, w: 216, p: “得分后卫” }, status: “R” }
])
查询文档
将一个空位当作为查询过滤器参数传递给 db.collection.find()
方法就可以得到所有文档,对应示例如下:
> db.inven.find({})
或者什么都不传,直接使用 find()
,对应示例如下:
> db.inven.find()
这等效于 SQL 中的 SELECT * FROM inven
。
指定等式条件
如果要指定相等条件,可以使用 {<field1>: <value1>, ...}
这样的过滤表达式,例如过滤出已退役球员(”R”
代表退役)的查询语句如下:
> db.inven.find({status: “R”})
{ “_id” : ObjectId(“5d159e794d3d891430a2512e”), “name” : “韦德”, “number” : 3, “attribute” : { “h” : 193, “w” : 220, “p” : “得分后卫” }, “status” : “R” }
{ “_id” : ObjectId(“5d159e794d3d891430a2512f”), “name” : “科比”, “number” : 24, “attribute” : { “h” : 198, “w” : 212, “p” : “得分后卫” }, “status” : “R” }
{ “_id” : ObjectId(“5d159e794d3d891430a25130”), “name” : “姚明”, “number” : 11, “attribute” : { “h” : 226, “w” : 308, “p” : “中锋” }, “status” : “R” }
{ “_id” : ObjectId(“5d159e794d3d891430a25131”), “name” : “乔丹”, “number” : 23, “attribute” : { “h” : 198, “w” : 216, “p” : “得分后卫” }, “status” : “R” }
这等效于 SQL 中的 SELECT * FROM inven WHERE status = "R"
。
根据嵌套文档字段查询
我们还可以根据嵌入式文档中的字段进行查询,例如过滤出球员属性中身高为 193
的球员,对应示例如下:
> db.inven.find({“attribute.h”: 193})
{ “_id” : ObjectId(“5d159e794d3d891430a2512e”), “name” : “韦德”, “number” : 3, “attribute” : { “h” : 193, “w” : 220, “p” : “得分后卫” }, “status” : “R” }
要注意的是,访问嵌入式文档中的字段时使用的并不是 attribute.h
,而是使用 "attribute.h"
。
查询与投影操作
查询的情况非常复杂,MongoDB 提供了多种查询操作符来应对这些问题。MongoDB 提供的查询操作符分为以下几类:
- 比较查询操作符
- 逻辑查询操作符
- 元素查询操作符
- 评估查询操作符
- 地理空间查询操作符
- 数组查询操作符
- 按位查询操作符
接下来,我们将学习每一种查询操作符的规则和语法。
比较查询操作符
比较是最常见的操作之一,它分为同类型比较和非同类型比较。在面对不同的 BSON 类型值时,比较的并不是值的大小,而是值的类型,即按类比较。MongoDB
使用以下比较顺序,顺序从低到高:
- MinKey (internal type)
- Null
- Numbers (ints, longs, doubles, decimals)
- Symbol, String
- Object
- Array
- BinData
- ObjectId
- Boolean
- Date
- Timestamp
- Regular Expression
- MaxKey (internal type)
同类型比较的情况则稍微复杂一些。数字类型比较的是值的大小,例如 5
大于 3
。字符串类型比较的是其值的二进制,例如 R
大于 A
是因为R
的二进制值 0101 0010
大于 A
的二进制值 0100
0001
。数组的小于比较或者升序排序比较的是数组中的最小元素,大于比较或降序排序比较的是数组中的最大元素。我们可以通过一个例子来了解这些知识。准备如下数据:
> db.arrs.insertMany([
… {name: “James”, attr: [5, 6, 7]},
… {name: “Wade”, attr: [1, 7, 8]},
… {name: “Kobe”, attr: [1, 9, 9]},
… {name: “Bosh”, attr: [2, 9, 9]}
… ])
假设要将文档按 name
升序排序,即字符串升序排序。对应示例如下:
> db.arrs.find().sort({name: 1})
{ “_id” : ObjectId(“5d1eb7fef91d329d7e731d4f”), “name” : “Bosh”, “attr” : [ 2, 9, 9 ] }
{ “_id” : ObjectId(“5d1eb7fef91d329d7e731d4c”), “name” : “James”, “attr” : [ 5, 6, 7 ] }
{ “_id” : ObjectId(“5d1eb7fef91d329d7e731d4e”), “name” : “Kobe”, “attr” : [ 1, 9, 9 ] }
{ “_id” : ObjectId(“5d1eb7fef91d329d7e731d4d”), “name” : “Wade”, “attr” : [ 1, 7, 8 ] }
排序结果为 Bosh- James - Kobe - Wade
,那么字符串降序排序的结果一定是 Wade - Kobe - James -
Bosh
。对应示例如下:
> db.arrs.find().sort({name: -1})
{ “_id” : ObjectId(“5d1eb7fef91d329d7e731d4d”), “name” : “Wade”, “attr” : [ 1, 7, 8 ] }
{ “_id” : ObjectId(“5d1eb7fef91d329d7e731d4e”), “name” : “Kobe”, “attr” : [ 1, 9, 9 ] }
{ “_id” : ObjectId(“5d1eb7fef91d329d7e731d4c”), “name” : “James”, “attr” : [ 5, 6, 7 ] }
{ “_id” : ObjectId(“5d1eb7fef91d329d7e731d4f”), “name” : “Bosh”, “attr” : [ 2, 9, 9 ] }
现有:[ 5, 6, 7 ], [ 1, 7, 8 ], [ 1, 9, 9 ], [ 2, 9, 9 ]
4
个数组,上面提到,数组升序排序比较的是最小元素。4 个数组中最小的值分别是 5, 1, 1, 2
,其中数组 [1, 7, 8]
和数组 [1,
9, 9]
的最小值相同,则比较第二小的值,即 7, 9
。那么正确的升序排序结果因该是 [ 1, 7, 8 ], [ 1, 9, 9 ], [ 2,
9, 9 ], [5, 6, 7]
。数组升序排序命令如下:
> db.arrs.find().sort({attr: 1})
{ “_id” : ObjectId(“5d1eb7fef91d329d7e731d4d”), “name” : “Wade”, “attr” : [ 1, 7, 8 ] }
{ “_id” : ObjectId(“5d1eb7fef91d329d7e731d4e”), “name” : “Kobe”, “attr” : [ 1, 9, 9 ] }
{ “_id” : ObjectId(“5d1eb7fef91d329d7e731d4f”), “name” : “Bosh”, “attr” : [ 2, 9, 9 ] }
{ “_id” : ObjectId(“5d1eb7fef91d329d7e731d4c”), “name” : “James”, “attr” : [ 5, 6, 7 ] }
排序结果与分析结果一致。数组降序排序比较的是最大元素。4 个数组中最大的值分别是 7, 8, 9, 9
,其中数组 [1, 9, 9]
和 [2,
9, 9]
的最大值和第二大的值相同,则比较第三大的值,即 1, 2
。那么正确的降序排序结果应该是 [2, 9, 9], [1, 9, 9],
[1, 7, 8], [5, 6, 7]
。数组降序排序命令如下:
> db.els.find().sort({attr: -1})
{ “_id” : ObjectId(“5d1edd28eb81ddef9df74fff”), “name” : “Kobe”, “attr” : [ 1, 9, 9 ] }
{ “_id” : ObjectId(“5d1edd28eb81ddef9df75000”), “name” : “Bosh”, “attr” : [ 2, 9, 9 ] }
{ “_id” : ObjectId(“5d1edd28eb81ddef9df74ffe”), “name” : “Wade”, “attr” : [ 1, 7, 8 ] }
{ “_id” : ObjectId(“5d1edd28eb81ddef9df74ffd”), “name” : “James”, “attr” : [ 5, 6, 7 ] }
排序结果和分析结果并不同,这是为什么呢?难道不是按最大元素比较大小吗?
文档中并没有提到,但我们可以通过例子寻找答案。准备以下数据:
> db.parts.insertMany([
… {name: 1, attr: [9, 9, 0, 5]},
… {name: 2, attr: [9, 9, 0, 1]},
… {name: 3, attr: [9, 0, 9, 0]},
… {name: 4, attr: [9, 8, 7, 6]},
… {name: 5, attr: [5, 2, 3, 6]},
… {name: 6, attr: [9, 0, 0, 0]},
… {name: 7, attr: [30, 0]},
… {name: 8, attr: [22, 0]},
… {name: 9, attr: [30, 5]},
… {name: 10, attr: [30, 3]}
… ])
数组降序排序示例如下:
> db.parts.find().sort({attr: -1})
{ “_id” : ObjectId(“5d1eef27882bacc4ec4cb121”), “name” : 7, “attr” : [ 30, 0 ] }
{ “_id” : ObjectId(“5d1eef27882bacc4ec4cb123”), “name” : 9, “attr” : [ 30, 5 ] }
{ “_id” : ObjectId(“5d1eef27882bacc4ec4cb124”), “name” : 10, “attr” : [ 30, 3 ] }
{ “_id” : ObjectId(“5d1eef27882bacc4ec4cb122”), “name” : 8, “attr” : [ 22, 0 ] }
{ “_id” : ObjectId(“5d1eef27882bacc4ec4cb11b”), “name” : 1, “attr” : [ 9, 9, 0, 5 ] }
{ “_id” : ObjectId(“5d1eef27882bacc4ec4cb11c”), “name” : 2, “attr” : [ 9, 9, 0, 1 ] }
{ “_id” : ObjectId(“5d1eef27882bacc4ec4cb11d”), “name” : 3, “attr” : [ 9, 0, 9, 0 ] }
{ “_id” : ObjectId(“5d1eef27882bacc4ec4cb11e”), “name” : 4, “attr” : [ 9, 8, 7, 6 ] }
{ “_id” : ObjectId(“5d1eef27882bacc4ec4cb120”), “name” : 6, “attr” : [ 9, 0, 0, 0 ] }
{ “_id” : ObjectId(“5d1eef27882bacc4ec4cb11f”), “name” : 5, “attr” : [ 5, 2, 3, 6 ] }
数组升序排序示例如下:
> db.parts.find().sort({attr: 1})
{ “_id” : ObjectId(“5d1eef27882bacc4ec4cb11b”), “name” : 1, “attr” : [ 9, 9, 0, 5 ] }
{ “_id” : ObjectId(“5d1eef27882bacc4ec4cb11c”), “name” : 2, “attr” : [ 9, 9, 0, 1 ] }
{ “_id” : ObjectId(“5d1eef27882bacc4ec4cb11d”), “name” : 3, “attr” : [ 9, 0, 9, 0 ] }
{ “_id” : ObjectId(“5d1eef27882bacc4ec4cb120”), “name” : 6, “attr” : [ 9, 0, 0, 0 ] }
{ “_id” : ObjectId(“5d1eef27882bacc4ec4cb121”), “name” : 7, “attr” : [ 30, 0 ] }
{ “_id” : ObjectId(“5d1eef27882bacc4ec4cb122”), “name” : 8, “attr” : [ 22, 0 ] }
{ “_id” : ObjectId(“5d1eef27882bacc4ec4cb11f”), “name” : 5, “attr” : [ 5, 2, 3, 6 ] }
{ “_id” : ObjectId(“5d1eef27882bacc4ec4cb124”), “name” : 10, “attr” : [ 30, 3 ] }
{ “_id” : ObjectId(“5d1eef27882bacc4ec4cb123”), “name” : 9, “attr” : [ 30, 5 ] }
{ “_id” : ObjectId(“5d1eef27882bacc4ec4cb11e”), “name” : 4, “attr” : [ 9, 8, 7, 6 ] }
根据以上结果,我们可以推测出排序规律: 如果被比较的值相同,那么就按照插入顺序(即 ObjectId)排序 。降序排序比较的是最大元素,即 `30
30 - 30 - 22 -9 - 9 - 9 - 9 - 9 - 6
,其中
30和
9` 均有重复。可以发现:- 第一个
30
对应的name
值为7
,第二个30
对应的name
值为9
,第三个30
对应的name
值为10
; - 第一个
9
对应的name
值为1
,第二个9
对应的name
值为2
,第三个9
对应的name
值为3
,第四个9
对应的name
值为4
,第五个9
对应的name
值为6
;
- 第一个
升序排序比较的是最小元素,即 0 - 0 - 0 - 0 - 0 - 0 - 2 - 3 - 5 - 6
,其中重复的只有 0
。0
对应的name
值依次为 1, 2, 3, 6, 7, 8
。无论是升序排序还是降序排序,实际得到的结果与我们推测出来的规律相同,这说明我们推测出来的规律是正确的。其他类型的比较或排序规则可查阅官方文档
[comparison-sort-order](https://docs.mongodb.com/manual/reference/bson-type-
comparison-order/#comparison-sort-order) 。
MongoDB 提供了一系列用于比较的比较符,它们分别是:
名称 | 描述 |
---|---|
$eq |
|
匹配等于指定值的值 | |
$gt |
|
匹配大于指定值的值 | |
$gte |
|
匹配大于或等于指定值的值 | |
$in |
|
匹配数组中指定的任何值 | |
$lt |
|
匹配小于指定值的值 | |
$lte |
|
匹配小于或等于指定值的值 | |
$ne |
|
匹配所有不等于指定值的值 | |
$nin |
|
不匹配数组中指定的任何值 |
其中,$eq
, $gte
, $lt
, $lte
, $gt
,$ne
的语法是相同的。以 $eq
为例,其语法格式如下:
{
假设要匹配集合 els
中名称为 James
的文档,对应示例如下:
> db.els.find({name: {$eq: “James”}})
{ “_id” : ObjectId(“5d1edd28eb81ddef9df74ffd”), “name” : “James”, “attr” : [ 5, 6, 7 ] }
这等效于 SQL 中的 SELECT * FROM els WHERE name = "James"
。
$in
和 $nin
的语法相同。以 $in
为例,其格式如下:
{ field: { $in: [
假设要过滤出集合 els
中文档字段 attr
中包含 6
或者 9
的文档,对应示例如下:
> db.els.find({attr: {$in: [6, 9]}})
{ “_id” : ObjectId(“5d1edd28eb81ddef9df74ffd”), “name” : “James”, “attr” : [ 5, 6, 7 ] }
{ “_id” : ObjectId(“5d1edd28eb81ddef9df74fff”), “name” : “Kobe”, “attr” : [ 1, 9, 9 ] }
{ “_id” : ObjectId(“5d1edd28eb81ddef9df75000”), “name” : “Bosh”, “attr” : [ 2, 9, 9 ] }
另外,$in
和 $nin
均支持正则表达式。例如要过滤出集合 inven
中 name
字段值以 詹
或者 韦
开头的文档,对应示例如下:
> db.inven.find({name: {$in: [/^詹/, /^韦/]}})
{ “_id” : ObjectId(“5d200b986c39176e3a421af2”), “name” : “詹姆斯”, “number” : 6, “attribute” : { “h” : 203, “w” : 222, “p” : “前锋” }, “status” : “A” }
{ “_id” : ObjectId(“5d200b986c39176e3a421af3”), “name” : “韦德”, “number” : 3, “attribute” : { “h” : 193, “w” : 220, “p” : “得分后卫” }, “status” : “R” }
反过来,过滤出集合 inven
中 name
字段值非 詹
或者非 韦
开头的文档。对应示例如下:
> db.inven.find({name: {$nin: [/^詹/, /^韦/]}})
{ “_id” : ObjectId(“5d200b986c39176e3a421af4”), “name” : “科比”, “number” : 24, “attribute” : { “h” : 198, “w” : 212, “p” : “得分后卫” }, “status” : “R” }
{ “_id” : ObjectId(“5d200b986c39176e3a421af5”), “name” : “姚明”, “number” : 11, “attribute” : { “h” : 226, “w” : 308, “p” : “中锋” }, “status” : “R” }
{ “_id” : ObjectId(“5d200b986c39176e3a421af6”), “name” : “乔丹”, “number” : 23, “attribute” : { “h” : 198, “w” : 216, “p” : “得分后卫” }, “status” : “R” }
以上就是比较查询操作符的相关知识,更多关于比较查询操作符的知识可查阅官方文档 [Comparison Query
Operators](https://docs.mongodb.com/manual/reference/operator/query-
comparison/#comparison-query-operators)。
逻辑查询操作符
MongoDB 中的逻辑查询操作符共有 4 种,它们是:
名称 | 描述 |
---|---|
$and |
|
匹配符合多个条件的文档 | |
$not |
|
匹配不符合条件的文档 | |
$nor |
|
匹配不符合多个条件的文档 | |
$or |
|
匹配符合任一条件的文档 |
其中,$and
, $nor
和 $or
语法格式相同:
{ $keyword: [ {
语法中的 keyword
代表 and/nor/or
。而 $not
语法格式如下
{ field: { $not: {
$and
是隐式的,这意味着我们不必在查询语句中表明 and
或 AND
。 假设要过滤出集合 inven
中 球衣号大于 10
的退役球员
,此时有两个条件:球衣号大于 10
,退役球员
。对应示例如下:
> db.inven.find({status: “R”, number: {$gt: 10}})
{ “_id” : ObjectId(“5d159e794d3d891430a2512f”), “name” : “科比”, “number” : 24, “attribute” : { “h” : 198, “w” : 212, “p” : “得分后卫” }, “status” : “R” }
{ “_id” : ObjectId(“5d159e794d3d891430a25130”), “name” : “姚明”, “number” : 11, “attribute” : { “h” : 226, “w” : 308, “p” : “中锋” }, “status” : “R” }
{ “_id” : ObjectId(“5d159e794d3d891430a25131”), “name” : “乔丹”, “number” : 23, “attribute” : { “h” : 198, “w” : 216, “p” : “得分后卫” }, “status” : “R” }
这等效于 SQL 中的 SELECT * FROM inven WHERE status = "R" AND number >
10
。当然,也可以采用显式写法,对应示例如下:
> db.inven.find({$and: [{status: “R”}, {number: {$gt: 10}}]})
{ “_id” : ObjectId(“5d159e794d3d891430a2512f”), “name” : “科比”, “number” : 24, “attribute” : { “h” : 198, “w” : 212, “p” : “得分后卫” }, “status” : “R” }
{ “_id” : ObjectId(“5d159e794d3d891430a25130”), “name” : “姚明”, “number” : 11, “attribute” : { “h” : 226, “w” : 308, “p” : “中锋” }, “status” : “R” }
{ “_id” : ObjectId(“5d159e794d3d891430a25131”), “name” : “乔丹”, “number” : 23, “attribute” : { “h” : 198, “w” : 216, “p” : “得分后卫” }, “status” : “R” }
$or
, $not
,$nor
均采用显式写法。假设要过滤出集合 inven
中 球衣号大于 10
或者 退役球员
的文档,对应示例如下:
> db.inven.find({$or: [{status: “R”}, {number: {$gt: 10}}]})
{ “_id” : ObjectId(“5d159e794d3d891430a2512e”), “name” : “韦德”, “number” : 3, “attribute” : { “h” : 193, “w” : 220, “p” : “得分后卫” }, “status” : “R” }
{ “_id” : ObjectId(“5d159e794d3d891430a2512f”), “name” : “科比”, “number” : 24, “attribute” : { “h” : 198, “w” : 212, “p” : “得分后卫” }, “status” : “R” }
{ “_id” : ObjectId(“5d159e794d3d891430a25130”), “name” : “姚明”, “number” : 11, “attribute” : { “h” : 226, “w” : 308, “p” : “中锋” }, “status” : “R” }
{ “_id” : ObjectId(“5d159e794d3d891430a25131”), “name” : “乔丹”, “number” : 23, “attribute” : { “h” : 198, “w” : 216, “p” : “得分后卫” }, “status” : “R” }
这等效于 SQL 中的 SELECT * FROM inven WHERE status = "R" OR number > 10
。
假设要过滤出集合 inven
中 number
不等于 11
且 number
不等于 23
的文档,对应示例如下:
> db.inven.find({$nor: [{number: 23}, {number: 11}]})
{ “_id” : ObjectId(“5d200b986c39176e3a421af2”), “name” : “詹姆斯”, “number” : 6, “attribute” : { “h” : 203, “w” : 222, “p” : “前锋” }, “status” : “A” }
{ “_id” : ObjectId(“5d200b986c39176e3a421af3”), “name” : “韦德”, “number” : 3, “attribute” : { “h” : 193, “w” : 220, “p” : “得分后卫” }, “status” : “R” }
{ “_id” : ObjectId(“5d200b986c39176e3a421af4”), “name” : “科比”, “number” : 24, “attribute” : { “h” : 198, “w” : 212, “p” : “得分后卫” }, “status” : “R” }
假设要过滤出集合 inven
中 number
不大于 20
的文档,对应示例如下:
> db.inven.find({number: {$not: {$gt: 20}}})
{ “_id” : ObjectId(“5d200b986c39176e3a421af2”), “name” : “詹姆斯”, “number” : 6, “attribute” : { “h” : 203, “w” : 222, “p” : “前锋” }, “status” : “A” }
{ “_id” : ObjectId(“5d200b986c39176e3a421af3”), “name” : “韦德”, “number” : 3, “attribute” : { “h” : 193, “w” : 220, “p” : “得分后卫” }, “status” : “R” }
{ “_id” : ObjectId(“5d200b986c39176e3a421af5”), “name” : “姚明”, “number” : 11, “attribute” : { “h” : 226, “w” : 308, “p” : “中锋” }, “status” : “R” }
这个例子将比较查询操作符和逻辑查询操作符结合使用,实现了更细致的查询。
更多关于逻辑查询操作符的知识可查阅官方文档 [Logical Query
Operators](https://docs.mongodb.com/manual/reference/operator/query-
logical/#logical-query-operators)。
元素查询操作符
MongoDB 中的元素查询操作符只有 2 种,它们是:
名称 | 描述 |
---|---|
$exists |
|
匹配具有指定字段的文档 | |
$type |
|
匹配字段值符合类型的文档 |
exists
在开始学习 $exists
前,我们需要准备以下数据:
> db.elem.insertMany([
… {title: “湖人今夏交易频繁”, author: “Asyncins”, date: “2019-07-01”, article: “…”},
… {title: “詹姆斯现身 MIA-CHN 比赛现场”, date: “2019-07-06”, article: “…”},
… {title: “伦纳德迟迟不肯表态”, author: “Asyncins”}
… ])
$exists
语法格式如下:
{ field: { $exists:
假设要过滤出集合 elem
中包含 author
字段的文档,对应示例如下:
> db.elem.find({author: {$exists: true}})
{ “_id” : ObjectId(“5d203f1a6c39176e3a421af7”), “title” : “湖人今夏交易频繁”, “author” : “Asyncins”, “date” : “2019-07-01”, “article” : “…” }
{ “_id” : ObjectId(“5d203f1a6c39176e3a421af9”), “title” : “伦纳德迟迟不肯表态”, “author” : “Asyncins” }
反过来,要过滤出集合 elem
中不包含 author
字段的文档,对应示例如下:
> db.elem.find({author: {$exists: false}})
{ “_id” : ObjectId(“5d203f1a6c39176e3a421af8”), “title” : “詹姆斯现身 MIA-CHN 比赛现场”, “date” : “2019-07-06”, “article” : “…” }
type
在开始学习 $type
前,我们需要准备如下数据:
> db.ops.insertMany([
… {title: “北京高温持续,注意避暑”, weight: 5, rec: false},
… {title: “广西持续降雨,最大降雨量 200 ml”, weight: 5, rec: false},
… {title: “高考分数线已出,高分学子增多”, weight: “hot”, rec: true},
… {title: “秋老虎是真是假?”, weight: 3, rec: false}
… ])
$type
语法如下:
{ field: { $type:
它也支持阵列写法:
{ field: { $type: [
假设要过滤出 weight
值类型为 String
的文档,对应示例如下:
> db.ops.find({weight: {$type: “string”}})
{ “_id” : ObjectId(“5d1838eb51b88758035de5b7”), “title” : “高考分数线已出,高分学子增多”, “weight” : “hot”, “rec” : true }
同理,过滤出 weight
值类型为 Number
的文档的对应示例如下:
> db.ops.find({weight: {$type: “number”}})
{ “_id” : ObjectId(“5d1838eb51b88758035de5b5”), “title” : “北京高温持续,注意避暑”, “weight” : 5, “rec” : false }
{ “_id” : ObjectId(“5d1838eb51b88758035de5b6”), “title” : “广西持续降雨,最大降雨量 200 ml”, “weight” : 5, “rec” : false }
{ “_id” : ObjectId(“5d1838eb51b88758035de5b8”), “title” : “秋老虎是真是假?”, “weight” : 3, “rec” : false }
阵列写法中的 BSON 类型为 or
关系,例如要过滤出 weight
值类型为 String
或者 Number
的文档,对应示例如下:
> db.ops.find({weight: {$type: [“string”, “number”]}})
{ “_id” : ObjectId(“5d1838eb51b88758035de5b5”), “title” : “北京高温持续,注意避暑”, “weight” : 5, “rec” : false }
{ “_id” : ObjectId(“5d1838eb51b88758035de5b6”), “title” : “广西持续降雨,最大降雨量 200 ml”, “weight” : 5, “rec” : false }
{ “_id” : ObjectId(“5d1838eb51b88758035de5b7”), “title” : “高考分数线已出,高分学子增多”, “weight” : “hot”, “rec” : true }
{ “_id” : ObjectId(“5d1838eb51b88758035de5b8”), “title” : “秋老虎是真是假?”, “weight” : 3, “rec” : false }
这等效于 SELECT * FROM ops WHERE weight.type = string OR weight.type = number
这样的 SQL 伪代码表示。要注意的是,$type
支持所有 BSON 类型的字符串标识符和整数标识符,例如 String
类型的字符串标识符sting
及其整数标识符 2
,即 {$type: "string"}
等效于 {$type: 2}
。
更多关于元素查询操作符的知识可查阅官方文档 [Element Query
Operators](https://docs.mongodb.com/manual/reference/operator/query-
element/#element-query-operators)。
评估查询操作符
MongoDB 中的评估查询操作符共有 6 种,它们是:
名称 | 描述 |
---|---|
$expr |
|
允许在查询语句中使用聚合表达式 | |
$jsonSchema |
|
根据给定的 JSON 模式验证文档 | |
$mod |
|
对字段的值执行模运算,并选择具有指定结果的文档 | |
$regex |
|
匹配与正则表达式规则相符的文档 | |
$text |
|
执行文本搜索 | |
$where |
|
匹配满足 JavaScript 表达式的文档 |
expr
在学习 $expr
前,我们需要准备以下数据:
> db.acbook.insertMany([
… {_id: 1, category: “衣”, 预算: 300, 开支: 600},
… {_id: 2, category: “食”, 预算: 1000, 开支: 600},
… {_id: 3, category: “住”, 预算: 800, 开支: 800},
… {_id: 4, category: “行”, 预算: 220, 开支: 360},
… {_id: 5, category: “医”, 预算: 200, 开支: -50}
… ])
$expr
语法格式如下:
{ $expr: {
假设要过滤出超出预算的文档,对应示例如下:
> db.acbook.find({$expr: {$gt: [“$开支”, “$预算”]}})
{ “_id” : 1, “category” : “衣”, “预算” : 300, “开支” : 600 }
{ “_id” : 4, “category” : “行”, “预算” : 220, “开支” : 360 }
示例中使用了 $gt
表达式,用于比较 开支
和 预算
。我们也可以使用 $lt
表达式,对应命令如下:
> db.acbook.find({$expr: {$lt: [“$预算”, “$开支”]}})
{ “_id” : 1, “category” : “衣”, “预算” : 300, “开支” : 600 }
{ “_id” : 4, “category” : “行”, “预算” : 220, “开支” : 360 }
$expr
支持的表达式非常多,详见官方文档
[Expressions](https://docs.mongodb.com/manual/meta/aggregation-quick-
reference/#aggregation-expressions)。
mod
$mod
的作用是对字段的值执行模运算,并选择具有指定结果的文档。其语法格式如下:
{ field: { $mod: [ divisor, remainder ] } }
假设要过滤出集合 acbook
中满足 mod(开支, 6) = 0
的文档。对应示例如下:
> db.acbook.find({开支: {$mod: [6, 0]}})
{ “_id” : 1, “category” : “衣”, “预算” : 300, “开支” : 600 }
{ “_id” : 2, “category” : “食”, “预算” : 1000, “开支” : 600 }
{ “_id” : 4, “category” : “行”, “预算” : 220, “开支” : 360 }
即 mod(600, 6) = 0
, mod(360, 6) = 0
。同理,要过滤出集合 acbook
中满足 mod(开支, 6) = 2
的文档,对应示例如下:
> db.acbook.find({开支: {$mod: [6, 2]}})
{ “_id” : 3, “category” : “住”, “预算” : 800, “开支” : 800 }
即 mod(800, 6) = 2
。要注意的是,$mod
只接受 2 个参数:divisor
和 remainder
。如果只传入 1
个参数,例如 db.acbook.find({开支: {$mod: [6]}})
, 就会得到如下错误提示:
Error: error: {
“ok” : 0,
“errmsg” : “malformed mod, not enough elements”,
“code” : 2,
“codeName” : “BadValue”
}
不传入值或传入多个值也是不被允许的,在返回文档中的 errmsg
处会给出对应的提示。例如不传入值对应的提示为 malformed mod, not
enough elements
,而多个值对应的提示为 malformed mod, too many elements
。
提示 :在 2.6
版本中,传入单个值时会默认补上 0
,传入多个值时会忽略多余的值。例如 db.acbook.find({开支:
{$mod: [6]}})
和 db.acbook.find({开支: {$mod: [6, 2, 3, 5]}})
等效于db.acbook.find({开支: {$mod: [6, 0]}})
。但 4.0
版本不允许这样做。
regex
MongoDB 提供的 $regex
让开发者可以在查询语句中使用正则表达式,这实在是令人惊喜。MongoDB 中的正则表达式是 PCRE,即 Perl
语言兼容的正则表达式。$regex
语法格式如下:
{
{
{
三种格式任选其一,特定语法的使用限制可参考 [$regex
vs./pattern/Syntax](https://docs.mongodb.com/manual/reference/operator/query/regex/#regex-
vs-pattern-syntax) 。也可以用下面这种语法:
{
正则表达式中有一些特殊选项(又称模式修正符),例如不区分大小写或允许使用点字符等,MongoDB 中支持的选项如下:
选项 | 描述 | 语法限制 |
---|---|---|
i |
不区分大小写字母。 | |
m |
支持多行匹配。 | |
x |
忽略空格和注释(#),注释以 \n 结尾。 |
必须使用 $option |
s |
允许点(.)字符匹配括换行符在内的所有字符,也可以理解为允许点(.)字符匹配换行符后面的字符。 | 必须使用 $option |
在开始学习之前,准备以下数据:
> db.regexs.insertMany([
… {_id: 1, nickname: “abc123”, desc: “Single Line Description.”},
… {_id: 2, nickname: “abc299”, desc: “First line \nSecond line”},
… {_id: 3, nickname: “xyz5566”, desc: “Many spaces before line”},
… {_id: 4, nickname: “xyz8205”, desc: “Multiple\nline description”}
… ])
假设要过滤出 nickname
值结尾为 299
的文档,对应示例如下:
> db.regexs.find({nickname: {$regex: /299$/}})
{ “_id” : 2, “nickname” : “abc299”, “desc” : “First line \nSecond line” }
这相当于 SQL 中的模糊查询,对应的 SQL 语句为 SELECT * FROM regexs WHERE nickname like
"%299"
。接下来使用模式修正符 i
实现不区分大小写的匹配,对应示例如下:
> db.regexs.find({nickname: {$regex: /^aBc/i}})
{ “_id” : 1, “nickname” : “abc123”, “desc” : “Single Line Description.” }
{ “_id” : 2, “nickname” : “abc299”, “desc” : “First line \nSecond line” }
这个语句的作用是过滤出集合 regexs
中 nickname
字段值由 aBc
开头的文档,并在匹配时忽略大小写字母。接下来我们再通过一个例子了解模式修正符 m
的用法和作用,对应示例如下:
> db.regexs.find({desc: {$regex: /^s/, $options: “im”}})
{ “_id” : 1, “nickname” : “abc123”, “desc” : “Single Line Description.” }
{ “_id” : 2, “nickname” : “abc299”, “desc” : “First line \nSecond line” }
这个语句的作用是过滤出集合 regexs
中 desc
字段值由 s
开头的文档,匹配时忽略大小写字母,并进行多行匹配。虽然 _id
为 2
的文档中的 desc
并不是 s
或 S
开头,但由于使用了模式修正符 m
,所以能够匹配到 \n
符号后面的Second
。如果没有使用模式修正符 m
,那么匹配结果将会是 { "_id" : 1, "nickname" : "abc123", "desc"
: "Single Line Description." }
。
点字符和星号在正则表达式中是最常用的组合,MongoDB 也支持这个组合。假设要过滤出集合 regexs
中 desc
字段值由 m
开头且line
结尾的文档,对应示例如下:
> db.regexs.find({desc: {$regex: /m.*line/, $options: “is”}})
{ “_id” : 3, “nickname” : “xyz5566”, “desc” : “Many spaces before line” }
{ “_id” : 4, “nickname” : “xyz8205”, “desc” : “Multiple\nline description” }
如果不使用模式修正符 s
,.*
组合也是可用的,但无法匹配到换行符后面的内容,那么匹配结果将会是 { "_id" : 3, "nickname"
: "xyz5566", "desc" : "Many spaces before line" }
。
模式修正符 x
的描述为:“忽略空格和注释(#),注释以 \n
结尾”。这理解起来有些困难,但你不用担心,只要跟着本文指引和案例,就能够掌握模式修正符 x
的正确用法。示例如下:
> var pattern = “abc #category code\n123 #item number”
> db.regexs.find({nickname: {$regex: pattern, $options: “x”}})
{ “_id” : 1, “nickname” : “abc123”, “desc” : “Single Line Description.” }
正则规则为 abc #category code\n123 #item number
,根据模式修正符 x
的描述,我们可以将其转换为abc123
。即过滤出集合 regexs
中 nickname
值为 abc123
的文档,所以执行结果为:
{ "_id" : 1, "nickname" : "abc123", "desc" : "Single Line Description." }
再来看一个示例:
> var pattern = “abc #category code\n xyz#item number”
> db.regexs.find({nickname: {$regex: pattern, $options: “x”}})
这个命令并没有得到文档输出,也就是说没有文档符合其规则,这是因为 abc #category code\n xyz#item number
等效于abcxyz
。pattern
中的 #
代表注释,而 \n
表示注释结束。所以 #category code\n
和 #item
number
是没有用的,有用的是 abc
和 xyz
。根据这个规则,我们可以过滤出集合 regexs
中 nickname
值包含abc
的文档。对应命令如下:
> var pattern = “abc # xyz #category code\n # item number 123”
> db.regexs.find({nickname: {$regex: pattern, $options: “x”}})
{ “_id” : 1, “nickname” : “abc123”, “desc” : “Single Line Description.” }
{ “_id” : 2, “nickname” : “abc299”, “desc” : “First line \nSecond line” }
此时 #xyz
,#category code\n
和 # item number 123
均无效,有效的只有abc
,所以能够匹配到两个文档。再来看下面这个示例:
> var pattern = “# abc\n 299 # xyz #category code\n # item number”
> db.regexs.find({nickname: {$regex: pattern, $options: “x”}})
{ “_id” : 2, “nickname” : “abc299”, “desc” : “First line \nSecond line” }
回顾一下模式修正符 x
的描述:“忽略空格和注释(#),注释以 \n
结尾”。此时 # abc\n
, # xyz #category
code\n
和 # item number
均无效,由于注释以 \n
结尾,所以 # abc\n 299
中的 299
有效,才会输出文档:
{ "_id" : 2, "nickname" : "abc299", "desc" : "First line \nSecond line" }
text
开发者可以通过 $text
搜索符合条件的文档,其语法格式如下:
{
$text:
{
$search:
$language:
$caseSensitive:
$diacriticSensitive:
}
}
在 $text
中可以使用 $search
, $language
, $caseSensitive
和$diacriticSensitive
等指令,指令的类型和对应描述如下:
指令 | 类型 | 描述 |
---|---|---|
$search |
string | MongoDB 解析并用于查询文本索引的字符串。查询时采用 OR 逻辑,除非指定字符串为短语。 |
$language |
string | 确定搜索停止词列表以及词干分析其和标记器规则的语言。如果未指定,则搜索时使用默认语言。 |
$caseSensitive |
boolean | 是否启用大小写区分。 |
$diacriticSensitive |
boolean | 是否启用变音符敏感搜索。 |
在开始学习之前,准备以下数据:
> db.news.insertMany([
… { _id: 1, subject: “coffee”, author: “xyz”, views: 50 },
… { _id: 2, subject: “Coffee Shopping”, author: “efg”, views: 5 },
… { _id: 3, subject: “Baking a cake”, author: “abc”, views: 90 },
… { _id: 4, subject: “baking”, author: “xyz”, views: 100 },
… { _id: 5, subject: “Café Con Leche”, author: “abc”, views: 200 },
… { _id: 6, subject: “Сырники”, author: “jkl”, views: 80 },
… { _id: 7, subject: “coffee and cream”, author: “efg”, views: 10 },
… { _id: 8, subject: “Cafe con Leche”, author: “xyz”, views: 10 },
… { _id: 9, subhect: “NBA”, author: “coffee”, views: 300}
… ])
接着为 subject
和 author
建立文本索引。对应命令如下:
> db.news.createIndex({subject: “text”, author: “text”})
{
“createdCollectionAutomatically” : false,
“numIndexesBefore” : 1,
“numIndexesAfter” : 2,
“ok” : 1
}
$search
可以搜索单个词、多个词和短语。假设要过滤出集合 news
中建立了文本索引的字段 subject
和 author
中值包含coffee
的文档,对应示例如下:
> db.news.find({$text: {$search: “coffee”}})
{ “_id” : 9, “subhect” : “NBA”, “author” : “coffee”, “views” : 300 }
{ “_id” : 1, “subject” : “coffee”, “author” : “xyz”, “views” : 50 }
{ “_id” : 7, “subject” : “coffee and cream”, “author” : “efg”, “views” : 10 }
{ “_id” : 2, “subject” : “Coffee Shopping”, “author” : “efg”, “views” : 5 }
多词搜索中,多个词以空格进行分隔,多词搜索示例如下:
> db.news.find({$text: {$search: “cream Shopping”}})
{ “_id” : 7, “subject” : “coffee and cream”, “author” : “efg”, “views” : 10 }
{ “_id” : 2, “subject” : “Coffee Shopping”, “author” : “efg”, “views” : 5 }
上面提到,多次搜索时采用 OR
逻辑。在此示例中,搜索语句释义为:过滤出集合 news
中建立了文本索引的字段 subject
和author
中值包含 coffee
或 Shopping
的文档。按短语搜索的语法与按词搜索不同,其示例如下:
> db.news.find({$text: {$search: “"coffee and"“}})
{ “_id” : 7, “subject” : “coffee and cream”, “author” : “efg”, “views” : 10 }
搜索语句释义为:过滤出集合 news
中建立了文本索引的字段 subject
和 author
中值包含 coffee
and
的文档。其中,以 \"keyword"\
格式将搜索词 keyword
设置为短语,示例中的短语表示为 \"coffee and\"
。
$search
中用前缀减号(-)作为否定词,这样我们就能够实现类似于 NOT
的过滤规则。假设要过滤出集合 news
中建立了文本索引的字段subject
和 author
中值包含 coffee
但不包含 Shopping
的文档,对应示例如下:
> db.news.find({$text: {$search: “coffee -Shopping”}})
{ “_id” : 9, “subhect” : “NBA”, “author” : “coffee”, “views” : 300 }
{ “_id” : 1, “subject” : “coffee”, “author” : “xyz”, “views” : 50 }
{ “_id” : 7, “subject” : “coffee and cream”, “author” : “efg”, “views” : 10 }
$language
可以指定语言。假要过滤出集合 news
中建立了文本索引的字段 subject
和 author
中值包含leche
且内容为默认语言或 es
(西班牙语)的文档,对应示例如下:
> db.news.find({ $text: {$search: “leche”, $language: “es”}})
{ “_id” : 8, “subject” : “Cafe con Leche”, “author” : “xyz”, “views” : 10 }
{ “_id” : 5, “subject” : “Café Con Leche”, “author” : “abc”, “views” : 200 }
$text
支持的语言如下:
语言名称 | 简写(ISO 639-1 规则) |
---|---|
danish |
da |
dutch |
nl |
english |
en |
finnish |
fi |
french |
fr |
german |
de |
hungarian |
hu |
italian |
it |
norwegian |
nb |
portuguese |
pt |
romanian |
ro |
russian |
ru |
spanish |
es |
swedish |
sv |
turkish |
tr |
上面示例中使用的是简写,实际上 $text
也支持全称。例如 es
等效于spanish
。变音符号和不敏感搜索并不常用,对此感兴趣的读者可以查阅官方文档 [Case and Diacritic Insensitive
Search](https://docs.mongodb.com/manual/reference/operator/query/text/#case-
and-diacritic-insensitive-search)。
$regex
中是否区分大小写使用的是模式修正符 i
,这里使用的是 $caseSensitive
指令。默认情况下,是不区分大小写的,即$caseSensitive: true
。如果需要开启大小写区分,则将其设为 false
即可。假设要过滤出集合 news
中建立了文本索引的字段 subject
和 author
中值包含 Baking
的文档,对应示例如下:
> db.news.find({$text: {$search: “Baking”, $caseSensitive: true}})
{ “_id” : 3, “subject” : “Baking a cake”, “author” : “abc”, “views” : 90 }
反之,如果要过滤出集合 news
中建立了文本索引的字段 subject
和 author
中值包含 Baking
或 baking
的文档。对应命令如下:
> db.news.find({$text: {$search: “Baking”, $caseSensitive: false}})
{ “_id” : 4, “subject” : “baking”, “author” : “xyz”, “views” : 100 }
{ “_id” : 3, “subject” : “Baking a cake”, “author” : “abc”, “views” : 90 }
单个词,多词,短语搜索和否定词搜索中,$caseSensitive
的用法一致。要注意的是,区分大小写会影响查询速度。
where
使用 $where
可以将包含 JavaScript 表达式的字符串或函数传递给查询系统。显然,$where
提供了更高的灵活性,但它会将
JavaScript 表达式或函数应用在集合中的每个文档上,因此它对查询速度有一定影响。$where
支持的函数和可用属性如下:
可用属性 | 支持的函数 | 支持的函数 | 支持的函数 |
---|---|---|---|
args , MaxKey , MinKey |
assert() , BinData() , DBPointer() , |
||
DBRef() , doassert() ,emit() , gc() , HexData() , hex_md5() , |
|||
isNumber() , isObject() , ISODate() , isString() |
Map() , MD5() , |
||
NumberInt() , NumberLong() , ObjectId() , print() , printjson() , |
|||
printjsononeline() |
sleep() , Timestamp() , tojson() , |
||
tojsononeline() , tojsonObject() , UUID() , version() |
准备以下数据:
> db.players.insertMany([
… {_id: 12378, name: “Steve”, username: “steveisawesome”, first_login: “2017-01-01”},
… {_id: 2, name: “Anya”, username: “anya”, first_login: “2001-02-02”}
… ])
假设要过滤出集合 players
中 name
的 MD5 值为 9b53e667f30cd329dca1ec9e6a83e994
的文档,对应示例如下:
> db.players.find( { $where: function() {
… return (hex_md5(this.name) == “9b53e667f30cd329dca1ec9e6a83e994”);
… }})
{ “_id” : 2, “name” : “Anya”, “username” : “anya”, “first_login” : “2001-02-02” }
由于 Anya
的 MD5 值为 9b53e667f30cd329dca1ec9e6a83e994
,所以匹配到 _id
为 2
的文档。要注意的是,$where
中不要使用全局变量,且 $where
无法使用索引。
更多关于评估查询操作符的知识可查阅官方文档 [Evaluation Query
Operators](https://docs.mongodb.com/manual/reference/operator/query-
evaluation/#evaluation-query-operators)。
数组查询操作符
MongoDB 中的数组查询操作符共有 3 个,它们是:
名称 | 描述 |
---|---|
$all |
|
匹配包含查询中指定条件的所有元素的数组。 | |
$elemMatch |
|
匹配数组字段中至少有 1 个元素与指定条件相符的文档。 | |
$size |
|
匹配数组元素数符合指定大小的文档。 |
all
$all
语法格式如下:
{
它与 $and
操作相当,即 {tags:{$all: ["ssl" , "security"]}}
等效于 {$and: [{ tags:
"ssl"}, {tags: "security"}]}
, 但二者写法不同。
准备以下数据:
> db.inventory.insertMany([
… {
… _id: 1,
… code: “xyz”,
… tags: [ “school”, “book”, “bag”, “headphone”, “appliance” ],
… qty: [
… { size: “S”, num: 10, color: “blue” },
… { size: “M”, num: 45, color: “blue” },
… { size: “L”, num: 100, color: “green” }
… ]
… },
… {
… _id: 2,
… code: “abc”,
… tags: [ “appliance”, “school”, “book” ],
… qty: [
… { size: “6”, num: 100, color: “green” },
… { size: “6”, num: 50, color: “blue” },
… { size: “8”, num: 100, color: “brown” }
… ]
… },
… {
… _id: 3,
… code: “efg”,
… tags: [ “school”, “book” ],
… qty: [
… { size: “S”, num: 10, color: “blue” },
… { size: “M”, num: 100, color: “blue” },
… { size: “L”, num: 100, color: “green” }
… ]
… },
… {
… _id: 4,
… code: “ijk”,
… tags: [ “electronics”, “school” ],
… qty: [
… { size: “M”, num: 100, color: “green” }
… ]
… }
… ])
假设要过滤出集合 inventory
中 tag
字段值(数组)元素包含 appliance
, school
和 book
的文档,对应示例如下:
> db.inventory.find({tags: {$all: [“appliance”, “school”, “book”]}})
{
_id: 1,
code: “xyz”,
tags: [ “school”, “book”, “bag”, “headphone”, “appliance” ],
qty: [
{ size: “S”, num: 10, color: “blue” },
{ size: “M”, num: 45, color: “blue” },
{ size: “L”, num: 100, color: “green” }
]
}
{
_id: 2,
code: “abc”,
tags: [ “appliance”, “school”, “book” ],
qty: [
{ size: “6”, num: 100, color: “green” },
{ size: “6”, num: 50, color: “blue” },
{ size: “8”, num: 100, color: “brown” }
]
}
$all
的存在是为了支持数组查询,但也可以作用于非数组。示例如下:
> db.inventory.find({“qty.num”: {$all: [50]}})
它等效于:
> db.inventory.find({“qty.num”: 50})
elemMatch
$elemMatch
操作符将匹配数组中至少有 1 个元素满足查询条件的文档,其语法格式如下:
{
准备以下数据:
> db.scores.insertMany([
… {_id: 1, res: [70, 85, 88]},
… {_id: 2, res: [60, 78, 90]}
… ])
假设要过滤出集合 scores
中 res
数组元素满足 大于 80
且 小于 86
的文档,对应示例如下:
> db.scores.find({res: {$elemMatch: {$gt: 80, $lt: 86}}})
{ “_id” : 1, “res” : [ 70, 85, 88 ] }
将条件改为 小于 70
,对应示例如下:
> db.scores.find({res: {$elemMatch: {$lt: 70}}})
{ “_id” : 2, “res” : [ 60, 78, 90 ] }
当 <query>
只有 1 个时,可以省略 $elemMatch
。即 {res: {$elemMatch: {$lt: 70}}
等效于{res: {$lt: 70}}
。$elemMatch
可以用于嵌入式文档,例如我们需要过滤出集合 inventory
中 qty
数组元素num = 50
且 color = "blue"
的文档,对应示例如下:
> db.inventory.find({qty: {$elemMatch: {num: 50, color: “blue”}}})
{
_id: 2,
code: “abc”,
tags: [ “appliance”, “school”, “book” ],
qty: [
{ size: “6”, num: 100, color: “green” },
{ size: “6”, num: 50, color: “blue” },
{ size: “8”, num: 100, color: “brown” }
]
}
$all
和 $elemMatch
联合使用的示例如下:
> db.inventory.find({qty: {$all: [{“$elemMatch”: {size: “M”, num: {$gt: 50}}},
… {“$elemMatch”: {num: 100, color: “green”}}
… ]}})
{
“_id” : 3,
“code” : “efg”,
“tags” : [ “school”, “book”],
“qty” : [
{ “size” : “S”, “num” : 10, “color” : “blue” },
{ “size” : “M”, “num” : 100, “color” : “blue” },
{ “size” : “L”, “num” : 100, “color” : “green” }
]
}
{
“_id” : 4,
“code” : “ijk”,
“tags” : [ “electronics”, “school” ],
“qty” : [
{ “size” : “M”, “num” : 100, “color” : “green” }
]
}
以上示例的目的是过滤出集合 inventory
中 qty
数组中满足 $elemMatch
条件的文档。要注意的是,$where
和$text
中不可使用 $elemMatch
。
size
$size
用于匹配数组元素数符合指定大小的文档。在开始学习之前,我们需要准备以下数据:
> db.mari.insertMany([
… {_id: 1, tag: [“大数据”, “数据分析”, “数据挖掘”]},
… {_id: 2, tag: [“python”, “java”, “rust”]},
… {_id: 3, tag: [“静态语言”, “编译”]},
… {_id: 4, tag: [“内存安全”]}
… ])
集合 mari
中共有 4 个文档,假设需要过滤出数组 tag
元素数量为 2 的文档,对应示例如下:
> db.mari.find({tag: {$size: 2}})
{ “_id” : 3, “tag” : [ “静态语言”, “编译” ] }
同理,过滤出数组 tag
元素数量为 3 的文档的示例如下:
> db.mari.find({tag: {$size: 3}})
{ “_id” : 1, “tag” : [ “大数据”, “数据分析”, “数据挖掘” ] }
{ “_id” : 2, “tag” : [ “python”, “java”, “rust” ] }
要注意的是,$size
和 size()
的作用不同,切勿混淆。
按位查询操作符
MongoDB 中的按位查询操作符共有 4 个,它们是:
名称 | 描述 |
---|---|
$bitsAllClear |
|
匹配 <field> 的二进制值中指定位置值均为 0 的文档。 |
|
$bitsAllSet |
|
匹配 <field> 的二进制值中指定位置值均为 1 的文档。 |
|
$bitsAnyClear |
|
匹配 <field> 的二进制值中任一指定位置值为 0 的文档。 |
|
$bitsAnySet |
|
匹配 <field> 的二进制值中任一指定位置值为 1 的文档。 |
按位查询操作符用于匹配二进制数值。数字 254
对应的二进制为 11111110
,每个二进制值与位置对应关系如下:
二进制值 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 0 |
---|---|---|---|---|---|---|---|---|
位置 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
4 个按位查询操作符的操作均基于该对应关系。
$bitsAllClear
匹配 <field>
的二进制值中指定位置值均为 0
的文档,其语法格式如下:
{
{
{
$bitsAllClear
支持 3 种不同的位置表示,它们分别是 Bit Position Array
, Integer Bitmask
和BinData Bitmast
。要注意的是,该 <field>
的值必须是数值或者一个 BinData
对象,否则$bitsAllClear
无法匹配到文档。在开始学习之前,我们需要准备以下数据:
> db.bits.insertMant([
… {“_id”: 1, “a”: 54, “binaryOfA”: “00110110”},
… {“_id”: 2, “a”: 20, “binaryOfA”: “00010100”},
… {“_id”: 3, “a”: 20, “binaryOfA”: “00010100”},
… {“_id”: 4, “a”: BinData(0,”Zg==”), “binaryOfA”: “01100110”}
… ])
假设要过滤出位置 1 和位置 5 上二进制值为 0
的文档,对应示例如下:
> db.bits.find({a: {$bitsAllClear: [1, 5]}})
{ “_id”: 2, “a”: 20, “binaryOfA”: “00010100” }
{ “_id”: 3, “a”: 20, “binaryOfA”: “00010100” }
其中 a
的值为 20
,对应的二进制为 00010100
,所以得到的结果是 _id
为 2
和 3
的两个文档。要注意的是,查询的<field>
是 a
,而不是 binaryOfA
。binaryOfA
只是便于我们在学习时查看二进制值而已,你可以将其值替换成任何内容,都不会影响查询结果。
除了用 [1, 5]
(即 Bit Position Array
)表示位置外,还可以用数字(即 Integer Bitmask
)来表示。数字35
的二进制为 00100011
,其位置关系如下:
二进制值 | 0 | 0 | 1 | 0 | 0 | 0 | 1 | 1 |
---|---|---|---|---|---|---|---|---|
位置 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
二进制值为 1
的位置分别是 0
、 1
和 5
,这等效于 Bit Position Array
中的 [0, 1, 5]
。即{a: {$bitsAllClear: [0, 1, 5]}}
等效于 {a: {$bitsAllClear: 35}}
。
BinData Bitmask
写法即用 BinData
对象表示位置,例如 BinData(0, ID==)
的二进制为00010100
,这等效于 Bit Position Array
中的 [2, 4]
。BinData 示例如下:
> db.bits.find({a: {$bitsAllClear: BinData(0, “ID==”)}})
由于没有文档能够满足该条件,所以查询结果为空。
考虑到按位查询操作符在实际应用中用的较少,且 4 个按位查询操作符语法和用法差异并不大,所以本小节只介绍 $bitsAllClear
。另外 3
种按位查询操作符的语法和用法可查阅官方文档 [Bitwise Query
Operators](https://docs.mongodb.com/manual/reference/operator/query-
bitwise/#bitwise-query-operators)
投影操作
在之前的介绍中,很多查询操作都类似于 SELECT * FROM
。在有些需要返回指定 <field>
的场景,例如 SELECT name
FROM
时怎么办呢?MongoDB 的查询语句非常友好,以集合 inven
中球员为 韦德
的文档为例:
{
“_id” : ObjectId(“5d159e794d3d891430a2512e”),
“name” : “韦德”,
“number” : 3,
“attribute” : {
“h” : 193,
“w” : 220,
“p” : “得分后卫”
},
“status” : “R”
}
假设想要执行与 SELECT name, number FROM inven WHERE number = 3
等效的操作,对应示例如下:
> db.inven.find({number: 3}, {name: 1, number: 1})
{
“_id” : ObjectId(“5d159e794d3d891430a2512e”),
“name” : “韦德”,
“number” : 3
}
通过在查询语句中加入 {name: 1, number: 1}
实现 <field>
的过滤,此示例中仅取球员名称和球衣号。如果要取反,那么只需要将1
改为 0
即可,例如:
> db.inven.find({number: 3}, {number: 0, _id: 0})
{
“name” : “韦德”,
“attribute” : { “h” : 193, “w” : 220, “p” : “得分后卫” },
“status” : “R”
}
语句允许同时存在 1
和 0
,但它有一定的条件。当我们使用 db.inven.find({number: 3}, {number: 1,
name: 0})
这样的命令时,会得到如下错误提示:
Error: error: {
“ok” : 0,
“errmsg” : “Projection cannot have a mix of inclusion and exclusion.”,
“code” : 2,
“codeName” : “BadValue”
}
但如果设为 0
的 <field>
是 _id
,那就不会出现错误提示。对应示例如下:
> db.inven.find({number: 3}, {number: 1, _id: 0, name: 1})
{ “name” : “韦德”, “number” : 3 }
MongoDB 提供了 4 种投影操作符,它们是:
名称 | 描述 |
---|---|
$ |
|
投影数组中与指定条件匹配的第一个元素。 | |
$elemMatch |
|
投影数组中与 $elemMatch 指定条件匹配的第一个元素。 |
|
$meta |
|
投影在$text 操作期间分配的文档分数。 |
|
$slice |
|
限制从数组投射的元素数量。支持跳过和限制切片。 |
$
$
的作用是投影数组中符合指定条件的第一个元素。在开始学习之前,我们需要准备以下数据:
> db.students.insertMany([
… {“_id”: 1, “semester”: 1, “grades”: [70, 87, 90]},
… {“_id”: 2, “semester”: 1, “grades”: [90, 88, 92]},
… {“_id”: 3, “semester”: 1, “grades”: [85, 100, 91]},
… {“_id”: 4, “semester”: 2, “grades”: [79, 85, 80]},
… {“_id”: 5, “semester”: 2, “grades”: [88, 88, 92]},
… {“_id”: 6, “semester”: 2, “grades”: [95, 90, 96]}
… ])
假设要过滤出 grades
中有元素大于等于 95
的 文档,并返回数组 grades
中满足条件的第一个元素。对应示例如下:
> db.students.find({grades: {$gte: 95}}, {“grades.$”: 1})
{ “_id” : 3, “grades” : [ 100 ] }
{ “_id” : 6, “grades” : [ 95 ] }
elemMatch
$elemMatch
的作用是投影数组中与 $elemMatch
指定条件匹配的第一个元素。在开始学习之前,我们需要准备以下数据:
> db.school.insertMany([
… {
… _id: 1,
… zipcode: “63109”,
… students: [
… {name: “john”, school: 102, age: 10 },
… {name: “jess”, school: 102, age: 11 },
… {name: “jeff”, school: 108, age: 15 }
… ]
… },
… {
… _id: 2,
… zipcode: “63110”,
… students: [
… {name: “ajax”, school: 100, age: 7 },
… {name: “achilles”, school: 100, age: 8 },
… ]
… },
… {
… _id: 3,
… zipcode: “63109”,
… students: [
… {name: “ajax”, school: 100, age: 7 },
… {name: “achilles”, school: 100, age: 8 },
… ]
… }
… ])
假设要过滤出集合 school
中 zipcode
为 "63109"
,且数组 students
的字段 school
为 102
的文档。对应示例如下:
> db.school.find({zipcode: “63109”}, {students: {$elemMatch: {school: 102 }}})
{ “_id” : 1, “students” : [ { “name” : “john”, “school” : 102, “age” : 10 } ] }
{ “_id” : 3 }
- 由于
_id
为1
的文档中数组students
的字段school
值为102
。所以$elemMatch
操作返回了数组中的第一个匹配元素{ "name" : "john", "school" : 102, "age" : 10 }
。 - 由于
_id
为1
的文档中数组students
的字段school
值不等于102
。所以只返回了{_id: 3}
。
如果省略 {zipcode: "63109"}
,那么返回结果就会变成下面这样:
{ “_id” : 1, “zipcode” : “63109”, “students” : [
{ “name” : “john”, “school” : 102, “age” : 10 },
{ “name” : “jess”, “school” : 102, “age” : 11 },
{ “name” : “jeff”, “school” : 108, “age” : 15 }
]}
slice
$slice
作用于数组,它的作用是限制从数组投影的元素数量。其语法格式如下:
db.collection.find( { field: value }, { array: {$slice: count } } );
$slice
接受多种格式的参数,包括整负值和数组,并且它支持 $skpi
和 $limit
。负数作为参数的查询命令如下:
db.collection.find({}, {array: {$slice: -5}})
参数为正代表数组从头到尾的顺序,参数为负代表从尾到头。将数组作为参数的查询命令如下:
db.collection.find({}, {array: {$slice: [3, 2]}})
这个命令的目的是跳过该数组的前 3
个元素,返回 2
个元素,这个操作相当于 [skip, limit]
。
Cursor 对象
Cursor
对象不是查询结果,而是查询返回的 接口
。当我们调用 find()/findMany()/findOne()
时,Shell
并不是立即从数据库中取出数据,而是在我们使用时才会取出数据。Cursor
对象有很多方法,例如close()
,hasNext()
,next()
, isClose()
等。完整方法及对应描述如下表所示:
名称 | 描述 |
---|---|
cursor.addOption() |
|
添加特殊的线程协议标志,用于修改查询的行为。 | |
cursor.batchSize() |
|
控制 MongoDB 在单个网络消息中返回客户端的文档数。 | |
cursor.close() |
|
关闭游标并释放相关的服务器资源。 | |
cursor.isClosed() |
|
true 如果光标关闭则返回。 |
|
cursor.collation() |
|
指定由返回的游标的排序规则。 | |
cursor.comment() |
|
在查询中附加注释,以便在日志和 system.profile 集合中实现可跟踪性。 |
|
cursor.count() |
|
返回结果集中的文档数。 | |
cursor.explain() |
|
报告游标的查询执行计划。 | |
cursor.forEach() |
|
为游标中的每个文档应用 JavaScript 函数。 | |
cursor.hasNext() |
|
如果游标包含文档并且可以迭代,则返回 true 。 |
|
cursor.hint() |
|
强制 MongoDB 为查询使用特定索引。 | |
cursor.isExhausted() |
|
检查游标是否处于关闭状态,为 true 代表关闭。 |
|
cursor.itcount() |
|
通过获取和迭代结果集来计算游标客户端中的文档总数。 | |
cursor.limit() |
|
约束游标结果集的大小。 | |
cursor.map() |
|
将函数应用于游标中的每个文档,并收集数组中的返回值。 | |
cursor.max() |
|
指定游标的独占上限索引。 | |
cursor.maxScan() |
|
指定要扫描的最大项目数; 收集扫描的文档,索引扫描的键。已过时 | |
cursor.maxTimeMS() |
|
指定处理游标操作的累积时间限制(以毫秒为单位)。 | |
cursor.min() |
|
指定游标的包含性较低索引范围。用于 | |
cursor.hint() |
|
cursor.next() |
|
返回游标中的下一个文档。 | |
cursor.noCursorTimeout() |
|
指示服务器在一段时间不活动后自动关闭光标。 | |
cursor.objsLeftInBatch() |
|
返回当前游标批处理中剩余的文档数。 | |
cursor.pretty() |
|
配置光标以易于阅读的格式显示结果。 | |
cursor.readConcern() |
|
指定[读取关注](https://docs.mongodb.com/manual/reference/glossary/#term-read- | |
concern)的find() 操作。 |
|
cursor.readPref() |
|
指定对游标的[读取首选项](https://docs.mongodb.com/manual/reference/glossary/#term-read- | |
preference),以控制客户端如何将查询定向到[复制集](https://docs.mongodb.com/manual/reference/glossary/#term- | |
replica-set)。 | |
cursor.returnKey() |
|
修改游标以返回索引键而不是文档。 | |
cursor.showRecordId() |
|
向光标返回的每个文档添加内部存储引擎ID字段。 | |
cursor.size() |
|
返回应用 | |
skip() |
|
和 | |
limit() |
|
方法后的游标中的文档计数。 | |
cursor.skip() |
|
返回仅在传递或跳过多个文档后才开始返回结果的游标。 | |
cursor.sort() |
|
返回根据排序规范排序的结果。 | |
cursor.tailable() |
|
将光标标记为 tailable,仅适用于超过上限集合的游标。 | |
cursor.toArray() |
|
返回一个数组,其中包含游标返回的所有文档。 |
接下来我们将通过几个需求场景学习常用的方法。
Limit
查询时可以使用 limit()
方法指定 Cursor
返回的文档数量,这能够有效地提高查询性能。limit()
语法如下:
db.collection.find(
假设当前集合 nba
的中的文档如下:
{ “_id” : ObjectId(“5d16d27c72f59731a7527e28”), “name” : “詹姆斯”, “number” : 6, “attribute” : { “h” : 203, “w” : 222, “p” : “前锋” }, “status” : “A” }
{ “_id” : ObjectId(“5d16d27c72f59731a7527e29”), “name” : “韦德”, “number” : 3, “attribute” : { “h” : 193, “w” : 220, “p” : “得分后卫” }, “status” : “R” }
{ “_id” : ObjectId(“5d16d27c72f59731a7527e2a”), “name” : “科比”, “number” : 24, “attribute” : { “h” : 198, “w” : 212, “p” : “得分后卫” }, “status” : “R” }
{ “_id” : ObjectId(“5d16d27c72f59731a7527e2b”), “name” : “姚明”, “number” : 11, “attribute” : { “h” : 226, “w” : 308, “p” : “中锋” }, “status” : “R” }
{ “_id” : ObjectId(“5d16d27c72f59731a7527e2c”), “name” : “乔丹”, “number” : 23, “attribute” : { “h” : 198, “w” : 216, “p” : “得分后卫” }, “status” : “R” }
假设要过滤出 3 名球员信息,对应示例如下:
> db.nba.find().limit(3)
{ “_id” : ObjectId(“5d16d27c72f59731a7527e28”), “name” : “詹姆斯”, “number” : 6, “attribute” : { “h” : 203, “w” : 222, “p” : “前锋” }, “status” : “A” }
{ “_id” : ObjectId(“5d16d27c72f59731a7527e29”), “name” : “韦德”, “number” : 3, “attribute” : { “h” : 193, “w” : 220, “p” : “得分后卫” }, “status” : “R” }
{ “_id” : ObjectId(“5d16d27c72f59731a7527e2a”), “name” : “科比”, “number” : 24, “attribute” : { “h” : 198, “w” : 212, “p” : “得分后卫” }, “status” : “R” }
limit()
可以接受小于 2^31 的正整数和大于 -2^31 的负整数,数字 0 也是有效的。limit()
中的数字取绝对值,也就是说limit(3)
和 limit(-3)
得到的结果是相同的。limit(0)
等效于 not limit
,即未使用 limit()
。
迭代 Cursor
在开始学习之前,我们需要准备一些数据,数据生成的命令如下:
> var i = 1;
> while(i<1000){
… db.fasp.insert({_id: i, name: “James-“ + i})
… i++;
… }
命令执行后,集合 fasp
中有 999 个文档。当我们执行 db.fasp.find()
命令时,会得到如下结果:
> db.fasp.find()
{ “_id” : 1, “name” : “James-1” }
{ “_id” : 2, “name” : “James-2” }
{ “_id” : 3, “name” : “James-3” }
…
{ “_id” : 19, “name” : “James-19” }
{ “_id” : 20, “name” : “James-20” }
Type “it” for more
它只显示 20 个文档,并提示我们输入 it
可以查看更多文档。此时输入 it
,将会得到 _id
范围为 21~40
的文档,即每次只得到
20 个文档。
MongoShell 默认只返回 20 个文档,就算使用 limit()
方法也不行。如果想获取更多文档可以使用 next()
方法或者forEach()
。对应命令如下:
> var Cursors = db.fasp.find();
> while (Cursors.hasNext()){
… print(tojson(Cursors.next()));
… }
{ “_id” : 1, “name” : “James-1” }
{ “_id” : 2, “name” : “James-2” }
{ “_id” : 3, “name” : “James-3” }
…
{ “_id” : 998, “name” : “James-998” }
{ “_id” : 999, “name” : “James-999” }
其中,hasNext()
方法用于检查是否还存在下一个文档,next()
的作用是取出下一个文档。forEach()
的语法如下:
db.collection.find().forEach(
使用 forEach()
遍历 Cursor
对象的示例如下:
> var Cursors = db.fasp.find();
> Cursors.forEach(printjson);
{ “_id” : 1, “name” : “James-1” }
{ “_id” : 2, “name” : “James-2” }
{ “_id” : 3, “name” : “James-3” }
…
{ “_id” : 998, “name” : “James-998” }
{ “_id” : 999, “name” : “James-999” }
也就是说,forEach()
中的 <function>
会作用于每一个文档,例如在遍历的过程中打印 name
字段的值:
> var Cursors = db.fasp.find();
> Cursors.forEach(function(n){print(n.name)})
James-1
James-2
James-3
…
James-999
除此之外,我们还可以使用 toArray()
方法迭代 Cursor
对象。toArray()
方法会将文档装载到数组中,接着我们就可以使用数组下标访问文档了,对应示例如下:
> var Cursors = db.fasp.find();
> Cursors.toArray()[5];
{ “_id” : 6, “name” : “James-6” }
统计
有时候我们只想获取集合中文档的数量,而不是获取集合中的文档。这时候可以使用 size()
方法和 count()
方法,对应示例如下:
> db.fasp.find().size()
999
> db.fasp.find().count()
999
既然它们的都能获取文档数量,那为什么还需要两个方法呢?
实际上 size()
会受到 skip()
或 limit()
的影响,举个例子:
> db.fasp.find().limit(800).count()
999
> db.fasp.find().limit(800).size()
800
size()
返回的是使用过 skip()
或 limit()
之后的文档数量,而 count()
返回的始终是集合中文档的数量。
Map
在学习迭代 Cursor 的时候提到了 forEach()
,map()
的作用与之相似。map()
示例如下:
> db.fasp.find().map( function(n) { return n.name} )
[
“James-1”,
“James-2”,
“James-3”,
…,
“James-999”,
]
与 forEach()
不同的是,map()
会将函数返回值装载到数组中。如果 map()
中的 <function>
没有return
,那么数组中得到的将是 undefined
。
Skip
SQL 中使用 offset
控制数据返回的起始位置,常见的场景是分页。MongoDB 中具有相同功能的方法是 skip()
,示例如下:
> db.fasp.find().skip(990)
{ “_id” : 991, “name” : “James-991” }
{ “_id” : 992, “name” : “James-992” }
{ “_id” : 993, “name” : “James-993” }
{ “_id” : 994, “name” : “James-994” }
{ “_id” : 995, “name” : “James-995” }
{ “_id” : 996, “name” : “James-996” }
{ “_id” : 997, “name” : “James-997” }
{ “_id” : 998, “name” : “James-998” }
{ “_id” : 999, “name” : “James-999” }
skip(990)
代表数据返回的起始位置为 990。skip()
可以跟 limit()
共同使用,假设要从 990 开始,提取 3
个文档,对应示例如下:
> db.fasp.find().skip(990).limit(3)
{ “_id” : 991, “name” : “James-991” }
{ “_id” : 992, “name” : “James-992” }
{ “_id” : 993, “name” : “James-993” }
在命令中,skip()
和 limit()
的顺序并不会影响结果,limit(3).skip(990)
得到的结果与skip(990).limit(3)
相同。
排序
排序是最常见也最重要的操作。MongoDB 提供了 sort()
用于排序,其语法格式如下:
{ field: value }
sort()
的升序降序用数字 1
和 -1
表示。假设要根据 _id
进行降序排序,对应示例如下:
> db.fasp.find().sort({_id: -1})
{ “_id” : 999, “name” : “James-999” }
…
{ “_id” : 980, “name” : “James-980” }
Type “it” for more
sort()
支持多条件排序,例如 sort(_id: 1, name:
-1)
。排序必定有比较,例如数字大小对应正序降序。那其他类型如何排序呢?MongoDB 在比较不同的 BOSN 类型时,将使用以下比较顺序,顺序从低到高:
- MinKey (internal type)
- Null
- Numbers (ints, longs, doubles, decimals)
- Symbol, String
- Object
- Array
- BinData
- ObjectId
- Boolean
- Date
- Timestamp
- Regular Expression
- MaxKey (internal type)
Update Operations
MongoDB 提供了几个方法用于更新文档,它们分别是:
db.collection.updateOne(
db.collection.updateMany(
db.collection.replaceOne(
更新单个文档
updateOne()
方法会根据过滤器更新集合中的单个文档,其语法格式如下:
db.collection.updateOne(
{
upsert:
writeConcern:
collation:
arrayFilters: [
}
)
例如将 inven
集合中名为 韦德
的球员名称改为 热火韦德
,对应示例如下:
> db.inven.updateOne(
… {name: “韦德”},
… {$set: {name: “热火韦德”}}
… )
{ “acknowledged” : true, “matchedCount” : 1, “modifiedCount” : 1 }
返回的结果文档包含操作状态 acknowledged
,匹配的文档数 matchedCount
和修改过的文档数modifiedCount
。本次返回结果文档代表修改成功,韦德
的名字被改变了。
Upsert
在实际应用中,upsert
指令非常常见。当 upsert
的值为 true
时,如果 <filter>
并匹配到文档,那么本次操作就会将它当作新文档添加到集合中。例如当文档中没有名为 ABC
的球员时,会将 奥尼尔
添加到集合中,对应示例如下:
> db.inven.updateOne( {name: “ABC”}, {$set: {name: “奥尼尔”}}, {upsert: true})
{
“acknowledged” : true,
“matchedCount” : 0,
“modifiedCount” : 0,
“upsertedId” : ObjectId(“5d15d6c718f0856b4385c123”)
}
结果文档显示本次操作未匹配到文档,也未更新文档,但操作成功。相对于上一次的结果文档,本次结果文档中多出了 upsertedId
,这正是 奥尼尔
文档的 _id
。我们可以通过 find()
方法来验证:
> db.inven.find({name: “奥尼尔”})
{ “_id” : ObjectId(“5d15d6c718f0856b4385c123”), “name” : “奥尼尔” }
果然, 奥尼尔
被添加到集合中。
更新多个文档
updateMany()
方法可以更新多个文档,其语法格式与 updateOne()
相同。假设要将球衣号大于 20 的所有球员球衣号设置为
33,对示例如下:
> db.inven.updateMany(
… {number: {$gt: 20}},
… {$set: {number: 33}}
… )
{ “acknowledged” : true, “matchedCount” : 2, “modifiedCount” : 2 }
其他指令如 upsert
使用时与之前相同。
update()
方法的语法格式与 updateOne()
相同,但它默认只更新单个文档。假设要将球衣号等于 33 的球员球衣号设置为
0,对应示例如下:
> db.inven.update( {number: {$eq: 33}}, {$set: {number: 0}} )
WriteResult({ “nMatched” : 1, “nUpserted” : 0, “nModified” : 1 })
返回文档显示只更新了 1 个文档,其他查询符合条件的文档并未被更新。如果想要更新多个文档,可以将指令 multi
设置为 true
,或者使用updateMany()
。
替换文档
replaceOne()
方法会根据过滤器替换集合中的单个文档,其语法如下:
db.collection.replaceOne(
{
upsert:
writeConcern:
collation:
}
)
假设要为 奥尼尔
添加球衣号,对应示例如下:
> db.inven.replaceOne( {name: “奥尼尔”}, {name: “奥尼尔”, number: 34})
{ “acknowledged” : true, “matchedCount” : 1, “modifiedCount” : 1 }
命令执行后,奥尼尔
文档从 {name: "奥尼尔"}
变成了 {name: "奥尼尔", number: 34}
。
Save
save()
是一个多用途的方法,它会根据 _id
是否存在而选择调用 insert()
或者 update()
。当文档中存在 _id
时,save()
等效于带有 upsert
指令的 update()
;当文档中不包含 _id
时,save()
等效于insert()
,此时 MongoShell 将创建一个 ObjectId
,并将其分配给 _id
。save()
的语法格式如下:
db.collection.save(
{
writeConcern:
}
)
奥尼尔
的 _id
为 ObjectId("5d16c699dca60c968c6d8f69")
,当 save()
方法中的文档包含_id
时会更新文档内容,对应示例如下:
> db.inven.save({_id: “5d16c699dca60c968c6d8f69”, name: “奥尼尔”, status: “R”})
WriteResult({
“nMatched” : 0,
“nUpserted” : 1,
“nModified” : 0,
“_id” : “5d16c699dca60c968c6d8f69”
})
{"name" : "奥尼尔" }
变成了 {"name" : "奥尼尔", "status" : "R"
}
。要注意的是,ObjectId("5d16c699dca60c968c6d8f69")
在命令中的写法是"5d16c699dca60c968c6d8f69"
。
更新操作符
MongoDB 共有四类更新操作符,它们是:字段更新操作符、数组更新操作符、修饰操作符和按位操作符。
字段更新操作符
MongoDB 中共有 9 个字段更新操作符,它们分别是:
名称 | 描述 |
---|---|
$currentDate |
|
将字段的值设置为当前日期,可以是 Date 或 Timestamp 。 |
|
$inc |
|
将指定字段的值与传入的值相加。 | |
$min |
|
仅当指定的值小于现有字段值时才更新字段。 | |
$max |
|
仅当指定的值大于现有字段值时才更新字段。 | |
$mul |
|
将指定字段的值与传入的值相乘。 | |
$rename |
|
重命名字段。 | |
$set |
|
设置文档中字段的值。 | |
$setOnInsert |
|
如果更新导致文档插入,则设置字段的值。对修改现有文档的更新操作没有影响。 | |
$unset |
|
从文档中删除指定的字段。 |
我们将在本节中挑选几个典型的字段更新操作符进行学习,对于作用相似或语法类似的将不作赘述。例如 $min
和 $max
,我们只需要了解其中一个即可。
currentDate
$currentDate
的作用是将字段的值设为当前日期,其语法格式如下:
{ $currentDate: {
其中,<typeSpecification1>
可以是一个布尔值、 {$type: "timestamp"}
或者 {$type:
"date"}
。在开始学习之前,我们需要准备以下数据:
> db.registers.save({_id: 1, name: “async”, pwd: “123456”, regTime: new Date()})
此时,regTime
的值为 ISODate("2019-07-10T08:40:02.253Z")
。假设要更新regTime
的值,我们可以使用如下命令:
> db.registers.update(
… {_id: 1},
… {$currentDate:{regTime: true}
… })
命令执行后,regTime
的值就会被改变,新的文档内容类似于:
{ “_id” : 1, “name” : “async”, “pwd” : “123456”, “regTime” : ISODate(“2019-07-10T08:43:24.981Z”) }
当然,我们也可以使用 {$type: "timestamp"}
这种语法,命令如下:
> db.registers.update(
… {_id: 1},
… {$currentDate:{ regTime: {$type: “timestamp”}}
… })
命令执行后,regTime
的值也会被改变。要注意的是,由于 Date
与 Timestamp
格式不同,所以 regTime
的值的格式将由原来的 ISODate
变成 Timestamp
。
inc
$inc
的作用是按指定的数量增加字段的值,其语法格式如下:
{ $inc: {
假设现在有一个这样的文档:
{
_id: 1,
sku: “abc123”,
quantity: 10,
metrics: {
orders: 2,
ratings: 3.5
}
}
如果我们需要在此基础上将 quantity
和 metrics.orders
的值做加法,对应示例如下:
db.products.update(
{ sku: “abc123” },
{ $inc: { quantity: -2, “metrics.orders”: 1 } }
)
命令执行后,文档将会变成下面这样:
{
“_id” : 1,
“sku” : “abc123”,
“quantity” : 8,
“metrics” : {
“orders” : 3,
“ratings” : 3.5
}
}
如果传入的不是数字,而是字符串或其它类型,我们将得到错误提示:
> db.products.update({sku: “abc123” },{$inc: {quantity: “async”}})
WriteResult({
“nMatched” : 0,
“nUpserted” : 0,
“nModified” : 0,
“writeError” : {
“code” : 14,
“errmsg” : “Cannot increment with non-numeric argument: {quantity: "async"}”
}
})
$mul
的作用是将指定字段的值与传入的值相乘,这与 $inc
类似,此处不再赘述。$mul
的语法和介绍可查阅官方文档
$mul。
min
$min
的描述是“仅当指定的值小于现有字段值时才更新字段”,其语法格式如下:
{ $min: {
假设有以下数据:
{ _id: 1, highScore: 800, lowScore: 200 }
最高分 highScore
为 800
,最低分 lowScore
为 200
。以下 $min
操作将用指定的 150
与 200
进行比对,由于 150 < 200
,原文档中 lowScore
的值会被更新为指定的 150
。更新语句如下:
> db.scores.update( { _id: 1 }, { $min: { lowScore: 250 } } )
命令执行后,文档将会变成:
{ _id: 1, highScore: 800, lowScore: 150 }
示例中只演示了数字类型的比较,实际上 BSON 都可以进行比较,比较顺序参考 [BSON comparison
order](https://docs.mongodb.com/manual/reference/bson-type-comparison-
order/#faq-dev-compare-order-for-bson-types)。$max
将指定值与现有字段值进行比较,如果指定值大于现有字段值,则更新对应字段值。这与 $min
类似,此处不再赘述。$max
的语法和介绍可查阅官方文档
$max。
unset
$unset
的作用是删除文档中的指定字段,其语法格式如下:
{ $unset: {
集合 products
中的文档如下:
{ “_id” : 1, “sku” : “abc123”, “quantity” : 8, “metrics” : { “orders” : 3, “ratings” : 3.5 } }
假设要删除 quantity
字段,对应示例如下:
> db.products.update(
… {_id: 1},
… {$unset: {quantity: “”}}
… )
命令执行后,文档内容将会变成下面这样:
{ “_id” : 1, “sku” : “abc123”, “metrics” : { “orders” : 3, “ratings” : 3.5 } }
对比命令执行前后的结果可以看出,字段 quantity
已被成功删除。$set
与 $unset
语法类似,此处不再赘述。$set
的具体介绍可查阅官方文档
$set。
数组更新操作符
MongoDB 中共有 8 个数组更新操作符,它们是:
名称 | 描述 |
---|---|
$ |
|
充当占位符以更新与查询条件匹配的第一个元素。 | |
[$[] ](https://docs.mongodb.com/manual/reference/operator/update/positional- |
|
all/#up.S[]) | 充当占位符以更新数组中与查询条件匹配的文档中的所有元素。 |
[$[<identifier>] ](https://docs.mongodb.com/manual/reference/operator/update/positional- |
|
filtered/#up.S[]) | 充当占位符以更新与 arrayFilters 匹配查询条件的文档的条件匹配的所有元素。 |
$addToSet |
|
仅当数组中尚不存在元素时才将元素添加到数组中。 | |
$pop |
|
删除数组的第一个或最后一个元素。 | |
$pull |
|
删除与指定查询匹配的所有数组元素。 | |
$push |
|
将元素添加到数组。 | |
$pullAll |
|
从数组中删除所有匹配的值。 |
我们将在本节中挑选两个个常用的数组更新操作符进行学习,例如 $pop
和 $push
pop
$pop
的作用是删除数组的第一个或最后一个元素,其语法格式如下:
{ $pop: {
假设现在有一个这样的文档:
{ _id: 1, scores: [ 8, 9, 10 ] }
文档中字段 scores
的值是一个数组。当我们需要删除 scores
中的第一个元素时,执行以下命令:
> db.collection.update( { _id: 1 }, { $pop: { scores: -1 } } )
命令执行后,文档将会变成:
{ “_id” : 1, “scores” : [ 9, 10 ] }
当我们要删除最后一个元素时,执行以下命令:
> db.collection.update( { _id: 1 }, { $pop: { scores: 1 } } )
命令执行后,文档将会变成:
{ “_id” : 1, “scores” : [ 9] }
如果将数组元素的排序看成从左到右,那么 {$pop: { <field>: -1}}
删除的是最左的元素,而 {$pop: { <field>:
1}}
删除的是最右的元素。
push
$push
的作用是将元素添加到数组,其语法格式如下:
{ $push: {
假设现在有一个这样的文档:
{ _id: 1, scores: [ 8, 9, 10 ] }
文档中字段 scores
的值是一个数组。当我们需要将 100
添加到 scores
中时,执行以下命令:
> db.collection.update({_id: 1}, {$push: {scores: 100}})
命令执行后,文档将会变成:
{ _id: 1, scores: [ 8, 9, 10, 100 ] }
但如果要添加的元素是一个数组,而不是一个数值呢?假设要将 200, 300
添加到数组 scores
中,希望得到的结果是 { _id: 1,
scores: [ 8, 9, 10, 100, 200, 300 ] }
,我们应该怎么做?用与上面相同的命令:
> db.collection.update({_id: 1}, {$push: {scores: [200, 300]}})
命令执行后,将会得到如下结果:
{ _id: 1, scores: [ 8, 9, 10, 100, [200, 300] ] }
换一种命令:
> db.collection.update({_id: 1}, {$push: {scores: 200, 300}})
得到的却是错误提示。实际上,MongoDB 提供了一些更新操作修饰符,用于丰富更新操作。在这个需求中,我们可以使用更新操作修饰符 $each
帮助我们完成任务。
更新操作符并不复杂,此处不再进行其它更新操作符的介绍,读者可查阅官方文档 [Update
Operators](https://docs.mongodb.com/manual/reference/operator/update-
array/#update-operators)。接下来,我们将学习更新操作修饰符的相关知识。
更新操作修饰符
MongoDB 中的更新操作修饰符共有 4 个,它们是:
名称 | 描述 |
---|---|
$each |
|
修饰 | |
$push |
|
和 | |
$addToSet 操作符以附加多个项目以进行阵列更新。 |
|
$position |
|
修饰 | |
$push 操作符以指定数组中添加元素的位置。 |
|
$slice |
|
修饰 | |
$push 操作符以限制更新数组的大小。 |
|
$sort |
|
修饰 | |
$push |
|
操作符以重新排序存储在数组中的文档。 |
each
$each
的作用是修饰 $push
和 $addToSet
更新操作符,以附加多个元素。上一个小节中,我们的需求是将 200, 300
添加到数组 scores
中,但添加结果却是 [200, 300]
。如果用 $each
修饰 $push
操作符,就能够达到目的。对应示例如下:
> db.collection.update({_id: 1}, {$push: {scores:{$each: [200, 300]}}})
命令执行后,文档将会变成:
{ “_id” : 1, “scores” : [ 8, 9, 10, 100, [ 200, 300 ], 200, 300 ] }
position
$position
的作用是修饰更新操作符 $push
,以指定元素添加时的位置。其语法格式如下:
{
$push: {
$each: [
$position:
}
}
}
以下示例中,$position
将与 $each
协同工作。假设要将 15, 25
插入到 scores
数组的第 1
个位置,对应示例如下:
> db.collection.update({_id: 1}, {$push: {scores:{$each: [15, 25], $position: 1}}})
命令执行后,文档将会变成:
{ “_id” : 1, “scores” : [ 8, 15, 25, 9, 10, 100, [ 200, 300 ], 200, 300 ] }
小提示 :当 $position
为负数时,将会从右往左插入。
更新操作修饰符用于修饰更新操作符,使更新操作变得更灵活更丰富。$sort
和 $slice
的语法和具体用法可在官网查阅,此处不再赘述。
按位操作符
MongoDB 中的按位操作符只有一个:$bit
。它的作用是执行按位与、按位或和按位异或等操作,其语法格式如下:
{ $bit: {
按位操作
很多读者对按位操作都不熟悉,这里简单介绍一下。
- 按位与操作将参与预算的两数各对应的二进位相与,只有对应的两个二进位均为
1
时,结果位才为1
。 - 按位或操作将参与预算的两数各对应的二进位相或,只要对应的两个二进位中有一个为
1
时,结果位就为1
。 - 按位异或操作参与预算的两数各对应的二进位相异或,对应的两个二进位中的值不相同时,结果位就为
1
。
如果想要全面了解位运算,可参考我在微信公众号【Rust之蝉】上发布的文章
【七分钟全面了解位运算】。
以数字 5
和 9
为例,进行按位与、按位或和按位异或操作演示。数字 5
对应的二进制为 0101
,数字 9
对应的二进制为1001
。二进位比对如下:
0101
1001
首先是按位与操作:只有对应的两个二进位均为 1
时,结果位才为 1
,否则为 0
。
0101
1001
—-
0001
所以按位与结果为 0001
,对应的十进制为 1
,即数字 5
和数字 9
按位与的结果为数字 1
。
接着看看按位或操作:只要对应的两个二进位中有一个为 1
时,结果位为 1
,否则为 0
。
0101
1001
—-
1101
所以按位或的结果为 1101
,对应的十进制为数字 13
,即数字 5
和数字 9
按位或的结果为数字 13
。
最后是按位异或操作: 对应的两个二进位中的值不相同时,结果位就为 1
,否则为 0
。
0101
1001
—-
1100
所以按位异或的结果为 1100
,对应的十进制为 12
,即数字 5
和数字 9
按位异或的结果为数字 12
。
bit
假设有如下文档:
{ _id: 1, expdata: NumberInt(13) }
以下 update
操作将 expdata
字段值进行按位与更新:
> db.collection.update(
{ _id: 1 },
{ $bit: { expdata: { and: NumberInt(10) } } }
)
13
对应的进制为 1101
,10
对应的二进制为 1010
,13
和 10
的按位与操作如下:
1101
1010
—-
1000
二进制 1000
对应的十进制为 8
。也就是说,更新操作执行后,文档将会变成:
{ “_id” : 1, “expdata” : 8 }
如果使用按位或,即 or
,则文档将会变成:
{ “_id” : 1, “expdata” : 15 }
至此,更新操作符的相关知识我们已经学习完毕。
DELETE Operations
MongoDB 提供了几个方法用于删除文档,它们是:
删除单个文档
deleteOne()
方法会根据过滤器删除集合中的单个文档。假设我们希望从集合中删除 奥尼尔
,对应命令如下:
> db.inven.deleteOne({name: “奥尼尔”})
{ “acknowledged” : true, “deletedCount” : 1 }
删除多个文档
deleteMany()
方法会根据过滤器删除集合中的多个文档,甚至所有文档。例如我们希望删除集合 inven
中的所有文档,对应命令如下:
> db.inven.deleteMany({})
{ “acknowledged” : true, “deletedCount” : 5 }
要注意的是,删除操作并不会删除索引,即使我们删除了集合中的所有文档。
文档的 CRUD 操作小结
以上就是 MongoDB 中文档 CURD 操作的介绍。内容篇幅较长,但经过本篇的学习,我们已经熟悉了 MongoDB
中常见的文档操作,能够很好地应对工作需要了。
基础篇 二 流式聚合操作
信息科学中的聚合是指对相关数据进行内容筛选、处理和归类并输出结果的过程。MongoDB
中的聚合是指同时对多个文档中的数据进行处理、筛选和归类并输出结果的过程。数据在聚合操作的过程中,就像是水流过一节一节的管道一样,所以 MongoDB
中的聚合又被人称为流式聚合。MongoDB 提供了几种聚合方式:
- Aggregation Pipeline
- Map-Reduce
- 简单聚合
接下来,我们将全方位地了解 MongoDB 中的聚合。
Aggregation Pipeline
Aggregation Pipeline 又称聚合管道。开发者可以将多个文档传入一个由多个 Stage
组成的 Pipeline
,每一个Stage
处理的结果将会传入下一个 Stage
中,最后一个 Stage
的处理结果就是整个 Pipeline
的输出。
创建聚合管道的语法如下:
db.collection.aggregate( [ {
MongoDB 提供了 23 种 Stage
,它们是:
Stage | 描述 |
---|---|
$addFields |
|
向文档添加新字段。 | |
$bucket |
|
根据指定的表达式和存储区边界将传入的文档分组。 | |
$bucketAuto |
|
根据指定的表达式将传入的文档分类为特定数量的组,自动确定存储区边界。 | |
$collStats |
|
返回有关集合或视图的统计信息。 | |
$count |
|
返回聚合管道此阶段的文档数量计数。 | |
$facet |
|
在同一组输入文档的单个阶段内处理多个聚合操作。 | |
$geoNear |
|
基于与地理空间点的接近度返回有序的文档流。 | |
$graphLookup |
|
对集合执行递归搜索。 | |
$group |
|
按指定的标识符表达式对文档进行分组。 | |
$indexStats |
|
返回集合的索引信息。 | |
$limit |
|
将未修改的前 n 个文档传递给管道。 | |
$listSessions |
|
列出system.sessions 集合的所有会话。 |
|
$lookup |
|
对同一数据库中的另一个集合执行左外连接。 | |
$match |
|
过滤文档,仅允许匹配的文档地传递到下一个管道阶段。 | |
$out |
|
将聚合管道的结果文档写入指定集合,它必须是管道中的最后一个阶段。 | |
$project |
|
为文档添加新字段或删除现有字段。 | |
$redact |
|
可用于实现字段级别的编辑。 | |
$replaceRoot |
|
用指定的嵌入文档替换文档。该操作将替换输入文档中的所有现有字段,包括_id 字段。指定嵌入在输入文档中的文档以将嵌入文档提升到顶层。 |
|
$sample |
|
从输入中随机选择指定数量的文档。 | |
$skip |
|
跳过前 n 个文档,并将未修改的其余文档传递到下一个阶段。 | |
$sort |
|
按指定的排序键重新排序文档流。只有订单改变; 文件保持不变。对于每个输入文档,输出一个文档。 | |
$sortByCount |
|
对传入文档进行分组,然后计算每个不同组中的文档计数。 | |
$unwind |
|
解构文档中的数组字段。 |
文档、Stage
和 Pipeline
的关系如下图所示:
上图描述了文档经过 $match
、$sample
和 $project
等三个 Stage
并输出的过程。SQL 中常见的聚合术语有WHERE
、SUM
和 COUNT
等。下表描述了常见的 SQL 聚合术语、函数和概念以及对应的 MongoDB 操作符或 Stage
。
SQL | MongoDB |
---|---|
WHERE | |
$match |
|
GROUP BY | |
$group |
|
HAVING | |
$match |
|
SELECT | |
$project |
|
ORDER BY | |
$sort |
|
LIMIT | |
$limit |
|
SUM() | |
$sum |
|
COUNT() | |
$sum $sortByCount |
|
join | |
$lookup |
下面,我们将通过示例了解 Aggregate
、 Stage
和 Pipeline
之间的关系。
概念浅出
$match
的描述为“过滤文档,仅允许匹配的文档地传递到下一个管道阶段”。其语法格式如下:
{ $match: {
在开始学习之前,我们需要准备以下数据:
> db.artic.insertMany([
… { “_id” : 1, “author” : “dave”, “score” : 80, “views” : 100 },
… { “_id” : 2, “author” : “dave”, “score” : 85, “views” : 521 },
… { “_id” : 3, “author” : “anna”, “score” : 60, “views” : 706 },
… { “_id” : 4, “author” : “line”, “score” : 55, “views” : 300 }
… ])
然后我们建立只有一个 Stage
的 Pipeline
,以实现过滤出 author
为 dave
的文档。对应示例如下:
> db.artic.aggregate([
… {$match: {author: “dave”}}
… ])
{ “_id” : 1, “author” : “dave”, “score” : 80, “views” : 100 }
{ “_id” : 2, “author” : “dave”, “score” : 85, “views” : 521 }
如果要建立有两个 Stage
的 Pipeline
,那么就在 aggregate
中添加一个 Stage
即可。现在有这样一个需求:统计集合artic
中 score
大于 70
且小于 90
的文档数量。这个需求分为两步进行:
- 过滤出符合要求的文档
- 统计文档数量
Aggregation 非常适合这种多步骤的操作。在这个场景中,我们需要用到 $match
、$group
这两个 Stage
,然后再与聚合表达式 $sum
相结合,对应示例如下:
> db.artic.aggregate([
… {$match: {score: {$gt: 70, $lt: 90}}},
… {$group: {_id: null, number: {$sum: 1}}}
… ])
{ “_id” : null, “number” : 2 }
这个示例的完整过程可以用下图表示:
通过上面的描述和举例,我相信你对 Aggregate
、 Stage
和 Pipeline
有了一定的了解。接下来,我们将学习常见的Stage
的语法和用途。
常见的 Stage
sample
$sample
的作用是从输入中随机选择指定数量的文档,其语法格式如下:
{ $sample: { size:
假设要从集合 artic
中随机选择两个文档,对应示例如下:
> db.artic.aggregate([
… {$sample: {size: 2}}
… ])
{ “_id” : 1, “author” : “dave”, “score” : 80, “views” : 100 }
{ “_id” : 3, “author” : “anna”, “score” : 60, “views” : 706 }
size
对应的值必须是正整数,如果输入负数会得到错误提示:size argument to $sample must not be
negative
。要注意的是,当值超过集合中的文档数量时,返回结果是集合中的所有文档,但文档顺序是随机的。
project
$project
的作用是过滤文档中的字段,这与投影操作相似,但处理结果将会传入到下一个阶段 。其语法格式如下:
{ $project: { <specification(s)> } }
准备以下数据:
> db.projects.save(
{_id: 1, title: “篮球训练营青春校园活动开始啦”, numb: “A829Sck23”, author: {last: “quinn”, first: “James”}, hot: 35}
)
假设 Pipeline
中的下一个 Stage
只需要文档中的 title
和 author
字段,对应示例如下:
> db.projects.aggregate([{$project: {title: 1, author: 1}}])
{ “_id” : 1, “title” : “篮球训练营青春校园活动开始啦”, “author” : { “last” : “quinn”, “first” : “James” } }
0
和 1
可以同时存在。对应示例如下:
> db.projects.aggregate([{$project: {title: 1, author: 1, _id: 0}}])
{ “title” : “篮球训练营青春校园活动开始啦”, “author” : { “last” : “quinn”, “first” : “James” } }
true
等效于 1
,false
等效于 0
,也可以混用布尔值和数字,对应示例如下:
> db.projects.aggregate([{$project: {title: 1, author: true, _id: false}}])
{ “title” : “篮球训练营青春校园活动开始啦”, “author” : { “last” : “quinn”, “first” : “James” } }
如果想要排除指定字段,那么在 $project
中将其设置为 0
或 false
即可,对应示例如下:
> db.projects.aggregate([{$project: {author: false, _id: false}}])
{ “title” : “篮球训练营青春校园活动开始啦”, “numb” : “A829Sck23”, “hot” : 35 }
$project
也可以作用于嵌入式文档。对于 author
字段,有时候我们只需要 FirstName
或者 Lastname
,对应示例如下:
> db.projects.aggregate([{$project: {author: {“last”: false}, _id: false, numb: 0}}])
{ “title” : “篮球训练营青春校园活动开始啦”, “author” : { “first” : “James” }, “hot” : 35 }
这里使用 {author: {"last": false}}
过滤掉 LastName
,但保留 first
。
以上就是 $project
的基本用法和作用介绍,更多与 $project
相关的知识可查阅官方文档
[$project](https://docs.mongodb.com/manual/reference/operator/aggregation/project/#project-
aggregation)。
lookup
$lookup
的作用是对同一数据库中的集合执行左外连接,其语法格式如下:
{
$lookup:
{
from:
localField:
foreignField: <field from the documents of the “from” collection>,
as:
左外连接类似与下面的伪 SQL 语句:
SELECT *,
lookup
支持的指令及对应描述如下:
领域 | 描述 |
---|---|
from |
指定集合名称。 |
localField |
指定输入 $lookup 中的字段。 |
foreignField |
指定from 给定的集合中的文档字段。 |
as |
指定要添加到输入文档的新数组字段的名称。 |
新数组字段包含from 集合中的匹配文档。 |
|
如果输入文档中已存在指定的名称,则会覆盖现有字段 。 |
准备以下数据:
> db.sav.insert([
{ “_id” : 1, “item” : “almonds”, “price” : 12, “quantity” : 2 },
{ “_id” : 2, “item” : “pecans”, “price” : 20, “quantity” : 1 },
{ “_id” : 3 }
])
> db.avi.insert([
{ "_id" : 1, "sku" : "almonds", description: "product 1", "instock" : 120 },
{ "_id" : 2, "sku" : "bread", description: "product 2", "instock" : 80 },
{ "_id" : 3, "sku" : "cashews", description: "product 3", "instock" : 60 },
{ "_id" : 4, "sku" : "pecans", description: "product 4", "instock" : 70 },
{ "_id" : 5, "sku": null, description: "Incomplete" },
{ "_id" : 6 }
])
假设要连接集合 sav
中的 item
和集合 avi
中的 sku
,并将连接结果命名为 savi
。对应示例如下:
> db.sav.aggregate([
{
$lookup:
{
from: “avi”,
localField: “item”,
foreignField: “sku”,
as: “savi”
}
}
])
命令执行后,输出如下内容:
{
“_id” : 1,
“item” : “almonds”,
“price” : 12,
“quantity” : 2,
“savi” : [
{ “_id” : 1, “sku” : “almonds”, “description” : “product 1”, “instock” : 120 }
]
}
{
“_id” : 2,
“item” : “pecans”,
“price” : 20,
“quantity” : 1,
“savi” : [
{ “_id” : 4, “sku” : “pecans”, “description” : “product 4”, “instock” : 70 }
]
}
{
“_id” : 3,
“savi” : [
{ “_id” : 5, “sku” : null, “description” : “Incomplete” },
{ “_id” : 6 }
]
}
上面的连接操作等效于下面这样的伪 SQL:
SELECT *, savi
FROM sav
WHERE savi IN (SELECT *
FROM avi
WHERE sku= sav.item);
以上就是 lookup
的基本用法和作用介绍,更多与 lookup
相关的知识可查阅官方文档
[lookup](https://docs.mongodb.com/manual/reference/operator/aggregation/lookup/#lookup-
aggregation)。
unwind
unwind
能将包含数组的文档拆分称多个文档,其语法格式如下:
{
$unwind:
{
path:
includeArrayIndex:
preserveNullAndEmptyArrays:
}
}
unwind
支持的指令及对应描述如下:
指令 | 类型 | 描述 |
---|---|---|
path |
string | 指定数组字段的字段路径, 必填。 |
includeArrayIndex |
string | 用于保存元素的数组索引的新字段的名称。 |
preserveNullAndEmptyArrays |
boolean | 默认情况下,如果path 为 null 、缺少该字段或空数组, |
则不输出文档。反之,将其设为 true 则会输出文档。 |
在开始学习之前,我们需要准备以下数据:
> db.shoes.save({_id: 1, brand: “Nick”, sizes: [37, 38, 39]})
集合 shoes
中的 sizes
是一个数组,里面有多个尺码数据。假设要将这个文档拆分成 3 个 size
为单个值的文档,对应示例如下:
> db.shoes.aggregate([{$unwind : “$sizes”}])
{ “_id” : 1, “brand” : “Nick”, “sizes” : 37 }
{ “_id” : 1, “brand” : “Nick”, “sizes” : 38 }
{ “_id” : 1, “brand” : “Nick”, “sizes” : 39 }
显然,这样的文档更方便我们做数据处理。preserveNullAndEmptyArrays
指令默认为 false
,也就是说文档中指定的path
为空、null
或缺少该 path
的时候,会忽略掉该文档。假设数据如下:
> db.shoes2.insertMany([
{“_id”: 1, “item”: “ABC”, “sizes”: [“S”, “M”, “L”]},
{“_id”: 2, “item”: “EFG”, “sizes”: [ ]},
{“_id”: 3, “item”: “IJK”, “sizes”: “M”},
{“_id”: 4, “item”: “LMN” },
{“_id”: 5, “item”: “XYZ”, “sizes”: null}
])
我们执行以下命令:
> db.shoes2.aggregate([{$unwind: “$sizes”}])
就会得到如下输出:
{ “_id” : 1, “item” : “ABC”, “sizes” : “S” }
{ “_id” : 1, “item” : “ABC”, “sizes” : “M” }
{ “_id” : 1, “item” : “ABC”, “sizes” : “L” }
{ “_id” : 3, “item” : “IJK”, “sizes” : “M” }
_id
为 2
、4
和 5
的文档由于满足 preserveNullAndEmptyArrays
的条件,所以不会被拆分。
以上就是 unwind
的基本用法和作用介绍,更多与 unwind
相关的知识可查阅官方文档
[unwind](https://docs.mongodb.com/manual/reference/operator/aggregation/unwind/#unwind-
aggregation)。
out
out
的作用是聚合 Pipeline
返回的结果文档,并将其写入指定的集合。要注意的是,out
操作必须出现在 Pipeline
的最后。out
语法格式如下:
{ $out: “
准备以下数据:
> db.books.insertMany([
{ “_id” : 8751, “title” : “The Banquet”, “author” : “Dante”, “copies” : 2 },
{ “_id” : 8752, “title” : “Divine Comedy”, “author” : “Dante”, “copies” : 1 },
{ “_id” : 8645, “title” : “Eclogues”, “author” : “Dante”, “copies” : 2 },
{ “_id” : 7000, “title” : “The Odyssey”, “author” : “Homer”, “copies” : 10 },
{ “_id” : 7020, “title” : “Iliad”, “author” : “Homer”, “copies” : 10 }
])
假设要集合 books
的分组结果保存到名为 books_result
的集合中,对应示例如下:
> db.books.aggregate([
… { $group : {_id: “$author”, books: {$push: “$title”}}},
… { $out : “books_result” }
… ])
命令执行后,MongoDB 将会创建 books_result
集合,并将分组结果保存到该集合中。集合 books_result
中的文档如下:
{ “_id” : “Homer”, “books” : [ “The Odyssey”, “Iliad” ] }
{ “_id” : “Dante”, “books” : [ “The Banquet”, “Divine Comedy”, “Eclogues” ] }
以上就是 out
的基本用法和作用介绍,更多与 out
相关的知识可查阅官方文档
[out](https://docs.mongodb.com/manual/reference/operator/aggregation/out/#out-
aggregation)。
Map-Reduce
Map-reduce 用于将大量数据压缩为有用的聚合结果,其语法格式如下:
db.runCommand(
{
mapReduce:
map:
reduce:
finalize:
out:
query:
sort:
limit:
scope:
jsMode:
verbose:
bypassDocumentValidation:
collation:
writeConcern:
}
)
其中,db.runCommand({mapReduce: <collection>})
也可以写成db.collection.mapReduce()
。各指令的对应描述如下:
指令 | 类型 | 描述 |
---|---|---|
mapReduce |
collection | 集合名称,必填。 |
map |
function | JavaScript 函数,必填。 |
reduce |
function | JavaScript 函数,必填。 |
out |
string or document | 指定输出结果,必填。 |
query |
document | 查询条件语句。 |
sort |
document | 对文档进行排序。 |
limit |
number | 指定输入到 map 中的最大文档数量。 |
finalize |
function | 修改 reduce 的输出。 |
scope |
document | 指定全局变量。 |
jsMode |
boolean | 是否在执行map 和reduce 函数之间将中间数据转换为 BSON 格式,默认 false 。 |
verbose |
boolean | 结果中是否包含 timing 信息,默认 false 。 |
bypassDocumentValidation |
boolean | 是否允许 |
mapReduce 在操作期间绕过文档验证,默认 |
||
false 。 |
||
collation |
document | |
指定要用于操作的[排序规则](https://docs.mongodb.com/manual/reference/bson-type-comparison- | ||
order/#collation)。 | ||
writeConcern |
document | 指定写入级别,不填写则使用默认级别。 |
简单的 mapReduce
一个简单的 mapReduce
语法示例如下:
var mapFunction = function() { … };
var reduceFunction = function(key, values) { … };
db.runCommand(
… {
… … mapReduce:
… … map: mapFunction,
… … reduce: reduceFunction,
… … out: { merge:
… … query:
… })
map
函数负责将每个输入的文档转换为零个或多个文档。map
结构如下:
function() {
…
emit(key, value);
}
emit
函数的作用是分组,它接收两个参数:
key
:指定用于分组的字段。value
:要聚合的字段。
在 map
中可以使用 this
关键字引用当前文档。reduce
结构如下:
function(key, values) {
…
return result;
}
reduce
执行具体的数据处理操作,它接收两个参数:
key
:与map
中的key
相同,即分组字段。values
:根据分组字段,将相同key
的值放到同一个数组,values
就是包含这些分类数组的对象。
out
用于指定结果输出,out: <collectionName>
会将结果输出到新的集合,或者使用以下语法将结果输出到已存在的集合中:
out: {
[, db:
[, sharded:
[, nonAtomic:
要注意的是,如果 out
指定的 collection
已存在,那么它就会覆盖该集合。在开始学习之前,我们需要准备以下数据:
> db.mprds.insertMany([
… {_id: 1, numb: 3, score: 9, team: “B”},
… {_id: 2, numb: 6, score: 9, team: “A”},
… {_id: 3, numb: 24, score: 9, team: “A”},
… {_id: 4, numb: 6, score: 8, team: “A”}
… ])
接着定义 map
函数、reduce
函数,并将其应用到集合 mrexample
上。然后为输出结果指定存放位置,这里将输出结果存放在名为mrexample_result
的集合中。
> var func_map = function(){emit(this.numb, this.score);};
> var func_reduce = function(key, values){return Array.sum(values);};
> db.mprds.mapReduce(func_map, func_reduce, {query: {team: “A”}, out: “mprds_result”})
map
函数指定了结果中包含的两个键,并将 this.class
相同的文档输出到同一个文档中。reduce
则对传入的列表进行求和,求和结果作为结果中的 value
。命令执行完毕后,结果会被存放在集合 mprds_result
中。用以下命令查看结果:
> db.mprds_result.find()
{ “_id” : 6, “value” : 17 }
{ “_id” : 24, “value” : 9 }
结果文档中的 _id
即 map
中的 this.numb
,value
为 reduce
函数的返回值。
下图描述了此次 mapReduce
操作的完整过程:
finallize 剪枝
finallize
用于修改 reduce
的输出结果,其语法格式如下:
function(key, reducedValue) {
…
return modifiedObject;
}
它接收两个参数:
key
,与 map
中的 key
相同,即分组字段。
reducedValue
,一个 Obecjt
,是reduce
的输出。
上面我们介绍了 map
和 reduce
,并通过一个简单的示例了解 mapReduce
的基本组成和用法。实际上我们还可以编写功能更丰富的reduce
函数,甚至使用 finallize
修改 reduce
的输出结果。以下 reduce
函数将传入的 values
进行计算和重组,返回一个 reduceVal
对象:
> var func_reduce2 = function(key, values){
reduceVal = {team: key, score: values, total: Array.sum(values), count: values.length};
return reduceVal;
};
reduceVal
对象中包含 team
、score
、total
和 count
四个属性。但我们还想为其添加 avg
属性,那么可以在 finallize
函数中执行 avg
值的计算和 avg
属性的添加工作:
> var func_finalize = function(key, values){
values.avg = values.total / values.count;
return values;
};
map
保持不变,将这几个函数作用于集合 mprds
上,对应示例如下:
> db.mprds.mapReduce(func_map, func_reduce2, {query: {team: “A”}, out: “mprds_result”, finalize: func_finalize})
命令执行后,结果会存入指定的集合中。此时,集合 mprds_result
内容如下:
{ “_id” : 6, “value” : { “team” : 6, “score” : [ 9, 8 ], “total” : 17, “count” : 2, “avg” : 8.5 } }
{ “_id” : 24, “value” : 9 }
下图描述了此次 mapReduce
操作的完整过程:finallize
在 reduce
后面使用,微调 reduce
的处理结果。这着看起来像是一个园丁在修剪花圃的枝丫,所以人们将finallize
形象地称为“剪枝”。
要注意的是:map
会将 key
值相同的文档中的 value
归纳到同一个对象中,这个对象会经过 reduce
和finallize
。对于 key
值唯一的那些文档,指定的 key
和 value
会被直接输出。
简单的聚合
除了 Aggregation Pipeline 和 Map-Reduce 这些复杂的聚合操作之外,MongoDB 还支持一些简单的聚合操作,例如count
、group
和 distinct
等。
count
count
用于计算集合或视图中的文档数,返回一个包含计数结果和状态的文档。其语法格式如下:
{
count:
query:
limit:
skip:
hint:
readConcern:
}
count
支持的指令及对应描述如下:
指令 | 类型 | 描述 |
---|---|---|
count |
string | 要计数的集合或视图的名称,必填。 |
query |
document | 查询条件语句。 |
limit |
integer | 指定要返回的最大匹配文档数。 |
skip |
integer | 指定返回结果之前要跳过的匹配文档数。 |
hint |
string or document | 指定要使用的索引,将索引名称指定为字符串或索引规范文档。 |
假设要统计集合 mprds
中的文档数量,对应示例如下:
> db.runCommand({count: ‘mprds’})
{ “n” : 4, “ok” : 1 }
假设要统计集合 mprds
中 numb
为 6
的文档数量,对应示例如下:
> db.runCommand({count: ‘mprds’, query: {numb: {$eq: 6}}})
{ “n” : 2, “ok” : 1 }
指定返回结果之前跳过 1
个文档,对应示例如下:
> db.runCommand({count: ‘mprds’, query: {numb: {$eq: 6}}, skip: 1})
{ “n” : 1, “ok” : 1 }
更多关于 count
的知识可查阅官方文档
Count。
group
group
的作用是按指定的键对集合中的文档进行分组,并执行简单的聚合函数,它与 SQL 中的 SELECT ... GROUP BY
类似。其语法格式如下:
{
group:
{
ns:
key:
$reduce:
$keyf:
cond:
finalize:
}
}
group
支持的指令及对应描述如下:
指令 | 类型 | 描述 |
---|---|---|
ns |
string | 通过操作执行组的集合,必填。 |
key |
ducoment | 要分组的字段或字段,必填。 |
$reduce |
function | 在分组操作期间对文档进行聚合操作的函数。 |
该函数有两个参数:当前文档和该组的聚合结果文档。 | ||
必填。 | ||
initial |
document | 初始化聚合结果文档, 必填。 |
$keyf |
function | 替代 key 。指定用于创建“密钥对象”以用作分组密钥的函数。 |
使用$keyf 而不是 key 按计算字段而不是现有文档字段进行分组。 |
||
cond |
document | 用于确定要处理的集合中的哪些文档的选择标准。 |
如果省略,group 会处理集合中的所有文档。 |
||
finalize |
function | 在返回结果之前运行,此函数可以修改结果文档。 |
准备以下数据:
> db.sales.insertMany([
{_id: 1, orderDate: ISODate(“2012-07-01T04:00:00Z”), shipDate: ISODate(“2012-07-02T09:00:00Z”), attr: {name: “新款椰子鞋”, price: 2999, size: 42, color: “香槟金”}},
{_id: 2, orderDate: ISODate(“2012-07-03T05:20:00Z”), shipDate: ISODate(“2012-07-04T09:00:00Z”), attr: {name: “高邦篮球鞋”, price: 1999, size: 43, color: “狮王棕”}},
{_id: 3, orderDate: ISODate(“2012-07-03T05:20:10Z”), shipDate: ISODate(“2012-07-04T09:00:00Z”), attr: {name: “新款椰子鞋”, price: 2999, size: 42, color: “香槟金”}},
{_id: 4, orderDate: ISODate(“2012-07-05T15:11:33Z”), shipDate: ISODate(“2012-07-06T09:00:00Z”), attr: {name: “极速跑鞋”, price: 500, size: 43, color: “西湖蓝”}},
{_id: 5, orderDate: ISODate(“2012-07-05T20:22:09Z”), shipDate: ISODate(“2012-07-06T09:00:00Z”), attr: {name: “新款椰子鞋”, price: 2999, size: 42, color: “香槟金”}},
{_id: 6, orderDate: ISODate(“2012-07-05T22:35:20Z”), shipDate: ISODate(“2012-07-06T09:00:00Z”), attr: {name: “透气网跑”, price: 399, size: 38, color: “玫瑰红”}}
])
假设要将集合 sales
中的文档按照 attr.name
进行分组,并限定参与分组的文档的 shipDate
大于指定时间。对应示例如下:
> db.runCommand({
group:{
ns: ‘sales’,
key: {“attr.name”: 1},
cond: {shipDate: {$gt: ISODate(‘2012-07-04T00:00:00Z’)}},
$reduce: function(curr, result){},
initial: {}
}
})
命令执行后,会返回一个结果档。其中, retval
包含指定字段 attr.name
的数据,count
为参与分组的文档数量,keys
代表组的数量,ok
代表文档状态。结果文档如下:
{
“retval” : [
{
“attr.name” : “高邦篮球鞋”
},
{
“attr.name” : “新款椰子鞋”
},
{
“attr.name” : “极速跑鞋”
},
{
“attr.name” : “透气网跑”
}
],
“count” : NumberLong(5),
“keys” : NumberLong(4),
“ok” : 1
}
上方示例指定的 key
是 attr.name
。由于参与分组的 5 个文档中只有 2 个文档的 attr.name
是相同的,所以分组结果中的keys
为 4
,这代表集合 sales
中的文档被分成了 4 组。
将 attr.name
换成 shipDate
,看看结果会是什么。对应示例如下:
> db.runCommand(
{
group:{
ns: ‘sales’,
key: {shipDate: 1},
cond: {shipDate: {$gt: ISODate(‘2012-07-04T00:00:00Z’)}},
$reduce: function(curr, result){},
initial: {}
}
}
)
命令执行后,返回如下结果:
{
“retval” : [
{
“shipDate” : ISODate(“2012-07-04T09:00:00Z”)
},
{
“shipDate” : ISODate(“2012-07-06T09:00:00Z”)
}
],
“count” : NumberLong(5),
“keys” : NumberLong(2),
“ok” : 1
}
由于参与分组的 5 个文档中有几个文档的 shipDate
是重复的,所以分组结果中的 keys
为 2
,这代表集合 sales
中的文档被分成了 2 组。
上面的示例并没有用到 reduce
、 initial
和 finallize
,接下来我们将演示它们的用法和作用。假设要统计同组的销售总额,那么可以在 reduce
中执行具体的计算逻辑。对应示例如下:
> db.runCommand(
{
group:{
ns: ‘sales’,
key: {shipDate: 1},
cond: {shipDate: {$gt: ISODate(‘2012-07-04T00:00:00Z’)}},
$reduce: function(curr, result){
result.total += curr.attr.price;
},
initial: {total: 0}
}
}
)
命令执行后,返回结果如下:
{
“retval” : [
{
“shipDate” : ISODate(“2012-07-04T09:00:00Z”),
“total” : 4998
},
{
“shipDate” : ISODate(“2012-07-06T09:00:00Z”),
“total” : 3898
}
],
“count” : NumberLong(5),
“keys” : NumberLong(2),
“ok” : 1
}
人工验证一下,发货日期 shipDate
大于 2012-07-04T09:00:00Z
的文档为:
{ “_id” : 2, “orderDate” : ISODate(“2012-07-03T05:20:00Z”), “shipDate” : ISODate(“2012-07-04T09:00:00Z”), “attr” : { “name” : “高邦篮球鞋”, “price” : 1999, “size” : 43, “color” : “狮王棕” } }
{ “_id” : 3, “orderDate” : ISODate(“2012-07-03T05:20:10Z”), “shipDate” : ISODate(“2012-07-04T09:00:00Z”), “attr” : { “name” : “新款椰子鞋”, “price” : 2999, “size” : 42, “color” : “香槟金” } }
销售总额为 1999 + 2999 = 4998
,与返回结果相同。发货日期 shipDate
大于 2012-07-06T09:00:00Z
的文档为:
{ “_id” : 4, “orderDate” : ISODate(“2012-07-05T15:11:33Z”), “shipDate” : ISODate(“2012-07-06T09:00:00Z”), “attr” : { “name” : “极速跑鞋”, “price” : 500, “size” : 43, “color” : “西湖蓝” } }
{ “_id” : 5, “orderDate” : ISODate(“2012-07-05T20:22:09Z”), “shipDate” : ISODate(“2012-07-06T09:00:00Z”), “attr” : { “name” : “新款椰子鞋”, “price” : 2999, “size” : 42, “color” : “香槟金” } }
{ “_id” : 6, “orderDate” : ISODate(“2012-07-05T22:35:20Z”), “shipDate” : ISODate(“2012-07-06T09:00:00Z”), “attr” : { “name” : “透气网跑”, “price” : 399, “size” : 38, “color” : “玫瑰红” } }
销售总额为 500 + 2999 + 399 = 3898
,与返回结果相同。
有时候可能需要统计每个组的文档数量以及计算平均销售额,对应示例如下:
> db.runCommand(
{
group:{
ns: ‘sales’,
key: {shipDate: 1},
cond: {shipDate: {$gt: ISODate(‘2012-07-04T00:00:00Z’)}},
$reduce: function(curr, result){
result.total += curr.attr.price;
result.count ++;
},
initial: {total: 0, count: 0},
finalize: function(result){
result.avg = Math.round(result.total / result.count);
}
}
}
)
上面的示例中改动了 $reduce
函数,目的是为了统计 count
。然后新增了finalize
,目的是计算分组中的平均销售额。命令执行后,返回以下文档:
{
“retval” : [
{
“shipDate” : ISODate(“2012-07-04T09:00:00Z”),
“total” : 4998,
“count” : 2,
“avg” : 2499
},
{
“shipDate” : ISODate(“2012-07-06T09:00:00Z”),
“total” : 3898,
“count” : 3,
“avg” : 1299
}
],
“count” : NumberLong(5),
“keys” : NumberLong(2),
“ok” : 1
}
以上就是 group
的基本用法和作用介绍,更多与 group
相关的知识可查阅官方文档
group。
distinct
distinct
的作用是查找单个集合中指定字段的不同值,其语法格式如下:
{
distinct: “
key: “
query:
readConcern:
collation:
}
distinct
支持的指令及对应描述如下:
指令 | 类型 | 描述 |
---|---|---|
distinct |
string | 集合名称, 必填。 |
key |
string | 指定的字段, 必填。 |
query |
document | 查询条件语句。 |
readConcern |
document | |
collation |
document |
准备以下数据:
> db.dress.insertMany([
… {_id: 1, “dept”: “A”, attr: {“款式”: “立领”, color: “red” }, sizes: [“S”, “M” ]},
… {_id: 2, “dept”: “A”, attr: {“款式”: “圆领”, color: “blue” }, sizes: [“M”, “L” ]},
… {_id: 3, “dept”: “B”, attr: {“款式”: “圆领”, color: “blue” }, sizes: “S” },
… {_id: 4, “dept”: “A”, attr: {“款式”: “V领”, color: “black” }, sizes: [“S” ] }
])
假设要统计集合 dress
中所有文档的 dept
字段的不同值,对应示例如下:
> db.runCommand ( { distinct: “dress”, key: “dept” } )
{ “values” : [ “A”, “B” ], “ok” : 1 }
或者看看有那些款式,对应示例如下
> db.runCommand ( { distinct: “dress”, key: “attr.款式” } )
{ “values” : [ “立领”, “圆领”, “V领” ], “ok” : 1 }
就算值是数组, distinct
也能作出正确处理,对应示例如下:
> db.runCommand ( { distinct: “dress”, key: “sizes” } )
{ “values” : [ “M”, “S”, “L” ], “ok” : 1 }
流式聚合操作小结
以上就是本篇对 MongoDB
中流式聚合操作的介绍。聚合与管道的概念并不常见,但是理解起来也不难。只要跟着示例思考,并动手实践,相信你很快就能够熟练掌握聚合操作。
基础篇 三 执行计划与索引
在前面的几篇中,我们学习了 MongoDB 常用的文档 CURD 操作,并了解了流式聚合的相关知识。要注意的是,如果查询语句使用不当,会降低 MongoDB
的检索效率。反之,如果查询语句设计得当,就能够有效提升检索效率。那么我们如何确定什么语句是“得当”,什么语句又“不得当”呢?
我们将在本篇了解查询语句的优劣,学习如何查看查询语句的执行计划,并学习索引相关的知识。这些知识能避免我们写出“不得当”的查询语句,设计出合理的查询方案。
执行计划
执行计划是对一次查询在数据库中的执行过程或访问路径的描述。我们可以通过这个描述来判断本次查询的效率,并根据实际情况进行调整,进而提升检索效率。
MongoDB 提供了几种方法用于返回执行计划和执行计划统计信息,它们是:
db.collection.explain()
方法;cursor.explain()
方法;explain
命令;
本篇我们讨论的是 cursor.explain()
方法,以下简称 explain()
,其语法如下:
db.collection.find().explain(
其中,<verbose>
参数代表执行计划的输出模式,该模式将会影响 explain()
的行为以及返回的信息量。<verbose>
的可选参数为:queryPlanner
、executionStats
和 allPlansExecution
,它们的作用如下:
模式名称 | 描述 |
---|---|
queryPlanner |
执行计划的详细信息,包括查询计划、集合信息、查询条件、最佳执行计划、查询方式和 MongoDB 服务信息等。 |
executionStats |
最佳执行计划的执行情况和被拒绝的计划等信息。 |
allPlansExecution |
选择并执行最佳执行计划,并返回最佳执行计划和其他执行计划的执行情况。 |
每个模式返回的信息均不相同,queryPlanner
模式返回的信息格式如下:
“queryPlanner” : {
“plannerVersion” :
“namespace” :
“indexFilterSet” :
“parsedQuery” : {
…
},
“winningPlan” : {
“stage” :
…
“inputStage” : {
“stage” :
…
“inputStage” : {
…
}
}
},
“rejectedPlans” : [
<candidate plan 1>,
…
]
},
“serverInfo” : {
“host” :
“port” :
“version” :
“gitVersion” :
}
stage
代表查询方式,各查询方式含义如下例如:
COLLSCAN
全文检索;IXSCAN
按索引检索;FETCH
检索文档;SHARD_MERGE
合并分片的结果;SHARDING_FILTER
从分片中过滤掉孤立文档;
我们注意到,queryPlanner
模式的返回信息中包含了很多字段,例如 plannerVersion
、namespace
、winningPlan
、rejetedPlans
和 serverInfo
等。字段及对应的描述如下:
字段名称 | 描述 |
---|---|
plannerVersion | 执行计划的版本 |
namespace | 要查询的集合 |
indexFilterSet | 是否使用索引 |
parsedQuery | 查询条件,此处为x=1 |
winningPlan | 最佳执行计划 |
stage | 查询方式 |
filter | 过滤条件 |
direction | 搜索方向 |
rejectedPlans | 拒绝的执行计划 |
serverInfo | MongoDB服务器信息 |
executionStats
模式返回的信息格式如下:
“queryPlanner” : {
“plannerVersion” :
“parsedQuery” : {
…
},
“winningPlan” : {
“stage” :
…
},
“rejectedPlans” : []
},
“executionStats” : {
“executionSuccess” :
“nReturned” :
“executionTimeMillis” :
“totalKeysExamined” :
“totalDocsExamined” :
“executionStages” : {
“stage” :
“nReturned” :
“executionTimeMillisEstimate” :
“works” :
“advanced” :
“needTime” :
“needYield” :
“saveState” :
“restoreState” :
“isEOF” :
…
}
},
“serverInfo” : {
“host” :
“port” :
“version” :
“gitVersion” :
}
executionStats
模式的返回信息中包含了 queryPlanner
模式的所有字段,并且还包含了最佳执行计划的执行情况,涉及的字段如executionSuccess
、 totalDocsExamined
、advanced
和 works
等。字段具体描述如下:
字段名称 | 描述 |
---|---|
executionSuccess | 是否执行成功 |
nReturned | 返回的结果 |
executionTimeMillis | 查询计划的选择和执行所耗费的时间 |
totalKeysExamined | 索引扫描次数 |
totalDocsExamined | 文档扫描次数 |
executionStages | 这个分类下描述执行的状态 |
isEOF | 是否到达 steam 结尾,1 或者 true 代表已到达结尾 |
executionTimeMillisEstimate | 预估耗时 |
works | 工作单元数,一个查询会分解成小的工作单元 |
advanced | 优先返回的结果数 |
docsExamined | 文档检查数 |
allPlansExecution
模式返回的信息包含 executionStats
模式的内容,且包含 "allPlansExecution"
: [ ]
块,也就是最佳执行计划和被拒绝计划的部分执行信息。"allPlansExecution" : [ ]
块信息格式如下:
“allPlansExecution” : [
{
“nReturned” :
“executionTimeMillisEstimate” :
“totalKeysExamined” :
“totalDocsExamined” :
“executionStages” : {
“stage” :
“nReturned” :
“executionTimeMillisEstimate” :
…
}
}
},
…
]
在了解了 explain
的语法和三种模式后,我们就可以开始实际练习了。假设要了解查询集合 inven
的查询语句db.inven.find({number: {$gt: 6}})
的查询计划,对应示例如下:
> db.inven.find({number: {$gt: 6}}).explain()
{
“queryPlanner” : {
“plannerVersion” : 1,
“namespace” : “test.inven”,
“indexFilterSet” : false,
“parsedQuery” : {
“number” : {
“$gt” : 6
}
},
“winningPlan” : {
“stage” : “COLLSCAN”,
“filter” : {
“number” : {
“$gt” : 6
}
},
“direction” : “forward”
},
“rejectedPlans” : [ ]
},
“serverInfo” : {
“host” : “asyncdeMBP”,
“port” : 27017,
“version” : “4.0.10”,
“gitVersion” : “c389e7f69f637f7a1ac3cc9fae843b635f20b766”
},
“ok” : 1
}
以上就是本次操作的返回信息。要注意的是,在未传入正确参数的情况下,默认模式为 queryPlanner
。从返回信息中,我们得知:
- 本次查询的集合为
test.inven
; - 最佳执行计划的
stage
为COLLSCAN
; - 查询时所用的过滤条件为
number: {$gt: 6}
,即球衣号大于 6; - 没有被拒绝的执行计划;
- MongoDB 版本为
4.0.10
,端口号为27017
。
假如我们将查询模式改为 executionStats
,那么我们将会得到如下信息:
> db.inven.find({number: {$gt: 6}}).explain(“executionStats”)
{
“queryPlanner” : {
“plannerVersion” : 1,
“namespace” : “test.inven”,
“indexFilterSet” : false,
“parsedQuery” : {
“number” : {
“$gt” : 6
}
},
“winningPlan” : {
“stage” : “COLLSCAN”,
“filter” : {
“number” : {
“$gt” : 6
}
},
“direction” : “forward”
},
“rejectedPlans” : [ ]
},
“executionStats” : {
“executionSuccess” : true,
“nReturned” : 3,
“executionTimeMillis” : 0,
“totalKeysExamined” : 0,
“totalDocsExamined” : 5,
“executionStages” : {
“stage” : “COLLSCAN”,
“filter” : {
“number” : {
“$gt” : 6
}
},
“nReturned” : 3,
“executionTimeMillisEstimate” : 0,
“works” : 7,
“advanced” : 3,
“needTime” : 3,
“needYield” : 0,
“saveState” : 0,
“restoreState” : 0,
“isEOF” : 1,
“invalidates” : 0,
“direction” : “forward”,
“docsExamined” : 5
}
},
“serverInfo” : {
“host” : “asyncdeMBP”,
“port” : 27017,
“version” : “4.0.10”,
“gitVersion” : “c389e7f69f637f7a1ac3cc9fae843b635f20b766”
},
“ok” : 1
}
由于 executionStats
模式的返回信息中包含了 queryPlanner
模式的返回内容,所以我们可以得到与执行默认模式相同的结果。除此之外,我们还可以看到最佳执行计划的详细情况:
- 此次查询共遍历
5
份文档; - 只有
3
份文档符合过滤要求; - 本次查询操作的执行时间小于
1
毫秒; - 本次查询操作已遍历整个集合,没有被
limit
等语句限制。
假如我们在命令中使用了 limit
,例如 db.inven.find({number: {$gt:
6}}).limit(2).explain("executionStats")
,那么返回信息中就会包含 limitAmount
字段,并且nReturned
字段和 advanced
字段对应的值也会发生相应变化。
索引
索引支持 MongoDB 中查询的高效执行。如果没有索引,MongoDB
必须执行全文检索,即扫描集合中的每个文档,以选择与查询语句匹配的文档。如果查询存在适当的索引,MongoDB 可以使用索引来限制它必须检查的文档数。
MongoDB 的索引是特殊的数据结构,这种结构叫做
B-tree,它以易于遍历的形式存储集合数据集的一小部分。索引存储特定字段或字段集的值,按字段的值排序。索引条目的排序支持有效的等式匹配和基于范围的查询操作。此外,MongoDB
可以使用索引中的顺序返回排序结果。
下图描述了使用索引选择和排序匹配文档的查询过程:
上图表示索引建立在集合 user
的 score
字段上,索引中记录着包含 score
字段的文档的位置。当发起查询操作时,MongoDB
会先从索引中检索,快速定位包含 score
字段的文档的位置,而不是扫描整个集合。
MongoDB 中的索引与其他数据库系统中的索引类似,它允许在集合级别定义索引,并支持文档的任何字段。
MongoDB 提供了非常多的索引类型来支持特定类型的数据和查询,例如单字段索引、复合索引、多键索引、文字索引、2d
索引、散列索引和稀疏索引等。接下来,我们将学习常用的单字段索引、复合索引和多键索引。
单字段索引
创建索引的语法格式如下:
db.collection.createIndex(
假设要为集合 inven
的 number
字段创建单字段索引,对应示例如下:
> db.inven.createIndex({number: 1})
{
“createdCollectionAutomatically” : false,
“numIndexesBefore” : 1,
“numIndexesAfter” : 2,
“ok” : 1
}
结果文档中的 numIndexesBefore
表示本次索引创建前的索引数量,而 numIndexesAfter
代表本次索引创建后的索引数量。在本次操作之前,我们从未为集合 inven
创建过索引,那么 numIndexesBefore
的值为什么是 1
呢?实际上 mongoDB 为每个集合创建了默认的索引,默认索引的字段为 _id
,所以本次操作后,numIndexesAfter
的值为 2
。
我们可以通过对比索引建立前后的执行计划来了解索引对查询效率的影响,查看执行计划的命令如下:
> db.inven.find({number: {$gt: 6}}).explain()
{
“queryPlanner” : {
“plannerVersion” : 1,
“namespace” : “test.inven”,
“indexFilterSet” : false,
“parsedQuery” : {
“number” : {
“$gt” : 6
}
},
“winningPlan” : {
“stage” : “FETCH”,
“inputStage” : {
“stage” : “IXSCAN”,
“keyPattern” : {
“number” : 1
},
“indexName” : “number_1”,
“isMultiKey” : false,
“multiKeyPaths” : {
“number” : [ ]
},
“isUnique” : false,
“isSparse” : false,
“isPartial” : false,
“indexVersion” : 2,
“direction” : “forward”,
“indexBounds” : {
“number” : [
“(6.0, inf.0]”
]
}
}
},
“rejectedPlans” : [ ]
},
“serverInfo” : {
“host” : “asyncdeMacBook-Pro.local”,
“port” : 27017,
“version” : “4.0.10”,
“gitVersion” : “c389e7f69f637f7a1ac3cc9fae843b635f20b766”
},
“ok” : 1
}
将本次执行计划与索引创建前的执行计划进行对比,可以发现 stage
发生了变化:
- 建立索引前:
stage
为COLLSCAN
,即扫描整个集合。 - 建立索引后:
stage
为IXSCAN
,即按索引检索文档。
相对于整个扫描整个集合来说,按索引检索文档的速度显然更快。
除了为文档指定的字段创建索引之外,我们还可以为内嵌文档的字段建立索引。假设文档结构如下:
{
“_id”: 1,
“score”: 1034,
“location”: { state: “NY”, city: “New York” }
}
为内嵌文档中的 location
字段创建索引的示例如下:
> db.collection.createIndex( { location: 1 } )
为内嵌文档 location
中的 state
字段创建索引的示例如下:
> db.collection.createIndex( { “location.state”: 1 } )
要注意的是,MongoDB 可以从任一方向遍历索引,所以对于单字段索引来说,索引键的排序顺序并不重要。
复合索引
单字段索引并不能满足所有需求,有时候我们需要为文档建立更多的索引,多个字段的组合索引在 MongoDB 中称为复合索引。创建复合索引的语法格式如下:
db.collection.createIndex( {
<type>
为 1
代表升序,-1
代表降序。索引键的排序顺序(即升序或降序)在复合索引中是非常重要的,它可以确定索引是否支持排序操作。准备以下数据:
> db.indx.insertMany([
… {_id: 1, name: “James”, number: 6, h: 203, w: 222},
… {_id: 2, name: “Wade”, number: 3, h: 193, w: 220},
… {_id: 3, name: “Kobe”, number: 24, h: 198, w: 212},
… {_id: 4, name: “Yao”, number: 11, h: 226, w: 308},
… {_id: 5, name: “Jd”, number: 23, h: 198, w: 216}
… ])
然后为身高和体重这两个字段创建索引。其中,身高索引为升序,体重索引为降序。对应示例如下:
> db.indx.createIndex({h: 1, w: -1})
{
“createdCollectionAutomatically” : false,
“numIndexesBefore” : 1,
“numIndexesAfter” : 2,
“ok” : 1
}
索引创建成功后,我们来做一个实验:我们希望查询结果先按升高进行升序排序,然后按体重进行降序排序。对应示例如下:
> db.indx.find().sort({h: 1, w: -1})
{ “_id” : 2, “name” : “Wade”, “number” : 3, “h” : 193, “w” : 220 }
{ “_id” : 5, “name” : “Jd”, “number” : 23, “h” : 198, “w” : 216 }
{ “_id” : 3, “name” : “Kobe”, “number” : 24, “h” : 198, “w” : 212 }
{ “_id” : 1, “name” : “James”, “number” : 6, “h” : 203, “w” : 222 }
{ “_id” : 4, “name” : “Yao”, “number” : 11, “h” : 226, “w” : 308 }
可以看到,身高同为 198
的两个球员会按体重进行降序排序,所以球员 jd
排在球员 kobe
之前。假如我们希望升高和体重都按升序排序呢?对应示例如下:
> db.indx.find().sort({h: 1, w: 1})
{ “_id” : 2, “name” : “Wade”, “number” : 3, “h” : 193, “w” : 220 }
{ “_id” : 3, “name” : “Kobe”, “number” : 24, “h” : 198, “w” : 212 }
{ “_id” : 5, “name” : “Jd”, “number” : 23, “h” : 198, “w” : 216 }
{ “_id” : 1, “name” : “James”, “number” : 6, “h” : 203, “w” : 222 }
{ “_id” : 4, “name” : “Yao”, “number” : 11, “h” : 226, “w” : 308 }
身高同为 198
的两个球员按体重进行升序排序 ,返回文档我们预想的结果相同,这有什么问题吗?由于 sort()
支持这种排序规则,所以从结果上来看是没有问题的。实际上这两种排序方式的执行计划是不同的,我们可以使用 explain()
查看它们的执行计划:
{h: 1, w: -1}
的执行计划显示stage
为IXSCAN
;{h: 1, w: 1}
的执行计划显示stage
为COLLSCAN
;
也就是说,如果索引不支持查询时所用的排序规则,那么索引将不会发挥作用。反之,如果索引支持查询时所用的排序规则,那么索引将会发挥作用。
索引前缀
索引前缀(也有人称前缀索引)是索引字段的起始子集。假设有以下复合索引:
{ “number”: 1, “h”: 1, “w”: 1 }
该复合索引的索引前缀为:
{ number: 1 }
{ number: 1, h: 1 }
MongoDB 允许我们在以下字段中使用索引进行查询:
number
字段number
字段和h
字段number
字段、h
字段和w
字段
这是因为查询时使用 number
或 number & h
组合作为索引前缀。如果使用其他索引前缀, 那就无法使用索引。其他前缀组合如下:
h
字段w
字段h
字段和w
字段
也就是说,当使用类似 db.indx.find({h: 1, w: 1})
这样的查询语句时,stage
的值为COLLSCAN
,即索引不起作用。而使用类似 db.indx.find({h: 1, w: 1, number: 1})
这样的语句时,stage
的值为 IXSCAN
,即索引起作用。
多键索引
在创建索引时,如果设定的字段值是数组,那么 MongoDB 就会为数组中的每一个元素创建索引键。多键索引的创建是完全自动的,不需要显式指定。准备以下数据:
> db.mindx.insertMany([
{ _id: 5, type: “food”, item: “aaa”, ratings: [ 5, 8, 9 ] },
{ _id: 6, type: “food”, item: “bbb”, ratings: [ 5, 9 ] },
{ _id: 7, type: “food”, item: “ccc”, ratings: [ 9, 5, 8 ] },
{ _id: 8, type: “food”, item: “ddd”, ratings: [ 9, 5 ] },
{ _id: 9, type: “food”, item: “eee”, ratings: [ 5, 9, 5 ] }
])
为 ratings
字段创建索引的示例如下:
> db.mindx.createIndex( { ratings: 1 } )
{
“createdCollectionAutomatically” : false,
“numIndexesBefore” : 1,
“numIndexesAfter” : 2,
“ok” : 1
}
由于 ratings
字段的值是数组,所以这个索引并不是单字段索引,而是多键索引。当我们执行 db.mindx.find( { ratings: [
5, 9 ] } )
这样的命令时,索引会发挥作用,执行计划中stage
的值为 IXSCAN
。要注意的是,MongoDB 不允许创建超过 1
个数组的复合多键索引。假设有如下文档结构:
{ _id: 1, a: [ 1, 2 ], b: [ 1, 2 ], category: “AB - both arrays” }
当我们试图创建类似 {a: 1, b: 1}
这样的复合多键索引时,会得到错误提示:
{
“ok” : 0,
“errmsg” : “cannot index parallel arrays [b] [a]”,
“code” : 171,
“codeName” : “CannotIndexParallelArrays”
}
如果文档结构为:
{ _id: 1, a: 99, b: [ 1, 2 ], category: “A” }
{ _id: 2, a: [3, 5], b: 77, category: “B” }
那么当我们试图创建类似 {a: 1, b: 1}
或 {a: -1, b: -1}
这样的复合多键索引时,命令将得到正确的执行。
索引综合知识
下面将介绍一些与索引相关的技巧或知识,包括索引的查看、创建删除和对数据库的影响等。
如何查看已有的索引
我们可以使用 db.collection.getIndexes()
方法查询集合中已有的索引。假设要查询集合 indx
已有的索引,对应示例如下:
> db.indx.getIndexes()
[
{
“v” : 2,
“key” : {
“id” : 1
},
“name” : “_id“,
“ns” : “test.indx”
},
{
“v” : 2,
“key” : {
“number” : 1,
“h” : 1,
“w” : 1
},
“name” : “number_1_h_1_w_1”,
“ns” : “test.indx”
}
]
结果文档中的 name
代表索引名称,key
代表索引键,ns
代表集合名称。
索引的命名
默认情况下,索引的名称是字段与排序的组合词,例如复合多键索引 {item: 1, ratings: -1}
的索引名称为item_1_ratings_-1
。当然,我们也可以在创建索引的时候指定索引名称,对应示例如下:
> db.mindx.createIndex( {item: 1, ratings: -1 }, {name: “irats”} )
{
“createdCollectionAutomatically” : false,
“numIndexesBefore” : 1,
“numIndexesAfter” : 2,
“ok” : 1
}
命令执行后,集合 mindx
会多出一个名为 irats
的复合多键索引。
如何删除索引
删除索引的方法为 db.collection.dropIndex()
,我们可以选择删除多个索引或删除指定的索引。假如我们将索引名称传递给dropindex()
,那么就可以删除这个索引。假如我们没有传递任何参数,那么将会删除对应集合中的所有索引。假设要删除集合 indx
中名为number_1_h_1_w_1
的索引。对应示例如下:
> db.indx.dropIndex(“number_1_h_1_w_1”)
{ “nIndexesWas” : 2, “ok” : 1 }
而如果要删除集合 mindx
中的所有索引,我们只需要执行如下命令即可:
> db.mindx.dropIndexes()
{
“nIndexesWas” : 2,
“msg” : “non-_id indexes dropped for collection”,
“ok” : 1
}
要注意的是,默认索引 _id
不会被删除。
索引的创建与数据库性能
索引是个好东西,但不能盲目地为所有字段都建立索引,这是因为索引的创建和维护也是有一定的代价。
索引的创建
在索引构建完成之前,集合所属的数据库将不可执行读或写操作。所以,如果需要构建大型索引,请考虑使用后台创建模式,即backound
。后台创建模式的语法很简单,只需要在创建索引时加上 { background: true }
即可。例如为集合 mindx
中的item
字段创建索引,并希望在索引创建过程中避免对数据库读写操作的影响,对应的示例如下:
> db.mindx.createIndex( { item: 1 }, {background: true} )
{
“createdCollectionAutomatically” : false,
“numIndexesBefore” : 2,
“numIndexesAfter” : 3,
“ok” : 1
}
这种情况下,索引的创建将会在后台进行,不会影响数据库读写操作。但因此付出的代价是在创建索引过程中阻塞当前的 Mongoshell 会话,直到索引创建完成。当
Mongoshell 阻塞时,我们想要向 MongoDB
发出命令就必须开启另一个连接。后台创建索引所要花费的时间会比在前台创建的要长,如果数据量较大或操作频繁,可以将索引的创建时间放在“夜深人静”的时候。
这种长时间的索引构建让人焦虑,人们希望能够看到索引的创建状态或终止创建。索引创建状态的查看可以使用 db.currentOp()
命令,终止创建的命令为db.killOp()
。
索引的维护
MongoDB 会自动维护创建了索引的字段。当我们在集合中添加或删除文档时,就会触发 MongoDB
的索引维护。如果集合中的文档数量较多,且建立了索引的字段也较多,那么索引维护也将会成为数据库的一笔开销。
索引小结
以上就是本篇对执行计划和索引的介绍,现在我们用一句话概括:执行计划用于判断查询效率,如果对查询效率不满意,可以考虑为字段建立索引。
进阶篇 一 数据模型
MongoDB 不强制执行文档结构,也就是说我们不需要在数据插入前定义文档的结构,也不要求集合中的文档具有相同的结构。要知道,在 MySQL
这样的关系型数据库中,我们必须在插入数据前定义数据模型(即创建表和定义字段),否则无法向将数据写入到数据库中。MongoDB
的文档结构非常灵活,具体表现在以下方面:
- 集合中的文档可以使用不同的字段;
- 文档的字段类型可以不同;
- 假如要更改文档的结构(例如删除字段、添加字段或更新字段值的类型),只需要执行更新操作即可。
文档结构
为 MongoDB
应用程序设计数据模型的关键决策围绕文档结构以及应用程序如何表示数据之间的关系。MongoDB允许将相关数据嵌入到单个文档中。MongoDB
中有两种文档结构:嵌入式和规范化。
嵌入式结构
MongoDB
允许将文档嵌入到字段或数组中,包含嵌入文档的文档结构叫做嵌入式结构。嵌入式结构通过在单个文档中存储相关数据来描述数据之间的关系或将数据关联在一起。嵌入式结构的文档如下:
{
_id: 1,
username: “123xyz”,
contact: {
phone: “123-456-789”,
email: “xyz@example.com“
},
access: {
level: 5,
group: “dev”
}
}
由于 contact
字段 和 access
字段的值是文档,所以这两个字段对应的值被称为嵌入式文档(又称内嵌文档),_id
为 1
的文档则是它们的外层文档。我们可以使用点符号访问嵌入式文档中的字段,例如使用 contact.phone
这种方式访问 contact
中的phone
字段。
规范化结构
规范化结构通过引用的方式来描述数据之间的关系或将数据关联在一起。这与 SQL 数据库中的 Foreign key
概念相似。规范化结构关联文档的方式如下图所示:
上图示例中,集合 contact
与集合 access
中的 user_id
字段均引用自集合 user
中的 _id
字段。规范化结构的存在使得我们可以描述更复杂的多对多关系。
文档结构与模型关系
嵌入式结构和规范化结构的适用场景不同,我们在设计数据模型时应该考虑哪些因素呢?下面将通过几个示例来了解这些场景。
嵌入式结构很适合有包含关系的情况,以下示例用规范化结构来表示客户 James
与其收货地址的关系:
{
_id: “1”,
user_id: 30,
name: “James”
}
{
user_id: “30”,
address: “虹桥机场员工宿舍C区5楼”,
city: “上海”,
phone: “13005920000”,
zip: “200020”
}
记录收货地址信息的文档引用了用户信息文档中的用户 user_id
字段。如果经常检索收货地址信息,那么就会发出多个查询。更好的数据模型是将用户信息和收货地址信息放在同一个文档,例如:
{
_id: “1”,
user_id: 30,
name: “James”,
adres:
{
address: “虹桥机场员工宿舍C区5楼”,
city: “上海”,
phone: “13005920000”,
zip: “200020”
}
}
使用嵌入式结构后,只需要一次查询就可以得到完整的结果。用户通常会有多个收货地址,即数据模型要满足一对多的关系。以下示例用规范化结构来表示客户 James
与其多个收货地址的关系:
{
_id:
user_id: 30,
name: “James”
}
{
_id:
user_id:
address: “虹桥机场员工宿舍C区5楼”,
city: “上海”,
phone: “13005920000”,
zip: “200020”
}
{
_id:
user_id:
address: “白云机场员工宿舍A区6楼”,
city: “广州”,
phone: “13005920000”,
zip: “510080”
}
也可以使用嵌入式结构来表示一对多关系,示例如下:
{
_id: “1”,
user_id: 30,
name: “James”,
adres:[
{
address: “虹桥机场员工宿舍C区5楼”,
city: “上海”,
phone: “13005920000”,
zip: “200020”
},
{
address: “白云机场员工宿舍A区6楼”,
city: “广州”,
phone: “13005920000”,
zip: “510080”
}]
}
当然,用规范化结构来描述一对多关系也有好处。通常情况下,论坛用户会频繁地发布自己的观点(发帖或回帖),如果用嵌入式结构存储观点发布者和观点内容,那么就会造成发布者信息重复。对应示例如下:
{
title: “吉利星越大型试驾活动,发夹弯见!”,
publisher_id: 29019,
publisher: “车评人陈杨”,
pdate: ISODate(“2019-01-01 12:03:36”),
view: 32810,
tag: [“吉利”, “星越”, “试驾”]
}
{
title: “宝马排名飙升 评2019上半年豪华品牌销量”,
publisher_id: 29019,
publisher: “车评人陈杨”,
pdate: ISODate(“2019-01-01 12:03:50”),
view: 6020,
tag: [“宝马”, “豪华”, “奔驰”]
}
{
title: “试驾全新一代广汽传祺GA6”,
publisher_id: 29019,
publisher: “车评人陈杨”,
pdate: ISODate(“2019-01-01 12:03:58”),
view: 10751,
tag: [“试驾”, “广汽”, “传祺”]
}
用规范化结构存储论坛用户观点,就可以节省非常多的空间,对应示例如下:
{
_id:
username: “chenyang”,
nickname: “车评人陈杨”
}
{
_id:
title: “吉利星越大型试驾活动,发夹弯见!”,
user_id:
pdate: ISODate(“2019-01-01 12:03:36”),
view: 32810,
tag: [“吉利”, “星越”, “试驾”]
}
{
_id:
title: “宝马排名飙升 评2019上半年豪华品牌销量”,
user_id:
pdate: ISODate(“2019-01-01 12:03:50”),
view: 6020,
tag: [“宝马”, “豪华”, “奔驰”]
}
{
_id:
title: “试驾全新一代广汽传祺GA6”,
user_id:
pdate: ISODate(“2019-01-01 12:03:58”),
view: 10751,
tag: [“试驾”, “广汽”, “传祺”]
}
嵌入式结构的读取操作性能比规范化结构更高,但嵌入式结构会导致数据重复,而规范化结构则不会。除了一对多关系之外,规范化结构还能够描述更复杂的多对多关系。
树状数据模型
嵌入式结构和规范化结构并不能满足所有的场景,例如树状关系的对象。
如上图所示,树状关系的对象或实体是编程中常见的结构,下面我们就来学习树状结构的数据模型。
父引用树状数据模型
父引用树状数据模型通过在子节点中存储对父节点的引用来组织树状结构中的文档,对应示例如下:
> db.trees1.insertMany([
{ _id: “MongoDB”, parent: “Databases” },
{ _id: “dbm”, parent: “Databases” },
{ _id: “Databases”, parent: “Programming” },
{ _id: “Languages”, parent: “Programming” },
{ _id: “Programming”, parent: “Books” },
{ _id: “Books”, parent: null }
])
在这样的树状结构中,我们可以直接检索某个节点的父节点,例如检索 MongoDB
节点的父节点的示例如下:
> db.trees1.findOne({_id: “MongoDB”}).parent
Databases
也可以直接检索某个节点的子节点,例如检索 Databases
节点的子节点的示例如下:
> db.trees1.find({parent: “Databases”})
{ “_id” : “MongoDB”, “parent” : “Databases” }
{ “_id” : “dbm”, “parent” : “Databases” }
我们还可以为父节点字段创建索引,以提高查询效率。为集合 trees1
的父节点字段 parent
创建索引的示例如下:
> db.trees1.createIndex( { parent: 1 } )
子引用树状数据模型
既然父节点可以存储在模型中,那么也可以在父节点中存储子节点的引用来组织树状结构中的文档,对应示例如下:
> db.trees2.insertMany([
{ _id: “MongoDB”, children: []},
{ _id: “dbm”, children: []},
{ _id: “Databases”, children: [ “MongoDB”, “dbm” ]},
{ _id: “Languages”, children: []},
{ _id: “Programming”, children: [ “Databases”, “Languages”]},
{ _id: “Books”, children: [ “Programming” ]}
])
在这样的树状结构中,我们可以直接检索某个节点的子节点,例如检索 MongoDB
节点的子节点的示例如下:
> db.trees2.findOne({_id: “Databases”}).children
[ “MongoDB”, “dbm” ]
也可以直接检索某个节点的父节点,例如检索 Languages
节点的父节点。对应示例如下:
> db.trees2.find({children: “Languages”})
{ “_id” : “Programming”, “children” : [ “Databases”, “Languages” ] }
我们还可以为子节点字段创建索引,以提高查询效率。为集合 trees2
的子节点字段 children
创建索引的示例为:
> db.trees2.createIndex({children: 1})
祖先阵列树状数据模型
后来又扩展出存储所有父节点数组和上层父节点的祖先阵列树状数据模型,这种模型为快速定位指定节点的后代节点和祖先节点提供了有效的解决办法,对应如下:
> db.trees3.insertMany([
{_id: “MongoDB”, ancestors: [“Books”, “Programming”, “Databases”], parent: “Databases”},
{_id: “dbm”, ancestors: [“Books”, “Programming”, “Databases”], parent: “Databases”},
{_id: “Databases”, ancestors: [“Books”, “Programming”], parent: “Programming”},
{_id: “Languages”, ancestors: [“Books”, “Programming”], parent: “Programming”},
{_id: “Programming”, ancestors: [“Books”], parent: “Books”},
{_id: “Books”, ancestors: [ ], parent: null}
])
在这样的树状结构中,我们可以直接检索某个节点的祖先节点,例如检索 MongoDB
节点的祖先节点的示例如下:
> db.trees3.findOne({_id: “MongoDB”}).ancestors
[ “Books”, “Programming”, “Databases” ]
也可以直接检索某个节点的所有后代节点,例如检索 Languages
节点的所有后代节点的示例如下:
> db.trees3.find({ancestors: “Programming”})
{ “_id” : “MongoDB”, “ancestors” : [ “Books”, “Programming”, “Databases” ], “parent” : “Databases” }
{ “_id” : “dbm”, “ancestors” : [ “Books”, “Programming”, “Databases” ], “parent” : “Databases” }
{ “_id” : “Databases”, “ancestors” : [ “Books”, “Programming” ], “parent” : “Programming” }
{ “_id” : “Languages”, “ancestors” : [ “Books”, “Programming” ], “parent” : “Programming” }
我们还可以为祖先数组字段创建索引,以提高查询效率。为集合 trees3
的祖先数组字段 ancestors
创建索引的示例为:
> db.trees3.createIndex({ancestors: 1})
路径树状数据模型
路径树状数据模型将存储文档中的每个树节点,并以字符串的形式存储祖先节点或路径。这种数据模型在检索时需要使用正则表达式,同时也允许通过部分路径进行检索,对应示例如下:
> db.trees4.insertMany([
{_id: “Books”, path: null},
{_id: “Programming”, path: “,Books,”},
{_id: “Databases”, path: “,Books,Programming,”},
{_id: “Languages”, path: “,Books,Programming,”},
{_id: “MongoDB”, path: “,Books,Programming,Databases,”},
{_id: “dbm”, path: “,Books,Programming,Databases,”}
])
我们可以对这样的结构按照路径进行排序,对应示例如下:
> db.trees4.find().sort({ path: 1})
{ “_id” : “Books”, “path” : null }
{ “_id” : “Programming”, “path” : “,Books,” }
{ “_id” : “Databases”, “path” : “,Books,Programming,” }
{ “_id” : “Languages”, “path” : “,Books,Programming,” }
{ “_id” : “MongoDB”, “path” : “,Books,Programming,Databases,” }
{ “_id” : “dbm”, “path” : “,Books,Programming,Databases,” }
在这样的树状结构中,我们可以直接检索某个节点的所有后代节点,例如检索 Programming
节点所有后代节点的示例如下:
> db.trees4.find({path: /,Programming,/})
{ “_id” : “Databases”, “path” : “,Books,Programming,” }
{ “_id” : “Languages”, “path” : “,Books,Programming,” }
{ “_id” : “MongoDB”, “path” : “,Books,Programming,Databases,” }
{ “_id” : “dbm”, “path” : “,Books,Programming,Databases,” }
由于查询语句使用的是正则表达式,所以我们可以写出更灵活的查询语句。例如检索以 Books
节点开头的所有后代节点:
> db.trees4.find({path: /^,Books,/})
{ “_id” : “Programming”, “path” : “,Books,” }
{ “_id” : “Databases”, “path” : “,Books,Programming,” }
{ “_id” : “Languages”, “path” : “,Books,Programming,” }
{ “_id” : “MongoDB”, “path” : “,Books,Programming,Databases,” }
{ “_id” : “dbm”, “path” : “,Books,Programming,Databases,” }
我们还可以为路径字段创建索引,以提高查询效率。为集合 trees4
的路径字段 path
创建索引的示例为:
> db.trees4.createIndex({path: 1})
嵌套集树状数据模型
祖先阵列树状数据模型为快速定位指定节点的后代节点和祖先节点提供了有效的解决办法,而嵌套集树状数据模型的出现则是为快速定位指定节点的子树。嵌套集树状数据模型的节点结构如下图所示:
每个节点会被访问两次:首次访问时和返程时,访问规则为深度优先。嵌套集存储文档中的每个树节点和父节点的 _id
,并在 left
字段中记录首次访问时的序号、在 right
字段中记录返程时的序号(序号为递增数字,例如 1~$+\infty$)。
以下示例采用嵌套集树状数据模型进行建模:
> db.tree5.insertMany([
{_id: “Books”, parent: 0, left: 1, right: 12},
{_id: “Programming”, parent: “Books”, left: 2, right: 11},
{_id: “Languages”, parent: “Programming”, left: 3, right: 4},
{_id: “Databases”, parent: “Programming”, left: 5, right: 10},
{_id: “MongoDB”, parent: “Databases”, left: 6, right: 7},
{_id: “dbm”, parent: “Databases”, left: 8, right: 9}
])
从建模语句可以看出,left
字段值越小则节点的层级越高,right
字段的值越小则节点的层级越低。根据这个规律,我们可以快速定位某个节点的所有后代节点,例如检索 Databases
节点所有后代节点的示例如下:
> var databaseCategory = db.tree5.findOne( { _id: “Databases” } );
> db.tree5.find( { left: { $gt: databaseCategory.left }, right: { $lt: databaseCategory.right } } );
{ "_id" : "MongoDB", "parent" : "Databases", "left" : 6, "right" : 7 }
{ "_id" : "dbm", "parent" : "Databases", "left" : 8, "right" : 9 }
要注意的是,在修改嵌套集树状结构时要付出的代价高于其他树状结构,因此嵌套集树状数据模型适合用于结构极少改动的场景。
数据模型小结
虽然 MongoDB 不强制执行文档结构,但一个合适的数据模型可以有效地提高查询效率或减少数据冗余。
进阶篇 二 提高数据服务可用性的复制集
MongoDB
中的复制指的是将数据同步在多个服务器。复制操作将会在多个服务器上建立数据副本,这些副本的集合称为复制集,它们存储的内容与主服务器上的内容一致。建立了复制集之后,就可以在主服务器出现故障或无法连接的情况下保证数据服务可用。
复制集成员
MongoDB
的复制集可以支持多个节点,但要求至少有两个节点。任何情况下都只有一个主节点(即主服务器),它负责处理客户端发出的命令。除主节点之外的所有节点都称为从节点,它们会主动同步主节点中的数据。在
MongoDB 中,主节点称为 Primary,从节点称为 Secondarie。
主节点 Primary
主节点是复制集中唯一一个可以接收写操作的成员。MongoDB 在主节点上执行写操作,并将操作记录在主节点的 oplog
中,从节点会复制主节点的操作记录,然后执行相同操作以实现数据同步的目的。下图描述了由三个成员组成的复制集的成员关系:
这个复制集中有两个从节点和一个主节点。主节点接收客户端发起的写操作,从节点通过 oplog
实现数据同步。要注意的是,复制集中的所有从节点都可以接收读操作,但默认情况下读取操作依然是交给主节点。如果想要更改读取规则,可以查阅官方文档 [Read
Preference](https://docs.mongodb.com/manual/core/read-
preference/)。要注意的是,每个复制集最多可拥有 50 名成员。
从节点 Secondary
从节点中存储的是主节点的数据副本。从节点将主节点中的 oplog
操作应用于自身,从而实现数据同步。要注意的是,这个“数据同步”的操作是异步进行的。下图描述了由三个成员组成的复制集的数据同步关系:
子节点会同步主节点上的操作,以实现数据同步。另外,各节点之间通过心跳(Heartbeat)来判断是否可用,假如某个节点在 10 秒内没有相应其他节点发出的
Heartbeat,那么它将会被标记为“掉线”,意为不可用或不可访问。
复制的基石—操作日志
上面提到,从节点的数据同步操作其实是执行主节点中执行过的操作。所有从节点都会拷贝主节点上的
[local.oplog.rs
](https://docs.mongodb.com/manual/reference/local-
database/#local.oplog.rs) 文件,即 oplog。oplog 记录主节点中的改动操作,但不记录读取操作。oplog
是一个特殊的上限集合,它支持基于顺序插入和检索文档的高吞吐操作。上限集合的大小是固定的,在达到最大记录数之后,如果再有新的记录传入,它会覆盖掉最早的记录。
从 MongoDB4.0 开始,我们可以使用oplogSizeMB
](https://docs.mongodb.com/manual/reference/configuration-
options/#replication.oplogSizeMB) 在创建时设置 oplog 的大小,或者使用
[replSetResizeOplog
使其能够突破上限集合的限制。假设我们想要将 oplog 的大小设置为 16000
兆字节,对应命令如下:
db.adminCommand({replSetResizeOplog: 1, size: 16000})
当第一次启用复制集,且未指定 oplog 大小时,MongoDB 将会创建一个默认大小的 oplog。oplog
的大小与操作系统和存储引擎相关,以下默认大小规则适用于类 Unix 操作系统和 Windows 操作系统:
存储引擎 | 默认的 oplog 大小 | 下限 | 上限 |
---|---|---|---|
内存存储引擎 | 物理内存的 5% | 50 MB | 50 GB |
WiredTiger 存储引擎 | 可用磁盘空间的 5% | 990 MB | 50 GB |
MMAPv1 存储引擎 | 可用磁盘空间的 5% | 990 MB | 50 GB |
对于 64 位的 macOS,oplog 的大小是 192M 的物理内存或磁盘空间,上述三种存储引擎的默认值均相同。
主节点故障的应对机制
在主节点出现故障时,整个系统应该有一个应对机制。MongoDB 为复制集提供了主节点选举和数据回滚来确保数据服务可用和避免数据丢失。
主节点选举
当主节点被标记为“掉线”,那么就意味着复制集需要一个新的主节点,否则将会导致服务不可用。这种情况下,复制集通过选举的方式来确定哪个成员会成为主节点。除了被标记为“掉线”之外,会触发选举的情况还有以下几种:
- 复制集中添加了新的节点;
- 初始化复制集;
- 使用
rs.stepDown()
或者rs.reconfig()
等方法维护复制集。
下图描述了因为主节点“掉线”引起的选举:
这是一个由三个成员组成的复制集,主节点“掉线”后,就需要在两个从节点中选出一个作为新的主节点。要注意的是,在选举完成之前,复制集无法处理写操作。在选出新的主节点之后,复制集的功能就会恢复正常。选举采用投票制,每个复制集最多只能拥有
7 名投票成员,这 7 个成员最多拥有 1 票。下图描述了由 9 个成员组成的复制集选举票权:
绿色背景的节点表示节点可用,灰色背景表示节点“掉线”。votes
代表票权,对应的数值代表初始票数。如果投票成员数量为偶数,就有可能会造成多个节点的票数相同,甚至陷入无限选举的泥潭。为了避免这种情况,我们就需要增加一个仲裁(Arbiter)节点。仲裁节点拥有投票权,但它没有存储数据副本,也不能成为主节点。新增仲裁节点后,票数就会从偶数变成奇数。
默认情况下,从标记主节点“掉线”到选举出新的主节点的时间不会超过 12 秒,但 MongoDB 也提供了可修改的配置来调整该时间。
数据回滚
当主节点发生故障,并选举出新的主节点时,MongoDB
将会在之前的主节点上执行回滚写操作。当“掉线”的主节点重新连接时,它将会以从节点的身份加入到复制集中,并回滚写操作,以便与其他成员的数据保持一致。MongoDB
4.0 版本对数据回滚进行了一些调整:
- 回滚操作会在后台索引构建完成后进行;
- 不限制回滚的数据量;
- 回滚时间默认为 24 小时,且可以配置。
4.0 版本之前,回滚的最大数据量为 300 兆字节,超过上限的数据量需要进行手动干预;回滚时间默认为 30 分钟,且不可配置。
复制集部署实践
我们将介绍由三个成员组成的复制集部署过程,本次部署演示使用 MongoDB 官方的 Docker
镜像,并且不启用访问控制。如果想要了解有访问控制的复制集部署知识,可查阅官方文档 [Deploy New Replica Set With Keyfile
Access Control](https://docs.mongodb.com/manual/tutorial/deploy-replica-set-
with-keyfile-access-control/#deploy-repl-set-with-auth)。
注意:本次部署演示将在同一台计算机上启动多个 Docker 镜像,但实际工作中则是在多台不同的云服务器上部署 MongoDB。
部署复制集
在开始学习本节之前,请确保按照附章 Docker
官方文档 的指引安装 Docker。
首先,我们需要从 DockerHub 中拉取 MongoDB 官方提供的 mongo 服务镜像。在版本选择方面,我们选择最新版,即latest
。对应命令如下:
$ docker pull mongo:latest
将镜像拉取到本地后,分别使用 run
命令启动三个容器。启动时,我们需要为容器指定名称,以便后期使用,同时还需要指定复制集的名称,并设置容器的bind_ip
。对应命令如下:
$ docker run –name mongoFir -d mongo:latest –replSet “mongoRepas” –bind_ip_all
$ docker run --name mongoSec -d mongo:latest --replSet "mongoRepas" --bind_ip_all
$ docker run --name mongoThr -d mongo:latest --replSet "mongoRepas" --bind_ip_all
这里将容器分别命名为 mongoFir
、mongoSec
和 mongoThr
,复制集的名称指定为 mongoRepas
,并解除 mongo
对于bind_ip
的限制。由于在启动时未绑定 IP,所以我们需要使用 grep
命令找到每个容器对应的 IP。对应命令如下:
$ docker inspect mongoFir | grep IPAddress
命令执行后,终端返回信息如下:
“SecondaryIPAddresses”: null,
“IPAddress”: “172.17.0.2”,
“IPAddress”: “172.17.0.2”
返回结果说明容器 mongoFir
绑定的 IP 为 172.17.0.2
,mongo 服务的默认端口为 27017,所以名为 mongoFir
的容器中的 mongo 服务完整地址为 172.17.0.2:27017
。接着依次查找容器 mongoSec
和 mongoThr
对应的
mongo 服务地址。最终,三个容器对应的 mongo 服务地址依次如下:
172.17.0.2:27017
172.17.0.3:27017
172.17.0.4:27017
容器启动成功后,就可以开始初始化复制集的工作了。首先,连接任意一个容器的 MongoShell,例如容器 mongoFir
。对应命令如下:
$ docker exec -it mongoFir mongo
命令执行后,就会连接上容器 mongoFir
的 MongoShell。然后在 MongoShell 中执行复制集初始化的命令:
> rs.initiate({
_id: “mongoRepas”,
members:[
{_id: 0, host: “172.17.0.2”},
{_id: 1, host: “172.17.0.3”},
{_id: 2, host: “172.17.0.4”}
]
})
在初始化复制集的时候指定了复制集的名称,并制定了成员的 _id
和对应的 IP 地址。命令执行后,MongoShell 输出如下文档:
{
“ok” : 1,
“operationTime” : Timestamp(1564287051, 1),
“$clusterTime” : {
“clusterTime” : Timestamp(1564287051, 1),
“signature” : {
“hash” : BinData(0,”AAAAAAAAAAAAAAAAAAAAAAAAAAA=”),
“keyId” : NumberLong(0)
}
}
}
返回结果中的 ok: 1
代表复制集初始化成功。此时,MongoShell 的命令行标识符从 >
变为mongoRepas:SECONDARY>
,即复制集的 Shell。在复制集 Shell 中使用 rs.status()
命令查看当前复制集的状态信息,命令执行后输出如下内容:
{
“set” : “mongoRepas”,
“date” : ISODate(“2019-07-28T04:36:02.341Z”),
…
“members” : [
{
“_id” : 0,
“name” : “172.17.0.2:27017”,
“health” : 1,
“state” : 2,
“stateStr” : “SECONDARY”,
“uptime” : 3178,
“optime” : {
“ts” : Timestamp(1564288553, 1),
“t” : NumberLong(3)
},
“optimeDate” : ISODate(“2019-07-28T04:35:53Z”),
“syncingTo” : “172.17.0.3:27017”,
“syncSourceHost” : “172.17.0.3:27017”,
“syncSourceId” : 1,
“infoMessage” : “”,
“configVersion” : 1,
“self” : true,
“lastHeartbeatMessage” : “”
},
{
“_id” : 1,
“name” : “172.17.0.3:27017”,
“health” : 1,
“state” : 1,
“stateStr” : “PRIMARY”,
“uptime” : 1511,
“optime” : {
“ts” : Timestamp(1564288553, 1),
“t” : NumberLong(3)
},
“optimeDurable” : {
“ts” : Timestamp(1564288553, 1),
“t” : NumberLong(3)
},
“optimeDate” : ISODate(“2019-07-28T04:35:53Z”),
“optimeDurableDate” : ISODate(“2019-07-28T04:35:53Z”),
“lastHeartbeat” : ISODate(“2019-07-28T04:36:01.077Z”),
“lastHeartbeatRecv” : ISODate(“2019-07-28T04:36:02.234Z”),
“pingMs” : NumberLong(0),
“lastHeartbeatMessage” : “”,
“syncingTo” : “”,
“syncSourceHost” : “”,
“syncSourceId” : -1,
“infoMessage” : “”,
“electionTime” : Timestamp(1564287718, 1),
“electionDate” : ISODate(“2019-07-28T04:21:58Z”),
“configVersion” : 1
},
{
“_id” : 2,
“name” : “172.17.0.4:27017”,
“health” : 1,
“state” : 2,
“stateStr” : “SECONDARY”,
“uptime” : 1511,
“optime” : {
“ts” : Timestamp(1564288553, 1),
“t” : NumberLong(3)
},
“optimeDurable” : {
“ts” : Timestamp(1564288553, 1),
“t” : NumberLong(3)
},
“optimeDate” : ISODate(“2019-07-28T04:35:53Z”),
“optimeDurableDate” : ISODate(“2019-07-28T04:35:53Z”),
“lastHeartbeat” : ISODate(“2019-07-28T04:36:01.078Z”),
“lastHeartbeatRecv” : ISODate(“2019-07-28T04:36:01.229Z”),
“pingMs” : NumberLong(0),
“lastHeartbeatMessage” : “”,
“syncingTo” : “172.17.0.3:27017”,
“syncSourceHost” : “172.17.0.3:27017”,
“syncSourceId” : 1,
“infoMessage” : “”,
“configVersion” : 1
}
],
“ok” : 1,
“operationTime” : Timestamp(1564288553, 1),
“$clusterTime” : {
“clusterTime” : Timestamp(1564288553, 1),
“signature” : {
“hash” : BinData(0,”AAAAAAAAAAAAAAAAAAAAAAAAAAA=”),
“keyId” : NumberLong(0)
}
}
}
结果文档的各字段含义如下:
字段名称 | 描述 |
---|---|
set | 复制集名称 |
date | 复制集创建时间 |
member | 复制集成员数组 |
_id | 成员 ID |
name | 成员名称 |
health | 成员健康状况,1 代表健康 |
stateStr | 成员身份 |
从返回结果得知:
- 复制集名称为
mongoRepas
; - 复制集创建时间为
ISODate("2019-07-28T04:36:02.341Z")
; _id
为2
的节点为主节点,其他两个节点为从节点;- 三个节点都是健康的。
管理复制集成员
接下来,我们将学习如何添加或删除复制集中的成员。
添加新成员
这里讨论的是为已存在的复制集添加新成员,对于“重新添加”已删除的成员这种操作,我们可以也将其理解为添加新成员。题外话:如果“重新添加”的成员数据相对较新,那么它的数据同步速度会比新成员的速度快。
上面提到过,一个复制集最多只能拥有七个投票成员。如果在添加新成员之前,目标复制集已经有了七个有投票权的成员,那么我们必须将新添加的成员设置为无投票权,或者删除掉目标复制集中的某个有投票权的成员,再将新添加的成员设置为有投票权的成员。
启动将要成为新成员的 MongoDB 后,在主节点的 Shell 中执行 rs.add()
命令即可实现新节点的添加。假设我们需要为复制集repas
添加一个无投票权的新成员。首先,我们需要启动一个 MongoDB 容器,并将其命名为 mongoFou
。对应命令如下:
$ docker run –name mongoFou -d mongo:latest –replSet “mongoRepas” –bind_ip_all
接着查看 mongoFou
的 IP 地址。对应命令如下:
$ docker inspect mongoFou | grep IPAddress
“SecondaryIPAddresses”: null,
“IPAddress”: “172.17.0.5”,
“IPAddress”: “172.17.0.5”,
接下来,我们就可以为复制集添加新成员了。要注意的是,成员添加操作只能在主节点上进行,如果在从节点上执行 rs.add()
命令,将会得到如下错误提示:
{
“operationTime” : Timestamp(1564288008, 1),
“ok” : 0,
“errmsg” : “replSetReconfig should only be run on PRIMARY, but my state is SECONDARY; use the "force" argument to override”,
“code” : 10107,
“codeName” : “NotMaster”,
“$clusterTime” : {
“clusterTime” : Timestamp(1564288008, 1),
“signature” : {
“hash” : BinData(0,”AAAAAAAAAAAAAAAAAAAAAAAAAAA=”),
“keyId” : NumberLong(0)
}
}
}
如果忘记了哪个节点是主节点,可以通过 rs.status()
查看当前复制集的节点身份,确定主节点后(假设主节点为 mongoSec
),使用docker exec -it mongoSec mongo
命令连接主节点 MongoShell,然后执行如下命令:
> rs.add({host: “172.17.0.5:27017”, priority: 0, votes: 0})
该命令表示将 host
信息为 172.17.0.5:27017
的 mongo 服务添加到复制集中。命令执行后,返回如下文档:
{
“ok” : 1,
“operationTime” : Timestamp(1564288848, 1),
“$clusterTime” : {
“clusterTime” : Timestamp(1564288848, 1),
“signature” : {
“hash” : BinData(0,”AAAAAAAAAAAAAAAAAAAAAAAAAAA=”),
“keyId” : NumberLong(0)
}
}
}
ok: 1
代表成员添加成功。除此之外,我们还可以通过查看复制集的状态信息来判断成员是否添加成功。在此示例中,现有节点的数量为 4(之前为
3),就代表新成员添加成功。
新增一个仲裁节点
前面提到过,设置仲裁节点是为了避免选举时出现无限循环的情况。也就是说,当复制集的成员数量为偶数时,我们就需要在复制集中添加一个仲裁节点。启动将要成为仲裁节点的
MongoDB 后,在主节点的 MongoShell 中执行 rs.addArb()
命令即可实现仲裁节点的添加。首先,我们需要启动一个 MongoDB
容器,并将其命名为 mongoFiv
。对应命令如下:
$ docker run –name mongoFiv -d mongo:latest –replSet “mongoRepas” –bind_ip_all
接着查看 mongoFiv
的 IP 地址。对应命令如下:
$ docker inspect mongoFiv | grep IPAddress
“SecondaryIPAddresses”: null,
“IPAddress”: “172.17.0.6”,
“IPAddress”: “172.17.0.6”,
在主节点的 MongoShell 中执行如下命令:
> rs.addArb(“172.17.0.6:27017”)
该命令代表为复制集添加一个仲裁节点,该节点的 host
信息为 172.17.0.6:27017
。命令执行后,返回如下文档:
{
“ok” : 1,
“operationTime” : Timestamp(1564289369, 1),
“$clusterTime” : {
“clusterTime” : Timestamp(1564289369, 1),
“signature” : {
“hash” : BinData(0,”AAAAAAAAAAAAAAAAAAAAAAAAAAA=”),
“keyId” : NumberLong(0)
}
}
}
ok: 1
代表仲裁节点添加成功。除此之外,我们还可以通过查看复制集的状态信息来判断成员是否添加成功。在此示例中,name
值为172.17.0.6
的成员的 stateStr
值为 ARBITER
就代表仲裁节点添加成功。
从复制集中删除一个成员
MongoBD 提供了 rs.remove()
方法用于从复制集中删除指定成员。假设我们希望从复制集 repas
中删除从节点mongoFou
。对应命令如下:
> rs.remove(“172.17.0.5:27017”)
命令执行后,结果文档如下:
{
“ok” : 1,
“operationTime” : Timestamp(1564290025, 1),
“$clusterTime” : {
“clusterTime” : Timestamp(1564290025, 1),
“signature” : {
“hash” : BinData(0,”AAAAAAAAAAAAAAAAAAAAAAAAAAA=”),
“keyId” : NumberLong(0)
}
}
}
要注意的是,如果删除指定成员后导致复制集重新选举,那么 MongoShell
有可能会断开链接连接,稍后自动重连。另外,即使命令执行成功,MongoShell 也有可能会显示类似 DBClientCursor::init
call()failed
这样的错误。
提示:对复制集成员的操作必须在主节点的 MogoShell 中执行,在从节点的 MongoShell 中执行会得到错误提示。
复制集小结
复制集是提高数据系统可用性的有效手段,它以数据冗余和选举的方式确保数据服务可用。
综合篇 分片、数据备份与访问控制
经过前面几篇的学习,我们已经掌握了 MongoDB 的基本使用,这些知识能够满足日常开发的需求。本篇我们将学习 MongoDB
中的分片、访问控制和数据备份等知识,这将使我们更好地管理 MongoDB 服务。
了解 MongoDB 中的分片
MongoDB 服务的性能有可能会受到业务增长的影响,例如高频的查询会导致 CPU
占用率居高不下,高吞吐的需求会挑战单个服务器的性能等。面对这种问题,通常有两种解决办法:
- 垂直扩展:堆砌单一服务器的硬件以提升其性能,例如增加内存、使用更强的 CPU或增加磁盘数量。
- 水平扩展:增加服务器数量以提升整体性能。
垂直扩展和水平扩展各有缺点,例如垂直扩展会受到单机性能上限的困扰,并且会降低容灾能力;水平扩展虽然不会受到性能上限的困扰,但增加了运维复杂度和成本。MongoBD
推荐的解决办法是通过分片进行水平扩展。
分片概述
MongoDB 中的分片是一种多机分散存储数据的方法,这种方法被用于解决单机难以应对超大数据量或超高吞吐操作的问题,多机组成的的协同工作组称为分片集群。
分片集群由以下几个部分组成:
- 分片:每个分片包含分片数据的子集,每个分片可以是单个 mongo 服务,也可以部署为复制集。
- mongos:充当查询路由器,将客户端的操作转发给分片集群。
- 配置服务器:存储集群的元数据和配置,配置服务器必须是复制集。
下图描述了分片集群的组成和它们之间的关系:
MongoDB
使用分片键对集合进行划分,划分好的数据将散布在多台机器上。分片键由集合中的一个或多个不可变字段组成,且一个分片集合只能拥有一个分片键。要注意的是,集合分片之后,分片键和分片键的值都是不可改变的,即无法为集合选择其他分片键,也无法更新分片键字段的值。分片键的选择很重要,它的选择将会影响分片集群的性能和可伸缩性。要注意的是,MongoDB
在集合级别实现分片,而不是数据库级别。
散列分片与范围分片
MongoDB
使用与集合相关的分片键将集合数据分成块,每个块都由分片数据构成。每个块都有一个基于分片键的左闭右开的区间,每个分片可以含有多个块。MongoDB
提供了散列分片和范围分片这两种划分块的方式。
散列分片
散列分片将单个字段的散列值作为分片键,这种方式使得数据分布更均匀。下图描述了散列分片的块划分方式:
上图中,x
为分片键值,Chunk
代表块。分片键值相近的文档会散布在不同的块中,而不是同一个块或相邻的块。
假设使用一个单调递增的值 X
(例如 ObjectId
)作为集合的分片键,那么散列分片的结果如下图所示:
散列分片的语法格式如下:
sh.shardCollection( “database.collection”, {
范围分片
顾名思义,范围分片就是块的划分是根据分片键值确定的连续范围,分片键值相近的文档可能会被划分到同一个块或者分片中。这种连续的范围分片查询效率较高,但写入性能很有可能会因为选择了不合适的分片键的降低。下图描述了范围分片的块划分方式:
上图中,x
为分片键值,Chunk
代表块。x < minKey < -75
的文档将会被划分到 Chunk 1
中,x < 175 <
maxKey
的文档将会被划分到 Chunk 4
中,其他块的划分将依此规则类推。下图描述了范围分片的结果:
如果范围分片使用的是单调变化的分片键,那么结果就完全不同。下图描述了使用单调变化的分片键的范围分片结果:
由于值 X
始终在增加,因此具有上限( maxKey
)的块接收大多数写入。这将会降低分片集群的优势,也就是上面提到的“分片键的选择将会影响分片集群的性能”。
范围分片是 MongoDB 默认的分片方式。范围分片的语法格式如下:
sh.shardCollection( “database.collection”, {
MongoDB 允许混用分片集合和非分片集合,分片集合散布在多台服务器中,非分片集合存储在主分片上。下图描述了不同服务器上的集合关系:
其中,collection 1
表示分片集合,collection 2
表示非分片集合。客户端必须借助 mongos
路由才能与分片集群中的集合进行交互,这里的集合包括分片集合和非分片集合。下图描述了客户端、mongos 路由和分片集群的交互流程:
客户端可以通过 MongoShell 或者对应的数据库驱动连接到 mongos 路由。
块的拆分和迁移
MongoDB 中,块的默认大小为 64
兆字节。MongoDB 允许调整块的大小,但大小限制为 1 <= chunksize <=
1024
。假设我们希望调整块的大小,首先我们应该切换到 config
数据库,对应示例如下:
> use config
然后使用 save()
方法更改 chunksize
的值,对应示例如下:
> db.settings.save( {_id:”chunksize”, value:
其中,<sizeInMB>
为块的大小。当块超出指定大小或者块中的文档数超出时,MongoDB
就会根据块的分片键值拆分块,即将一个过大的块拆分成多个块。大多数时候,触发块拆分的操作是插入或更新。下图描述了块的拆分过程:
在分片集合 A
中有一个容量超过指定块大小的块(该块大小为 64.2MB
),它将会被一分为二,以满足 MongoDB
对块大小的限制。要注意的是,拆分有可能会导致分片的分布不均。在这种情况下,MongoDB 的平衡器会跨分片重新分配块,这种行为叫做块迁移。
平衡器是管理块迁移的后台进程,如果最大分片和最小分片中块的数量差值超过迁移阈值,平衡器就会开始块的迁移工作,以确保数据均匀分布。迁移阈值与块的总数相关,具体关联关系如下:
块的数量 | 迁移阈值 |
---|---|
少于 20 | 2 |
20~79 | 4 |
80 以及以上 | 8 |
下图描述了平衡器迁移块的过程:
分片集合 B
中的一个块将会被迁移到分片集合 C
中。要注意的是,chunksize
越小,拆分和迁移就会越多,数据分布也会更均匀,但过多的拆分和迁移也是不合理的。
以上就是本篇对分片的理论介绍,更多关于分片的知识和操作可参考官方文档
Sharding。
认证与访问控制
数据安全是一个很重要的知识点,我们将在本篇学习 MongoDB 提供的多种安全功能,例如认证、访问控制和加密等。
认证
认证指的是服务端验证客户端的身份,启用访问控制后,MongoDB
会验证所有客户端的身份以确定其访问权限。虽然认证和访问控制是紧密相连的,但认证与访问控制是不同的,认证用于确认用户的身份,而访问控制则验证用户对资源的访问和操作。
MongoDB 支持的认证机制如下:
- SCRAM,默认机制;
- 证书身份验证;
- LADAP 代理身份,MongoDB 企业版独有;
- Kerberos 身份验证,MongoDB 企业版独有;
本文我们讨论的是 MongoDB 默认的 SCRAM 认证机制。SCRAM 英文全称为 Salted Challenge Response
Authentication Mechanism,是一种基于用户名和密码的身份验证机制。
除了验证客户段的身份之外,MongoDB 还支持复制集和分片集群的成员进行内部身份认证。
MongoDB 对客户端身份的验证其实是对用户的身份进行验证,即认证是基于用户的。我们可以使用 db.createUser()
方法添加用户,并通过为用户分配角色实现授权。从 MongoDB 4.0 开始,用户在创建时会获得一个具有唯一值的 userId
。如果开启了
MongoDB 的访问控制,那么创建的第一个用户必须分配 userAdmin
或 userAdminAnyDatabase
角色,以便它有权创建其他用户。
基于角色的访问控制
MongoDB 使用基于角色的访问控制来管理对 MongoDB
系统的访问。角色被授予访问资源或指定操作的权限,每个权限都可以在角色中明确指定,也可以继承其他角色的权限。拥有者可以为用户授予一个或多个角色,分配了角色的用户将获得该角色的所有权限。
MongoDB 提供了很多的内置角色,这些角色已经拥有了数据库系统中常用的权限,如果它们无法满足需求,我们还可以自定义角色和权限。MongoDB
中的内置角色有以下几类:
角色类别 | 内置角色 |
---|---|
数据库用户角色 | read 、readWrite |
数据库管理角色 | dbAdmin 、dbOwner 、userAdmin |
集群管理角色 | clusterAdmin 、 clusterManager 、clusterMonitor 、hostManager |
备份和恢复角色 | backup 、restore |
全数据库角色 | |
readAnyDatabase 、readWriteAnyDatabase 、userAdminAnyDatabase 、dbAdminAnyDatabase |
|
超级用户角色 | root |
内部角色 | __system |
每个内置角色对应有多个操作权,这些操作权允许角色访问文档资源或执行操作。例如 read
角色拥有如下操作权:
这些操作权为角色 read
提供了读取权限,例如 listIndexes
操作权允许角色使用 listIndexes
命令。其他角色,例如readWrite
、restore
和 userAdmin
等对应的操作权可查阅官方文档 [Built-In
Roles](https://docs.mongodb.com/manual/reference/built-in-
roles/)。每个操作权对应的描述可查阅官方文档 Privilege
actions
,此处不再赘述。
创建管理员用户
开启访问控制的前提之一是创建一个管理员用户,否则开启访问控制后将会导致无法通过认证或无法执行操作。创建管理员用户很简单,切换到 admin
数据库,并使用 db.createUser()
即可,但要记得为用户分配权限。对应示例如下:
> use admin
> db.createUser({
… user: “Asyncins”,
… pwd: “123456”,
… roles: [{
… role: “userAdminAnyDatabase”, db: “admin”},
… “readWriteAnyDatabase”]
… })
命令执行后,返回如下内容:
Successfully added user: {
“user” : “Asyncins”,
“roles” : [
{
“role” : “userAdminAnyDatabase”,
“db” : “admin”
},
“readWriteAnyDatabase”
]
}
返回结果显示用户 Asyncins
已创建成功。
启用访问控制
默认情况下,MongoDB 不启用访问控制。如果我们需要,可以使用在配置文件 mongod.conf
中开启。
提示:在开启访问控制前,请按照上面的指引创建管理员用户。
在开篇中提到过,mongod.conf
的完整路径为 /usr/local/etc/mongod.conf
,用 Vim 编辑器打开它:
$ vim /usr/local/etc/mongod.conf
文件完整内容如下:
systemLog:
destination: file
path: /usr/local/var/log/mongodb/mongo.log
logAppend: true
storage:
dbPath: /usr/local/var/mongodb
net:
bindIp: 127.0.0.1
要开启访问控制,只需要在配置中将 security.authorization
打开即可,即在文件中添加以下内容:
security:
authorization: enabled
文件保存后,退出 MongoShell。我们可以让 MongoDB 在前台运行,也可以把它当作系统服务启动。前台启动的示例如下:
$ mongod –auth –port 27017 –dbpath /usr/local/var/mongodb
要注意的是,启动示例中的 dbpath
路径是配置文件 mongod.conf
中设置的路径。我们可以在连接 MongoShell
时验证身份,对应示例如下:
$ mongo –port 27017 -u “Asyncins” –authenticationDatabase “admin” -p
命令执行后,终端会提示输入密码,此时输入之前设定的 123456
即可。我们可以通过命令验证当前用户的权限,示例如下:
> show tables
如果 MongoShell 返回集合列表,就代表用户身份验证通过。我们尝试使用无权用户或匿名用户连接 MongoShell
并查看集合列表,看看会发生什么。此时打开另外一个终端窗口,并执行以下命令:
$ mongo
命令执行后,终端输入内容如下:
MongoDB shell version v4.0.10
connecting to: mongodb://127.0.0.1:27017/?gssapiServiceName=mongodb
Implicit session: session { “id” : UUID(“580ebfe6-ab68-4c6d-ad40-fbfaaa30f408”) }
MongoDB server version: 4.0.10
这代表已经成功连接了 MongoShell,接着执行查看集合列表的命令:
> show tables
Warning: unable to run listCollections, attempting to approximate collection names by parsing connectionStatus
这次并没有返回集合列表,而是给出一个 Warning
级别的提示。提示无法执行 listCollections
命令,即该匿名用户无对应权限。这说明访问控制已生效。
创建其他用户并分配权限
上面已经创建了管理员用户,并开启了访问控制。接下来我们将学习如何创建其他用户,并通过分配角色的方式为其授权。假设要创建一个名为 rust
的用户,且只分配读取 test
数据库的权限。要完成这个任务,首先要登录管理员用户,接着进入到 test
数据库中创建rust
。用户创建的对应示例如下:
> use test
> db.createUser(
{
user: “rust”,
pwd: “133”,
roles: [ { role: “read”, db: “test” }]
}
)
命令执行后,MongoShell 输出如下内容:
Successfully added user: {
“user” : “rust”,
“roles” : [
{
“role” : “read”,
“db” : “test”
}
]
}
返回结果说明用户 rust
创建成功,接下来我们验证一下 rust
用户的权限。退出当前 MongoShell,并使用新用户 rust
登录,对应示例如下:
$ mongo –port 27017 -u “rust” –authenticationDatabase “test” -p
按提示输入密码后,就连上了 MongoShell。此时使用查询命令:
> db.inven.find()
{ “_id” : ObjectId(“5d38688e2ac0ecb464f2c162”), “name” : “詹姆斯”, “number” : 6, “attribute” : { “h” : 203, “w” : 222, “p” : “前锋” }, “status” : “A” }
{ “_id” : ObjectId(“5d38688e2ac0ecb464f2c163”), “name” : “韦德”, “number” : 3, “attribute” : { “h” : 193, “w” : 220, “p” : “得分后卫” }, “status” : “R” }
{ “_id” : ObjectId(“5d38688e2ac0ecb464f2c164”), “name” : “科比”, “number” : 24, “attribute” : { “h” : 198, “w” : 212, “p” : “得分后卫” }, “status” : “R” }
{ “_id” : ObjectId(“5d38688e2ac0ecb464f2c165”), “name” : “姚明”, “number” : 11, “attribute” : { “h” : 226, “w” : 308, “p” : “中锋” }, “status” : “R” }
{ “_id” : ObjectId(“5d38688e2ac0ecb464f2c166”), “name” : “乔丹”, “number” : 23, “attribute” : { “h” : 198, “w” : 216, “p” : “得分后卫” }, “status” : “R” }
说明用户具有查询权限。但是当我们执行写操作时,就会得到没有权限的提示,对应示例如下:
> db.ssv.insert({_id: 1, name: “ssv”})
WriteCommandError({
“ok” : 0,
“errmsg” : “not authorized on test to execute command { insert: "ssv", ordered: true, lsid: { id: UUID("a19c72a8-2301-4fb3-a39d-5161900242a1") }, $db: "test" }”,
“code” : 13,
“codeName” : “Unauthorized”
})
刚才我们测试的是读写权限,实际上在创建 rust
用户时,我们限定了该用户只能访问 test
数据库。如果切换到其他数据库,那么它连读取权限都没有,对应示例如下:
> use admin
switched to db admin
> show tables
Warning: unable to run listCollections, attempting to approximate collection names by parsing connectionStatus
数据备份和还原
在生产环境中使用 MongoDB 时,我们应该备份数据,以便在发生数据丢失事件时恢复数据。MongoDB 提供了四种可用备份方法:
- 用 Atlas 备份数据,即使用 MongoDB 官方的云服务进行备份。这种方式可以增量备份数据,确保备份的数据仅比生产环境的数据落后几秒钟;
- 用 MongoDB Cloud Manager 备份数据。这种方式通过读取 oplog 实现数据备份,还能够能够备份复制集和分片集群中的数据;
- 通过复制底层数据文件进行备份。这种方式通过复制 MongoDB 的基础数据文件来实现数据备份,备份的前提是必须启用日志功能;
- 使用 mongodump 备份数据。mongodump 读取 MongoDB 中的数据并保存为 BSON 文件,是备份和恢复小型数据库的简单而有效的方式,但不适合备份较大的数据库。要注意的是,用这种方式备份后的数据,在还原后必须重建索引;
上述几种备份还原方式各有优劣,本篇我们只讨论 mongodump 备份。mongodump 可以为整个
MongoDB、指定数据库或指定集合创建备份,甚至可以使用查询语句实现备份集合的指定内容。
备份本地数据
使用 mongodump
备份数据,指定备份数据的输出目录
$ mongodump –out /Users/async/Documents/Testing/mongobackup
还可以备份指定数据库中的指定集合:
$ mongodump –db test –collection inven –out /Users/async/Documents/Testing/mongo_test_invenbackup
命令执行后,返回如下信息:
2019-07-29T23:04:47.273+0800 writing test.inven to
2019-07-29T23:04:47.276+0800 done dumping test.inven (5 documents)
信息显示备份的是 test
数据库中的集合 inven
,备份的文档数为 5
。指定的备份目录将会保存一份 JSON 文件和一份 BSON
文件。其中,JSON 文件保存的是本次备份的元数据,文件内容如下:
{“options”:{},”indexes”:[{“v”:2,”key”:{“id”:1},”name”:”_id“,”ns”:”test.inven”},{“v”:2,”key”:{“number”:1.0},”name”:”number_1”,”ns”:”test.inven”},{“v”:2,”key”:{“number”:-1.0},”name”:”number_-1”,”ns”:”test.inven”}],”uuid”:”83e27b2b35d0476d84b8788b4cf3591f”}
BSON 文件保存的是本次备份的数据,即数据被保存为二进制格式。
还原本地数据
在学习数据的还原之前,我们先删除集合 inven
,对应命令如下:
> db.inven.drop()
true
返回结果为 true
代表删除成功。为了确保它真的被删除了,我们可以使用 show tables
查看集合列表,如果 inven
并不在其中,说明它已经被我们已经删除了。接下来使用 mongorestore
恢复数据,在备份文件的同级目录唤起终端,并输入还原命令:
$ mongorestore ./mongo_test_invenbackup/
命令执行后,终端输入如下信息:
2019-07-29T23:17:12.907+0800 preparing collections to restore from
2019-07-29T23:17:12.907+0800 don’t know what to do with file “mongo_test_invenbackup/.DS_Store”, skipping…
2019-07-29T23:17:12.909+0800 reading metadata for test.inven from mongo_test_invenbackup/test/inven.metadata.json
2019-07-29T23:17:12.938+0800 restoring test.inven from mongo_test_invenbackup/test/inven.bson
2019-07-29T23:17:12.940+0800 restoring indexes for collection test.inven from metadata
2019-07-29T23:17:12.976+0800 finished restoring test.inven (5 documents)
2019-07-29T23:17:12.977+0800 done
出现 done
说明数据已经还原成功。我们可以到数据库中使用 show tables
命令对还原操作进行验证:
> show tables
inven
…
集合 inven
出现在集合列表中,说明还原成功。我们还可以使用 db.inven.find()
命令验证数据的完整性:
> db.inven.find()
{ “_id” : ObjectId(“5d38688e2ac0ecb464f2c162”), “name” : “詹姆斯”, “number” : 6, “attribute” : { “h” : 203, “w” : 222, “p” : “前锋” }, “status” : “A” }
{ “_id” : ObjectId(“5d38688e2ac0ecb464f2c163”), “name” : “韦德”, “number” : 3, “attribute” : { “h” : 193, “w” : 220, “p” : “得分后卫” }, “status” : “R” }
{ “_id” : ObjectId(“5d38688e2ac0ecb464f2c164”), “name” : “科比”, “number” : 24, “attribute” : { “h” : 198, “w” : 212, “p” : “得分后卫” }, “status” : “R” }
{ “_id” : ObjectId(“5d38688e2ac0ecb464f2c165”), “name” : “姚明”, “number” : 11, “attribute” : { “h” : 226, “w” : 308, “p” : “中锋” }, “status” : “R” }
{ “_id” : ObjectId(“5d38688e2ac0ecb464f2c166”), “name” : “乔丹”, “number” : 23, “attribute” : { “h” : 198, “w” : 216, “p” : “得分后卫” }, “status” : “R” }
这说明数据也是完整的,本次本地的数据备份与还原演示到此结束。
远程备份与还原
除了备份本地数据之外,我们还可以备份远程服务器上的数据,备份时我们可能需要提供用户身份信息(如果未开启访问控制则不需要)。假设我们需要为mg.porters.vip
服务器上已开启访问控制的 MongoDB 进行备份对应的目录。备份命令如下:
$ mongodump –host mg.porters.vip –port 27017 –username user –password “123456” – out /opt/backup/mongodump-20190729
命令执行后,指定的数据将会备份到 /opt/backup/mongodump-20190729
目录。当我们需要还原数据的时候,运行以下命令:
$ mongorestore –host mg.porters.vip –port 27017 –username user –password “123456” /opt/backup/mongodump-20190729
命令执行后,指定目录的数据将会被还原到 MongoDB 中。
至此,MongoDB 数据备份与还原的 mongodump 演示结束,更多关于备份与还原的知识可查阅官方文档 MongDB Backup
Methods。
结束语
经过几篇的学习,相信你已经对 MongoDB 不再陌生。想要掌握
MongoDB,还需要更多的实践操作。如果在实践中遇到问题,建议翻阅官方文档或者加入作者微信群一起讨论。作者微信号:zenrusts,加我好友拉你入群。
本文首发于 GitChat,未经授权不得转载,转载需与 GitChat 联系。