MongoDB-权威指南第三版-全-
MongoDB 权威指南第三版(全)
原文:
zh.annas-archive.org/md5/7bde329f1b8dde7759444c8d339b0bb6译者:飞龙
序言
本书的组织结构
本书分为六个部分,涵盖了开发、管理和部署信息。
MongoDB 入门
在第一章中,我们提供了关于 MongoDB 的背景信息:它的创建原因、它试图实现的目标以及为什么您可能选择在项目中使用它。我们在第二章中进行了更详细的介绍,这一章介绍了 MongoDB 的核心概念和词汇。第二章还为您提供了开始使用数据库和 shell 的第一步。接下来的两章介绍了开发人员需要了解的 MongoDB 基础知识。在第三章中,我们描述了如何执行基本的写操作,包括如何在不同的安全性和速度级别下执行这些操作。第四章解释了如何查找文档并创建复杂的查询。本章还涵盖了如何迭代结果以及限制、跳过和排序结果的选项。
使用 MongoDB 进行开发
第五章介绍了索引的概念以及如何为您的 MongoDB 集合创建索引。第六章解释了如何使用几种特殊类型的索引和集合。第七章涵盖了使用 MongoDB 聚合数据的多种技术,包括计数、查找不同的值、文档分组、聚合框架以及将这些结果写入集合的方法。第八章介绍了事务:它们是什么,如何最好地为您的应用程序使用它们,以及如何进行调优。最后,本节以一章讲述应用程序设计结束:第九章介绍了撰写与 MongoDB 配合良好的应用程序的技巧。
复制
复制部分从第十章开始,提供了在本地设置副本集的快速方法,并涵盖了许多可用的配置选项。第十一章然后涵盖了与复制相关的各种概念。第十二章展示了复制如何与您的应用程序交互,第十三章涵盖了运行副本集的管理方面。
分片
分片部分从第十四章开始进行快速本地设置。第十五章然后概述了集群组件及其设置方式。第十六章提供了选择各种应用程序的分片键的建议。最后,第十七章涵盖了管理分片集群的内容。
应用程序管理
接下来的两章从您的应用程序的角度讨论了 MongoDB 管理的许多方面。第 18 章讨论了如何审视 MongoDB 正在做什么。第 19 章涵盖了 MongoDb 的安全性以及如何为您的部署配置身份验证和授权。第 20 章解释了 MongoDB 如何持久存储数据。
服务器管理
最后一部分集中在服务器管理上。第 21 章讨论了启动和停止 MongoDB 时的常见选项。第 22 章讨论了在监控时要查找和如何读取统计信息。第 23 章描述了如何为每种部署类型进行备份和恢复。最后,第 24 章讨论了部署 MongoDB 时需要牢记的一些系统设置。
附录
附录 A 解释了 MongoDB 的版本方案及如何在 Windows、OS X 和 Linux 上安装。附录 B 详细介绍了 MongoDB 的内部工作原理:其存储引擎、数据格式和协议。
本书使用的约定
本书使用以下排版约定:
斜体
表示新术语、网址、电子邮件地址、集合名称、数据库名称、文件名和文件扩展名。
Constant width
用于程序清单,以及段落中引用程序元素,如变量或函数名称、命令行实用程序、环境变量、语句和关键字。
Constant width bold
显示用户应逐字输入的命令或其他文本。
Constant width italic
显示应该用用户提供的值或由上下文确定的值替换的文本。
提示
这个元素表示一个提示或建议。
注意
这个元素表示一般的注释。
警告
这个元素指示警告或注意事项。
使用代码示例
补充材料(代码示例、练习等)可在https://github.com/mongodb-the-definitive-guide-3e/mongodb-the-definitive-guide-3e下载。
如果您有技术问题或在使用代码示例时遇到问题,请发送电子邮件至bookquestions@oreilly.com。
本书旨在帮助您完成工作。通常情况下,如果本书提供了示例代码,您可以在自己的程序和文档中使用它。除非您复制了大部分代码,否则无需征得我们的许可。例如,编写一个使用本书多个代码片段的程序并不需要许可。出售或分发 O’Reilly 书籍的示例代码确实需要许可。引用本书回答问题并引用示例代码不需要许可。将本书大量示例代码整合到产品文档中需要许可。
我们感谢您的使用,但通常不要求您提及。署名通常包括标题、作者、出版商和 ISBN。例如:“MongoDB: The Definitive Guide, Third Edition by Shannon Bradshaw, Eoin Brazil, and Kristina Chodorow (O’Reilly)。版权所有 2020 年 Shannon Bradshaw 和 Eoin Brazil,978-1-491-95446-1。”
如果您认为您使用的示例代码超出了合理使用范围或上述许可,请随时通过 permissions@oreilly.com 联系我们。
O’Reilly Online Learning
注意
40 多年来,O’Reilly Media一直为公司提供技术和业务培训、知识和见解,帮助其取得成功。
我们独特的专家和创新者网络通过书籍、文章、会议和我们的在线学习平台分享他们的知识和专长。O’Reilly 的在线学习平台让您随时访问现场培训课程、深入学习路径、交互式编码环境以及来自 O’Reilly 和 200 多家其他出版商的大量文本和视频。有关更多信息,请访问 http://oreilly.com。
如何联系我们
有关本书的评论和问题,请联系出版商:
-
O’Reilly Media, Inc.
-
1005 Gravenstein Highway North
-
Sebastopol, CA 95472
-
800-998-9938(美国或加拿大)
-
707-829-0515(国际或本地)
-
707-829-0104(传真)
我们有本书的网页,上面列出了勘误、示例和任何其他信息。您可以访问此页面:https://oreil.ly/mongoDB_TDG_3e。
发送邮件至 bookquestions@oreilly.com 提出对本书的评论或技术问题。
有关我们的书籍、课程、会议和新闻的更多信息,请访问我们的网站:http://www.oreilly.com。
在 Facebook 上找到我们:http://facebook.com/oreilly
在 Twitter 上关注我们:http://twitter.com/oreillymedia
在 YouTube 上观看我们:http://www.youtube.com/oreillymedia
第一部分:MongoDB 简介
第一章:介绍
MongoDB 是一款强大、灵活且可扩展的通用数据库。它结合了横向扩展的能力和诸如次要索引、范围查询、排序、聚合和地理空间索引等功能。本章介绍了使 MongoDB 成为现在的主要设计决策。
使用便捷
MongoDB 是一个面向文档的数据库,而不是关系型数据库。摆脱关系模型的主要原因是为了更容易地进行横向扩展,但也有其他一些优点。
面向文档的数据库用更灵活的“文档”模型替代了“行”的概念。通过允许嵌入式文档和数组,面向文档的方法可以用单个记录表示复杂的层次关系。这与现代面向对象语言中开发者的数据思维方式自然契合。
同时也没有预定义的模式:文档的键和值不是固定类型或大小的。没有固定的模式,根据需要添加或删除字段变得更加容易。通常情况下,这使得开发速度更快,因为开发者可以快速迭代。这也更容易进行实验。开发者可以尝试数十种数据模型,然后选择最佳模型进行进一步开发。
设计用于扩展
应用程序的数据集大小正在以惊人的速度增长。带宽的增加和廉价存储的出现创造了一个环境,即使是小规模应用程序也需要存储比许多数据库所能处理的数据量还要多的数据。一千兆字节的数据,曾经是一个难以置信的信息量,现在却很普遍。
随着开发者需要存储的数据量增加,他们面临一个困难的决定:他们应该如何扩展他们的数据库?扩展数据库涉及选择在扩展上(获取更大的机器)和横向扩展(在更多机器之间分区数据)之间的折衷。扩展上通常是最容易的路径,但有缺点:大型机器通常非常昂贵,并且最终会达到物理极限,即使有再多的资金也无法购买更强大的机器。另一种选择是横向扩展:增加存储空间或增加读写操作的吞吐量,购买额外的服务器,并将它们添加到集群中。这既便宜又可扩展;然而,管理一千台机器比照顾一台机器更困难。
MongoDB 设计用于横向扩展。面向文档的数据模型使得更容易地将数据分割到多个服务器上。MongoDB 会自动处理集群中数据和负载的平衡,自动重新分配文档,并将读写操作路由到正确的机器上,如图 1-1 所示。

图 1-1 横向扩展 MongoDB 通过在多个服务器上进行分片
MongoDB 集群的拓扑结构,或者说是否存在集群而不是仅仅是数据库连接的另一端,对应用程序是透明的。这使开发人员可以专注于编程应用程序,而不是扩展它。同样,如果现有部署的拓扑结构需要更改,以支持更大的负载,应用程序逻辑可以保持不变。
功能丰富…
MongoDB 是一个通用数据库,因此除了创建、读取、更新和删除数据外,它还提供了大多数数据库管理系统所期望的功能,以及许多其他使其与众不同的功能。这些包括:
索引
MongoDB 支持通用的二级索引,并提供唯一、复合、地理空间和全文索引的能力。还支持嵌套文档和数组等层次结构上的二级索引,并使开发人员能够充分利用按其应用程序最合适的方式进行建模的能力。
聚合
MongoDB 提供基于数据处理管道概念的聚合框架。聚合管道允许您通过一系列相对简单的阶段在服务器端处理数据,充分利用数据库优化,从而构建复杂的分析引擎。
特殊集合和索引类型
MongoDB 支持生存时间(TTL)集合,用于应在某个特定时间过期的数据,例如会话,以及固定大小(capped)集合,用于保存最近的数据,例如日志。MongoDB 还支持仅限于匹配特定条件筛选器的部分索引,以增加效率并减少所需的存储空间。
文件存储
MongoDB 支持用于存储大文件和文件元数据的易于使用的协议。
MongoDB 中没有出现在关系数据库中常见的某些功能,特别是复杂的连接。MongoDB 通过在 3.2 版本中引入的$lookup聚合操作符有非常有限的方式支持连接。在 3.6 版本中,通过多个连接条件以及无关子查询,可以实现更复杂的连接。MongoDB 对连接的处理是为了允许更大的可伸缩性而进行的架构决策,因为在分布式系统中提供这两个功能都很难有效地实现。
…而不损害速度
性能是 MongoDB 的主要目标,也塑造了其设计的很大部分。它在其 WiredTiger 存储引擎中使用机会锁定以最大化并发性和吞吐量。它尽可能多地使用 RAM 作为其缓存,并尝试自动选择适合查询的正确索引。简而言之,MongoDB 的几乎每个方面都是为了保持高性能而设计的。
尽管 MongoDB 强大,集成了许多关系系统的特性,但并不意味着它要完成关系数据库所能做的一切。在某些功能上,数据库服务器将处理和逻辑卸载到客户端(由驱动程序或用户的应用程序代码处理)。它保持这种简化设计的维护是 MongoDB 能够实现如此高性能的原因之一。
哲学
在本书中,我们将花时间记录 MongoDB 开发过程中做出的特定决策背后的推理或动机。通过这些注释,我们希望分享 MongoDB 背后的哲学。然而,总结 MongoDB 项目的最佳方式是引用其主要关注点——创建一个可扩展、灵活且快速的全功能数据存储。
第二章:入门
MongoDB 强大而易于上手。在本章中,我们将介绍 MongoDB 的一些基本概念:
-
文档 是 MongoDB 的基本数据单元,大致相当于关系数据库管理系统中的一行(但更具表现力)。
-
类似地,集合 可以被看作是具有动态模式的表。
-
一个 MongoDB 实例可以托管多个独立的 数据库,每个数据库都包含其自己的集合。
-
每个文档都有一个特殊的键,
"_id",在集合内是唯一的。 -
MongoDB 随附一个简单但功能强大的工具,称为 mongo shell。mongo shell 提供了内置支持,用于管理 MongoDB 实例并使用 MongoDB 查询语言操作数据。它还是一个完全功能的 JavaScript 解释器,允许用户为各种目的创建和加载自己的脚本。
文档
MongoDB 的核心是 文档:一组带有关联值的有序键集。文档的表示因编程语言而异,但大多数语言都有一个自然的数据结构来适应,比如映射、哈希表或字典。例如,在 JavaScript 中,文档表示为对象:
{"greeting" : "Hello, world!"}
这个简单的文档包含一个名为 "greeting" 的键,其值为 "Hello, world!"。大多数文档会比这个简单的文档复杂,并且通常会包含多个键值对:
{"greeting" : "Hello, world!", "views" : 3}
正如您所见,文档中的值不仅仅是“blob”。它们可以是几种不同的数据类型之一(甚至是整个嵌入式文档——参见 “嵌入式文档”)。在此示例中,"greeting" 的值是一个字符串,而 "views" 的值是一个整数。
文档中的键是字符串。键中允许任何 UTF-8 字符,但有几个显著的例外:
-
键不能包含字符 \0(空字符)。该字符用于表示键的结尾。
-
. 和 $ 字符具有一些特殊属性,只应在特定情况下使用,如后续章节所述。一般来说,它们应被视为保留字符,如果不恰当地使用,驱动程序会报错。
MongoDB 是类型敏感和区分大小写的。例如,以下文档是不同的:
{"count" : 5}
{"count" : "5"}
正如下面这些:
{"count" : 5}
{"Count" : 5}
最后需要注意的重要事项是,MongoDB 中的文档不能包含重复的键。例如,以下内容不是合法的文档:
{"greeting" : "Hello, world!", "greeting" : "Hello, MongoDB!"}
集合
一个 集合 是一组文档。如果文档是关系数据库中行的 MongoDB 类比,那么集合可以被看作是表的类比。
动态模式
集合具有 动态模式。这意味着单个集合中的文档可以具有任意数量的不同“形状”。例如,以下两个文档都可以存储在同一个集合中:
{"greeting" : "Hello, world!", "views": 3}
{"signoff": "Good night, and good luck"}
请注意,前述文档具有不同的键、不同数量的键和不同类型的值。因为任何文档都可以放入任何集合中,所以常常会问:“我们到底为什么需要单独的集合?” 没有需要为不同类型的文档创建单独模式,那么我们为什么需要使用多个集合呢?有几个很好的理由:
-
将不同类型的文档放在同一集合中可能会给开发人员和管理员带来噩梦。开发人员需要确保每个查询仅返回符合特定模式的文档,或者应用程序代码执行查询时可以处理不同形状的文档。如果我们要查询博客文章,那么筛选掉包含作者数据的文档会很麻烦。
-
获取集合列表要比从集合中提取文档类型列表快得多。例如,如果每个文档都有一个
"type"字段,指定文档是“浏览”、“完整”还是“大块猴子”,在单个集合中查找这三个值将会慢得多,与查询三个不同的集合相比。 -
将同类文档放在同一集合中可以实现数据局部性。从仅包含帖子的集合中获取多篇博客文章可能需要的磁盘查找次数比从包含帖子和作者数据的集合中获取相同帖子少。
-
当我们创建索引时,我们开始对文档施加一些结构。(特别是在唯一索引的情况下。)这些索引是针对每个集合定义的。通过将仅包含单一类型文档的文档放入同一集合,我们可以更有效地为集合建立索引。
创建模式和将相关类型的文档分组有充分的理由。虽然不是默认要求,但为您的应用程序定义模式是一个好习惯,并且可以通过 MongoDB 的文档验证功能和许多编程语言的对象-文档映射库进行强制执行。
命名
集合通过其名称来识别。集合名称可以是任何 UTF-8 字符串,但有一些限制:
-
空字符串 (
"") 不是有效的集合名称。 -
集合名称不得包含字符 \0(
null字符),因为这会标志集合名称的结尾。 -
不应创建任何以 system. 开头的集合名称,这是为内部集合保留的前缀。例如,system.users 集合包含数据库的用户,system.namespaces 集合包含有关数据库所有集合的信息。
-
用户创建的集合名称不应包含保留字符 \(*。尽管各种可用于数据库的驱动程序支持在集合名称中使用 *\),因为一些系统生成的集合中包含它,但除非您正在访问这些集合之一,否则不应在名称中使用 $。
子集合
一种组织集合的常用约定是使用由 . 字符分隔的命名空间子集合。例如,一个包含博客的应用程序可能有一个名为 blog.posts 的集合和一个单独的名为 blog.authors 的集合。这仅仅是为了组织目的——blog 集合与其“子集合”之间没有关系(甚至 blog 集合本身都不一定存在)。
虽然子集合没有任何特殊属性,但它们非常有用,并被整合到许多 MongoDB 工具中。例如:
-
GridFS,用于存储大文件的协议,使用子集合将文件元数据与内容块分开存储(有关 GridFS 的更多信息,请参见 第六章)。
-
大多数驱动程序提供一些语法糖来访问给定集合的子集合。例如,在数据库 shell 中,
db.blog将给您 blog 集合,而db.blog.posts将给您 blog.posts 集合。
对于许多用例,子集合是在 MongoDB 中组织数据的一种好方法。
数据库
除了按集合分组文档外,MongoDB 还将集合分组到 数据库 中。MongoDB 的单个实例可以托管多个数据库,每个数据库都可以组合零个或多个集合。一个很好的经验法则是将单个应用程序的所有数据存储在同一个数据库中。在同一 MongoDB 服务器上存储多个应用程序或用户数据时,单独的数据库非常有用。
与集合类似,数据库通过名称标识。数据库名称可以是任何 UTF-8 字符串,但有以下限制:
-
空字符串(“”)不是有效的数据库名称。
-
数据库名称不能包含以下任何字符:/、*、.、"、、<、>、:、|、?、$、(单个空格)或 \0(
null字符)。基本上,保持使用字母数字 ASCII。 -
数据库名称对大小写不敏感。
-
数据库名称限制为最多 64 个字节。
在使用 WiredTiger 存储引擎之前,历史上数据库名称变成了文件名。现在不再是这种情况。这也解释了为什么先前存在这些限制。
也有一些保留的数据库名称,您可以访问但它们具有特殊的语义。这些如下:
admin
admin 数据库在认证和授权中起着重要作用。此外,对于某些管理操作,需要访问这个数据库。更多关于 admin 数据库的信息请参见 第十九章。
local
此数据库存储特定于单个服务器的数据。在副本集中,local存储用于复制过程中使用的数据。local数据库本身永远不会被复制。(有关复制和本地数据库的更多信息,请参见第十章。)
config
分片 MongoDB 集群(参见第十四章)使用config数据库来存储每个分片的信息。
通过将数据库名称与其中的集合连接起来,您可以获得一个完全限定的集合名称,这称为命名空间。例如,如果您正在使用cms数据库中的blog.posts集合,则该集合的命名空间将是cms.blog.posts。命名空间长度限制为 120 字节,在实践中应少于 100 字节长。有关命名空间和 MongoDB 中集合的内部表示的更多信息,请参见附录 B。
获取和启动 MongoDB
要启动服务器,请在您选择的 Unix 命令行环境中运行mongod可执行文件:
$ mongod
2016-04-27T22:15:55.871-0400 I CONTROL [initandlisten] MongoDB starting :
pid=8680 port=27017 dbpath=/data/db 64-bit host=morty
2016-04-27T22:15:55.872-0400 I CONTROL [initandlisten] db version v4.2.0
2016-04-27T22:15:55.872-0400 I CONTROL [initandlisten] git version:
34e65e5383f7ea1726332cb175b73077ec4a1b02
2016-04-27T22:15:55.872-0400 I CONTROL [initandlisten] allocator: system
2016-04-27T22:15:55.872-0400 I CONTROL [initandlisten] modules: none
2016-04-27T22:15:55.872-0400 I CONTROL [initandlisten] build environment:
2016-04-27T22:15:55.872-0400 I CONTROL [initandlisten] distarch: x86_64
2016-04-27T22:15:55.872-0400 I CONTROL [initandlisten] target_arch: x86_64
2016-04-27T22:15:55.872-0400 I CONTROL [initandlisten] options: {}
2016-04-27T22:15:55.889-0400 I JOURNAL [initandlisten]
journal dir=/data/db/journal
2016-04-27T22:15:55.889-0400 I JOURNAL [initandlisten] recover :
no journal files
present, no recovery needed
2016-04-27T22:15:55.909-0400 I JOURNAL [durability] Durability thread started
2016-04-27T22:15:55.909-0400 I JOURNAL [journal writer] Journal writer thread
started
2016-04-27T22:15:55.909-0400 I CONTROL [initandlisten]
2016-04-27T22:15:56.777-0400 I NETWORK [HostnameCanonicalizationWorker]
Starting hostname canonicalization worker
2016-04-27T22:15:56.778-0400 I FTDC [initandlisten] Initializing full-time
diagnostic data capture with directory '/data/db/diagnostic.data'
2016-04-27T22:15:56.779-0400 I NETWORK [initandlisten] waiting for connections
on port 27017
如果您使用 Windows,请运行以下命令:
> mongod.exe
提示
有关在您的系统上安装 MongoDB 的详细信息,请参见附录 A 或 MongoDB 文档中相应的安装教程。
当不带参数运行时,mongod将使用默认数据目录/data/db/(或 Windows 当前卷上的*\data\db*)。如果数据目录不存在或不可写,则服务器将无法启动。在启动 MongoDB 之前,创建数据目录(例如,mkdir -p /data/db/)并确保您的用户有写入目录的权限非常重要。
在启动时,服务器将打印一些版本和系统信息,然后开始等待连接。默认情况下,MongoDB 监听端口 27017 上的套接字连接。如果该端口不可用,服务器将无法启动——最常见的原因是已经运行另一个 MongoDB 实例。
提示
您应该始终保护您的mongod实例。有关如何保护 MongoDB 的更多信息,请参见第十九章。
您可以在启动mongod的命令行环境中键入 Ctrl-C 安全停止mongod服务器。
提示
有关启动或停止 MongoDB 的更多信息,请参见第二十一章。
MongoDB Shell 简介
MongoDB 附带一个 JavaScript shell,允许从命令行与 MongoDB 实例交互。这个 shell 对于执行管理功能、检查运行中的实例或仅仅探索 MongoDB 非常有用。mongo shell 是使用 MongoDB 的关键工具。我们将在本文的其余部分广泛使用它。
运行 Shell
要启动 shell,请运行mongo可执行文件:
$ mongo
MongoDB shell version: 4.2.0
connecting to: test
>
Shell 在启动时会自动尝试连接到本地机器上运行的 MongoDB 服务器,请确保在启动 Shell 之前启动 mongod。
Shell 是一个功能齐全的 JavaScript 解释器,能够运行任意的 JavaScript 程序。为了说明这一点,让我们执行一些基本的数学运算:
> x = 200;
200
> x / 5;
40
我们还可以利用所有标准的 JavaScript 库:
> Math.sin(Math.PI / 2);
1
> new Date("20109/1/1");
ISODate("2019-01-01T05:00:00Z")
> "Hello, World!".replace("World", "MongoDB");
Hello, MongoDB!
我们甚至可以定义和调用 JavaScript 函数:
> function factorial (n) {
... if (n <= 1) return 1;
... return n * factorial(n - 1);
... }
> factorial(5);
120
请注意,您可以创建多行命令。当您按 Enter 键时,Shell 将检测 JavaScript 语句是否完整。如果语句不完整,Shell 将允许您在下一行继续编写它。连续按三次 Enter 将取消未完成的命令,并返回到 > 提示符。
一个 MongoDB 客户端
尽管执行任意 JavaScript 的能力非常有用,但 Shell 的真正强大之处在于它也是一个独立的 MongoDB 客户端。在启动时,Shell 连接到 MongoDB 服务器上的 test 数据库,并将此数据库连接分配给全局变量 db。这个变量是通过 Shell 访问 MongoDB 服务器的主要接入点。
要查看当前分配给 db 的数据库,请键入 db 并按 Enter:
> db
test
Shell 包含一些附加组件,这些组件不是有效的 JavaScript 语法,但由于其对 SQL shell 用户的熟悉性而实现。这些附加组件不提供任何额外的功能,但它们是很好的语法糖。例如,最重要的操作之一是选择要使用的数据库:
> use video
switched to db video
现在,如果查看 db 变量,可以看到它指向 video 数据库:
> db
video
因为这是一个 JavaScript shell,键入一个变量名将导致该名称作为表达式进行评估。然后打印出值(在本例中是数据库名称)。
您可以从 db 变量访问集合。例如:
> db.movies
返回当前数据库中 movies 集合。既然我们可以在 Shell 中访问集合,我们几乎可以执行任何数据库操作。
Shell 的基本操作
我们可以使用四个基本操作,即创建(Create)、读取(Read)、更新(Update)和删除(Delete)(CRUD),来操作和查看 Shell 中的数据。
创建
insertOne 函数将文档添加到集合中。例如,假设我们要存储一部电影。首先,我们将创建一个名为 movie 的本地变量,它是一个表示我们文档的 JavaScript 对象。它将具有键 "title"、"director" 和 "year"(发布年份):
> movie = {"title" : "Star Wars: Episode IV - A New Hope",
... "director" : "George Lucas",
... "year" : 1977}
{
"title" : "Star Wars: Episode IV - A New Hope",
"director" : "George Lucas",
"year" : 1977
}
此对象是一个有效的 MongoDB 文档,因此我们可以使用 insertOne 方法将其保存到 movies 集合中:
> db.movies.insertOne(movie)
{
"acknowledged" : true,
"insertedId" : ObjectId("5721794b349c32b32a012b11")
}
电影已保存到数据库中。我们可以通过在集合上调用 find 来查看它:
> db.movies.find().pretty()
{
"_id" : ObjectId("5721794b349c32b32a012b11"),
"title" : "Star Wars: Episode IV - A New Hope",
"director" : "George Lucas",
"year" : 1977
}
我们可以看到添加了 "_id" 键,并且其他键值对都按我们输入的方式保存了下来。关于 "_id" 字段突然出现的原因在本章末尾有解释。
读取
find 和 findOne 可用于查询集合。如果我们只想从集合中看到一个文档,我们可以使用 findOne:
> db.movies.findOne()
{
"_id" : ObjectId("5721794b349c32b32a012b11"),
"title" : "Star Wars: Episode IV - A New Hope",
"director" : "George Lucas",
"year" : 1977
}
find 和 findOne 也可以通过查询文档的形式传递条件。这将限制查询匹配的文档。Shell 将自动显示最多匹配 find 的 20 个文档,但可以获取更多。(有关查询的详细信息,请参见第四章。)
更新
如果我们想修改我们的文章,可以使用 updateOne。updateOne 至少需要两个参数:第一个是找到要更新的文档的条件,第二个是描述要进行的更新的文档。假设我们决定为之前创建的电影启用评论。我们需要在我们的文档中添加一个评论数组作为新键的值。
要执行更新操作,我们需要使用更新操作符 set:
> db.movies.updateOne({title : "Star Wars: Episode IV - A New Hope"},
... {$set : {reviews: []}})
WriteResult({"nMatched": 1, "nUpserted": 0, "nModified": 1})
现在文档有一个 "reviews" 键。如果我们再次调用 find,我们可以看到新键:
> db.movies.find().pretty()
{
"_id" : ObjectId("5721794b349c32b32a012b11"),
"title" : "Star Wars: Episode IV - A New Hope",
"director" : "George Lucas",
"year" : 1977,
"reviews" : [ ]
}
有关更新文档的详细信息,请参见“更新文档”。
删除
deleteOne 和 deleteMany 从数据库中永久删除文档。这两种方法都接受一个过滤文档,指定要删除的条件。例如,这将删除我们刚刚创建的电影:
> db.movies.deleteOne({title : "Star Wars: Episode IV - A New Hope"})
使用 deleteMany 删除匹配过滤器的所有文档。
数据类型
本章的开头介绍了文档的基础知识。现在你已经能够在 MongoDB 中使用 Shell 并进行尝试,本节将深入探讨一些内容。MongoDB 支持各种数据类型作为文档中的值。在本节中,我们将概述所有支持的类型。
基本数据类型
在 MongoDB 中,文档可以被视为“类似 JSON 的”,因为它们在概念上类似于 JavaScript 中的对象。JSON 是数据的简单表示形式:其规范可以用大约一段话描述(该网站证明了这一点),并且只列出六种数据类型。从许多方面来说,这是一件好事:它易于理解、解析和记忆。另一方面,由于其只有 null、布尔值、数字、字符串、数组和对象这六种类型,JSON 的表达能力有限。
虽然这些类型可以表达出色的表现力,但大多数应用程序,特别是在处理数据库时,关键的几种附加类型仍然至关重要。例如,JSON 没有日期类型,这使得处理日期比通常更加烦人。虽然有数值类型,但只有一种——无法区分浮点数和整数,更别提区分 32 位和 64 位数字了。也无法表示其他常用类型,如正则表达式或函数。
MongoDB 在保持 JSON 的基本键/值对性质的同时,增加了对许多其他数据类型的支持。每种类型的值如何表示因语言而异,但这是一个常见支持的类型列表以及它们在 shell 中作为文档的一部分如何表示的列表。最常见的类型有:
空
空类型可用于表示空值和不存在的字段:
{"x" : null}
布尔
存在布尔类型,可用于值true和false:
{"x" : true}
数字
shell 默认使用 64 位浮点数。因此,这些数字在 shell 中看起来“正常”:
{"x" : 3.14}
{"x" : 3}
对于整数,使用NumberInt或NumberLong类,分别表示 4 字节或 8 字节的有符号整数。
{"x" : NumberInt("3")}
{"x" : NumberLong("3")}
字符串
任何 UTF-8 字符的字符串都可以使用字符串类型表示:
{"x" : "foobar"}
日期
MongoDB 将日期存储为表示自 Unix 纪元(1970 年 1 月 1 日)以来的毫秒数的 64 位整数。不存储时区:
{"x" : new Date()}
正则表达式
查询可以使用 JavaScript 的正则表达式语法来使用正则表达式:
{"x" : /foobar/i}
数组
值的集合或列表可以表示为数组:
{"x" : ["a", "b", "c"]}
嵌入式文档
文档可以包含作为父文档中值嵌入的整个文档:
{"x" : {"foo" : "bar"}}
对象 ID
对象 ID 是文档的 12 字节 ID:
{"x" : ObjectId()}
有关详细信息,请参见“_id 和 ObjectIds”部分。
还有一些较少见的类型,您可能需要使用,包括:
二进制数据
二进制数据是一串任意字节的字符串。无法从 shell 中操作它。二进制数据是将非 UTF-8 字符串保存到数据库的唯一方法。
代码
MongoDB 还可以在查询和文档中存储任意 JavaScript:
{"x" : function() { /* ... */ }}
最后,还有一些大多数情况下仅在内部使用的类型(或已被其他类型取代)。这些将根据需要在文本中描述。
有关 MongoDB 数据格式的更多信息,请参见附录 B。
日期
在 JavaScript 中,Date类用于 MongoDB 的日期类型。创建新的Date对象时,始终调用new Date(),而不仅仅是Date()。调用构造函数作为函数(即不包括new)会返回日期的字符串表示,而不是实际的Date对象。这不是 MongoDB 的选择;这是 JavaScript 的工作原理。如果不小心始终使用Date构造函数,可能会得到一堆字符串和日期。字符串与日期不匹配,反之亦然,因此这可能会导致删除、更新、查询等方面出现问题。
有关 JavaScript 的Date类的完整解释和构造函数的可接受格式,请参见ECMAScript 规范的第 15.9 节。
shell 中的日期使用本地时区设置显示。但是,数据库中的日期仅存储为自纪元以来的毫秒数,因此它们没有与之关联的时区信息。(当然,时区信息可以作为另一个键的值存储。)
数组
数组是可以在有序操作(如列表、堆栈或队列)和无序操作(如集合)中互换使用的值。
在以下文档中,键"things"具有一个数组值:
{"things" : ["pie", 3.14]}
从这个例子中可以看出,数组可以包含不同的数据类型作为值(在本例中是字符串和浮点数)。实际上,数组值可以是任何正常键/值对支持的值类型,甚至是嵌套数组。
文档中数组的一个很棒的地方是 MongoDB“理解”它们的结构,并知道如何深入数组内部执行操作。这使得我们能够在数组上进行查询并使用它们的内容构建索引。例如,在前面的例子中,MongoDB 可以查询所有包含3.14作为"things"数组元素的文档。如果这是一个常见的查询,甚至可以在"things"键上创建一个索引来提高查询速度。
MongoDB 还允许原子更新,可以修改数组的内容,例如深入数组并将值"pie"更改为pi。我们将在文本中看到更多这类操作的示例。
嵌入式文档
文档可以作为键的值。这称为嵌入式文档。嵌入式文档可以用于以比仅有键/值对的平坦结构更自然的方式组织数据。
例如,如果我们有一个表示人的文档,并希望存储该人的地址,我们可以将这些信息嵌套在嵌入式"address"文档中:
{
"name" : "John Doe",
"address" : {
"street" : "123 Park Street",
"city" : "Anytown",
"state" : "NY"
}
}
在这个例子中,"address"键的值是一个带有自己的键/值对("street"、"city"和"state")的嵌入式文档。
就像数组一样,MongoDB“理解”嵌入式文档的结构,并能够深入其中来构建索引,执行查询或进行更新。
我们将深入讨论架构设计,但即使从这个基本例子中,我们也可以开始看到嵌入式文档如何改变我们处理数据的方式。在关系数据库中,前面的文档可能会被建模为两个不同表格(people和addresses)中的两行。使用 MongoDB,我们可以直接将"address"文档嵌入"person"文档中。因此,当正确使用时,嵌入式文档可以提供更自然的信息表示。
这样做的反面是在 MongoDB 中可能会有更多的数据重复。假设addresses在关系数据库中是一个单独的表,我们需要修正一个地址中的拼写错误。当我们与people和addresses进行联接时,我们会为所有共享地址的人得到更新后的地址。在 MongoDB 中,我们需要在每个人的文档中修正这个拼写错误。
_id 和 ObjectIds
存储在 MongoDB 中的每个文档必须具有一个 "_id" 键。"_id" 键的值可以是任何类型,但默认为 ObjectId。在单个集合中,每个文档的 "_id" 必须具有唯一值,这确保了集合中的每个文档都可以被唯一标识。也就是说,如果您有两个集合,每个集合都可以有一个 "_id" 值为 123 的文档。但是,不能在任一集合中包含多个具有 "_id" 为 123 的文档。
对象标识符
ObjectId 是 "_id" 的默认类型。ObjectId 类被设计为轻量级,同时又能够以在不同机器间全局唯一的方式生成。MongoDB 的分布式特性是它使用 ObjectId 而不是像自增主键这样更传统的东西的主要原因:在多台服务器上同步自增主键是困难且耗时的。因为 MongoDB 设计为分布式数据库,能够在分片环境中生成唯一标识符至关重要。
ObjectIds 使用 12 字节的存储空间,这使它们具有一个包含 24 个十六进制数字的字符串表示形式:每个字节有 2 个数字。这导致它们看起来比它们实际上更大,这让一些人感到紧张。重要的是要注意,即使 ObjectId 经常被表示为一个巨大的十六进制字符串,这个字符串实际上是存储的数据的两倍长。
如果您快速连续创建多个新的 ObjectIds,您会看到每次仅最后几位数字发生变化。此外,如果您将创建间隔几秒钟,ObjectId 中间的一些数字将发生变化。这是由于 ObjectIds 的生成方式。ObjectId 的 12 字节生成如下:
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 时间戳 | 随机值 | 计数器(随机起始值) |
ObjectId 的前四个字节是自纪元以来的秒数时间戳。这提供了一些有用的特性:
-
时间戳与接下来的五个字节(稍后会描述)结合使用,以秒为粒度提供唯一性。
-
因为时间戳首先出现,
ObjectIds 将按照大致插入顺序排序。这不是一个强有力的保证,但确实具有一些好的属性,例如使ObjectIds 易于索引。 -
在这四个字节中存在一个隐含的时间戳,表示每个文档创建的时间。大多数驱动程序提供了从
ObjectId提取此信息的方法。
因为当前时间用于 ObjectIds,一些用户担心他们的服务器需要具有同步时钟。尽管出于其他原因同步时钟是一个好主意(参见 “同步时钟”),实际的时间戳对 ObjectIds 并不重要,只要它通常是新的(每秒一次)并且递增的。
ObjectId的下一个五个字节是一个随机值。最后三个字节是一个计数器,从一个随机值开始,以避免在不同机器上生成冲突的ObjectId:
因此,ObjectId的前九个字节确保了在单个秒内跨机器和进程的唯一性。最后三个字节仅仅是一个递增计数器,负责确保在单个进程内每秒生成的唯一性。这允许在单个秒内生成多达 256³(16,777,216)个进程内的唯一ObjectId。
自动生成的 _id
如前所述,如果在插入文档时不存在"_id"键,则将自动向插入的文档添加一个。这可以由 MongoDB 服务器处理,但通常由客户端驱动程序执行。
使用 MongoDB Shell
本节涵盖了如何将 shell 作为命令行工具的一部分使用、自定义它以及使用一些更高级的功能。
尽管我们上面连接到了一个本地的mongod实例,你可以连接你的 shell 到你的机器可以访问的任何 MongoDB 实例。要连接到不同机器或端口的mongod,请在启动 shell 时指定主机名、端口和数据库:
$ mongo some-host:30000/myDB
MongoDB shell version: 4.2.0
connecting to: some-host:30000/myDB
>
现在,db将引用some-host:30000的myDB数据库。
有时,在启动mongo shell 时根本不连接到mongod也很方便。如果你使用--nodb启动 shell,它将在启动时不尝试连接任何东西:
$ mongo --nodb
MongoDB shell version: 4.2.0
>
一旦启动,你可以通过运行new Mongo("*`hostname`*")随意连接到mongod:
> conn = new Mongo("some-host:30000")
connection to some-host:30000
> db = conn.getDB("myDB")
myDB
在执行这两个命令之后,你可以正常使用db。你可以随时使用这些命令连接到不同的数据库或服务器。
使用 Shell 的技巧
因为mongo仅仅是一个 JavaScript shell,你可以通过简单地在线查阅 JavaScript 文档为其获取大量帮助。对于特定于 MongoDB 的功能,该 shell 包含内置帮助,可通过输入**`help`**来访问:
> help
db.help() help on db methods
db.mycoll.help() help on collection methods
sh.help() sharding helpers
...
show dbs show database names
show collections show collections in current database
show users show users in current database
...
db.help()提供数据库级帮助,db.foo.help()提供集合级帮助。
弄清楚一个函数在做什么的一个好方法是不带括号地键入它。这将打印函数的 JavaScript 源代码。例如,如果你想知道update函数是如何工作的,或者无法记住参数的顺序,你可以执行以下操作:
> db.movies.updateOne
function (filter, update, options) {
var opts = Object.extend({}, options || {});
// Check if first key in update statement contains a $
var keys = Object.keys(update);
if (keys.length == 0) {
throw new Error("the update operation document must contain at
least one atomic operator");
}
...
使用 Shell 运行脚本
除了交互式使用 shell 外,你还可以将 shell JavaScript 文件传递给执行。只需在命令行中传递你的脚本:
$ mongo script1.js script2.js script3.js
MongoDB shell version: 4.2.1
connecting to: mongodb://127.0.0.1:27017
MongoDB server version: 4.2.1
loading file: script1.js
I am script1.js
loading file: script2.js
I am script2.js
loading file: script3.js
I am script3.js
...
mongo shell 将执行列出的每个脚本并退出。
如果你想使用连接到非默认主机/端口mongod的连接运行脚本,请首先指定地址,然后再指定脚本(s):
$ mongo server-1:30000/foo --quiet script1.js script2.js script3.js
这将在server-1:30000上的foo数据库上设置db,并执行这三个脚本。
您可以在脚本中使用 print 函数将输出打印到 stdout(如前述脚本所示)。这使您可以将 shell 作为命令管道的一部分使用。如果您计划将 shell 脚本的输出管道传递给另一个命令,请使用 --quiet 选项阻止打印“MongoDB shell version v4.2.0”横幅。
您还可以使用 load 函数在交互式 shell 内运行脚本:
> load("script1.js")
I am script1.js
true
>
脚本可以访问 db 变量(以及任何其他全局变量)。但是,诸如 use db 或 show collections 这样的 shell 辅助程序在文件中无效。每个这类辅助程序都有其有效的 JavaScript 等效方法,如 Table 2-1 所示。
表 2-1. shell 辅助程序的 JavaScript 等效功能
| 辅助功能 | 等效功能 |
|---|---|
use video |
db.getSisterDB("video") |
show dbs |
db.getMongo().getDBs() |
show collections |
db.getCollectionNames() |
您还可以使用脚本将变量注入到 shell 中。例如,您可以编写一个简单的脚本,初始化您常用的辅助函数。例如,下面的脚本可能对 Part III 和 Part IV 有帮助。它定义了一个名为 connectTo 的函数,该函数连接到给定端口上的本地运行数据库,并将 db 设置为该连接:
// defineConnectTo.js
/**
* Connect to a database and set db.
*/
var connectTo = function(port, dbname) {
if (!port) {
port = 27017;
}
if (!dbname) {
dbname = "test";
}
db = connect("localhost:"+port+"/"+dbname);
return db;
};
如果在 shell 中加载此脚本,则现在已定义了 connectTo:
> typeof connectTo
undefined
> load('defineConnectTo.js')
> typeof connectTo
function
除了添加辅助函数外,您还可以使用脚本自动化常见任务和管理活动。
默认情况下,shell 将查找您启动 shell 的目录(使用 pwd() 查看该目录)。如果脚本不在当前目录中,可以向 shell 提供相对或绝对路径。例如,如果您想将 shell 脚本放在 ~/my-scripts 中,可以使用 load("/home/myUser/my-scripts/defineConnectTo.js") 加载 defineConnectTo.js。请注意,load 无法解析 ~。
您可以使用 run 从 shell 运行命令行程序。您可以将参数作为参数传递给该函数:
> run("ls", "-l", "/home/myUser/my-scripts/")
sh70352| -rw-r--r-- 1 myUser myUser 2012-12-13 13:15 defineConnectTo.js
sh70532| -rw-r--r-- 1 myUser myUser 2013-02-22 15:10 script1.js
sh70532| -rw-r--r-- 1 myUser myUser 2013-02-22 15:12 script2.js
sh70532| -rw-r--r-- 1 myUser myUser 2013-02-22 15:13 script3.js
由于输出格式化不正常且不支持管道,这是有限使用的。
创建一个 .mongorc.js
如果您经常加载脚本,可能希望将它们放在 .mongorc.js 文件中。每当您启动 shell 时,该文件都会运行。
例如,假设您希望 shell 在您登录时向您打招呼。在您的主目录中创建一个名为 .mongorc.js 的文件,然后将以下行添加到该文件中:
// .mongorc.js
var compliment = ["attractive", "intelligent", "like Batman"];
var index = Math.floor(Math.random()*3);
print("Hello, you're looking particularly "+compliment[index]+" today!");
然后,当您启动 shell 时,您将看到如下内容:
$ mongo
MongoDB shell version: 4.2.1
connecting to: test
Hello, you're looking particularly like Batman today!
>
更实际地说,您可以使用此脚本设置任何您想要使用的全局变量,将长名称别名为更短的名称,并覆盖内置函数。.mongorc.js 最常用的用途之一是删除一些更“危险”的 shell 辅助程序。您可以通过无操作或完全未定义来覆盖诸如 dropDatabase 或 deleteIndexes 等函数:
var no = function() {
print("Not on my watch.");
};
// Prevent dropping databases
db.dropDatabase = DB.prototype.dropDatabase = no;
// Prevent dropping collections
DBCollection.prototype.drop = no;
// Prevent dropping an index
DBCollection.prototype.dropIndex = no;
// Prevent dropping indexes
DBCollection.prototype.dropIndexes = no;
现在,如果您尝试调用任何这些函数,它将简单地打印一个错误消息。请注意,这种技术不能保护您免受恶意用户的攻击;它只能帮助防止输错。
您可以通过在启动 shell 时使用 --norc 选项来禁用加载您的 .mongorc.js。
自定义您的提示符
可通过将 prompt 变量设置为字符串或函数来覆盖默认的 shell 提示符。例如,如果您运行一个需要几分钟才能完成的查询,您可能希望有一个提示符显示当前时间,以便您知道最后操作完成的时间:
prompt = function() {
return (new Date())+"> ";
};
另一个方便的提示可能会显示你正在使用的当前数据库:
prompt = function() {
if (typeof db == 'undefined') {
return '(nodb)> ';
}
// Check the last db operation
try {
db.runCommand({getLastError:1});
}
catch (e) {
print(e);
}
return db+"> ";
};
请注意,提示函数应返回字符串,并且在捕获异常时应非常谨慎:如果您的提示变成异常,这可能会极其令人困惑!
通常,您的提示函数应包括对 getLastError 的调用。这可以捕获写入时的错误,并在 shell 断开连接时自动重新连接您(例如,如果重新启动 mongod)。
如果您希望始终使用自定义提示符(或设置几个可以在 shell 中切换的自定义提示符),.mongorc.js 文件是一个不错的选择。
编辑复杂的变量
Shell 中的多行支持有些有限:您无法编辑之前的行,当您意识到第一行有拼写错误并且当前正在处理第 15 行时,这可能会令人恼火。因此,对于更大的代码块或对象,您可能希望在编辑器中编辑它们。要这样做,请在 shell 中设置 EDITOR 变量(或在您的环境中设置,但既然您已经在 shell 中了…):
> EDITOR="/usr/bin/emacs"
现在,如果您想编辑一个变量,您可以说 edit varname —例如:
> var wap = db.books.findOne({title: "War and Peace"});
> edit wap
当您完成更改时,请保存并退出编辑器。变量将被解析并加载回到 shell 中。
向您的 .mongorc.js 文件添加 EDITOR="*`/path/to/editor`*";,这样您就不必担心再次设置它了。
不方便的集合名称
使用 db.*`collectionName`* 语法获取集合几乎总是有效,除非集合名称是保留字或无效的 JavaScript 属性名称。
例如,假设我们正在尝试访问 version 集合。我们不能说 db.version,因为 db.version 是 db 上的一个方法(它返回运行中的 MongoDB 服务器的版本):
> db.version
function () {
return this.serverBuildInfo().version;
}
要实际访问 version 集合,您必须使用 getCollection 函数:
> db.getCollection("version");
test.version
这也适用于具有不是有效 JavaScript 属性名称的字符的集合名称,例如 foo-bar-baz 和 123abc(JavaScript 属性名称只能包含字母、数字、$ 和 _,并且不能以数字开头)。
另一种避免无效属性的方法是使用数组访问语法。在 JavaScript 中,x.y 和 x['y'] 是相同的。这意味着可以使用变量访问子集合,而不仅仅是字面名称。因此,如果你需要对每个 blog 子集合执行某些操作,可以像这样进行迭代:
var collections = ["posts", "comments", "authors"];
for (var i in collections) {
print(db.blog[collections[i]]);
}
而不是这样:
print(db.blog.posts);
print(db.blog.comments);
print(db.blog.authors);
注意,你不能执行 db.blog.i,这会被解释为 test.blog.i,而不是 test.blog.posts。你必须使用 db.blog[i] 语法,使 i 被解释为一个变量。
你可以使用这种技术来访问命名奇特的集合:
> var name = "@#&!"
> db[name].find()
尝试查询 db.@#&! 是不合法的,但 db[name] 是可以的。
第三章: 创建、更新和删除文档
本章介绍了将数据移入和移出数据库的基础知识,包括以下内容:
-
向集合添加新文档
-
从集合中删除文档
-
更新现有文档
-
在所有这些操作中选择正确的安全性与速度级别
插入文档
插入是向 MongoDB 添加数据的基本方法。 要插入单个文档,请使用集合的insertOne方法:
> db.movies.insertOne({"title" : "Stand by Me"})
insertOne会为文档添加一个"_id"键(如果您没有提供),并将文档存储在 MongoDB 中。
insertMany
如果您需要将多个文档插入集合,可以使用insertMany。 此方法使您可以将文档数组传递到数据库。 这样做效率更高,因为您的代码不会为每个插入的文档进行往返数据库,而是会批量插入。
在 shell 中,您可以按如下方式尝试:
> db.movies.drop()
true
> db.movies.insertMany([{"title" : "Ghostbusters"},
... {"title" : "E.T."},
... {"title" : "Blade Runner"}]);
{
"acknowledged" : true,
"insertedIds" : [
ObjectId("572630ba11722fac4b6b4996"),
ObjectId("572630ba11722fac4b6b4997"),
ObjectId("572630ba11722fac4b6b4998")
]
}
> db.movies.find()
{ "_id" : ObjectId("572630ba11722fac4b6b4996"), "title" : "Ghostbusters" }
{ "_id" : ObjectId("572630ba11722fac4b6b4997"), "title" : "E.T." }
{ "_id" : ObjectId("572630ba11722fac4b6b4998"), "title" : "Blade Runner" }
一次性发送几十、几百甚至上千个文档可以显著加快插入速度。
如果要将多个文档插入单个集合,则insertMany非常有用。 如果仅导入原始数据(例如来自数据源或 MySQL),则可以使用像* mongoimport *这样的命令行工具,而不是批量插入。 另一方面,在将数据保存到 MongoDB 之前对数据进行处理(例如将日期转换为日期类型或添加自定义"_id")通常很方便。 在这种情况下,可以使用insertMany来导入数据。
当前版本的 MongoDB 不接受超过 48 MB 的消息,因此一次批量插入中可以插入的数据量有限。 如果尝试插入超过 48 MB,则许多驱动程序将批量插入拆分为多个 48 MB 的批量插入。 有关详细信息,请查阅您的驱动程序文档。
使用insertMany执行批量插入时,如果数组中间的某个文档产生某种类型的错误,发生的情况取决于您是否选择了有序或无序操作。 作为insertMany的第二个参数,您可以指定一个选项文档。 在选项文档中为键"ordered"指定true,以确保按提供顺序插入文档。 指定false,MongoDB 可以重新排序插入以提高性能。 如果未指定排序,则默认为有序插入。 对于有序插入,传递给insertMany的数组定义了插入顺序。 如果文档产生插入错误,则不会插入数组中该点之后的任何文档。 对于无序插入,MongoDB 将尝试插入所有文档,而不管某些插入是否产生错误。
在本示例中,由于有序插入是默认的,只会插入前两个文档。 第三个文档会产生错误,因为您不能插入具有相同"_id"的两个文档:
> db.movies.insertMany([
... {"_id" : 0, "title" : "Top Gun"},
... {"_id" : 1, "title" : "Back to the Future"},
... {"_id" : 1, "title" : "Gremlins"},
... {"_id" : 2, "title" : "Aliens"}])
2019-04-22T12:27:57.278-0400 E QUERY [js] BulkWriteError: write
error at item 2 in bulk operation :
BulkWriteError({
"writeErrors" : [
{
"index" : 2,
"code" : 11000,
"errmsg" : "E11000 duplicate key error collection:
test.movies index: _id_ dup key: { _id: 1.0 }",
"op" : {
"_id" : 1,
"title" : "Gremlins"
}
}
],
"writeConcernErrors" : [ ],
"nInserted" : 2,
"nUpserted" : 0,
"nMatched" : 0,
"nModified" : 0,
"nRemoved" : 0,
"upserted" : [ ]
})
BulkWriteError@src/mongo/shell/bulk_api.js:367:48
BulkWriteResult/this.toError@src/mongo/shell/bulk_api.js:332:24
Bulk/this.execute@src/mongo/shell/bulk_api.js:1186:23
DBCollection.prototype.insertMany@src/mongo/shell/crud_api.js:314:5
@(shell):1:1
如果我们指定无序插入,数组中的第一、第二和第四个文档将被插入。唯一失败的插入是第三个文档,同样是因为重复的"_id"错误。
> db.movies.insertMany([
... {"_id" : 3, "title" : "Sixteen Candles"},
... {"_id" : 4, "title" : "The Terminator"},
... {"_id" : 4, "title" : "The Princess Bride"},
... {"_id" : 5, "title" : "Scarface"}],
... {"ordered" : false})
2019-05-01T17:02:25.511-0400 E QUERY [thread1] BulkWriteError: write
error at item 2 in bulk operation :
BulkWriteError({
"writeErrors" : [
{
"index" : 2,
"code" : 11000,
"errmsg" : "E11000 duplicate key error index: test.movies.$_id_
dup key: { : 4.0 }",
"op" : {
"_id" : 4,
"title" : "The Princess Bride"
}
}
],
"writeConcernErrors" : [ ],
"nInserted" : 3,
"nUpserted" : 0,
"nMatched" : 0,
"nModified" : 0,
"nRemoved" : 0,
"upserted" : [ ]
})
BulkWriteError@src/mongo/shell/bulk_api.js:367:48
BulkWriteResult/this.toError@src/mongo/shell/bulk_api.js:332:24
Bulk/this.execute@src/mongo/shell/bulk_api.js:1186.23
DBCollection.prototype.insertMany@src/mongo/shell/crud_api.js:314:5
@(shell):1:1
如果您仔细研究这些示例,您可能会注意到insertMany的这两次调用的输出暗示了除了简单插入之外可能支持的其他操作。虽然insertMany不支持除插入之外的操作,但 MongoDB 支持 Bulk Write API,使您能够在一次调用中批量处理多种类型的操作。虽然这超出了本章的范围,您可以在 MongoDB 文档中阅读有关批量写入 API的信息。
插入验证
MongoDB 对插入的数据进行最小的检查:它检查文档的基本结构,并在不存在时添加一个"_id"字段。基本结构检查之一是大小:所有文档的大小必须小于 16 MB。这是一个相对任意的限制(可能在未来会提高);主要用于防止糟糕的模式设计,并确保一致的性能。要查看文档doc的二进制 JSON(BSON)大小(以字节为单位),请在 shell 中运行Object.bsonsize(*`doc`*)。
为了给您一个 16 MB 数据的概念,整个《战争与和平》的文本只有 3.14 MB。
这些最小的检查也意味着相对容易插入无效数据(如果你试图这样做)。因此,您应该只允许信任的来源(如您的应用程序服务器)连接到数据库。所有主要语言的 MongoDB 驱动程序(以及大多数次要语言的驱动程序)在发送任何内容到数据库之前都会检查各种无效数据(例如文档过大、包含非 UTF-8 字符串或使用未识别的类型)。
insert
在 MongoDB 3.0 之前的版本中,insert是将文档插入 MongoDB 的主要方法。MongoDB 驱动程序在 MongoDB 3.0 服务器发布时引入了一个新的 CRUD API。截至 MongoDB 3.2,mongo shell 也支持此 API,其中包括insertOne和insertMany以及其他几种方法。当前 CRUD API 的目标是使所有 CRUD 操作在驱动程序和 shell 中的语义一致和清晰。虽然诸如insert之类的方法仍然支持向后兼容性,但不应在今后的应用程序中使用。您应该优先考虑使用insertOne和insertMany来创建文档。
删除文档
现在我们的数据库中有数据了,让我们删除它。CRUD API 提供了deleteOne和deleteMany来实现此目的。这两种方法的第一个参数都是一个过滤文档。过滤器指定要匹配以删除文档的一组条件。要删除"_id"值为4的文档,我们在mongo shell 中使用deleteOne,如下所示:
> db.movies.find()
{ "_id" : 0, "title" : "Top Gun"}
{ "_id" : 1, "title" : "Back to the Future"}
{ "_id" : 3, "title" : "Sixteen Candles"}
{ "_id" : 4, "title" : "The Terminator"}
{ "_id" : 5, "title" : "Scarface"}
> db.movies.deleteOne({"_id" : 4})
{ "acknowledged" : true, "deletedCount" : 1 }
> db.movies.find()
{ "_id" : 0, "title" : "Top Gun"}
{ "_id" : 1, "title" : "Back to the Future"}
{ "_id" : 3, "title" : "Sixteen Candles"}
{ "_id" : 5, "title" : "Scarface"}
在本例中,我们使用了一个过滤器,该过滤器只能匹配集合中的一个文档,因为 "_id" 值在集合中是唯一的。但是,我们也可以指定一个可以匹配集合中多个文档的过滤器。在这种情况下,deleteOne 将删除首个与过滤器匹配的文档。首先找到哪个文档取决于几个因素,包括文档插入的顺序,对文档的更新(对于某些存储引擎),以及指定的索引。与任何数据库操作一样,确保你知道使用 deleteOne 将对数据产生什么影响。
要删除匹配过滤器的所有文档,请使用 deleteMany:
> db.movies.find()
{ "_id" : 0, "title" : "Top Gun", "year" : 1986 }
{ "_id" : 1, "title" : "Back to the Future", "year" : 1985 }
{ "_id" : 3, "title" : "Sixteen Candles", "year" : 1984 }
{ "_id" : 4, "title" : "The Terminator", "year" : 1984 }
{ "_id" : 5, "title" : "Scarface", "year" : 1983 }
> db.movies.deleteMany({"year" : 1984})
{ "acknowledged" : true, "deletedCount" : 2 }
> db.movies.find()
{ "_id" : 0, "title" : "Top Gun", "year" : 1986 }
{ "_id" : 1, "title" : "Back to the Future", "year" : 1985 }
{ "_id" : 5, "title" : "Scarface", "year" : 1983 }
作为更实际的用例,假设你想删除 mailing.list 集合中每个 "opt-out" 值为 true 的用户:
> db.mailing.list.deleteMany({"opt-out" : true})
在 MongoDB 3.0 之前的版本中,remove 是删除文档的主要方法。MongoDB 驱动程序在 MongoDB 3.0 服务器发布时同时引入了 deleteOne 和 deleteMany 方法,而 shell 在 MongoDB 3.2 开始支持这些方法。虽然为了向后兼容仍支持 remove 方法,但在你的应用程序中应该使用 deleteOne 和 deleteMany。当前的 CRUD API 提供了更清晰的语义集,特别是对于多文档操作,帮助应用程序开发人员避免以前 API 的一些常见陷阱。
drop
可以使用 deleteMany 来删除集合中的所有文档:
> db.movies.find()
{ "_id" : 0, "title" : "Top Gun", "year" : 1986 }
{ "_id" : 1, "title" : "Back to the Future", "year" : 1985 }
{ "_id" : 3, "title" : "Sixteen Candles", "year" : 1984 }
{ "_id" : 4, "title" : "The Terminator", "year" : 1984 }
{ "_id" : 5, "title" : "Scarface", "year" : 1983 }
> db.movies.deleteMany({})
{ "acknowledged" : true, "deletedCount" : 5 }
> db.movies.find()
删除文档通常是一个相当快速的操作。但是,如果要清空整个集合,最快的方法是使用 drop:
> db.movies.drop()
true
然后在空集合上重新创建任何索引。
一旦数据被删除,就永远消失了。除非恢复先前备份版本的数据,否则无法撤消删除或删除文档。详细讨论 MongoDB 备份和恢复,请参阅 第二十三章。
更新文档
一旦文档存储在数据库中,就可以使用几种更新方法进行更改:updateOne、updateMany 和 replaceOne。updateOne 和 updateMany 各自将一个过滤器文档作为其第一个参数,并将描述要进行的更改的修改器文档作为第二个参数。replaceOne 也接受一个过滤器作为第一个参数,但是作为第二个参数,replaceOne 期望一个用于替换与过滤器匹配的文档的文档。
更新文档是原子性的:如果同时发生两个更新,首先到达服务器的那一个将被应用,然后将应用下一个。因此,可以安全地连续发送冲突的更新而不会损坏任何文档:最后一个更新将“获胜”。如果不想使用默认行为,可以考虑文档版本模式(参见 “模式设计模式”)。
文档替换
replaceOne完全用新文档替换匹配的文档。这对于进行重大的模式迁移非常有用(参见第九章中的模式迁移策略)。例如,假设我们正在对用户文档进行重大更改,其格式如下:
{
"_id" : ObjectId("4b2b9f67a1f631733d917a7a"),
"name" : "joe",
"friends" : 32,
"enemies" : 2
}
我们希望将"friends"和"enemies"字段移动到"relationships"子文档中。我们可以在 Shell 中更改文档的结构,然后用replaceOne替换数据库的版本:
> var joe = db.users.findOne({"name" : "joe"});
> joe.relationships = {"friends" : joe.friends, "enemies" : joe.enemies};
{
"friends" : 32,
"enemies" : 2
}
> joe.username = joe.name;
"joe"
> delete joe.friends;
true
> delete joe.enemies;
true
> delete joe.name;
true
> db.users.replaceOne({"name" : "joe"}, joe);
现在,执行findOne显示文档的结构已更新:
{
"_id" : ObjectId("4b2b9f67a1f631733d917a7a"),
"username" : "joe",
"relationships" : {
"friends" : 32,
"enemies" : 2
}
}
一个常见的错误是根据条件匹配多个文档,然后使用第二个参数创建一个重复的"_id"值。数据库会因此抛出错误,并且不会更新任何文档。
例如,假设我们创建了几个具有相同"name"值的文档,但我们没有意识到:
> db.people.find()
{"_id" : ObjectId("4b2b9f67a1f631733d917a7b"), "name" : "joe", "age" : 65}
{"_id" : ObjectId("4b2b9f67a1f631733d917a7c"), "name" : "joe", "age" : 20}
{"_id" : ObjectId("4b2b9f67a1f631733d917a7d"), "name" : "joe", "age" : 49}
现在,如果是 Joe #2 的生日,我们想增加他的"age"键的值,我们可能会这样说:
> joe = db.people.findOne({"name" : "joe", "age" : 20});
{
"_id" : ObjectId("4b2b9f67a1f631733d917a7c"),
"name" : "joe",
"age" : 20
}
> joe.age++;
> db.people.replaceOne({"name" : "joe"}, joe);
E11001 duplicate key on update
发生了什么?当您进行更新时,数据库将寻找匹配{"name" : "joe"}的文档。它找到的第一个将是 65 岁的 Joe。它将尝试用joe变量中的文档替换该文档,但在这个集合中已经有一个具有相同"_id"的文档。因此,更新将失败,因为"_id"值必须是唯一的。避免这种情况的最佳方法是确保您的更新始终指定一个唯一的文档,可能是通过匹配"_id"之类的键。对于前面的例子,这将是正确的更新使用方法:
> db.people.replaceOne({"_id" : ObjectId("4b2b9f67a1f631733d917a7c")}, joe)
使用"_id"作为过滤器也是高效的,因为"_id"值形成了集合的主索引的基础。我们将在第五章更详细地讨论主索引和次要索引以及索引如何影响更新和其他操作。
使用更新操作符
通常只需更新文档的某些部分。您可以使用原子更新操作符来更新文档中的特定字段。更新操作符是特殊的键,可用于指定复杂的更新操作,如更改、添加或删除键,甚至操作数组和嵌入式文档。
假设我们将网站分析保存在一个集合中,并且希望每次有人访问页面时都增加计数器。我们可以使用更新操作符来原子地进行增量操作。每个 URL 及其页面浏览次数存储在如下格式的文档中:
{
"_id" : ObjectId("4b253b067525f35f94b60a31"),
"url" : "www.example.com",
"pageviews" : 52
}
每当有人访问页面时,我们可以根据其 URL 找到页面,并使用"$inc"修饰符增加"pageviews"键的值:
> db.analytics.updateOne({"url" : "www.example.com"},
... {"$inc" : {"pageviews" : 1}})
{ "acknowledged" : true, "matchedCount" : 1, "modifiedCount" : 1 }
现在,如果我们执行findOne,会发现"pageviews"已增加了一次:
> db.analytics.findOne()
{
"_id" : ObjectId("4b253b067525f35f94b60a31"),
"url" : "www.example.com",
"pageviews" : 53
}
在使用操作符时,"_id"的值不能被更改。(请注意,通过使用整个文档替换,"_id"可以被更改。)任何其他键的值,包括其他唯一索引键,都可以被修改。
入门“$set”修饰符
"$set" 用于设置字段的值。如果字段尚不存在,它将被创建。这对于更新模式或添加用户定义的键非常方便。例如,假设您有一个简单的用户配置文件,存储为以下形式的文档:
> db.users.findOne()
{
"_id" : ObjectId("4b253b067525f35f94b60a31"),
"name" : "joe",
"age" : 30,
"sex" : "male",
"location" : "Wisconsin"
}
这是一个相当简单的用户配置文件。如果用户想要在其配置文件中存储他喜欢的书籍,可以使用 "$set" 添加它:
> db.users.updateOne({"_id" : ObjectId("4b253b067525f35f94b60a31")},
... {"$set" : {"favorite book" : "War and Peace"}})
现在文档将有一个 "favorite book" 键:
> db.users.findOne()
{
"_id" : ObjectId("4b253b067525f35f94b60a31"),
"name" : "joe",
"age" : 30,
"sex" : "male",
"location" : "Wisconsin",
"favorite book" : "War and Peace"
}
如果用户决定他实际上喜欢不同的书籍,可以再次使用 "$set" 更改值:
> db.users.updateOne({"name" : "joe"},
... {"$set" : {"favorite book" : "Green Eggs and Ham"}})
"$set" 甚至可以更改它修改的键的类型。例如,如果我们善变的用户决定他实际上喜欢很多书籍,可以将 "favorite book" 键的值更改为数组:
> db.users.updateOne({"name" : "joe"},
... {"$set" : {"favorite book" :
... ["Cat's Cradle", "Foundation Trilogy", "Ender's Game"]}})
如果用户意识到他实际上并不喜欢阅读,可以使用 "$unset" 完全删除该键:
> db.users.updateOne({"name" : "joe"},
... {"$unset" : {"favorite book" : 1}})
现在文档将与本例开始时的文档相同。
您还可以使用 "$set" 进行嵌入式文档的更改:
> db.blog.posts.findOne()
{
"_id" : ObjectId("4b253b067525f35f94b60a31"),
"title" : "A Blog Post",
"content" : "...",
"author" : {
"name" : "joe",
"email" : "joe@example.com"
}
}
> db.blog.posts.updateOne({"author.name" : "joe"},
... {"$set" : {"author.name" : "joe schmoe"}})
> db.blog.posts.findOne()
{
"_id" : ObjectId("4b253b067525f35f94b60a31"),
"title" : "A Blog Post",
"content" : "...",
"author" : {
"name" : "joe schmoe",
"email" : "joe@example.com"
}
}
您必须始终使用 $ 修饰符来添加、更改或移除键。初学者开始时常见的错误是尝试通过类似于以下更新来将键的值设置为其他值:
> db.blog.posts.updateOne({"author.name" : "joe"},
... {"author.name" : "joe schmoe"})
这将导致错误。更新文档必须包含更新操作符。之前的 CRUD API 版本没有捕获这种类型的错误。在早期的更新方法中,在这种情况下通常会简单地完成整个文档的替换。正是这种陷阱导致了新的 CRUD API 的创建。
自增和自减
"$inc" 操作符可用于更改现有键的值或在尚不存在时创建新键。用于更新分析、声望、投票或任何具有可变数值的内容非常有用。
假设我们正在创建一个游戏集合,我们希望保存游戏并在其变化时更新分数。当用户开始玩,比如说弹球游戏时,我们可以插入一个文档,其中包含游戏名称和正在玩游戏的用户:
> db.games.insertOne({"game" : "pinball", "user" : "joe"})
当球撞到保险杠时,游戏应增加玩家的分数。由于弹球中的分数很容易获得,让我们假设玩家可以获得的基本单位分数是 50。我们可以使用 "$inc" 修饰符将 50 添加到玩家的分数中:
> db.games.updateOne({"game" : "pinball", "user" : "joe"},
... {"$inc" : {"score" : 50}})
如果我们在此更新之后查看文档,我们将看到以下内容:
> db.games.findOne()
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"game" : "pinball",
"user" : "joe",
"score" : 50
}
"score" 键尚不存在,因此 "$inc" 创建了它,并设置为增量金额:50。
如果球落入“奖励”槽中,我们希望在分数上增加 10,000。我们可以通过向 "$inc" 传递不同的值来实现这一点:
> db.games.updateOne({"game" : "pinball", "user" : "joe"},
... {"$inc" : {"score" : 10000}})
现在如果我们查看游戏,我们将看到以下内容:
> db.games.findOne()
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"game" : "pinball",
"user" : "joe",
"score" : 10050
}
"score" 键存在且具有数值,因此服务器向其添加了 10,000。
"$inc"类似于"$set",但设计用于增加(和减少)数字。"$inc"只能用于整数、长整数、双精度浮点数或十进制数值类型的值。如果用于其他类型的值,操作将失败。这包括许多语言会自动转换为数字的类型,如空值、布尔值或数值字符的字符串:
> db.strcounts.insert({"count" : "1"})
WriteResult({ "nInserted" : 1 })
> db.strcounts.update({}, {"$inc" : {"count" : 1}})
WriteResult({
"nMatched" : 0,
"nUpserted" : 0,
"nModified" : 0,
"writeError" : {
"code" : 16837,
"errmsg" : "Cannot apply $inc to a value of non-numeric type.
{_id: ObjectId('5726c0d36855a935cb57a659')} has the field 'count' of
non-numeric type String"
}
})
此外,"$inc"键的值必须是一个数字。不能按字符串、数组或其他非数值值增加。这样做将导致“Modifier "$inc" allowed for numbers only”错误消息。要修改其他类型,请使用"$set"或以下数组操作符之一。
数组操作符
存在大量的更新操作符用于操作数组。数组是常见且功能强大的数据结构:它们不仅可以作为可以通过索引引用的列表,还可以充当集合。
添加元素
"$push"会将元素添加到数组末尾(如果数组存在)或创建一个新数组(如果不存在)。例如,假设我们正在存储博客文章,并希望添加一个包含数组的"comments"键。我们可以将评论推入不存在的"comments"数组中,这将创建数组并添加评论:
> db.blog.posts.findOne()
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"title" : "A blog post",
"content" : "..."
}
> db.blog.posts.updateOne({"title" : "A blog post"},
... {"$push" : {"comments" :
... {"name" : "joe", "email" : "joe@example.com",
... "content" : "nice post."}}})
{ "acknowledged" : true, "matchedCount" : 1, "modifiedCount" : 1 }
> db.blog.posts.findOne()
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"title" : "A blog post",
"content" : "...",
"comments" : [
{
"name" : "joe",
"email" : "joe@example.com",
"content" : "nice post."
}
]
}
现在,如果我们想再添加一个评论,只需再次使用"$push"即可:
> db.blog.posts.updateOne({"title" : "A blog post"},
... {"$push" : {"comments" :
... {"name" : "bob", "email" : "bob@example.com",
... "content" : "good post."}}})
{ "acknowledged" : true, "matchedCount" : 1, "modifiedCount" : 1 }
> db.blog.posts.findOne()
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"title" : "A blog post",
"content" : "...",
"comments" : [
{
"name" : "joe",
"email" : "joe@example.com",
"content" : "nice post."
},
{
"name" : "bob",
"email" : "bob@example.com",
"content" : "good post."
}
]
}
这是"push"的“简单”形式,但你也可以用它进行更复杂的数组操作。MongoDB 查询语言为某些操作符提供了修饰符,包括"$push"。你可以使用"$each"修饰符一次推送多个值到数组中的"$push"操作中:
> db.stock.ticker.updateOne({"_id" : "GOOG"},
... {"$push" : {"hourly" : {"$each" : [562.776, 562.790, 559.123]}}})
这将向数组推入三个新元素。
如果你只希望数组增长到特定长度,可以使用"$slice"修饰符和"$push"一起防止数组超出某个大小,有效地创建一个“前 N”项列表:
> db.movies.updateOne({"genre" : "horror"},
... {"$push" : {"top10" : {"$each" : ["Nightmare on Elm Street", "Saw"],
... "$slice" : -10}}})
此示例将数组限制为最后推入的 10 个元素。
如果数组在推入后少于 10 个元素,则保留所有元素。如果数组大于 10 个元素,则只保留最后 10 个元素。因此,"$slice"可用于在文档中创建队列。
最后,你可以在裁剪之前对"$push"操作应用"$sort"修饰符:
> db.movies.updateOne({"genre" : "horror"},
... {"$push" : {"top10" : {"$each" : [{"name" : "Nightmare on Elm Street",
... "rating" : 6.6},
... {"name" : "Saw", "rating" : 4.3}],
... "$slice" : -10,
... "$sort" : {"rating" : -1}}}})
这会按照数组中的"rating"字段对所有对象进行排序,然后保留前 10 个。请注意,你必须包含"$each";不能仅使用"$slice"或"$sort"来对带有"$push"的数组进行操作。
使用数组作为集合
如果希望将数组视为集合,只在值不存在时添加,可以在查询文档中使用"$ne"来实现。例如,要将作者推入引文列表,但仅在其不存在时才添加,使用以下方法:
> db.papers.updateOne({"authors cited" : {"$ne" : "Richie"}},
... {$push : {"authors cited" : "Richie"}})
这也可以通过"$addToSet"完成,对于"$ne"不适用或"$addToSet"描述更准确的情况非常有用。
例如,假设你有一个代表用户的文档。你可能有一组用户添加的电子邮件地址:
> db.users.findOne({"_id" : ObjectId("4b2d75476cc613d5ee930164")})
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"username" : "joe",
"emails" : [
"joe@example.com",
"joe@gmail.com",
"joe@yahoo.com"
]
}
在添加另一个地址时,可以使用“$addToSet"来防止重复:
> db.users.updateOne({"_id" : ObjectId("4b2d75476cc613d5ee930164")},
... {"$addToSet" : {"emails" : "joe@gmail.com"}})
{ "acknowledged" : true, "matchedCount" : 1, "modifiedCount" : 0 }
> db.users.findOne({"_id" : ObjectId("4b2d75476cc613d5ee930164")})
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"username" : "joe",
"emails" : [
"joe@example.com",
"joe@gmail.com",
"joe@yahoo.com",
]
}
> db.users.updateOne({"_id" : ObjectId("4b2d75476cc613d5ee930164")},
... {"$addToSet" : {"emails" : "joe@hotmail.com"}})
{ "acknowledged" : true, "matchedCount" : 1, "modifiedCount" : 1 }
> db.users.findOne({"_id" : ObjectId("4b2d75476cc613d5ee930164")})
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"username" : "joe",
"emails" : [
"joe@example.com",
"joe@gmail.com",
"joe@yahoo.com",
"joe@hotmail.com"
]
}
您还可以将"$addToSet"与"$each"结合使用,以添加多个唯一值,这是无法通过"$ne"/"$push"组合实现的。例如,如果用户想要添加多个电子邮件地址,可以使用这些运算符:
> db.users.updateOne({"_id" : ObjectId("4b2d75476cc613d5ee930164")},
... {"$addToSet" : {"emails" : {"$each" :
... ["joe@php.net", "joe@example.com", "joe@python.org"]}}})
{ "acknowledged" : true, "matchedCount" : 1, "modifiedCount" : 1 }
> db.users.findOne({"_id" : ObjectId("4b2d75476cc613d5ee930164")})
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"username" : "joe",
"emails" : [
"joe@example.com",
"joe@gmail.com",
"joe@yahoo.com",
"joe@hotmail.com"
"joe@php.net"
"joe@python.org"
]
}
删除元素
有几种方法可以从数组中删除元素。如果您想要像队列或堆栈一样处理数组,可以使用"$pop",它可以从任一端删除元素。{"$pop" : {"*key*" : 1}}从数组末尾移除一个元素。{"$pop" : {"*key*" : -1}}从开头移除一个元素。
有时应根据特定标准删除元素,而不是其在数组中的位置。"$pull"用于删除与给定标准匹配的数组元素。例如,假设我们有一个不需要按特定顺序完成的任务清单:
> db.lists.insertOne({"todo" : ["dishes", "laundry", "dry cleaning"]})
如果我们首先洗衣服,可以使用以下方法将其从列表中移除:
> db.lists.updateOne({}, {"$pull" : {"todo" : "laundry"}})
现在,如果我们进行查找,我们会看到数组中只剩下两个元素:
> db.lists.findOne()
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"todo" : [
"dishes",
"dry cleaning"
]
}
"$pull"删除所有匹配的文档,而不仅仅是单个匹配。如果您有一个看起来像[1, 1, 2, 1]的数组,并且使用pull 1,您将得到一个单元素数组[2]。
数组运算符只能用于具有数组值的键。例如,您不能将整数推入栈或从字符串中弹出。使用"$set"或"$inc"来修改标量值。
数组位置修改
当数组中有多个值且希望修改其中一些时,数组操作变得有些棘手。有两种方式可以操作数组中的值:按位置或使用位置运算符($字符)。
数组使用基于 0 的索引,可以选择元素,就像它们的索引是文档键一样。例如,假设我们有一个包含几个嵌入文档的文档,如带有评论的博客文章:
> db.blog.posts.findOne()
{
"_id" : ObjectId("4b329a216cc613d5ee930192"),
"content" : "...",
"comments" : [
{
"comment" : "good post",
"author" : "John",
"votes" : 0
},
{
"comment" : "i thought it was too short",
"author" : "Claire",
"votes" : 3
},
{
"comment" : "free watches",
"author" : "Alice",
"votes" : -5
},
{
"comment" : "vacation getaways",
"author" : "Lynn",
"votes" : -7
}
]
}
如果我们要增加第一条评论的投票数,我们可以这样说:
> db.blog.updateOne({"post" : post_id},
... {"$inc" : {"comments.0.votes" : 1}})
然而,在许多情况下,我们不知道要修改的数组的索引,除非首先查询文档并检查它。为了解决这个问题,MongoDB 引入了位置运算符$,它可以确定查询文档匹配的数组元素,并更新该元素。例如,如果我们有一个名为 John 的用户,他将他的名字更新为 Jim,我们可以使用位置运算符在评论中替换它:
> db.blog.updateOne({"comments.author" : "John"},
... {"$set" : {"comments.$.author" : "Jim"}})
位置运算符仅更新第一个匹配项。因此,如果 John 留下了多条评论,他的名字只会在第一条评论中更改。
使用数组过滤器进行更新
MongoDB 3.6 引入了另一种更新单个数组元素的选项:arrayFilters。此选项使我们能够修改与特定条件匹配的数组元素。例如,如果我们想要隐藏所有具有五个或更多负面评价的评论,我们可以做如下操作:
db.blog.updateOne(
{"post" : post_id },
{ $set: { "comments.$[elem].hidden" : true } },
{
arrayFilters: [ { "elem.votes": { $lte: -5 } } ]
}
)
此命令将elem定义为"comments"数组中每个匹配元素的标识符。如果由elem标识的评论的votes值小于或等于-5,我们将向"comments"文档添加一个名为"hidden"的字段,并将其值设置为true。
Upserts
Upsert 是一种特殊类型的更新。如果未找到与过滤器匹配的文档,将创建一个新文档,通过组合条件和更新文档。如果找到匹配的文档,则正常更新。Upserts 很方便,因为它们可以消除“播种”集合的需要:通常可以使用相同的代码创建和更新文档。
让我们回到记录网站每个页面访问量的示例。如果没有 upsert,我们可能会尝试查找 URL 并增加访问量,或者在 URL 不存在时创建新文档。如果将其编写为 JavaScript 程序,它可能看起来像以下内容:
// check if we have an entry for this page
blog = db.analytics.findOne({url : "/blog"})
// if we do, add one to the number of views and save
if (blog) {
blog.pageviews++;
db.analytics.save(blog);
}
// otherwise, create a new document for this page
else {
db.analytics.insertOne({url : "/blog", pageviews : 1})
}
这意味着每当有人访问页面时,我们都会对数据库进行一次往返,并发送更新或插入操作。如果在多个进程中运行此代码,我们还可能会遇到竞争条件,其中多个文档可能会插入给定的 URL。
我们可以通过仅向数据库发送 upsert(updateOne和updateMany的第三个参数是一个选项文档,允许我们指定此操作)来消除竞争条件并减少代码量:
> db.analytics.updateOne({"url" : "/blog"}, {"$inc" : {"pageviews" : 1}},
... {"upsert" : true})
此行代码与前一个代码块完全相同,只是速度更快且原子化!通过使用条件文档作为基础并应用任何修改文档来创建新文档。
例如,如果您执行一个 upsert,该 upsert 匹配一个键并增加到该键的值,则增量将应用于匹配:
> db.users.updateOne({"rep" : 25}, {"$inc" : {"rep" : 3}}, {"upsert" : true})
WriteResult({
"acknowledged" : true,
"matchedCount" : 0,
"modifiedCount" : 0,
"upsertedId" : ObjectId("5a93b07aaea1cb8780a4cf72")
})
> db.users.findOne({"_id" : ObjectId("5727b2a7223502483c7f3acd")} )
{ "_id" : ObjectId("5727b2a7223502483c7f3acd"), "rep" : 28 }
Upsert 创建一个具有"rep"为25的新文档,然后将其增加3,得到一个"rep"为28的文档。如果未指定 upsert 选项,则{"rep": 25}不会匹配任何文档,因此不会发生任何操作。
如果我们再次运行 upsert(使用条件{"rep": 25}),它将创建另一个新文档。这是因为条件与集合中唯一文档不匹配(其"rep"为28)。
有时,在创建文档时需要设置字段,但在后续更新时不更改。这就是"$setOnInsert"的用途。"$setOnInsert"是一个运算符,只在插入文档时设置字段的值。因此,我们可以像这样做:
> db.users.updateOne({}, {"$setOnInsert" : {"createdAt" : new Date()}},
... {"upsert" : true})
{
"acknowledged" : true,
"matchedCount" : 0,
"modifiedCount" : 0,
"upsertedId" : ObjectId("5727b4ac223502483c7f3ace")
}
> db.users.findOne()
{
"_id" : ObjectId("5727b4ac223502483c7f3ace"),
"createdAt" : ISODate("2016-05-02T20:12:28.640Z")
}
如果我们再次运行此更新,它将匹配现有文档,不会插入任何内容,因此"createdAt"字段不会更改:
> db.users.updateOne({}, {"$setOnInsert" : {"createdAt" : new Date()}},
... {"upsert" : true})
{ "acknowledged" : true, "matchedCount" : 1, "modifiedCount" : 0 }
> db.users.findOne()
{
"_id" : ObjectId("5727b4ac223502483c7f3ace"),
"createdAt" : ISODate("2016-05-02T20:12:28.640Z")
}
请注意,通常不需要保留"createdAt"字段,因为ObjectId包含文档创建时的时间戳。但是,对于不使用ObjectId的集合,"$setOnInsert"可用于创建填充、初始化计数器等情况。
保存 shell 辅助程序
save是一个 shell 函数,允许您在文档不存在时插入它,并在存在时更新它。它接受一个参数:一个文档。如果文档包含"_id"键,save将执行 upsert 操作。否则,它将执行插入操作。save实际上只是一个便利函数,使程序员可以快速在 shell 中修改文档:
> var x = db.testcol.findOne()
> x.num = 42
42
> db.testcol.save(x)
如果没有save,最后一行将会更加繁琐:
db.testcol.replaceOne({"_id" : x._id}, x)
更新多个文档
在本章中,我们迄今为止使用了updateOne来说明更新操作。updateOne只会更新符合过滤条件的第一个找到的文档。如果有更多匹配的文档,它们将保持不变。要修改所有匹配过滤条件的文档,请使用updateMany。updateMany遵循与updateOne相同的语义并接受相同的参数。主要区别在于可能被更改的文档数量。
updateMany为执行架构迁移或向特定用户推出新功能提供了强大的工具。例如,假设我们想要给每个生日在某一天的用户送礼物。我们可以使用updateMany向他们的账户添加一个"gift"。例如:
> db.users.insertMany([
... {birthday: "10/13/1978"},
... {birthday: "10/13/1978"},
... {birthday: "10/13/1978"}])
{
"acknowledged" : true,
"insertedIds" : [
ObjectId("5727d6fc6855a935cb57a65b"),
ObjectId("5727d6fc6855a935cb57a65c"),
ObjectId("5727d6fc6855a935cb57a65d")
]
}
> db.users.updateMany({"birthday" : "10/13/1978"},
... {"$set" : {"gift" : "Happy Birthday!"}})
{ "acknowledged" : true, "matchedCount" : 3, "modifiedCount" : 3 }
调用updateMany方法,为我们刚刚插入到users集合中的三个文档添加了一个"gift"字段。
返回更新后的文档
对于某些用例,返回修改后的文档非常重要。在 MongoDB 的早期版本中,findAndModify是这种情况下的首选方法。它非常适合操作队列和执行其他需要获取并设置样式原子性的操作。但是,findAndModify容易出现用户错误,因为它是一个复杂的方法,结合了删除、替换和更新(包括 upsert)的功能。
MongoDB 3.2 引入了三种新的集合方法到 shell 中,以适应findAndModify的功能,但其语义更易学习和记忆:findOneAndDelete、findOneAndReplace和findOneAndUpdate。这些方法与例如updateOne之间的主要区别在于它们使您能够原子地获取修改后的文档的值。MongoDB 4.2 扩展了findOneAndUpdate以接受用于更新的聚合管道。该管道可以包括以下阶段:$addFields及其别名$set,$project及其别名$unset,以及$replaceRoot及其别名$replaceWith。
假设我们有一系列按特定顺序运行的进程。每个进程用一个具有以下形式的文档表示:
{
"_id" : ObjectId(),
"status" : "*`state`*",
"priority" : *`N`*
}
"status"是一个字符串,可以是"READY"、"RUNNING"或"DONE"。我们需要找到处于"READY"状态且具有最高优先级的作业,运行处理函数,然后将状态更新为"DONE"。我们可以尝试查询准备好的进程,按优先级排序,并将最高优先级进程的状态更新为"RUNNING"。一旦处理完毕,我们将状态更新为"DONE"。这看起来像下面这样:
var cursor = db.processes.find({"status" : "READY"});
ps = cursor.sort({"priority" : -1}).limit(1).next();
db.processes.updateOne({"_id" : ps._id}, {"$set" : {"status" : "RUNNING"}});
do_something(ps);
db.processes.updateOne({"_id" : ps._id}, {"$set" : {"status" : "DONE"}});
此算法并不优秀,因为它存在竞态条件。假设有两个线程在运行。如果一个线程(称为 A)在更新其状态为"RUNNING"之前检索了文档,并且另一个线程(称为 B)在 A 更新状态之前也检索了同一个文档,那么这两个线程将运行相同的进程。我们可以通过在更新查询中检查结果来避免这种情况,但这会变得复杂:
var cursor = db.processes.find({"status" : "READY"});
cursor.sort({"priority" : -1}).limit(1);
while ((ps = cursor.next()) != null) {
var result = db.processes.updateOne({"_id" : ps._id, "status" : "READY"},
{"$set" : {"status" : "RUNNING"}});
if (result.modifiedCount === 1) {
do_something(ps);
db.processes.updateOne({"_id" : ps._id}, {"$set" : {"status" : "DONE"}});
break;
}
cursor = db.processes.find({"status" : "READY"});
cursor.sort({"priority" : -1}).limit(1);
}
另外,根据时间的不同,一个线程可能会完成所有工作,而另一个线程则无用地跟随其后。线程 A 可能总是抢占进程,然后 B 尝试获取相同的进程,失败后留给 A 来完成所有工作。
这种情况非常适合使用findOneAndUpdate。findOneAndUpdate可以在单个操作中返回项目并更新它。在这种情况下,看起来像下面这样:
> db.processes.findOneAndUpdate({"status" : "READY"},
... {"$set" : {"status" : "RUNNING"}},
... {"sort" : {"priority" : -1}})
{
"_id" : ObjectId("4b3e7a18005cab32be6291f7"),
"priority" : 1,
"status" : "READY"
}
注意,在返回的文档中状态仍然为"READY",因为findOneAndUpdate方法默认返回修改之前的文档状态。如果我们将选项文档中的"returnNewDocument"字段设置为true,它将返回更新后的文档。选项文档作为findOneAndUpdate的第三个参数传递:
> db.processes.findOneAndUpdate({"status" : "READY"},
... {"$set" : {"status" : "RUNNING"}},
... {"sort" : {"priority" : -1},
... "returnNewDocument": true})
{
"_id" : ObjectId("4b3e7a18005cab32be6291f7"),
"priority" : 1,
"status" : "RUNNING"
}
因此,程序变成了以下内容:
ps = db.processes.findOneAndUpdate({"status" : "READY"},
{"$set" : {"status" : "RUNNING"}},
{"sort" : {"priority" : -1},
"returnNewDocument": true})
do_something(ps)
db.process.updateOne({"_id" : ps._id}, {"$set" : {"status" : "DONE"}})
除此之外,还有另外两种你应该知道的方法。findOneAndReplace接受相同的参数,并返回与过滤器匹配的文档,无论替换前还是替换后,这取决于returnNewDocument的值。findOneAndDelete类似,但它不接受更新文档作为参数,并且具有另外两种方法的子集选项。findOneAndDelete返回被删除的文档。
第四章:查询
本章详细讨论了查询。主要涵盖的主题如下:
-
您可以使用
$条件来查询范围、集合包含、不等式等更多内容。 -
查询返回一个数据库游标,它在需要时惰性地返回文档批次。
-
您可以在游标上执行许多元操作,包括跳过一定数量的结果、限制返回的结果数量和对结果进行排序。
查找入门
find 方法用于在 MongoDB 中执行查询。查询返回集合中的文档子集,从不返回文档到返回整个集合。哪些文档被返回由 find 的第一个参数决定,该参数是指定查询条件的文档。
空查询文档(即 {})匹配集合中的所有内容。如果没有给定查询文档,则 find 默认为 {}。例如,以下示例:
> db.c.find()
匹配集合中的每个文档 c(并以批量返回这些文档)。
当我们开始向查询文档添加键值对时,我们开始限制我们的搜索范围。这对大多数类型都很直接:数字匹配数字,布尔值匹配布尔值,字符串匹配字符串。查询简单类型的方式就像指定您要查找的值一样简单。例如,要查找所有 "age" 值为 27 的文档,我们可以将该键值对添加到查询文档中:
> db.users.find({"age" : 27})
如果我们要匹配一个字符串,例如具有值 "joe" 的 "username" 键,我们可以改为使用该键值对:
> db.users.find({"username" : "joe"})
多个条件可以通过向查询文档添加更多键值对来串联在一起,这些条件被解释为“condition1 AND condition2 AND … AND conditionN。” 例如,要获取所有年龄为 27 岁且用户名为“joe”的用户,我们可以查询如下:
> db.users.find({"username" : "joe", "age" : 27})
指定要返回的键
有时您不需要文档中返回的所有键值对。如果是这种情况,可以将第二个参数传递给 find(或 findOne),指定您想要的键。这不仅减少了通过网络发送的数据量,还减少了客户端解码文档所需的时间和内存。
例如,如果您有一个用户集合,只对 "username" 和 "email" 键感兴趣,可以使用以下查询仅返回这些键:
> db.users.find({}, {"username" : 1, "email" : 1})
{
"_id" : ObjectId("4ba0f0dfd22aa494fd523620"),
"username" : "joe",
"email" : "joe@example.com"
}
如前一输出所示,默认情况下会返回 "_id" 键,即使没有显式请求它。
您还可以使用第二个参数来排除查询结果中特定的键值对。例如,可能有包含各种键的文档,但您只知道绝对不希望返回 "fatal_weakness" 键:
> db.users.find({}, {"fatal_weakness" : 0})
这也可以防止返回 "_id":
> db.users.find({}, {"username" : 1, "_id" : 0})
{
"username" : "joe",
}
限制
查询存在一些限制。就数据库而言,查询文档的值必须是一个常量。(在您自己的代码中可能是一个普通变量。)也就是说,它不能引用文档中另一个键的值。例如,如果我们正在管理库存,有"in_stock"和"num_sold"两个键,我们不能通过以下查询来比较它们的值:
> db.stock.find({"in_stock" : "this.num_sold"}) // doesn't work
有方法可以实现这一点(参见“$where Queries”),但通常通过稍微重组文档来实现更好的性能,例如使用"initial_stock"和"in_stock"键。然后,每当有人购买物品时,我们将"in_stock"键的值减少一个。最后,我们可以执行简单的查询来检查哪些物品已经售罄:
> db.stock.find({"in_stock" : 0})
查询条件
查询可以超出上一节描述的精确匹配,还可以匹配更复杂的条件,例如范围、OR 子句和否定。
查询条件运算符
"$lt"、"$lte"、"$gt"和"$gte"都是比较运算符,分别对应于 <、<=、> 和 >=。它们可以组合使用来查找一系列的值。例如,要查找年龄在 18 到 30 岁之间的用户,我们可以这样做:
> db.users.find({"age" : {"$gte" : 18, "$lte" : 30}})
这将查找所有"age"字段大于或等于18且小于或等于30的文档。
这些类型的范围查询通常对日期非常有用。例如,要找到在 2007 年 1 月 1 日之前注册的人员,我们可以这样做:
> start = new Date("01/01/2007")
> db.users.find({"registered" : {"$lt" : start}})
根据您如何创建和存储日期,精确匹配可能不太有用,因为日期是以毫秒精度存储的。通常情况下,您可能需要整天、整周或整月,因此需要进行范围查询。
要查询键值不等于某个值的文档,必须使用另一个条件运算符"$ne",它代表“不等于”。如果您想找到所有没有用户名为“joe”的用户,可以使用以下查询:
> db.users.find({"username" : {"$ne" : "joe"}})
"$ne"可以与任何类型一起使用。
OR 查询
在 MongoDB 中有两种方法进行 OR 查询。"$in"可以用来查询单个键的多个值。"$or"更通用;它可以用来查询多个键中给定值的任何一个。
如果一个键有多个可能的匹配值,请使用"$in"的数组条件。例如,假设我们正在进行抽奖,获奖的票号分别是 725、542 和 390。为了找到所有这三个文档,我们可以构建以下查询:
> db.raffle.find({"ticket_no" : {"$in" : [725, 542, 390]}})
"$in"非常灵活,允许您指定不同类型和值的条件。例如,如果我们正在逐步迁移我们的模式以使用用户名而不是用户 ID 号码,我们可以通过以下方式查询:
> db.users.find({"user_id" : {"$in" : [12345, "joe"]}})
这将匹配"user_id"等于12345和"user_id"等于"joe"的文档。
如果 "$in" 提供的是一个包含单个值的数组,则其行为与直接匹配该值相同。例如,{ticket_no: {$in: [725]}} 与 {ticket_no: 725} 匹配相同的文档。
"$in" 的反义是 "$nin",它返回不匹配数组中任何条件的文档。如果我们想要返回在抽奖中没有赢得任何东西的所有人,我们可以用这个查询:
> db.raffle.find({"ticket_no" : {"$nin" : [725, 542, 390]}})
此查询返回那些没有具有这些号码的票的人。
"$in" 为单个键提供了 OR 查询,但如果我们需要找到 "ticket_no" 是 725 或 "winner" 是 true 的文档,我们需要使用 "$or" 条件。"$or" 接受一个可能条件的数组。在抽奖案例中,使用 "$or" 如下所示:
> db.raffle.find({"$or" : [{"ticket_no" : 725}, {"winner" : true}]})
"$or" 可以包含其他条件。例如,如果我们想匹配任意三个 "ticket_no" 值或 "winner" 键,则可以使用以下方式:
> db.raffle.find({"$or" : [{"ticket_no" : {"$in" : [725, 542, 390]}},
... {"winner" : true}]})
使用普通的 AND 类型查询,你希望尽可能少的参数尽可能缩小结果范围。OR 类型查询则相反:如果第一个参数匹配尽可能多的文档,则效率最高。
虽然 "$or" 总是有效,但尽可能使用 "$in",因为查询优化器处理它更有效率。
$not
"$not" 是一种元条件:它可以应用在任何其他条件之上。例如,让我们考虑模数运算符 "$mod"。"$mod" 查询的是其值除以给定的第一个值后余数为第二个值的键:
> db.users.find({"id_num" : {"$mod" : [5, 1]}})
前面的查询返回了 "id_num" 为 1、6、11、16 等的用户。如果我们想要返回 "id_num" 为 2、3、4、5、7、8、9、10、12 等的用户,可以使用 "$not":
> db.users.find({"id_num" : {"$not" : {"$mod" : [5, 1]}}})
"$not" 结合正则表达式特别有用,用于查找所有不匹配给定模式的文档(正则表达式的使用在章节 “Regular Expressions” 中描述)。
类型特定查询
如 第二章 中所述,MongoDB 具有多种可以在文档中使用的类型。某些类型在查询时具有特殊行为。
null
null 的行为有些奇怪。它确实匹配自身,因此如果我们有一个包含以下文档的集合:
> db.c.find()
{ "_id" : ObjectId("4ba0f0dfd22aa494fd523621"), "y" : null }
{ "_id" : ObjectId("4ba0f0dfd22aa494fd523622"), "y" : 1 }
{ "_id" : ObjectId("4ba0f148d22aa494fd523623"), "y" : 2 }
我们可以按预期的方式查询 "y" 键为 null 的文档:
> db.c.find({"y" : null})
{ "_id" : ObjectId("4ba0f0dfd22aa494fd523621"), "y" : null }
然而,null 也匹配“不存在”。因此,查询具有值为 null 的键将返回所有缺少该键的文档:
> db.c.find({"z" : null})
{ "_id" : ObjectId("4ba0f0dfd22aa494fd523621"), "y" : null }
{ "_id" : ObjectId("4ba0f0dfd22aa494fd523622"), "y" : 1 }
{ "_id" : ObjectId("4ba0f148d22aa494fd523623"), "y" : 2 }
如果我们只想找到值为 null 的键,我们可以检查该键是否为 null 并存在,使用 "$exists" 条件:
> db.c.find({"z" : {"$eq" : null, "$exists" : true}})
正则表达式
"$regex" 提供了正则表达式功能,用于在查询中匹配字符串模式。正则表达式对于灵活的字符串匹配非常有用。例如,如果我们想找到所有名为“Joe”或“joe”的用户,可以使用正则表达式进行大小写不敏感匹配:
> db.users.find( {"name" : {"$regex" : /joe/i } })
正则表达式标志(例如 i)是允许的但不是必需的。如果我们想匹配不仅是“joe”不同大小写形式,还包括“joey”,我们可以继续改进我们的正则表达式:
> db.users.find({"name" : /joey?/i})
MongoDB 使用 Perl 兼容正则表达式(PCRE)库来匹配正则表达式;任何 PCRE 允许的正则表达式语法在 MongoDB 中都是允许的。在使用查询之前,建议在 JavaScript shell 中检查您的语法,以确保它匹配您认为的内容。
注意
MongoDB 可以利用前缀正则表达式(例如 /^joey/)的索引进行查询。索引 不能 用于不区分大小写的搜索(/^joey/i)。当正则表达式以插入符号 (^) 或左锚点 (\A) 开头时,它被称为“前缀表达式”。如果正则表达式使用区分大小写的查询,那么如果字段存在索引,匹配可以针对索引中的值进行。如果它也是前缀表达式,那么搜索可以限制在索引中由该前缀创建的范围内的值。
正则表达式也可以匹配自身。很少有人将正则表达式插入到数据库中,但如果您插入一个,可以与其本身匹配:
> db.foo.insertOne({"bar" : /baz/})
> db.foo.find({"bar" : /baz/})
{
"_id" : ObjectId("4b23c3ca7525f35f94b60a2d"),
"bar" : /baz/
}
查询数组
查询数组元素的行为设计得与标量查询相同。例如,如果数组是像这样的水果列表:
> db.food.insertOne({"fruit" : ["apple", "banana", "peach"]})
以下查询将成功匹配文档:
> db.food.find({"fruit" : "banana"})
我们可以像查询类似于(非法)文档 {"fruit" : "apple", "fruit" : "banana", "fruit" : "peach"} 的方式来查询它。
“$all”
如果您需要通过多个元素匹配数组,可以使用 "$all"。这允许您匹配一个元素列表。例如,假设我们创建一个包含三个元素的集合:
> db.food.insertOne({"_id" : 1, "fruit" : ["apple", "banana", "peach"]})
> db.food.insertOne({"_id" : 2, "fruit" : ["apple", "kumquat", "orange"]})
> db.food.insertOne({"_id" : 3, "fruit" : ["cherry", "banana", "apple"]})
然后我们可以通过使用 "$all" 查询来找到所有包含 "apple" 和 "banana" 元素的文档:
> db.food.find({fruit : {$all : ["apple", "banana"]}})
{"_id" : 1, "fruit" : ["apple", "banana", "peach"]}
{"_id" : 3, "fruit" : ["cherry", "banana", "apple"]}
顺序不重要。请注意,第二个结果中 "banana" 出现在 "apple" 之前。使用带有 "$all" 的单元素数组等同于不使用 "$all"。例如,{fruit : {$all : ['apple']} 将与 {fruit : 'apple'} 匹配相同的文档。
您还可以通过整个数组进行精确匹配查询。但是,如果任何元素缺失或多余,精确匹配将不会匹配文档。例如,这将匹配我们三个文档中的第一个:
> db.food.find({"fruit" : ["apple", "banana", "peach"]})
但这不会:
> db.food.find({"fruit" : ["apple", "banana"]})
这也不会:
> db.food.find({"fruit" : ["banana", "apple", "peach"]})
如果您想查询数组的特定元素,可以使用 key.index 的语法指定索引:
> db.food.find({"fruit.2" : "peach"})
数组始终从 0 开始索引,因此这将使第三个数组元素与字符串 "peach" 匹配。
“$size”
查询数组的一个有用的条件是 "$size",它允许您查询特定大小的数组。以下是一个示例:
> db.food.find({"fruit" : {"$size" : 3}})
一个常见的查询是获取一系列大小。 "$size" 不能与另一个 $ 条件结合使用(在这个例子中是 "$gt"),但可以通过向文档添加 "size" 键来实现此查询。然后,每次向数组添加元素时,增加 "size" 的值。如果原始更新看起来像这样:
> db.food.update(criteria, {"$push" : {"fruit" : "strawberry"}})
它可以简单地更改为这样:
> db.food.update(criteria,
... {"$push" : {"fruit" : "strawberry"}, "$inc" : {"size" : 1}})
自增非常快,因此任何性能损失都可以忽略不计。像这样存储文档允许你执行这样的查询:
> db.food.find({"size" : {"$gt" : 3}})
不幸的是,这种技术与 "$addToSet" 运算符的结合效果不佳。
"$slice"
如本章前面提到的,find 的可选第二个参数指定要返回的键。特殊的 "$slice" 运算符可以用来返回数组键的子集。
例如,假设我们有一个博客文章文档,并且想返回前 10 条评论:
> db.blog.posts.findOne(criteria, {"comments" : {"$slice" : 10}})
或者,如果我们想要最后 10 条评论,我们可以使用 -10:
> db.blog.posts.findOne(criteria, {"comments" : {"$slice" : -10}})
"$slice" 也可以通过指定偏移量和要返回的元素数量返回结果中的页面中的页面:
> db.blog.posts.findOne(criteria, {"comments" : {"$slice" : [23, 10]}})
这将跳过前 23 个元素,并返回第 24 到 33 个元素。如果数组中的元素少于 33 个,它将返回尽可能多的元素。
除非另有说明,使用 "$slice" 时文档中的所有键都会被返回。这与其他键规范不同,其他键规范会抑制未提及的键的返回。例如,如果我们有一个博客文章文档如下所示:
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"title" : "A blog post",
"content" : "...",
"comments" : [
{
"name" : "joe",
"email" : "joe@example.com",
"content" : "nice post."
},
{
"name" : "bob",
"email" : "bob@example.com",
"content" : "good post."
}
]
}
如果我们使用 "$slice" 来获取最后一条评论,我们将得到以下内容:
> db.blog.posts.findOne(criteria, {"comments" : {"$slice" : -1}})
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"title" : "A blog post",
"content" : "...",
"comments" : [
{
"name" : "bob",
"email" : "bob@example.com",
"content" : "good post."
}
]
}
即使没有显式包含在键规范中,"title" 和 "content" 仍然会被返回。
返回匹配的数组元素
"$slice" 在你知道元素索引时非常有用,但有时你希望返回任何与你的条件匹配的数组元素。你可以使用 $ 运算符返回匹配的元素。给定前面的博客示例,你可以通过以下方式获取 Bob 的评论:
> db.blog.posts.find({"comments.name" : "bob"}, {"comments.$" : 1})
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"comments" : [
{
"name" : "bob",
"email" : "bob@example.com",
"content" : "good post."
}
]
}
注意,这只返回每个文档的第一个匹配项:如果 Bob 在这篇文章上留了多条评论,只返回在 "comments" 数组中的第一条。
数组和范围查询的交互
文档中的标量(非数组元素)必须符合查询条件的每个子句。例如,如果你查询 {"x" : {"$gt" : 10, "$lt" : 20}},"x" 必须同时大于 10 和小于 20。然而,如果文档的 "x" 字段是一个数组,那么文档匹配的条件是 "x" 中有一个元素满足每个查询子句 但每个查询子句可以匹配不同的数组元素。
理解这种行为的最佳方法是看一个例子。假设我们有以下文档:
{"x" : 5}
{"x" : 15}
{"x" : 25}
{"x" : [5, 25]}
如果我们想找到所有 "x" 在 10 到 20 之间的文档,我们可能会天真地将查询结构化为 db.test.find({"x" : {"$gt" : 10, "$lt" : 20}}),并期望得到一个文档: {"x" : 15}。然而,运行此查询,我们得到了两个文档:
> db.test.find({"x" : {"$gt" : 10, "$lt" : 20}})
{"x" : 15}
{"x" : [5, 25]}
既不是 5 也不是 25 在 10 和 20 之间,但是文档被返回,因为 25 匹配了第一个子句(它大于 10),而 5 匹配了第二个子句(它小于 20)。
这使得对数组进行范围查询基本无效:范围将匹配任何多元素数组。有几种方法可以获得预期的行为。
首先,你可以使用 "$elemMatch" 强制 MongoDB 将两个子句与单个数组元素进行比较。然而,问题在于 "$elemMatch" 不会匹配非数组元素:
> db.test.find({"x" : {"$elemMatch" : {"$gt" : 10, "$lt" : 20}}})
> // no results
文档 {"x" : 15} 不再与查询匹配,因为 "x" 字段不是数组。也就是说,在字段中混合使用数组和标量值时,你应该有充分的理由。许多用例不需要混合使用。对于这些情况,"$elemMatch" 为数组元素的范围查询提供了一个很好的解决方案。
如果你在查询字段上有索引(参见第五章),你可以使用 min 和 max 限制查询遍历的索引范围到 "$gt" 和 "$lt" 的值:
> db.test.find({"x" : {"$gt" : 10, "$lt" : 20}}).min({"x" : 10}).max({"x" : 20})
{"x" : 15}
现在这将仅遍历从 10 到 20 的索引,忽略了 5 和 25 的条目。你只能在查询字段上有索引时使用 min 和 max,并且必须将索引的所有字段传递给 min 和 max。
在查询可能包含数组的文档上使用 min 和 max 通常是个好主意。对数组的 "$gt"/"$lt" 查询的索引边界是低效的。它基本上接受任何值,因此将搜索每个索引条目,而不仅仅是在范围内的条目。
查询嵌入文档
有两种查询嵌入文档的方法:查询整个文档或查询其各个键/值对。
对整个嵌入文档的查询与普通查询完全相同。例如,如果我们有一个看起来像这样的文档:
{
"name" : {
"first" : "Joe",
"last" : "Schmoe"
},
"age" : 45
}
我们可以查询名为 Joe Schmoe 的人如下:
> db.people.find({"name" : {"first" : "Joe", "last" : "Schmoe"}})
然而,完整子文档的查询必须完全匹配子文档。如果 Joe 决定添加一个中间名字段,突然这个查询就不再起作用了;它不匹配整个嵌入文档!这种查询也是有序敏感的:{"last" : "Schmoe", "first" : "Joe"} 不会匹配。
如果可能的话,通常最好只查询嵌入文档的特定键或键。然后,如果你的模式发生变化,你所有的查询不会突然因为它们不再是精确匹配而断开。你可以使用点符号来查询嵌入键:
> db.people.find({"name.first" : "Joe", "name.last" : "Schmoe"})
现在,如果 Joe 添加了更多的键,这个查询仍然可以匹配他的姓和名。
此点符号标记是查询文档与其他文档类型之间的主要区别。查询文档可以包含点号,表示“进入嵌入文档”。点符号也是插入时不能包含.字符的原因。人们在尝试将 URL 保存为键时经常遇到此限制的情况。解决办法之一是在插入或检索之前始终执行全局替换,将不合法的 URL 字符替换为点字符。
随着文档结构变得更加复杂,嵌入文档的匹配可能会变得有些棘手。例如,假设我们存储博客文章,并且想要找到 Joe 发表的评分至少为 5 的评论。我们可以如下建模:
> db.blog.find()
{
"content" : "...",
"comments" : [
{
"author" : "joe",
"score" : 3,
"comment" : "nice post"
},
{
"author" : "mary",
"score" : 6,
"comment" : "terrible post"
}
]
}
现在,我们不能使用db.blog.find({"comments" : {"author" : "joe", "score" : {"$gte" : 5}}})进行查询。嵌入文档匹配必须匹配整个文档,而这不匹配"comment"键。同样也不能这样做db.blog.find({"comments.author" : "joe", "comments.score" : {"$gte" : 5}}),因为作者条件可能与评分条件匹配不同的评论。也就是说,它会返回上述文档:它会匹配第一个评论中的"author" : "joe"和第二个评论中的"score" : 6。
要正确分组条件,而不需要指定每个键,请使用"$elemMatch"。这个名称模糊的条件允许您部分指定匹配数组中单个嵌入文档的条件。正确的查询如下:
> db.blog.find({"comments" : {"$elemMatch" :
... {"author" : "joe", "score" : {"$gte" : 5}}}})
"$elemMatch"允许您“分组”您的条件。因此,只有当您在嵌入文档中有多个键需要匹配时才需要它。
$where 查询
键/值对是一种相当表达力强的查询方式,但有些查询无法表示。对于无法通过其他方式完成的查询,有"$where"子句可以使用,允许您在查询中执行任意 JavaScript。这允许您在查询中执行(几乎)任何操作。为了安全起见,应该严格限制或消除"$where"子句的使用。终端用户不应被允许执行任意"$where"子句。
使用"$where"最常见的情况是比较文档中两个键的值。例如,假设我们有这样的文档:
> db.foo.insertOne({"apple" : 1, "banana" : 6, "peach" : 3})
> db.foo.insertOne({"apple" : 8, "spinach" : 4, "watermelon" : 4})
我们希望返回任意两个字段相等的文档。例如,在第二个文档中,"spinach"和"watermelon"具有相同的值,因此我们希望返回该文档。MongoDB 不太可能会有一个$条件来处理这个问题,所以我们可以使用"$where"子句在 JavaScript 中执行它:
> db.foo.find({"$where" : function () {
... for (var current in this) {
... for (var other in this) {
... if (current != other && this[current] == this[other]) {
... return true;
... }
... }
... }
... return false;
... }});
如果函数返回true,则该文档将成为结果集的一部分;如果返回false,则不会。
除非绝对必要,否则不应使用"$where"查询:它们比常规查询慢得多。每个文档都必须从 BSON 转换为 JavaScript 对象,然后通过"$where"表达式运行。索引也不能用于满足"$where"条件。因此,只有在没有其他方法可以执行查询时才应使用"$where"。通过与"$where"结合使用其他查询过滤器可以减少惩罚。如果可能,将使用索引根据非$where子句进行过滤;"$where"表达式将仅用于微调结果。MongoDB 3.6 添加了$expr运算符,允许在 MongoDB 查询语言中使用聚合表达式。它比$where更快,因为不执行 JavaScript,并建议在可能的情况下使用它来替换此运算符。
进行复杂查询的另一种方式是使用聚合工具之一,这在第七章中有所涵盖。
游标
数据库使用游标从find返回结果。游标的客户端实现通常允许您控制查询的最终输出的很多方面。您可以限制结果的数量,跳过一些结果,按任意键的任意组合和方向对结果进行排序,并执行许多其他强大的操作。
要在 shell 中创建游标,请将一些文档放入集合中,对它们进行查询,并将结果分配给一个本地变量(使用"var"定义的变量是局部的)。在这里,我们创建一个非常简单的集合并查询它,将结果存储在cursor变量中:
> for(i=0; i<100; i++) {
... db.collection.insertOne({x : i});
... }
> var cursor = db.collection.find();
这样做的优点是您可以一次查看一个结果。如果将结果存储在全局变量或根本不存储变量中,MongoDB shell 将自动迭代并显示前几个文档。这是我们到目前为止看到的行为,并且通常是您查看集合内容而不是使用 shell 进行实际编程时想要的行为。
要遍历结果,您可以使用游标的next方法。您可以使用hasNext检查是否有另一个结果。典型的遍历结果的循环如下所示:
> while (cursor.hasNext()) {
... obj = cursor.next();
... // do stuff
... }
cursor.hasNext()检查是否存在下一个结果,cursor.next()获取下一个结果。
cursor类还实现了 JavaScript 的迭代器接口,因此您可以在forEach循环中使用它:
> var cursor = db.people.find();
> cursor.forEach(function(x) {
... print(x.name);
... });
adam
matt
zak
当您调用find时,shell 不会立即查询数据库。它会等到您开始请求结果时才发送查询,这使您可以在执行查询之前对查询进行附加选项的链式调用。几乎cursor对象上的每个方法都返回游标本身,因此您可以按任意顺序链式调用选项。例如,以下所有方式都是等效的:
> var cursor = db.foo.find().sort({"x" : 1}).limit(1).skip(10);
> var cursor = db.foo.find().limit(1).sort({"x" : 1}).skip(10);
> var cursor = db.foo.find().skip(10).limit(1).sort({"x" : 1});
到目前为止,查询还没有执行。所有这些函数只是构建查询而已。现在,假设我们调用以下内容:
> cursor.hasNext()
在这一点上,查询将被发送到服务器。Shell 一次获取前 100 个结果或前 4 MB 的结果(以较小者为准),这样下一次调用next或hasNext就不必再次与服务器通信。客户端浏览了第一批结果后,Shell 将再次联系数据库,并通过getMore请求获取更多结果。getMore请求基本上包含一个游标标识符,并询问数据库是否还有更多结果,如果有,则返回下一批。这个过程持续到游标耗尽并返回所有结果为止。
限制、跳过和排序
最常见的查询选项包括限制返回的结果数量、跳过一定数量的结果以及排序。所有这些选项必须在向数据库发送查询之前添加。
要设置限制,请将limit函数链接到find的调用中。例如,要仅返回三个结果,请使用:
> db.c.find().limit(3)
如果在集合中查询的匹配文档少于三个,则只返回匹配文档的数量;limit设置的是上限,而不是下限。
skip的工作方式类似于limit:
> db.c.find().skip(3)
这将跳过前三个匹配的文档并返回其余的匹配项。如果集合中的文档少于三个,则不会返回任何文档。
sort接受一个对象:一组键/值对,其中键是键名,值是排序方向。排序方向可以是1(升序)或−1(降序)。如果给定多个键,则结果将按顺序排序。例如,要按"username"升序和"age"降序排序结果,我们这样做:
> db.c.find().sort({username : 1, age : -1})
这三种方法可以结合使用。这对分页非常方便。例如,假设你运营一个在线商店,有人搜索mp3。如果你想每页显示 50 个结果,并按价格从高到低排序,可以这样做:
> db.stock.find({"desc" : "mp3"}).limit(50).sort({"price" : -1})
如果用户点击“下一页”查看更多结果,则可以简单地在查询中添加一个 skip,这将跳过前 50 个匹配项(用户在第一页上已经看过的):
> db.stock.find({"desc" : "mp3"}).limit(50).skip(50).sort({"price" : -1})
然而,大的跳过并不太高效;在下一节中有建议如何避免它们。
比较顺序
MongoDB 有一套类型比较的层次结构。有时你会有一个单一的键包含多种类型:例如整数和布尔值,或字符串和 null 值。如果对混合类型的键进行排序,则会按预定义的顺序进行排序。从最小到最大的值,这个排序顺序如下:
-
最小值
-
Null
-
数字(整数、长整数、双精度、小数)
-
字符串
-
对象/文档
-
数组
-
二进制数据
-
对象 ID
-
布尔值
-
日期
-
时间戳
-
正则表达式
-
最大值
避免大跳过
对少量文档使用skip是可以接受的。但是对于大量结果,由于需要找到并且丢弃所有被跳过的结果,skip可能会很慢。大多数数据库在索引中保留更多元数据以帮助处理跳过,但 MongoDB 目前不支持此功能,因此应避免大量跳过。通常可以基于前一个查询计算下一个查询的结果。
无skip分页结果
分页的最简单方法是使用limit返回第一页结果,然后从开头返回每个后续页:
> // do not use: slow for large skips
> var page1 = db.foo.find(criteria).limit(100)
> var page2 = db.foo.find(criteria).skip(100).limit(100)
> var page3 = db.foo.find(criteria).skip(200).limit(100)
...
然而,根据您的查询,通常可以找到一种方法进行分页而无需跳过。例如,假设我们想按照"date"降序显示文档,我们可以通过以下方式获取第一页结果:
> var page1 = db.foo.find().sort({"date" : -1}).limit(100)
然后,假设日期是唯一的,我们可以使用最后一个文档的"date"值作为获取下一页的条件:
var latest = null;
// display first page
while (page1.hasNext()) {
latest = page1.next();
display(latest);
}
// get next page
var page2 = db.foo.find({"date" : {"$lt" : latest.date}});
page2.sort({"date" : -1}).limit(100);
现在查询不需要包括跳过。
查找随机文档
一个相当常见的问题是如何从集合中获取一个随机文档。天真(并且缓慢)的解决方案是计算文档数,然后进行find,跳过从零到集合大小之间的随机数量的文档:
> // do not use
> var total = db.foo.count()
> var random = Math.floor(Math.random()*total)
> db.foo.find().skip(random).limit(1)
以这种方式获取随机元素实际上效率非常低:您必须进行计数(如果使用条件可能会很昂贵),并且跳过大量元素可能会耗时。
这需要一些预见性,但如果您知道您将在集合中查找随机元素,有一种更有效的方法。诀窍是在插入每个文档时添加额外的随机键。例如,如果我们正在使用 Shell,我们可以使用Math.random()函数(生成 0 到 1 之间的随机数):
> db.people.insertOne({"name" : "joe", "random" : Math.random()})
> db.people.insertOne({"name" : "john", "random" : Math.random()})
> db.people.insertOne({"name" : "jim", "random" : Math.random()})
现在,当我们想要从集合中找到一个随机文档时,我们可以计算一个随机数,并将其用作查询条件,而不是使用skip:
> var random = Math.random()
> result = db.people.findOne({"random" : {"$gt" : random}})
有一定几率random大于集合中任何"random"值,并且不会返回结果。我们可以通过简单地返回另一方向的文档来防范这种情况:
> if (result == null) {
... result = db.people.findOne({"random" : {"$lte" : random}})
... }
如果集合中没有任何文档,这种技术最终会返回null,这是有道理的。
这种技术可以用于任意复杂的查询;只需确保有一个包含随机键的索引即可。例如,如果我们想在加州找到一个随机的水管工,我们可以在"profession"、"state"和"random"上创建一个索引:
> db.people.ensureIndex({"profession" : 1, "state" : 1, "random" : 1})
这样可以快速找到随机结果(详见第五章了解更多关于索引的信息)。
永存的游标
游标有两个方面:面向客户端的游标和客户端代表的数据库游标。到目前为止,我们一直在谈论客户端的那个,但我们将简要看看服务器上发生了什么。
在服务器端,游标占用内存和资源。一旦游标耗尽了结果或客户端发送消息告知其终止,数据库可以释放其正在使用的资源。释放这些资源使得数据库可以用于其他事务,这是好的,因此我们希望确保游标可以迅速释放(在合理范围内)。
有几种情况可能会导致游标的“死亡”(及随后的清理)。首先,当游标完成对匹配结果的迭代时,它将自我清理。另一种方式是,当客户端上的游标超出作用域时,驱动程序会向数据库发送特殊消息,告知可以终止该游标。最后,即使用户尚未遍历所有结果且游标仍在作用域内,数据库游标在 10 分钟的不活动后也会自动“死亡”。这样,如果客户端崩溃或存在错误,MongoDB 将不会留下成千上万个开放的游标。
这种“超时死亡”通常是期望的行为:很少有应用程序希望用户坐在那里等待几分钟才能得到结果。然而,有时你可能知道需要一个长时间保持的游标。在这种情况下,许多驱动程序已经实现了一个名为immortal或类似机制的函数,告诉数据库不要超时游标。如果关闭游标的超时时间,你必须迭代其所有结果或将其终止,以确保它被关闭。否则,它将继续留在数据库中占用资源,直到服务器重新启动。
第二部分:设计你的应用程序
第五章:索引
本章介绍了 MongoDB 索引。索引使您能够高效执行查询。它们是应用程序开发的重要组成部分,甚至对某些类型的查询是必需的。本章将涵盖:
-
索引是什么以及为什么要使用它们
-
如何选择要索引的字段
-
如何强制和评估索引使用
-
创建和删除索引的管理细节
如您所见,为您的集合选择合适的索引非常关键以提升性能。
索引简介
数据库索引类似于书的索引。与查阅整本书不同,数据库采用一种快捷方式,只查看一个有序列表,并引用其内容。这使得 MongoDB 能够进行数量级更快的查询。
不使用索引的查询称为集合扫描,这意味着服务器必须“浏览整本书”以找到查询结果。这个过程基本上就是在没有索引的书中查找信息时所做的事情:从第一页开始阅读整本书。一般情况下,您希望避免使服务器进行集合扫描,因为对于大型集合来说这个过程非常慢。
让我们看一个例子。为了开始,我们将创建一个包含 100 万个文档的集合(或者如果您有耐心,可以是 1000 万或 1 亿个文档):
> for (i=0; i<1000000; i++) {
... db.users.insertOne(
... {
... "i" : i,
... "username" : "user"+i,
... "age" : Math.floor(Math.random()*120),
... "created" : new Date()
... }
... );
... }
接下来我们将查看在此集合上查询的性能差异,首先是没有索引的情况,然后是有索引的情况。
如果我们在这个集合上进行查询,可以使用 explain 命令来查看 MongoDB 在执行查询时的操作。使用 explain 命令的首选方式是通过包装此命令的游标辅助方法。explain 游标方法提供了关于执行各种 CRUD 操作的信息。我们将查看 executionStats 模式,因为这有助于我们理解使用索引来满足查询的效果。尝试查询特定用户名以查看示例:
> db.users.find({"username": "user101"}).explain("executionStats")
{
"queryPlanner" : {
"plannerVersion" : 1,
"namespace" : "test.users",
"indexFilterSet" : false,
"parsedQuery" : {
"username" : {
"$eq" : "user101"
}
},
"winningPlan" : {
"stage" : "COLLSCAN",
"filter" : {
"username" : {
"$eq" : "user101"
}
},
"direction" : "forward"
},
"rejectedPlans" : [ ]
},
"executionStats" : {
"executionSuccess" : true,
"nReturned" : 1,
"executionTimeMillis" : 419,
"totalKeysExamined" : 0,
"totalDocsExamined" : 1000000,
"executionStages" : {
"stage" : "COLLSCAN",
"filter" : {
"username" : {
"$eq" : "user101"
}
},
"nReturned" : 1,
"executionTimeMillisEstimate" : 375,
"works" : 1000002,
"advanced" : 1,
"needTime" : 1000000,
"needYield" : 0,
"saveState" : 7822,
"restoreState" : 7822,
"isEOF" : 1,
"invalidates" : 0,
"direction" : "forward",
"docsExamined" : 1000000
}
},
"serverInfo" : {
"host" : "eoinbrazil-laptop-osx",
"port" : 27017,
"version" : "4.0.12",
"gitVersion" : "5776e3cbf9e7afe86e6b29e22520ffb6766e95d4"
},
"ok" : 1
}
“explain 输出” 将解释输出字段;目前您可以忽略其中的几乎所有内容。对于本例子,我们想查看作为 "executionStats" 字段值的嵌套文档。在这个文档中,"totalDocsExamined" 是 MongoDB 在尝试满足查询时查看的文档数,正如您所看到的,是集合中的每个文档。也就是说,MongoDB 必须查看每个文档中的每个字段。这在我的笔记本电脑上花费了将近半秒钟("executionTimeMillis" 字段显示执行查询花费的毫秒数)。
"executionStats" 文档的 "nReturned" 字段显示返回的结果数为 1,这是有道理的,因为只有一个用户名为 "user101" 的用户。请注意,MongoDB 必须查看集合中的每个文档以匹配,因为它不知道用户名是唯一的。
为了使 MongoDB 能够高效地响应查询,在您的应用程序中所有的查询模式都应该有相应的索引支持。所谓查询模式,简单来说,就是应用程序向数据库提出的不同类型的问题。在这个例子中,我们通过用户名查询了 users 集合。这是一个具体的查询模式示例。在许多应用程序中,单个索引可以支持多个查询模式。我们将在后面的章节讨论如何根据查询模式定制索引。
创建索引
现在让我们尝试在 "username" 字段上创建一个索引。要创建索引,我们将使用 createIndex 集合方法:
> db.users.createIndex({"username" : 1})
{
"createdCollectionAutomatically" : false,
"numIndexesBefore" : 1,
"numIndexesAfter" : 2,
"ok" : 1
}
创建索引不应该花费超过几秒钟的时间,除非你的集合特别大。如果 createIndex 调用几秒钟后没有返回,请在另一个 shell 中运行 db.currentOp() 或检查 mongod 的日志,查看索引构建的进度。
索引建立完成后,请尝试重复原始查询:
> db.users.find({"username": "user101"}).explain("executionStats")
{
"queryPlanner" : {
"plannerVersion" : 1,
"namespace" : "test.users",
"indexFilterSet" : false,
"parsedQuery" : {
"username" : {
"$eq" : "user101"
}
},
"winningPlan" : {
"stage" : "FETCH",
"inputStage" : {
"stage" : "IXSCAN",
"keyPattern" : {
"username" : 1
},
"indexName" : "username_1",
"isMultiKey" : false,
"multiKeyPaths" : {
"username" : [ ]
},
"isUnique" : false,
"isSparse" : false,
"isPartial" : false,
"indexVersion" : 2,
"direction" : "forward",
"indexBounds" : {
"username" : [
"[\"user101\", \"user101\"]"
]
}
}
},
"rejectedPlans" : [ ]
},
"executionStats" : {
"executionSuccess" : true,
"nReturned" : 1,
"executionTimeMillis" : 1,
"totalKeysExamined" : 1,
"totalDocsExamined" : 1,
"executionStages" : {
"stage" : "FETCH",
"nReturned" : 1,
"executionTimeMillisEstimate" : 0,
"works" : 2,
"advanced" : 1,
"needTime" : 0,
"needYield" : 0,
"saveState" : 0,
"restoreState" : 0,
"isEOF" : 1,
"invalidates" : 0,
"docsExamined" : 1,
"alreadyHasObj" : 0,
"inputStage" : {
"stage" : "IXSCAN",
"nReturned" : 1,
"executionTimeMillisEstimate" : 0,
"works" : 2,
"advanced" : 1,
"needTime" : 0,
"needYield" : 0,
"saveState" : 0,
"restoreState" : 0,
"isEOF" : 1,
"invalidates" : 0,
"keyPattern" : {
"username" : 1
},
"indexName" : "username_1",
"isMultiKey" : false,
"multiKeyPaths" : {
"username" : [ ]
},
"isUnique" : false,
"isSparse" : false,
"isPartial" : false,
"indexVersion" : 2,
"direction" : "forward",
"indexBounds" : {
"username" : [
"[\"user101\", \"user101\"]"
]
},
"keysExamined" : 1,
"seeks" : 1,
"dupsTested" : 0,
"dupsDropped" : 0,
"seenInvalidated" : 0
}
}
},
"serverInfo" : {
"host" : "eoinbrazil-laptop-osx",
"port" : 27017,
"version" : "4.0.12",
"gitVersion" : "5776e3cbf9e7afe86e6b29e22520ffb6766e95d4"
},
"ok" : 1
}
这个 explain 输出比较复杂,但目前你可以忽略除了 "nReturned"、"totalDocsExamined" 和 "executionTimeMillis" 在 "executionStats" 嵌套文档中的所有字段。正如你所见,现在查询几乎是瞬时的,而且在查询任何用户名时运行时间相似:
> db.users.find({"username": "user999999"}).explain("executionStats")
索引可以显著提高查询时间。然而,索引也有其代价:修改索引字段的写操作(插入、更新和删除)会花费更长时间。这是因为除了更新文档外,MongoDB 还必须在数据变化时更新索引。通常情况下,这种权衡是值得的。难点在于找出哪些字段需要建立索引。
提示
MongoDB 的索引工作方式几乎与典型的关系数据库索引相同,因此如果您熟悉这些内容,您可以仅仅浏览本节以获取语法的具体信息。
要选择哪些字段创建索引,请浏览您频繁查询和需要快速响应的查询,并尝试找到一个共同的键集。例如,在前面的例子中,我们正在查询 "username"。如果这是一个特别常见的查询或者成为瓶颈,建立 "username" 索引是一个不错的选择。但是,如果这是一个不寻常的查询或者只由不关心花费多长时间的管理员执行的查询,那么建立索引并不是一个好选择。
复合索引简介
索引的目的是尽可能提高您的查询效率。对于许多查询模式,基于两个或更多键构建索引是必要的。例如,索引将其所有值保持在排序顺序中,因此通过索引键排序文档速度更快。但是,只有当索引是排序的前缀时,索引才能帮助排序。例如,索引在"username"上对于这种排序帮助不大:
> db.users.find().sort({"age" : 1, "username" : 1})
这按"age"然后按"username"排序,因此严格按"username"排序并不特别有帮助。为了优化这种排序,您可以在"age"和"username"上建立索引:
> db.users.createIndex({"age" : 1, "username" : 1})
这被称为复合索引,如果您的查询具有多个排序方向或多个条件键,则非常有用。复合索引是对多个字段的索引。
假设我们有一个类似以下这样的users集合,如果我们运行一个没有排序的查询(称为自然顺序):
> db.users.find({}, {"_id" : 0, "i" : 0, "created" : 0})
{ "username" : "user0", "age" : 69 }
{ "username" : "user1", "age" : 50 }
{ "username" : "user2", "age" : 88 }
{ "username" : "user3", "age" : 52 }
{ "username" : "user4", "age" : 74 }
{ "username" : "user5", "age" : 104 }
{ "username" : "user6", "age" : 59 }
{ "username" : "user7", "age" : 102 }
{ "username" : "user8", "age" : 94 }
{ "username" : "user9", "age" : 7 }
{ "username" : "user10", "age" : 80 }
...
如果我们按{"age" : 1, "username" : 1}索引这个集合,索引将有以下形式:
[0, "user100020"] -> 8623513776
[0, "user1002"] -> 8599246768
[0, "user100388"] -> 8623560880
...
[0, "user100414"] -> 8623564208
[1, "user100113"] -> 8623525680
[1, "user100280"] -> 8623547056
[1, "user100551"] -> 8623581744
...
[1, "user100626"] -> 8623591344
[2, "user100191"] -> 8623535664
[2, "user100195"] -> 8623536176
[2, "user100197"] -> 8623536432
...
每个索引条目包含一个年龄和一个用户名,并指向一个记录标识符。记录标识符由存储引擎内部使用,用于定位文档的数据。注意"age"字段被排序为严格升序,并且在每个年龄内,用户名也是按升序排列的。在这个示例数据集中,每个年龄大约有 8,000 个关联的用户名。这里我们只包含了传达一般思想所必需的部分。
MongoDB 使用此索引的方式取决于您正在执行的查询类型。以下是三种最常见的方法:
db.users.find({"age" : 21}).sort({"username" : -1})
这是一个等值查询,搜索单个值。可能存在多个具有该值的文档。由于索引中的第二个字段,结果已按正确的排序顺序排列:MongoDB 可以从{"age" : 21}的最后匹配开始,并按顺序遍历索引:
[21, "user100154"] -> 8623530928
[21, "user100266"] -> 8623545264
[21, "user100270"] -> 8623545776
[21, "user100285"] -> 8623547696
[21, "user100349"] -> 8623555888
...
这种类型的查询非常高效:MongoDB 可以直接跳转到正确的年龄,而不需要对结果进行排序,因为遍历索引会按正确顺序返回数据。
注意排序方向无关紧要:MongoDB 可以以任意方向遍历索引。
db.users.find({"age" : {"$gte" : 21, "$lte" : 30}})
这是一个范围查询,查找匹配多个值的文档(在本例中,所有年龄在 21 到 30 之间的)。MongoDB 将使用索引中的第一个键"age"返回匹配的文档,如下所示:
[21, "user100154"] -> 8623530928
[21, "user100266"] -> 8623545264
[21, "user100270"] -> 8623545776
...
[21, "user999390"] -> 8765250224
[21, "user999407"] -> 8765252400
[21, "user999600"] -> 8765277104
[22, "user100017"] -> 8623513392
...
[29, "user999861"] -> 8765310512
[30, "user100098"] -> 8623523760
[30, "user100155"] -> 8623531056
[30, "user100168"] -> 8623532720
...
一般来说,如果 MongoDB 为查询使用索引,它将按索引顺序返回结果文档。
db.users.find({"age" : {"$gte" : 21, "$lte" : 30}}).sort({"username" : 1})
这是一个多值查询,与之前的查询类似,但这次有一个排序。与之前一样,MongoDB 将使用索引来匹配条件。然而,索引不会按照用户名的顺序返回用户名,而查询要求结果按用户名排序。这意味着 MongoDB 需要在返回结果之前在内存中对结果进行排序,而不是简单地遍历一个已按所需顺序排序的索引中的文档。因此,这种类型的查询通常效率较低。
当然,速度取决于有多少结果符合您的条件:如果您的结果集只有几个文档,MongoDB 将不需要太多工作来对它们进行排序,但如果结果更多,速度会较慢,甚至可能根本无法工作。如果结果超过 32 MB,MongoDB 将直接报错,拒绝对如此多的数据进行排序:
Error: error: {
"ok" : 0,
"errmsg" : "Executor error during find command: OperationFailed:
Sort operation used more than the maximum 33554432 bytes of RAM. Add
an index, or specify a smaller limit.",
"code" : 96,
"codeName" : "OperationFailed"
}
小贴士
如果您需要避免此错误,则必须创建一个支持排序操作的索引(https://docs.mongodb.com/manual/reference/method/cursor.sort/index.html#sort-index-use)或者在使用 sort 与 limit 结合以将结果减少到 32 MB 以下。
在上一个例子中,您可以使用的另一个索引是相同键的倒序排列:{"username" : 1, "age" : 1}。MongoDB 将遍历所有索引条目,但按您希望它们返回的顺序进行。它将使用索引的"age"部分来选择匹配的文档:
[user0, 4]
[user1, 67]
[user10, 11]
[user100, 92]
[user1000, 10]
[user10000, 31]
[user100000, 21] -> 8623511216
[user100001, 52]
[user100002, 69]
[user100003, 27] -> 8623511600
[user100004, 22] -> 8623511728
[user100005, 95]
...
这样做的好处是不需要任何大内存排序。然而,它必须扫描整个索引以找到所有匹配项。将排序键放在首位通常是设计复合索引时的一个好策略。稍后我们会看到,在考虑如何构建考虑等值查询、多值查询和排序的复合索引时,这是几个最佳实践之一。
MongoDB 如何选择索引
现在让我们看看 MongoDB 是如何选择索引来满足查询的。假设我们有五个索引。当一个查询进来时,MongoDB 会看这个查询的形状。形状涉及到搜索哪些字段以及额外的信息,比如是否有排序。根据这些信息,系统确定了一组可能用于满足查询的候选索引。
假设我们有一个查询进来,我们的五个索引中有三个被标识为该查询的候选索引。MongoDB 随后会为这三个索引创建三个查询计划,并在三个并行线程中运行查询,每个线程使用不同的索引。这里的目标是看哪个能够最快地返回结果。
从视觉上来看,我们可以把这个过程想象成一场比赛,如图 5-1 所示。这里的想法是,首个达到目标状态的查询计划将获胜。但更重要的是,未来将选择它作为具有相同查询形状的查询所使用的索引。这些计划在一段时间内进行比赛(称为试验期),之后每场比赛的结果将用于计算总体获胜计划。

图 5-1. MongoDB 查询规划器如何选择索引,视觉化为一场比赛
要赢得比赛,查询线程必须首先返回所有查询结果或按顺序返回一定数量的试验结果。考虑到在内存中执行排序的昂贵性,排序顺序部分尤为重要。
将几个查询计划相互比赛的真正价值在于,对于具有相同查询形状的后续查询,MongoDB 服务器将知道选择哪个索引。服务器维护一个查询计划的缓存。获胜计划将存储在缓存中,以备将来用于该形状的查询。随着集合的变化和索引的变化,随着时间的推移,某个查询计划可能会从缓存中删除,MongoDB 会再次尝试可能的查询计划,以找到最适合当前集合和索引集的计划。导致计划从缓存中删除的其他事件包括重建给定索引、添加或删除索引,或显式清除计划缓存。最后,查询计划缓存在 mongod 进程重新启动后不会保留。
使用复合索引
在之前的章节中,我们一直在使用复合索引,即在其中具有多个键的索引。复合索引比单键索引更加复杂,但它们非常强大。本节将更深入地介绍它们。
在这里,我们将通过一个例子来逐步说明当您设计复合索引时需要考虑的思路类型。我们的目标是使我们的读取和写入操作尽可能高效——但与许多事物一样,这需要一些前期思考和实验。
确保我们放置正确的索引非常重要,有必要在某些真实工作负载下测试我们的索引,并从中进行调整。然而,在设计我们的索引时,我们可以应用一些最佳实践。
首先,我们需要考虑索引的选择性。我们对于特定的查询模式,索引在最小化扫描记录数方面的作用感兴趣。我们需要在考虑满足查询所需的所有操作时,有时需要进行权衡。例如,我们需要考虑如何处理排序操作。
让我们来看一个例子。为此,我们将使用包含约一百万条记录的学生数据集。该数据集中的文档如下所示:
{
"_id" : ObjectId("585d817db4743f74e2da067c"),
"student_id" : 0,
"scores" : [
{
"type" : "exam",
"score" : 38.05000060199827
},
{
"type" : "quiz",
"score" : 79.45079445008987
},
{
"type" : "homework",
"score" : 74.50150548699534
},
{
"type" : "homework",
"score" : 74.68381684615845
}
],
"class_id" : 127
}
我们将从两个索引开始,并查看 MongoDB 如何使用(或不使用)这些索引来满足查询。这两个索引的创建方式如下:
> db.students.createIndex({"class_id": 1})
> db.students.createIndex({student_id: 1, class_id: 1})
在处理这个数据集时,我们将考虑以下查询,因为它展示了我们在设计索引时需要考虑的几个问题:
> db.students.find({student_id:{$gt:500000}, class_id:54})
... .sort({student_id:1})
... .explain("executionStats")
请注意,在此查询中,我们要求所有 ID 大于 500,000 的记录,大约占记录总数的一半。我们还限制搜索以查找 ID 为 54 的类别的记录。在这个数据集中,大约有 500 个类别。最后,我们按照 "student_id" 的升序排序。请注意,这是我们进行多值查询的同一字段。在本例中,我们将查看 explain 方法提供的执行统计信息,以说明 MongoDB 如何处理此查询。
如果我们运行此查询,explain 方法的输出告诉我们 MongoDB 如何使用索引来满足它:
{
"queryPlanner": {
"plannerVersion": 1,
"namespace": "school.students",
"indexFilterSet": false,
"parsedQuery": {
"$and": [
{
"class_id": {
"$eq": 54
}
},
{
"student_id": {
"$gt": 500000
}
}
]
},
"winningPlan": {
"stage": "FETCH",
"inputStage": {
"stage": "IXSCAN",
"keyPattern": {
"student_id": 1,
"class_id": 1
},
"indexName": "student_id_1_class_id_1",
"isMultiKey": false,
"multiKeyPaths": {
"student_id": [ ],
"class_id": [ ]
},
"isUnique": false,
"isSparse": false,
"isPartial": false,
"indexVersion": 2,
"direction": "forward",
"indexBounds": {
"student_id": [
"(500000.0, inf.0]"
],
"class_id": [
"[54.0, 54.0]"
]
}
}
},
"rejectedPlans": [
{
"stage": "SORT",
"sortPattern": {
"student_id": 1
},
"inputStage": {
"stage": "SORT_KEY_GENERATOR",
"inputStage": {
"stage": "FETCH",
"filter": {
"student_id": {
"$gt": 500000
}
},
"inputStage": {
"stage": "IXSCAN",
"keyPattern": {
"class_id": 1
},
"indexName": "class_id_1",
"isMultiKey": false,
"multiKeyPaths": {
"class_id": [ ]
},
"isUnique": false,
"isSparse": false,
"isPartial": false,
"indexVersion": 2,
"direction": "forward",
"indexBounds": {
"class_id": [
"[54.0, 54.0]"
]
}
}
}
}
}
]
},
"executionStats": {
"executionSuccess": true,
"nReturned": 9903,
"executionTimeMillis": 4325,
"totalKeysExamined": 850477,
"totalDocsExamined": 9903,
"executionStages": {
"stage": "FETCH",
"nReturned": 9903,
"executionTimeMillisEstimate": 3485,
"works": 850478,
"advanced": 9903,
"needTime": 840574,
"needYield": 0,
"saveState": 6861,
"restoreState": 6861,
"isEOF": 1,
"invalidates": 0,
"docsExamined": 9903,
"alreadyHasObj": 0,
"inputStage": {
"stage": "IXSCAN",
"nReturned": 9903,
"executionTimeMillisEstimate": 2834,
"works": 850478,
"advanced": 9903,
"needTime": 840574,
"needYield": 0,
"saveState": 6861,
"restoreState": 6861,
"isEOF": 1,
"invalidates": 0,
"keyPattern": {
"student_id": 1,
"class_id": 1
},
"indexName": "student_id_1_class_id_1",
"isMultiKey": false,
"multiKeyPaths": {
"student_id": [ ],
"class_id": [ ]
},
"isUnique": false,
"isSparse": false,
"isPartial": false,
"indexVersion": 2,
"direction": "forward",
"indexBounds": {
"student_id": [
"(500000.0, inf.0]"
],
"class_id": [
"[54.0, 54.0]"
]
},
"keysExamined": 850477,
"seeks": 840575,
"dupsTested": 0,
"dupsDropped": 0,
"seenInvalidated": 0
}
}
},
"serverInfo": {
"host": "SGB-MBP.local",
"port": 27017,
"version": "3.4.1",
"gitVersion": "5e103c4f5583e2566a45d740225dc250baacfbd7"
},
"ok": 1
}
与大多数从 MongoDB 输出的数据一样,explain 的输出是 JSON 格式的。让我们首先看看输出的底部,几乎完全是执行统计信息。"executionStats" 字段包含描述已完成的查询执行的统计信息,用于获胜查询计划。稍后我们将查看查询计划和 explain 输出中的查询计划输出。
在 "executionStats" 中,首先我们将查看 "totalKeysExamined"。这是 MongoDB 为了生成结果集而遍历的索引键的数量。我们可以将 "totalKeysExamined" 与 "nReturned" 进行比较,以了解 MongoDB 为了找到与查询匹配的文档而必须遍历索引的多少部分。在这种情况下,检查了 850,477 个索引键以定位 9,903 个匹配的文档。
这意味着用于满足此查询的索引选择性不高。这一点进一步强调了查询运行超过 4.3 秒的事实,如 "executionTimeMillis" 字段所示。在设计索引时,选择性是我们的主要目标之一,因此让我们找出我们在为此查询设计的现有索引出了什么问题。
在 explain 输出的顶部附近是获胜的查询计划(参见 "winningPlan" 字段)。查询计划描述了 MongoDB 用于满足查询的步骤。这是几种不同查询计划竞争的具体结果,以 JSON 形式呈现。特别是,我们对使用了哪些索引以及 MongoDB 是否需要在内存中排序感兴趣。在获胜计划下面是被拒绝的计划。我们将查看这两者。
在这种情况下,获胜计划使用了基于 "student_id" 和 "class_id" 的复合索引。这在 explain 输出的以下部分中明显可见:
"winningPlan": {
"stage": "FETCH",
"inputStage": {
"stage": "IXSCAN",
"keyPattern": {
"student_id": 1,
"class_id": 1
},
explain输出将查询计划表示为一个阶段树。一个阶段可以有一个或多个输入阶段,这取决于它有多少子阶段。一个输入阶段向其父级提供文档或索引键。在本例中,有一个输入阶段,即索引扫描,该扫描为与查询匹配的文档提供了记录 ID,传递给其父级"FETCH"阶段。然后,"FETCH"阶段将检索文档本身,并根据客户端请求逐批返回它们。
失败的查询计划——只有一个——本应使用基于"class_id"的索引,但随后需要在内存中进行排序。这就是特定查询计划的以下部分的含义。当你在查询计划中看到"SORT"阶段时,意味着 MongoDB 无法使用索引对结果集进行排序,而必须在内存中进行排序:
"rejectedPlans": [
{
"stage": "SORT",
"sortPattern": {
"student_id": 1
},
对于这个查询,获胜的索引是能够返回排序输出的索引。要赢得它,它只需达到一定数量的已排序结果文档的试验号。对于另一个计划要获胜,那个查询线程必须首先返回整个结果集(将近 10,000 个文档),因为然后需要在内存中对其进行排序。
这里的问题是选择性的问题。我们正在运行的多值查询指定了一个广泛范围的"student_id"值,因为它请求"student_id"大于 500,000 的记录,这大约占我们收藏中的一半记录。这里再次为了方便,列出我们正在运行的查询:
> db.students.find({student_id:{$gt:500000}, class_id:54})
... .sort({student_id:1})
... .explain("executionStats")
现在,我相信你可以看出我们将要讨论的内容。此查询包含多值部分和相等部分。相等部分是我们要求所有"class_id"等于54的记录。在这个数据集中只有大约 500 个班级,尽管有许多学生在这些班级中获得了成绩,"class_id"作为执行此查询的更为选择性的依据。正是这个值将我们的结果集约束在不到 10,000 条记录,而不是此查询的多值部分所识别的大约 850,000 条记录。
换句话说,考虑到我们拥有的索引,如果我们使用仅基于"class_id"的索引——即失败的查询计划中的索引——会更好。MongoDB 提供了两种强制数据库使用特定索引的方法。但是,我强调你应该谨慎使用这些方式,以覆盖查询规划器的结果。这些不是你应该在生产环境中使用的技术。
游标 hint 方法使我们能够指定要使用的特定索引,可以通过指定其形状或名称来实现。索引过滤器使用查询形状,这是查询、排序和投影规范的组合。planCacheSetFilter 函数可以与索引过滤器一起使用,以限制查询优化器仅考虑索引过滤器中指定的索引。如果查询形状存在索引过滤器,MongoDB 将忽略 hint。索引过滤器仅在 mongod 服务器进程的持续时间内存在;它们在关闭后不会持续存在。
如果我们稍微改变查询,使用 hint,如下例所示,explain 输出将会有很大不同:
> db.students.find({student_id:{$gt:500000}, class_id:54})
... .sort({student_id:1})
... .hint({class_id:1})
... .explain("executionStats")
结果输出显示,我们现在从扫描大约 850,000 个索引键减少到大约 20,000 个,以便获得我们少于 10,000 个结果集。此外,执行时间仅为 272 毫秒,而不是使用其他索引的查询计划时的 4.3 秒:
{
"queryPlanner": {
"plannerVersion": 1,
"namespace": "school.students",
"indexFilterSet": false,
"parsedQuery": {
"$and": [
{
"class_id": {
"$eq": 54
}
},
{
"student_id": {
"$gt": 500000
}
}
]
},
"winningPlan": {
"stage": "SORT",
"sortPattern": {
"student_id": 1
},
"inputStage": {
"stage": "SORT_KEY_GENERATOR",
"inputStage": {
"stage": "FETCH",
"filter": {
"student_id": {
"$gt": 500000
}
},
"inputStage": {
"stage": "IXSCAN",
"keyPattern": {
"class_id": 1
},
"indexName": "class_id_1",
"isMultiKey": false,
"multiKeyPaths": {
"class_id": [ ]
},
"isUnique": false,
"isSparse": false,
"isPartial": false,
"indexVersion": 2,
"direction": "forward",
"indexBounds": {
"class_id": [
"[54.0, 54.0]"
]
}
}
}
}
},
"rejectedPlans": [ ]
},
"executionStats": {
"executionSuccess": true,
"nReturned": 9903,
"executionTimeMillis": 272,
"totalKeysExamined": 20076,
"totalDocsExamined": 20076,
"executionStages": {
"stage": "SORT",
"nReturned": 9903,
"executionTimeMillisEstimate": 248,
"works": 29982,
"advanced": 9903,
"needTime": 20078,
"needYield": 0,
"saveState": 242,
"restoreState": 242,
"isEOF": 1,
"invalidates": 0,
"sortPattern": {
"student_id": 1
},
"memUsage": 2386623,
"memLimit": 33554432,
"inputStage": {
"stage": "SORT_KEY_GENERATOR",
"nReturned": 9903,
"executionTimeMillisEstimate": 203,
"works": 20078,
"advanced": 9903,
"needTime": 10174,
"needYield": 0,
"saveState": 242,
"restoreState": 242,
"isEOF": 1,
"invalidates": 0,
"inputStage": {
"stage": "FETCH",
"filter": {
"student_id": {
"$gt": 500000
}
},
"nReturned": 9903,
"executionTimeMillisEstimate": 192,
"works": 20077,
"advanced": 9903,
"needTime": 10173,
"needYield": 0,
"saveState": 242,
"restoreState": 242,
"isEOF": 1,
"invalidates": 0,
"docsExamined": 20076,
"alreadyHasObj": 0,
"inputStage": {
"stage": "IXSCAN",
"nReturned": 20076,
"executionTimeMillisEstimate": 45,
"works": 20077,
"advanced": 20076,
"needTime": 0,
"needYield": 0,
"saveState": 242,
"restoreState": 242,
"isEOF": 1,
"invalidates": 0,
"keyPattern": {
"class_id": 1
},
"indexName": "class_id_1",
"isMultiKey": false,
"multiKeyPaths": {
"class_id": [ ]
},
"isUnique": false,
"isSparse": false,
"isPartial": false,
"indexVersion": 2,
"direction": "forward",
"indexBounds": {
"class_id": [
"[54.0, 54.0]"
]
},
"keysExamined": 20076,
"seeks": 1,
"dupsTested": 0,
"dupsDropped": 0,
"seenInvalidated": 0
}
}
}
}
},
"serverInfo": {
"host": "SGB-MBP.local",
"port": 27017,
"version": "3.4.1",
"gitVersion": "5e103c4f5583e2566a45d740225dc250baacfbd7"
},
"ok": 1
}
然而,我们真正希望看到的是 "nReturned" 接近 "totalKeysExamined"。此外,我们希望避免使用 hint 以更有效地执行此查询。解决这两个问题的方法是设计一个更好的索引。
对于问题中的查询模式,更好的索引是基于 "class_id" 和 "student_id" 的索引,按照这个顺序。以 "class_id" 作为前缀,我们在查询中使用等值过滤器来限制在索引中考虑的键。这是查询中最具选择性的组件,因此有效地限制了 MongoDB 需要考虑的键的数量以满足此查询。我们可以如下构建这个索引:
> db.students.createIndex({class_id:1, student_id:1})
尽管对于绝对每个数据集都不是真实的,但通常你应该设计复合索引,使得那些你将使用等值过滤器的字段位于你的应用程序将使用多值过滤器的字段之前。
有了我们的新索引,如果重新运行我们的查询,这次不需要提示,我们可以从 explain 输出的 "executionStats" 字段看到,我们有一个快速的查询(37 毫秒),其中返回的结果数 ("nReturned") 等于索引中扫描的键数 ("totalKeysExamined")。我们还可以看到,这是因为 "executionStages" 反映了获胜的查询计划,其中包含使用我们创建的新索引的索引扫描:
...
"executionStats": {
"executionSuccess": true,
"nReturned": 9903,
"executionTimeMillis": 37,
"totalKeysExamined": 9903,
"totalDocsExamined": 9903,
"executionStages": {
"stage": "FETCH",
"nReturned": 9903,
"executionTimeMillisEstimate": 36,
"works": 9904,
"advanced": 9903,
"needTime": 0,
"needYield": 0,
"saveState": 81,
"restoreState": 81,
"isEOF": 1,
"invalidates": 0,
"docsExamined": 9903,
"alreadyHasObj": 0,
"inputStage": {
"stage": "IXSCAN",
"nReturned": 9903,
"executionTimeMillisEstimate": 0,
"works": 9904,
"advanced": 9903,
"needTime": 0,
"needYield": 0,
"saveState": 81,
"restoreState": 81,
"isEOF": 1,
"invalidates": 0,
"keyPattern": {
"class_id": 1,
"student_id": 1
},
"indexName": "class_id_1_student_id_1",
"isMultiKey": false,
"multiKeyPaths": {
"class_id": [ ],
"student_id": [ ]
},
"isUnique": false,
"isSparse": false,
"isPartial": false,
"indexVersion": 2,
"direction": "forward",
"indexBounds": {
"class_id": [
"[54.0, 54.0]"
],
"student_id": [
"(500000.0, inf.0]"
]
},
"keysExamined": 9903,
"seeks": 1,
"dupsTested": 0,
"dupsDropped": 0,
"seenInvalidated": 0
}
}
},
考虑到我们对索引构建方式的了解,你可能能够理解为什么这会奏效。[class_id, student_id] 索引由以下类似的键对组成。由于学生 ID 在这些键对中是有序的,为了满足我们的排序,MongoDB 只需遍历以 class_id 54 开头的所有键对:
...
[53, 999617]
[53, 999780]
[53, 999916]
[54, 500001]
[54, 500009]
[54, 500048]
...
在考虑复合索引设计时,我们需要知道如何处理常见查询模式的等值过滤器、多值过滤器和排序组件。对于所有复合索引,必须考虑这三个因素,如果您设计您的索引以正确平衡这些问题,您将获得 MongoDB 查询的最佳性能。虽然我们已经通过[class_id, student_id]索引解决了我们示例查询的所有三个因素,但是由于我们正在对其中一个我们也正在过滤的字段进行排序,所以该查询作为复合索引问题的一个特例。
为了消除这个示例的特例性质,让我们改为按照最终成绩排序,修改我们的查询如下:
> db.students.find({student_id:{$gt:500000}, class_id:54})
... .sort({final_grade:1})
... .explain("executionStats")
如果我们运行此查询并查看explain输出,我们会看到我们现在正在执行内存排序。虽然查询仍然快速,只需 136 毫秒,但比在"student_id"上排序慢一个数量级,因为我们现在正在执行内存排序。我们可以看到我们正在执行内存排序,因为获胜查询计划现在包含一个"SORT"阶段:
...
"executionStats": {
"executionSuccess": true,
"nReturned": 9903,
"executionTimeMillis": 136,
"totalKeysExamined": 9903,
"totalDocsExamined": 9903,
"executionStages": {
"stage": "SORT",
"nReturned": 9903,
"executionTimeMillisEstimate": 36,
"works": 19809,
"advanced": 9903,
"needTime": 9905,
"needYield": 0,
"saveState": 315,
"restoreState": 315,
"isEOF": 1,
"invalidates": 0,
"sortPattern": {
"final_grade": 1
},
"memUsage": 2386623,
"memLimit": 33554432,
"inputStage": {
"stage": "SORT_KEY_GENERATOR",
"nReturned": 9903,
"executionTimeMillisEstimate": 24,
"works": 9905,
"advanced": 9903,
"needTime": 1,
"needYield": 0,
"saveState": 315,
"restoreState": 315,
"isEOF": 1,
"invalidates": 0,
"inputStage": {
"stage": "FETCH",
"nReturned": 9903,
"executionTimeMillisEstimate": 24,
"works": 9904,
"advanced": 9903,
"needTime": 0,
"needYield": 0,
"saveState": 315,
"restoreState": 315,
"isEOF": 1,
"invalidates": 0,
"docsExamined": 9903,
"alreadyHasObj": 0,
"inputStage": {
"stage": "IXSCAN",
"nReturned": 9903,
"executionTimeMillisEstimate": 12,
"works": 9904,
"advanced": 9903,
"needTime": 0,
"needYield": 0,
"saveState": 315,
"restoreState": 315,
"isEOF": 1,
"invalidates": 0,
"keyPattern": {
"class_id": 1,
"student_id": 1
},
"indexName": "class_id_1_student_id_1",
"isMultiKey": false,
"multiKeyPaths": {
"class_id": [ ],
"student_id": [ ]
},
"isUnique": false,
"isSparse": false,
"isPartial": false,
"indexVersion": 2,
"direction": "forward",
"indexBounds": {
"class_id": [
"[54.0, 54.0]"
],
"student_id": [
"(500000.0, inf.0]"
]
},
"keysExamined": 9903,
"seeks": 1,
"dupsTested": 0,
"dupsDropped": 0,
"seenInvalidated": 0
}
}
}
}
},
...
如果我们可以通过更好的索引设计避免内存排序,那就应该这么做。这将使我们能够更轻松地应对数据集大小和系统负载。
但是要做到这一点,我们将不得不做出一个权衡。这在设计复合索引时通常是必要的。
对于复合索引经常必须做的是,为了避免内存排序,我们需要检查更多的键比我们返回的文档数目。为了使用索引进行排序,MongoDB 需要能够按顺序遍历索引键。这意味着我们需要在复合索引键中包括排序字段。
我们新的复合索引中键的顺序应该如下:[class_id, final_grade, student_id]。请注意,我们在等值过滤器之后但在多值过滤器之前包括排序组件。这个索引将非常有选择地缩小此查询考虑的键集合。然后,通过遍历与此索引中等值过滤器匹配的键三元组,MongoDB 可以识别匹配多值过滤器的记录,并且这些记录将按照最终成绩升序正确排序。
这个复合索引强制 MongoDB 检查的键比最终将在我们的结果集中结束的文档数多。然而,通过使用索引来确保我们已经排序的文档,我们节省了执行时间。我们可以使用以下命令构建新索引:
> db.students.createIndex({class_id:1, final_grade:1, student_id:1})
现在,如果我们再次发出我们的查询:
> db.students.find({student_id:{$gt:500000}, class_id:54})
... .sort({final_grade:1})
... .explain("executionStats")
我们从explain输出中得到以下"executionStats"。这会根据您的硬件和系统中的其他活动而变化,但您可以看到获胜计划不再包括内存排序。相反,它使用我们刚刚创建的索引来满足查询,包括排序:
"executionStats": {
"executionSuccess": true,
"nReturned": 9903,
"executionTimeMillis": 42,
"totalKeysExamined": 9905,
"totalDocsExamined": 9903,
"executionStages": {
"stage": "FETCH",
"nReturned": 9903,
"executionTimeMillisEstimate": 34,
"works": 9905,
"advanced": 9903,
"needTime": 1,
"needYield": 0,
"saveState": 82,
"restoreState": 82,
"isEOF": 1,
"invalidates": 0,
"docsExamined": 9903,
"alreadyHasObj": 0,
"inputStage": {
"stage": "IXSCAN",
"nReturned": 9903,
"executionTimeMillisEstimate": 24,
"works": 9905,
"advanced": 9903,
"needTime": 1,
"needYield": 0,
"saveState": 82,
"restoreState": 82,
"isEOF": 1,
"invalidates": 0,
"keyPattern": {
"class_id": 1,
"final_grade": 1,
"student_id": 1
},
"indexName": "class_id_1_final_grade_1_student_id_1",
"isMultiKey": false,
"multiKeyPaths": {
"class_id": [ ],
"final_grade": [ ],
"student_id": [ ]
},
"isUnique": false,
"isSparse": false,
"isPartial": false,
"indexVersion": 2,
"direction": "forward",
"indexBounds": {
"class_id": [
"[54.0, 54.0]"
],
"final_grade": [
"[MinKey, MaxKey]"
],
"student_id": [
"(500000.0, inf.0]"
]
},
"keysExamined": 9905,
"seeks": 2,
"dupsTested": 0,
"dupsDropped": 0,
"seenInvalidated": 0
}
}
},
本节提供了设计复合索引的一些最佳实践的具体示例。虽然这些指南并不适用于每一种情况,但对大多数情况有效,并且应该是你在构建复合索引时首先考虑的想法。
总结一下,在设计复合索引时:
-
用于等值过滤器的键应该首先出现。
-
用于排序的键应该出现在多值字段之前。
-
用于多值过滤器的键应该最后出现。
根据这些指南设计你的复合索引,然后在真实的工作负载下测试它,以支持索引设计的查询模式范围。
选择键的方向
到目前为止,我们所有的索引条目都按升序或最小至最大的顺序排序。然而,如果你需要根据两个(或更多)标准进行排序,可能需要让索引键以不同的方向排列。例如,回到我们早期的例子,假设我们想按年龄从小到大和按名称从 Z 到 A 对集合进行排序。我们之前的索引对于这个问题不是很有效:在每个年龄组内,用户按用户名升序(A-Z,而不是 Z-A)排序。到目前为止,我们使用的复合索引没有以任何有用的顺序保存值,以获得"age"升序和"username"降序。
要优化不同方向的复合排序,我们需要使用具有匹配方向的索引。在这个例子中,我们可以使用{"age" : 1, "username" : -1},这将按以下方式组织数据:
[21, user999600] -> 8765277104
[21, user999407] -> 8765252400
[21, user999390] -> 8765250224
...
[21, user100270] -> 8623545776
[21, user100266] -> 8623545264
[21, user100154] -> 8623530928
...
[30, user100168] -> 8623532720
[30, user100155] -> 8623531056
[30, user100098] -> 8623523760
年龄从小到大排列,在每个年龄段内,用户名从 Z 到 A 排序(或者说是从 9 到 0,鉴于我们的用户名)。
如果我们的应用程序还需要优化{"age" : 1, "username" : 1}的排序,我们需要创建一个带有这些方向的第二个索引。要确定索引使用的方向,只需匹配你的排序使用的方向。请注意,反向索引(每个方向乘以-1)是等效的:{"age" : 1, "username" : -1}适用于与{"age" : -1, "username" : 1}相同的查询。
索引方向只在基于多个标准进行排序时才真正重要。如果仅按单个键排序,MongoDB 可以轻松地以相反顺序读取索引。例如,如果你按{"age" : -1}排序并且有一个{"age" : 1}的索引,MongoDB 可以像在{"age" : -1}上创建索引一样进行优化(因此不要同时创建两者!)。方向仅对多键排序有效。
使用覆盖查询
在前面的例子中,索引总是用于查找正确的文档,然后跟随指针返回以获取实际文档。但是,如果您的查询只是查找索引中包含的字段,它就不需要获取文档。当索引包含查询请求的所有值时,该查询被认为是covered。在实际操作中,优先使用 covered 查询,而不是返回文档。这样可以显著减少您的工作集。
要确保查询只使用索引,您应该使用投影(限制返回的字段仅限于查询中指定的字段;参见“指定要返回的键”)来避免返回"_id"字段(除非它是索引的一部分)。您可能还需要对未查询的字段创建索引,因此您应该在提高查询速度和写入开销之间取得平衡。
如果在一个 covered 查询上运行explain,结果会有一个不是"FETCH"阶段后代的"IXSCAN"阶段,并且在"executionStats"中,"totalDocsExamined"的值为0。
隐式索引
复合索引可以“双重职能”,对不同的查询起到不同的索引作用。例如,如果我们在{"age": 1, "username": 1}上有一个索引,则"age"字段的排序方式与仅在{"age": 1}上有索引时相同。因此,复合索引可以像仅在{"age": 1}上有索引时那样使用。
这可以推广到任意数量的键:如果一个索引有N个键,您可以在这些键的任何前缀上获得一个“免费”的索引。例如,如果我们有一个看起来像{"a": 1, "b": 1, "c": 1, ..., "z": 1}的索引,我们实际上有{"a": 1}、{"a": 1, "b" : 1}、{"a": 1, "b": 1, "c": 1}等索引。
注意,这并不适用于任何键的子集:例如,使用索引{"b": 1}或{"a": 1, "c": 1}的查询都不会被优化。只有能使用索引前缀的查询才能利用它。
$操作符如何使用索引
一些查询可以比其他查询更有效地使用索引;有些查询根本不能使用索引。本节介绍了 MongoDB 如何处理各种查询操作符。
低效的操作符
总的来说,否定操作效率低下。"$ne"查询可以使用索引,但效果不佳。它们必须查看除了"$ne"指定的条目之外的所有索引条目,因此基本上必须扫描整个索引。例如,对于一个具有字段名为"i"的集合,在这样一个查询中遍历的索引范围如下:
db.example.find({"i" : {"$ne" : 3}}).explain()
{
"queryPlanner" : {
...,
"parsedQuery" : {
"i" : {
"$ne" : "3"
}
},
"winningPlan" : {
{
...,
"indexBounds" : {
"i" : [
[
{
"$minElement" : 1
},
3
],
[
3,
{
"$maxElement" : 1
}
]
]
}
}
},
"rejectedPlans" : [ ]
},
"serverInfo" : {
...,
}
}
此查询查看所有小于3的索引条目和所有大于3的索引条目。如果集合中有大量的3,这可能是有效的,但否则它必须几乎检查所有内容。
"$not"有时可以使用索引,但通常不知道如何。它可以反转基本范围({"*`key`*" : {"$lt" : 7}}变成{"*`key`*" : {"$gte" : 7}})和正则表达式。但是,大多数带有"$not"的其他查询将退回到扫描表。"$nin"始终使用扫描表。
如果需要快速执行此类查询,请查看是否有其他子句可以添加到查询中,以使用索引将结果集过滤到少量文档,然后再尝试进行非索引匹配。
范围
复合索引可以帮助 MongoDB 高效地执行具有多个子句的查询。设计具有多个字段的索引时,首先放置将在精确匹配中使用的字段(例如"x" : 1),然后放置范围字段(例如"y": {"$gt" : 3, "$lt" : 5})。这允许查询找到第一个索引键的精确值,然后在其中搜索第二个索引范围。例如,假设我们使用{"age" : 1, "username" : 1}索引查询特定年龄和用户名范围。我们将得到相当精确的索引边界:
> db.users.find({"age" : 47, "username" :
... {"$gt" : "user5", "$lt" : "user8"}}).explain('executionStats')
{
"queryPlanner" : {
"plannerVersion" : 1,
"namespace" : "test.users",
"indexFilterSet" : false,
"parsedQuery" : {
"$and" : [
{
"age" : {
"$eq" : 47
}
},
{
"username" : {
"$lt" : "user8"
}
},
{
"username" : {
"$gt" : "user5"
}
}
]
},
"winningPlan" : {
"stage" : "FETCH",
"inputStage" : {
"stage" : "IXSCAN",
"keyPattern" : {
"age" : 1,
"username" : 1
},
"indexName" : "age_1_username_1",
"isMultiKey" : false,
"multiKeyPaths" : {
"age" : [ ],
"username" : [ ]
},
"isUnique" : false,
"isSparse" : false,
"isPartial" : false,
"indexVersion" : 2,
"direction" : "forward",
"indexBounds" : {
"age" : [
"[47.0, 47.0]"
],
"username" : [
"(\"user5\", \"user8\")"
]
}
}
},
"rejectedPlans" : [
{
"stage" : "FETCH",
"filter" : {
"age" : {
"$eq" : 47
}
},
"inputStage" : {
"stage" : "IXSCAN",
"keyPattern" : {
"username" : 1
},
"indexName" : "username_1",
"isMultiKey" : false,
"multiKeyPaths" : {
"username" : [ ]
},
"isUnique" : false,
"isSparse" : false,
"isPartial" : false,
"indexVersion" : 2,
"direction" : "forward",
"indexBounds" : {
"username" : [
"(\"user5\", \"user8\")"
]
}
}
}
]
},
"executionStats" : {
"executionSuccess" : true,
"nReturned" : 2742,
"executionTimeMillis" : 5,
"totalKeysExamined" : 2742,
"totalDocsExamined" : 2742,
"executionStages" : {
"stage" : "FETCH",
"nReturned" : 2742,
"executionTimeMillisEstimate" : 0,
"works" : 2743,
"advanced" : 2742,
"needTime" : 0,
"needYield" : 0,
"saveState" : 23,
"restoreState" : 23,
"isEOF" : 1,
"invalidates" : 0,
"docsExamined" : 2742,
"alreadyHasObj" : 0,
"inputStage" : {
"stage" : "IXSCAN",
"nReturned" : 2742,
"executionTimeMillisEstimate" : 0,
"works" : 2743,
"advanced" : 2742,
"needTime" : 0,
"needYield" : 0,
"saveState" : 23,
"restoreState" : 23,
"isEOF" : 1,
"invalidates" : 0,
"keyPattern" : {
"age" : 1,
"username" : 1
},
"indexName" : "age_1_username_1",
"isMultiKey" : false,
"multiKeyPaths" : {
"age" : [ ],
"username" : [ ]
},
"isUnique" : false,
"isSparse" : false,
"isPartial" : false,
"indexVersion" : 2,
"direction" : "forward",
"indexBounds" : {
"age" : [
"[47.0, 47.0]"
],
"username" : [
"(\"user5\", \"user8\")"
]
},
"keysExamined" : 2742,
"seeks" : 1,
"dupsTested" : 0,
"dupsDropped" : 0,
"seenInvalidated" : 0
}
}
},
"serverInfo" : {
"host" : "eoinbrazil-laptop-osx",
"port" : 27017,
"version" : "4.0.12",
"gitVersion" : "5776e3cbf9e7afe86e6b29e22520ffb6766e95d4"
},
"ok" : 1
}
查询直接转到"age" : 47,然后在其中搜索用户名在"user5"和"user8"之间。
相反,假设我们使用{"username" : 1, "age" : 1}的索引。这会改变查询计划,因为查询必须查看所有用户名在"user5"和"user8"之间的用户,并挑选出"age" : 47的用户:
> db.users.find({"age" : 47, "username" : {"$gt" : "user5", "$lt" : "user8"}})
.explain('executionStats')
{
"queryPlanner" : {
"plannerVersion" : 1,
"namespace" : "test.users",
"indexFilterSet" : false,
"parsedQuery" : {
"$and" : [
{
"age" : {
"$eq" : 47
}
},
{
"username" : {
"$lt" : "user8"
}
},
{
"username" : {
"$gt" : "user5"
}
}
]
},
"winningPlan" : {
"stage" : "FETCH",
"filter" : {
"age" : {
"$eq" : 47
}
},
"inputStage" : {
"stage" : "IXSCAN",
"keyPattern" : {
"username" : 1
},
"indexName" : "username_1",
"isMultiKey" : false,
"multiKeyPaths" : {
"username" : [ ]
},
"isUnique" : false,
"isSparse" : false,
"isPartial" : false,
"indexVersion" : 2,
"direction" : "forward",
"indexBounds" : {
"username" : [
"(\"user5\", \"user8\")"
]
}
}
},
"rejectedPlans" : [
{
"stage" : "FETCH",
"inputStage" : {
"stage" : "IXSCAN",
"keyPattern" : {
"username" : 1,
"age" : 1
},
"indexName" : "username_1_age_1",
"isMultiKey" : false,
"multiKeyPaths" : {
"username" : [ ],
"age" : [ ]
},
"isUnique" : false,
"isSparse" : false,
"isPartial" : false,
"indexVersion" : 2,
"direction" : "forward",
"indexBounds" : {
"username" : [
"(\"user5\", \"user8\")"
],
"age" : [
"[47.0, 47.0]"
]
}
}
}
]
},
"executionStats" : {
"executionSuccess" : true,
"nReturned" : 2742,
"executionTimeMillis" : 369,
"totalKeysExamined" : 333332,
"totalDocsExamined" : 333332,
"executionStages" : {
"stage" : "FETCH",
"filter" : {
"age" : {
"$eq" : 47
}
},
"nReturned" : 2742,
"executionTimeMillisEstimate" : 312,
"works" : 333333,
"advanced" : 2742,
"needTime" : 330590,
"needYield" : 0,
"saveState" : 2697,
"restoreState" : 2697,
"isEOF" : 1,
"invalidates" : 0,
"docsExamined" : 333332,
"alreadyHasObj" : 0,
"inputStage" : {
"stage" : "IXSCAN",
"nReturned" : 333332,
"executionTimeMillisEstimate" : 117,
"works" : 333333,
"advanced" : 333332,
"needTime" : 0,
"needYield" : 0,
"saveState" : 2697,
"restoreState" : 2697,
"isEOF" : 1,
"invalidates" : 0,
"keyPattern" : {
"username" : 1
},
"indexName" : "username_1",
"isMultiKey" : false,
"multiKeyPaths" : {
"username" : [ ]
},
"isUnique" : false,
"isSparse" : false,
"isPartial" : false,
"indexVersion" : 2,
"direction" : "forward",
"indexBounds" : {
"username" : [
"(\"user5\", \"user8\")"
]
},
"keysExamined" : 333332,
"seeks" : 1,
"dupsTested" : 0,
"dupsDropped" : 0,
"seenInvalidated" : 0
}
}
},
"serverInfo" : {
"host" : "eoinbrazil-laptop-osx",
"port" : 27017,
"version" : "4.0.12",
"gitVersion" : "5776e3cbf9e7afe86e6b29e22520ffb6766e95d4"
},
"ok" : 1
}
这迫使 MongoDB 扫描比使用先前索引大 100 倍的索引条目数。在查询中使用两个范围基本上总是强制执行这种效率较低的查询计划。
OR 查询
截至本文撰写时,MongoDB 每次查询只能使用一个索引。也就是说,如果您在{"x" : 1}上创建了一个索引,并在{"y" : 1}上创建了另一个索引,然后进行{"x" : 123, "y" : 456}的查询,MongoDB 将使用您创建的其中一个索引,而不是两个。唯一的例外是"$or"。"$or"可以每个"$or"子句使用一个索引,因为"$or"执行两次查询然后合并结果:
db.foo.find({"$or" : [{"x" : 123}, {"y" : 456}]}).explain()
{
"queryPlanner" : {
"plannerVersion" : 1,
"namespace" : "foo.foo",
"indexFilterSet" : false,
"parsedQuery" : {
"$or" : [
{
"x" : {
"$eq" : 123
}
},
{
"y" : {
"$eq" : 456
}
}
]
},
"winningPlan" : {
"stage" : "SUBPLAN",
"inputStage" : {
"stage" : "FETCH",
"inputStage" : {
"stage" : "OR",
"inputStages" : [
{
"stage" : "IXSCAN",
"keyPattern" : {
"x" : 1
},
"indexName" : "x_1",
"isMultiKey" : false,
"multiKeyPaths" : {
"x" : [ ]
},
"isUnique" : false,
"isSparse" : false,
"isPartial" : false,
"indexVersion" : 2,
"direction" : "forward",
"indexBounds" : {
"x" : [
"[123.0, 123.0]"
]
}
},
{
"stage" : "IXSCAN",
"keyPattern" : {
"y" : 1
},
"indexName" : "y_1",
"isMultiKey" : false,
"multiKeyPaths" : {
"y" : [ ]
},
"isUnique" : false,
"isSparse" : false,
"isPartial" : false,
"indexVersion" : 2,
"direction" : "forward",
"indexBounds" : {
"y" : [
"[456.0, 456.0]"
]
}
}
]
}
}
},
"rejectedPlans" : [ ]
},
"serverInfo" : {
...,
},
"ok" : 1
}
正如您所见,此explain需要在两个索引上执行两个单独的查询(由两个"IXSCAN"阶段指示)。通常情况下,执行两次查询并合并结果比执行单个查询效率低得多;因此,尽可能使用"$in"而不是"$or"。
如果必须使用"$or",请记住 MongoDB 需要查看两个查询的结果并删除任何重复项(匹配多个"$or"子句的文档)。
在运行"$in"查询时,除了排序之外,没有其他方法控制返回文档的顺序。例如,{"x" : {"$in" : [1, 2, 3]}}将以与{"x" : {"$in" : [3, 2, 1]}}相同的顺序返回文档。
对象和数组的索引
MongoDB 允许您深入到文档中并在嵌套字段和数组上创建索引。嵌入对象和数组字段可以与顶级字段结合在复合索引中,尽管它们在某些方面很特殊,但它们大多数情况下的行为与“普通”索引字段相同。
索引嵌入文档
可以像在普通键上创建索引一样,在嵌入文档的键上创建索引。如果我们有一个集合,每个文档代表一个用户,我们可能有一个描述每个用户位置的嵌入文档:
{
"username" : "sid",
"loc" : {
"ip" : "1.2.3.4",
"city" : "Springfield",
"state" : "NY"
}
}
我们可以在"loc"的子字段之一,比如"loc.city"上创建一个索引,以加快使用该字段的查询:
> db.users.createIndex({"loc.city" : 1})
如果您愿意,您可以深入到"x.y.z.w.a.b.c"(等等)进行索引。
注意,索引嵌入文档本身("loc")与索引该嵌入文档的字段("loc.city")有非常不同的行为。仅对整个子文档进行索引将仅帮助查询整个子文档的情况。查询优化器只能在描述整个子文档且字段顺序正确的查询中使用"loc"上的索引(例如,db.users.find({"loc" : {"ip" : "123.456.789.000", "city" : "Shelbyville", "state" : "NY"}}))。对于类似于db.users.find({"loc.city" : "Shelbyville"})的查询,它无法使用该索引。
索引数组
您还可以对数组进行索引,这使您能够高效地搜索特定的数组元素。
假设我们有一个博客文章的集合,每个文档都是一篇帖子。每篇帖子都有一个"comments"字段,其中包含嵌套的"comment"子文档数组。如果我们希望能够找到最近评论的博客文章,我们可以在博客文章集合的嵌入式"comments"文档数组中的"date"键上创建一个索引:
> db.blog.createIndex({"comments.date" : 1})
对数组进行索引会为数组的每个元素创建一个索引条目,因此如果一篇帖子有 20 条评论,它将有 20 个索引条目。这使得数组索引比单值索引更昂贵:对于单个插入、更新或删除操作,可能需要更新每个数组条目(潜在的数千个索引条目)。
不像前一节中的"loc"示例,您不能将整个数组作为单个实体进行索引:对数组字段的索引会索引数组的每个元素,而不是数组本身。
数组元素的索引不保留位置概念:您不能使用索引来查询特定的数组元素,比如"comments.4"。
顺便说一句,您可以像这样对特定数组条目进行索引:
> db.blog.createIndex({"comments.10.votes": 1})
然而,这个索引仅对查询第 11 个数组元素的情况有用(数组从索引 0 开始)。
索引条目中只能有一个字段来自数组。这是为了避免多个多键索引产生爆炸性的索引条目数量:每个文档将产生nm*个索引条目。例如,假设我们在{"x" : 1, "y" : 1}上有一个索引:
> // x is an array - legal
> db.multi.insert({"x" : [1, 2, 3], "y" : 1})
>
> // y is an array - still legal
> db.multi.insert({"x" : 1, "y" : [4, 5, 6]})
>
> // x and y are arrays - illegal!
> db.multi.insert({"x" : [1, 2, 3], "y" : [4, 5, 6]})
cannot index parallel arrays [y] [x]
如果 MongoDB 要对最终示例创建索引,它必须为{"x" : 1, "y" : 4}, {"x" : 1, "y" : 5}, {"x" : 1, "y" : 6}, {"x" : 2, "y" : 4}, {"x" : 2, "y" : 5}, {"x" : 2, "y" : 6}, {"x" : 3, "y" : 4}, {"x" : 3, "y" : 5}, 和 {"x" : 3, "y" : 6}创建索引条目(这些数组只有三个元素长)。
多键索引的影响
如果任何文档具有一个数组字段作为索引键,则该索引立即被标记为多键索引。您可以从explain的输出中看到索引是否是多键的:如果使用了多键索引,那么"isMultikey"字段将为true。一旦索引被标记为多键索引,即使移除了该字段中包含数组的所有文档,它也永远无法取消多键标记。唯一的解除多键标记方法是删除并重新创建索引。
多键索引可能比非多键索引稍慢一些。许多索引条目可以指向同一个文档,因此 MongoDB 在返回结果之前可能需要进行一些去重操作。
索引基数
基数 指集合中字段的不同值数量。一些字段,如"gender"或"newsletter opt-out",可能只有两个可能的值,这被认为是非常低的基数。而其他一些字段,如"username"或"email",可能在集合中每个文档中有唯一的值,这是高基数。还有一些字段介于两者之间,如"age"或"zip code"。
一般来说,字段的基数越大,索引在该字段上就越有帮助。这是因为索引可以快速缩小搜索空间,得到一个更小的结果集。对于低基数字段,索引通常无法消除许多可能的匹配。
例如,假设我们在"gender"上建立了一个索引,并正在寻找名为 Susan 的女性。在查看个别文档之前,我们只能将结果空间缩小约 50%。相反,如果我们按"name"索引,我们可以立即将结果集缩小到名为 Susan 的极小用户群体,然后可以参考这些文档以检查性别。
作为一个经验法则,尽量在高基数键上创建索引,或者至少将高基数键放在复合索引的前面(低基数键之前)。
explain 输出
正如你所见,explain 为你的查询提供了大量信息。它是慢查询中最重要的诊断工具之一。通过查看查询的 "explain" 输出,你可以找出哪些索引被使用以及它们如何被使用。对于任何查询,你可以在末尾添加一个 explain 调用(就像你会添加 sort 或 limit 一样,但 explain 必须是最后一个调用)。
有两种常见的 explain 输出类型:对于使用索引和未使用索引的查询。特殊的索引类型可能会创建略有不同的查询计划,但大多数字段应该是相似的。此外,分片会返回一个 explain 的总合(如 第十四章 中所述),因为它在多个服务器上运行查询。
explain 最基本的类型是在不使用索引的查询上。你可以通过它使用了 "COLLSCAN" 来判断一个查询是否使用了索引。
对使用索引的查询进行 explain 的输出有所不同,但在最简单的情况下,如果我们在 imdb.rating 上添加了索引,它看起来会像这样:
> db.users.find({"age" : 42}).explain('executionStats')
{
"queryPlanner" : {
"plannerVersion" : 1,
"namespace" : "test.users",
"indexFilterSet" : false,
"parsedQuery" : {
"age" : {
"$eq" : 42
}
},
"winningPlan" : {
"stage" : "FETCH",
"inputStage" : {
"stage" : "IXSCAN",
"keyPattern" : {
"age" : 1,
"username" : 1
},
"indexName" : "age_1_username_1",
"isMultiKey" : false,
"multiKeyPaths" : {
"age" : [ ],
"username" : [ ]
},
"isUnique" : false,
"isSparse" : false,
"isPartial" : false,
"indexVersion" : 2,
"direction" : "forward",
"indexBounds" : {
"age" : [
"[42.0, 42.0]"
],
"username" : [
"[MinKey, MaxKey]"
]
}
}
},
"rejectedPlans" : [ ]
},
"executionStats" : {
"executionSuccess" : true,
"nReturned" : 8449,
"executionTimeMillis" : 15,
"totalKeysExamined" : 8449,
"totalDocsExamined" : 8449,
"executionStages" : {
"stage" : "FETCH",
"nReturned" : 8449,
"executionTimeMillisEstimate" : 10,
"works" : 8450,
"advanced" : 8449,
"needTime" : 0,
"needYield" : 0,
"saveState" : 66,
"restoreState" : 66,
"isEOF" : 1,
"invalidates" : 0,
"docsExamined" : 8449,
"alreadyHasObj" : 0,
"inputStage" : {
"stage" : "IXSCAN",
"nReturned" : 8449,
"executionTimeMillisEstimate" : 0,
"works" : 8450,
"advanced" : 8449,
"needTime" : 0,
"needYield" : 0,
"saveState" : 66,
"restoreState" : 66,
"isEOF" : 1,
"invalidates" : 0,
"keyPattern" : {
"age" : 1,
"username" : 1
},
"indexName" : "age_1_username_1",
"isMultiKey" : false,
"multiKeyPaths" : {
"age" : [ ],
"username" : [ ]
},
"isUnique" : false,
"isSparse" : false,
"isPartial" : false,
"indexVersion" : 2,
"direction" : "forward",
"indexBounds" : {
"age" : [
"[42.0, 42.0]"
],
"username" : [
"[MinKey, MaxKey]"
]
},
"keysExamined" : 8449,
"seeks" : 1,
"dupsTested" : 0,
"dupsDropped" : 0,
"seenInvalidated" : 0
}
}
},
"serverInfo" : {
"host" : "eoinbrazil-laptop-osx",
"port" : 27017,
"version" : "4.0.12",
"gitVersion" : "5776e3cbf9e7afe86e6b29e22520ffb6766e95d4"
},
"ok" : 1
}
输出首先告诉你使用了哪个索引:imdb.rating。接下来是作为结果实际返回的文档数:"nReturned"。请注意,这不一定反映了 MongoDB 为回答查询所做的工作量(即它必须搜索多少个索引和文档)。"totalKeysExamined" 报告了扫描的索引条目数,而 "totalDocsExamined" 表示扫描的文档数。扫描的文档数反映在 "nscannedObjects" 中。
输出还显示没有 rejectedPlans,并且它在索引中使用了有界搜索,搜索值为 42.0。
"executionTimeMillis" 报告了查询执行的速度,从服务器接收请求到发送响应为止。然而,它可能并不总是你要找的数字。如果 MongoDB 尝试了多个查询计划,"executionTimeMillis" 将反映所有计划运行的时间,而不是选择的最佳计划的时间。
现在你已经了解了基础知识,接下来详细解释一些更重要的字段的分解:
"isMultiKey" : false
如果此查询使用了多键索引(参见 “对象和数组的索引”)。
"nReturned" : 8449"
查询返回的文档数。
"totalDocsExamined" : 8449
MongoDB 必须跟随索引指针到实际存储在磁盘上的文档的次数。如果查询包含不是索引的一部分的条件或请求不包含在索引中的字段,那么 MongoDB 必须查找每个索引条目指向的文档。
"totalKeysExamined" : 8449"
如果使用了索引,则查看的索引条目数。如果这是表扫描,则是扫描的文档数。
"stage" : "IXSCAN"
如果 MongoDB 能够使用索引来完成此查询;否则 "COLSCAN" 将表明它必须执行集合扫描来完成查询。
在这个例子中,MongoDB 使用索引找到了所有匹配的文档,我们知道这一点是因为 "totalKeysExamined" 等于 "totalDocsExamined"。然而,查询被告知返回匹配文档的每个字段,而索引只包含 "age" 和 "username" 字段。
"needYield" : 0
此查询暂停的次数(让步)以允许写入请求继续。如果有待写入的内容,查询会定期释放其锁并允许其继续。在此系统中,没有待写入内容,因此查询从未让步。
"executionTimeMillis" : 15
数据库执行查询所需的毫秒数。此数值越低越好。
"indexBounds" : {...}
描述索引使用方式的说明,给出索引遍历的范围。在此示例中,由于查询的第一个子句是精确匹配,因此索引只需要查看该值:42。第二个索引键是自由变量,因为查询没有指定任何对它的限制。因此,数据库寻找了用户名在 "age" : 42 内的 -无穷大(`` "\(minElement" : 1` ``)到无穷大(`` `"\)maxElement" : 1` ``)之间的值。
让我们看一个稍微复杂的例子。假设您在 {"username" : 1, "age" : 1} 和 {"age" : 1, "username" : 1} 上都有索引。如果查询 "username" 和 "age",会发生什么?好吧,这取决于查询:
> db.users.find({"age" : {$gt : 10}, "username" : "user2134"}).explain()
{
"queryPlanner" : {
"plannerVersion" : 1,
"namespace" : "test.users",
"indexFilterSet" : false,
"parsedQuery" : {
"$and" : [
{
"username" : {
"$eq" : "user2134"
}
},
{
"age" : {
"$gt" : 10
}
}
]
},
"winningPlan" : {
"stage" : "FETCH",
"filter" : {
"age" : {
"$gt" : 10
}
},
"inputStage" : {
"stage" : "IXSCAN",
"keyPattern" : {
"username" : 1
},
"indexName" : "username_1",
"isMultiKey" : false,
"multiKeyPaths" : {
"username" : [ ]
},
"isUnique" : false,
"isSparse" : false,
"isPartial" : false,
"indexVersion" : 2,
"direction" : "forward",
"indexBounds" : {
"username" : [
"[\"user2134\", \"user2134\"]"
]
}
}
},
"rejectedPlans" : [
{
"stage" : "FETCH",
"inputStage" : {
"stage" : "IXSCAN",
"keyPattern" : {
"age" : 1,
"username" : 1
},
"indexName" : "age_1_username_1",
"isMultiKey" : false,
"multiKeyPaths" : {
"age" : [ ],
"username" : [ ]
},
"isUnique" : false,
"isSparse" : false,
"isPartial" : false,
"indexVersion" : 2,
"direction" : "forward",
"indexBounds" : {
"age" : [
"(10.0, inf.0]"
],
"username" : [
"[\"user2134\", \"user2134\"]"
]
}
}
}
]
},
"serverInfo" : {
"host" : "eoinbrazil-laptop-osx",
"port" : 27017,
"version" : "4.0.12",
"gitVersion" : "5776e3cbf9e7afe86e6b29e22520ffb6766e95d4"
},
"ok" : 1
}
我们正在查询 "username" 的精确匹配和 "age" 的一系列值,因此数据库选择使用 {"username" : 1, "age" : 1} 索引,颠倒查询的条件。另一方面,如果我们查询一个确切的年龄和一系列名称,MongoDB 将使用另一个索引:
> db.users.find({"age" : 14, "username" : /.*/}).explain()
{
"queryPlanner" : {
"plannerVersion" : 1,
"namespace" : "test.users",
"indexFilterSet" : false,
"parsedQuery" : {
"$and" : [
{
"age" : {
"$eq" : 14
}
},
{
"username" : {
"$regex" : ".*"
}
}
]
},
"winningPlan" : {
"stage" : "FETCH",
"inputStage" : {
"stage" : "IXSCAN",
"filter" : {
"username" : {
"$regex" : ".*"
}
},
"keyPattern" : {
"age" : 1,
"username" : 1
},
"indexName" : "age_1_username_1",
"isMultiKey" : false,
"multiKeyPaths" : {
"age" : [ ],
"username" : [ ]
},
"isUnique" : false,
"isSparse" : false,
"isPartial" : false,
"indexVersion" : 2,
"direction" : "forward",
"indexBounds" : {
"age" : [
"[14.0, 14.0]"
],
"username" : [
"[\"\", {})",
"[/.*/, /.*/]"
]
}
}
},
"rejectedPlans" : [
{
"stage" : "FETCH",
"filter" : {
"age" : {
"$eq" : 14
}
},
"inputStage" : {
"stage" : "IXSCAN",
"filter" : {
"username" : {
"$regex" : ".*"
}
},
"keyPattern" : {
"username" : 1
},
"indexName" : "username_1",
"isMultiKey" : false,
"multiKeyPaths" : {
"username" : [ ]
},
"isUnique" : false,
"isSparse" : false,
"isPartial" : false,
"indexVersion" : 2,
"direction" : "forward",
"indexBounds" : {
"username" : [
"[\"\", {})",
"[/.*/, /.*/]"
]
}
}
}
]
},
"serverInfo" : {
"host" : "eoinbrazil-laptop-osx",
"port" : 27017,
"version" : "4.0.12",
"gitVersion" : "5776e3cbf9e7afe86e6b29e22520ffb6766e95d4"
},
"ok" : 1
}
如果发现 Mongo 在查询时使用不同于您希望的索引,您可以通过使用 hint 强制其使用特定索引。例如,如果要确保 MongoDB 在上一个查询中使用 {"username" : 1, "age" : 1} 索引,可以这样说:
> db.users.find({"age" : 14, "username" : /.*/}).hint({"username" : 1, "age" : 1})
警告
如果查询没有使用您希望的索引,并且您使用提示更改它,请在部署之前对提示的查询运行 explain。如果强制 MongoDB 在不知道如何使用索引的查询上使用索引,可能会使查询的效率不如没有索引时高。
何时不要索引
索引在检索少量数据子集时效果最佳,并且某些类型的查询在没有索引的情况下更快。随着需要获取集合较大百分比的数据时,索引效率逐渐降低,因为使用索引需要两次查找:一次查看索引条目,一次跟随索引指针查看文档。而集合扫描只需要一次查找:查看文档。在最坏的情况下(返回集合中的所有文档),使用索引将需要两倍的查找次数,并且通常比集合扫描慢得多。
不幸的是,并没有一个确切的规则来说明索引何时有帮助,何时有阻碍,因为这确实取决于您的数据大小、索引、文档和平均结果集的大小(见表 5-1)。作为经验法则,如果查询返回的集合占比达到 30%或更多,索引通常会加速查询。但是,这个数字可以在 2%到 60%之间变化。表 5-1 总结了索引或集合扫描更有效的条件。
表 5-1. 影响索引效果的属性
| 索引通常很有效 | 集合扫描通常很有效 |
|---|---|
| 大型集合 | 小型集合 |
| 大型文档 | 小型文档 |
| 选择性查询 | 非选择性查询 |
假设我们有一个收集统计数据的分析系统。我们的应用程序查询系统,为了生成从一小时前到时间开始的所有数据的漂亮图表:
> db.entries.find({"created_at" : {"$lt" : hourAgo}})
我们对"created_at"进行索引以加快此查询速度。
当我们首次启动时,结果集很小,查询立即返回。但是几周后,数据量开始增加,一个月后,这个查询运行时间已经太长。
对于大多数应用程序来说,这可能是“错误”的查询:您真的希望一个查询返回大部分数据集吗?大多数应用程序,特别是那些具有大型数据集的应用程序,不会这样做。但是,有一些合法的情况,您可能需要大部分或全部数据。例如,您可能正在将此数据导出到报告系统或将其用于批处理作业。在这些情况下,您希望尽快返回数据集的大部分。
索引类型
在构建索引时,您可以指定几种索引选项,这些选项会改变索引的行为方式。最常见的变体在以下各节中描述,更高级或特殊情况的选项在下一章节中描述。
唯一索引
唯一索引确保索引中的每个值最多只会出现一次。例如,如果您希望确保没有两个文档可以在"username"键中具有相同的值,您可以创建一个带有partialFilterExpression的唯一索引,仅适用于具有firstname字段的文档(关于此选项的更多信息将在本章后面介绍):
> db.users.createIndex({"firstname" : 1},
... {"unique" : true, "partialFilterExpression":{
"firstname": {$exists: true } } } )
{
"createdCollectionAutomatically" : false,
"numIndexesBefore" : 3,
"numIndexesAfter" : 4,
"ok" : 1
}
例如,假设您尝试将以下文档插入到users集合中:
> db.users.insert({firstname: "bob"})
WriteResult({ "nInserted" : 1 })
> db.users.insert({firstname: "bob"})
WriteResult({
"nInserted" : 0,
"writeError" : {
"code" : 11000,
"errmsg" : "E11000 duplicate key error collection: test.users index:
firstname_1 dup key: { : \"bob\" }"
}
})
如果您检查集合,您会看到只存储了第一个"bob"。抛出重复键异常并不高效,因此请将唯一约束用于偶尔的重复,而不是过滤大量重复的数据。
你可能已经很熟悉的一个唯一索引是在创建集合时自动创建的"_id"索引。这是一个普通的唯一索引(除了它不能像其他唯一索引那样被删除)。
警告
如果键不存在,索引会将其值存储为该文档的 null。这意味着如果您创建了一个唯一索引,并尝试插入超过一个缺少索引字段的文档,插入将失败,因为您已经有一个值为 null 的文档。有关处理此问题的建议,请参阅 “部分索引”。
在某些情况下,某个值可能不会被索引。索引桶的大小是有限的,如果索引条目超出了它,它就不会被包含在索引中。这可能会导致混淆,因为它使文档对使用索引的查询“不可见”。在 MongoDB 4.2 之前,字段的大小必须小于 1,024 字节才能包含在索引中。在 MongoDB 4.2 及更高版本中,取消了此约束。如果文档的字段由于大小而无法索引,MongoDB 不会返回任何错误或警告。这意味着长度超过 8 KB 的键不会受到唯一索引约束的影响:例如,您可以插入相同的 8 KB 字符串。
复合唯一索引
您也可以创建复合唯一索引。如果这样做,单个键可以具有相同的值,但在索引条目中,跨所有键的值组合最多只能出现一次。
例如,如果我们在 {"username" : 1, "age" : 1} 上有一个唯一索引,则以下插入将是合法的:
> db.users.insert({"username" : "bob"})
> db.users.insert({"username" : "bob", "age" : 23})
> db.users.insert({"username" : "fred", "age" : 23})
然而,尝试插入任何这些文档的第二份副本将引发重复键异常。
GridFS 是在 MongoDB 中存储大文件的标准方法(参见 “使用 GridFS 存储文件”),它使用复合唯一索引。包含文件内容的集合在 {"files_id" : 1, "n" : 1} 上有一个唯一索引,允许部分如下所示的文档:
{"files_id" : ObjectId("4b23c3ca7525f35f94b60a2d"), "n" : 1}
{"files_id" : ObjectId("4b23c3ca7525f35f94b60a2d"), "n" : 2}
{"files_id" : ObjectId("4b23c3ca7525f35f94b60a2d"), "n" : 3}
{"files_id" : ObjectId("4b23c3ca7525f35f94b60a2d"), "n" : 4}
请注意,"files_id" 的所有值都相同,但"n" 不同。
去除重复项
如果您尝试在现有集合上构建唯一索引,并且存在任何重复值,则构建将失败:
> db.users.createIndex({"age" : 1}, {"unique" : true})
WriteResult({
"nInserted" : 0,
"writeError" : {
"code" : 11000,
"errmsg" : "E11000 duplicate key error collection:
test.users index: age_1 dup key: { : 12 }"
}
})
一般来说,您需要处理您的数据(聚合框架可以帮助)并找出重复项及其处理方法。
部分索引
如前一节所述,唯一索引将 null 视为一个值,因此不能有一个缺少该键的文档的唯一索引。但是,在许多情况下,您可能希望仅在键存在时强制执行唯一索引。如果有一个字段可能存在也可能不存在,但在存在时必须是唯一的,您可以将 "unique" 选项与 "partial" 选项结合使用。
注意
MongoDB 中的部分索引仅在数据子集上创建。这与关系数据库中的稀疏索引不同,后者创建少量指向数据块的索引条目,但所有数据块都将在 RDBMS 中有一个关联的稀疏索引条目。
要创建部分索引,请包含"partialFilterExpression"选项。部分索引提供了稀疏索引所提供功能的超集,使用文档表示您希望在其上创建过滤器表达式。例如,如果提供电子邮件地址是可选的但是如果提供了应该是唯一的,我们可以这样做:
> db.users.ensureIndex({"email" : 1}, {"unique" : true, "partialFilterExpression" :
... { email: { $exists: true } }})
部分索引不一定非得是唯一的。要创建非唯一的部分索引,只需不包含"unique"选项。
需要注意的一点是,同一个查询可能会根据是否使用部分索引返回不同的结果。例如,假设我们有一个集合,其中大多数文档具有"x"字段,但有一个文档没有:
> db.foo.find()
{ "_id" : 0 }
{ "_id" : 1, "x" : 1 }
{ "_id" : 2, "x" : 2 }
{ "_id" : 3, "x" : 3 }
当我们在"x"上进行查询时,它将返回所有匹配的文档:
> db.foo.find({"x" : {"$ne" : 2}})
{ "_id" : 0 }
{ "_id" : 1, "x" : 1 }
{ "_id" : 3, "x" : 3 }
如果我们在"x"上创建了部分索引,"_id" : 0文档将不会包含在索引中。现在如果我们在"x"上查询,MongoDB 将使用该索引并且不返回{"_id" : 0}文档:
> db.foo.find({"x" : {"$ne" : 2}})
{ "_id" : 1, "x" : 1 }
{ "_id" : 3, "x" : 3 }
如果需要具有缺失字段的文档,可以使用hint来强制进行表扫描。
索引管理
如前一节所示,您可以使用createIndex函数创建新索引。每个集合只需创建一次索引。如果尝试再次创建相同的索引,将不会发生任何操作。
数据库索引的所有信息都存储在system.indexes集合中。这是一个保留集合,因此您不能修改其文档或删除其中的文档。您只能通过createIndex、createIndexes和dropIndexes数据库命令来操作它。
创建索引时,可以在system.indexes中看到其元信息。您还可以运行db.*`collectionName`*.getIndexes()来查看给定集合上所有索引的信息:
> db.students.getIndexes()
[
{
"v" : 2,
"key" : {
"_id" : 1
},
"name" : "_id_",
"ns" : "school.students"
},
{
"v" : 2,
"key" : {
"class_id" : 1
},
"name" : "class_id_1",
"ns" : "school.students"
},
{
"v" : 2,
"key" : {
"student_id" : 1,
"class_id" : 1
},
"name" : "student_id_1_class_id_1",
"ns" : "school.students"
}
]
重要字段是"key"和"name"。key可用于提示和其他需要指定索引的地方。这是字段顺序很重要的地方:{"class_id" : 1, "student_id" : 1}的索引与{"student_id" : 1, "class_id" : 1}的索引不同。索引名称用作许多管理索引操作的标识符,例如dropIndexes。索引是否为多键未在其规范中指定。
"v"字段用于索引版本管理。如果您有任何索引没有至少一个 "v": 1字段,它们将以较旧、不太有效的格式存储。您可以通过确保运行至少 MongoDB 版本 2.0 并删除和重建索引来升级它们。
索引识别
集合中的每个索引都有一个唯一标识该索引的名称,并且服务器用此名称来删除或操作它。默认情况下,索引名称为 *`keyname1`*_*`dir1`*_*`keyname2`*_*`dir2`*_..._*`keynameN`*_*`dirN`*,其中 *`keynameX`* 是索引的键, *`dirX`* 是索引的方向(1 或 -1)。如果索引包含超过几个键,这可能会变得难以管理,因此可以在createIndex的选项中指定自定义名称:
> db.soup.createIndex({"a" : 1, "b" : 1, "c" : 1, ..., "z" : 1},
... {"name" : "alphabet"})
索引名称中的字符数有限,因此复杂的索引可能需要自定义名称来创建。调用getLastError可以显示索引创建是否成功或未成功的原因。
更改索引
随着应用程序的增长和变化,您可能会发现数据或查询已经发生变化,过去工作良好的索引现在可能不再适用。您可以使用dropIndex命令删除不需要的索引:
> db.people.dropIndex("x_1_y_1")
{ "nIndexesWas" : 3, "ok" : 1 }
使用索引描述中的"name"字段来指定要删除的索引。
建立新索引是耗时且资源密集的工作。在版本 4.2 之前,MongoDB 会尽可能快地构建索引,在此过程中会阻塞数据库上的所有读写操作,直到索引构建完成。如果希望数据库在构建索引过程中对读写操作保持响应性,可以在构建索引时使用"background"选项。这会强制索引构建偶尔让步给其他操作,但仍可能严重影响应用程序(详见“构建索引”)。与前台索引构建相比,后台索引构建速度要慢得多。MongoDB 4.2 版本引入了一种新方法,混合索引构建。它只在索引构建的开始和结束时持有独占锁。在其余构建过程中,允许交错读写操作。这取代了 MongoDB 4.2 中的前台和后台索引构建类型。
如果可以选择,直接在现有文档上创建索引比先创建索引然后插入所有文档要稍快一些。
关于构建索引的操作方面还有更多内容请参阅第十九章。
第六章:特殊索引和集合类型
本章涵盖了 MongoDB 提供的特殊集合和索引类型,包括:
-
带有队列特性的有上限的集合
-
用于缓存的 TTL 索引
-
简单字符串搜索的全文索引
-
用于 2D 和球形几何的地理空间索引
-
用于存储大文件的 GridFS
地理空间索引
MongoDB 有两种类型的地理空间索引:2dsphere和2d。2dsphere索引使用球形几何模型化地球表面,基于 WGS84 数据。此数据模型将地球表面建模为一个扁球体,这意味着在极点有一些扁平化。因此,使用2sphere索引进行距离计算可以考虑地球的形状,并提供比2d索引更准确的城市间距离处理,例如两个城市之间的距离。对于存储在二维平面上的点,请使用2d索引。
2dsphere允许您在GeoJSON 格式中指定点、线和多边形的几何形状。一个点由一个表示[经度,纬度]的两元素数组给出:
{
"name" : "New York City",
"loc" : {
"type" : "Point",
"coordinates" : [50, 2]
}
}
一条线由一系列点组成:
{
"name" : "Hudson River",
"loc" : {
"type" : "LineString",
"coordinates" : [[0,1], [0,2], [1,2]]
}
}
多边形的指定方式与线相同(一个点数组),但具有不同的"type":
{
"name" : "New England",
"loc" : {
"type" : "Polygon",
"coordinates" : [[0,1], [0,2], [1,2]]
}
}
我们在此示例中命名的字段"loc"可以是任何名称,但嵌入对象中的字段名称由 GeoJSON 指定,不可更改。
使用"2dsphere"类型和`createIndex`可以创建地理空间索引:
> db.openStreetMap.createIndex({"loc" : "2dsphere"})
要创建2dsphere索引,请向createIndex传递一个文档,该文档指定要为特定集合中的几何图形创建索引的字段,并将"2dsphere"指定为值。
地理空间查询类型
您可以执行三种类型的地理空间查询:交集、包含和邻近。您可以指定要查找的内容作为一个类似于{"$geometry" : *`geoJsonDesc`*}的 GeoJSON 对象。
例如,您可以使用"$geoIntersects"操作符找到与查询位置相交的所有文档:
> var eastVillage = {
... "type" : "Polygon",
... "coordinates" : [
... [
... [ -73.9732566, 40.7187272 ],
... [ -73.9724573, 40.7217745 ],
... [ -73.9717144, 40.7250025 ],
... [ -73.9714435, 40.7266002 ],
... [ -73.975735, 40.7284702 ],
... [ -73.9803565, 40.7304255 ],
... [ -73.9825505, 40.7313605 ],
... [ -73.9887732, 40.7339641 ],
... [ -73.9907554, 40.7348137 ],
... [ -73.9914581, 40.7317345 ],
... [ -73.9919248, 40.7311674 ],
... [ -73.9904979, 40.7305556 ],
... [ -73.9907017, 40.7298849 ],
... [ -73.9908171, 40.7297751 ],
... [ -73.9911416, 40.7286592 ],
... [ -73.9911943, 40.728492 ],
... [ -73.9914313, 40.7277405 ],
... [ -73.9914635, 40.7275759 ],
... [ -73.9916003, 40.7271124 ],
... [ -73.9915386, 40.727088 ],
... [ -73.991788, 40.7263908 ],
... [ -73.9920616, 40.7256489 ],
... [ -73.9923298, 40.7248907 ],
... [ -73.9925954, 40.7241427 ],
... [ -73.9863029, 40.7222237 ],
... [ -73.9787659, 40.719947 ],
... [ -73.9772317, 40.7193229 ],
... [ -73.9750886, 40.7188838 ],
... [ -73.9732566, 40.7187272 ]
... ]
... ]}
> db.openStreetMap.find(
... {"loc" : {"$geoIntersects" : {"$geometry" : eastVillage}}})
这将查找所有包含东村点的点、线和多边形文档。
您可以使用"$geoWithin"查询完全包含在区域内的事物(例如,“东村有哪些餐馆?”):
> db.openStreetMap.find({"loc" : {"$geoWithin" : {"$geometry" : eastVillage}}})
与我们的第一个查询不同,这不会返回仅通过东村的事物(例如街道)或部分重叠它的事物(例如描述曼哈顿的多边形)。
最后,您可以使用"$near"查询附近的位置:
> db.openStreetMap.find({"loc" : {"$near" : {"$geometry" : eastVillage}}})
注意"$near"是唯一一个暗示排序的地理空间操作符:"$near"的结果始终按距离从近到远返回。
使用地理空间索引
MongoDB 的地理空间索引允许您在包含地理空间形状和点的集合上高效执行空间查询。为展示地理空间特性的能力并比较不同的方法,我们将逐步编写一个简单地理空间应用的查询过程。我们将深入介绍几个地理空间索引的核心概念,并展示它们如何与 "$geoWithin"、"$geoIntersects" 和 "$geoNear" 一起使用。
假设我们正在设计一个移动应用程序,帮助用户在纽约市找到餐馆。该应用程序必须:
-
确定用户当前所在的社区。
-
显示该社区内的餐馆数量。
-
在指定距离内查找餐馆。
我们将使用 2dsphere 索引在这些球形几何数据上进行查询。
查询中的 2D 对比球形几何。
地理空间查询可以根据查询和索引类型使用球形或 2D(平面)几何。表 6-1 显示了每个地理空间操作符使用的几何类型。
表 6-1. MongoDB 中的查询类型和几何形状
| 查询类型 | 几何类型 |
|---|---|
$near(GeoJSON 点,2dsphere 索引) |
球面 |
$near(旧版坐标,2d 索引) |
平面 |
$geoNear(GeoJSON 点,2dsphere 索引) |
球面 |
$geoNear(旧版坐标,2d 索引) |
平面 |
$nearSphere(GeoJSON 点,2dsphere 索引) |
球面 |
$nearSphere(旧版坐标,2d 索引)^(a) |
球面 |
$geoWithin : { $geometry: ... } |
球面 |
$geoWithin: { $box: ... } |
平面 |
$geoWithin: { $polygon: ... } |
平面 |
$geoWithin : { $center: ... } |
平面 |
$geoWithin : { $centerSphere: ... } |
球面 |
$geoIntersects |
球面 |
| ^(a) 使用 GeoJSON 点代替。 |
还需注意,2d 索引支持平面几何和仅在球面上进行距离计算(即使用 $nearSphere)。然而,使用球形几何的查询在 2dsphere 索引下性能更佳且更精确。
还需注意,$geoNear 操作符是一个聚合操作符。聚合框架在 第七章 中进行了讨论。除了 $near 查询操作外,$geoNear 聚合操作符和特殊命令 geoNear 还能让我们查询附近位置。请记住,$near 查询操作符无法在使用分片的集合上工作,MongoDB 的分片解决方案(参见 第十五章)。
geoNear 命令和 $geoNear 聚合操作要求集合最多只能有一个 2dsphere 索引和一个 2d 索引,而地理空间查询操作符(如 $near 和 $geoWithin)允许集合拥有多个地理空间索引。
geoNear命令和$geoNear聚合运算符的地理空间索引限制存在,因为geoNear命令和$geoNear语法都不包括位置字段。因此,在多个2d索引或2dsphere索引中选择索引是不明确的。
对于地理空间查询操作符,不存在此类限制;这些操作符需要一个位置字段,从而消除了歧义。
扭曲
由于将三维球体(例如地球)投影到平面上的性质,球形几何在地图上显示时会出现扭曲。
例如,考虑由经度、纬度点(0,0)、(80,0)、(80,80)和(0,80)定义的球形广场的规范。图 6-1 描述了该区域的覆盖区域。

图 6-1. 由点(0,0)、(80,0)、(80,80)和(0,80)定义的球形广场
搜索餐馆
在这个例子中,我们将使用基于纽约市的社区和餐馆数据集进行操作。您可以从 GitHub 下载示例数据集。
我们可以使用mongoimport工具将数据集导入数据库,方法如下:
$ mongoimport *`<path to neighborhoods.json>`* -c neighborhoods
$ mongoimport *`<path to restaurants.json>`* -c restaurants
我们可以在mongo shell中使用createIndex命令在每个集合上创建2dsphere索引。
> db.neighborhoods.createIndex({location:"2dsphere"})
> db.restaurants.createIndex({location:"2dsphere"})
探索数据
我们可以通过在mongo shell 中执行一些快速查询来了解这些集合中文档的模式使用。
> db.neighborhoods.find({name: "Clinton"})
{
"_id": ObjectId("55cb9c666c522cafdb053a4b"),
"geometry": {
"coordinates": [
[
[-73.99,40.77],
.
.
.
[-73.99,40.77],
[-73.99,40.77]]
]
],
"type": "Polygon"
},
"name": "Clinton"
}
> db.restaurants.find({name: "Little Pie Company"})
{
"_id": ObjectId("55cba2476c522cafdb053dea"),
"location": {
"coordinates": [
-73.99331699999999,
40.7594404
],
"type": "Point"
},
"name": "Little Pie Company"
}
前面代码中的社区文档对应于图 6-2 中显示的纽约市地区。

图 6-2. 纽约市的 Hell's Kitchen(克林顿)社区
面包店对应于图 6-3 中所示的位置。

图 6-3. 位于西 43 街 424 号的小派公司
查找当前社区
假设用户的移动设备可以提供一个相当精确的位置信息,使用$geoIntersects可以简单地找到用户当前的社区。
假设用户位于经度-73.93414657 和纬度 40.82302903。要找到当前社区(Hell's Kitchen),我们可以使用 GeoJSON 格式中的特殊$geometry字段指定一个点:
> db.neighborhoods.findOne({geometry:{$geoIntersects:{$geometry:{type:"Point",
... coordinates:[-73.93414657,40.82302903]}}}})
此查询将返回以下结果:
{
"_id":ObjectId("55cb9c666c522cafdb053a68"),
"geometry":{
"type":"Polygon",
"coordinates":[[[-73.93383000695911,40.81949109558767],...]]},
"name":"Central Harlem North-Polo Grounds"
}
查找社区内所有餐馆
我们还可以查询以查找包含在特定社区内的所有餐馆。为此,我们可以在mongo shell 中执行以下操作,以查找包含用户的社区,并计算该社区内的餐馆数量。例如,要查找 Hell's Kitchen 社区中的所有餐馆:
> var neighborhood = db.neighborhoods.findOne({
geometry: {
$geoIntersects: {
$geometry: {
type: "Point",
coordinates: [-73.93414657,40.82302903]
}
}
}
});
> db.restaurants.find({
location: {
$geoWithin: {
// Use the geometry from the neighborhood object we retrieved above
$geometry: neighborhood.geometry
}
}
},
// Project just the name of each matching restaurant
{name: 1, _id: 0});
此查询将告诉您,在请求的社区中有 127 家餐馆,这些餐馆的名称如下:
{
"name": "White Castle"
}
{
"name": "Touch Of Dee'S"
}
{
"name": "Mcdonald'S"
}
{
"name": "Popeyes Chicken & Biscuits"
}
{
"name": "Make My Cake"
}
{
"name": "Manna Restaurant Ii"
}
...
{
"name": "Harlem Coral Llc"
}
在一定距离内找到餐馆
要找到距某点指定距离内的餐厅,可以使用"$geoWithin"和"$centerSphere"来返回无序结果,或者使用"$nearSphere"和"$maxDistance"来按距离排序需要结果。
要在圆形区域内查找餐厅,请使用"$geoWithin"和"$centerSphere"。"$centerSphere"是 MongoDB 特定的语法,用于通过指定中心和弧度半径来表示圆形区域。"$geoWithin"不会以任何特定顺序返回文档,因此可能首先返回最远的文档。
以下将查找用户五英里范围内的所有餐厅:
> db.restaurants.find({
location: {
$geoWithin: {
$centerSphere: [
[-73.93414657,40.82302903],
5/3963.2
]
}
}
})
"$centerSphere"的第二个参数接受以弧度表示的半径。该查询通过将距离除以地球的近似赤道半径 3963.2 英里来转换为弧度。
应用程序可以使用"$centerSphere"而不需要地理空间索引。但是,地理空间索引支持比未索引的等效操作更快的查询。2dsphere和2d地理空间索引均支持"$centerSphere"。
您还可以使用"$nearSphere"并指定"$maxDistance"项以米为单位。这将按从最近到最远的顺序返回所有距离用户五英里范围内的餐厅:
> var METERS_PER_MILE = 1609.34;
db.restaurants.find({
location: {
$nearSphere: {
$geometry: {
type: "Point",
coordinates: [-73.93414657,40.82302903]
},
$maxDistance: 5*METERS_PER_MILE
}
}
});
复合地理空间索引
与其他类型的索引一样,可以将地理空间索引与其他字段结合使用,以优化更复杂的查询。前面提到的一个可能的查询是:“Hell’s Kitchen 里有什么餐厅?”仅使用地理空间索引,我们可以将范围缩小到 Hell’s Kitchen 内的所有内容,但将其缩小到仅“餐厅”或“披萨”则需要索引中的另一个字段:
> db.openStreetMap.createIndex({"tags" : 1, "location" : "2dsphere"})
然后我们可以快速找到 Hell’s Kitchen 的披萨店:
> db.openStreetMap.find({"loc" : {"$geoWithin" :
... {"$geometry" : hellsKitchen.geometry}},
... "tags" : "pizza"})
我们可以在"2dsphere"字段之前或之后有“普通”的索引字段,这取决于我们想先按照哪个字段过滤,是按照普通字段还是位置。选择更具选择性的项(即作为第一个索引项将过滤掉更多结果)。
2d 索引
对于非球形地图(视频游戏地图、时间序列数据等),可以使用"2d"索引代替"2dsphere":
> db.hyrule.createIndex({"tile" : "2d"})
2d索引假设一个完全平坦的表面,而不是一个球体。因此,除非不介意极点周围的严重畸变,否则不应将2d索引与球体一起使用。
文档应使用两元素数组作为其"2d"索引字段。该数组中的元素应分别反映经度和纬度坐标。示例文档可能如下所示:
{
"name" : "Water Temple",
"tile" : [ 32, 22 ]
}
如果计划存储 GeoJSON 数据,请勿使用2d索引——它们只能索引点。您可以存储一个点数组,但它将被完全存储为一个点数组,而不是一条线。这对于"$geoWithin"查询尤为重要。如果将街道存储为点数组,则文档将在给定形状内的点匹配"$geoWithin"。但是,由这些点创建的线可能不完全包含在形状内。
默认情况下,2d 索引假定您的值将在从−180 到 180 的范围内。如果您期望较大或较小的边界,则可以将最小值和最大值作为 createIndex 的选项进行指定:
> db.hyrule.createIndex({"light-years" : "2d"}, {"min" : -1000, "max" : 1000})
这将创建一个适用于 2,000 × 2,000 正方形的空间索引。
2d 索引支持 "$geoWithin", "$nearSphere", 和 "$near" 查询选择器。使用 "$geoWithin" 查询在平面上定义的形状内的点。"$geoWithin" 可以查询矩形、多边形、圆或球内的所有点;它使用 "$geometry" 运算符来指定 GeoJSON 对象。回到我们的网格索引如下:
> db.hyrule.createIndex({"tile" : "2d"})
以下查询用于文档位于由底部左下角 [10, 10] 和顶部右上角 [100, 100] 定义的矩形内:
> db.hyrule.find({
tile: {
$geoWithin: {
$box: [[10, 10], [100, 100]]
}
}
})
$box 接受一个两元素数组:第一个元素指定左下角的坐标,第二个元素指定右上角的坐标。
要查询位于以 [−17 , 20.5] 为中心且半径为 25 的圆内的文档,可以执行以下命令:
> db.hyrule.find({
tile: {
$geoWithin: {
$center: [[-17, 20.5] , 25]
}
}
})
以下查询返回所有坐标位于由 [0, 0], [3, 6], 和 [6 , 0] 定义的多边形内的文档:
> db.hyrule.find({
tile: {
$geoWithin: {
$polygon: [[0, 0], [3, 6], [6, 0]]
}
}
})
将多边形定义为点数组。列表中的最后一个点将与第一个点“连接”,形成多边形。此示例将定位包含在给定三角形内的所有文档。
由于历史原因,MongoDB 还支持对平面 2d 索引进行基本的球面查询。一般来说,球面计算应使用如 “2D versus spherical geometry in queries” 中所述的 2dsphere 索引。然而,要查询球内的传统坐标对,请使用带有 “$centerSphere” 运算符的 "$geoWithin"。指定一个包含以下内容的数组:
-
圆心点的网格坐标
-
以弧度测量的圆的半径
例如:
> db.hyrule.find({
loc: {
$geoWithin: {
$centerSphere: [[88, 30], 10/3963.2]
}
}
})
要查询附近的点,请使用 "$near"。接近查询返回与定义点最接近的坐标对应的文档,并按距离排序。这将从点 (20, 21) 开始按距离从近到远返回 hyrule 集合中的所有文档:
> db.hyrule.find({"tile" : {"$near" : [20, 21]}})
如果未指定限制,则默认应用 100 个文档的限制。如果您不需要那么多结果,应设置一个限制以节省服务器资源。例如,以下代码返回最接近 (20, 21) 的 10 个文档:
> db.hyrule.find({"tile" : {"$near" : [20, 21]}}).limit(10)
全文搜索索引
在 MongoDB 中,text 索引支持全文搜索要求。这种类型的 text 索引不应与 MongoDB Atlas 全文搜索索引混淆,后者利用 Apache Lucene 进行附加文本搜索能力,与 MongoDB text 索引相比具有更多的功能。如果您的应用程序需要允许用户提交关键字查询以匹配集合中标题、描述和其他字段中的文本,则应使用 text 索引。
在之前的章节中,我们使用精确匹配和正则表达式来查询字符串,但是这些技术有一些限制。使用正则表达式搜索大文本块速度较慢,并且难以考虑人类语言的形态(例如,“entry”应该匹配“entries”)和其他挑战。text索引使您能够快速搜索文本,并提供对语言适当的分词、停用词和词干提取等常见搜索引擎要求的支持。
text索引需要的键数量与索引字段中的单词数成正比。因此,创建text索引可能会消耗大量系统资源。您应在不会对用户应用程序性能造成负面影响的时间创建此类索引,或者如果可能的话在后台构建索引。与所有索引一样,为了确保良好的性能,您还应注意任何创建的text索引是否适合 RAM。有关在不影响应用程序的情况下创建索引的更多信息,请参阅第十九章。
写入集合需要更新所有索引。如果您使用文本搜索,字符串将被分词和词干提取,并在多个位置更新索引。因此,与单字段、复合字段甚至多键索引相比,涉及text索引的写入通常更昂贵。因此,您会发现text索引的写入性能较差。如果您进行分片,它们还会减慢数据移动:在迁移到新的分片时,所有文本都必须重新建立索引。
创建文本索引
假设我们有一组维基百科文章需要索引。要在文本上运行搜索,我们首先需要创建一个text索引。以下对createIndex的调用将基于"title"和"body"字段中的术语创建索引:
> db.articles.createIndex({"title": "text",
"body" : "text"})
这不像“普通”的复合索引,其中键有顺序。默认情况下,text索引中每个字段都被赋予相等的考虑权重。您可以通过指定权重来控制 MongoDB 赋予每个字段的相对重要性:
> db.articles.createIndex({"title": "text",
"body": "text"},
{"weights" : {
"title" : 3,
"body" : 2}})
这将以 3:2 的比例权重"title"字段相对于"body"字段。
在索引创建后无法更改字段权重(除非删除索引并重新创建),因此您可能希望在生产数据上创建索引之前在样本数据集上调整权重。
对于某些集合,您可能不知道文档将包含哪些字段。您可以通过在"$**"上创建索引来在文档中的所有字符串字段上创建全文索引——这不仅索引所有顶级字符串字段,还搜索嵌入文档和数组中的字符串字段:
> db.articles.createIndex({"$**" : "text"})
文本搜索
使用"$text"查询运算符在具有text索引的集合上执行文本搜索。"$text"将使用空格和大多数标点符号作为分隔符对搜索字符串进行标记,并对搜索字符串中的所有这些标记执行逻辑 OR 操作。例如,您可以使用以下查询查找包含术语“impact”,“crater”或“lunar”的所有文章。请注意,因为我们的索引基于文章标题和正文中的术语,此查询将匹配那些术语在任一字段中找到的文档。为了本示例的目的,我们将投影标题,以便我们可以在页面上放置更多结果:
> db.articles.find({"$text": {"$search": "impact crater lunar"}},
{title: 1}
).limit(10)
{ "_id" : "170375", "title" : "Chengdu" }
{ "_id" : "34331213", "title" : "Avengers vs. X-Men" }
{ "_id" : "498834", "title" : "Culture of Tunisia" }
{ "_id" : "602564", "title" : "ABC Warriors" }
{ "_id" : "40255", "title" : "Jupiter (mythology)" }
{ "_id" : "80356", "title" : "History of Vietnam" }
{ "_id" : "22483", "title" : "Optics" }
{ "_id" : "8919057", "title" : "Characters in The Legend of Zelda series" }
{ "_id" : "20767983", "title" : "First inauguration of Barack Obama" }
{ "_id" : "17845285", "title" : "Kushiel's Mercy" }
你可以看到,我们初始查询的结果并不是非常相关。像所有技术一样,了解 MongoDB 中text索引的工作原理是使用它们有效的关键。在这种情况下,我们发出查询存在两个问题。首先是,我们的查询相当广泛,因为 MongoDB 使用“impact”,“crater”和“lunar”的逻辑 OR 发出查询。第二个问题是,默认情况下,文本搜索不会按相关性排序。
我们可以通过在查询中使用短语来解决查询本身的问题。您可以通过用双引号括起来来搜索精确短语。例如,以下查询将找到所有包含短语“impact crater”的文档。也许令人惊讶的是,MongoDB 将此查询发布为“impact crater” AND “lunar”:
> db.articles.find({$text: {$search: "\"impact crater\" lunar"}},
{title: 1}
).limit(10)
{ "_id" : "2621724", "title" : "Schjellerup (crater)" }
{ "_id" : "2622075", "title" : "Steno (lunar crater)" }
{ "_id" : "168118", "title" : "South Pole–Aitken basin" }
{ "_id" : "1509118", "title" : "Jackson (crater)" }
{ "_id" : "10096822", "title" : "Victoria Island structure" }
{ "_id" : "968071", "title" : "Buldhana district" }
{ "_id" : "780422", "title" : "Puchezh-Katunki crater" }
{ "_id" : "28088964", "title" : "Svedberg (crater)" }
{ "_id" : "780628", "title" : "Zeleny Gai crater" }
{ "_id" : "926711", "title" : "Fracastorius (crater)" }
为了确保这一语义清楚,让我们看一个扩展的例子。对于以下查询,MongoDB 将作为“impact crater” AND (“lunar” OR “meteor”)发布查询。MongoDB 对搜索字符串中的短语与搜索字符串中的各个术语进行逻辑 AND 操作,并对各个术语与彼此进行逻辑 OR 操作:
> db.articles.find({$text: {$search: "\"impact crater\" lunar meteor"}},
{title: 1}
).limit(10)
如果您希望在查询中的各个术语之间发出逻辑 AND,请将每个术语视为短语并用引号括起来。以下查询将返回包含“impact crater” AND “lunar” AND “meteor”的文档:
> db.articles.find({$text: {$search: "\"impact crater\" \"lunar\" \"meteor\""}},
{title: 1}
).limit(10)
{ "_id" : "168118", "title" : "South Pole–Aitken basin" }
{ "_id" : "330593", "title" : "Giordano Bruno (crater)" }
{ "_id" : "421051", "title" : "Opportunity (rover)" }
{ "_id" : "2693649", "title" : "Pascal Lee" }
{ "_id" : "275128", "title" : "Tektite" }
{ "_id" : "14594455", "title" : "Beethoven quadrangle" }
{ "_id" : "266344", "title" : "Space debris" }
{ "_id" : "2137763", "title" : "Wegener (lunar crater)" }
{ "_id" : "929164", "title" : "Dawes (lunar crater)" }
{ "_id" : "24944", "title" : "Plate tectonics" }
现在你对在查询中使用短语和逻辑 AND 有了更好的理解,让我们回到结果没有按相关性排序的问题。虽然前面的结果确实相关,但这主要是因为我们发出了相当严格的查询。我们可以通过按相关性排序来做得更好。
文本查询会导致与每个查询结果相关联的一些元数据。除非我们使用$meta运算符显式投影它,否则这些元数据不会显示在查询结果中。因此,除了标题之外,我们还将投影每个文档计算的相关性分数。相关性分数存储在名为"textScore"的元数据字段中。对于本示例,我们将返回我们的“impact crater” AND “lunar”的查询:
> db.articles.find({$text: {$search: "\"impact crater\" lunar"}},
{title: 1, score: {$meta: "textScore"}}
).limit(10)
{"_id": "2621724", "title": "Schjellerup (crater)", "score": 2.852987132352941}
{"_id": "2622075", "title": "Steno (lunar crater)", "score": 2.4766639610389607}
{"_id": "168118", "title": "South Pole–Aitken basin", "score": 2.980198136295181}
{"_id": "1509118", "title": "Jackson (crater)", "score": 2.3419137286324787}
{"_id": "10096822", "title": "Victoria Island structure",
"score": 1.782051282051282}
{"_id": "968071", "title": "Buldhana district", "score": 1.6279783393501805}
{"_id": "780422", "title": "Puchezh-Katunki crater", "score": 1.9295977011494254}
{"_id": "28088964", "title": "Svedberg (crater)", "score": 2.497767857142857}
{"_id": "780628", "title": "Zeleny Gai crater", "score": 1.4866071428571428}
{"_id": "926711", "title": "Fracastorius (crater)", "score": 2.7511877111486487}
现在你可以看到每个结果的标题与相关性分数的投影。请注意它们没有排序。要按相关性分数的顺序对结果进行排序,我们必须添加一个sort调用,再次使用$meta来指定"textScore"字段的值。请注意,我们在排序中必须使用与投影中使用的字段名称相同的字段名称。在这种情况下,我们在搜索结果中显示的相关性分数值的字段名称为"score"。正如您所见,结果现在按相关性的降序排序:
> db.articles.find({$text: {$search: "\"impact crater\" lunar"}},
{title: 1, score: {$meta: "textScore"}}
).sort({score: {$meta: "textScore"}}).limit(10)
{"_id": "1621514", "title": "Lunar craters", "score": 3.1655242042922014}
{"_id": "14580008", "title": "Kuiper quadrangle", "score": 3.0847527829208814}
{"_id": "1019830", "title": "Shackleton (crater)", "score": 3.076471119932001}
{"_id": "2096232", "title": "Geology of the Moon", "score": 3.064981949458484}
{"_id": "927269", "title": "Messier (crater)", "score": 3.0638183133686008}
{"_id": "206589", "title": "Lunar geologic timescale", "score": 3.062029540854157}
{"_id": "14536060", "title": "Borealis quadrangle", "score": 3.0573010719646687}
{"_id": "14609586", "title": "Michelangelo quadrangle",
"score": 3.057224063486582}
{"_id": "14568465", "title": "Shakespeare quadrangle",
"score": 3.0495256481056443}
{"_id": "275128", "title": "Tektite", "score" : 3.0378807169646915}
文本搜索也可以在聚合管道中使用。我们在第七章讨论了聚合管道。
优化全文搜索
有几种优化全文搜索的方法。如果您可以首先通过其他条件缩小搜索结果,那么可以创建一个带有这些条件前缀的复合索引,然后是全文字段:
> db.blog.createIndex({"date" : 1, "post" : "text"})
这被称为将全文索引分区,因为它将其分解为基于"date"(在此示例中)的多个较小的树。这使得针对特定日期或日期范围的全文搜索速度更快。
你也可以使用其他条件的后缀来覆盖索引查询。例如,如果我们只返回"author"和"post"字段,我们可以在这两个字段上创建一个复合索引:
> db.blog.createIndex({"post" : "text", "author" : 1})
这些前缀和后缀形式可以结合使用:
> db.blog.createIndex({"date" : 1, "post" : "text", "author" : 1})
在其他语言中搜索
当插入文档(或首次创建索引)时,MongoDB 会查看索引字段并对每个单词进行词干处理,将其缩减为基本单位。然而,不同语言以不同方式处理单词的词干,因此必须指定索引或文档所在的语言。text索引允许指定"default_language"选项,默认为"english",但可以设置为多种其他语言(详见在线文档获取最新列表)。
例如,要创建一个法语索引,我们可以这样说:
> db.users.createIndex({"profil" : "text",
"intérêts" : "text"},
{"default_language" : "french"})
除非另有规定,否则将使用法语进行词干处理。您可以在每个文档上指定另一种词干处理语言,方法是具有描述文档语言的"language"字段:
> db.users.insert({"username" : "swedishChef",
... "profile" : "Bork de bork", language : "swedish"})
容量限制集合
在 MongoDB 中,“普通”集合是动态创建的,并自动增长以适应额外数据。MongoDB 还支持一种称为容量限制集合的不同类型集合,它是预先创建的并且大小固定(见图 6-4)。

图 6-4. 新文档插入队列末尾
拥有固定大小的集合提出了一个有趣的问题:当我们尝试向已满的限制集合插入数据时会发生什么?答案是,限制集合表现得像循环队列:如果空间不足,最旧的文档将被删除,新文档将占据其位置(见图 6-5)。这意味着,随着插入新文档,限制集合会自动使最老的文档过时。
一些操作在限制集合上是不允许的。文档不能被移除或删除(除了前面描述的自动过时),并且不允许导致文档增长大小的更新操作。通过阻止这两个操作,我们保证限制集合中的文档按插入顺序存储,并且不需要维护从删除文档中的空闲列表的需求。

图 6-5. 当队列满时,最老的元素将被最新元素替换
限制集合与大多数 MongoDB 集合具有不同的访问模式:数据按顺序写入固定的磁盘部分。这使它们倾向于在旋转磁盘上快速执行写入操作,特别是如果它们可以获得自己的磁盘(以免受其他集合随机写入的“干扰”)。
通常,推荐使用 MongoDB TTL 索引而不是限制集合,因为它们在使用 WiredTiger 存储引擎时性能更好。 TTL 索引根据日期类型字段的值和索引的 TTL 值在普通集合中过期并删除数据。这些内容稍后在本章节中会更深入地介绍。
注意
限制集合无法分片。如果更新或替换操作在限制集合中改变了文档大小,该操作将失败。
限制集合通常用于日志记录,尽管它们缺乏灵活性:除了在创建集合时设置大小外,无法控制数据何时过时。
创建限制集合
不同于普通集合,必须在使用之前显式地创建限制集合。要创建一个限制集合,可以使用create命令。在 Shell 中,可以使用createCollection来完成:
> db.createCollection("my_collection", {"capped" : true, "size" : 100000});
前面的命令创建了一个名为my_collection的限制集合,其固定大小为 100,000 字节。
createCollection还可以指定限制集合中文档的数量限制:
> db.createCollection("my_collection2",
{"capped" : true, "size" : 100000, "max" : 100});
您可以使用它来保留最新的 10 篇新闻文章或限制用户为 1,000 个文档。
一旦创建了限制集合,就无法更改它(如果希望更改其属性,必须删除并重新创建)。因此,在创建大型集合之前,应仔细考虑其大小。
注意
当限制限制集合中文档的数量时,必须同时指定大小限制。根据首先达到的限制,自动使过期取决于它可以"最大"文档数量不能持有,以及不能多于"大小"空间。
创建固定大小的集合的另一种选择是将现有的常规集合转换为固定大小的集合。可以使用convertToCapped命令来完成这个操作——在下面的示例中,我们将 test 集合转换为一个固定大小为 10,000 字节的集合:
> db.runCommand({"convertToCapped" : "test", "size" : 10000});
{ "ok" : true }
无法“取消”固定大小的集合(除非删除它)。
可追溯的游标
可追溯的游标是一种特殊类型的游标,当其结果耗尽时不会关闭。它们受tail -f命令启发,并且类似于该命令,会尽可能地持续获取输出。由于游标在耗尽结果后不会终止,它们可以在向集合添加文档时继续获取新结果。只能在固定大小的集合上使用可追溯的游标,因为普通集合不追踪插入顺序。对于绝大多数用途,建议使用第十六章中介绍的变更流,因为它们提供更多控制和配置选项,并且适用于普通集合。
可追溯的游标经常用于处理文档,因为它们被插入到“工作队列”(固定大小的集合)。因为可追溯的游标在没有结果的情况下会在 10 分钟后超时,所以重要的是在逻辑中包含重新查询集合的步骤,以防它们中断。mongo shell 不允许使用可追溯的游标,但在 PHP 中使用类似以下内容:
$cursor = $collection->find([], [
'cursorType' => MongoDB\Operation\Find::TAILABLE_AWAIT,
'maxAwaitTimeMS' => 100,
]);
while (true) {
if ($iterator->valid()) {
$document = $iterator->current();
printf("Consumed document created at: %s\n", $document->createdAt);
}
$iterator->next();
}
游标将处理结果或等待更多结果到达,直到超时或有人终止查询操作。
生命周期索引
正如前一节所述,固定大小的集合对于其内容何时被覆盖具有有限的控制能力。如果需要更灵活的过期系统,TTL 索引允许您为每个文档设置超时时间。当文档达到预配置的年龄时,将会删除它。这种索引类型对于缓存用例(如会话存储)非常有用。
可以通过在createIndex的第二个参数中指定"expireAfterSeconds"选项来创建 TTL 索引:
> // 24-hour timeout
> db.sessions.createIndex({"lastUpdated" : 1}, {"expireAfterSeconds" : 60*60*24})
这将在"lastUpdated"字段上创建一个 TTL 索引。如果文档的"lastUpdated"字段存在且为日期,则一旦服务器时间比文档时间晚"expireAfterSeconds"秒,文档将被删除。
为了防止活动会话被删除,可以在有活动时将"lastUpdated"字段更新为当前时间。一旦"lastUpdated"超过 24 小时,文档将被移除。
MongoDB 每分钟扫描一次 TTL 索引,因此不应依赖于到秒的精度。可以使用collMod命令更改"expireAfterSeconds":
> db.runCommand( {"collMod" : "someapp.cache" , "index" : { "keyPattern" :
... {"lastUpdated" : 1} , "expireAfterSeconds" : 3600 } } );
在给定集合上可以有多个 TTL 索引。它们不能是复合索引,但可以像“普通”索引一样用于排序和查询优化。
使用 GridFS 存储文件
GridFS 是在 MongoDB 中存储大型二进制文件的机制。以下是您考虑使用 GridFS 进行文件存储的几个原因:
-
使用 GridFS 可以简化您的堆栈。如果您已经在使用 MongoDB,则可能可以使用 GridFS 而不是单独的文件存储工具。
-
GridFS 将利用您为 MongoDB 设置的任何现有复制或自动分片,因此更容易实现文件存储的故障转移和扩展。
-
GridFS 可以减轻某些文件系统在用于存储用户上传时可能出现的问题。例如,GridFS 不会在同一目录中存储大量文件时出现问题。
当然也有一些缺点:
-
性能较慢。从 MongoDB 访问文件不会像直接通过文件系统那样快。
-
您只能通过删除并重新保存整个文档来修改文档。MongoDB 将文件存储为多个文档,因此无法同时锁定文件中的所有块。
当您有大型文件需要按顺序访问且不会经常更改时,通常最适合使用 GridFS。
入门 GridFS:mongofiles
尝试 GridFS 最简单的方法是使用 mongofiles 实用程序。mongofiles 包含在所有 MongoDB 发行版中,并可用于上传、下载、列出、搜索或删除 GridFS 中的文件。
与其他命令行工具一样,运行 mongofiles --help 查看 mongofiles 的可用选项。
以下会话显示了如何使用 mongofiles 将文件从文件系统上传到 GridFS,列出 GridFS 中的所有文件,并下载先前上传的文件:
$ echo "Hello, world" > foo.tx
$ mongofiles put foo.txt
2019-10-30T10:12:06.588+0000 connected to: localhost
2019-10-30T10:12:06.588+0000 added file: foo.txt
$ mongofiles list
2019-10-30T10:12:41.603+0000 connected to: localhost
foo.txt 13
$ rm foo.txt
$ mongofiles get foo.txt
2019-10-30T10:13:23.948+0000 connected to: localhost
2019-10-30T10:13:23.955+0000 finished writing to foo.txt
$ cat foo.txt
Hello, world
在前面的示例中,我们使用 mongofiles 执行了三个基本操作:put、list 和 get。put 操作将文件从文件系统添加到 GridFS。list 将列出已添加到 GridFS 的任何文件。get 的作用与 put 相反:它从 GridFS 中取出文件并将其写入文件系统。mongofiles 还支持另外两个操作:search 用于按文件名在 GridFS 中查找文件,delete 用于从 GridFS 中删除文件。
使用 MongoDB 驱动程序操作 GridFS
所有客户端库都有 GridFS API。例如,使用 PyMongo(MongoDB 的 Python 驱动程序),您可以执行与我们使用 mongofiles 相同的一系列操作(假设使用 Python 3 并在端口 27017 上本地运行 mongod)。
>>> import pymongo
>>> import gridfs
>>> client = pymongo.MongoClient()
>>> db = client.test
>>> fs = gridfs.GridFS(db)
>>> file_id = fs.put(b"Hello, world", filename="foo.txt")
>>> fs.list()
['foo.txt']
>>> fs.get(file_id).read()
b'Hello, world'
从 PyMongo 使用 GridFS 的 API 与 mongofiles 非常相似:您可以轻松执行基本的 put、get 和 list 操作。几乎所有 MongoDB 驱动程序都遵循此基本模式来处理 GridFS,通常还公开更高级的功能。有关特定驱动程序的 GridFS 信息,请查阅您正在使用的特定驱动程序的文档。
在幕后
GridFS 是建立在普通 MongoDB 文档之上的轻量级文件存储规范。MongoDB 服务器实际上几乎不进行任何“特殊处理”来处理 GridFS 请求;所有工作都由客户端驱动程序和工具处理。
GridFS 的基本思想是,通过将大文件分割成块并将每个块存储为单独的文档,我们可以存储大文件。由于 MongoDB 支持在文档中存储二进制数据,我们可以将块的存储开销降到最低。除了存储文件的每个块外,我们还存储一个单独的文档,将这些块组合在一起,并包含有关文件的元数据。
GridFS 的块存储在它们自己的集合中。默认情况下,块将使用fs.chunks集合,但可以进行覆盖。在块集合中,各个文档的结构非常简单:
{
"_id" : ObjectId("..."),
"n" : 0,
"data" : BinData("..."),
"files_id" : ObjectId("...")
}
像任何其他 MongoDB 文档一样,一个块有其自己独特的"_id"。此外,它还有几个其他键:
"files_id"
包含该块所属文件元数据的文件文档的"_id"。
"n"
文件在文件中的位置,相对于其他文件块
"data"
文件块中的字节。
每个文件的元数据存储在单独的集合中,默认为fs.files。文件集合中的每个文档代表 GridFS 中的单个文件,并可以包含应与该文件关联的任何自定义元数据。除了任何用户定义的键之外,还有几个由 GridFS 规范强制的键:
"_id"
文件的唯一 ID —— 这是将作为"files_id"键的值存储在每个块中的内容。
"length"
构成文件内容的总字节数。
"chunkSize"
构成文件的每个块的大小(以字节为单位)。默认值为 255 KB,但可以根据需要进行调整。
"uploadDate"
表示此文件存储在 GridFS 中的时间戳。
"md5"
在服务器端生成的此文件内容的 MD5 校验和。
在所需的所有键中,也许最有趣(或者说最不容易理解)的是"md5"。"md5"键的值是由 MongoDB 服务器使用filemd5命令生成的,该命令计算上传块的 MD5 校验和。这意味着用户可以检查"md5"键的值,以确保文件已正确上传。
正如前面提到的,fs.files中并不限于所需字段:可以随意在此集合中保留任何其他文件元数据。您可能希望在文件的元数据中保留下载次数、MIME 类型或用户评分等信息。
一旦理解了底层的 GridFS 规范,就可以轻松地实现您使用的驱动程序可能没有提供帮助器的功能。例如,您可以使用distinct命令获取存储在 GridFS 中的唯一文件名列表:
> db.fs.files.distinct("filename")
[ "foo.txt" , "bar.txt" , "baz.txt" ]
这使得您的应用程序在加载和收集文件信息时具有很大的灵活性。在接下来的一章中,我们将稍作调整,介绍聚合框架。它提供了一系列数据分析工具,用于处理数据库中的数据。
第七章:聚合框架简介
许多应用程序需要进行某种形式的数据分析。MongoDB 通过聚合框架提供强大的本机分析支持。在本章中,我们介绍这个框架及其提供的一些基本工具。我们将涵盖:
-
聚合框架
-
聚合阶段
-
聚合表达式
-
聚合累加器
在下一章中,我们将深入探讨更高级的聚合特性,包括能够在集合之间执行联接的能力。
管道、阶段和可调参数
聚合框架是 MongoDB 中的一组分析工具,允许您对一个或多个集合中的文档进行分析。
聚合框架基于管道的概念。通过聚合管道,我们从 MongoDB 集合中获取输入,并将这些文档通过一个或多个阶段,每个阶段在其输入上执行不同的操作(图 7-1)。每个阶段的输入是前一个阶段产生的输出。所有阶段的输入和输出都是文档——可以说是文档流。

图 7-1。聚合管道
如果您熟悉 Linux Shell(如 bash)中的管道,那么这个概念非常类似。每个阶段都有它要执行的特定任务。它期望特定形式的文档,并产生一个特定的输出,这本身就是文档流。在管道的末端,我们可以访问输出,这与执行查找查询时的方式非常相似。也就是说,我们得到了一系列文档,我们可以用来进行其他工作,无论是创建报告、生成网站还是其他类型的任务。
现在,让我们深入了解并考虑单个阶段。聚合管道的单个阶段是一个数据处理单元。它逐个接收输入文档流,逐个处理每个文档,并逐个生成输出文档流(图 7-2)。

图 7-2。聚合管道的各个阶段
每个阶段提供了一组旋钮,或称为tunables,我们可以控制这些旋钮来参数化阶段,以执行我们感兴趣的任务。一个阶段执行某种通用的、多功能的任务,并且我们根据我们正在处理的特定集合以及我们希望该阶段对这些文档执行的确切操作来参数化该阶段。
这些可调参数通常采用我们可以提供的运算符的形式,这些运算符将修改字段、执行算术运算、重塑文档,或执行某种累积任务或其他多种操作。
在我们开始查看一些具体示例之前,有一个管道的另一个方面尤为重要,在您开始使用它们时特别要牢记。经常情况下,我们希望在单个管道中多次包含相同类型的阶段(图 7-3)。例如,我们可能希望执行初始过滤,这样我们就不必将整个集合传递到我们的管道中。稍后,在进行了一些额外处理之后,我们可能希望进一步过滤,应用不同的条件。

图 7-3. 聚合管道中的重复阶段
总结一下,管道适用于 MongoDB 集合。它们由多个阶段组成,每个阶段对其输入执行不同的数据处理任务,并生成文档作为输出传递到下一个阶段。最终,在处理结束时,管道产生的输出可以用于我们的应用程序中的某些操作,或者可以发送到集合以供以后使用。在许多情况下,为了执行我们需要做的分析,我们会在单个管道内多次包含相同类型的阶段。
开始阶段:熟悉的操作
要开始开发聚合管道,我们将看看构建一些涉及您已经熟悉的操作的管道。为此,我们将查看 match、project、sort、skip 和 limit 阶段。
为了通过这些聚合示例,我们将使用一个公司数据集合。该集合有多个字段,指定了关于公司的详细信息,例如名称、公司的简短描述以及公司成立的时间。
还有描述公司经历的融资轮次、公司的重要里程碑、公司是否进行了首次公开发行(IPO),以及如果进行了首次公开发行,则 IPO 的详细信息的字段。这里有一个包含 Facebook, Inc. 数据的示例文档:
{
"_id" : "52cdef7c4bab8bd675297d8e",
"name" : "Facebook",
"category_code" : "social",
"founded_year" : 2004,
"description" : "Social network",
"funding_rounds" : [{
"id" : 4,
"round_code" : "b",
"raised_amount" : 27500000,
"raised_currency_code" : "USD",
"funded_year" : 2006,
"investments" : [
{
"company" : null,
"financial_org" : {
"name" : "Greylock Partners",
"permalink" : "greylock"
},
"person" : null
},
{
"company" : null,
"financial_org" : {
"name" : "Meritech Capital Partners",
"permalink" : "meritech-capital-partners"
},
"person" : null
},
{
"company" : null,
"financial_org" : {
"name" : "Founders Fund",
"permalink" : "founders-fund"
},
"person" : null
},
{
"company" : null,
"financial_org" : {
"name" : "SV Angel",
"permalink" : "sv-angel"
},
"person" : null
}
]
},
{
"id" : 2197,
"round_code" : "c",
"raised_amount" : 15000000,
"raised_currency_code" : "USD",
"funded_year" : 2008,
"investments" : [
{
"company" : null,
"financial_org" : {
"name" : "European Founders Fund",
"permalink" : "european-founders-fund"
},
"person" : null
}
]
}],
"ipo" : {
"valuation_amount" : NumberLong("104000000000"),
"valuation_currency_code" : "USD",
"pub_year" : 2012,
"pub_month" : 5,
"pub_day" : 18,
"stock_symbol" : "NASDAQ:FB"
}
}
作为我们的第一个聚合示例,让我们做一个简单的过滤,寻找所有在 2004 年成立的公司:
db.companies.aggregate([
{$match: {founded_year: 2004}},
])
这等同于使用 find 进行以下操作:
db.companies.find({founded_year: 2004})
现在让我们向我们的管道中添加一个 project 阶段,将输出减少到每个文档的几个字段。我们将排除 "_id" 字段,但包括 "name" 和 "founded_year"。我们的管道如下所示:
db.companies.aggregate([
{$match: {founded_year: 2004}},
{$project: {
_id: 0,
name: 1,
founded_year: 1
}}
])
如果我们运行这个操作,我们将得到如下所示的输出:
{"name": "Digg", "founded_year": 2004 }
{"name": "Facebook", "founded_year": 2004 }
{"name": "AddThis", "founded_year": 2004 }
{"name": "Veoh", "founded_year": 2004 }
{"name": "Pando Networks", "founded_year": 2004 }
{"name": "Jobster", "founded_year": 2004 }
{"name": "AllPeers", "founded_year": 2004 }
{"name": "blinkx", "founded_year": 2004 }
{"name": "Yelp", "founded_year": 2004 }
{"name": "KickApps", "founded_year": 2004 }
{"name": "Flickr", "founded_year": 2004 }
{"name": "FeedBurner", "founded_year": 2004 }
{"name": "Dogster", "founded_year": 2004 }
{"name": "Sway", "founded_year": 2004 }
{"name": "Loomia", "founded_year": 2004 }
{"name": "Redfin", "founded_year": 2004 }
{"name": "Wink", "founded_year": 2004 }
{"name": "Techmeme", "founded_year": 2004 }
{"name": "Eventful", "founded_year": 2004 }
{"name": "Oodle", "founded_year": 2004 }
...
让我们稍微详细解释一下这个聚合管道。你会注意到的第一件事是,我们正在使用 aggregate 方法。这是当我们想要运行聚合查询时调用的方法。要进行聚合,我们传入一个聚合管道。管道是一个具有文档作为元素的数组。每个文档必须规定一个特定的阶段操作符。在这个示例中,我们有一个包含两个阶段的管道:一个用于过滤的 match 阶段和一个 project 阶段,我们在其中将输出限制为每个文档的两个字段。
匹配阶段根据集合进行过滤,并一次将结果文档传递给项目阶段。然后,项目阶段执行其操作,重塑文档,并将输出传递出流水线,返回给我们。
现在让我们进一步扩展我们的流水线,包括一个限制阶段。我们将使用相同的查询进行匹配,但我们将把结果集限制为五个,然后投影出我们想要的字段。为简单起见,让我们的输出仅限于每家公司的名称:
db.companies.aggregate([
{$match: {founded_year: 2004}},
{$limit: 5},
{$project: {
_id: 0,
name: 1}}
])
结果如下:
{"name": "Digg"}
{"name": "Facebook"}
{"name": "AddThis"}
{"name": "Veoh"}
{"name": "Pando Networks"}
注意,我们设计这个流水线的方式是在项目阶段之前进行限制。如果我们先运行项目阶段,然后再进行限制,就像下面的查询一样,我们会得到完全相同的结果,但是在最终将结果限制为五个之前,我们需要通过项目阶段传递数百个文档:
db.companies.aggregate([
{$match: {founded_year: 2004}},
{$project: {
_id: 0,
name: 1}},
{$limit: 5}
])
不管 MongoDB 查询规划器在特定版本中可能有什么类型的优化,您都应始终考虑聚合流水线的效率。确保在构建流水线时限制需要从一个阶段传递到另一个阶段的文档数量。
这需要仔细考虑整个文档在流水线中的流动。在前面的查询中,我们只对匹配查询的前五个文档感兴趣,无论它们如何排序,所以在第二阶段进行限制是完全可以的。
但是,如果顺序很重要,那么我们需要在限制阶段之前进行排序。排序工作方式与我们已经看到的类似,不同之处在于在聚合框架中,我们将排序作为流水线中的一个阶段来指定(在本例中,我们将按名称升序排序):
db.companies.aggregate([
{ $match: { founded_year: 2004 } },
{ $sort: { name: 1} },
{ $limit: 5 },
{ $project: {
_id: 0,
name: 1 } }
])
我们从我们的companies集合中得到以下结果:
{"name": "1915 Studios"}
{"name": "1Scan"}
{"name": "2GeeksinaLab"}
{"name": "2GeeksinaLab"}
{"name": "2threads"}
注意,我们现在正在查看一组不同的五家公司,而是按名称的字母顺序获取前五个文档。
最后,让我们看看如何包含跳过阶段。在这里,我们首先进行排序,然后跳过前 10 个文档,再次将我们的结果集限制为 5 个文档:
db.companies.aggregate([
{$match: {founded_year: 2004}},
{$sort: {name: 1}},
{$skip: 10},
{$limit: 5},
{$project: {
_id: 0,
name: 1}},
])
让我们再次审视我们的流水线。我们有五个阶段。首先,我们正在过滤companies集合,只寻找"founded_year"为2004的文档。然后,我们按名称升序排序,跳过前 10 个匹配项,并将最终结果限制为 5 个。最后,我们将这五个文档传递给项目阶段,在那里重塑文档,使我们的输出文档仅包含公司名称。
在这里,我们已经看过如何使用阶段来构建管道,这些阶段执行的操作应该已经对你来说很熟悉了。这些操作在聚合框架中提供,因为它们对于我们后面讨论的阶段所要完成的分析类型是必需的。在本章剩余部分的过程中,我们将深入探讨聚合框架提供的其他操作。
表达式
当我们深入讨论聚合框架时,重要的是要了解可用于构建聚合管道的不同类型的表达式。聚合框架支持许多不同类别的表达式:
-
布尔 表达式允许我们使用 AND、OR 和 NOT 表达式。
-
集合 表达式允许我们使用数组作为集合进行工作。特别是,我们可以获取两个或多个集合的交集或并集。我们还可以取两个集合的差集并执行许多其他集合操作。
-
比较 表达式使我们能够表示许多不同类型的范围过滤器。
-
算术 表达式使我们能够计算天花板、地板、自然对数和对数,以及执行简单的算术运算,如乘法、除法、加法和减法。我们甚至可以进行更复杂的操作,比如计算一个值的平方根。
-
字符串 表达式允许我们连接、查找子字符串,并执行与大小写和文本搜索操作相关的操作。
-
数组 表达式提供了许多操作数组的强大功能,包括过滤数组元素、切片数组或仅获取特定数组的值范围。
-
变量 表达式,我们不会深入探讨,允许我们使用文字、解析日期值的表达式和条件表达式进行工作。
-
累加器 提供了计算总和、描述统计以及许多其他类型数值的能力。
$project
现在我们将深入探讨项目阶段和重塑文档,探索在你开发的应用程序中应该最常见的重塑操作类型。我们已经在聚合管道中看到了一些简单的投影,现在我们将看一些更复杂的投影。
首先,让我们来看看如何提升嵌套字段。在以下的管道中,我们正在进行匹配:
db.companies.aggregate([
{$match: {"funding_rounds.investments.financial_org.permalink": "greylock" }},
{$project: {
_id: 0,
name: 1,
ipo: "$ipo.pub_year",
valuation: "$ipo.valuation_amount",
funders: "$funding_rounds.investments.financial_org.permalink"
}}
]).pretty()
作为我们 companies 集合文档中相关字段的示例,让我们再次看一下 Facebook 文档的一部分:
{
"_id" : "52cdef7c4bab8bd675297d8e",
"name" : "Facebook",
"category_code" : "social",
"founded_year" : 2004,
"description" : "Social network",
"funding_rounds" : [{
"id" : 4,
"round_code" : "b",
"raised_amount" : 27500000,
"raised_currency_code" : "USD",
"funded_year" : 2006,
"investments" : [
{
"company" : null,
"financial_org" : {
"name" : "Greylock Partners",
"permalink" : "greylock"
},
"person" : null
},
{
"company" : null,
"financial_org" : {
"name" : "Meritech Capital Partners",
"permalink" : "meritech-capital-partners"
},
"person" : null
},
{
"company" : null,
"financial_org" : {
"name" : "Founders Fund",
"permalink" : "founders-fund"
},
"person" : null
},
{
"company" : null,
"financial_org" : {
"name" : "SV Angel",
"permalink" : "sv-angel"
},
"person" : null
}
]
},
{
"id" : 2197,
"round_code" : "c",
"raised_amount" : 15000000,
"raised_currency_code" : "USD",
"funded_year" : 2008,
"investments" : [
{
"company" : null,
"financial_org" : {
"name" : "European Founders Fund",
"permalink" : "european-founders-fund"
},
"person" : null
}
]
}],
"ipo" : {
"valuation_amount" : NumberLong("104000000000"),
"valuation_currency_code" : "USD",
"pub_year" : 2012,
"pub_month" : 5,
"pub_day" : 18,
"stock_symbol" : "NASDAQ:FB"
}
}
回到我们的匹配过程:
db.companies.aggregate([
{$match: {"funding_rounds.investments.financial_org.permalink": "greylock" }},
{$project: {
_id: 0,
name: 1,
ipo: "$ipo.pub_year",
valuation: "$ipo.valuation_amount",
funders: "$funding_rounds.investments.financial_org.permalink"
}}
]).pretty()
我们正在过滤所有那些参与过 Greylock Partners 融资轮次的公司。"greylock" 的永久链接值是这些文档的唯一标识符。这里是 Facebook 文档的另一视图,只显示了相关字段:
{
...
"name" : "Facebook",
...
"funding_rounds" : [{
...
"investments" : [{
...
"financial_org" : {
"name" : "Greylock Partners",
"permalink" : "greylock"
},
...
},
{
...
"financial_org" : {
"name" : "Meritech Capital Partners",
"permalink" : "meritech-capital-partners"
},
...
},
{
...
"financial_org" : {
"name" : "Founders Fund",
"permalink" : "founders-fnd"
},
...
},
{
"company" : null,
"financial_org" : {
"name" : "SV Angel",
"permalink" : "sv-angel"
},
...
}],
...
]},
{
...
"investments" : [{
...
"financial_org" : {
"name" : "European Founders Fund",
"permalink" : "european-founders-fund"
},
...
}]
}],
"ipo" : {
"valuation_amount" : NumberLong("104000000000"),
"valuation_currency_code" : "USD",
"pub_year" : 2012,
"pub_month" : 5,
"pub_day" : 18,
"stock_symbol" : "NASDAQ:FB"
}
}
我们在这个聚合管道中定义的投影阶段将抑制"_id"并包括"name"。它还将提升一些嵌套字段。此投影使用点表示法来表达字段路径,以从"ipo"字段和"funding_rounds"字段中选择那些嵌套文档和数组中的值。此投影阶段将使这些值成为所生成输出文档中的顶级字段值,如下所示:
{
"name" : "Digg",
"funders" : [
[
"greylock",
"omidyar-network"
],
[
"greylock",
"omidyar-network",
"floodgate",
"sv-angel"
],
[
"highland-capital-partners",
"greylock",
"omidyar-network",
"svb-financial-group"
]
]
}
{
"name" : "Facebook",
"ipo" : 2012,
"valuation" : NumberLong("104000000000"),
"funders" : [
[
"accel-partners"
],
[
"greylock",
"meritech-capital-partners",
"founders-fund",
"sv-angel"
],
...
[
"goldman-sachs",
"digital-sky-technologies-fo"
]
]
}
{
"name" : "Revision3",
"funders" : [
[
"greylock",
"sv-angel"
],
[
"greylock"
]
]
}
...
在输出中,每个文档都有一个"name"字段和一个"funders"字段。对于那些已经进行了 IPO 的公司,"ipo"字段包含公司上市的年份,"valuation"字段包含公司在 IPO 时的估值。请注意,在所有这些文档中,这些都是顶层字段,并且这些字段的值是从嵌套文档和数组中提升而来的。
在我们的投影阶段中,用于指定ipo、valuation和funders值的$字符表示这些值应被解释为字段路径,并用于选择每个字段应投影的值,分别如此。
你可能已经注意到的一件事是,我们看到了多个值打印出来作为funders。事实上,我们看到的是一个数组的数组。根据我们对 Facebook 示例文档的审查,我们知道所有的投资者都列在一个名为"investments"的数组中。我们的阶段指定,我们要为每个资金轮次中的每个条目投影financial_org.permalink值。因此,建立了一个投资者名称数组的数组。
在后续章节中,我们将看看如何在字符串、日期和其他多种值类型上执行算术和其他操作,以投影各种形状和大小的文档。从投影阶段唯一不能做到的事情几乎是更改值的数据类型。
$unwind
在聚合管道中处理数组字段时,通常需要包括一个或多个$unwind阶段。这使得我们可以生成输出,使得指定的数组字段中的每个元素都有一个输出文档。

图 7-4. $unwind 从输入文档中获取一个数组,并为该数组中的每个元素创建一个输出文档。
在示例 图 7-4 中,我们有一个输入文档,其中包含三个键及其对应的值。第三个键的值是一个包含三个元素的数组。如果在这种输入文档上运行 $unwind 并配置为展开 key3 字段,将生成类似于 图 7-4 底部显示的文档。关于这一点可能对你不直观的是,在每个这些输出文档中将会有一个 key3 字段,但该字段将包含一个单独的值而不是一个数组值,并且每个数组元素都会生成一个单独的文档。换句话说,如果数组中有 10 个元素,展开阶段将生成 10 个输出文档。
现在让我们回到我们的 公司 示例,并看看展开阶段的使用。我们将从以下聚合管道开始。请注意,在这个管道中,与前一节一样,我们只是在特定的资助者上进行匹配,并使用项目阶段提升嵌入的 funding_rounds 文档的值:
db.companies.aggregate([
{$match: {"funding_rounds.investments.financial_org.permalink": "greylock"} },
{$project: {
_id: 0,
name: 1,
amount: "$funding_rounds.raised_amount",
year: "$funding_rounds.funded_year"
}}
])
再次,这里是该集合中文档的数据模型示例:
{
"_id" : "52cdef7c4bab8bd675297d8e",
"name" : "Facebook",
"category_code" : "social",
"founded_year" : 2004,
"description" : "Social network",
"funding_rounds" : [{
"id" : 4,
"round_code" : "b",
"raised_amount" : 27500000,
"raised_currency_code" : "USD",
"funded_year" : 2006,
"investments" : [
{
"company" : null,
"financial_org" : {
"name" : "Greylock Partners",
"permalink" : "greylock"
},
"person" : null
},
{
"company" : null,
"financial_org" : {
"name" : "Meritech Capital Partners",
"permalink" : "meritech-capital-partners"
},
"person" : null
},
{
"company" : null,
"financial_org" : {
"name" : "Founders Fund",
"permalink" : "founders-fund"
},
"person" : null
},
{
"company" : null,
"financial_org" : {
"name" : "SV Angel",
"permalink" : "sv-angel"
},
"person" : null
}
]
},
{
"id" : 2197,
"round_code" : "c",
"raised_amount" : 15000000,
"raised_currency_code" : "USD",
"funded_year" : 2008,
"investments" : [
{
"company" : null,
"financial_org" : {
"name" : "European Founders Fund",
"permalink" : "european-founders-fund"
},
"person" : null
}
]
}],
"ipo" : {
"valuation_amount" : NumberLong("104000000000"),
"valuation_currency_code" : "USD",
"pub_year" : 2012,
"pub_month" : 5,
"pub_day" : 18,
"stock_symbol" : "NASDAQ:FB"
}
}
我们的聚合查询将生成如下结果:
{
"name" : "Digg",
"amount" : [
8500000,
2800000,
28700000,
5000000
],
"year" : [
2006,
2005,
2008,
2011
]
}
{
"name" : "Facebook",
"amount" :
500000,
12700000,
27500000,
...
查询会生成文档,其中 "amount" 和 "year" 都是数组,因为我们访问了 "funding_rounds" 数组中每个元素的 "raised_amount" 和 "funded_year"。
要解决这个问题,我们可以在聚合管道的项目阶段之前包含一个展开阶段,并通过指定应展开的 "funding_rounds" 数组来参数化此过程(参见 [图 7-5)。

图 7-5. 到目前为止我们聚合管道的轮廓,匹配“greylock”,然后展开“funding_rounds”,最后为每一轮融资项目投影出名称、金额和年份
再次回到我们的 Facebook 示例,我们可以看到每一轮融资都有一个 "raised_amount" 字段和一个 "funded_year" 字段。
展开阶段将为 "funding_rounds" 数组的每个元素生成一个输出文档。在这个例子中,我们的值是字符串,但无论值的类型如何,展开阶段都会为每个值生成一个输出文档。以下是更新后的聚合查询:
db.companies.aggregate([
{ $match: {"funding_rounds.investments.financial_org.permalink": "greylock"} },
{ $unwind: "$funding_rounds" },
{ $project: {
_id: 0,
name: 1,
amount: "$funding_rounds.raised_amount",
year: "$funding_rounds.funded_year"
} }
])
展开阶段会生成输入的每一个文档的精确副本。所有字段将具有相同的键和值,除了 "funding_rounds" 字段。该字段不再是一个 "funding_rounds" 文档的数组,而是包含一个单独文档的值,该文档对应于单个的融资轮次:
{"name": "Digg", "amount": 8500000, "year": 2006 }
{"name": "Digg", "amount": 2800000, "year": 2005 }
{"name": "Digg", "amount": 28700000, "year": 2008 }
{"name": "Digg", "amount": 5000000, "year": 2011 }
{"name": "Facebook", "amount": 500000, "year": 2004 }
{"name": "Facebook", "amount": 12700000, "year": 2005 }
{"name": "Facebook", "amount": 27500000, "year": 2006 }
{"name": "Facebook", "amount": 240000000, "year": 2007 }
{"name": "Facebook", "amount": 60000000, "year": 2007 }
{"name": "Facebook", "amount": 15000000, "year": 2008 }
{"name": "Facebook", "amount": 100000000, "year": 2008 }
{"name": "Facebook", "amount": 60000000, "year": 2008 }
{"name": "Facebook", "amount": 200000000, "year": 2009 }
{"name": "Facebook", "amount": 210000000, "year": 2010 }
{"name": "Facebook", "amount": 1500000000, "year": 2011 }
{"name": "Revision3", "amount": 1000000, "year": 2006 }
{"name": "Revision3", "amount": 8000000, "year": 2007 }
...
现在让我们在输出文档中添加一个额外的字段。这样做时,我们实际上会发现当前编写的聚合管道中存在一个小问题:
db.companies.aggregate([
{ $match: {"funding_rounds.investments.financial_org.permalink": "greylock"} },
{ $unwind: "$funding_rounds" },
{ $project: {
_id: 0,
name: 1,
funder: "$funding_rounds.investments.financial_org.permalink",
amount: "$funding_rounds.raised_amount",
year: "$funding_rounds.funded_year"
} }
])
在添加了 "funder" 字段之后,我们现在有一个字段路径值,可以访问来自解开阶段的 "funding_rounds" 嵌入式文档的 "investments" 字段,并选择永久链接值作为金融机构。请注意,这与我们在匹配过滤器中所做的非常相似。让我们来看看我们的输出:
{
"name" : "Digg",
"funder" : [
"greylock",
"omidyar-network"
],
"amount" : 8500000,
"year" : 2006
}
{
"name" : "Digg",
"funder" : [
"greylock",
"omidyar-network",
"floodgate",
"sv-angel"
],
"amount" : 2800000,
"year" : 2005
}
{
"name" : "Digg",
"funder" : [
"highland-capital-partners",
"greylock",
"omidyar-network",
"svb-financial-group"
],
"amount" : 28700000,
"year" : 2008
}
...
{
"name" : "Farecast",
"funder" : [
"madrona-venture-group",
"wrf-capital"
],
"amount" : 1500000,
"year" : 2004
}
{
"name" : "Farecast",
"funder" : [
"greylock",
"madrona-venture-group",
"wrf-capital"
],
"amount" : 7000000,
"year" : 2005
}
{
"name" : "Farecast",
"funder" : [
"greylock",
"madrona-venture-group",
"par-capital-management",
"pinnacle-ventures",
"sutter-hill-ventures",
"wrf-capital"
],
"amount" : 12100000,
"year" : 2007
}
要理解我们在这里看到的内容,我们需要回到我们的文档并查看 "investments" 字段。
"funding_rounds.investments" 字段本身就是一个数组。每一轮融资都可能有多个资助者参与,所以 "investments" 将列出所有这些资助者。查看结果,正如我们最初看到的 "raised_amount" 和 "funded_year" 字段一样,现在我们看到了 "funder" 的数组,因为 "investments" 是一个数组值字段。
另一个问题是,由于我们编写流水线的方式,许多文档被传递到项目阶段,这些文档代表了 Greylock 没有参与的融资轮次。我们可以通过查看 Farecast 的融资轮次来看到这一点。这个问题源于我们的匹配阶段选择了所有 Greylock 至少参与了一轮融资的公司。如果我们只关注 Greylock 实际参与的那些融资轮次,我们需要找出一种不同的过滤方式。
有一种可能性是颠倒我们的解开和匹配阶段的顺序——也就是说,先解开然后再匹配。这样可以确保我们只匹配出解开阶段产生的文档。但是通过这种方法的思考很快就会明显,将解开设为第一阶段时,我们将会扫描整个集合。
出于效率考虑,我们希望在流水线中尽早进行匹配。这样可以使聚合框架能够利用索引,例如。因此,为了仅选择 Greylock 参与的那些融资轮次,我们可以包含第二个匹配阶段:
db.companies.aggregate([
{ $match: {"funding_rounds.investments.financial_org.permalink": "greylock"} },
{ $unwind: "$funding_rounds" },
{ $match: {"funding_rounds.investments.financial_org.permalink": "greylock"} },
{ $project: {
_id: 0,
name: 1,
individualFunder: "$funding_rounds.investments.person.permalink",
fundingOrganization: "$funding_rounds.investments.financial_org.permalink",
amount: "$funding_rounds.raised_amount",
year: "$funding_rounds.funded_year"
} }
])
这个流水线首先会筛选出 Greylock 至少参与了一轮融资的公司。然后解开融资轮次并再次筛选,以便只传递那些实际上 Greylock 参与的融资轮次的文档到项目阶段。
如本章开头所述,我们经常需要包含同一类型的多个阶段。这是一个很好的例子:我们通过筛选来减少最初查看的文档数量,通过缩小我们考虑的文档集合,只选择那些格雷洛克参与至少一轮融资的文档。然后,通过我们的展开阶段,我们最终得到一些文档,这些文档代表了格雷洛克确实资助的公司的融资轮次,但格雷洛克没有参与的个别融资轮次。我们可以通过简单地包括另一个过滤器来摆脱我们不感兴趣的所有融资轮次,使用第二个匹配阶段。
数组表达式
现在让我们转向数组表达式。作为我们深入研究的一部分,我们将看看如何在项目阶段中使用数组表达式。
我们将要检查的第一个表达式是一个过滤表达式。过滤表达式基于过滤条件从数组中选择子集元素。
再次使用我们companies数据集,我们将使用相同的资金轮次的条件进行匹配,看看此管道中的rounds字段:
db.companies.aggregate([
{ $match: {"funding_rounds.investments.financial_org.permalink": "greylock"} },
{ $project: {
_id: 0,
name: 1,
founded_year: 1,
rounds: { $filter: {
input: "$funding_rounds",
as: "round",
cond: { $gte: ["$$round.raised_amount", 100000000] } } }
} },
{ $match: {"rounds.investments.financial_org.permalink": "greylock" } },
]).pretty()
rounds字段使用了一个过滤表达式。$filter运算符设计用于处理数组字段,并指定我们必须提供的选项。$filter的第一个选项是input。对于input,我们简单地指定一个数组。在本例中,我们使用字段路径指示符来识别我们companies集合中的文档中找到的"funding_rounds"数组。接下来,我们指定我们希望在后续过滤表达式中使用的此"funding_rounds"数组的名称。然后,作为第三个选项,我们需要指定一个条件。条件应该提供用于过滤我们提供的任何数组的标准,选择一个子集。在本例中,我们通过过滤器来选择"raised_amount"大于或等于 1 亿的"funding_round"元素。
在指定条件时,我们使用了$$。我们使用$$来引用在我们正在工作的表达式中定义的变量。as子句在我们的过滤表达式中定义了一个变量。这个变量的名称是"round",因为这是我们在as子句中标记它的名称。这是为了消除对变量引用与字段路径的歧义。在本例中,我们的比较表达式接受两个值的数组,并且如果提供的第一个值大于或等于第二个值,则返回true。
现在让我们考虑一下此管道的项目阶段将会生成哪些文档,考虑到此过滤条件。输出文档将具有"name"、"founded_year"和"rounds"字段。"rounds"的值将是由符合我们的过滤条件的元素组成的数组:即筹集金额大于$100,000,000 的元素。
在接下来的匹配阶段中,就像我们之前做的那样,我们将简单地过滤那些以某种方式由 Greylock 资助的输入文档。由此管道输出的文档将类似于以下内容:
{
"name" : "Dropbox",
"founded_year" : 2007,
"rounds" : [
{
"id" : 25090,
"round_code" : "b",
"source_description" :
"Dropbox Raises $250M In Funding, Boasts 45 Million Users",
"raised_amount" : 250000000,
"raised_currency_code" : "USD",
"funded_year" : 2011,
"investments" : [
{
"financial_org" : {
"name" : "Index Ventures",
"permalink" : "index-ventures"
}
},
{
"financial_org" : {
"name" : "RIT Capital Partners",
"permalink" : "rit-capital-partners"
}
},
{
"financial_org" : {
"name" : "Valiant Capital Partners",
"permalink" : "valiant-capital-partners"
}
},
{
"financial_org" : {
"name" : "Benchmark",
"permalink" : "benchmark-2"
}
},
{
"company" : null,
"financial_org" : {
"name" : "Goldman Sachs",
"permalink" : "goldman-sachs"
},
"person" : null
},
{
"financial_org" : {
"name" : "Greylock Partners",
"permalink" : "greylock"
}
},
{
"financial_org" : {
"name" : "Institutional Venture Partners",
"permalink" : "institutional-venture-partners"
}
},
{
"financial_org" : {
"name" : "Sequoia Capital",
"permalink" : "sequoia-capital"
}
},
{
"financial_org" : {
"name" : "Accel Partners",
"permalink" : "accel-partners"
}
},
{
"financial_org" : {
"name" : "Glynn Capital Management",
"permalink" : "glynn-capital-management"
}
},
{
"financial_org" : {
"name" : "SV Angel",
"permalink" : "sv-angel"
}
}
]
}
]
}
仅超过 100,000,000 美元的"rounds"数组项将通过过滤器。在 Dropbox 的情况下,只有一轮符合此标准。您在设置过滤器表达式时有很大的灵活性,但这是基本形式,并提供了此特定数组表达式的用例的具体示例。
接下来,让我们看一下数组元素运算符。我们将继续处理融资轮次,但在这种情况下,我们只想提取第一轮和最后一轮。例如,我们可能对看到这些轮次发生的时间或比较它们的金额感兴趣。我们可以通过日期和算术表达式来完成这些操作,正如我们将在下一节中看到的那样。
$arrayElemAt 运算符使我们能够选择数组中特定插槽的元素。以下管道提供了使用 $arrayElemAt 的示例:
db.companies.aggregate([
{ $match: { "founded_year": 2010 } },
{ $project: {
_id: 0,
name: 1,
founded_year: 1,
first_round: { $arrayElemAt: [ "$funding_rounds", 0 ] },
last_round: { $arrayElemAt: [ "$funding_rounds", -1 ] }
} }
]).pretty()
注意在项目阶段使用 $arrayElemAt 的语法。我们定义一个要投影出的字段,并指定一个包含 $arrayElemAt 作为字段名和一个两元素数组作为值的文档。第一个元素应该是一个字段路径,指定我们想要选择的数组字段。第二个元素标识我们想要的数组中的插槽。请记住,数组是从 0 开始索引的。
在许多情况下,数组的长度并不容易得到。要从数组末尾选择数组插槽,使用负整数。数组中的最后一个元素用 -1 标识。
此聚合管道的简单输出文档将类似于以下内容:
{
"name" : "vufind",
"founded_year" : 2010,
"first_round" : {
"id" : 19876,
"round_code" : "angel",
"source_url" : "",
"source_description" : "",
"raised_amount" : 250000,
"raised_currency_code" : "USD",
"funded_year" : 2010,
"funded_month" : 9,
"funded_day" : 1,
"investments" : [ ]
},
"last_round" : {
"id" : 57219,
"round_code" : "seed",
"source_url" : "",
"source_description" : "",
"raised_amount" : 500000,
"raised_currency_code" : "USD",
"funded_year" : 2012,
"funded_month" : 7,
"funded_day" : 1,
"investments" : [ ]
}
}
$arrayElemAt 表达式相关的是 $slice 表达式。这允许我们按顺序从数组中返回不止一个,而是多个项,从特定索引开始:
db.companies.aggregate([
{ $match: { "founded_year": 2010 } },
{ $project: {
_id: 0,
name: 1,
founded_year: 1,
early_rounds: { $slice: [ "$funding_rounds", 1, 3 ] }
} }
]).pretty()
在这里,再次使用 funding_rounds 数组,我们从索引 1 开始,从数组中取三个元素。也许我们知道在这个数据集中,第一个融资轮并不那么有趣,或者我们只是想要一些早期的轮次但不包括第一个。
对数组进行过滤和选择单个元素或数组切片是我们需要在数组上执行的更常见的操作之一。然而,可能最常见的操作是确定数组的大小或长度。为了做到这一点,我们可以使用 $size 运算符:
db.companies.aggregate([
{ $match: { "founded_year": 2004 } },
{ $project: {
_id: 0,
name: 1,
founded_year: 1,
total_rounds: { $size: "$funding_rounds" }
} }
]).pretty()
当在项目阶段使用 $size 表达式时,它将简单地提供一个值,即数组中元素的数量。
在本节中,我们探讨了一些最常见的数组表达式。还有许多其他表达式,且每个版本都在增加列表。请查看 MongoDB 文档中的聚合管道快速参考 来获取所有可用表达式的摘要。
累加器
到目前为止,我们已经涵盖了几种不同类型的表达式。接下来,让我们看看聚合框架提供了哪些累加器。累加器本质上是另一种类型的表达式,但我们将它们视为自己的类别,因为它们从多个文档中找到的字段值计算值。
聚合框架提供的累加器使我们能够执行诸如在特定字段中求和($sum)、计算平均值($avg)等操作。我们还考虑$first和$last也是累加器,因为它们考虑通过它们使用的阶段中的所有文档中的值。$max和$min是另外两个考虑文档流并仅保存一个值的累加器的例子。我们可以使用$mergeObjects将多个文档合并成一个文档。
我们还有用于数组的累加器。我们可以在通过管道阶段的文档上$push值到数组中。$addToSet与$push非常相似,不同之处在于它确保结果数组中不包含重复值。
接下来有一些用于计算描述统计量的表达式—例如,用于计算样本和总体标准偏差的表达式。这两种表达式都可以处理通过管道阶段的文档流。
在 MongoDB 3.2 之前,累加器仅在 group 阶段可用。MongoDB 3.2 引入了在 project 阶段访问累加器子集的功能。group 阶段和 project 阶段累加器的主要区别在于,在 project 阶段,诸如$sum和$avg的累加器必须在单个文档的数组上操作,而在 group 阶段,正如我们将在后面的部分中看到的,累加器允许您在多个文档上执行值的计算。
这是对累加器的快速概述,为我们深入探讨示例提供一些背景和舞台设定。
在项目阶段使用累加器
我们将从在项目阶段使用累加器的示例开始。请注意,我们的匹配阶段过滤包含"funding_rounds"字段且funding_rounds数组不为空的文档:
db.companies.aggregate([
{ $match: { "funding_rounds": { $exists: true, $ne: [ ]} } },
{ $project: {
_id: 0,
name: 1,
largest_round: { $max: "$funding_rounds.raised_amount" }
} }
])
因为$funding_rounds的值是每个公司文档中的一个数组,所以我们可以使用一个累加器。请记住,在项目阶段,累加器必须在一个数组值字段上工作。在这种情况下,我们可以做些很酷的事情。我们可以轻松地通过访问该数组中的嵌入文档来识别数组中的最大值,并在输出文档中投影出最大值。
{ "name" : "Wetpaint", "largest_round" : 25000000 }
{ "name" : "Digg", "largest_round" : 28700000 }
{ "name" : "Facebook", "largest_round" : 1500000000 }
{ "name" : "Omnidrive", "largest_round" : 800000 }
{ "name" : "Geni", "largest_round" : 10000000 }
{ "name" : "Twitter", "largest_round" : 400000000 }
{ "name" : "StumbleUpon", "largest_round" : 17000000 }
{ "name" : "Gizmoz", "largest_round" : 6500000 }
{ "name" : "Scribd", "largest_round" : 13000000 }
{ "name" : "Slacker", "largest_round" : 40000000 }
{ "name" : "Lala", "largest_round" : 20000000 }
{ "name" : "eBay", "largest_round" : 6700000 }
{ "name" : "MeetMoi", "largest_round" : 2575000 }
{ "name" : "Joost", "largest_round" : 45000000 }
{ "name" : "Babelgum", "largest_round" : 13200000 }
{ "name" : "Plaxo", "largest_round" : 9000000 }
{ "name" : "Cisco", "largest_round" : 2500000 }
{ "name" : "Yahoo!", "largest_round" : 4800000 }
{ "name" : "Powerset", "largest_round" : 12500000 }
{ "name" : "Technorati", "largest_round" : 10520000 }
...
举个例子,让我们使用$sum累加器来计算我们集合中每家公司的总资金:
db.companies.aggregate([
{ $match: { "funding_rounds": { $exists: true, $ne: [ ]} } },
{ $project: {
_id: 0,
name: 1,
total_funding: { $sum: "$funding_rounds.raised_amount" }
} }
])
这只是使用项目阶段中累加器的一小部分功能。再次建议您查阅 MongoDB 文档中的 聚合管道快速参考,了解可用的累加器表达式的完整概述。
分组介绍
历史上,累加器是 MongoDB 聚合框架中小组阶段的特色。小组阶段执行类似于 SQL GROUP BY 命令的功能。在小组阶段中,我们可以聚合多个文档的值,并对它们执行某种类型的聚合操作,例如计算平均值。让我们看一个例子:
db.companies.aggregate([
{ $group: {
_id: { founded_year: "$founded_year" },
average_number_of_employees: { $avg: "$number_of_employees" }
} },
{ $sort: { average_number_of_employees: -1 } }
])
在这里,我们使用一个小组阶段根据公司成立的年份聚合所有公司,然后计算每年的平均员工人数。这个管道的输出类似于以下内容:
{ "_id" : { "founded_year" : 1847 }, "average_number_of_employees" : 405000 }
{ "_id" : { "founded_year" : 1896 }, "average_number_of_employees" : 388000 }
{ "_id" : { "founded_year" : 1933 }, "average_number_of_employees" : 320000 }
{ "_id" : { "founded_year" : 1915 }, "average_number_of_employees" : 186000 }
{ "_id" : { "founded_year" : 1903 }, "average_number_of_employees" : 171000 }
{ "_id" : { "founded_year" : 1865 }, "average_number_of_employees" : 125000 }
{ "_id" : { "founded_year" : 1921 }, "average_number_of_employees" : 107000 }
{ "_id" : { "founded_year" : 1835 }, "average_number_of_employees" : 100000 }
{ "_id" : { "founded_year" : 1952 }, "average_number_of_employees" : 92900 }
{ "_id" : { "founded_year" : 1946 }, "average_number_of_employees" : 91500 }
{ "_id" : { "founded_year" : 1947 }, "average_number_of_employees" : 88510.5 }
{ "_id" : { "founded_year" : 1898 }, "average_number_of_employees" : 80000 }
{ "_id" : { "founded_year" : 1968 }, "average_number_of_employees" : 73550 }
{ "_id" : { "founded_year" : 1957 }, "average_number_of_employees" : 70055 }
{ "_id" : { "founded_year" : 1969 }, "average_number_of_employees" : 67635.1 }
{ "_id" : { "founded_year" : 1928 }, "average_number_of_employees" : 51000 }
{ "_id" : { "founded_year" : 1963 }, "average_number_of_employees" : 50503 }
{ "_id" : { "founded_year" : 1959 }, "average_number_of_employees" : 47432.5 }
{ "_id" : { "founded_year" : 1902 }, "average_number_of_employees" : 41171.5 }
{ "_id" : { "founded_year" : 1887 }, "average_number_of_employees" : 35000 }
...
输出包括具有文档作为其 "_id" 值的文档,然后是员工平均数的报告。这是我们可能作为评估公司成立年份与其增长之间相关性的第一步分析类型,可能会标准化公司的年龄。
如您所见,我们构建的管道有两个阶段:小组阶段和排序阶段。小组阶段的基础是我们作为文档一部分指定的 "_id" 字段。这是 $group 操作符本身的值,使用非常严格的解释。
我们使用这个字段来定义小组阶段用于组织其所见文档的方式。由于小组阶段位于首位,aggregate 命令将通过这一阶段传递 companies 集合中的所有文档。小组阶段将获取每个具有相同 "founded_year" 值的文档,并将它们视为单一分组。在构建该字段的值时,此阶段将使用 $avg 累加器计算具有相同 "founded_year" 的所有公司的平均员工人数。
您可以这样想。每当小组阶段遇到具有特定成立年份的文档时,它将从该文档中的 "number_of_employees" 值添加到员工数量的累加和,并将该年份文档数量的计数加一。一旦所有文档都通过小组阶段,它便可以使用该累加和和计数计算出每个根据成立年份标识出的文档分组的平均值。
在此管道的最后,我们按 average_number_of_employees 的降序对文档进行排序。
让我们看另一个例子。在 companies 数据集中,我们尚未考虑的一个字段是关系。relationships 字段出现在以下形式的文档中:
{
"_id" : "52cdef7c4bab8bd675297d8e",
"name" : "Facebook",
"permalink" : "facebook",
"category_code" : "social",
"founded_year" : 2004,
...
"relationships" : [
{
"is_past" : false,
"title" : "Founder and CEO, Board Of Directors",
"person" : {
"first_name" : "Mark",
"last_name" : "Zuckerberg",
"permalink" : "mark-zuckerberg"
}
},
{
"is_past" : true,
"title" : "CFO",
"person" : {
"first_name" : "David",
"last_name" : "Ebersman",
"permalink" : "david-ebersman"
}
},
...
],
"funding_rounds" : [
...
{
"id" : 4,
"round_code" : "b",
"source_url" : "http://www.facebook.com/press/info.php?factsheet",
"source_description" : "Facebook Funding",
"raised_amount" : 27500000,
"raised_currency_code" : "USD",
"funded_year" : 2006,
"funded_month" : 4,
"funded_day" : 1,
"investments" : [
{
"company" : null,
"financial_org" : {
"name" : "Greylock Partners",
"permalink" : "greylock"
},
"person" : null
},
{
"company" : null,
"financial_org" : {
"name" : "Meritech Capital Partners",
"permalink" : "meritech-capital-partners"
},
"person" : null
},
{
"company" : null,
"financial_org" : {
"name" : "Founders Fund",
"permalink" : "founders-fund"
},
"person" : null
},
{
"company" : null,
"financial_org" : {
"name" : "SV Angel",
"permalink" : "sv-angel"
},
"person" : null
}
]
},
...
"ipo" : {
"valuation_amount" : NumberLong("104000000000"),
"valuation_currency_code" : "USD",
"pub_year" : 2012,
"pub_month" : 5,
"pub_day" : 18,
"stock_symbol" : "NASDAQ:FB"
},
...
}
"relationships" 字段使我们能够深入挖掘并寻找与相对较多公司有关联的人员。让我们看看这个聚合:
db.companies.aggregate( [
{ $match: { "relationships.person": { $ne: null } } },
{ $project: { relationships: 1, _id: 0 } },
{ $unwind: "$relationships" },
{ $group: {
_id: "$relationships.person",
count: { $sum: 1 }
} },
{ $sort: { count: -1 } }
]).pretty()
我们在 relationships.person 上进行匹配。如果我们看看我们的 Facebook 示例文档,我们可以看到关系是如何构造的,并了解进行此操作的含义。我们正在过滤所有 "person" 不为空的关系。然后我们投射出所有匹配文档的所有关系。我们只会将关系传递到管道的下一个阶段,即展开。我们展开关系,以便数组中的每个关系都通过到接下来的分组阶段。在分组阶段,我们使用字段路径来识别每个 "relationship" 文档中的人员。所有具有相同 "person" 值的文档将被分组在一起。正如我们之前看到的那样,一个文档可以完全成为我们分组的值。因此,每个与一个人的名字、姓氏和永久链接匹配的文档都将被聚合在一起。我们使用 $sum 累加器来计算每个人参与的关系数量。最后,我们按降序排序。该管道的输出类似于以下内容:
{
"_id" : {
"first_name" : "Tim",
"last_name" : "Hanlon",
"permalink" : "tim-hanlon"
},
"count" : 28
}
{
"_id" : {
"first_name" : "Pejman",
"last_name" : "Nozad",
"permalink" : "pejman-nozad"
},
"count" : 24
}
{
"_id" : {
"first_name" : "David S.",
"last_name" : "Rose",
"permalink" : "david-s-rose"
},
"count" : 24
}
{
"_id" : {
"first_name" : "Saul",
"last_name" : "Klein",
"permalink" : "saul-klein"
},
"count" : 24
}
...
Tim Hanlon 是这个集合中参与最多公司关系的个人。可能是因为汉隆先生实际上与 28 家公司有关系,但我们无法确定,因为他可能与一家或多家公司有多个关系,每个关系都有不同的职位。这个例子说明了聚合管道的一个非常重要的点:在进行计算时,特别是使用累加器表达式计算聚合值时,一定要充分理解你所处理的内容。
在这种情况下,我们可以说 Tim Hanlon 在我们收集的公司的 "relationships" 文档中出现了 28 次。我们需要深入挖掘一下,以查看他究竟与多少个独特的公司有关联,但是我们将把构建该管道的任务留给您作为练习。
在分组阶段的 _id 字段
在继续讨论分组阶段之前,让我们再谈谈 _id 字段,并查看一些在组合聚合阶段为该字段构造值的最佳实践。我们将演示几个示例,说明我们通常如何组合文档。作为我们的第一个例子,请考虑以下管道:
db.companies.aggregate([
{ $match: { founded_year: { $gte: 2013 } } },
{ $group: {
_id: { founded_year: "$founded_year"},
companies: { $push: "$name" }
} },
{ $sort: { "_id.founded_year": 1 } }
]).pretty()
该管道的输出类似于以下内容:
{
"_id" : {
"founded_year" : 2013
},
"companies" : [
"Fixya",
"Wamba",
"Advaliant",
"Fluc",
"iBazar",
"Gimigo",
"SEOGroup",
"Clowdy",
"WhosCall",
"Pikk",
"Tongxue",
"Shopseen",
"VistaGen Therapeutics"
]
}
...
在我们的输出中,我们有两个字段的文档:"_id" 和 "companies"。每个文档包含了一个数组,这些数组包含了根据 "founded_year" 的公司名称列表。
注意这里我们如何在组阶段构建"_id"字段。为什么不直接提供成立年份,而是将其放在一个带有标记为"founded_year"的字段的文档中?我们之所以不这样做的原因是,如果我们不标记组值,那么就不明确我们是在公司成立年份上进行分组。为了避免混淆,明确标记我们分组的值是一个最佳实践。
在某些情况下,可能需要使用另一种方法,其中我们的_id值是由多个字段组成的文档。在这种情况下,我们实际上是根据它们的成立年份和类别代码对文档进行分组:
db.companies.aggregate([
{ $match: { founded_year: { $gte: 2010 } } },
{ $group: {
_id: { founded_year: "$founded_year", category_code: "$category_code" },
companies: { $push: "$name" }
} },
{ $sort: { "_id.founded_year": 1 } }
]).pretty()
在组阶段使用多字段文档作为我们的_id值是完全可以的。在其他情况下,可能也需要像这样做:
db.companies.aggregate([
{ $group: {
_id: { ipo_year: "$ipo.pub_year" },
companies: { $push: "$name" }
} },
{ $sort: { "_id.ipo_year": 1 } }
]).pretty()
在这种情况下,我们根据公司进行首次公开募股的年份对文档进行分组,而那一年实际上是嵌入文档的一个字段。在组阶段使用到嵌入文档的字段路径作为分组值是一种常见做法。在这种情况下,输出将类似于以下内容:
{
"_id" : {
"ipo_year" : 1999
},
"companies" : [
"Akamai Technologies",
"TiVo",
"XO Group",
"Nvidia",
"Blackberry",
"Blue Coat Systems",
"Red Hat",
"Brocade Communications Systems",
"Juniper Networks",
"F5 Networks",
"Informatica",
"Iron Mountain",
"Perficient",
"Sitestar",
"Oxford Instruments"
]
}
请注意,本节中的示例使用了我们以前没有见过的累加器$push。在组阶段处理其输入流中的文档时,$push表达式将把结果值添加到它在运行过程中构建的数组中。在前述管道的情况下,组阶段正在构建一个由公司名称组成的数组。
我们的最后一个例子是我们已经看过的,但是为了完整起见,这里包括了它:
db.companies.aggregate( [
{ $match: { "relationships.person": { $ne: null } } },
{ $project: { relationships: 1, _id: 0 } },
{ $unwind: "$relationships" },
{ $group: {
_id: "$relationships.person",
count: { $sum: 1 }
} },
{ $sort: { count: -1 } }
] )
在前面的例子中,我们是根据首次公开募股年份进行分组,使用的是解析为标量值的字段路径 — 首次公开募股年份。在这种情况下,我们的字段路径解析为包含三个字段的文档:"first_name","last_name"和"permalink"。这表明组阶段支持在文档值上进行分组。
您现在已经看到了在组阶段如何构建_id值的几种方式。总的来说,要记住我们在这里想要做的是确保在输出中,我们的_id值的语义是清晰的。
组与项目
为了完善我们对组聚合阶段的讨论,我们将看一看一些在项目阶段不可用的额外累加器。这是为了鼓励您更深入地思考在项目阶段和组阶段对累加器可以做什么。例如,考虑以下聚合查询:
db.companies.aggregate([
{ $match: { funding_rounds: { $ne: [ ] } } },
{ $unwind: "$funding_rounds" },
{ $sort: { "funding_rounds.funded_year": 1,
"funding_rounds.funded_month": 1,
"funding_rounds.funded_day": 1 } },
{ $group: {
_id: { company: "$name" },
funding: {
$push: {
amount: "$funding_rounds.raised_amount",
year: "$funding_rounds.funded_year"
} }
} },
] ).pretty()
在这里,我们首先过滤数组funding_rounds不为空的文档。然后展开funding_rounds。因此,排序和分组阶段将为每个公司的funding_rounds数组的每个元素看到一个文档。
在这个流水线中的排序阶段按照年、月、日的升序排序。这意味着这个阶段将首先输出最早的融资轮次。正如您在第五章中了解到的,我们可以使用复合索引来支持这种类型的排序。
在排序后的组阶段中,我们按公司名称分组,并使用$push累加器构建一个排序后的funding_rounds数组。因为我们在排序阶段全局排序了所有的融资轮次,所以每家公司的funding_rounds数组都会被排序。
从这个流水线输出的文档将类似于以下内容:
{
"_id" : {
"company" : "Green Apple Media"
},
"funding" : [
{
"amount" : 30000000,
"year" : 2013
},
{
"amount" : 100000000,
"year" : 2013
},
{
"amount" : 2000000,
"year" : 2013
}
]
}
在这个流水线中,使用$push,我们正在累积一个数组。在这种情况下,我们已经指定了我们的$push表达式,使其将文档添加到累积数组的末尾。由于融资轮次是按时间顺序排列的,将文档推送到数组的末尾可以确保每家公司的融资金额按时间顺序排序。
$push表达式只能在组阶段中使用。这是因为组阶段旨在接受文档的输入流,并通过依次处理每个文档来累积值。相反,投影阶段是逐个处理其输入流中的每个文档。
让我们再看一个例子。这个例子有点长,但是它是在前一个例子的基础上构建的:
db.companies.aggregate([
{ $match: { funding_rounds: { $exists: true, $ne: [ ] } } },
{ $unwind: "$funding_rounds" },
{ $sort: { "funding_rounds.funded_year": 1,
"funding_rounds.funded_month": 1,
"funding_rounds.funded_day": 1 } },
{ $group: {
_id: { company: "$name" },
first_round: { $first: "$funding_rounds" },
last_round: { $last: "$funding_rounds" },
num_rounds: { $sum: 1 },
total_raised: { $sum: "$funding_rounds.raised_amount" }
} },
{ $project: {
_id: 0,
company: "$_id.company",
first_round: {
amount: "$first_round.raised_amount",
article: "$first_round.source_url",
year: "$first_round.funded_year"
},
last_round: {
amount: "$last_round.raised_amount",
article: "$last_round.source_url",
year: "$last_round.funded_year"
},
num_rounds: 1,
total_raised: 1,
} },
{ $sort: { total_raised: -1 } }
] ).pretty()
再次,我们正在解开funding_rounds并按时间顺序排序。但在这种情况下,我们不是累积条目数组,每个条目代表一个funding_rounds,而是使用了两个我们尚未看到实际运行的累加器:$first和$last。$first表达式简单地保存通过阶段输入流的第一个值。$last表达式简单地跟踪通过组阶段的值,并保留最后一个。
与$push一样,我们不能在投影阶段中使用$first和$last,因为投影阶段不是根据流经它们的多个文档累积值的设计。相反,它们被设计为单独重塑文档。
除了$first和$last,我们在这个例子中还使用了$sum来计算融资轮次的总数。对于这个表达式,我们只需指定值1。这样的$sum表达式简单地用于计算每个分组中看到的文档数量。
最后,此管道包含一个相当复杂的项目阶段。然而,它真正做的只是使输出更美观。不是显示 first_round 的值,或者整个首轮和最后一轮融资轮次的文档,这个项目阶段创建了一个摘要。请注意,这保持了良好的语义,因为每个值都有明确的标签。对于 first_round,我们将生成一个简单的嵌入文档,其中包含金额、文章和年份的基本细节,这些值来自将成为 $first_round 值的原始融资轮次文档。项目阶段对于 $last_round 也执行类似的操作。最后,这个项目阶段只是将输入流中接收到的文档的 num_rounds 和 total_raised 值传递到输出文档中。
从此管道输出的文档如下所示:
{
"first_round" : {
"amount" : 7500000,
"article" : "http://www.teslamotors.com/display_data/pressguild.swf",
"year" : 2004
},
"last_round" : {
"amount" : 10000000,
"article" : "http://www.bizjournals.com/sanfrancisco/news/2012/10/10/
tesla-motors-to-get-10-million-from.html",
"year" : 2012
},
"num_rounds" : 11,
"total_raised" : 823000000,
"company" : "Tesla Motors"
}
至此,我们已经完成了对组阶段的概述。
将聚合管道结果写入集合
在聚合管道中,有两个特定阶段,$out 和 $merge,可以将文档写入集合。你只能使用这两个阶段中的一个,并且它必须是聚合管道的最后一个阶段。$merge 是 MongoDB 版本 4.2 引入的首选阶段,用于向集合写入数据(如果可用)。$out 存在一些限制:只能写入同一数据库,会覆盖现有集合(如果存在),且无法写入分片集合。$merge 能够向任何数据库和集合写入数据,无论是否分片。在处理现有集合时,$merge 还可以包含以下结果(插入新文档、与现有文档合并、操作失败、保留现有文档或使用自定义更新处理所有文档)。但是,使用 $merge 的真正优势在于它可以创建按需的物化视图,当运行管道时增量更新输出集合的内容。
在本章中,我们涵盖了许多不同的累加器,一些累加器在项目阶段可用,我们还讨论了在考虑各种累加器时何时使用组和项目的思考方式。接下来,我们将看看 MongoDB 中的事务。
第八章:事务
事务是数据库中的逻辑处理组,每个组或事务可以包含一个或多个操作,如跨多个文档的读取和/或写入。MongoDB 支持跨多个操作、集合、数据库、文档和分片的 ACID 合规事务。在本章中,我们介绍事务,定义数据库中 ACID 的含义,突出显示如何在应用程序中使用它们,并提供调整 MongoDB 中事务的提示。我们将涵盖:
-
事务的定义
-
如何使用事务
-
调整应用程序的事务限制
事务简介
正如上文所述,事务是数据库中的一个逻辑处理单元,包括一个或多个数据库操作,可以是读取或写入操作。在处理的这个逻辑单元中,您的应用程序可能需要对多个文档(一个或多个集合中的多个文档)进行读取和写入。事务的一个重要方面是它永远不会部分完成——它要么成功,要么失败。
注意
要使用事务,您的 MongoDB 部署必须在 MongoDB 版本 4.2 或更高版本,并且您的 MongoDB 驱动程序必须更新到 MongoDB 4.2 或更高版本。MongoDB 提供了一个驱动程序兼容性参考页面,您可以使用它来确保您的 MongoDB 驱动程序版本兼容。
ACID 的定义
ACID 是事务必须满足的一组接受的属性,以成为“真正”的事务。ACID 是原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)的首字母缩写。ACID 事务保证数据的有效性及数据库状态的一致性,即使发生断电或其他错误。
原子性确保事务内部的所有操作要么全部应用,要么全部不应用。事务永远不会部分应用;它要么被提交,要么中止。
一致性确保如果事务成功,数据库将从一个一致状态移动到下一个一致状态。
隔离性是允许多个事务同时在数据库中运行的属性。它保证一个事务不会查看任何其他事务的部分结果,这意味着多个并行事务将具有与按顺序运行每个事务相同的结果。
持久性确保当事务提交时,所有数据都将持久存在,即使系统故障也是如此。
数据库在确保满足所有这些属性并且仅处理成功事务时,被称为符合 ACID 标准。在事务完成之前发生故障的情况下,ACID 合规性确保数据不会被更改。
MongoDB 是一个分布式数据库,支持跨副本集和/或分片的 ACID 兼容事务。网络层增加了额外的复杂性。MongoDB 工程团队提供了几个黑板讲解视频,描述了他们如何实现支持 ACID 事务所需的必要功能。
如何使用事务
MongoDB 提供两个 API 来使用事务。第一个 API 与关系型数据库类似(例如,start_transaction和commit_transaction),称为核心 API;第二个称为回调 API,是使用事务的推荐方法。
核心 API 不提供大多数错误的重试逻辑,并要求开发者编写操作逻辑、事务提交函数以及任何必要的重试和错误处理逻辑。
回调 API 提供一个函数,它比核心 API 包含更多功能,包括启动与指定逻辑会话相关联的事务、执行作为回调函数提供的函数,然后提交事务(或在错误时中止)。此函数还包括处理提交错误的重试逻辑。回调 API 在 MongoDB 4.2 中添加,旨在简化使用事务的应用程序开发,同时使添加应用程序重试逻辑以处理任何事务错误变得更加容易。
在这两个 API 中,开发者负责启动将用于事务的逻辑会话。两个 API 要求事务中的操作与特定的逻辑会话相关联(即,将会话传递给每个操作)。MongoDB 中的逻辑会话跟踪操作的时间和顺序,这些操作在整个 MongoDB 部署的上下文中。逻辑会话或服务器会话是客户端会话使用的基础框架的一部分,用于支持可重试写入和因果一致性。这些功能作为支持事务所需的基础的一部分,已经在 MongoDB 版本 3.6 中添加。在 MongoDB 中,具有因果关系的读写操作序列被定义为因果一致的客户端会话。客户端会话由应用程序启动并用于与服务器会话交互。
2019 年,MongoDB 的六名高级工程师在 SIGMOD 2019 大会上发表了题为“在 MongoDB 中实现集群范围逻辑时钟和因果一致性”的论文。^(1) 该论文深入解释了 MongoDB 中逻辑会话和因果一致性背后的机制。论文记录了一个跨多团队、多年的工程项目的努力。这项工作涉及改变存储层的方面,添加新的复制一致性协议,修改分片架构,重构分片集群元数据,并添加全局逻辑时钟。这些变更为数据库提供了在实现 ACID 符合事务之前所需的基础。
推荐回调 API 而不是核心 API 的主要原因是应用程序中所需的复杂性和额外编码。这些 API 之间的差异在表 8-1 中总结。
表 8-1. 核心 API 与回调 API 的比较
| 核心 API | 回调 API |
|---|---|
| 需要显式调用以开始事务并提交事务。 | 开始一个事务,执行指定的操作,并提交(或在出错时中止)。 |
不含有处理 TransientTransactionError 和 UnknownTransactionCommitResult 的错误处理逻辑,而是提供了灵活性以便为这些错误添加自定义错误处理。 |
自动包含了处理 TransientTransactionError 和 UnknownTransactionCommitResult 的错误处理逻辑。 |
| 需要将显式逻辑会话传递给特定事务的 API。 | 需要将显式逻辑会话传递给特定事务的 API。 |
要理解这两个 API 之间的差异,我们可以使用一个简单的电子商务站点事务示例来比较这些 API,在这个示例中,订单被下单并在销售时从可用库存中移除相应的商品。这涉及到单个事务中不同集合的两个文档。我们事务示例的核心操作将是:
orders.insert_one({"sku": "abc123", "qty": 100}, session=session)
inventory.update_one({"sku": "abc123", "qty": {"$gte": 100}},
{"$inc": {"qty": -100}}, session=session)
首先,让我们看看如何在 Python 中为我们的事务示例使用核心 API。我们事务的两个操作在以下程序清单的第 1 步中被突出显示:
# Define the uriString using the DNS Seedlist Connection Format
# for the connection
uri = 'mongodb+srv://server.example.com/'
client = MongoClient(uriString)
my_wc_majority = WriteConcern('majority', wtimeout=1000)
# Prerequisite / Step 0: Create collections, if they don't already exist.
# CRUD operations in transactions must be on existing collections.
client.get_database( "webshop",
write_concern=my_wc_majority).orders.insert_one({"sku":
"abc123", "qty":0})
client.get_database( "webshop",
write_concern=my_wc_majority).inventory.insert_one(
{"sku": "abc123", "qty": 1000})
# Step 1: Define the operations and their sequence within the transaction
def update_orders_and_inventory(my_session):
orders = session.client.webshop.orders
inventory = session.client.webshop.inventory
with session.start_transaction(
read_concern=ReadConcern("snapshot"),
write_concern=WriteConcern(w="majority"),
read_preference=ReadPreference.PRIMARY):
orders.insert_one({"sku": "abc123", "qty": 100}, session=my_session)
inventory.update_one({"sku": "abc123", "qty": {"$gte": 100}},
{"$inc": {"qty": -100}}, session=my_session)
commit_with_retry(my_session)
# Step 2: Attempt to run and commit transaction with retry logic
def commit_with_retry(session):
while True:
try:
# Commit uses write concern set at transaction start.
session.commit_transaction()
print("Transaction committed.")
break
except (ConnectionFailure, OperationFailure) as exc:
# Can retry commit
if exc.has_error_label("UnknownTransactionCommitResult"):
print("UnknownTransactionCommitResult, retrying "
"commit operation ...")
continue
else:
print("Error during commit ...")
raise
# Step 3: Attempt with retry logic to run the transaction function txn_func
def run_transaction_with_retry(txn_func, session):
while True:
try:
txn_func(session) # performs transaction
break
except (ConnectionFailure, OperationFailure) as exc:
# If transient error, retry the whole transaction
if exc.has_error_label("TransientTransactionError"):
print("TransientTransactionError, retrying transaction ...")
continue
else:
raise
# Step 4: Start a session.
with client.start_session() as my_session:
# Step 5: Call the function 'run_transaction_with_retry' passing it the function
# to call 'update_orders_and_inventory' and the session 'my_session' to associate
# with this transaction.
try:
run_transaction_with_retry(update_orders_and_inventory, my_session)
except Exception as exc:
# Do something with error. The error handling code is not
# implemented for you with the Core API.
raise
现在,让我们看看如何在 Python 中使用回调 API 来完成同样的事务示例。我们事务的两个操作在以下程序清单的第 1 步中被突出显示:
# Define the uriString using the DNS Seedlist Connection Format
# for the connection
uriString = 'mongodb+srv://server.example.com/'
client = MongoClient(uriString)
my_wc_majority = WriteConcern('majority', wtimeout=1000)
# Prerequisite / Step 0: Create collections, if they don't already exist.
# CRUD operations in transactions must be on existing collections.
client.get_database( "webshop",
write_concern=my_wc_majority).orders.insert_one({"sku":
"abc123", "qty":0})
client.get_database( "webshop",
write_concern=my_wc_majority).inventory.insert_one(
{"sku": "abc123", "qty": 1000})
# Step 1: Define the callback that specifies the sequence of operations to
# perform inside the transactions.
def callback(my_session):
orders = my_session.client.webshop.orders
inventory = my_session.client.webshop.inventory
# Important:: You must pass the session variable 'my_session' to
# the operations.
orders.insert_one({"sku": "abc123", "qty": 100}, session=my_session)
inventory.update_one({"sku": "abc123", "qty": {"$gte": 100}},
{"$inc": {"qty": -100}}, session=my_session)
#. Step 2: Start a client session.
with client.start_session() as session:
# Step 3: Use with_transaction to start a transaction, execute the callback,
# and commit (or abort on error).
session.with_transaction(callback,
read_concern=ReadConcern('local'),
write_concern=my_write_concern_majority,
read_preference=ReadPreference.PRIMARY)
}
注意
在 MongoDB 的多文档事务中,您只能对现有集合或数据库执行读/写(CRUD)操作。如我们的示例所示,如果您希望将其插入到事务中,您必须首先在事务之外创建集合。不允许创建、删除或索引操作在事务中执行。
为您的应用程序调整事务限制
在使用事务时,有几个重要的参数需要注意。它们可以进行调整,以确保您的应用程序能够最优化地使用事务。
时间和 Oplog 大小限制
MongoDB 事务中存在两个主要类别的限制。第一个与事务的时间限制有关,控制特定事务可以运行的时间,事务等待获取锁的时间以及所有事务将运行的最大长度。第二个类别特别与 MongoDB oplog 条目和单个条目的大小限制相关。
时间限制
事务的默认最大运行时间是一分钟或更短。可以通过修改由mongod实例级别的transactionLifetimeLimitSeconds控制的限制来增加此时间。在分片集群的情况下,必须在所有分片副本集成员上设置此参数。超过此时间后,事务将被视为过期,并将由定期运行的清理过程中止。清理过程每 60 秒运行一次,或者每transactionLifetimeLimitSeconds/2,以较低者为准。
若要在事务上明确设置时间限制,建议在commitTransaction上指定maxTimeMS。如果未设置maxTimeMS,则将使用transactionLifetimeLimitSeconds,或者如果设置了但超出transactionLifetimeLimitSeconds,则将使用transactionLifetimeLimitSeconds。
事务在运行操作所需的锁之前等待的默认最大时间是 5 毫秒。可以通过修改由maxTransactionLockRequestTimeoutMillis控制的限制来增加此时间。如果事务在此时间内无法获取所需的锁,则会中止。maxTransactionLockRequestTimeoutMillis可以设置为0、-1或大于0的数字。将其设置为0意味着如果事务无法立即获取所需的所有锁,则会中止。设置为-1将使用由maxTimeMS指定的操作特定超时。任何大于0的数字将配置等待时间为以秒为单位的指定时间,作为事务尝试获取所需锁的期间。
Oplog 大小限制
MongoDB 将为事务中的写操作创建所需的操作日志(oplog)条目。然而,每个 oplog 条目必须在 16MB 的 BSON 文档大小限制内。
事务在 MongoDB 中提供了一个有用的功能,用于确保一致性,但应该与丰富的文档模型一起使用。这种模型的灵活性以及使用诸如模式设计等最佳实践将有助于避免在大多数情况下使用事务。事务是一个强大的功能,在应用程序中最好节制使用。
^(1) 本文的作者是分片的高级软件工程师 Misha Tyulenev;分布式系统副总裁 Andy Schwerin;分布式系统首席产品经理 Asya Kamsky;分片的高级软件工程师 Randolph Tan;分布式系统产品经理 Alyson Cabral;以及分片的软件工程师 Jack Mulrow。
第九章:应用程序设计
本章涵盖了如何有效地设计与 MongoDB 协同工作的应用程序。它讨论了:
-
架构设计考虑因素
-
决定是嵌入数据还是引用数据时的权衡
-
优化技巧
-
一致性考虑因素
-
如何迁移架构
-
如何管理架构
-
当 MongoDB 不适合作为数据存储时
架构设计考虑因素
数据表达的关键方面是架构的设计,即数据在文档中的表示方式。此设计的最佳方法是按照应用程序希望查看数据的方式进行表示。因此,与关系数据库不同,您需要在建模架构之前首先了解查询和数据访问模式。
设计架构时需要考虑的关键方面如下:
约束
您需要了解任何数据库或硬件限制。您还需要考虑 MongoDB 的一些特定方面,例如最大文档大小为 16 MB,完整文档从磁盘读取和写入,更新重写整个文档,以及原子更新是在文档级别进行的。
查询和写入的访问模式
您需要识别和量化应用程序及更广泛系统的工作负载。工作负载包括应用程序中的读取和写入。一旦您知道查询何时运行以及频率,您可以识别最常见的查询。这些查询是您需要设计架构来支持的查询。确定了这些查询后,您应尽量减少查询数量,并确保在设计中查询到的数据存储在同一文档中。
不在这些查询中使用的数据应放入不同的集合中。也应将很少使用的数据移至不同的集合中。是否可以将动态(读/写)数据与静态(主要是读取)数据分开值得考虑。优化架构设计以支持最常见的查询可以获得最佳性能结果。
关系类型
您应考虑与您应用需求相关的数据及文档间的关系。然后,您可以确定最佳的嵌入或引用数据或文档的方法。您需要解决如何引用文档而不需要执行额外的查询,以及在关系更改时更新多少文档。还必须考虑数据结构是否易于查询,例如支持建模特定关系的嵌套数组(数组中的数组)。
基数
一旦确定了文档和数据之间的关系,应考虑这些关系的基数。具体来说,它是一对一,一对多,多对多,一对百万还是多对十亿?确立关系的基数非常重要,以确保在 MongoDB 架构中使用最佳格式对其进行建模。您还应考虑在访问许多/百万侧的对象时是否单独访问或仅在父对象的上下文中访问,以及问题数据字段的更新与读取的比率。这些问题的答案将帮助您确定是否应嵌入文档或引用文档,以及是否应跨文档对数据进行去规范化。
架构设计模式
在 MongoDB 中,模式设计对应用程序性能直接影响重大。模式设计中有许多常见问题可以通过已知模式或“构建块”来解决。在模式设计中最佳实践是同时使用一个或多个这些模式。
可能适用的方案设计模式包括:
多态模式
这适用于集合中所有文档具有相似但不完全相同结构的情况。它涉及识别文档中支持应用程序将运行的常见查询的公共字段。跟踪文档或子文档中的特定字段有助于识别数据之间的差异,以及可以在应用程序中编码的不同代码路径或类/子类。这允许在不完全相同的文档集合中使用简单查询以提高查询性能。
属性模式
当文档中有一个子集的字段共享公共特征,并且您想要对其进行排序或查询时,或者需要进行排序的字段仅存在于文档的子集中,或者这两种情况同时成立时,适合使用此模式。它涉及将数据重新塑造为键/值对数组,并在此数组的元素上创建索引。修饰符可以作为这些键/值对的附加字段添加。此模式有助于针对每个文档的许多类似字段,从而减少所需的索引并简化查询。
桶模式
此适用于时间序列数据,其中数据作为一段时间内的流捕获。在 MongoDB 中,将此数据“分桶”为一组文档,每个文档都包含特定时间范围内的数据,比创建每个时间点/数据点的文档要有效得多。例如,您可以使用一个小时的“桶”,将该小时内的所有读数放入单个文档中的数组中。文档本身将具有指示此“桶”覆盖的时间段的开始和结束时间。
异常模式
处理偶尔的文档查询超出应用程序正常模式的罕见情况。这是为受欢迎度影响的高级架构模式设计的情况。这可以在社交网络中看到,例如重要影响者、图书销售、电影评论等。它使用一个标志来指示文档是异常值,并将额外的内容存储到一个或多个文档中,这些文档通过"_id"引用回第一个文档。标志将由您的应用程序代码使用,以获取溢出文档。
计算模式
当数据需要频繁计算时使用,也可以在数据访问模式为读取密集型时使用。这种模式建议在后台进行计算,并定期更新主文档。这样可以有效地近似计算字段或文档,而无需为每个查询持续生成这些内容。这样可以显著减少 CPU 的负担,避免重复计算相同的内容,特别是在读操作触发计算且读写比高的情况下。
子集模式
当您的工作集超过机器可用 RAM 时使用。这可能是由包含许多信息但未被应用程序使用的大型文档引起的。这种模式建议将频繁使用的数据和不经常使用的数据分割到两个单独的集合中。典型的例子可能是电子商务应用程序在“主”(经常访问)集合中保留产品的最近 10 条评论,并将所有较旧的评论移动到第二个集合中,仅在应用程序需要超过最近 10 条评论时才查询该集合。
扩展引用模式
用于存在许多不同逻辑实体或“物件”,每个实体都有自己的集合,但您可能希望为特定功能将这些实体聚集在一起的情景。典型的电子商务架构可能为订单、客户和库存分别设置不同的集合。当我们想要从这些单独的集合中汇总单个订单的所有信息时,这可能会对性能产生负面影响。解决方案是识别经常访问的字段,并在订单文档内部进行重复。对于电子商务订单而言,这可能是我们要将商品发运给的客户的姓名和地址。这种模式通过数据重复换取减少汇总信息所需的查询数量。
近似模式
对于需要资源密集型(时间、内存、CPU 周期)计算但精确性并非绝对必需的情况,这是非常有用的。例如,像/赞数或页面访问计数器这样的图片或帖子,在这些情况下,应用这种模式可以大大减少写入数量,例如每 100 次或更多次浏览后才更新计数器,而不是每次浏览都更新。
树模式
当您有大量查询且数据主要是分层结构时,可以应用此模式。它遵循将通常一起查询的数据存储在一起的早期概念。在 MongoDB 中,您可以在同一文档中的数组中轻松存储层次结构。在电子商务网站的产品目录示例中,通常有属于多个类别或属于其他类别的类别的产品。例如,“硬盘”本身是一个类别,但属于“存储”类别,后者又属于“计算机配件”类别,后者又是“电子产品”类别的一部分。在这种情况下,我们将有一个字段来跟踪整个层次结构,并且另一个字段将保留直接类别(“硬盘”)。在这些值上使用多键索引,可以确保轻松找到与层次结构中类别相关的所有项目。直接类别字段允许找到与此类别直接相关的所有项目。
预分配模式
虽然最初主要用于 MMAP 存储引擎,但现在仍有此模式的用途。该模式建议创建一个初始空结构,稍后将其填充。例如,用于管理按天管理资源的预订系统,跟踪资源是否空闲或已预订/不可用。资源(x)和天数(y)的二维结构使得检查可用性和执行计算非常简单。
文档版本化模式
这提供了一种机制,可以保留文档的旧版本。需要向每个文档添加一个额外的字段来跟踪“主”集合中的文档版本,以及一个包含所有文档修订版本的附加集合。这种模式有一些假设,特别是每个文档有限数量的修订版本,需要版本化的文档数量不多,并且主要查询都是在每个文档的当前版本上进行。在这些假设不成立的情况下,您可能需要修改模式或考虑不同的模式设计方案。
MongoDB 在线提供了关于模式和架构设计的多个有用资源。MongoDB University 提供了一门免费课程,M320 数据建模,以及一个“使用模式构建”博客系列。
规范化与去规范化
有许多表示数据的方式,其中一个最重要的问题是考虑数据的规范化程度。规范化指的是将数据分成多个集合,并在集合之间建立引用关系。每个数据片段只存在于一个集合中,尽管可能有多个文档引用它。因此,要更改数据,只需更新一个文档。MongoDB 聚合框架提供了带有 $lookup 阶段的连接功能,通过将“加入”集合中具有源集合匹配文档的文档添加到“加入”集合中,向每个匹配文档添加一个新的数组字段,其中包含来自源集合的文档的详细信息。这些重塑后的文档随后可以在下一个阶段中进一步处理。
去规范化是规范化的相反:将所有数据嵌入到单个文档中。而不是文档包含对数据的一份定义性拷贝的引用,许多文档可能包含数据的拷贝。这意味着如果信息更改,需要更新多个文档,但可以使用单个查询获取所有相关数据。
决定何时规范化和何时去规范化可能很困难:通常规范化使写操作更快,而去规范化使读操作更快。因此,您需要决定哪些权衡对您的应用程序是合理的。
数据表示示例
假设我们正在存储关于学生和他们所选课程的信息。一种表示方法是有一个students集合(每个学生是一个文档),一个classes集合(每门课程是一个文档)。然后我们可以有一个第三个集合(studentClasses),其中包含对学生和他们所选课程的引用:
> db.studentClasses.findOne({"studentId" : id})
{
"_id" : ObjectId("512512c1d86041c7dca81915"),
"studentId" : ObjectId("512512a5d86041c7dca81914"),
"classes" : [
ObjectId("512512ced86041c7dca81916"),
ObjectId("512512dcd86041c7dca81917"),
ObjectId("512512e6d86041c7dca81918"),
ObjectId("512512f0d86041c7dca81919")
]
}
如果你熟悉关系数据库,可能以前见过这种连接表(虽然通常每个文档只有一个学生和一个课程,而不是一个课程"_id"列表)。在 MongoDB 中,将课程放入数组可能更合适一些,但通常不建议以这种方式存储数据,因为这样需要大量查询才能获取实际信息。
假设我们想要找出一个学生正在上的课程。我们将在students集合中查询学生,在studentClasses中查询课程"_id",然后在classes集合中查询课程信息。因此,查找这些信息将需要向服务器发送三次请求。通常情况下,这不是在 MongoDB 中结构化数据的方式,除非课程和学生经常变化且读取数据不需要快速完成。
我们可以通过在学生文档中嵌入类引用来消除一个解引用查询:
{
"_id" : ObjectId("512512a5d86041c7dca81914"),
"name" : "John Doe",
"classes" : [
ObjectId("512512ced86041c7dca81916"),
ObjectId("512512dcd86041c7dca81917"),
ObjectId("512512e6d86041c7dca81918"),
ObjectId("512512f0d86041c7dca81919")
]
}
"classes"字段保留 John Doe 正在上的班级的"_id"数组。当我们想要了解这些班级的信息时,可以使用这些"_id"在classes集合中进行查询。这只需要两个查询。这是一种相当流行的数据结构方式,不需要立即访问且变化不频繁但也不是一成不变的数据。
如果我们需要进一步优化读取,我们可以通过完全去归一化数据并将每个班级作为嵌入文档存储在"classes"字段中来一次性获取所有信息的查询:
{
"_id" : ObjectId("512512a5d86041c7dca81914"),
"name" : "John Doe",
"classes" : [
{
"class" : "Trigonometry",
"credits" : 3,
"room" : "204"
},
{
"class" : "Physics",
"credits" : 3,
"room" : "159"
},
{
"class" : "Women in Literature",
"credits" : 3,
"room" : "14b"
},
{
"class" : "AP European History",
"credits" : 4,
"room" : "321"
}
]
}
这样做的好处是只需要一个查询即可获取信息。缺点是占用更多空间且更难保持同步。例如,如果事实证明物理应该值四学分(而不是三学分),则物理班的每个学生都需要更新他们的文档(而不仅仅是更新一个中心的“物理”文档)。
最后,你可以使用之前提到的扩展引用模式,这是嵌入和引用的混合体——你创建一个包含常用信息的子文档数组,但同时有一个指向实际文档的引用以获取更多信息:
{
"_id" : ObjectId("512512a5d86041c7dca81914"),
"name" : "John Doe",
"classes" : [
{
"_id" : ObjectId("512512ced86041c7dca81916"),
"class" : "Trigonometry"
},
{
"_id" : ObjectId("512512dcd86041c7dca81917"),
"class" : "Physics"
},
{
"_id" : ObjectId("512512e6d86041c7dca81918"),
"class" : "Women in Literature"
},
{
"_id" : ObjectId("512512f0d86041c7dca81919"),
"class" : "AP European History"
}
]
}
这种方法也是一个不错的选择,因为随着需求的变化,嵌入的信息量随时间可以改变:如果你想在页面中包含更多或更少的信息,可以在文档中嵌入更多或更少的信息。
另一个重要的考虑因素是这些信息变化的频率,以及它们被阅读的频率。如果它会经常更新,那么归一化是个好主意。然而,如果变化不频繁,那么优化更新过程可能对应用程序的每次读取都是有代价的,收益较少。
例如,教科书归一化的一个用例是将用户及其地址存储在单独的集合中。然而,人们的地址很少更改,所以通常不应因为某人可能搬迁而对每次读取进行惩罚。你的应用程序应该将地址嵌入到用户文档中。
如果决定使用嵌入式文档并需要更新它们,应设置定期任务以确保成功地将任何更新传播到每个文档。例如,假设你尝试进行多次更新,但服务器在所有文档更新完成之前崩溃。你需要一种方法来检测并重试更新。
在更新操作符方面,"$set"是幂等的,但"$inc"则不是。幂等操作无论尝试一次还是多次,结果都相同;在网络错误的情况下,重试操作足以使更新发生。对于不是幂等的操作符,操作应分为两个分别是幂等且可以安全重试的操作。这可以通过在第一个操作中包含唯一的待处理令牌,并在第二个操作中同时使用唯一键和唯一的待处理令牌来实现。这种方法允许"$inc"是幂等的,因为每个单独的updateOne操作都是幂等的。
在一定程度上,生成的信息越多,嵌入的内容就越少。如果嵌入字段的内容或嵌入字段的数量可能会无限增长,则通常应该使用引用而不是嵌入。像评论树或活动列表这样的内容应存储为它们自己的文档,而不是嵌入其中。还值得考虑使用子集模式(在“模式设计模式”中描述)来存储最新的项目(或其他子集)在文档中。
最后,包含的字段应与文档中的数据完整相关。如果在查询文档时,一个字段几乎总是被排除在结果之外,这表明它可能属于另一个集合。这些准则在表 9-1 中总结。
表 9-1. 嵌入与引用的比较
| 嵌入更适合... | 引用更适合... |
|---|---|
| 小型子文档 | 大型子文档 |
| 不经常更改的数据 | 易变数据 |
| 当接受最终一致性时 | 当需要立即一致性时 |
| 少量增长的文档 | 大量增长的文档 |
| 您经常需要执行第二个查询来获取的数据 | 您经常在结果中排除的数据 |
| 快速读取 | 快速写入 |
假设我们有一个users集合。以下是可能包含在用户文档中的一些示例字段及其是否应该嵌入的指示:
账户偏好
这些只与此用户文档相关,并且可能会与文档中的其他用户信息一起公开。账户偏好通常应该被嵌入。
最近活动
这取决于最近活动的增长和变化情况。如果它是一个固定大小的字段(例如,最近的 10 个事物),可能有必要将这些信息嵌入或实现子集模式。
好友
通常不应完全嵌入此类信息,或者至少不应完全嵌入。参见“好友、关注者和其他不便之处”。
此用户产生的所有内容
这不应该被嵌入。
基数
基数表示一个集合对另一个集合的引用数量。常见的关系是一对一、一对多或多对多。例如,假设我们有一个博客应用程序。每篇文章都有一个标题,因此这是一对一关系。每个作者有多篇文章,所以这是一对多关系。文章有多个标签,标签也指向多篇文章,因此这是多对多关系。
在使用 MongoDB 时,将“许多”概念上分为“许多”和“少”。例如,您可能在作者和文章之间有一个一对少的关系:每个作者只写几篇文章。您可能在博客文章和标签之间有一个多对少的关系:您可能拥有比标签更多的博客文章。但是,您可能在博客文章和评论之间有一个一对多的关系:每篇文章有许多评论。
确定少关系与多关系可以帮助您决定何时嵌入何时引用。通常情况下,“少”关系与嵌入效果更好,“多”关系与引用效果更好。
朋友、粉丝和其他不便之处
让朋友近在咫尺,而敌人则深藏不露。
本节涵盖社交图数据的考虑因素。许多社交应用程序需要连接人员、内容、关注者、朋友等。找到如何平衡嵌入和引用这些高度连接信息可能有些棘手,但通常遵循、成为朋友或收藏可以简化为发布/订阅系统:一个用户订阅另一个用户的通知。因此,有两种基本操作需要高效处理:存储订阅者和通知所有感兴趣的人某个事件。
人们通常有三种方式实现订阅。第一种选择是将生产者放入订阅者的文档中,看起来像这样:
{
"_id" : ObjectId("51250a5cd86041c7dca8190f"),
"username" : "batman",
"email" : "batman@waynetech.com"
"following" : [
ObjectId("51250a72d86041c7dca81910"),
ObjectId("51250a7ed86041c7dca81936")
]
}
现在,给定用户的文档,您可以发出以下查询来查找他们可能感兴趣的所有已发布活动:
db.activities.find({"user" : {"$in" :
user["following"]}})
然而,如果您需要找到所有对新发布的活动感兴趣的人,您将不得不跨所有用户查询"following"字段。
或者,您可以将关注者附加到生产者的文档中,就像这样:
{
"_id" : ObjectId("51250a7ed86041c7dca81936"),
"username" : "joker",
"email" : "joker@mailinator.com"
"followers" : [
ObjectId("512510e8d86041c7dca81912"),
ObjectId("51250a5cd86041c7dca8190f"),
ObjectId("512510ffd86041c7dca81910")
]
}
每当此用户执行某些操作时,所有需要通知的用户都在这里。缺点是现在您需要查询整个用户集合来找到每个用户关注的所有人(与前一种情况相反的限制)。
这两个选项都带来了一个额外的缺点:它们使您的用户文档变得更大并且更不稳定。"following"(或"followers")字段通常甚至不需要返回:您想多频繁地列出每个关注者?因此,最终的选项通过进一步归一化甚至存储在另一个集合中的订阅来抵消这些缺点。彻底归一化通常是过度的,但对于经常与其余文档一起返回的极不稳定的字段可能是有用的。"followers"可能是以这种方式彻底归一化的一个明智的字段。
在这种情况下,您需要维护一个将发布者与订阅者匹配的集合,并且文档看起来像这样:
{
"_id" : ObjectId("51250a7ed86041c7dca81936"), // followee's "_id"
"followers" : [
ObjectId("512510e8d86041c7dca81912"),
ObjectId("51250a5cd86041c7dca8190f"),
ObjectId("512510ffd86041c7dca81910")
]
}
这样可以保持您的用户文档简洁,但意味着需要额外的查询来获取关注者。
处理威尔·惠特恩效应
无论您使用哪种策略,只嵌入有限数量的子文档或引用是可行的。如果您有名人用户,他们可能会溢出您存储关注者的任何文档。补偿此问题的典型方法是使用“模式设计模式”中讨论的异常模式,并在必要时使用“续集”文档。例如,您可能会有:
> db.users.find({"username" : "wil"})
{
"_id" : ObjectId("51252871d86041c7dca8191a"),
"username" : "wil",
"email" : "wil@example.com",
"tbc" : [
ObjectId("512528ced86041c7dca8191e"),
ObjectId("5126510dd86041c7dca81924")
]
"followers" : [
ObjectId("512528a0d86041c7dca8191b"),
ObjectId("512528a2d86041c7dca8191c"),
ObjectId("512528a3d86041c7dca8191d"),
...
]
}
{
"_id" : ObjectId("512528ced86041c7dca8191e"),
"followers" : [
ObjectId("512528f1d86041c7dca8191f"),
ObjectId("512528f6d86041c7dca81920"),
ObjectId("512528f8d86041c7dca81921"),
...
]
}
{
"_id" : ObjectId("5126510dd86041c7dca81924"),
"followers" : [
ObjectId("512673e1d86041c7dca81925"),
ObjectId("512650efd86041c7dca81922"),
ObjectId("512650fdd86041c7dca81923"),
...
]
}
然后添加应用逻辑来支持获取“待续”("tbc")数组中的文档。
数据操作的优化
要优化您的应用程序,您必须首先通过评估其读取和写入性能来确定其瓶颈所在。优化读取通常涉及拥有正确的索引并尽可能返回单个文档中的尽可能多的信息。优化写入通常涉及最小化索引数量并尽可能高效地进行更新。
通常在为快速写入而优化的模式和为快速读取而优化的模式之间存在权衡,因此您可能必须决定对于您的应用程序来说哪个更重要。不仅要考虑读取与写入的重要性,还要考虑它们的比例:如果写入更重要,但您每次写入都进行了一千次读取,您可能仍然希望优先优化读取。
删除旧数据
有些数据只在短时间内重要:几周或几个月后,它只会浪费存储空间。有三种流行的方法可以删除旧数据:使用固定大小集合(capped collections)、使用 TTL 集合和按时间段删除集合。
最简单的选项是使用固定大小集合:将其设置为一个大尺寸,让旧数据“掉落”到末尾。然而,固定大小集合对您可以执行的操作有一定的限制,并且容易受到流量峰值的影响,暂时降低它们可以保持的时间长度。更多信息请参见“固定大小集合”。
第二个选项是使用 TTL 集合。这可以让您更精细地控制文档何时被移除,但对于写入量非常高的集合可能不够快速:它通过遍历 TTL 索引来移除文档,方式与用户请求的移除相同。但是,如果 TTL 集合能够跟得上,这可能是最容易实现的解决方案。有关 TTL 索引的更多信息,请参阅“生存时间索引”。
最终的选项是使用多个集合:例如,每个月一个集合。每次月份变更时,您的应用程序开始使用本月的(空)集合,并在当前和前几个月的集合中搜索数据。一旦一个集合超过了,比如说,六个月,您可以丢弃它。这种策略可以应对几乎任何量的流量,但构建应用程序会更加复杂,因为您需要使用动态集合(或数据库)名称,并可能查询多个数据库。
规划数据库和集合
一旦您勾画出您的文档的外观,您必须决定将它们放在哪些集合或数据库中。这通常是一个相当直观的过程,但有一些指导原则需要牢记。
通常情况下,具有相似模式的文档应放在同一个集合中。MongoDB 通常不允许将来自多个集合的数据合并,因此如果有需要一起查询或聚合的文档,这些文档适合放在一个大集合中。例如,您可能有形状相当不同的文档,但如果要对它们进行聚合,它们应该都存在于同一个集合中(或者如果它们在不同的集合或数据库中,则可以使用$merge阶段)。
对于集合,需要考虑的主要问题是锁定(每个文档获取读/写锁)和存储。一般来说,如果您有高写入工作负载,可能需要考虑使用多个物理卷来减少 I/O 瓶颈。当您使用--directoryperdb选项时,每个数据库可以驻留在自己的目录中,允许您将不同的数据库挂载到不同的卷上。因此,您可能希望数据库中的所有项具有类似的“质量”,具有类似的访问模式或类似的流量水平。
例如,假设您有一个应用程序,包括几个组件:一个创建大量非常有价值数据的日志组件,一个用户集合,以及几个用于用户生成数据的集合。这些集合都是高价值的:用户数据的安全非常重要。还有一个用于社交活动的高流量集合,重要性较低,但不像日志那么不重要。这个集合主要用于用户通知,因此几乎是一个追加记录的集合。
将这些按重要性分开,你可能会得到三个数据库:日志、活动和用户。这种策略的好处在于,你可能会发现你的最有价值的数据也是你最少拥有的(例如,用户生成的数据可能不如日志那么多)。你可能无法为整个数据集买 SSD,但你可能可以为用户买一个,或者你可以为用户使用 RAID10,而为日志和活动使用 RAID0。
请注意,在 MongoDB 4.2 之前及聚合框架中引入$merge操作符之前,使用多个数据库存在一些限制,该操作允许将聚合的结果从一个数据库存储到不同数据库和该数据库内的不同集合。另一个需要注意的点是,在将现有集合从一个数据库复制到另一个数据库时,使用renameCollection命令速度较慢,因为它必须将所有文档复制到新数据库。
管理一致性
您必须弄清楚您的应用程序读取数据需要多一致。MongoDB 支持多种一致性级别,从始终能够读取您自己的写入数据到读取未知数据的旧度。如果您正在报告过去一年的活动情况,您可能只需要正确到过去几天的数据。相反,如果您正在进行实时交易,您可能需要立即读取最新的写入数据。
要了解如何实现这些不同级别的一致性,了解 MongoDB 在幕后的操作是很重要的。服务器保持每个连接的请求队列。当客户端发送请求时,它将被放置在其连接队列的末尾。连接上的任何后续请求将在先前入队的操作处理后发生。因此,单个连接具有数据库的一致视图,并且始终可以读取其自己的写入数据。
请注意,这是一个连接队列:如果我们打开两个 shell,我们将有两个连接到数据库。如果我们在一个 shell 中进行插入操作,在另一个 shell 中进行后续查询可能不会返回插入的文档。然而,在单个 shell 中,如果我们在插入后查询文档,文档将被返回。这种行为在手动复制时可能难以复制,但在繁忙的服务器上,插入和查询可能交错发生。开发人员经常遇到这种情况,当他们在一个线程中插入数据然后在另一个线程中检查是否成功插入时。在某个瞬间,看起来数据没有插入,然后突然出现了。
当使用 Ruby、Python 和 Java 驱动程序时,特别需要注意这种行为,因为这三种语言都使用连接池。为了效率,这些驱动程序会打开多个连接(一个池)到服务器,并在它们之间分发请求。然而,它们都有机制来确保一系列请求由单个连接处理。有关各种语言的连接池的详细文档在MongoDB 驱动程序连接监控和池化规范中有所介绍。
当你将读操作发送到复制集的次要节点时(参见第十二章),这就成为一个更大的问题。次要节点可能落后于主节点,导致读取数据是几秒、几分钟甚至几小时前的数据。有几种方法可以处理这个问题,最简单的方法是如果你关心数据的陈旧性,就把所有读取操作都发送到主节点。
MongoDB 提供了readConcern选项来控制所读取数据的一致性和隔离特性。它可以与writeConcern结合使用,以控制向你的应用程序所做的一致性和可用性保证。有五个级别:"local"、"available"、"majority"、"linearizable"和"snapshot"。根据应用程序的不同,如果你希望避免读取数据的陈旧性,可以考虑使用"majority",它仅返回已被大多数复制集成员确认的持久化数据,并且不会被回滚。"linearizable"也是一个选择:它返回反映了所有成功的多数确认写操作完成之前的数据。在返回带有"linearizable"的readConcern结果之前,MongoDB 可能会等待并发执行的写操作完成。
MongoDB 的三位资深工程师在 2019 年的 PVLDB 会议上发表了一篇名为“MongoDB 中的可调一致性”的论文。^1 本文概述了用于复制的不同 MongoDB 一致性模型及应用程序开发人员如何利用这些模型。
迁移模式
随着应用程序的增长和需求的变化,你的模式可能也需要增长和变化。有几种方法可以实现这一点,但无论你选择哪种方法,你都应该仔细记录应用程序使用过的每个模式。理想情况下,你应该考虑是否适用于文档版本控制模式(参见“模式设计模式”)。
最简单的方法是随着应用程序的需要使模式逐步演变,确保应用程序支持所有旧版本的模式(例如,接受字段的存在或不存在,或优雅地处理多种可能的字段类型)。但是,如果您有冲突的模式版本,这种技术可能会变得混乱。例如,一个版本可能需要一个"mobile"字段,另一个版本可能需要没有"mobile"字段,而是需要一个不同的字段,而另一个版本可能将"mobile"字段视为可选。跟踪这些变化的需求可能会逐渐使您的代码变得混乱不堪。
为了以稍微结构化的方式处理变化的需求,您可以在每个文档中包含一个"version"字段(或仅使用"v"),并使用它来确定应用程序将接受的文档结构。这样可以更严格地执行您的模式:文档必须对某个模式版本有效,即使不是当前版本。然而,它仍然需要支持旧版本。
最后的选择是在模式更改时迁移所有数据。通常这不是一个好主意:MongoDB 允许您具有动态模式以避免迁移,因为它们会给系统施加很大压力。但是,如果您决定更改每个文档,您需要确保所有文档都已成功更新。MongoDB 支持事务,支持此类迁移。如果 MongoDB 在事务中途中崩溃,则会保留旧模式。
管理模式
MongoDB 在 3.2 版本中引入了模式验证,允许在更新和插入期间进行验证。在 3.6 版本中,它通过$jsonSchema操作符添加了 JSON 模式验证,现在建议在 MongoDB 中使用此方法进行所有模式验证。截至撰写本文时,MongoDB 支持 JSON 模式的草案 4,但请查阅文档获取此功能的最新信息。
验证在修改现有文档之前不会检查它们,并且配置是针对每个集合的。要向现有集合添加验证,可以使用collMod命令,并使用validator选项。在使用db.createCollection()时,通过指定validator选项可以向新集合添加验证。MongoDB 还提供了两个额外的选项,validationLevel和validationAction。validationLevel确定在更新现有文档期间应用验证规则的严格程度,而validationAction决定在出现错误加拒绝或警告加允许非法文档的情况下应该采取的操作。
不适合使用 MongoDB 的情况
虽然 MongoDB 是一种通用的数据库,在大多数应用中表现良好,但它并非万能。有几个原因可能需要避免使用它:
-
加入多种不同类型的数据以及跨多个不同维度进行连接是关系数据库擅长的事情。MongoDB 并不擅长这样做,并且很可能永远也不会。
-
使用关系数据库而不是 MongoDB 的一个重要(如果幸运的话是暂时的)原因是,如果你正在使用不支持 MongoDB 的工具。从 SQLAlchemy 到 WordPress,有成千上万的工具只是不支持 MongoDB。支持 MongoDB 的工具数量正在增加,但其生态系统远不及关系数据库的庞大。
^(1) 作者是复制高级软件工程师威廉·舒尔茨;复制团队团队负责人特斯·阿维塔比勒;以及分布式系统产品经理艾莉森·卡布拉尔。
第三部分:复制
第十章:设置副本集
本章介绍了 MongoDB 的高可用性系统:副本集。它涵盖了:
-
什么是副本集
-
如何设置副本集
-
副本集成员的配置选项有哪些
引入复制
自从第一章以来,我们一直在使用独立服务器,即单个mongod服务器。这是一个快速入门的方式,但在生产环境中运行是有风险的。如果服务器崩溃或不可用,您的数据库将在一段时间内无法访问。如果硬件出现问题,可能需要将数据迁移到另一台机器。在最坏的情况下,磁盘或网络问题可能导致数据损坏或无法访问。
复制是将数据的完全相同副本存储在多个服务器上的一种方式,并且建议在所有生产部署中使用。即使一台或多台服务器发生故障,复制也能保持应用程序运行和数据安全。
在 MongoDB 中,通过创建副本集来设置复制。副本集是一组服务器,其中包括一个主服务器(负责写入数据)和多个从服务器(负责保留主服务器数据的副本)。如果主服务器崩溃,从服务器可以从自身选举新的主服务器。
如果您正在使用复制,并且一台服务器出现故障,仍然可以从集合中的其他服务器访问数据。如果服务器上的数据受损或无法访问,则可以从集合中的其他成员制作数据的新副本。
本章介绍了副本集,并涵盖了如何在您的系统上设置复制。如果您对复制机制不太感兴趣,只是想为测试/开发或生产创建一个副本集,可以使用 MongoDB 的云解决方案,MongoDB Atlas。它易于使用,并提供免费的试用选项供实验。或者,要在自己的基础架构中管理 MongoDB 集群,可以使用Ops Manager。
设置副本集,第一部分
在本章中,我们将向您展示如何在单台机器上设置一个三节点复制集,以便您可以开始实验复制集机制。这种设置可能只是为了快速启动和运行一个复制集,然后在mongo shell 中使用管理命令或模拟网络分区或服务器故障,以更好地了解 MongoDB 如何处理高可用性和灾难恢复。在生产环境中,您应该始终使用复制集,并为每个成员分配一个专用主机,以避免资源争用并提供对服务器故障的隔离。为了提供进一步的弹性,您还应该使用DNS 种子连接格式来指定应用程序如何连接到您的复制集。使用 DNS 的优势在于,托管 MongoDB 复制集成员的服务器可以轮换更改,而无需重新配置客户端(特别是它们的连接字符串)。
鉴于现有的虚拟化和云选项的多样性,几乎可以像为每个成员在专用主机上创建一个测试副本集一样简单。我们提供了一个 Vagrant 脚本,让您可以尝试这个选项。^(1)
要开始测试我们的复制集,请首先为每个节点创建单独的数据目录。在 Linux 或 macOS 上,在终端中运行以下命令来创建这三个目录:
$ mkdir -p ~/data/rs{1,2,3}
这将创建目录/data/rs1*、*/data/rs2和~/data/rs3(~表示您的主目录)。
在 Windows 上,要创建这些目录,请在命令提示符(cmd)或 PowerShell 中运行以下命令:
> md c:\data\rs1 c:\data\rs2 c:\data\rs3
然后,在 Linux 或 macOS 上,需要在单独的终端中运行以下每个命令:
$ mongod --replSet mdbDefGuide --dbpath ~/data/rs1 --port 27017 \
--smallfiles --oplogSize 200
$ mongod --replSet mdbDefGuide --dbpath ~/data/rs2 --port 27018 \
--smallfiles --oplogSize 200
$ mongod --replSet mdbDefGuide --dbpath ~/data/rs3 --port 27019 \
--smallfiles --oplogSize 200
在 Windows 上,每个命令都需要在单独的命令提示符或 PowerShell 窗口中运行:
> mongod --replSet mdbDefGuide --dbpath c:\data\rs1 --port 27017 \
--smallfiles --oplogSize 200
> mongod --replSet mdbDefGuide --dbpath c:\data\rs2 --port 27018 \
--smallfiles --oplogSize 200
> mongod --replSet mdbDefGuide --dbpath c:\data\rs3 --port 27019 \
--smallfiles --oplogSize 200
启动后,您应该有三个独立的mongod进程在运行。
注意
一般来说,我们将在本章剩余部分介绍的原则适用于在生产部署中使用的复制集,其中每个mongod都有一个专用的主机。然而,在保护复制集的相关细节方面,我们会在第十九章中简要提及一些额外的细节。
网络考虑事项
集合的每个成员必须能够与集合中的每个其他成员(包括自身)建立连接。如果出现有关无法连接正在运行的其他成员的错误,请检查您的网络配置,确保允许它们之间的连接。
您启动的进程同样可以轻松运行在单独的服务器上。但是,从 MongoDB 3.6 版本开始,默认情况下mongod仅绑定到localhost(127.0.0.1)。为了使复制集的每个成员能够与其他成员通信,您还必须绑定到其他成员可达的 IP 地址。如果我们在具有 IP 地址为 198.51.100.1 的网络接口的服务器上运行mongod实例,并且希望将其作为复制集的成员与不同服务器上的每个成员一起运行,则可以指定命令行参数--bind_ip或在该实例的配置文件中使用bind_ip:
$ mongod --bind_ip localhost,192.51.100.1 --replSet mdbDefGuide \
--dbpath ~/data/rs1 --port 27017 --smallfiles --oplogSize 200
在这种情况下,无论我们运行在 Linux、macOS 还是 Windows 上,我们都会对其他mongod进行类似的修改。
安全注意事项
在绑定到除localhost以外的 IP 地址之前,在配置复制集时,应启用授权控制并指定身份验证机制。此外,建议对磁盘上的数据以及复制集成员之间和集合与客户端之间的通信进行加密。我们将在第十九章中详细讨论保护复制集的问题。
设置复制集,第二部分
回到我们的例子,到目前为止我们所做的工作,每个mongod都还不知道其他实例的存在。为了告诉它们彼此的存在,我们需要创建一个列出每个成员的配置,并将此配置发送给我们的一个mongod进程。它将负责将配置传播到其他成员。
在第四个终端、Windows 命令提示符或 PowerShell 窗口中,启动连接到运行中的mongod实例之一的mongo shell。您可以通过输入以下命令来执行此操作。使用此命令,我们将连接到运行在端口 27017 上的mongod:
$ mongo --port 27017
然后,在mongo shell 中,创建一个配置文档,并将其传递给rs.initiate()助手以初始化一个复制集。这将启动一个包含三个成员的复制集,并将配置传播到其余的mongod实例,从而形成一个复制集:
> rsconf = {
_id: "mdbDefGuide",
members: [
{_id: 0, host: "localhost:27017"},
{_id: 1, host: "localhost:27018"},
{_id: 2, host: "localhost:27019"}
]
}
> rs.initiate(rsconf)
{ "ok" : 1, "operationTime" : Timestamp(1501186502, 1) }
复制集配置文档有几个重要部分。配置的"_id"是您在命令行中传递的复制集名称(在本例中为"mdbDefGuide")。确保此名称完全匹配。
文档的下一部分是一组集合成员的数组。每个成员都需要两个字段:一个"_id",它是复制集成员之间唯一的整数,以及一个主机名。
请注意,我们在本套中使用localhost作为成员的主机名。这仅供示例目的。在稍后讨论安全复制集的章节中,我们将探讨更适合生产部署的配置。MongoDB 允许用于本地测试的全localhost复制集,但如果尝试在配置中混合localhost和非localhost服务器,则会有问题。
此配置文档是您的复制集配置。运行在 localhost:27017 上的成员将解析配置并向其他成员发送消息,通知它们有新的配置。一旦它们加载了配置,它们将选举主节点并开始处理读写操作。
提示
不幸的是,您无法在不重启并初始化集合的情况下将独立服务器转换为复制集。因此,即使您一开始只有一个服务器,您可能希望将其配置为单成员复制集。这样,如果以后想添加更多成员,也可以在无需停机的情况下执行。
如果您正在启动全新的集合,可以将配置发送到集合中的任何成员。如果您在其中一个成员上拥有数据,则必须将配置发送到具有数据的成员。您无法在具有多个成员数据的情况下启动复制集。
一旦初始化完成,您应该拥有一个完全功能的复制集。复制集应该会选举一个主节点。您可以使用 rs.status() 查看复制集的状态。rs.status() 的输出会告诉您很多关于复制集的信息,包括我们尚未涵盖的许多内容,但不用担心,我们会介绍的!现在,先看看 members 数组。请注意,此输出中列出了我们的三个 mongod 实例,其中一个(在本例中是运行在端口 27017 上的 mongod)已经被选为主节点。其他两个是从节点。如果您自己尝试,可能会发现输出中的 "date" 和多个 Timestamp 值不同,甚至可能会发现不同的 mongod 被选为主节点(这完全正常):
> rs.status()
{
"set" : "mdbDefGuide",
"date" : ISODate("2017-07-27T20:23:31.457Z"),
"myState" : 1,
"term" : NumberLong(1),
"heartbeatIntervalMillis" : NumberLong(2000),
"optimes" : {
"lastCommittedOpTime" : {
"ts" : Timestamp(1501187006, 1),
"t" : NumberLong(1)
},
"appliedOpTime" : {
"ts" : Timestamp(1501187006, 1),
"t" : NumberLong(1)
},
"durableOpTime" : {
"ts" : Timestamp(1501187006, 1),
"t" : NumberLong(1)
}
},
"members" : [
{
"_id" : 0,
"name" : "localhost:27017",
"health" : 1,
"state" : 1,
"stateStr" : "PRIMARY",
"uptime" : 688,
"optime" : {
"ts" : Timestamp(1501187006, 1),
"t" : NumberLong(1)
},
"optimeDate" : ISODate("2017-07-27T20:23:26Z"),
"electionTime" : Timestamp(1501186514, 1),
"electionDate" : ISODate("2017-07-27T20:15:14Z"),
"configVersion" : 1,
"self" : true
},
{
"_id" : 1,
"name" : "localhost:27018",
"health" : 1,
"state" : 2,
"stateStr" : "SECONDARY",
"uptime" : 508,
"optime" : {
"ts" : Timestamp(1501187006, 1),
"t" : NumberLong(1)
},
"optimeDurable" : {
"ts" : Timestamp(1501187006, 1),
"t" : NumberLong(1)
},
"optimeDate" : ISODate("2017-07-27T20:23:26Z"),
"optimeDurableDate" : ISODate("2017-07-27T20:23:26Z"),
"lastHeartbeat" : ISODate("2017-07-27T20:23:30.818Z"),
"lastHeartbeatRecv" : ISODate("2017-07-27T20:23:30.113Z"),
"pingMs" : NumberLong(0),
"syncingTo" : "localhost:27017",
"configVersion" : 1
},
{
"_id" : 2,
"name" : "localhost:27019",
"health" : 1,
"state" : 2,
"stateStr" : "SECONDARY",
"uptime" : 508,
"optime" : {
"ts" : Timestamp(1501187006, 1),
"t" : NumberLong(1)
},
"optimeDurable" : {
"ts" : Timestamp(1501187006, 1),
"t" : NumberLong(1)
},
"optimeDate" : ISODate("2017-07-27T20:23:26Z"),
"optimeDurableDate" : ISODate("2017-07-27T20:23:26Z"),
"lastHeartbeat" : ISODate("2017-07-27T20:23:30.818Z"),
"lastHeartbeatRecv" : ISODate("2017-07-27T20:23:30.113Z"),
"pingMs" : NumberLong(0),
"syncingTo" : "localhost:27017",
"configVersion" : 1
}
],
"ok" : 1,
"operationTime" : Timestamp(1501187006, 1)
}
观察复制
如果您的复制集选举端口为 27017 上的 mongod 为主节点,则用于启动复制集的 mongo shell 当前已连接到主节点。您应该看到提示更改为以下内容:
mdbDefGuide:PRIMARY>
这表明我们已连接到复制集的主节点,其 "_id" 为 "mdbDefGuide"。为了简化和清晰起见,我们将在复制示例中仅使用 > 来表示 mongo shell 提示。
如果您的复制集选举了其他节点作为主节点,请退出 shell 并在命令行中指定正确的端口号连接到主节点,就像我们在启动 mongo shell 时所做的那样。例如,如果您的集合的主节点在端口 27018 上,请使用以下命令连接:
$ mongo --port 27018
现在您已连接到主节点,请尝试执行一些写操作并查看发生了什么。首先,插入 1000 个文档:
> use test
> for (i=0; i<1000; i++) {db.coll.insert({count: i})}
>
> // make sure the docs are there
> db.coll.count()
1000
现在检查其中一个从节点,并验证它是否拥有所有这些文档的副本。您可以退出 shell 并使用其中一个从节点的端口号连接来执行此操作,但是通过在已运行的 shell 中使用 Mongo 构造函数实例化连接对象,即可轻松获得连接到其中一个从节点的连接。
首先,在主服务器上使用你连接到test数据库来运行isMaster命令。这将以比rs.status()更为简洁的形式显示副本集的状态。在编写应用程序代码或脚本时,这也是一个方便的确定主成员的方式:
> db.isMaster()
{
"hosts" : [
"localhost:27017",
"localhost:27018",
"localhost:27019"
],
"setName" : "mdbDefGuide",
"setVersion" : 1,
"ismaster" : true,
"secondary" : false,
"primary" : "localhost:27017",
"me" : "localhost:27017",
"electionId" : ObjectId("7fffffff0000000000000004"),
"lastWrite" : {
"opTime" : {
"ts" : Timestamp(1501198208, 1),
"t" : NumberLong(4)
},
"lastWriteDate" : ISODate("2017-07-27T23:30:08Z")
},
"maxBsonObjectSize" : 16777216,
"maxMessageSizeBytes" : 48000000,
"maxWriteBatchSize" : 1000,
"localTime" : ISODate("2017-07-27T23:30:08.722Z"),
"maxWireVersion" : 6,
"minWireVersion" : 0,
"readOnly" : false,
"compression" : [
"snappy"
],
"ok" : 1,
"operationTime" : Timestamp(1501198208, 1)
}
如果在任何时候发生选举,你连接的mongod成为辅助服务器,你可以使用isMaster命令确定哪个成员已成为主服务器。这里的输出告诉我们,localhost:27018和localhost:27019都是辅助服务器,因此我们可以使用任何一个来实现我们的目的。让我们实例化一个连接到localhost:27019:
> secondaryConn = new Mongo("localhost:27019")
connection to localhost:27019
>
> secondaryDB = secondaryConn.getDB("test")
test
现在,如果我们试图在已复制到辅助服务器的集合上进行读取,我们会收到一个错误。让我们尝试在这个集合上进行find操作,然后查看错误及其原因:
> secondaryDB.coll.find()
Error: error: {
"operationTime" : Timestamp(1501200089, 1),
"ok" : 0,
"errmsg" : "not master and slaveOk=false",
"code" : 13435,
"codeName" : "NotMasterNoSlaveOk"
}
辅助服务器可能落后于主服务器(或滞后),因此可能没有最新的写操作,所以默认情况下,辅助服务器将拒绝读请求,以防止应用程序意外读取陈旧数据。因此,如果你尝试查询一个辅助服务器,你会收到一个错误,指示它不是主服务器。这是为了保护你的应用程序免受意外连接到辅助服务器并读取陈旧数据的影响。为了允许在辅助服务器上进行查询,我们可以设置一个“我允许从辅助服务器读取”的标志,像这样:
> secondaryConn.setSlaveOk()
注意,slaveOk设置在连接(secondaryConn)上,而不是数据库(secondaryDB)上。
现在你已经准备好从这个成员读取了。正常查询它:
> secondaryDB.coll.find()
{ "_id" : ObjectId("597a750696fd35621b4b85db"), "count" : 0 }
{ "_id" : ObjectId("597a750696fd35621b4b85dc"), "count" : 1 }
{ "_id" : ObjectId("597a750696fd35621b4b85dd"), "count" : 2 }
{ "_id" : ObjectId("597a750696fd35621b4b85de"), "count" : 3 }
{ "_id" : ObjectId("597a750696fd35621b4b85df"), "count" : 4 }
{ "_id" : ObjectId("597a750696fd35621b4b85e0"), "count" : 5 }
{ "_id" : ObjectId("597a750696fd35621b4b85e1"), "count" : 6 }
{ "_id" : ObjectId("597a750696fd35621b4b85e2"), "count" : 7 }
{ "_id" : ObjectId("597a750696fd35621b4b85e3"), "count" : 8 }
{ "_id" : ObjectId("597a750696fd35621b4b85e4"), "count" : 9 }
{ "_id" : ObjectId("597a750696fd35621b4b85e5"), "count" : 10 }
{ "_id" : ObjectId("597a750696fd35621b4b85e6"), "count" : 11 }
{ "_id" : ObjectId("597a750696fd35621b4b85e7"), "count" : 12 }
{ "_id" : ObjectId("597a750696fd35621b4b85e8"), "count" : 13 }
{ "_id" : ObjectId("597a750696fd35621b4b85e9"), "count" : 14 }
{ "_id" : ObjectId("597a750696fd35621b4b85ea"), "count" : 15 }
{ "_id" : ObjectId("597a750696fd35621b4b85eb"), "count" : 16 }
{ "_id" : ObjectId("597a750696fd35621b4b85ec"), "count" : 17 }
{ "_id" : ObjectId("597a750696fd35621b4b85ed"), "count" : 18 }
{ "_id" : ObjectId("597a750696fd35621b4b85ee"), "count" : 19 }
Type "it" for more
你可以看到所有我们的文档都在这里。
现在,尝试向辅助服务器写入:
> secondaryDB.coll.insert({"count" : 1001})
WriteResult({ "writeError" : { "code" : 10107, "errmsg" : "not master" } })
> secondaryDB.coll.count()
1000
你可以看到辅助服务器不接受写操作。辅助服务器只会执行通过复制获取的写操作,而不会执行来自客户端的写操作。
还有一个有趣的功能需要你尝试:自动故障转移。如果主服务器宕机,其中一个辅助服务器将自动被选为主服务器。为测试此功能,请停止主服务器:
> db.adminCommand({"shutdown" : 1})
当你运行此命令时,你会看到一些错误消息,因为运行在端口 27017 上的mongod(我们连接到的成员)将终止,我们使用的 shell 会失去连接:
2017-07-27T20:10:50.612-0400 E QUERY [thread1] Error: error doing query:
failed: network error while attempting to run command 'shutdown' on host
'127.0.0.1:27017' :
DB.prototype.runCommand@src/mongo/shell/db.js:163:1
DB.prototype.adminCommand@src/mongo/shell/db.js:179:16
@(shell):1:1
2017-07-27T20:10:50.614-0400 I NETWORK [thread1] trying reconnect to
127.0.0.1:27017 (127.0.0.1) failed
2017-07-27T20:10:50.615-0400 I NETWORK [thread1] reconnect
127.0.0.1:27017 (127.0.0.1) ok
MongoDB Enterprise mdbDefGuide:SECONDARY>
2017-07-27T20:10:56.051-0400 I NETWORK [thread1] trying reconnect to
127.0.0.1:27017 (127.0.0.1) failed
2017-07-27T20:10:56.051-0400 W NETWORK [thread1] Failed to connect to
127.0.0.1:27017, in(checking socket for error after poll), reason:
Connection refused
2017-07-27T20:10:56.051-0400 I NETWORK [thread1] reconnect
127.0.0.1:27017 (127.0.0.1) failed failed
MongoDB Enterprise >
MongoDB Enterprise > secondaryConn.isMaster()
2017-07-27T20:11:15.422-0400 E QUERY [thread1] TypeError:
secondaryConn.isMaster is not a function :
@(shell):1:1
这不是问题。它不会导致 shell 崩溃。继续在辅助服务器上运行isMaster,看看谁已成为新的主服务器:
> secondaryDB.isMaster()
isMaster命令的输出应该看起来像这样:
{
"hosts" : [
"localhost:27017",
"localhost:27018",
"localhost:27019"
],
"setName" : "mdbDefGuide",
"setVersion" : 1,
"ismaster" : true,
"secondary" : false,
"primary" : "localhost:27018",
"me" : "localhost:27019",
"electionId" : ObjectId("7fffffff0000000000000005"),
"lastWrite" : {
"opTime" : {
"ts" : Timestamp(1501200681, 1),
"t" : NumberLong(5)
},
"lastWriteDate" : ISODate("2017-07-28T00:11:21Z")
},
"maxBsonObjectSize" : 16777216,
"maxMessageSizeBytes" : 48000000,
"maxWriteBatchSize" : 1000,
"localTime" : ISODate("2017-07-28T00:11:28.115Z"),
"maxWireVersion" : 6,
"minWireVersion" : 0,
"readOnly" : false,
"compression" : [
"snappy"
],
"ok" : 1,
"operationTime" : Timestamp(1501200681, 1)
}
注意,主服务器已切换到 27018 端口。你的主服务器可能是另一个服务器;首先注意到主服务器已下线的任何辅助服务器都将被选为主服务器。现在你可以向新的主服务器发送写操作。
小贴士
isMaster是一个非常古老的命令,早于 MongoDB 只支持主/从复制的复制集。因此,它在使用复制集术语时并不一致:它仍然将主服务器称为“master”。你可以通常将“master”等同于“primary”,将“slave”等同于“secondary”。
继续并重新启动我们在 localhost:27017 上运行的服务器。您只需要找到启动它的命令行界面。您将看到一些指示它已终止的消息。只需再次使用最初启动它时使用的相同命令来运行它。
恭喜!您刚刚设置、使用甚至稍微试验了一下复制集,以强制关闭并选举新的主服务器。
有几个关键概念需要记住:
-
客户端可以向主服务器发送与独立服务器相同的所有操作(读取、写入、命令、索引构建等)。
-
客户端无法向次要节点写入。
-
默认情况下,客户端无法从次要节点读取。您可以通过在连接上显式设置“我知道我正在从次要节点读取”选项来启用此功能。
更改您的复制集配置
复制集配置可以随时更改:可以添加、移除或修改成员。对于一些常见操作,有 shell 辅助工具。例如,要向集合添加新成员,可以使用 rs.add:
> rs.add("localhost:27020")
同样,您也可以删除成员:
> rs.remove("localhost:27017")
{ "ok" : 1, "operationTime" : Timestamp(1501202441, 2) }
您可以通过在 shell 中运行 rs.config() 来检查重新配置是否成功。它将打印当前配置:
> rs.config()
{
"_id" : "mdbDefGuide",
"version" : 3,
"protocolVersion" : NumberLong(1),
"members" : [
{
"_id" : 1,
"host" : "localhost:27018",
"arbiterOnly" : false,
"buildIndexes" : true,
"hidden" : false,
"priority" : 1,
"tags" : {
},
"slaveDelay" : NumberLong(0),
"votes" : 1
},
{
"_id" : 2,
"host" : "localhost:27019",
"arbiterOnly" : false,
"buildIndexes" : true,
"hidden" : false,
"priority" : 1,
"tags" : {
},
"slaveDelay" : NumberLong(0),
"votes" : 1
},
{
"_id" : 3,
"host" : "localhost:27020",
"arbiterOnly" : false,
"buildIndexes" : true,
"hidden" : false,
"priority" : 1,
"tags" : {
},
"slaveDelay" : NumberLong(0),
"votes" : 1
}
],
"settings" : {
"chainingAllowed" : true,
"heartbeatIntervalMillis" : 2000,
"heartbeatTimeoutSecs" : 10,
"electionTimeoutMillis" : 10000,
"catchUpTimeoutMillis" : -1,
"getLastErrorModes" : {
},
"getLastErrorDefaults" : {
"w" : 1,
"wtimeout" : 0
},
"replicaSetId" : ObjectId("597a49c67e297327b1e5b116")
}
}
每次更改配置时,"version"字段都会增加。它从版本 1 开始。
您还可以修改现有成员,而不仅仅是添加和删除它们。要进行修改,请在 shell 中创建您想要的配置文档,然后调用 rs.reconfig()。例如,假设我们有如下配置:
> rs.config()
{
"_id" : "testReplSet",
"version" : 2,
"members" : [
{
"_id" : 0,
"host" : "198.51.100.1:27017"
},
{
"_id" : 1,
"host" : "localhost:27018"
},
{
"_id" : 2,
"host" : "localhost:27019"
}
]
}
某人意外地通过 IP 地址添加了成员 0,而不是其主机名。要更改此设置,首先在 shell 中加载当前配置,然后更改相关字段:
> var config = rs.config()
> config.members[0].host = "localhost:27017"
现在配置文档正确后,我们需要使用 rs.reconfig() 辅助函数将其发送到数据库:
> rs.reconfig(config)
rs.reconfig() 在复杂操作(如修改成员配置或同时添加/删除多个成员)中通常比 rs.add() 和 rs.remove() 更有用。您可以使用它进行任何合法的配置更改:只需创建表示所需配置的配置文档,并将其传递给 rs.reconfig()。
如何设计一个集合
为了规划您的集合,您必须熟悉某些概念。下一章将更详细地讨论这些概念,但最重要的是复制集主要与多数派有关:您需要大多数成员来选举主服务器,主服务器只能在能够达到多数派时保持主要地位,并且在写入被复制到多数派时是安全的。这个多数派被定义为“集合中所有成员的一半以上”,如 表 10-1 所示。
表 10-1. 什么是多数派?
| 集合中的成员数量 | 集合的多数派 |
|---|---|
| 1 | 1 |
| 2 | 2 |
| 3 | 2 |
| 4 | 3 |
| 5 | 3 |
| 6 | 4 |
| 7 | 4 |
请注意,成员数量如何无关紧要或不可用;多数是基于集合的配置。
例如,假设我们有一个五成员集合,三个成员下线,如图 10-1 所示。仍然有两个成员在线。这两个成员无法达到集合的多数(至少三个成员),因此无法选举主节点。如果其中一个是主节点,它会在注意到无法达到多数后下台。几秒钟后,您的集合将由两个次要成员和三个不可达成员组成。

图 10-1。当集合中少数成员可用时,所有成员将成为次要节点。
许多用户觉得这很令人沮丧:为什么剩下的两个成员不能选举出一个主节点?问题在于,可能并非其他三个成员实际宕机,而是网络宕机,如图 10-2 所示。在这种情况下,左侧的三个成员将选举出一个主节点,因为他们能够达到集合的多数(五个成员中的三个)。在网络分区的情况下,我们不希望分区的两侧都选举出一个主节点,因为那样集合将会有两个主节点。两个主节点将都在写入数据库,并且数据集会发散。要求多数选举或保持主节点是避免出现多个主节点的一个巧妙方法。

图 10-2。对于成员来说,网络分区看起来与分区另一侧的服务器宕机是相同的。
非常重要的是,要配置您的集合,以便通常能够拥有一个主节点。例如,在这里描述的五成员集合中,如果成员 1、2 和 3 位于一个数据中心,成员 4 和 5 位于另一个数据中心,第一个数据中心几乎总是可以获得多数(在数据中心之间更有可能发生网络中断而非在它们内部)。
有几种常见的推荐配置:
-
在一个数据中心中占据大多数,如图 10-2 所示。如果您有一个主要数据中心,您总是希望将副本集的主节点放置在那里,这是一个很好的设计。只要您的主要数据中心健康,您就会有一个主节点。但是,如果该数据中心不可用,您的次要数据中心将无法选举新的主节点。
-
每个数据中心中有相同数量的服务器,加上第三个位置的决策性服务器。如果您的数据中心具有“相等”的偏好,这是一个很好的设计,因为通常来自任何数据中心的服务器都能看到集合的多数。但它涉及在服务器的三个单独位置。
更复杂的需求可能需要不同的配置,但您应该记住在逆境条件下,您的集合如何获得多数。
如果 MongoDB 支持多个主节点,则所有这些复杂性将消失。然而,这会带来自己的一系列复杂性。有了两个主节点,您必须处理冲突写入(例如,如果某人在一个主节点上更新文档,而在另一个主节点上删除它)。在支持多个写入者的系统中,处理冲突的两种流行方法是:手动协调或让系统随意选择“赢家”。这两个选项都不是开发人员编码的非常简单模型,因为您不能确定您编写的数据不会在您下面更改。因此,MongoDB 选择仅支持单个主节点。这使开发变得更简单,但可能导致复制集在某些时期变为只读状态。
选举工作原理
当次要节点无法联系主节点时,它将联系所有其他成员并请求被选为主节点。这些其他成员进行几项健全检查:它们是否能联系到寻求选举的成员无法联系的主节点?寻求选举的成员是否保持与复制的最新状态?是否有任何具有更高优先级的可用成员应该被选为主节点?
在版本 3.2 中,MongoDB 引入了复制协议的第一个版本。协议版本 1 基于 Diego Ongaro 和 John Ousterhout 在斯坦福大学开发的 RAFT 共识协议。它最适合描述为类似 RAFT 的协议,并专门设计以包括一些 MongoDB 特有的复制概念,如仲裁者、优先级、非投票成员、写入关注等。协议版本 1 为新功能提供了基础,如较短的故障切换时间,并极大地减少了检测错误主节点情况的时间。它还通过使用术语 ID 防止了双重投票。
注意
RAFT 是一个共识算法,可分为相对独立的子问题。共识是多个服务器或进程就数值达成一致的过程。RAFT 确保共识,使得相同的一系列命令产生相同的一系列结果,并在部署的成员之间达到相同的一系列状态。
复制集成员每两秒向彼此发送心跳(ping)。如果在 10 秒内没有从成员收到心跳,则其他成员将标记该不良成员为不可访问。选举算法将“尽力”尝试让优先级最高的次要成员发起选举。成员的优先级影响选举的时间和结果;具有较高优先级的次要成员相对较早地发起选举,并且更有可能获胜。然而,即使有更高优先级的次要实例可用,较低优先级的实例也可能在短时间内被选为主节点。复制集成员持续发起选举,直到可用的优先级最高的成员成为主节点为止。
要被选为主节点,成员必须在复制上保持最新状态,就像它能够到达的成员所知道的那样。所有复制的操作都严格按升序标识符排序,因此候选者的操作必须晚于或等于任何它能够到达的成员的操作。
成员配置选项
到目前为止,我们设置的副本集在每个成员的配置上都相当统一。然而,在许多情况下,你可能不希望成员完全相同:你可能希望一个成员优先成为主节点,或者使一个成员对客户端不可见,以便不会将读取请求路由到它。这些以及其他许多配置选项可以在副本集配置的成员子文档中指定。本节概述了可以设置的成员选项。
优先级
优先级是指这个成员“希望”成为主节点的程度。它的值可以从0到100,默认为1。将"priority"设置为0具有特殊含义:具有优先级为0的成员永远不能成为主节点。这些被称为passive成员。
优先级最高的成员将始终被选为主节点(只要它能够达到集合的大多数,并具有最新的数据)。例如,假设你向集合中添加了优先级为1.5的成员,如下所示:
> rs.add({"host" : "server-4:27017", "priority" : 1.5})
假设集合中的其他成员的优先级是1,一旦server-4赶上了集合的其他部分,当前的主节点将自动下台,server-4将选举自己。如果由于某种原因server-4无法赶上,当前的主节点将继续保持主节点状态。设置优先级永远不会导致你的集合没有主节点。它也永远不会导致落后的成员成为主节点(直到它赶上为止)。
"priority"的绝对值只关系到它是大于还是小于集合中其他优先级的值:具有优先级为100、1和1的成员将与具有优先级为2、1和1的另一个集合中的成员表现相同。
隐藏成员
客户端不会向隐藏成员发送请求,隐藏成员也不会作为复制源首选(尽管如果没有更理想的源可用,则会使用它们)。因此,许多人会隐藏较不强大或备用的服务器。
例如,假设你有一个看起来像这样的集合:
> rs.isMaster()
{
...
"hosts" : [
"server-1:27107",
"server-2:27017",
"server-3:27017"
],
...
}
要隐藏server-3,你可以在其配置中添加hidden: true字段。成员必须具有优先级0才能被隐藏(不能有隐藏的主节点):
> var config = rs.config()
> config.members[2].hidden = true
0
> config.members[2].priority = 0
0
> rs.reconfig(config)
现在运行isMaster将显示:
> rs.isMaster()
{
...
"hosts" : [
"server-1:27107",
"server-2:27017"
],
...
}
rs.status()和rs.config()仍会显示该成员;它只是在isMaster中消失。当客户端连接到副本集时,它们会调用isMaster来确定集合的成员。因此,隐藏成员永远不会用于读取请求。
要取消隐藏一个成员,将hidden选项更改为false或完全删除该选项。
选举仲裁者
两成员集在多数需求上有明显的缺点。然而,许多小型部署的人并不想保留三份数据副本,他们认为两份足够了,保留第三份副本并不值得管理、运维和财务成本。
对于这些部署,MongoDB 支持一种特殊类型的成员称为仲裁者,其唯一目的是参与选举。仲裁者不持有任何数据,也不被客户端使用:它们只是为两成员集提供多数。一般来说,没有仲裁者的部署更为理想。
由于仲裁者没有mongod服务器的传统责任,因此可以在比一般用于 MongoDB 的服务器更轻量的服务器上运行仲裁者作为一个轻量级进程。如果可能的话,通常建议将仲裁者运行在与其他成员不同的故障域中,这样它对集合有一个“外部视角”,正如在“如何设计一个集合”中描述的部署建议中所述。
启动仲裁者的方法与启动普通的mongod相同,使用--replSet *`name`*选项和一个空数据目录。您可以使用rs.addArb()助手将其添加到集合中:
> rs.addArb("server-5:27017")
同样地,您可以在成员配置中指定"arbiterOnly"选项:
> rs.add({"_id" : 4, "host" : "server-5:27017", "arbiterOnly" : true})
一旦将仲裁者添加到集合中,它将永远是仲裁者:您无法重新配置仲裁者成为非仲裁者,反之亦然。
仲裁者另一个好处是在更大的集群中打破平局。如果节点数量是偶数,可能会有一半节点投票支持一个成员,另一半支持另一个成员。仲裁者可以做出决定性的投票。然而,在使用仲裁者时有几件事情需要记住;我们接下来会讨论这些事情。
最多使用一个仲裁者
请注意,在刚刚描述的两种用例中,您最多只需要一个仲裁者。如果节点数为奇数,则不需要仲裁者。一个常见的误解似乎是应该“预防性地”添加额外的仲裁者。然而,增加额外的仲裁者既不能加快选举速度,也不能提供额外的数据安全性。
假设您有一个三成员集。需要两个成员来选举主节点。如果添加一个仲裁者,您将得到一个四成员集,因此需要三个成员来选择主节点。因此,您的集合可能不够稳定:现在不再需要 67%的集合在线,而是需要 75%。
添加额外的成员也可能导致选举时间更长。如果节点数量是偶数因为您添加了仲裁者,您的仲裁者可能导致平局,而非阻止它们。
使用仲裁者的缺点
如果您在数据节点和仲裁者之间可以选择,选择数据节点。在小型集群中使用仲裁者而不是数据节点可能会使一些操作任务更加困难。例如,假设您正在运行一个包含两个“普通”成员和一个仲裁者的副本集,并且其中一个数据持有成员宕机。如果该成员确实已经彻底死亡(数据不可恢复),则必须将数据从当前主服务器复制到将用作次要服务器的新服务器上。复制数据可能会给服务器带来很大压力,从而减慢应用程序的运行速度。(通常情况下,将几十个 GB 复制到新服务器是微不足道的,但超过一百个 GB 就变得不切实际了。)
相反,如果您有三个数据持有成员,如果一个服务器完全崩溃,会有更多的“呼吸空间”。您可以使用剩余的次要成员引导一个新的服务器,而不是依赖于主服务器。
在两成员加仲裁者的场景中,主服务器是您数据的最后一个良好副本,同时也是尝试处理应用程序负载的服务器,当您尝试将另一个数据副本上线时。
因此,如果可能的话,使用奇数个“普通”成员而不是仲裁者。
警告
在具有主-次-仲裁者(PSA)架构的三成员副本集或具有三成员 PSA 分片的分片集群中,存在一个已知问题:如果其中任何两个数据节点之一宕机,并启用了"majority"读关注,那么缓存压力会增加。理想情况下,对于这些部署,您应该用数据节点替换仲裁者。或者,为了防止存储缓存压力,可以在部署或分片的每个mongod实例上禁用"majority"读关注。
创建索引
有时,次要节点不需要与主节点上存在的索引相同(或者根本不需要索引)。如果您仅将次要节点用于备份数据或离线批处理作业,则可能需要在成员配置中指定"buildIndexes" : false。此选项可防止次要节点构建任何索引。
这是一个永久设置:已指定"buildIndexes" : false的成员将永远无法重新配置为“正常”索引构建成员。如果您想将非索引构建成员更改为索引构建成员,则必须从集合中删除它,删除其所有数据,然后将其重新添加到集合中,并允许其重新同步。
与隐藏成员一样,此选项要求成员的优先级为0。
^(1) 请参阅https://github.com/mongodb-the-definitive-guide-3e/mongodb-the-definitive-guide-3e。
第十一章:复制集的组件
本章介绍了复制集中各个部件的组成,包括:
-
复制集成员如何复制新数据
-
如何启动新成员的工作原理
-
选举的工作原理
-
可能的服务器和网络故障场景
同步
复制关注于在多个服务器上保持数据的相同副本。MongoDB 实现这一目标的方式是通过保持操作日志(oplog)的操作日志的记录,其中包含主服务器执行的每个写操作。这是一个固定大小的集合,存在于主服务器上的本地数据库中。从服务器查询此集合以获取要复制的操作。
每个从服务器维护自己的操作日志,记录从主服务器复制的每个操作。这使得任何成员都可以作为任何其他成员的同步源,如 图 11-1 所示。从服务器从它们同步的成员获取操作,将这些操作应用于其数据集,然后将这些操作写入它们的操作日志。如果应用操作失败(这只会在底层数据已损坏或以某种方式与主服务器不同的情况下发生),从服务器将退出。

图 11-1. 操作日志(oplog)保持已发生写操作的有序列表;每个成员都有其自己的操作日志副本,应该与主服务器的相同(除了一些滞后)。
如果任何原因导致辅助服务器宕机,在重新启动时,它将从其操作日志(oplog)中的最后一个操作开始同步。随着操作被应用到数据并写入操作日志,辅助服务器可能会重放已经应用到其数据的操作。MongoDB 设计上能够正确处理这种情况:多次重放操作日志操作会产生与单次重放相同的结果。操作日志中的每个操作都是幂等的。也就是说,操作日志操作无论应用一次还是多次到目标数据集,都会产生相同的结果。
因为操作日志(oplog)是固定大小的,它只能容纳一定数量的操作。一般来说,操作日志的使用空间速率大致与系统中写入的速率相同:如果在主服务器上每分钟写入 1 KB,你的操作日志可能以每分钟约 1 KB 的速度填满。然而,也有几个例外情况:影响多个文档的操作,如删除或多文档更新,将会被分解为多个操作日志条目。主服务器上的单个操作将被拆分为每个受影响文档一个操作日志条目。因此,如果你使用 db.coll.remove() 从集合中删除 1,000,000 个文档,它将变成 1,000,000 个逐个删除文档的操作日志条目。如果你执行大量的批量操作,这可能比你预期的更快地填满你的操作日志。
在大多数情况下,默认的操作日志大小已经足够。如果您能预测您的副本集工作负载类似以下哪种模式,那么您可能需要创建一个大于默认大小的操作日志。相反,如果您的应用程序主要执行读取操作,并且写入操作很少,则较小的操作日志可能足够。这些是可能需要较大操作日志大小的工作负载类型:
同时更新多个文档
操作日志必须将多个更新转换为单个操作,以保持幂等性。这可能会在不增加数据大小或磁盘使用的情况下使用大量操作日志空间。
删除和插入的数据量相等。
如果您删除的数据量与插入的数据量大致相等,则数据库在磁盘使用方面不会显著增长,但操作日志的大小可能会非常大。
大量的原地更新
如果工作负载的一个重要部分是更新操作,但文档大小不增加,数据库记录的操作数量很多,但磁盘上的数据量不会改变。
在mongod创建操作日志之前,您可以使用oplogSizeMB选项指定其大小。但是,在首次启动副本集成员之后,只能使用“更改操作日志大小”过程更改操作日志的大小。
MongoDB 使用两种形式的数据同步:初始同步用于将新成员填充完整数据集,复制用于将持续变化应用于整个数据集。让我们更详细地看看每个过程。
初始同步
MongoDB 执行初始同步,将所有数据从复制集的一个成员复制到另一个成员。当集合的成员启动时,它会检查是否处于可以开始从其他人同步的有效状态。如果处于有效状态,则会尝试从集合的另一个成员复制数据的完整副本。此过程包括几个步骤,您可以在mongod的日志中跟踪这些步骤。
首先,MongoDB 克隆除local数据库之外的所有数据库。 mongod会扫描每个源数据库中的每个集合,并将所有数据插入到目标成员的这些集合的副本中。在开始克隆操作之前,目标成员上的任何现有数据都将被删除。
警告
如果你不想在数据目录中存储数据或已将其移动到其他位置,只需为成员进行初始同步,因为mongod的第一步操作是将其全部删除。
在 MongoDB 3.4 及更高版本中,初始同步会在为每个集合复制文档时构建所有集合索引(在早期版本中,仅在此阶段构建"_id"索引)。在数据复制期间还会拉取新增的操作日志记录,因此您应确保目标成员在此数据复制阶段具有足够的磁盘空间来存储local数据库中的这些记录。
一旦所有数据库被克隆,mongod使用源的操作日志更新其数据集,以反映副本集的当前状态,将在复制过程中发生的所有更改应用于数据集。这些更改可能包括任何类型的写入(插入、更新和删除),这个过程可能意味着mongod必须重新克隆某些因为移动而被克隆器错过的文档。
如果某些文档必须重新克隆,则日志大致如下所示。根据同步源的流量级别和正在进行的操作类型,可能会有遗漏的对象或者不会有遗漏的对象。
Mon Jan 30 15:38:36 [rsSync] oplog sync 1 of 3
Mon Jan 30 15:38:36 [rsBackgroundSync] replSet syncing to: server-1:27017
Mon Jan 30 15:38:37 [rsSyncNotifier] replset setting oplog notifier to
server-1:27017
Mon Jan 30 15:38:37 [repl writer worker 2] replication update of non-mod
failed:
{ ts: Timestamp 1352215827000|17, h: -5618036261007523082, v: 2, op: "u",
ns: "db1.someColl", o2: { _id: ObjectId('50992a2a7852201e750012b7') },
o: { $set: { count.0: 2, count.1: 0 } } }
Mon Jan 30 15:38:37 [repl writer worker 2] replication info
adding missing object
Mon Jan 30 15:38:37 [repl writer worker 2] replication missing object
not found on source. presumably deleted later in oplog
在这一点上,数据应该与主服务器在某个时间点存在的数据集完全匹配。成员完成初始同步过程并转换为正常同步,允许其成为次要成员。
从操作员的角度来看,执行初始同步非常简单:只需启动一个干净的数据目录下的mongod。然而,通常更倾向于从备份中恢复,如第二十三章所述。从备份中恢复通常比通过mongod复制所有数据快。
此外,克隆可能会破坏同步源的工作集。许多部署最终会得到一个数据的子集,经常访问并始终在内存中(因为操作系统经常访问它)。执行初始同步会强制成员将其所有数据分页到内存中,驱逐经常使用的数据。这可能会显著减慢成员的速度,因为原本在 RAM 中处理的请求突然被迫访问磁盘。然而,对于小数据集和具有一定空间的服务器,初始同步是一个好的、简单的选择。
初次同步过程中,人们经常遇到的一个最常见问题是花费的时间过长。在这些情况下,新成员可能会“掉队”,无法跟上同步源的操作日志的末尾:它落后于同步源,因为同步源的操作日志已经覆盖了成员需要使用的数据,无法继续复制。
除了在较不忙的时候尝试初始同步或从备份中恢复外,没有其他修复此问题的方法。如果成员已经掉队同步源的操作日志,初始同步将无法继续。“处理过时数据”将更深入地讨论此问题。
复制
MongoDB 执行的第二种同步类型是复制。从节点在初始同步后持续复制数据。它们从其同步源复制操作日志,并在异步过程中应用这些操作。从节点可能会根据需要自动更改它们的同步源,以响应 ping 时间和其他成员复制状态的变化。有几条规则决定了给定节点可以从哪些成员进行同步。例如,具有一个投票的复制集成员不能从零投票的成员进行同步,而从节点避免从延迟成员和隐藏成员进行同步。选举和不同类别的复制集成员在后续部分中讨论。
处理过时状态
如果一个从节点在同步源执行的实际操作中落后太多,它将会变得过时。一个过时的从节点无法追赶上来,因为同步源的操作日志中每个操作都太过超前:如果继续同步,它将会跳过操作。这可能发生在从节点停机期间、写入操作过多超出其处理能力,或者处理读取请求过于繁忙。
当从节点过时时,它将依次尝试从集合中的每个成员复制数据,以查看是否有更长操作日志可以引导它进行引导式同步。如果没有任何一个成员有足够长的操作日志,该成员的复制将停止,并且需要完全重新同步(或者从最近的备份进行恢复)。
为了避免从节点不同步,有一个大的操作日志非常重要,这样主节点可以存储长时间的操作历史。一个较大的操作日志显然会使用更多的磁盘空间,但总体而言,这是一个很好的权衡,因为磁盘空间往往便宜,而且操作日志通常只有少量在使用,因此不会占用太多的内存。一个一般的经验法则是操作日志应该提供两到三天正常操作的覆盖范围(复制窗口)。有关调整操作日志大小的更多信息,请参阅“调整操作日志大小”。
心跳
成员需要了解其他成员的状态:谁是主节点、可以从谁同步,以及谁宕机。为了保持集合的最新视图,每个成员每两秒向集合中的每个其他成员发送一个心跳请求。心跳请求是一个简短的消息,用于检查每个人的状态。
心跳的最重要功能之一是让主节点知道它是否能够联系到大多数集合成员。如果主节点不能再联系到大多数服务器,它将自动降级并成为从节点(参见“如何设计一个集合”)。
成员状态
成员还通过心跳来传达它们的状态。我们已经讨论了两种状态:主节点和从节点。还有几种其他常见状态,你经常会看到成员处于其中:
启动
这是成员在首次启动时的状态,此时 MongoDB 正在尝试加载其副本集配置。一旦加载了配置,它将转换到 STARTUP2 状态。
STARTUP2
此状态持续整个初始同步过程,通常仅需几秒钟。成员分叉出几个线程来处理复制和选举,然后转入下一个状态:RECOVERING。
RECOVERING
这个状态表明成员操作正常,但不可用于读取。您可能会在各种情况下看到它。
在启动时,成员必须进行一些检查,以确保其处于有效状态,然后才能接受读取操作;因此,在成为从属之前,所有成员在启动时都会短暂地经历 RECOVERING 状态。在长时间运行的操作(如压缩)期间或响应于replSetMaintenance 命令时,成员也可能进入此状态。
如果一个成员落后于其他成员太多而无法追上,它也会进入 RECOVERING 状态。这通常是一个失败状态,需要重新同步该成员。此时成员不会进入错误状态,因为它依然希望某人在线上,有一个足够长的操作日志,可以使自己回到非陈旧状态。
ARBITER
仲裁者(参见“选举仲裁者”)具有特殊状态,在正常运行期间应始终处于此状态。
还有一些状态表示系统存在问题。这些包括:
DOWN
如果一个成员曾经在线但后来变得无法访问,它将进入此状态。请注意,报告为“down”的成员实际上可能仍然在线,只是由于网络问题无法访问。
UNKNOWN
如果一个成员从未能够联系另一个成员,它将不知道自己处于什么状态,因此会报告为 UNKNOWN。这通常表明未知成员已经宕机或两个成员之间存在网络问题。
REMOVED
这是一个从集合中删除的成员的状态。如果将已删除的成员重新添加到集合中,则它将重新转换为其“正常”状态。
ROLLBACK
此状态用于成员回滚数据,如“回滚”中所述。在回滚过程结束时,服务器将转换回 RECOVERING 状态,然后成为从属。
选举
如果一个成员无法达到主节点(并且本身有资格成为主节点),它将寻求选举。寻求选举的成员会向其能够联系到的所有成员发送通知。这些成员可能知道为什么此成员不适合作为主节点:它可能在复制中落后,或者可能已经有一个主节点,而此成员无法达到。在这些情况下,其他成员将反对候选者。
假设没有反对意见,其他成员将投票给寻求选举的成员。如果寻求选举的成员得到了大多数成员的投票,选举就成功了,该成员将过渡到主节点状态。如果没有获得多数票,它将保持次级状态,并可能稍后再次尝试成为主节点。主节点将保持主节点状态,直到无法与大多数成员通信、宕机、降级或设置重新配置。
假设网络健康且大多数服务器正常运行,选举应该很快。一旦某个成员注意到主节点宕机(由之前提到的心跳引起),它会立即开始选举,整个过程应该只需要几毫秒。然而,情况通常不尽如人意:可能由于网络问题或过载服务器响应缓慢而触发选举。在这些情况下,选举可能需要更长时间——甚至可能达到几分钟。
回滚
在上一节描述的选举过程中,如果一个主节点在次级节点有机会复制之前崩溃,下一个选出的主节点可能没有该写操作。例如,假设我们有两个数据中心,一个有主节点和一个次级节点,另一个有三个次级节点,如图 11-2 所示。

图 11-2. 可能的两数据中心配置
假设两个数据中心之间存在网络分区,如图 11-3 所示。第一个数据中心的服务器已经运行到操作 126,但该数据中心尚未将数据复制到另一个数据中心的服务器。

图 11-3. 跨数据中心的复制速度可能比单个数据中心内部慢
另一个数据中心的服务器仍然可以与一半以上的服务器(五台中的三台)通信。因此,它们中的一个可能会被选为主节点。这个新的主节点开始接受自己的写操作,如图 11-4 所示。

图 11-4. 未复制的写操作可能与网络分区另一侧的写操作不匹配
当网络修复后,第一个数据中心中的服务器将查找操作 126 以开始从其他服务器同步,但找不到它。在这种情况下,A 和 B 将开始一个名为 rollback 的进程。回滚用于撤消在故障转移前未复制的操作。其 oplogs 中具有操作 126 的服务器将回溯到另一个数据中心的服务器的 oplogs,寻找一个共同点。它们将发现操作 125 是最新匹配的操作。图 11-5 显示了 oplogs 的情况。显然,A 在复制操作 126−128 之前崩溃了,因此这些操作不在 B 上,而 B 具有更新的操作。A 在恢复同步之前必须回滚这三个操作。

图 11-5. 具有冲突操作日志的两个成员—最后一个公共操作是 125,因此由于 B 具有更新操作,A 需要回滚操作 126-128。
在此时,服务器将检查其操作,并将受这些操作影响的每个文档的版本写入数据目录的 rollback 目录中的 .bson 文件中。因此,例如,如果操作 126 是更新操作,则它将写入由 126 更新的文档到
以下是生成的典型回滚日志条目的粘贴:
Fri Oct 7 06:30:35 [rsSync] replSet syncing to: server-1
Fri Oct 7 06:30:35 [rsSync] replSet our last op time written: Oct 7
06:30:05:3
Fri Oct 7 06:30:35 [rsSync] replset source's GTE: Oct 7 06:30:31:1
Fri Oct 7 06:30:35 [rsSync] replSet rollback 0
Fri Oct 7 06:30:35 [rsSync] replSet ROLLBACK
Fri Oct 7 06:30:35 [rsSync] replSet rollback 1
Fri Oct 7 06:30:35 [rsSync] replSet rollback 2 FindCommonPoint
Fri Oct 7 06:30:35 [rsSync] replSet info rollback our last optime: Oct 7
06:30:05:3
Fri Oct 7 06:30:35 [rsSync] replSet info rollback their last optime: Oct 7
06:30:31:2
Fri Oct 7 06:30:35 [rsSync] replSet info rollback diff in end of log times:
-26 seconds
Fri Oct 7 06:30:35 [rsSync] replSet rollback found matching events at Oct 7
06:30:03:4118
Fri Oct 7 06:30:35 [rsSync] replSet rollback findcommonpoint scanned : 6
Fri Oct 7 06:30:35 [rsSync] replSet replSet rollback 3 fixup
Fri Oct 7 06:30:35 [rsSync] replSet rollback 3.5
Fri Oct 7 06:30:35 [rsSync] replSet rollback 4 n:3
Fri Oct 7 06:30:35 [rsSync] replSet minvalid=Oct 7 06:30:31 4e8ed4c7:2
Fri Oct 7 06:30:35 [rsSync] replSet rollback 4.6
Fri Oct 7 06:30:35 [rsSync] replSet rollback 4.7
Fri Oct 7 06:30:35 [rsSync] replSet rollback 5 d:6 u:0
Fri Oct 7 06:30:35 [rsSync] replSet rollback 6
Fri Oct 7 06:30:35 [rsSync] replSet rollback 7
Fri Oct 7 06:30:35 [rsSync] replSet rollback done
Fri Oct 7 06:30:35 [rsSync] replSet RECOVERING
Fri Oct 7 06:30:36 [rsSync] replSet syncing to: server-1
Fri Oct 7 06:30:36 [rsSync] replSet SECONDARY
服务器开始从另一个成员(在本例中是 server-1)同步,并意识到在同步源上找不到其最新操作。此时,它通过进入 ROLLBACK 状态 (replSet ROLLBACK) 开始回滚过程。
在步骤 2 中,它找到了两个 oplogs 之间的共同点,即 26 秒前。然后,它开始撤消其 oplog 中最后 26 秒的操作。一旦回滚完成,它将过渡到 RECOVERING 状态并开始正常同步。
要将已回滚的操作应用到当前主节点上,请首先使用 mongorestore 将它们加载到临时集合中:
$ mongorestore --db stage --collection stuff \
/data/db/rollback/important.stuff.2018-12-19T18-27-14.0.bson
使用 shell 检查文档,并将其与它们所属的集合的当前内容进行比较。例如,如果有人在回滚成员上创建了“普通”索引,并在当前主键上创建了唯一索引,则需要确保回滚数据中没有重复项,并在有重复时解决它们。
一旦您在暂存集合中获得满意的文档版本,请将其加载到主集合中:
> staging.stuff.find().forEach(function(doc) {
... prod.stuff.insert(doc);
... })
如果您有任何仅支持插入的集合,可以直接将回滚文档加载到该集合中。但是,如果您在集合上执行更新操作,则需要更加注意如何合并回滚数据。
一个经常被误用的成员配置选项是每个成员拥有的投票数。几乎总是不希望去操控投票数,并导致大量的回滚(这就是为什么它没有包含在上一章成员配置选项列表中)。除非你准备好处理常规回滚,否则不要更改投票数。
要了解更多关于预防回滚的信息,请参阅第十二章。
当回滚失败时
在较旧的 MongoDB 版本中,它可能会决定回滚的数据量太大而无法执行。自 MongoDB 版本 4.0 起,可以回滚的数据量没有限制。在 4.0 之前的版本中,如果有超过 300 MB 的数据或大约 30 分钟的操作需要回滚,回滚可能会失败。在这些情况下,必须重新同步陷入回滚状态的节点。
这种情况最常见的原因是当次要节点滞后而主节点宕机时。如果其中一个次要节点成为主节点,它将缺少旧主节点的许多操作。确保不会让成员陷入回滚的最佳方法是尽可能保持次要节点的最新状态。
第十二章:从您的应用程序连接到副本集
本章涵盖应用程序与副本集的交互,包括:
-
连接和故障转移的工作方式
-
等待写入复制完成
-
将读取路由到正确的成员
客户端到副本集连接行为
MongoDB 客户端库(MongoDB 术语中称为“驱动程序”)旨在管理与 MongoDB 服务器的通信,无论服务器是独立的 MongoDB 实例还是副本集。对于副本集,默认情况下,驱动程序将连接到主要成员并将所有流量路由到主要成员。您的应用程序可以像与独立服务器交互一样执行读取和写入操作,而您的副本集则在后台保持准备就绪的热备份。
连接到副本集的方式类似于连接到单个服务器。在您的驱动程序中使用MongoClient类(或等效类),并为驱动程序提供一个种子列表以连接到。种子列表只是一系列服务器地址。种子是您的应用程序将从中读取和写入数据的副本集成员。您不需要在种子列表中列出所有成员(尽管可以这样做)。当驱动程序连接到种子时,它将从它们那里发现其他成员。连接字符串通常如下所示:
"mongodb://server-1:27017,server-2:27017,server-3:27017"
有关详细信息,请参阅您的驱动程序文档。
要增强可靠性,您还应使用DNS 种子连接格式来指定应用程序如何连接到您的副本集。使用 DNS 的优势在于,托管 MongoDB 副本集成员的服务器可以轮换更改,而无需重新配置客户端(具体来说是它们的连接字符串)。
所有 MongoDB 驱动程序遵循服务器发现和监控(SDAM)规范。它们持续监视您的副本集的拓扑结构,以便检测应用程序能否访问集合的所有成员是否有任何变化。此外,驱动程序还监视集合以保持关于哪个成员是主要成员的信息。
副本集的目的是在面对网络分区或服务器宕机时使您的数据高度可用。在正常情况下,副本集通过选举新的主要成员来优雅地响应此类问题,以便应用程序可以继续读取和写入数据。如果主要成员宕机,驱动程序将自动找到新的主要成员(一旦选举出一个)并尽快将请求路由到它。然而,在没有可达的主要成员时,您的应用程序将无法执行写操作。
在选举期间可能短时间内没有主要成员,或者长时间内没有可达的成员能够成为主要成员。默认情况下,驱动程序在此期间不会处理任何请求(读取或写入)。如果对您的应用程序有必要,可以配置驱动程序使用次要成员进行读取请求。
一个常见的愿望是使驱动程序隐藏选举过程的全部细节(主节点失效和选出新的主节点)对用户不可见。然而,没有驱动程序以这种方式处理故障转移,原因有几个。首先,驱动程序只能在有限的时间内隐藏没有主节点的情况。其次,驱动程序通常是因为操作失败而发现主节点已经宕机,这意味着驱动程序无法确定主节点在宕机之前是否已处理了操作。这是一个基本的分布式系统问题,不可能避免,因此我们在问题出现时需要一种处理策略。如果新的主节点迅速选出,我们应该重试操作吗?假设它已经在旧的主节点上处理了?检查并查看新的主节点是否有该操作?
正确的策略,结果证明,最多重试一次。嗯?解释一下,让我们考虑一下我们的选择。这些可以归结为以下几点:不重试,在重试固定次数后放弃,或者最多重试一次。我们还需要考虑可能导致问题的错误类型。在尝试写入副本集时,可能出现三种类型的错误:瞬时网络错误,持久性中断(无论是网络还是服务器),或者由服务器拒绝的命令错误(例如,未经授权)。针对每种错误类型,让我们考虑我们的重试选项。
为了讨论的完整性,让我们以简单地增加计数器的写入为例。如果我们的应用程序试图增加计数器但未收到服务器的响应,我们不知道服务器是否收到了消息并执行了更新。因此,如果我们遵循不重试此写入的策略,对于瞬时网络错误,我们可能会计数不准确。对于持久性中断或命令错误,不重试是正确的策略,因为再次尝试写操作不会产生预期的效果。
如果我们遵循重试固定次数的策略,对于瞬时网络错误,我们可能会计数过多(在第一次尝试成功的情况下)。对于持久性中断或命令错误,多次重试只会浪费资源。
现在让我们看看只重试一次的策略。对于瞬时网络错误,我们可能会计数过多。对于持久性中断或命令错误,这是正确的策略。然而,如果我们能确保我们的操作是幂等的呢?幂等操作是指无论我们执行一次还是多次,其结果都相同。对于幂等操作,仅在网络错误时重试一次具有最佳处理所有三种错误类型的可能性。
从 MongoDB 3.6 起,服务器和所有 MongoDB 驱动程序都支持可重试写选项。请查阅您驱动程序的文档以获取有关如何使用此选项的详细信息。使用可重试写入时,驱动程序将自动采用最多一次重试的策略。命令错误将返回给应用程序以供客户端处理。网络错误将在适当的延迟后重试一次,以便在普通情况下适应主节点选举。启用可重试写入后,服务器将为每个写操作维护唯一标识符,因此可以确定驱动程序何时尝试重试已成功的命令。而不是再次应用写入,它将简单地返回一个表明写入成功的消息,从而解决由于暂时网络问题导致的问题。
等待写入的复制
根据您的应用程序需求,可能希望在服务器确认之前将所有写入复制到大多数副本集。在极少数情况下,如果副本集的主节点宕机,新选举的主节点(之前是副本)未能复制前主节点的最后写入,那么当前主节点恢复时将会回滚这些写入。这些写入可以恢复,但需要手动干预。对许多应用程序而言,少量写入回滚并非问题。例如,在博客应用程序中,回滚一两条读者评论几乎没有真正的危险性。
但是,对于其他应用程序,应尽量避免任何写入的回滚。假设您的应用程序将写入发送到主节点,并收到写入已成功的确认,但在任何副本有机会复制该写入之前,主节点崩溃了。您的应用程序认为它能够访问该写入,但副本集的当前成员却没有它的副本。
在某些时候,一个副本可能会被选为主节点并开始接受新的写入。当先前的主节点恢复时,它将发现自己有一些当前主节点没有的写入。为了纠正这一点,它将撤销任何与当前主节点操作顺序不匹配的写入。这些操作并未丢失,而是写入特殊的回滚文件中,需要手动应用到当前主节点上。由于可能与其他在宕机后发生的写入冲突,MongoDB 无法自动应用这些写入。因此,这些写入实际上会消失,直到管理员有机会将回滚文件应用到当前主节点上(详见第十一章有关回滚的更多详情)。
写入到大多数的要求防止了这种情况:如果应用程序收到确认写入成功的确认,那么新的主节点必须复制写入才能被选举(成员必须保持最新以被选举为主节点)。如果应用程序未收到服务器的确认或收到错误,则将知道需要再次尝试,因为写入在主节点崩溃之前未传播到大多数集合。
因此,为了确保写入被持久化,不管集群中发生什么情况,我们必须确保每次写入都传播到集合中大多数成员。我们可以使用writeConcern来实现这一点。
自 MongoDB 2.6 起,writeConcern与写操作集成在一起。例如,在 JavaScript 中,我们可以如下使用writeConcern:
try {
db.products.insertOne(
{ "_id": 10, "item": "envelopes", "qty": 100, type: "Self-Sealing" },
{ writeConcern: { "w" : "majority", "wtimeout" : 100 } }
);
} catch (e) {
print (e);
}
在你的驱动程序中的具体语法将根据编程语言而异,但语义保持不变。在这个例子中,我们指定了"majority"的写关注点。成功后,服务器将响应以下消息:
{ "acknowledged" : true, "insertedId" : 10 }
但是在此写入操作复制到复制集大多数成员之前,服务器将不会响应。只有在此写入操作成功复制到指定的超时时间内,我们的应用程序才会收到确认消息:
WriteConcernError({
"code" : 64,
"errInfo" : {
"wtimeout" : true
},
"errmsg" : "waiting for replication timed out"
})
写入关注点大多数和复制集选举协议确保在主节点选举事件中,只有更新了已确认写入的从节点才能被选为主节点。通过超时选项,我们还有一个可调整的设置,可以在应用程序层检测和标记任何长时间运行的写入。
其他关于“w”的选项
"majority"不是唯一的writeConcern选项。MongoDB 还允许您通过向"w"传递数字来指定要复制到的任意数量的服务器,如下所示:
db.products.insertOne(
{ "_id": 10, "item": "envelopes", "qty": 100, type: "Self-Sealing" },
{ writeConcern: { "w" : 2, "wtimeout" : 100 } }
);
这将等待两个成员(主节点和一个从节点)写入完成。
注意,"w"值包括主节点。如果要将写入传播到*`n`*个从节点,您应将"w"设置为*`n`*+1(包括主节点)。设置"`w`" : 1与根本不传递"w"选项相同,因为它只是检查写入是否在主节点上成功。
使用字面数字的缺点是,如果复制集配置更改,您必须更改应用程序。
自定义复制保证
向集合中的大多数写入被认为是“安全的”。然而,某些集合可能具有更复杂的要求:您可能希望确保写入至少传播到每个数据中心的一个服务器或大多数非隐藏节点。复制集允许您创建自定义规则,可以将其传递给"getLastError",以保证复制到所需的服务器组合。
确保每个数据中心有一个服务器
数据中心之间的网络问题比数据中心内部的问题要普遍得多,整个数据中心停电的可能性也比多个数据中心中的散落服务器停电的可能性要大得多。因此,您可能希望为写操作引入一些特定于数据中心的逻辑。在确认成功之前保证向每个数据中心写入数据意味着,如果写入后数据中心断电,其他每个数据中心都将至少有一份本地副本。
要设置此项,我们首先按数据中心对成员进行分类。我们通过向副本集配置中添加一个"tags"字段来完成这项任务:
> var config = rs.config()
> config.members[0].tags = {"dc" : "us-east"}
> config.members[1].tags = {"dc" : "us-east"}
> config.members[2].tags = {"dc" : "us-east"}
> config.members[3].tags = {"dc" : "us-east"}
> config.members[4].tags = {"dc" : "us-west"}
> config.members[5].tags = {"dc" : "us-west"}
> config.members[6].tags = {"dc" : "us-west"}
"tags"字段是一个对象,每个成员可以有多个标签。例如,它可能是"us-east"数据中心中的“高质量”服务器,此时我们希望"tags"字段如{"dc": "us-east", "quality" : "high"}。
第二步是通过在我们的副本集配置中创建一个"getLastErrorModes"字段来添加一条规则。名称"getLastErrorModes"在某种程度上是遗留的,因为在 MongoDB 2.6 之前,应用程序使用称为"getLastError"的方法来指定写入关注点。在副本配置中,对于"getLastErrorModes",每个规则的形式为"*`name`*" : {"*`key`*" : *`number`*}}。"*name*"是规则的名称,应该以客户端可以理解的方式描述规则的作用,因为在调用getLastError时,他们将使用此名称。在本例中,我们可以称此规则为"eachDC"或者更抽象的名称,如"user-level safe"。
"*`key`*"字段是标签中的键字段,因此在本例中,它将是"dc"。number是需要满足此规则的组数。在本例中,number为 2(因为我们希望至少从"us-east"和"us-west"各选取一个服务器)。number始终表示“至少从每个number组中选取一个服务器”。
我们按以下方式向副本集配置中添加"getLastErrorModes"并重新配置以创建此规则:
> config.settings = {}
> config.settings.getLastErrorModes = [{"eachDC" : {"dc" : 2}}]
> rs.reconfig(config)
"getLastErrorModes"位于副本集配置的"settings"子对象中,其中包含几个集级可选设置。
现在我们可以为写操作使用这条规则:
db.products.insertOne(
{ "_id": 10, "item": "envelopes", "qty": 100, type: "Self-Sealing" },
{ writeConcern: { "w" : "eachDC", wtimeout : 1000 } }
);
请注意,规则与应用开发者有些抽象:他们不必知道“每个 DC”中有哪些服务器才能使用该规则,并且该规则可以更改而无需更改其应用程序。我们可以添加一个数据中心或更改成员集合,而应用程序无需了解这些更改。
保证大多数非隐藏成员
通常,隐藏成员有点像二等公民:你永远不会将故障切换到它们,它们肯定不会进行任何读取操作。因此,您可能只关心非隐藏成员是否收到写入,并让隐藏成员自行处理。
假设我们有五个成员,host0 到 host4,其中 host4 是隐藏成员。我们想确保大多数非隐藏成员有写入权限,即至少 host0、host1、host2 和 host3 中的三个。为此,首先我们为每个非隐藏成员分配自己的标签:
> var config = rs.config()
> config.members[0].tags = [{"normal" : "A"}]
> config.members[1].tags = [{"normal" : "B"}]
> config.members[2].tags = [{"normal" : "C"}]
> config.members[3].tags = [{"normal" : "D"}]
隐藏成员 host4 没有被分配标签。
现在我们为大多数这些服务器添加一个规则:
> config.settings.getLastErrorModes = [{"visibleMajority" : {"normal" : 3}}]
> rs.reconfig(config)
最后,我们可以在我们的应用程序中使用这个规则:
db.products.insertOne(
{ "_id": 10, "item": "envelopes", "qty": 100, type: "Self-Sealing" },
{ writeConcern: { "w" : "visibleMajority", wtimeout : 1000 } }
);
这将等待至少三个非隐藏成员进行写入。
创建其他的保证。
你可以创建的规则是没有限制的。记住创建自定义复制规则有两个步骤:
-
通过分配键值对为成员打上标签。键描述分类,例如
"data_center"、"region"或"serverQuality"。值确定服务器在分类内属于哪个组。例如,对于键"data_center",你可能会有一些服务器标记为"us-east",一些标记为"us-west",还有其他的标记为"aust"。 -
基于你创建的分类创建一个规则。规则总是采用这种形式
{"*`name`*" : {"*`key`*" : *`number`*}},其中至少来自number组的一个服务器必须在成功之前写入。例如,你可以创建一个规则{"twoDCs" : {"data_center" : 2}},这意味着至少有两个被标记的数据中心中的一个服务器必须确认写入成功。
然后你可以在 getLastErrorModes 中使用这个规则。
规则是配置复制的强大方式,尽管理解和设置它们很复杂。除非你有相当复杂的复制需求,否则坚持使用 "w" : "majority" 是非常安全的选择。
向次要节点发送读请求
默认情况下,驱动程序将所有请求路由到主节点。这通常是你想要的,但你可以通过在驱动程序中设置读取偏好来配置其他选项。读取偏好允许你指定查询应该发送到哪些类型的服务器。
通常向次要节点发送读请求是一个不好的做法。在某些特定情况下,这样做是有意义的,但你应该在允许之前非常仔细地权衡利弊。本节涵盖了为什么这是一个不好的想法以及在何种特定条件下这样做是有意义的。
一致性考虑
需要强一致性读取的应用程序不应从次要节点读取。
辅助节点通常应与主节点相差几毫秒。然而,并没有这方面的保证。有时候,由于负载、配置错误、网络错误或其他问题,辅助节点可能会落后数分钟、数小时甚至数天。客户端库无法判断辅助节点的实时性,因此客户端可能会愉快地将查询发送到远远落后的辅助节点。可以隐藏辅助节点以防止客户端读取,但这是一个手动过程。因此,如果您的应用程序需要可预测的实时数据,就不应该从辅助节点读取。
如果您的应用程序需要读取自己的写入(例如,插入文档然后查询并找到它),则不应将读取操作发送到辅助节点(除非写入使用"w"等待所有辅助节点的复制,如前所示)。否则,应用程序可能会执行成功的写入操作,尝试读取该值,但却找不到它(因为它将读取操作发送到尚未复制的辅助节点)。客户端可以比复制操作更快地发出请求。
要始终将读取请求发送到主节点,请将读取首选项设置为primary(或者保持不变,因为primary是默认设置)。如果没有主节点,查询将报错。这意味着如果主节点宕机,则您的应用程序无法执行查询。但是,如果您的应用程序可以在故障转移或网络分区期间处理停机时间,或者不能接受获取过时数据,这绝对是一个可接受的选项。
负载考虑
许多用户将读取操作发送到辅助节点以分担负载。例如,如果您的服务器每秒只能处理 10,000 个查询,而您需要处理 30,000 个查询,您可能会设置几个辅助节点并让它们承担部分负载。然而,这种扩展的方式存在危险性,因为很容易意外地过载系统,并且一旦过载,很难从中恢复。
例如,假设您有刚才描述的情况:每秒 30,000 次读取。您决定创建一个包含四个成员的复制集(其中一个将配置为非投票成员,以防止选举中的平局)来处理此问题:每个辅助节点都远低于其最大负载,并且系统运行良好。
直到其中一个辅助节点崩溃。
现在剩下的每个成员都在处理他们可能的全部负载。如果需要重建崩溃的成员,可能需要从其他服务器中复制数据,从而使剩余的服务器负担过重。服务器过载通常会使其性能下降,进一步降低集合的容量,并迫使其他成员承担更多负载,导致它们陷入一个越来越慢的螺旋式崩溃。
过载还会导致复制速度减慢,使其余辅助节点落后。突然间,一个成员崩溃,一个成员滞后,而整个系统过载到没有任何余地。
如果你对服务器可以承受多大负载有一个良好的了解,你可能会觉得你可以更好地计划这一点:使用五台服务器而不是四台,即使有一台故障,整个系统也不会过载。然而,即使你计划得很完美(并且只丢失了你预期的服务器数量),你仍然需要处理其他服务器在比预期更大的压力下的情况。
更好的选择是使用分片来分发负载。我们将在第十四章中介绍如何设置分片。
从次要节点读取的原因
有几种情况下将应用程序读取发送到次要节点是合理的。例如,如果主节点宕机(而你不在乎这些读取可能有些陈旧),你可能希望你的应用程序仍然能执行读取操作。这是将读取分发到次要节点的最常见情况:当你的集群失去主节点时,你希望有一个临时的只读模式。这种读取偏好被称为primaryPreferred。
从次要节点读取的一个常见论点是获得低延迟读取。你可以将nearest作为你的读取偏好,根据从驱动程序到副本集成员的平均 ping 时间路由请求到最低延迟的成员。如果你的应用程序需要在多个数据中心中低延迟访问相同的文档,这是唯一的方法。然而,如果你的文档更加基于位置(此数据中心的应用服务器需要低延迟访问一些数据,或者另一个数据中心的应用服务器需要低延迟访问其他数据),这应该通过分片来完成。请注意,如果你的应用程序要求低延迟读取和写入,你必须使用分片:副本集只允许写入到一个位置(主节点所在位置)。
如果你从可能尚未复制所有写入的成员读取数据,你必须愿意牺牲一致性。或者,如果你希望等待写入已经复制到所有成员再进行写入,你可以牺牲写入速度。
如果你的应用程序可以接受任意陈旧的数据,你可以使用secondary或secondaryPreferred读取偏好。secondary总是将读取请求发送到次要节点。如果没有次要节点可用,它会报错而不是将读取请求发送到主节点。这可以用于那些不关心陈旧数据并且只想使用主节点进行写入的应用程序。如果你对数据的陈旧性有任何顾虑,不建议使用这种方法。
secondaryPreferred会将读取请求发送到次要节点(如果有)。如果没有次要节点可用,请求将被发送到主节点。
有时,读取负载与写入负载显著不同——即,您读取的数据完全不同于您写入的数据。您可能希望为离线处理设置数十个索引,这些索引不希望在主要数据库上存在。在这种情况下,您可能希望设置一个具有不同索引的次要数据库。如果您想要为此目的使用次要数据库,您可能会直接从驱动程序创建连接,而不是使用复制集连接。
考虑哪种选项适合您的应用程序。您还可以结合选项使用:如果某些读取请求必须来自主要数据库,请使用primary。如果您可以接受其他读取请求不具有最新数据,请为这些请求使用primaryPreferred。如果某些请求需要低延迟而不需要一致性,请为这些请求使用nearest。
第十三章:管理
本章涵盖副本集管理,包括:
-
对各个成员执行维护
-
在各种情况下配置集合
-
获取关于和调整你的操作日志(oplog)大小的信息
-
进行一些更复杂的集合配置
-
转换从主/从(master/slave)到副本集
在独立模式下启动成员
许多维护任务无法在辅助节点上执行(因为它们涉及写操作),也不应该在主节点上执行,因为这可能会影响应用程序性能。因此,以下各节经常提到启动服务器为独立模式。这意味着重新启动成员,使其成为独立服务器,而不是副本集成员(暂时)。
要在独立模式下启动成员,请首先查看用于启动它的命令行选项。假设它们看起来像这样:
> db.serverCmdLineOpts()
{
"argv" : [ "mongod", "-f", "/var/lib/mongod.conf" ],
"parsed" : {
"replSet": "mySet",
"port": "27017",
"dbpath": "/var/lib/db"
},
"ok" : 1
}
要在该服务器上执行维护操作,我们可以在不使用replSet选项的情况下重新启动它。这将允许我们像普通独立的mongod一样读写它。我们不希望集合中的其他服务器能够联系到它,因此我们将使其监听不同的端口(以便其他成员无法找到它)。最后,我们希望保持dbpath不变,因为我们假定以这种方式启动它来某种方式地操作服务器的数据。
首先,我们从mongo shell 中关闭服务器:
> db.shutdownServer()
然后,在操作系统 shell(例如,bash)中,我们在另一个端口上重新启动mongod并且不使用replSet参数:
$ mongod --port 30000 --dbpath /var/lib/db
现在它将作为独立服务器运行,监听端口 30000 以进行连接。该集合的其他成员将尝试在端口 27017 上连接它并认为它已经停止。
当我们完成对服务器的维护时,我们可以关闭它并使用其原始选项重新启动它。它将自动与集合的其余部分同步,复制其在“离线”期间错过的任何操作。
副本集配置
副本集配置始终保留在local.system.replset集合的文档中。此文档在集合的所有成员上都相同。永远不要使用update来更新此文档。始终使用rs助手或replSetReconfig命令。
创建副本集
通过启动你想要作为成员的mongods并通过rs.initiate()传递配置来创建副本集:
> var config = {
... "_id" : <setName>,
... "members" : [
... {"_id" : 0, "host" : <host1>},
... {"_id" : 1, "host" : <host2>},
... {"_id" : 2, "host" : <host3>}
... ]}
> rs.initiate(config)
警告
你应该总是向rs.initiate()传递一个配置对象。如果不这样做,MongoDB 将尝试自动生成一个单成员副本集的配置;它可能不使用你想要的主机名或者正确配置该集合。
你只在集合的一个成员上调用rs.initiate()。接收配置的成员将将其传递给其他成员。
更改集合成员
当你添加一个新的集合成员时,它应该在其数据目录中要么为空—这种情况下它将执行初始同步—要么拥有来自另一个成员的数据的副本(有关备份和恢复复制集成员的更多信息,请参阅第二十三章)。
连接到主服务器,并按以下方式添加新成员:
> rs.add("spock:27017")
或者,你可以将更复杂的成员配置指定为一个文档:
> rs.add({"host" : "spock:27017", "priority" : 0, "hidden" : true})
你也可以通过它们的 "host" 字段移除成员:
> rs.remove("spock:27017")
你可以通过重新配置来更改成员的设置。在更改成员设置时有一些限制:
-
你不能改变成员的
"_id"。 -
你不能使你发送重新配置的成员(通常是主服务器)的优先级为 0。
-
你不能将裁判转换为非裁判,反之亦然。
-
你不能将成员的
"buildIndexes"字段从false改为true。
值得注意的是,你可以改变成员的 "host" 字段。因此,如果你错误地指定了主机(比如说,如果你使用了公共 IP 而不是私有 IP),你可以稍后返回并简单地更改配置以使用正确的 IP。
要更改主机名,你可以像这样做:
> var config = rs.config()
> config.members[0].host = "spock:27017"
spock:27017
> rs.reconfig(config)
这个策略同样适用于改变任何其他选项:获取配置信息 rs.config(),修改任何你希望修改的部分,然后通过传递新配置给 rs.reconfig() 来重新配置集合。
创建更大的集合
复制集总共限制为 50 个成员,只能有 7 个投票成员。这是为了减少每个人需要相互心跳的网络流量,并限制选举所需的时间。
如果你正在创建一个超过七个成员的复制集,那么每个额外的成员必须被赋予零票。你可以通过在成员配置中指定来做到这一点:
> rs.add({"_id" : 7, "host" : "server-7:27017", "votes" : 0})
这将阻止这些成员在选举中投出正面票。
强制重新配置
当你永久丢失了一个集合的大部分时,你可能希望在没有主服务器的情况下重新配置该集合。这有点棘手,因为通常你会把重新配置发送到主服务器。但在这种情况下,你可以通过向次级服务器发送带有 "force" 选项的重新配置命令来强制重新配置集合。在 shell 中连接到次级服务器,并传递一个带有 "force" 选项的重新配置:
> rs.reconfig(config, {"force" : true})
强制重新配置遵循与正常重新配置相同的规则:你必须发送一个有效且格式良好的配置,带有正确的选项。"force" 选项不允许无效的配置;它只允许次级服务器接受重新配置。
强制重新配置会显著增加复制集的 "version" 数字。你可能会看到它跳动几万甚至几十万。这是正常的:它是为了防止版本号冲突(以防网络分区的情况下有重新配置)。
当次要成员接收到重新配置时,它将更新其配置并将新配置传递给其他成员。如果其他集合成员将发送服务器识别为其当前配置的成员,它们才能检测到配置更改。因此,如果您的一些成员已更改主机名,您应该从保留其旧主机名的成员强制重新配置。如果每个成员都有新的主机名,您应该关闭集合中的每个成员,在独立模式下启动一个新的成员,手动更改其local.system.replset文档,然后重新启动成员。
操控成员状态
有几种方式可以手动更改成员的状态以进行维护或响应负载。请注意,除了通过适当配置集合来配置副本集成员的优先级高于任何其他成员之外,没有其他方法可以强制成员成为主服务器。
将主服务器转为次要服务器
您可以使用stepDown函数将主服务器降级为次要服务器:
> rs.stepDown()
这使得主服务器降为次要状态,持续 60 秒。如果在该时间段内没有选举出其他主服务器,它将能够尝试重新选举。如果您希望它在次要状态下停留更长或更短的时间,您可以为其指定自己的秒数:
> rs.stepDown(600) // 10 minutes
防止选举
如果您需要对主服务器进行一些维护,但不希望其他符合条件的成员在此期间成为主服务器,您可以通过在每个成员上运行freeze来强制它们保持次要状态:
> rs.freeze(10000)
同样,这需要一些时间使成员保持次要状态。
如果您在此时间段到期之前完成了对主服务器的任何维护,并希望解冻其他成员,只需在每个成员上再次运行该命令,指定超时为 0 秒即可。
> rs.freeze(0)
一个未冻结的成员将能够进行选举,如果它选择这样做。
您还可以通过运行rs.freeze(0)来解冻已被降级为次要服务器的主服务器。
监控复制
能够监视集合的状态非常重要:不仅要确保所有成员处于活动状态,还要知道它们的状态及复制的最新情况。有几个命令可用于查看副本集信息。MongoDB 托管服务和管理工具包括 Atlas、Cloud Manager 和 Ops Manager(参见第二十二章)还提供了监视复制和关键复制指标的仪表板机制。
复制问题通常是暂时的:一个服务器可能无法连接另一个服务器,但现在可以了。查看日志是发现这类问题的最简单方法。确保您知道日志存储在哪里(以及确实被存储),并且可以访问它们。
获取状态
您可以运行的最有用的命令之一是 replSetGetStatus,它获取集合的每个成员的当前信息(从您运行它的成员的视角)。在 shell 中有一个此命令的辅助程序:
> rs.status()
"set" : "replset",
"date" : ISODate("2019-11-02T20:02:16.543Z"),
"myState" : 1,
"term" : NumberLong(1),
"heartbeatIntervalMillis" : NumberLong(2000),
"optimes" : {
"lastCommittedOpTime" : {
"ts" : Timestamp(1478116934, 1),
"t" : NumberLong(1)
},
"readConcernMajorityOpTime" : {
"ts" : Timestamp(1478116934, 1),
"t" : NumberLong(1)
},
"appliedOpTime" : {
"ts" : Timestamp(1478116934, 1),
"t" : NumberLong(1)
},
"durableOpTime" : {
"ts" : Timestamp(1478116934, 1),
"t" : NumberLong(1)
}
},
"members" : [
{
"_id" : 0,
"name" : "m1.example.net:27017",
"health" : 1,
"state" : 1,
"stateStr" : "PRIMARY",
"uptime" : 269,
"optime" : {
"ts" : Timestamp(1478116934, 1),
"t" : NumberLong(1)
},
"optimeDate" : ISODate("2019-11-02T20:02:14Z"),
"infoMessage" : "could not find member to sync from",
"electionTime" : Timestamp(1478116933, 1),
"electionDate" : ISODate("2019-11-02T20:02:13Z"),
"configVersion" : 1,
"self" : true
},
{
"_id" : 1,
"name" : "m2.example.net:27017",
"health" : 1,
"state" : 2,
"stateStr" : "SECONDARY",
"uptime" : 14,
"optime" : {
"ts" : Timestamp(1478116934, 1),
"t" : NumberLong(1)
},
"optimeDurable" : {
"ts" : Timestamp(1478116934, 1),
"t" : NumberLong(1)
},
"optimeDate" : ISODate("2019-11-02T20:02:14Z"),
"optimeDurableDate" : ISODate("2019-11-02T20:02:14Z"),
"lastHeartbeat" : ISODate("2019-11-02T20:02:15.618Z"),
"lastHeartbeatRecv" : ISODate("2019-11-02T20:02:14.866Z"),
"pingMs" : NumberLong(0),
"syncingTo" : "m3.example.net:27017",
"configVersion" : 1
},
{
"_id" : 2,
"name" : "m3.example.net:27017",
"health" : 1,
"state" : 2,
"stateStr" : "SECONDARY",
"uptime" : 14,
"optime" : {
"ts" : Timestamp(1478116934, 1),
"t" : NumberLong(1)
},
"optimeDurable" : {
"ts" : Timestamp(1478116934, 1),
"t" : NumberLong(1)
},
"optimeDate" : ISODate("2019-11-02T20:02:14Z"),
"optimeDurableDate" : ISODate("2019-11-02T20:02:14Z"),
"lastHeartbeat" : ISODate("2019-11-02T20:02:15.619Z"),
"lastHeartbeatRecv" : ISODate("2019-11-02T20:02:14.787Z"),
"pingMs" : NumberLong(0),
"syncingTo" : "m1.example.net:27018",
"configVersion" : 1
}
],
"ok" : 1
}
这些是一些最有用的字段之一:
"self"
此字段仅出现在运行 rs.status() 的成员中——在本例中,即 server-2 (m1.example.net:27017)。
"stateStr"
描述服务器状态的字符串。有关各种状态的描述,请参阅“成员状态”。
"uptime"
成员可达的秒数,或者对于 "self" 成员来说,自服务器启动以来的时间。因此,server-1 已经运行了 269 秒,server-2 和 server-3 运行了 14 秒。
"optimeDate"
每个成员操作日志中每个成员的最后 optime(该成员同步到的地方)。请注意,这是由心跳报告的每个成员的状态,因此这里报告的 optime 可能会比实际少几秒钟。
"lastHeartbeat"
此服务器上次从 "self" 成员接收到心跳的时间。如果存在网络问题或服务器忙碌,这可能比两秒钟之前更长。
"pingMs"
向此服务器发出心跳所花费的平均时间。这用于确定从哪个成员进行同步。
"errmsg"
成员选择在心跳请求中返回的任何状态消息。这些通常只是信息性的,不是错误消息。例如,server-3 中的 "errmsg" 字段指示此服务器正在进行初始同步的过程中。十六进制数字 507e9a30:851 是此成员需要达到以完成初始同步的操作的时间戳。
有几个字段提供了重叠的信息。"state" 与 "stateStr" 相同;它只是状态的内部 ID。"health" 只是反映了给定服务器是否可达(1)或不可达(0),这也可以通过 "state" 和 "stateStr" 来显示(如果服务器不可达,则它们将显示为 UNKNOWN 或 DOWN)。类似地,"optime" 和 "optimeDate" 是以两种方式表示的相同值:一个表示自纪元以来的毫秒数("t" : 135...),另一个是更易读的日期。
警告
注意,此报告是从您运行它的集合成员的视角来看的:它包含的信息可能由于网络问题而不正确或过时。
可视化复制图
如果在辅助节点上运行 rs.status(),将会有一个顶级字段称为 "syncingTo"。这显示了此成员正在复制的主机。通过在集合的每个成员上运行 replSetGetStatus 命令,您可以了解复制图。例如,假设 server1 是连接到 server1,server2 是连接到 server2,依此类推,可能会有如下内容:
> server1.adminCommand({replSetGetStatus: 1})['syncingTo']
server0:27017
> server2.adminCommand({replSetGetStatus: 1})['syncingTo']
server1:27017
> server3.adminCommand({replSetGetStatus: 1})['syncingTo']
server1:27017
> server4.adminCommand({replSetGetStatus: 1})['syncingTo']
server2:27017
因此,server0是server1的复制源,server1是server2和server3的复制源,server2是server4的复制源。
MongoDB 根据 ping 时间确定同步到哪个成员。当一个成员心跳另一个成员时,它计算该请求花费的时间。MongoDB 会维护这些时间的运行平均值。当一个成员必须选择另一个成员进行同步时,它会选择距离自己最近且在复制中领先的成员(因此,不能出现复制循环:成员只会从主服务器或进展较快的辅助服务器进行复制)。
这意味着,如果您在次要数据中心中启动了新的成员,它更有可能从该数据中心的另一个成员进行同步,而不是从主数据中心的成员进行同步(从而最小化广域网流量),如图 13-1 所示。
然而,自动复制链的一个缺点是:复制的跳数越多,将写操作复制到所有服务器的时间就会稍长一些。例如,假设所有内容都在一个数据中心,但由于网络速度的不确定性,当您添加成员时,MongoDB 最终以线性方式进行复制,如图 13-2 所示。

图 13-1. 新的辅助服务器通常会选择从同一数据中心的成员进行同步

图 13-2. 随着复制链变得越来越长,所有成员获取数据的时间也会变得更长
这种情况极为罕见,但并非不可能。然而,这可能是不可取的:链中的每个辅助服务器都比其前面的辅助服务器落后一些。您可以通过使用replSetSyncFrom命令(或rs.syncFrom()辅助工具)来修改成员的复制源来解决这个问题。
连接到要更改其复制源的辅助服务器,并运行以下命令,传递您希望此成员同步的服务器:
> secondary.adminCommand({"replSetSyncFrom" : "server0:27017"})
切换同步源可能需要几秒钟,但如果您再次在该成员上运行rs.status(),您应该会看到"syncingTo"字段现在显示为"server0:27017"。
此成员(server4)现在将从server0继续复制,直到server0不可用或者(如果它恰好是辅助服务器)落后于其他成员。
复制环路
复制环路是指成员最终彼此进行复制,例如,A从B同步,B从C同步,C又从A同步。由于复制环路中的成员都不能成为主服务器,因此这些成员将无法接收到任何新的操作来进行复制,并且会落后。
复制环路应该是不可能的,当成员选择自动同步时,但是,你可以使用 replSetSyncFrom 命令强制创建复制环路。在手动更改同步目标之前,仔细检查 rs.status() 输出,并注意不要创建环路。如果您不选择严格领先的成员进行同步,replSetSyncFrom 命令将会提醒您,但仍将允许该操作。
禁用链式同步
链式同步 是指次要服务器从另一个次要服务器同步(而不是从主服务器)。如前所述,成员可能决定自动从其他成员同步。您可以通过将 "chainingAllowed" 设置为 false(如果未指定,默认为 true)来禁用链式同步,强制所有成员从主服务器同步:
> var config = rs.config()
> // create the settings subobject, if it does not already exist
> config.settings = config.settings || {}
> config.settings.chainingAllowed = false
> rs.reconfig(config)
当 "chainingAllowed" 设置为 false 时,所有成员将从主服务器同步。如果主服务器不可用,它们将退回到从次要服务器同步。
计算滞后
对于复制而言,最重要的度量之一是跟踪次要服务器与主服务器的同步情况。滞后 是指次要服务器落后于主服务器执行的最后一个操作的时间戳与次要服务器应用的最后一个操作的时间戳之间的差异。
您可以使用 rs.status() 查看成员的复制状态,但也可以通过运行 rs.printReplicationInfo() 或 rs.printSlaveReplicationInfo() 获取快速摘要。
rs.printReplicationInfo() 提供了主要操作日志的摘要,包括其大小和操作的日期范围:
> rs.printReplicationInfo();
configured oplog size: 10.48576MB
log length start to end: 3590 secs (1.00hrs)
oplog first event time: Tue Apr 10 2018 09:27:57 GMT-0400 (EDT)
oplog last event time: Tue Apr 10 2018 10:27:47 GMT-0400 (EDT)
now: Tue Apr 10 2018 10:27:47 GMT-0400 (EDT)
在此示例中,操作日志约为 10 MB(10 MiB),仅能够容纳约一小时的操作。
如果这是一个真实的部署,操作日志(oplog)可能应该更大(请参阅下一节有关更改 oplog 大小的说明)。我们希望日志长度至少与执行完整重新同步所需的时间一样长。这样,我们就不会遇到在次要服务器完成初始同步之前,次要服务器已经落后于操作日志结尾的情况。
注意
日志长度是通过在操作日志填满后计算第一个和最后一个操作之间的时间差来计算的。如果服务器刚刚启动且操作日志中没有任何内容,则最早的操作可能相对较新。在这种情况下,尽管操作日志可能仍然有空闲空间可用,但日志长度较小。对于已经运行足够长时间以至少完整写入其整个操作日志的服务器来说,长度是一个更有用的度量。
您还可以使用 rs.printSlaveReplicationInfo() 函数获取每个成员的 syncedTo 值以及最后一个操作日志条目写入每个次要服务器的时间,如下例所示:
> rs.printSlaveReplicationInfo();
source: m1.example.net:27017
syncedTo: Tue Apr 10 2018 10:27:47 GMT-0400 (EDT)
0 secs (0 hrs) behind the primary
source: m2.example.net:27017
syncedTo: Tue Apr 10 2018 10:27:43 GMT-0400 (EDT)
0 secs (0 hrs) behind the primary
source: m3.example.net:27017
syncedTo: Tue Apr 10 2018 10:27:39 GMT-0400 (EDT)
0 secs (0 hrs) behind the primary
请记住,副本集成员的滞后是相对于主节点计算的,而不是相对于“墙上的时间”。这通常是无关紧要的,但在非常低写入系统上,这可能会导致幻影复制滞后“峰值”。例如,假设你每小时写入一次。在该写入之后,但在其被复制之前,次要节点看起来落后主节点一小时。然而,它将能够在几毫秒内赶上那“小时”的操作。在监视低吞吐量系统时,这有时可能会导致混淆。
调整操作日志大小
你的主要操作日志应该被视为维护窗口。如果你的主要操作日志长度为一小时,那么在你的次要节点落后太多并必须从头同步之前,你只有一小时来修复任何出现的问题。因此,通常希望操作日志能够保存几天到一周的数据,以便在出现问题时有所缓冲。
不幸的是,没有简单的方法可以预测操作日志在填满之前会有多长。WiredTiger 存储引擎允许在服务器运行时在线调整操作日志的大小。你应该先在每个次要副本集成员上执行这些步骤;一旦这些步骤完成,然后才应该对主节点进行更改。请记住,每个可能成为主节点的服务器都应该有足够大的操作日志,以便为你提供合理的维护窗口。
增加操作日志(oplog)的大小,请执行以下步骤:
-
连接到副本集成员。如果启用了认证,请确保使用具有修改
local数据库权限的用户。 -
验证当前操作日志的大小:
> use local > db.oplog.rs.stats(1024*1024).maxSize注意
这将显示以兆字节为单位的集合大小。
-
更改副本集成员的操作日志大小:
> db.adminCommand({replSetResizeOplog: 1, size: 16000})注意
下面的操作将副本集成员的操作日志大小更改为 16 GB,即 16000 MB。
-
最后,如果你减少了操作日志的大小,可能需要运行
compact以回收分配的磁盘空间。请参阅MongoDB 文档中的“更改操作日志大小”教程以获取更多关于此案例和整个过程的详细信息。
通常不应减少操作日志的大小:尽管它可能长达数月,但通常有足够的磁盘空间,并且不会使用 RAM 或 CPU 等宝贵资源。
建立索引
如果向主服务器发送索引构建请求,则主服务器将正常构建索引,然后在复制“构建索引”操作时次要节点将构建索引。尽管这是构建索引的最简单方法,但索引构建是资源密集型操作,可能会使成员不可用。如果所有次要节点同时开始构建索引,那么集合的几乎每个成员都将离线,直到索引构建完成。此过程仅适用于副本集;对于分片集群,请参阅MongoDB 文档中关于在分片集群上构建索引的教程。
警告
创建"unique"索引时,必须停止向集合写入。如果不停止写入,则可能导致副本集成员之间的数据不一致。
因此,您可能希望逐个成员构建索引,以最小化对应用程序的影响。为此,请执行以下操作:
-
关闭一个次要节点。
-
重新启动为独立服务器。
-
在独立服务器上构建索引。
-
当索引构建完成时,将服务器重新启动为副本集的成员。重新启动此成员时,如果您的命令行选项或配置文件中存在
disableLogicalSessionCacheRefresh参数,则需要将其删除。 -
对副本集中的每个次要节点重复步骤 1 至 4。
现在,您应该有一个每个成员(主服务器除外)都构建了索引的集合。现在有两个选项,您应该选择对生产系统影响最小的选项之一:
-
在主服务器上构建索引。如果您有交通量较少的“关闭”时间,那可能是构建索引的好时机。您还可能希望暂时修改读取偏好设置,将更多负载转移至次要节点,同时进行构建。
主服务器将索引构建复制到次要节点,但它们已经拥有该索引,因此对它们来说是无操作。
-
首先降低主服务器,然后按照先前概述的步骤 2 至 4 进行操作。这需要进行故障切换,但旧主服务器正在构建其索引时,您将拥有一个正常运行的主服务器。索引构建完成后,您可以将其重新引入集合。
请注意,您还可以使用此技术在次要节点上构建与其余集合不同的索引。这对离线处理可能很有用,但请确保具有不同索引的成员永远不能成为主服务器:其优先级应始终为0。
如果正在构建唯一索引,请确保主服务器未插入重复项,或者首先在主服务器上构建索引。否则,主服务器可能会插入重复项,然后在次要节点上会导致复制错误。如果发生这种情况,则次要节点将自行关闭。您将需要将其重新启动为独立服务器,删除唯一索引,然后重新启动。
紧缩预算的复制
如果很难获得超过一个高质量的服务器,请考虑获取一个仅用于灾难恢复的次级服务器,具有较少的 RAM 和 CPU,较慢的磁盘 I/O 等。优质服务器始终将是您的主服务器,并且更便宜的服务器永远不会处理任何客户端流量(请配置您的客户端将所有读取发送到主服务器)。以下是为更便宜的机器设置的选项:
"priority" : 0
您不希望此服务器成为主服务器。
"hidden" : true
您不希望客户端向此次级发送读取请求。
"buildIndexes" : false
这是可选项,但可以显著减少此服务器需要处理的负载。如果您需要从此服务器进行恢复,则需要重建索引。
"votes" : 0
如果您只有两台机器,请将此次级设置为0,以便主机在此机器宕机时保持主机状态。如果有第三台服务器(即使只是您的应用服务器),请在该服务器上运行仲裁者,而不是将"votes"设置为0。
这将为您提供具有次级备份的安全性和保障,而无需投资于两台高性能服务器。
第四部分:分片
第十四章:介绍分片
本章涵盖了如何使用 MongoDB 进行扩展。我们将看看:
-
什么是分片,以及集群的组件
-
如何配置分片
-
分片与应用程序交互的基础知识
什么是分片?
分片(Sharding)指的是将数据分散存储在多台机器上的过程;有时也会用术语分区(partitioning)来描述这个概念。将数据的子集放在每台机器上,可以在不需要更大或更强大的机器的情况下存储更多数据并处理更多负载,只需更多数量的性能较弱的机器。分片也可用于其他目的,包括将访问频率较高的数据放在性能更好的硬件上,或者基于地理位置将数据集分割,将集合中的一部分文档(例如针对特定地区用户的文档)放在最常访问它们的应用服务器附近。
几乎所有数据库软件都可以进行手动分片。使用这种方法,应用程序维护与多个不同的数据库服务器的连接,每个服务器都是完全独立的。应用程序管理将不同的数据存储在不同的服务器上,并针对合适的服务器进行查询以获取数据。这种设置可以很好地工作,但在向集群添加或移除节点、数据分布或负载模式发生变化时,维护变得困难。
MongoDB 支持自动分片,试图在某种程度上将架构与应用程序抽象化,并简化此类系统的管理。MongoDB 允许您的应用程序在一定程度上忽略它并非在与独立的 MongoDB 服务器交互。在运维方面,MongoDB 自动平衡数据跨分片,并简化增加和移除容量的操作。
从开发和操作的角度看,分片是配置 MongoDB 最复杂的方式。需要配置和监控许多组件,并且数据在集群中自动移动。在尝试部署或使用分片集群之前,您应该对独立服务器和副本集感到满意。此外,与副本集一样,建议配置和部署分片集群的方法是通过 MongoDB Ops Manager 或 MongoDB Atlas。如果您需要维护对计算基础设施的控制,推荐使用 Ops Manager。如果您可以将基础设施管理交给 MongoDB,则推荐使用 MongoDB Atlas(您可以选择在 Amazon AWS、Microsoft Azure 或 Google Compute Cloud 上运行)。
理解集群的组件
MongoDB 的分片功能允许您创建一个由多台机器(分片)组成的集群,并在它们之间分割集合,将数据的子集放在每个分片上。这使得您的应用程序能够超越独立服务器或副本集的资源限制。
注意
许多人对复制和分片之间的区别感到困惑。请记住,复制在多个服务器上创建数据的精确副本,因此每个服务器都是其他服务器的镜像。相反,每个分片包含不同的数据子集。
分片的一个目标是使 2、3、10 甚至数百个分片看起来对应你的应用程序像是单一的机器。为了将这些细节隐藏在应用程序之外,我们在分片前面运行一个或多个路由进程,称为mongos。mongos维护一个“目录”,告诉它哪个分片包含哪些数据。应用程序可以连接到这个路由器并正常发出请求,就像在图 14-1 中展示的那样。路由器知道哪些数据在哪个分片上,能够将请求转发到适当的分片。如果请求有响应,路由器收集它们并在必要时合并它们,然后将它们发送回应用程序。对于应用程序而言,它连接的就像是一个独立的mongod,如图 14-2 所示。

图 14-1. 分片客户端连接

图 14-2. 非分片客户端连接
单机集群上的分片
我们将在单台机器上快速设置一个集群。首先,使用--nodb和--norc选项启动一个mongo shell:
$ mongo --nodb --norc
要创建一个集群,请使用ShardingTest类。在刚刚启动的mongo shell 中运行以下命令:
st = ShardingTest({
name:"one-min-shards",
chunkSize:1,
shards:2,
rs:{
nodes:3,
oplogSize:10
},
other:{
enableBalancer:true
}
});
chunksize选项在第十七章中有详细说明。现在,简单地将其设置为1。至于这里传递给ShardingTest的其他选项,name仅提供我们分片集群的标签,shards指定我们的集群由两个分片组成(我们这样做是为了降低此示例的资源需求),而rs定义每个分片为三节点副本集,并设置了 10 MiB 的oplogSize(同样是为了保持资源利用率低)。尽管可以为每个分片运行一个独立的mongod,但如果我们将每个分片创建为副本集,这将更清晰地展示分片集群的典型架构。在指定的最后一个选项中,我们指示ShardingTest在集群启动后启用平衡器。这将确保数据均匀分布在两个分片之间。
ShardingTest是 MongoDB 工程内部使用的类,因此在外部未记录文档。但是,因为它与 MongoDB 服务器一起提供,它提供了最直接的方法来实验分片集群。ShardingTest最初是为支持服务器测试套件而设计的,并且仍然用于此目的。默认情况下,它提供了许多便利功能,帮助尽可能减少资源利用,并设置分片集群的相对复杂的架构。它假设您的机器上存在/data /db目录;如果ShardingTest运行失败,请创建此目录,然后重新运行命令。
运行此命令时,ShardingTest将会自动为您完成许多工作。它将创建一个包含两个副本集的新集群。它将配置副本集,并使用必要的选项启动每个节点以建立复制协议。它将启动一个mongos来管理跨分片的请求,以便客户端可以与集群交互,就像与独立的mongod通信一样,在某种程度上。最后,它将启动一个额外的副本集,用于配置服务器,这些服务器维护了确保查询定向到正确分片的路由表信息。请记住,分片的主要用例是为了将数据集分割以解决硬件和成本约束,或者为应用程序提供更好的性能(例如,地理分区)。MongoDB 的分片功能在许多方面以对应用程序无缝的方式提供这些能力。
一旦ShardingTest完成了集群的设置,您将有 10 个正在运行的进程可以连接:两个包含三个节点的副本集,一个包含三个节点的配置服务器副本集,以及一个mongos。默认情况下,这些进程应从端口 20000 开始。mongos应该在端口 20009 运行。您本地机器上运行的其他进程和之前对ShardingTest的调用可能会影响ShardingTest使用的端口,但您应该不会在确定集群进程运行的端口时遇到太多困难。
接下来,您将连接到mongos以在集群中进行操作。整个集群将将其日志转储到当前 shell,因此请打开第二个终端窗口并启动另一个mongo shell:
$ mongo --nodb
使用此 shell 连接到您集群的mongos。再次提醒,您的mongos应该在端口 20009 上运行:
> db = (new Mongo("localhost:20009")).getDB("accounts")
请注意,您的 mongo shell 提示符应更改以反映您已连接到 mongos。现在,您处于之前显示的情况,参见图 14-1:shell 是客户端,并连接到 mongos。您可以开始将请求传递给 mongos,它将适当地路由到分片。您实际上不需要了解分片的任何信息,例如它们有多少或其地址是什么。只要有一些分片存在,您就可以将请求传递给 mongos,并允许它适当地转发它们。
首先插入一些数据:
> for (var i=0; i<100000; i++) {
... db.users.insert({"username" : "user"+i, "created_at" : new Date()});
... }
> db.users.count()
100000
正如您可以看到的,与 mongos 交互的方式与与独立服务器交互的方式相同。
您可以通过运行sh.status()来获取集群的整体视图。它会为您提供关于分片、数据库和集合的摘要:
> sh.status()
--- Sharding Status ---
sharding version: {
"_id": 1,
"minCompatibleVersion": 5,
"currentVersion": 6,
"clusterId": ObjectId("5a4f93d6bcde690005986071")
}
shards:
{
"_id" : "one-min-shards-rs0",
"host" :
"one-min-shards-rs0/MBP:20000,MBP:20001,MBP:20002",
"state" : 1 }
{ "_id" : "one-min-shards-rs1",
"host" :
"one-min-shards-rs1/MBP:20003,MBP:20004,MBP:20005",
"state" : 1 }
active mongoses:
"3.6.1" : 1
autosplit:
Currently enabled: no
balancer:
Currently enabled: no
Currently running: no
Failed balancer rounds in last 5 attempts: 0
Migration Results for the last 24 hours:
No recent migrations
databases:
{ "_id" : "accounts", "primary" : "one-min-shards-rs1",
"partitioned" : false }
{ "_id" : "config", "primary" : "config",
"partitioned" : true }
config.system.sessions
shard key: { "_id" : 1 }
unique: false
balancing: true
chunks:
one-min-shards-rs0 1
{ "_id" : { "$minKey" : 1 } } -->> { "_id" : { "$maxKey" : 1 } }
on : one-min-shards-rs0 Timestamp(1, 0)
注意
sh类似于rs,但用于分片:它是一个全局变量,定义了许多分片助手函数,您可以通过运行sh.help()查看这些函数。正如从sh.status()输出中可以看出的那样,您有两个分片和两个数据库(config将自动创建)。
您的 accounts 数据库可能具有与此处显示的不同的主分片。主分片是随机为每个数据库选择的“主基地”分片。所有数据都将位于此主分片上。MongoDB 目前无法自动分配数据,因为它不知道(或不知道)您希望如何分配数据。您必须告诉它,对于每个集合,您希望如何分配数据。
注意
主分片与副本集的主服务器不同。主分片指的是组成一个分片的整个副本集。副本集中的主服务器是可以进行写操作的单个服务器。
要分片特定集合,首先在集合的数据库上启用分片。要执行此操作,请运行enableSharding命令:
> sh.enableSharding("accounts")
现在 accounts 数据库已启用分片,这使您可以在数据库内分片集合。
当您分片一个集合时,您需要选择一个分片键。这是 MongoDB 用来拆分数据的一个字段或两个字段。例如,如果您选择在"username"上分片,MongoDB 将根据用户名范围将数据分为几段:从"a1-steak-sauce"到"defcon",从"defcon1"到"howie1998"等。选择分片键可以视为选择集合数据的排序方式。这与索引类似,并且有其道理:分片键成为集合中最重要的索引,特别是在集合变大时。要创建分片键,字段(们)必须被索引。
因此,在启用分片之前,您必须为要分片的关键字创建索引:
> db.users.createIndex({"username" : 1})
现在,您可以通过"username"来分片集合:
> sh.shardCollection("accounts.users", {"username" : 1})
尽管我们在这里选择分片键并没有仔细考虑,但在实际系统中,这是一个需要仔细考虑的重要决定。请参阅第十六章获取有关选择分片键的更多建议。
如果等待几分钟然后再次运行sh.status(),你会发现显示的信息比之前多得多:
> sh.status()
--- Sharding Status ---
sharding version: {
"_id" : 1,
"minCompatibleVersion" : 5,
"currentVersion" : 6,
"clusterId" : ObjectId("5a4f93d6bcde690005986071")
}
shards:
{ "_id" : "one-min-shards-rs0",
"host" :
"one-min-shards-rs0/MBP:20000,MBP:20001,MBP:20002",
"state" : 1 }
{ "_id" : "one-min-shards-rs1",
"host" :
"one-min-shards-rs1/MBP:20003,MBP:20004,MBP:20005",
"state" : 1 }
active mongoses:
"3.6.1" : 1
autosplit:
Currently enabled: no
balancer:
Currently enabled: yes
Currently running: no
Failed balancer rounds in last 5 attempts: 0
Migration Results for the last 24 hours:
6 : Success
databases:
{ "_id" : "accounts", "primary" : "one-min-shards-rs1",
"partitioned" : true }
accounts.users
shard key: { "username" : 1 }
unique: false
balancing: true
chunks:
one-min-shards-rs0 6
one-min-shards-rs1 7
{ "username" : { "$minKey" : 1 } } -->>
{ "username" : "user17256" } on : one-min-shards-rs0 Timestamp(2, 0)
{ "username" : "user17256" } -->>
{ "username" : "user24515" } on : one-min-shards-rs0 Timestamp(3, 0)
{ "username" : "user24515" } -->>
{ "username" : "user31775" } on : one-min-shards-rs0 Timestamp(4, 0)
{ "username" : "user31775" } -->>
{ "username" : "user39034" } on : one-min-shards-rs0 Timestamp(5, 0)
{ "username" : "user39034" } -->>
{ "username" : "user46294" } on : one-min-shards-rs0 Timestamp(6, 0)
{ "username" : "user46294" } -->>
{ "username" : "user53553" } on : one-min-shards-rs0 Timestamp(7, 0)
{ "username" : "user53553" } -->>
{ "username" : "user60812" } on : one-min-shards-rs1 Timestamp(7, 1)
{ "username" : "user60812" } -->>
{ "username" : "user68072" } on : one-min-shards-rs1 Timestamp(1, 7)
{ "username" : "user68072" } -->>
{ "username" : "user75331" } on : one-min-shards-rs1 Timestamp(1, 8)
{ "username" : "user75331" } -->>
{ "username" : "user82591" } on : one-min-shards-rs1 Timestamp(1, 9)
{ "username" : "user82591" } -->>
{ "username" : "user89851" } on : one-min-shards-rs1 Timestamp(1, 10)
{ "username" : "user89851" } -->>
{ "username" : "user9711" } on : one-min-shards-rs1 Timestamp(1, 11)
{ "username" : "user9711" } -->>
{ "username" : { "$maxKey" : 1 } } on : one-min-shards-rs1 Timestamp(1, 12)
{ "_id" : "config", "primary" : "config", "partitioned" : true }
config.system.sessions
shard key: { "_id" : 1 }
unique: false
balancing: true
chunks:
one-min-shards-rs0 1
{ "_id" : { "$minKey" : 1 } } -->>
{ "_id" : { "$maxKey" : 1 } } on : one-min-shards-rs0 Timestamp(1, 0)
这个集合已经分割成了 13 个块,每个块都是你的数据的一个子集。这些块按照分片键范围列出({"username" : *minValue*} -->> {"username" : *maxValue*}表示每个块的范围)。查看输出的"on" : *shard*部分,你可以看到这些块已经在分片之间均匀分布。
这个集合被分成块的过程在图 14-3 到 14-5 中以图形方式显示。在分片之前,集合本质上是一个单一的块。分片将其分成基于分片键的较小块,如 图 14-4 所示。这些块然后可以分布在集群中,如 图 14-5 所示。

图 14-3. 在集合分片之前,可以将其视为从分片键的最小值到最大值的单个块

图 14-4. Sharding 根据分片键范围将集合分割成多个块

图 14-5. 块均匀分布在可用分片之间
注意块列表开头和结尾的键:$minKey 和 $maxKey。$minKey 可以被视为“负无穷”。它比 MongoDB 中的任何其他值都小。类似地,$maxKey 就像“正无穷”。它比任何其他值都大。因此,你会始终看到它们作为块范围的“上下限”。你的分片键的值将始终在$minKey和$maxKey之间。这些值实际上是 BSON 类型,不应在应用程序中使用;它们主要用于内部使用。如果你希望在 shell 中引用它们,请使用MinKey和MaxKey常量。
现在数据已分布在多个分片上,让我们尝试进行一些查询。首先,尝试查询特定用户名:
> db.users.find({username: "user12345"})
{
"_id" : ObjectId("5a4fb11dbb9ce6070f377880"),
"username" : "user12345",
"created_at" : ISODate("2018-01-05T17:08:45.657Z")
}
如你所见,查询正常工作。然而,让我们运行一个explain来查看 MongoDB 在幕后的操作:
> db.users.find({username: "user12345"}}).explain()
{
"queryPlanner" : {
"mongosPlannerVersion" : 1,
"winningPlan" : {
"stage" : "SINGLE_SHARD",
"shards" : [{
"shardName" : "one-min-shards-rs0",
"connectionString" :
"one-min-shards-rs0/MBP:20000,MBP:20001,MBP:20002",
"serverInfo" : {
"host" : "MBP",
"port" : 20000,
"version" : "3.6.1",
"gitVersion" : "025d4f4fe61efd1fb6f0005be20cb45a004093d1"
},
"plannerVersion" : 1,
"namespace" : "accounts.users",
"indexFilterSet" : false,
"parsedQuery" : {
"username" : {
"$eq" : "user12345"
}
},
"winningPlan" : {
"stage" : "FETCH",
"inputStage" : {
"stage" : "SHARDING_FILTER",
"inputStage" : {
"stage" : "IXSCAN",
"keyPattern" : {
"username" : 1
},
"indexName" : "username_1",
"isMultiKey" : false,
"multiKeyPaths" : {
"username" : [ ]
},
"isUnique" : false,
"isSparse" : false,
"isPartial" : false,
"indexVersion" : 2,
"direction" : "forward",
"indexBounds" : {
"username" : [
"[\"user12345\", \"user12345\"]"
]
}
}
}
},
"rejectedPlans" : [ ]
}]
}
},
"ok" : 1,
"$clusterTime" : {
"clusterTime" : Timestamp(1515174248, 1),
"signature" : {
"hash" : BinData(0,"AAAAAAAAAAAAAAAAAAAAAAAAAAA="),
"keyId" : NumberLong(0)
}
},
"operationTime" : Timestamp(1515173700, 201)
}
从explain输出中的"winningPlan"字段,我们可以看到我们的集群使用了单个分片one-min-shards-rs0来满足这个查询。根据之前显示的sh.status()输出,我们可以看到user12345确实落在我们集群中为该分片列出的第一个块的键范围内。
因为"username"是分片键,mongos能够直接将查询路由到正确的分片。与查询所有用户的结果相比,可以看到:
> db.users.find().explain()
{
"queryPlanner":{
"mongosPlannerVersion":1,
"winningPlan":{
"stage":"SHARD_MERGE",
"shards":[
{
"shardName":"one-min-shards-rs0",
"connectionString":
"one-min-shards-rs0/MBP:20000,MBP:20001,MBP:20002",
"serverInfo":{
"host":"MBP.fios-router.home",
"port":20000,
"version":"3.6.1",
"gitVersion":"025d4f4fe61efd1fb6f0005be20cb45a004093d1"
},
"plannerVersion":1,
"namespace":"accounts.users",
"indexFilterSet":false,
"parsedQuery":{
},
"winningPlan":{
"stage":"SHARDING_FILTER",
"inputStage":{
"stage":"COLLSCAN",
"direction":"forward"
}
},
"rejectedPlans":[
]
},
{
"shardName":"one-min-shards-rs1",
"connectionString":
"one-min-shards-rs1/MBP:20003,MBP:20004,MBP:20005",
"serverInfo":{
"host":"MBP.fios-router.home",
"port":20003,
"version":"3.6.1",
"gitVersion":"025d4f4fe61efd1fb6f0005be20cb45a004093d1"
},
"plannerVersion":1,
"namespace":"accounts.users",
"indexFilterSet":false,
"parsedQuery":{
},
"winningPlan":{
"stage":"SHARDING_FILTER",
"inputStage":{
"stage":"COLLSCAN",
"direction":"forward"
}
},
"rejectedPlans":[
]
}
]
}
},
"ok":1,
"$clusterTime":{
"clusterTime":Timestamp(1515174893, 1),
"signature":{
"hash":BinData(0, "AAAAAAAAAAAAAAAAAAAAAAAAAAA="),
"keyId":NumberLong(0)
}
},
"operationTime":Timestamp(1515173709, 514)
}
如你从这个explain中所见,这个查询必须访问两个分片来找到所有的数据。通常情况下,如果我们在查询中没有使用分片键,mongos 将不得不将查询发送到每个分片。
包含分片键并可以发送到单个分片或一组分片的查询称为定向查询。必须发送到所有分片的查询称为分散-聚合(广播)查询:mongos将查询分散到所有分片,然后收集结果。
实验完成后,关闭集群。切换回原始 Shell 并按几次 Enter 返回命令行,然后运行st.stop()以干净地关闭所有服务器:
> st.stop()
如果你不确定一个操作会做什么,使用ShardingTest快速启动本地集群进行尝试会很有帮助。
第十五章:配置分片
在上一章中,您在一台机器上设置了一个“集群”。本章将介绍如何设置一个更真实的集群以及每个组件的作用。特别是,您将了解:
-
如何设置配置服务器、分片和 mongos 进程
-
如何向集群添加容量
-
数据如何存储和分布
何时分片
决定何时进行分片是一个权衡考量。通常不希望过早进行分片,因为这会增加部署的操作复杂性,并迫使您做出后续难以更改的设计决策。另一方面,也不希望等待过长时间再进行分片,因为在系统超载的情况下进行分片会很困难且可能导致停机。
一般来说,分片用于:
-
增加可用内存
-
增加可用磁盘空间
-
减少服务器负载
-
以比单个 mongod 能处理的更大吞吐量读取或写入数据
因此,良好的监控对于确定何时需要进行分片至关重要。仔细测量每个指标。通常情况下,人们会更快地达到其中一个瓶颈,因此要确定您的部署将首先需要为哪一个瓶颈提供资源,并提前计划何时以及如何转换您的复制集。
启动服务器
创建集群的第一步是启动所需的所有进程。正如前一章提到的,您需要设置 mongos 和分片。还有第三个组件,即配置服务器,它们是一个重要组成部分。配置服务器是正常的 mongod 服务器,用于存储集群配置:哪些复制集托管分片、哪些集合被分片以及每个分块位于哪个分片上。MongoDB 3.2 引入了将复制集用作配置服务器的功能。复制集替代了配置服务器使用的原始同步机制;在 MongoDB 3.4 中删除了使用该机制的能力。
配置服务器
配置服务器是集群的大脑:它们保存关于哪些服务器持有哪些数据的所有元数据。因此,它们必须首先设置,并且它们持有的数据非常重要:确保它们启用了日志记录,并且它们的数据存储在非临时驱动器上。在生产部署中,您的配置服务器复制集应至少包含三个成员。每个配置服务器应位于单独的物理机器上,最好是地理分布均匀的。
在启动任何 mongos 进程之前,必须先启动配置服务器,因为 mongos 从配置服务器获取其配置。要开始,请在三台单独的机器上运行以下命令来启动您的配置服务器:
$ mongod --configsvr --replSet configRS --bind_ip localhost,198.51.100.51 mongod
--dbpath /var/lib/mongodb
$ mongod --configsvr --replSet configRS --bind_ip localhost,198.51.100.52 mongod
--dbpath /var/lib/mongodb
$ mongod --configsvr --replSet configRS --bind_ip localhost,198.51.100.53 mongod
--dbpath /var/lib/mongodb
然后,将配置服务器初始化为一个复制集。为此,请将 mongo shell 连接到其中一个复制集成员:
$ mongo --host *`<hostname>`* --port *`<port>`*
并使用 rs.initiate() 助手:
> rs.initiate(
{
_id: "configRS",
configsvr: true,
members: [
{ _id : 0, host : "cfg1.example.net:27019" },
{ _id : 1, host : "cfg2.example.net:27019" },
{ _id : 2, host : "cfg3.example.net:27019" }
]
}
)
在这里我们将 configRS 用作复制集名称。请注意,此名称在每个配置服务器实例化时的命令行上显示,并且在调用 rs.initiate() 时也会出现。
--configsvr选项指示mongod您计划将其用作配置服务器。在运行此选项的服务器上,客户端(即其他集群组件)无法向除config或admin之外的任何数据库写入数据。
admin数据库包含与身份验证和授权相关的集合,以及其他用于内部用途的system.集合。config数据库包含保存分片集群元数据的集合。当元数据发生更改时(例如分块迁移或分块拆分后),MongoDB 将数据写入config数据库。
在写入配置服务器时,MongoDB 使用writeConcern级别为"majority"。类似地,从配置服务器读取时,MongoDB 使用readConcern级别为"majority"。这确保分片集群元数据不会提交到配置服务器副本集,直到无法回滚为止。它还确保只有在配置服务器失败后将幸存的元数据读取出来。这是确保所有mongos路由器在分片集群中组织数据的方式的一致视图的必要条件。
在配置方面,配置服务器应在网络和 CPU 资源方面充分配置。它们仅保存集群数据的目录,因此所需的存储资源很少。它们应该部署在单独的硬件上,以避免争用机器资源。
警告
如果您的所有配置服务器都丢失了,您必须深入分析分片上的数据,以确定数据在哪里。这是可能的,但速度较慢且不愉快。定期备份配置服务器数据。在执行任何集群维护之前,务必备份配置服务器。
mongos 进程
一旦您有三个配置服务器运行,就启动一个mongos进程供应用程序连接。mongos进程需要知道配置服务器的位置,因此您必须始终使用--configdb选项启动mongos:
$ mongos --configdb \
configRS/cfg1.example.net:27019, \
cfg2.example.net:27019,cfg3.example.net:27019 \
--bind_ip localhost,198.51.100.100 --logpath /var/log/mongos.log
默认情况下,mongos在端口 27017 上运行。请注意,它不需要数据目录(mongos本身不存储数据;它在启动时从配置服务器加载集群配置)。确保将--logpath设置为将mongos日志保存到安全的位置。
您应该启动少量的mongos进程,并将它们尽可能靠近所有分片。这可以提高需要访问多个分片或执行散列/聚合操作的查询性能。最小设置至少需要两个mongos进程以确保高可用性。可以运行数十个或数百个mongos进程,但这会在配置服务器上引起资源争用。推荐的方法是提供一个小型路由器池。
从副本集添加分片
最后,您准备好添加一个分片。有两种可能性:您可能有一个现有的副本集,也可能从头开始。我们将介绍从现有集开始的步骤。如果您从头开始,请初始化一个空集,并按照这里概述的步骤操作。
如果您已经有一个为应用程序服务的副本集,那么它将成为您的第一个分片。要将其转换为分片,您需要对成员进行一些小的配置修改,然后告诉mongos如何找到将组成该分片的副本集。
例如,如果您在svr1.example.net、svr2.example.net和svr3.example.net上有一个名为rs0的副本集,则首先使用mongo shell 连接到其中一个成员:
$ mongo srv1.example.net
然后使用rs.status()确定哪个成员是主节点,哪些是辅助节点:
> rs.status()
"set" : "rs0",
"date" : ISODate("2018-11-02T20:02:16.543Z"),
"myState" : 1,
"term" : NumberLong(1),
"heartbeatIntervalMillis" : NumberLong(2000),
"optimes" : {
"lastCommittedOpTime" : {
"ts" : Timestamp(1478116934, 1),
"t" : NumberLong(1)
},
"readConcernMajorityOpTime" : {
"ts" : Timestamp(1478116934, 1),
"t" : NumberLong(1)
},
"appliedOpTime" : {
"ts" : Timestamp(1478116934, 1),
"t" : NumberLong(1)
},
"durableOpTime" : {
"ts" : Timestamp(1478116934, 1),
"t" : NumberLong(1)
}
},
"members" : [
{
"_id" : 0,
"name" : "svr1.example.net:27017",
"health" : 1,
"state" : 1,
"stateStr" : "PRIMARY",
"uptime" : 269,
"optime" : {
"ts" : Timestamp(1478116934, 1),
"t" : NumberLong(1)
},
"optimeDate" : ISODate("2018-11-02T20:02:14Z"),
"infoMessage" : "could not find member to sync from",
"electionTime" : Timestamp(1478116933, 1),
"electionDate" : ISODate("2018-11-02T20:02:13Z"),
"configVersion" : 1,
"self" : true
},
{
"_id" : 1,
"name" : "svr2.example.net:27017",
"health" : 1,
"state" : 2,
"stateStr" : "SECONDARY",
"uptime" : 14,
"optime" : {
"ts" : Timestamp(1478116934, 1),
"t" : NumberLong(1)
},
"optimeDurable" : {
"ts" : Timestamp(1478116934, 1),
"t" : NumberLong(1)
},
"optimeDate" : ISODate("2018-11-02T20:02:14Z"),
"optimeDurableDate" : ISODate("2018-11-02T20:02:14Z"),
"lastHeartbeat" : ISODate("2018-11-02T20:02:15.618Z"),
"lastHeartbeatRecv" : ISODate("2018-11-02T20:02:14.866Z"),
"pingMs" : NumberLong(0),
"syncingTo" : "m1.example.net:27017",
"configVersion" : 1
},
{
"_id" : 2,
"name" : "svr3.example.net:27017",
"health" : 1,
"state" : 2,
"stateStr" : "SECONDARY",
"uptime" : 14,
"optime" : {
"ts" : Timestamp(1478116934, 1),
"t" : NumberLong(1)
},
"optimeDurable" : {
"ts" : Timestamp(1478116934, 1),
"t" : NumberLong(1)
},
"optimeDate" : ISODate("2018-11-02T20:02:14Z"),
"optimeDurableDate" : ISODate("2018-11-02T20:02:14Z"),
"lastHeartbeat" : ISODate("2018-11-02T20:02:15.619Z"),
"lastHeartbeatRecv" : ISODate("2018-11-02T20:02:14.787Z"),
"pingMs" : NumberLong(0),
"syncingTo" : "m1.example.net:27017",
"configVersion" : 1
}
],
"ok" : 1
}
从 MongoDB 3.4 开始,对于分片集群,mongod实例必须使用--shardsvr选项进行配置,可以通过配置文件设置sharding.clusterRole或通过命令行选项--shardsvr进行设置。
您需要为正在转换为分片的副本集的每个成员执行此操作。您将首先使用--shardsvr选项依次重新启动每个辅助节点,然后让主节点下台并使用--shardsvr选项重新启动它。
在关闭辅助节点后,按以下步骤重新启动:
$ mongod --replSet "rs0" --shardsvr --port 27017
--bind_ip localhost,<*`ip address of member`*>
请注意,您需要为--bind_ip参数的每个辅助节点使用正确的 IP 地址。
现在连接到主节点的mongo shell:
$ mongo m1.example.net
并让其下台:
> rs.stepDown()
然后使用--shardsvr选项重新启动前主节点:
$ mongod --replSet "rs0" --shardsvr --port 27017
--bind_ip localhost,<*`ip address of the former primary`*>
现在您准备将您的副本集作为分片添加。连接到mongos的admin数据库的mongo shell:
$ mongo mongos1.example.net:27017/admin
并使用sh.addShard()方法向集群添加一个分片:
> sh.addShard(
"rs0/svr1.example.net:27017,svr2.example.net:27017,svr3.example.net:27017" )
您可以指定集合中的所有成员,但不必如此。mongos将自动检测未包含在种子列表中的任何成员。如果运行sh.status(),您会看到 MongoDB 很快将分片列为
rs0/svr1.example.net:27017,svr2.example.net:27017,svr3.example.net:27017
集合名称rs0被用作此分片的标识符。如果您希望删除此分片或将数据迁移到它,可以使用rs0来描述它。这比使用特定服务器(例如svr1.example.net)更好,因为副本集成员资格和状态可能随时间而变化。
一旦将副本集添加为分片,您可以将应用程序从连接到副本集切换到连接到mongos。当您添加分片时,mongos会注册副本集中所有数据库为该分片的“所有权”,因此它将所有查询传递到您的新分片上。mongos还将自动处理应用程序的故障转移,就像您的客户端库一样:它会将错误传递给您。
在开发环境中测试分片的主节点故障转移,以确保您的应用程序正确处理从 mongos 接收的错误(它们应与直接与主节点交互时接收到的错误相同)。
注意
添加了分片后,您必须设置所有客户端发送请求到 mongos 而不是直接连接到副本集。如果某些客户端仍然直接向副本集发出请求(而不通过 mongos),则分片功能将无法正常运行。在添加分片后立即切换所有客户端以联系 mongos,并设置防火墙规则以确保它们无法直接连接到分片。
在 MongoDB 3.6 之前,可以将独立的 mongod 作为一个分片。但是在 MongoDB 3.6 之后的版本中不再支持这个选项。所有的分片必须是副本集。
增加容量
当您需要增加更多的容量时,您需要添加更多的分片。要添加一个新的空分片,需要创建一个副本集。确保它的名称与您的其他任何分片不同。一旦初始化并有了主节点,通过 mongos 运行 addShard 命令,指定新副本集的名称和其主机作为种子,将其添加到集群中。
如果您有几个现有的副本集不是分片,只要它们没有任何公共数据库名称,就可以将它们全部添加为集群中的新分片。例如,如果您有一个带有 blog 数据库的副本集,一个带有 calendar 数据库,以及一个带有 mail、tel 和 music 数据库的副本集,您可以将每个副本集添加为一个分片,并最终得到一个有三个分片和五个数据库的集群。但是,如果您有第四个副本集,它也有一个名为 tel 的数据库,mongos 将拒绝将其添加到集群中。
分片数据
在告诉 MongoDB 如何分配数据之前,它不会自动分配您的数据。您必须明确告诉数据库和集合,您希望它们被分布。例如,假设您希望在 music 数据库的 artists 集合上按 "name" 键进行分片。首先,您需要为数据库启用分片:
> db.enableSharding("music")
在分片集合之前,分片数据库始终是先决条件。
一旦在数据库级别启用了分片,您可以通过运行 sh.shardCollection() 来为集合分片:
> sh.shardCollection("music.artists", {"name" : 1})
现在 artists 集合将按 "name" 键进行分片。如果要对现有集合进行分片,必须在 "name" 字段上存在一个索引;否则,shardCollection 调用将返回错误。如果出现错误,请创建索引(mongos 将在错误消息的一部分返回建议的索引)并重试 shardCollection 命令。
如果您要分片的集合尚不存在,mongos 将为您自动创建分片键索引。
shardCollection命令将集合分成分块,这些分块是 MongoDB 用于数据移动的单位。一旦命令成功返回,MongoDB 将开始在集群中的分片之间平衡集合。这个过程不是即时的。对于大集合,可能需要几个小时来完成初始平衡。可以通过预分片来减少这段时间,预分片是指在加载数据之前在分片上创建分块。在此之后加载的数据将直接插入到当前分片,而无需额外的平衡。
MongoDB 如何跟踪集群数据
每个mongos都必须始终知道如何根据其分片键找到文档的位置。从理论上讲,MongoDB 可以追踪每个文档的存储位置,但对于包含数百万或数十亿文档的集合来说,这变得难以管理。因此,MongoDB 将文档分组成分块,即在给定分片键范围内的文档。每个分块始终驻留在单个分片上,因此 MongoDB 可以维护一个映射到分片的小表格。
例如,如果用户集合的分片键是{"age" : 1},一个分块可能包含所有具有"age"字段在 3 和 17 之间的文档。如果mongos收到一个查询{"age" : 5},它可以将查询路由到包含此分块的分片。
随着写入的发生,分块中文档的数量和大小可能会改变。插入操作可以使分块包含更多文档,而删除操作则减少文档数量。例如,如果我们为儿童和青少年制作游戏,我们的 3−17 岁的年龄分块可能会越来越大(希望如此)。几乎所有用户都会在该分块中,并且会在一个单独的分片上,这在某种程度上违背了分配数据的初衷。因此,一旦分块增长到一定大小,MongoDB 会自动将其拆分为两个较小的分块。例如,在此示例中,原始分块可能会被拆分为一个包含年龄 3 至 11 岁的分块,以及一个包含年龄 12 至 17 岁的分块。请注意,这两个分块仍然覆盖了原始分块所涵盖的整个年龄范围:3−17 岁。随着这些新分块的增长,它们可以进一步分成更小的分块,直到每个年龄段都有一个分块。
不能有重叠范围的分块,比如 3−15 和 12−17。如果允许存在这样的重叠,当尝试查找重叠中的年龄(例如 14)时,MongoDB 将需要检查两个分块,这样效率不高。只需在一个位置查找更有效,特别是一旦分块开始在集群中移动。
每个文档始终属于且仅属于一个分块。这一规则的一个结果是,不能使用数组字段作为分片键,因为 MongoDB 为数组创建多个索引条目。例如,如果文档的"age"字段中有[5, 26, 83],它可能属于多达三个分块。
注意
一种常见的误解是分块中的数据在物理上在磁盘上分组。这是不正确的:分块对mongod存储集合数据的方式没有影响。
分块范围
每个块由其包含的范围描述。新分片的集合从单个块开始,每个文档都位于此块中。此块的边界为负无穷到正无穷,显示为 Shell 中的$minKey和$maxKey。
当此块增长时,MongoDB 将自动将其分为两个块,范围为负无穷到<某个值>和<某个值>到正无穷。<某个值> 对于两个块都是相同的:下面的块包含一切直到(但不包括)<某个值>,上面的块包含<某个值>及更高的一切。
通过示例可能更容易理解。假设我们按照前述方式通过"age"进行分片。所有"age"在3和17之间的文档都包含在一个块中:3 ≤ "age" < 17。当此块被分割时,我们得到两个范围:一个块中的3 ≤ "age" < 12,另一个块中的12 ≤ "age" < 17。12 被称为分割点。
块信息存储在config.chunks集合中。如果您查看该集合的内容,您会看到类似以下内容的文档(为了清晰起见,某些字段已经省略):
> db.chunks.find(criteria, {"min" : 1, "max" : 1})
{
"_id" : "test.users-age_-100.0",
"min" : {"age" : -100},
"max" : {"age" : 23}
}
{
"_id" : "test.users-age_23.0",
"min" : {"age" : 23},
"max" : {"age" : 100}
}
{
"_id" : "test.users-age_100.0",
"min" : {"age" : 100},
"max" : {"age" : 1000}
}
根据显示的config.chunks文档,以下是各种文档可能存放的几个示例位置:
{"_id" : 123, "age" : 50}
此文档将位于第二个块中,因为该块包含所有年龄在23和100之间的文档。
{"_id" : 456, "age" : 100}
此文档将位于第三个块中,因为下界是包含的。第二个块包含所有年龄在"age" : 100之前的文档,但不包含"age"等于100的文档。
{"_id" : 789, "age" : -101}
此文档将不属于任何这些块。它将属于某个范围低于第一个块的块中。
使用复合分片键时,块范围的工作方式与按两个键排序的方式相同。例如,假设我们使用{"username" : 1, "age" : 1}作为分片键。然后我们可能会有如下块范围:
{
"_id" : "test.users-username_MinKeyage_MinKey",
"min" : {
"username" : { "$minKey" : 1 },
"age" : { "$minKey" : 1 }
},
"max" : {
"username" : "user107487",
"age" : 73
}
}
{
"_id" : "test.users-username_\"user107487\"age_73.0",
"min" : {
"username" : "user107487",
"age" : 73
},
"max" : {
"username" : "user114978",
"age" : 119
}
}
{
"_id" : "test.users-username_\"user114978\"age_119.0",
"min" : {
"username" : "user114978",
"age" : 119
},
"max" : {
"username" : "user122468",
"age" : 68
}
}
因此,mongos可以轻松找到居住在具有给定用户名(或给定用户名和年龄)的人的块。但是,只给出一个年龄,mongos必须检查所有或几乎所有的块。如果我们希望能够将年龄的查询定位到正确的块上,我们必须使用“相反的”分片键:{"age" : 1, "username" : 1}。这常常是令人困惑的一点:在分片键的第二半范围内进行范围查询将跨越多个块。
分割块
每个分片主 mongod 跟踪其当前的块,并在达到一定阈值时检查是否需要分裂块,如图 15-1 和 15-2 所示。如果确实需要分裂块,则 mongod 将从配置服务器请求全局块大小配置值。然后执行块分裂并更新配置服务器上的元数据。新的块文档将在配置服务器上创建,旧块的范围 ("max") 将被修改。如果块是分片的顶块,则 mongod 将请求平衡器将此块移动到另一个分片。其目的是防止分片变得“热”,其中分片键使用单调递增的键。
对于大块而言,有时分片可能无法找到任何分裂点,因为合法分裂块的方法有限。具有相同分片键的两个文档必须位于同一块中,因此块只能在分片键的值发生变化的文档之间进行分裂。例如,如果分片键是 "age",则可以在分片键变化的点处分裂以下块,如下所示:
{"age" : 13, "username" : "ian"}
{"age" : 13, "username" : "randolph"}
------------ // split point
{"age" : 14, "username" : "randolph"}
{"age" : 14, "username" : "eric"}
{"age" : 14, "username" : "hari"}
{"age" : 14, "username" : "mathias"}
------------ // split point
{"age" : 15, "username" : "greg"}
{"age" : 15, "username" : "andrew"}
仅针对分片的主 mongod 请求在分片分裂时将顶部分片移动到平衡器。其他分片将保留在分片上,除非手动移动。
但是,如果块包含以下文档,则无法分裂(除非应用程序开始插入小数年龄):
{"age" : 12, "username" : "kevin"}
{"age" : 12, "username" : "spencer"}
{"age" : 12, "username" : "alberto"}
{"age" : 12, "username" : "tad"}
因此,拥有多样化的分片键值对于您的分片键非常重要。其他重要属性将在下一章中介绍。
如果配置服务器之一在 mongod 尝试执行分裂时宕机,则 mongod 将无法更新元数据(如 图 15-3 所示)。所有配置服务器必须处于运行状态并且可访问才能进行分裂操作。如果 mongod 继续接收分片的写请求,则会继续尝试并失败。只要配置服务器不健康,分裂将继续无法正常工作,并且所有分裂尝试都会使 mongod 和涉及的分片变慢(这与图 15-1 到 15-3 中显示的过程重复)。 mongod 反复尝试分裂分片且无法成功的过程称为 split storm。防止分裂风暴的唯一方法是确保您的配置服务器尽可能保持运行和健康。

图 15-1. 当客户端写入块时,mongod 将检查其分裂阈值

图 15-2. 如果达到分裂阈值,则 mongod 将发送请求到平衡器以迁移顶部块;否则块保留在分片上

图 15-3. mongod 选择一个拆分点并尝试通知配置服务器,但无法达到它;因此,它仍然超过其数据块的拆分阈值,并且任何后续写操作都将再次触发此过程。
Balancer
Balancer负责数据迁移。它定期检查分片之间的不平衡,如果发现不平衡,将开始迁移数据块。在 MongoDB 3.4+版本中,Balancer 位于配置服务器副本集的主节点上;在此版本之前,每个mongos有时会扮演“Balancer”的角色。
Balancer 是配置服务器副本集的主节点上的后台进程,监视每个分片上的数据块数量。仅当分片的数据块数量达到特定的迁移阈值时,它才会变为活动状态。
注意
在 MongoDB 3.4+中,并发迁移的数量增加到每个分片一个迁移,最大并发迁移数量为总分片数的一半。在早期版本中,仅支持总共一个并发迁移。
假设某些集合已达到阈值,Balancer 将开始迁移数据块。它从负载过多的分片中选择一个数据块,并询问该分片在迁移之前是否应拆分数据块。完成必要的拆分后,它将数据块迁移到拥有较少数据块的机器上。
使用集群的应用程序无需知道数据正在移动:所有读写操作都路由到旧的数据块,直到迁移完成。一旦元数据更新,任何尝试访问旧位置数据的mongos进程将收到错误。这些错误对客户端不可见:mongos会静默处理错误,并在新的分片上重试操作。
这是你可能在mongos日志中看到与“无法setShardVersion”相关的常见错误原因。当mongos收到此类错误时,它会从配置服务器查找数据的新位置,更新其数据块表,并再次尝试请求。如果成功从新位置检索数据,它将向客户端返回,就像什么都没有发生过一样(但会在日志中打印错误消息)。
如果mongos无法检索到新的数据块位置,因为配置服务器不可用,它将向客户端返回错误。这是始终保持配置服务器正常运行的另一个重要原因。
Collations
MongoDB 中的Collations允许为字符串比较指定特定语言的规则。这些规则的示例包括如何比较大小写和重音符号。可以对具有默认排序规则的集合进行分片。有两个要求:集合必须有一个索引,其前缀是分片键,并且该索引还必须具有{ locale: "simple" }的排序规则。
变更流
更改流 允许应用程序跟踪数据库中数据的实时更改。在 MongoDB 3.6 之前,只能通过尾随 oplog 进行,并且是一个复杂且容易出错的操作。更改流为集合、一组集合、数据库或整个部署中的所有数据更改提供了订阅机制。聚合框架被这一功能所使用。它允许应用程序过滤特定的更改或转换接收到的更改通知。在跨片群集中,所有更改流操作必须针对一个mongos进行。
跨片群集上的更改通过全局逻辑时钟保持有序。这保证了更改的顺序,流通知可以安全地按照它们接收的顺序进行解释。mongos 在接收到更改通知时需要与每个片段进行核对,以确保没有片段看到更新的更改。群集的活动水平和片段的地理分布都可能影响此检查的响应时间。在这些情况下使用通知过滤器可以提高响应时间。
注意
在使用跨片群集的更改流时有几点注意事项和警告。通过发出打开更改流操作来打开更改流。在分片部署中,这必须针对一个mongos进行。如果对带有打开更改流的分片集合运行具有 multi: true 的更新操作,则可能会发送孤立文档的通知。如果删除了一个片段,则可能会导致打开的更改流游标关闭——此外,该游标可能无法完全恢复。
第十六章:选择一个分片键
当使用分片时,最重要的任务是选择数据分布方式。为了对此做出明智的选择,你必须了解 MongoDB 如何分布数据。本章通过以下内容帮助你选择一个好的分片键:
-
如何在多个可能的分片键之间做出决策
-
几种用例的分片键
-
不能作为分片键的内容
-
如果你想要定制数据分布的一些替代策略
-
如何手动分片数据
假设你已经理解了前两章中涵盖的分片的基本组成部分。
盘点你的使用情况
当你对一个集合进行分片时,你需要选择一个或两个字段来拆分数据。这个键(或键)被称为分片键。一旦你对一个集合进行了分片,你就无法更改你的分片键,因此选择正确非常重要。
要选择一个好的分片键,你需要了解你的工作负载以及你的分片键将如何分配你的应用程序请求。这可能很难理解,所以尝试一些示例或者更好的办法是在备份数据集上进行样本流量的尝试。本节包含大量图表和解释,但没有什么能代替在自己的数据上尝试。
对于每个你计划分片的集合,首先回答以下问题:
-
你计划增长到多少片?一个三片集群比一个千片集群具有更大的灵活性。随着集群的扩大,你不应该计划发出可以击中所有分片的查询,因此几乎所有查询必须包含分片键。
-
你是否在进行分片以减少读取或写入延迟?(延迟指某事物花费的时间;例如,写入需要 20 毫秒,但你希望它只需 10 毫秒。)减少写入延迟通常涉及将请求发送到地理位置更近或更强大的机器。
-
你是否在进行分片以增加读取或写入吞吐量?(吞吐量指集群能同时处理多少请求;例如,集群可以在 20 毫秒内执行 1,000 次写入,但你需要它在 20 毫秒内执行 5,000 次写入。)增加吞吐量通常涉及增加更多的并行处理,并确保请求在集群中均匀分布。
-
你是否正在进行分片以增加系统资源(例如,为 MongoDB 每 GB 数据增加更多 RAM)?如果是这样,你希望尽可能保持工作集大小尽可能小。
使用这些答案来评估以下分片键描述,并决定你正在考虑的分片键是否能在你的情况下很好地工作。它是否为你提供了所需的目标查询?它是否以你需要的方式改变了系统的吞吐量或延迟?如果你需要一个紧凑的工作集,它是否提供了这个?
图解分布
人们选择分割数据的最常见方式是通过升序、随机和基于位置的键。还有其他类型的键可以使用,但大多数用例都属于这些类别之一。不同类型的分布在接下来的章节中进行了讨论。
升序分片键
升序分片键通常是像"date"字段或ObjectId这样的东西——任何随着时间稳定增长的东西。自增主键是升序字段的另一个例子,尽管在 MongoDB 中并不常见(除非您从另一个数据库导入)。
假设我们在一个升序字段上进行分片,比如在一个使用ObjectId的集合的"_id"上进行分片。如果我们在"_id"上进行分片,那么数据将根据"_id"范围被分割成块,如图 16-1 所示。这些块将分布在我们的三个分片的分片集群中,如图 16-2 所示。

图 16-1. 集合被划分为 ObjectId 的范围;每个范围是一个块
假设我们创建一个新文档。它将在哪个块中?答案是包含范围为ObjectId("5112fae0b4a4b396ff9d0ee5")到$maxKey的块。这被称为max chunk,因为它是包含$maxKey的块。
如果我们插入另一个文档,它也将在最大块中。实际上,每个后续的插入都将在最大块中进行!每个插入的"_id"字段都将比前一个更接近无限(因为ObjectId总是增加的),所以它们都将进入最大块。

图 16-2. 块在随机顺序分布在分片之间
这有几个有趣(通常是不可取的)的特性。首先,所有的写入将被路由到一个分片(shard0002,在本例中)。这个块将是唯一增长和分裂的块,因为它是唯一接收插入的块。当您插入数据时,新的块将从这个块“掉落”,如图 16-3 所示。

图 16-3. 最大块持续增长并分裂成多个块
这种模式经常使 MongoDB 更难以保持块的均衡,因为所有的块都是由一个分片创建的。因此,MongoDB 必须不断地将块移动到其他分片,而不是纠正可能在更均匀分布的系统中出现的小不平衡。
注
在 MongoDB 4.2 中,将自动分割功能移动到分片主节点的mongod中,添加了顶部块优化以解决升序分片键模式。均衡器将决定将顶部块放置在哪个其他分片中。这有助于避免所有新块都在同一个分片上创建的情况。
随机分布的分片键
在另一端是随机分布的分片键。随机分布的键可以是用户名、电子邮件地址、UUID、MD5 哈希或数据集中没有可识别模式的任何其他键。
假设分片键是介于 0 和 1 之间的随机数。我们会在各个分片上得到不同的块的随机分布,如 图 16-4 所示。

图 16-4. 如前所述,块在集群中随机分布
随着插入更多数据,数据的随机性意味着插入应该相对均匀地命中每个块。你可以通过插入 10,000 个文档来证明这一点,看看它们最终分布在哪里:
> var servers = {}
> var findShard = function (id) {
... var explain = db.random.find({_id:id}).explain();
... for (var i in explain.shards) {
... var server = explain.shards[i][0];
... if (server.n == 1) {
... if (server.server in servers) {
... servers[server.server]++;
... } else {
... servers[server.server] = 1;
... }
... }
... }
... }
> for (var i = 0; i < 10000; i++) {
... var id = ObjectId();
... db.random.insert({"_id" : id, "x" : Math.random()});
... findShard(id);
... }
> servers
{
"spock:30001" : 2942,
"spock:30002" : 4332,
"spock:30000" : 2726
}
由于写入是随机分布的,分片应以大致相同的速度增长,从而限制需要发生的迁移数量。
随机分布的分片键唯一的缺点是 MongoDB 在超出 RAM 大小的随机访问数据方面效率不高。但是,如果您有能力或者不介意性能损失,随机键可以很好地分布负载在集群中。
基于位置的分片键
基于位置的分片键可能是用户的 IP、纬度和经度或地址等。它们不一定与物理位置字段相关联:这个“位置”可能是数据应该以更抽象的方式分组在一起的方式。无论如何,基于位置的键是一种键,其中具有某种相似性的文档根据此字段的范围落入。这对于将数据放置在靠近其用户的位置并在磁盘上保持相关数据在一起可能会很有用。这也可能是为了符合 GDPR 或其他类似的数据隐私立法的法律要求。MongoDB 使用区域分片来管理这一点。
注意
在 MongoDB 4.0.3+ 中,您可以在对集合进行分片之前定义区域和区域范围,这将为区域范围和分片键值的块填充以及执行这些的初始块分布。这极大地简化了分片区域设置的复杂性。
例如,假设我们有一个基于 IP 地址分片的文档集合。文档将根据它们的 IP 组织成块,并随机分布在集群中,如 图 16-5 所示。

图 16-5. IP 地址集合中块的示例分布
如果我们希望某些块范围附加到特定的分片上,我们可以对这些分片进行区域划分,然后为每个区域分配块范围。例如,假设我们希望将某些 IP 区块保留在特定的分片上:例如,56...(美国邮政服务的 IP 区块)在 shard0000 上,17...(苹果的 IP 区块)在 shard0000 或 shard0002 上。我们不在乎其他 IP 位于何处。我们可以通过设置区域要求负载平衡器执行此操作:
> sh.addShardToZone("shard0000", "USPS")
> sh.addShardToZone("shard0000", "Apple")
> sh.addShardToZone("shard0002", "Apple")
接下来,我们创建规则:
> sh.updateZoneKeyRange("test.ips", {"ip" : "056.000.000.000"},
... {"ip" : "057.000.000.000"}, "USPS")
这将把所有大于或等于 56.0.0.0 且小于 57.0.0.0 的 IP 附加到标记为"USPS"的分片。接下来,我们为苹果添加一个规则:
> sh.updateZoneKeyRange("test.ips", {"ip" : "017.000.000.000"},
... {"ip" : "018.000.000.000"}, "Apple")
当均衡器移动块时,它将尝试将具有这些范围的块移动到这些分片。请注意,此过程不是立即的。没有被区域键范围覆盖的块将按正常方式移动。均衡器将继续尝试在分片之间均匀分布块。
分片键策略
本节介绍了各种类型应用程序的一些分片键选项。
哈希分片键
为了尽可能快地加载数据,哈希分片键是最佳选择。哈希分片键可以使任何字段随机分布,因此如果你要在许多查询中使用升序键但希望写入是随机分布的,则哈希分片键是一个不错的选择。
这种方式的权衡是你永远不能使用哈希分片键进行定向的范围查询。但如果你不打算进行范围查询,哈希分片键是一个不错的选择。
要创建哈希分片键,首先创建一个哈希索引:
> db.users.createIndex({"username" : "hashed"})
接下来,使用以下方式对集合进行分片:
> sh.shardCollection("app.users", {"username" : "hashed"})
{ "collectionsharded" : "app.users", "ok" : 1 }
如果你在不存在的集合上创建了一个哈希分片键,shardCollection的行为会变得很有趣:它会假设你希望均匀分布的块,因此它会立即创建一堆空块,并将它们分布在你的集群周围。例如,假设在创建哈希分片键之前我们的集群看起来是这样的:
> sh.status()
--- Sharding Status ---
sharding version: { "_id" : 1, "version" : 3 }
shards:
{ "_id" : "shard0000", "host" : "localhost:30000" }
{ "_id" : "shard0001", "host" : "localhost:30001" }
{ "_id" : "shard0002", "host" : "localhost:30002" }
databases:
{ "_id" : "admin", "partitioned" : false, "primary" : "config" }
{ "_id" : "test", "partitioned" : true, "primary" : "shard0001" }
立即在shardCollection返回后,每个分片上都有两个块,将键空间均匀分布在整个集群中:
> sh.status()
--- Sharding Status ---
sharding version: { "_id" : 1, "version" : 3 }
shards:
{ "_id" : "shard0000", "host" : "localhost:30000" }
{ "_id" : "shard0001", "host" : "localhost:30001" }
{ "_id" : "shard0002", "host" : "localhost:30002" }
databases:
{ "_id" : "admin", "partitioned" : false, "primary" : "config" }
{ "_id" : "test", "partitioned" : true, "primary" : "shard0001" }
test.foo
shard key: { "username" : "hashed" }
chunks:
shard0000 2
shard0001 2
shard0002 2
{ "username" : { "$MinKey" : true } }
-->> { "username" : NumberLong("-6148914691236517204") }
on : shard0000 { "t" : 3000, "i" : 2 }
{ "username" : NumberLong("-6148914691236517204") }
-->> { "username" : NumberLong("-3074457345618258602") }
on : shard0000 { "t" : 3000, "i" : 3 }
{ "username" : NumberLong("-3074457345618258602") }
-->> { "username" : NumberLong(0) }
on : shard0001 { "t" : 3000, "i" : 4 }
{ "username" : NumberLong(0) }
-->> { "username" : NumberLong("3074457345618258602") }
on : shard0001 { "t" : 3000, "i" : 5 }
{ "username" : NumberLong("3074457345618258602") }
-->> { "username" : NumberLong("6148914691236517204") }
on : shard0002 { "t" : 3000, "i" : 6 }
{ "username" : NumberLong("6148914691236517204") }
-->> { "username" : { "$MaxKey" : true } }
on : shard0002 { "t" : 3000, "i" : 7 }
请注意,集合中目前没有文档,但当你开始插入它们时,写操作应该从一开始就均匀分布在所有分片上。通常情况下,你需要等待块增长、分割和移动到其他分片才能开始向其他分片写入。但通过这种自动启动,你将立即在所有分片上获得块范围。
注意
如果使用哈希分片键,对于分片键的选择有一些限制。首先,你不能使用unique选项。与其他分片键一样,你不能使用数组字段。最后,请注意,在进行哈希之前,浮点数值会被四舍五入为整数,因此 1 和 1.999999 将被哈希为相同的值。
用于 GridFS 的哈希分片键
在尝试对 GridFS 集合进行分片之前,请确保你理解了 GridFS 如何存储数据(参见第六章进行解释)。
在接下来的解释中,“块”这个术语具有重载含义,因为 GridFS 将文件分割为块,而分片将集合分割为块。因此,这两种类型的块被称为“GridFS 块”和“分片块”。
GridFS 集合通常是分片的绝佳候选,因为它们包含大量的文件数据。然而,fs.chunks自动创建的索引中,{"_id" : 1}是一个升序键,{"files_id" : 1, "n" : 1}选取了fs.files的"_id"字段,因此也是一个升序键。
然而,如果你在"files_id"字段上创建了哈希索引,每个文件将会随机分布在整个集群中,并且每个文件始终会包含在单个分片中。这是两全其美的最佳选择:写操作会均匀分布到所有的分片上,而读取文件数据只需要访问单个分片。
要设置这个,你必须在{"files_id" : "hashed"}上创建一个新的索引(截至目前为止,mongos不能使用复合索引的子集作为分片键)。然后在这个字段上对集合进行分片:
> db.fs.chunks.ensureIndex({"files_id" : "hashed"})
> sh.shardCollection("test.fs.chunks", {"files_id" : "hashed"})
{ "collectionsharded" : "test.fs.chunks", "ok" : 1 }
顺便提一下,fs.files集合可能需要分片,也可能不需要,因为它的大小远小于fs.chunks。如果你愿意,你可以对其进行分片,但这可能并不是必要的。
消防栓策略
如果你有一些服务器比其他服务器更强大,你可能希望让它们处理比较多的负载。例如,假设你有一个分片可以处理其他机器 10 倍的负载。幸运的是,你还有其他 10 个分片。你可以强制所有的插入操作都发送到更强大的分片,然后允许负载均衡器将旧的块移动到其他分片上。这将带来更低延迟的写入操作。
要使用这种策略,我们必须将最高的块固定在最强大的分片上。首先,我们对这个分片进行区域设置:
> sh.addShardToZone("<shard-name>", "10x")
然后我们将升序键的当前值固定到那个分片上,所以所有的新写入操作都会发送到这里:
> sh.updateZoneKeyRange("<dbName.collName>", {"_id" : ObjectId()},
... {"_id" : MaxKey}, "10x")
现在所有的插入操作将会路由到最后一个块,它将始终存在于分片区域"10x"上。
然而,从现在开始直到无穷大的范围将会被困在这个分片上,除非我们修改区域键范围。为了避免这个问题,我们可以设置一个 cron 任务,每天更新一次键范围,例如:
> use config
> var zone = db.tags.findOne({"ns" : "<dbName.collName>",
... "max" : {"<shardKey>" : MaxKey}})
> zone.min.<shardKey> = ObjectId()
> db.tags.save(zone)
随后,前一天的所有块都将能够移动到其他分片上。
这种策略的另一个缺点是,它需要一些扩展的变动。如果你的最强大服务器不能再处理所有写入的数量,那么在这台服务器和另一台服务器之间没有简单的方法来分担负载。
如果你没有高性能服务器用于输入流或者没有使用区域分片,不要将升序键作为分片键。如果这样做,所有的写操作都将集中在单个分片上。
多热点
独立的mongod服务器在进行升序写入时效率最高。这与分片冲突,因为分片在整个集群上均匀分布写入时效率最高。这里描述的技术基本上创建了多个热点——在每个分片上最好有几个——以便在集群中均匀分配写入,但在分片内部保持升序。
为了实现这一点,我们使用复合分片键。复合键中的第一个值是一个粗略的、具有较低基数的随机值。你可以把分片键的第一部分中的每个值想象成一个块,就像图 16-6 中展示的那样。随着插入更多数据,这将最终得到解决,尽管可能永远不会这么整齐地分割(恰好位于$minKey行上)。然而,如果插入足够的数据,最终每个随机值大约应该有一个块。随着继续插入数据,你最终会得到多个具有相同随机值的块,这将带我们来到分片键的第二部分。

图 16-6. 一些块的子集:每个块包含一个状态和一系列“_id”值的范围
第二部分的分片键是一个升序键。这意味着在一个块内,值始终是增加的,就像在图 16-7 的示例文档中所示。因此,如果每个分片只有一个块,你将拥有完美的设置:每个分片上都有升序写入,如图 16-8 所示。当然,拥有n个分片,每个分片有n个热点散布在其中并不是很可扩展的:添加一个新分片,它将无法获得任何写入,因为没有热点块可以放置在上面。因此,你希望每个分片有几个热点块(以便为你提供增长的空间),但不要太多。拥有几个热点块将保持升序写入的效果,但是,例如在一个分片上有一千个热点块最终会导致等同于随机写入。

图 16-7. 插入文档的样本列表(注意所有“_id”值都是增加的)

图 16-8. 插入的文档,分成块(注意,每个块内的“_id”值是增加的)
你可以把这个设置想象成每个块都是一堆升序文档。每个分片上有多个堆栈,每个堆栈都升序直至块被分割。一旦块被分割,新块中只有一个是热点块:另一个块本质上是“死”的,永远不会再增长。如果堆栈均匀分布在各个分片上,写入将均匀分布。
分片键的规则和指南
在选择分片键之前,需要注意几个实际限制。
确定用于分片的键以及创建分片键应该与索引类似,因为这两个概念是相似的。事实上,通常你的分片键可能只是你最常使用的索引(或者它的某种变体)。
分片键限制
分片键不能是数组。如果任何键具有数组值,sh.shardCollection()将失败,并且不允许将数组插入该字段。
一旦插入,文档的分片键值可能会被修改,除非分片键字段是不可变的_id字段。在 MongoDB 4.2 之前的旧版本中,无法修改文档的分片键值。
大多数特殊类型的索引不能用作分片键。特别是,您不能在地理空间索引上进行分片。使用哈希索引作为分片键是允许的,如前所述。
Shard 键的基数
无论你的分片键是跳跃还是稳步增加,选择一个值会变化的键是很重要的。与索引类似,高基数字段在分片时表现更好。例如,如果你有一个"logLevel"键,只有"DEBUG"、"WARN"或"ERROR"这三个值,MongoDB 无法将你的数据分成超过三个块(因为分片键只有三个不同的值)。如果你有一个变化很少的键,并且仍然想将其用作分片键,你可以在该键上创建一个复合分片键,以及一个更有变化的键,比如"logLevel"和"timestamp"。重要的是这些键的组合具有高基数。
控制数据分发
有时,自动数据分发无法满足您的要求。本节提供了一些选择,超出了选择分片键并允许 MongoDB 自动执行所有操作的范围。
当您的集群变得更大或更繁忙时,这些解决方案变得不太实用。但是,对于小集群,您可能希望有更多的控制。
使用一个集群来处理多个数据库和集合
MongoDB 会在集群中的每个分片上均匀分布集合,这在存储同质数据时效果很好。但是,如果您有一个日志集合比其他数据“价值低”,您可能不希望它占用昂贵服务器的空间。或者,如果您有一个强大的分片,您可能只想用它来进行实时收集,而不允许其他集合使用它。您可以创建单独的集群,但您还可以向 MongoDB 指定您希望它将某些数据放置在哪里。
要设置这个,请在 shell 中使用sh.addShardToZone()辅助程序:
> sh.addShardToZone("shard0000", "high")
> // shard0001 - no zone
> // shard0002 - no zone
> // shard0003 - no zone
> sh.addShardToZone("shard0004", "low")
> sh.addShardToZone("shard0005", "low")
然后,您可以将不同的集合分配给不同的分片。例如,对于您的超级重要的实时集合:
> sh.updateZoneKeyRange("super.important", {"<shardKey>" : MinKey},
... {"<shardKey>" : MaxKey}, "high")
这句话说,“对于此集合的负无穷到正无穷,将其存储在标记为"high"的分片上。” 这意味着来自super.important集合的数据不会存储在其他服务器上。请注意,这不影响其他集合的分布方式:它们仍然会均匀分布在这个分片和其他分片之间。
您可以执行类似的操作,将日志集合保存在低质量服务器上:
> sh.updateZoneKeyRange("some.logs", {"<shardKey>" : MinKey},
... {"<shardKey>" : MaxKey}, "low")
日志集合现在将在shard0004和shard0005之间均匀分割。
将区域键范围分配给集合不会立即影响它。这是对平衡器的一项指示,即在其运行时,这些是移动集合的可行目标。因此,如果整个日志集合在shard0002上或在各个分片之间均匀分布,那么将所有块迁移到shard0004和shard0005将需要一些时间。
举个例子,也许你有一个不希望放在分区 "high" 的集合,但你不关心它放在哪个其他分片上。你可以将所有非高性能分片分区为一个新的组。分片可以有任意数量的分区:
> sh.addShardToZone("shard0001", "whatever")
> sh.addShardToZone("shard0002", "whatever")
> sh.addShardToZone("shard0003", "whatever")
> sh.addShardToZone("shard0004", "whatever")
> sh.addShardToZone("shard0005", "whatever")
现在,你可以指定你希望这个集合(称为 normal.coll)分布在这五个分片上:
> sh.updateZoneKeyRange("normal.coll", {"<shardKey>" : MinKey},
... {"<shardKey>" : MaxKey}, "whatever")
提示
你不能动态分配集合,即你不能说:“当创建一个集合时,随机地将其分配到一个分片。” 但是,你可以编写一个 cron 作业来帮你完成这个工作。
如果你犯了一个错误或改变了主意,你可以使用 sh.removeShardFromZone() 从一个分区中移除一个分片:
> sh.removeShardFromZone("shard0005", "whatever")
如果你从一个区域中移除了所有分片(例如,如果你从区域 "high" 中移除了 shard0000),那么均衡器将不会将数据分布到任何地方,因为没有任何有效的位置可用。所有数据仍然可以读写;只是在修改标签或标签范围之前,无法进行迁移。
要从一个区域中移除一个键范围,请使用 sh.removeRangeFromZone()。以下是一个示例。指定的范围必须与之前为命名空间 some.logs 和给定区域定义的范围完全匹配:
> sh.removeRangeFromZone("some.logs", {"<shardKey>" : MinKey},
... {"<shardKey>" : MaxKey})
手动分片
有时候,对于复杂的需求或特殊情况,你可能更喜欢完全控制数据的分布。如果你不希望数据自动分布,你可以关闭均衡器,并使用 moveChunk 命令手动分布数据。
要关闭均衡器,请连接到任何 mongos(任何一个 mongos 都可以)使用 mongo shell,并使用 shell 辅助函数 sh.stopBalancer() 来禁用均衡器:
> sh.stopBalancer()
如果当前正在进行迁移操作,这个设置将在迁移完成后生效。然而,一旦所有正在进行中的迁移完成,均衡器将停止数据迁移。要验证禁用后没有迁移正在进行,在 mongo shell 中执行以下操作:
> use config
> while(sh.isBalancerRunning()) {
... print("waiting...");
... sleep(1000);
... }
一旦均衡器关闭,你可以手动移动数据(如果需要)。首先,通过查看 config.chunks 确定每个数据块的位置:
> db.chunks.find()
现在,使用 moveChunk 命令将数据块迁移到其他分片。指定要迁移的数据块的下限,并给出你想要将数据块迁移到的分片的名称:
> sh.moveChunk(
... "test.manual.stuff",
... {user_id: NumberLong("-1844674407370955160")},
... "test-rs1")
不过,除非你处于特殊情况,你应该使用 MongoDB 的自动分片而不是手动操作。如果你在一个未预期的分片上出现热点,你可能会发现大部分数据都在那个分片上。
特别是,请不要将手动设置不寻常的分发与运行均衡器结合起来。如果均衡器检测到不均匀的块数,它将简单地重新洗牌所有工作以再次使集合均衡。如果您想要不均匀分布的块,请使用《使用集群进行多个数据库和集合》中讨论的区域分片技术(#shard-tags)。
第十七章:分片管理
与副本集一样,您可以选择多种选项来管理分片集群。手动管理是一种选择。如今,越来越常见的做法是使用工具如 Ops Manager、Cloud Manager 和 Atlas Database-as-a-Service(DBaaS)提供的所有集群管理功能。在本章中,我们将演示如何手动管理分片集群,包括:
-
检查集群的状态:其成员是谁,数据存储在哪里,以及有哪些打开的连接
-
添加、移除和更改集群的成员
-
管理数据移动和手动移动数据
查看当前状态
有几个辅助工具可用于查找数据所在位置、分片信息以及集群的运行状态。
使用sh.status()获取摘要
sh.status()为您提供了有关分片、数据库和分片集合的概述。如果您有少量块,它将打印哪些块位于何处的详细信息。否则,它将仅提供集合的分片键,并报告每个分片有多少块:
> sh.status()
--- Sharding Status ---
sharding version: {
"_id" : 1,
"minCompatibleVersion" : 5,
"currentVersion" : 6,
"clusterId" : ObjectId("5bdf51ecf8c192ed922f3160")
}
shards:
{ "_id" : "shard01",
"host" : "shard01/localhost:27018,localhost:27019,localhost:27020",
"state" : 1 }
{ "_id" : "shard02",
"host" : "shard02/localhost:27021,localhost:27022,localhost:27023",
"state" : 1 }
{ "_id" : "shard03",
"host" : "shard03/localhost:27024,localhost:27025,localhost:27026",
"state" : 1 }
active mongoses:
"4.0.3" : 1
autosplit:
Currently enabled: yes
balancer:
Currently enabled: yes
Currently running: no
Failed balancer rounds in last 5 attempts: 0
Migration Results for the last 24 hours:
6 : Success
databases:
{ "_id" : "config", "primary" : "config", "partitioned" : true }
config.system.sessions
shard key: { "_id" : 1 }
unique: false
balancing: true
chunks:
shard01 1
{ "_id" : { "$minKey" : 1 } } -->>
{ "_id" : { "$maxKey" : 1 } } on : shard01 Timestamp(1, 0)
{ "_id" : "video", "primary" : "shard02", "partitioned" : true,
"version" :
{ "uuid" : UUID("3d83d8b8-9260-4a6f-8d28-c3732d40d961"),
"lastMod" : 1 } }
video.movies
shard key: { "imdbId" : "hashed" }
unique: false
balancing: true
chunks:
shard01 3
shard02 4
shard03 3
{ "imdbId" : { "$minKey" : 1 } } -->>
{ "imdbId" : NumberLong("-7262221363006655132") } on :
shard01 Timestamp(2, 0)
{ "imdbId" : NumberLong("-7262221363006655132") } -->>
{ "imdbId" : NumberLong("-5315530662268120007") } on :
shard03 Timestamp(3, 0)
{ "imdbId" : NumberLong("-5315530662268120007") } -->>
{ "imdbId" : NumberLong("-3362204802044524341") } on :
shard03 Timestamp(4, 0)
{ "imdbId" : NumberLong("-3362204802044524341") } -->>
{ "imdbId" : NumberLong("-1412311662519947087") }
on : shard01 Timestamp(5, 0)
{ "imdbId" : NumberLong("-1412311662519947087") } -->>
{ "imdbId" : NumberLong("524277486033652998") } on :
shard01 Timestamp(6, 0)
{ "imdbId" : NumberLong("524277486033652998") } -->>
{ "imdbId" : NumberLong("2484315172280977547") } on :
shard03 Timestamp(7, 0)
{ "imdbId" : NumberLong("2484315172280977547") } -->>
{ "imdbId" : NumberLong("4436141279217488250") } on :
shard02 Timestamp(7, 1)
{ "imdbId" : NumberLong("4436141279217488250") } -->>
{ "imdbId" : NumberLong("6386258634539951337") } on :
shard02 Timestamp(1, 7)
{ "imdbId" : NumberLong("6386258634539951337") } -->>
{ "imdbId" : NumberLong("8345072417171006784") } on :
shard02 Timestamp(1, 8)
{ "imdbId" : NumberLong("8345072417171006784") } -->>
{ "imdbId" : { "$maxKey" : 1 } } on :
shard02 Timestamp(1, 9)
一旦有几个块以上,sh.status()将总结块统计信息,而不是打印每个块。要查看所有块,请运行sh.status(true)(true告诉sh.status()要详细显示)。
所有sh.status()显示的信息都是从您的config数据库中收集的。
查看配置信息
您集群的所有配置信息都保存在配置服务器上config数据库的集合中。Shell 提供了几个辅助工具,以更可读的方式显示这些信息。但是,您始终可以直接查询config数据库以获取有关集群的元数据。
警告
永远不要直接连接到配置服务器,因为您不希望意外更改或删除配置服务器数据。相反,连接到mongos进程,并使用config数据库查看其数据,就像您对待任何其他数据库一样:
> use config
如果您通过mongos(而不是直接连接到配置服务器)操作配置数据,mongos 将确保所有配置服务器保持同步,并防止意外删除config数据库等各种危险操作。
通常情况下,您不应直接更改config数据库中的任何数据(在下面的部分中会有例外)。如果您进行了任何更改,通常需要重新启动所有的mongos服务器才能看到其效果。
config数据库中有几个集合。本节介绍了每个集合包含的内容及其用途。
config.shards
shards集合跟踪集群中的所有分片。shards集合中典型的文档可能如下所示:
> db.shards.find()
{ "_id" : "shard01",
"host" : "shard01/localhost:27018,localhost:27019,localhost:27020",
"state" : 1 }
{ "_id" : "shard02",
"host" : "shard02/localhost:27021,localhost:27022,localhost:27023",
"state" : 1 }
{ "_id" : "shard03",
"host" : "shard03/localhost:27024,localhost:27025,localhost:27026",
"state" : 1 }
分片的"_id"是从副本集名称中提取的,因此集群中的每个副本集必须具有唯一的名称。
当更新复制集配置(例如,添加或移除成员)时,"host" 字段将自动更新。
config.databases
databases 集合跟踪集群了解的所有数据库,无论是分片的还是非分片的:
> db.databases.find()
{ "_id" : "video", "primary" : "shard02", "partitioned" : true,
"version" : { "uuid" : UUID("3d83d8b8-9260-4a6f-8d28-c3732d40d961"),
"lastMod" : 1 } }
如果在数据库上运行了 enableSharding,"partitioned" 将为 true。"primary" 是数据库的“主基地”。默认情况下,该数据库中的所有新集合都将在主分片上创建。
config.collections
collections 集合跟踪所有分片集合(未分片的集合未显示)。典型文档看起来像这样:
> db.collections.find().pretty()
{
"_id" : "config.system.sessions",
"lastmodEpoch" : ObjectId("5bdf53122ad9c6907510c22d"),
"lastmod" : ISODate("1970-02-19T17:02:47.296Z"),
"dropped" : false,
"key" : {
"_id" : 1
},
"unique" : false,
"uuid" : UUID("7584e4cd-fac4-4305-a9d4-bd73e93621bf")
}
{
"_id" : "video.movies",
"lastmodEpoch" : ObjectId("5bdf72c021b6e3be02fabe0c"),
"lastmod" : ISODate("1970-02-19T17:02:47.305Z"),
"dropped" : false,
"key" : {
"imdbId" : "hashed"
},
"unique" : false,
"uuid" : UUID("e6580ffa-fcd3-418f-aa1a-0dfb71bc1c41")
}
重要字段是:
"_id"
集合的命名空间。
"key"
分片键。在这种情况下,它是对 "imdbId" 进行散列分片键。
"unique"
表示分片键不是唯一索引。默认情况下,分片键不是唯一的。
config.chunks
chunks 集合中记录了所有集合中每个 chunk 的记录。chunks 集合中的典型文档看起来像这样:
> db.chunks.find().skip(1).limit(1).pretty()
{
"_id" : "video.movies-imdbId_MinKey",
"lastmod" : Timestamp(2, 0),
"lastmodEpoch" : ObjectId("5bdf72c021b6e3be02fabe0c"),
"ns" : "video.movies",
"min" : {
"imdbId" : { "$minKey" : 1 }
},
"max" : {
"imdbId" : NumberLong("-7262221363006655132")
},
"shard" : "shard01",
"history" : [
{
"validAfter" : Timestamp(1541370579, 3096),
"shard" : "shard01"
}
]
}
最有用的字段是:
"_id"
chunk 的唯一标识符。通常是命名空间、分片键和较低 chunk 边界。
"ns"
此 chunk 所属的集合。
"min"
chunk 的范围中的最小值(包括在内)。
"max"
所有 chunk 中的值均小于此值。
"shard"
chunk 所在的分片。
"lastmod" 字段跟踪 chunk 的版本信息。例如,如果 chunk "video.movies-imdbId_MinKey" 被拆分为两个 chunk,我们需要一种方法来区分新的、更小的 "video.movies-imdbId_MinKey" chunk 和它们之前作为单个 chunk 的版本。因此,Timestamp 值的第一个组件反映了 chunk 迁移到新分片的次数。该值的第二个组件反映了拆分次数。"lastmodEpoch" 字段指定了集合的创建时期。它用于区分在集合被删除并立即重新创建的情况下对同一集合名称的请求。
sh.status() 使用 config.chunks 集合来收集大部分信息。
config.changelog
changelog 集合对于跟踪集群正在执行的操作非常有用,因为它记录了所有已发生的拆分和迁移。
拆分记录在如下所示的文档中:
> db.changelog.find({what: "split"}).pretty()
{
"_id" : "router1-2018-11-05T09:58:58.915-0500-5be05ab2f8c192ed922ffbe7",
"server" : "bob",
"clientAddr" : "127.0.0.1:64621",
"time" : ISODate("2018-11-05T14:58:58.915Z"),
"what" : "split",
"ns" : "video.movies",
"details" : {
"before" : {
"min" : {
"imdbId" : NumberLong("2484315172280977547")
},
"max" : {
"imdbId" : NumberLong("4436141279217488250")
},
"lastmod" : Timestamp(9, 1),
"lastmodEpoch" : ObjectId("5bdf72c021b6e3be02fabe0c")
},
"left" : {
"min" : {
"imdbId" : NumberLong("2484315172280977547")
},
"max" : {
"imdbId" : NumberLong("3459137475094092005")
},
"lastmod" : Timestamp(9, 2),
"lastmodEpoch" : ObjectId("5bdf72c021b6e3be02fabe0c")
},
"right" : {
"min" : {
"imdbId" : NumberLong("3459137475094092005")
},
"max" : {
"imdbId" : NumberLong("4436141279217488250")
},
"lastmod" : Timestamp(9, 3),
"lastmodEpoch" : ObjectId("5bdf72c021b6e3be02fabe0c")
}
}
}
"details" 字段提供了关于原始文档的信息以及它是如何分割的。
此输出显示了集合第一个 chunk 拆分的情况。请注意,每个新 chunk 的 "lastmod" 的第二个组件已更新,因此其值分别为 Timestamp(9, 2) 和 Timestamp(9, 3)。
迁移稍微复杂,实际上创建了四个单独的变更日志文档:一个标记迁移开始,一个为“源”分片,一个为“目标”分片,以及一个在迁移最终化时发生的提交。中间两个文档很有意思,因为它们详细说明了过程中每个步骤所花费的时间。这可以让你了解是磁盘、网络还是其他原因导致迁移瓶颈。
例如,“源”分片创建的文档如下:
> db.changelog.findOne({what: "moveChunk.to"})
{
"_id" : "router1-2018-11-04T17:29:39.702-0500-5bdf72d32ad9c69075112f08",
"server" : "bob",
"clientAddr" : "",
"time" : ISODate("2018-11-04T22:29:39.702Z"),
"what" : "moveChunk.to",
"ns" : "video.movies",
"details" : {
"min" : {
"imdbId" : { "$minKey" : 1 }
},
"max" : {
"imdbId" : NumberLong("-7262221363006655132")
},
"step 1 of 6" : 965,
"step 2 of 6" : 608,
"step 3 of 6" : 15424,
"step 4 of 6" : 0,
"step 5 of 6" : 72,
"step 6 of 6" : 258,
"note" : "success"
}
}
"details"中列出的每个步骤都有时间限制,并且"step*N* of *N*"消息显示了每个步骤所花费的时间,以毫秒为单位。
当“源”分片从mongos接收到moveChunk命令时,它:
-
检查命令参数。
-
确认配置服务器可以为迁移获取分布式锁。
-
尝试联系“目标”分片。
-
复制数据。这被称为“关键部分”,并记录在日志中。
-
与“目标”分片和配置服务器协调以确认迁移。
请注意,“目标”和“源”分片必须从“第 4 步到第 6 步”开始密切通信:分片直接与彼此和配置服务器通信以执行迁移。如果“源”服务器在最后几个步骤中的网络连接不稳定,则可能会导致无法撤消迁移并且无法继续进行迁移的状态。在这种情况下,mongod将关闭。
“目标”分片的变更日志文档与“源”分片类似,但步骤略有不同。它看起来是这样的:
> db.changelog.find({what: "moveChunk.from", "details.max.imdbId":
NumberLong("-7262221363006655132")}).pretty()
{
"_id" : "router1-2018-11-04T17:29:39.753-0500-5bdf72d321b6e3be02fabf0b",
"server" : "bob",
"clientAddr" : "127.0.0.1:64743",
"time" : ISODate("2018-11-04T22:29:39.753Z"),
"what" : "moveChunk.from",
"ns" : "video.movies",
"details" : {
"min" : {
"imdbId" : { "$minKey" : 1 }
},
"max" : {
"imdbId" : NumberLong("-7262221363006655132")
},
"step 1 of 6" : 0,
"step 2 of 6" : 4,
"step 3 of 6" : 191,
"step 4 of 6" : 17000,
"step 5 of 6" : 341,
"step 6 of 6" : 39,
"to" : "shard01",
"from" : "shard02",
"note" : "success"
}
}
当“目标”分片从“源”分片接收到命令时,它:
-
迁移索引。如果该分片以前从未持有过迁移集合的分块,它需要知道哪些字段被索引。如果这不是首次将该集合的分块移动到该分片,则应该是一个无操作。
-
删除分块范围内的任何现有数据。可能会有来自失败的迁移或恢复过程的数据残留,我们不希望干扰当前数据。
-
将分块中的所有文档复制到“目标”分片。
-
在复制期间重新执行任何操作(在“目标”分片上)。
-
等待“目标”分片将新迁移的数据复制到大多数服务器。
-
通过更改分块的元数据来提交迁移,表示它存储在“目标”分片上。
config.settings
此集合包含代表当前平衡器设置和分块大小的文档。通过更改此集合中的文档,您可以启用或禁用平衡器,或更改分块大小。请注意,您应该始终连接到mongos,而不是直接连接到配置服务器,以更改此集合中的值。
跟踪网络连接
集群组件之间有许多连接。本节涵盖了一些关于分片的特定信息(更多信息请参见第二十四章有关网络的内容)。
获取连接统计信息
命令 connPoolStats 返回有关当前数据库实例与分片集群或副本集中其他成员之间的开放传出连接的信息。
为了避免干扰任何正在运行的操作,connPoolStats 不会锁定任何内容。因此,随着 connPoolStats 收集信息,计数可能会略有变化,导致主机和池连接计数之间存在轻微差异:
> db.adminCommand({"connPoolStats": 1})
{
"numClientConnections" : 10,
"numAScopedConnections" : 0,
"totalInUse" : 0,
"totalAvailable" : 13,
"totalCreated" : 86,
"totalRefreshing" : 0,
"pools" : {
"NetworkInterfaceTL-TaskExecutorPool-0" : {
"poolInUse" : 0,
"poolAvailable" : 2,
"poolCreated" : 2,
"poolRefreshing" : 0,
"localhost:27027" : {
"inUse" : 0,
"available" : 1,
"created" : 1,
"refreshing" : 0
},
"localhost:27019" : {
"inUse" : 0,
"available" : 1,
"created" : 1,
"refreshing" : 0
}
},
"NetworkInterfaceTL-ShardRegistry" : {
"poolInUse" : 0,
"poolAvailable" : 1,
"poolCreated" : 13,
"poolRefreshing" : 0,
"localhost:27027" : {
"inUse" : 0,
"available" : 1,
"created" : 13,
"refreshing" : 0
}
},
"global" : {
"poolInUse" : 0,
"poolAvailable" : 10,
"poolCreated" : 71,
"poolRefreshing" : 0,
"localhost:27026" : {
"inUse" : 0,
"available" : 1,
"created" : 8,
"refreshing" : 0
},
"localhost:27027" : {
"inUse" : 0,
"available" : 1,
"created" : 1,
"refreshing" : 0
},
"localhost:27023" : {
"inUse" : 0,
"available" : 1,
"created" : 7,
"refreshing" : 0
},
"localhost:27024" : {
"inUse" : 0,
"available" : 1,
"created" : 6,
"refreshing" : 0
},
"localhost:27022" : {
"inUse" : 0,
"available" : 1,
"created" : 9,
"refreshing" : 0
},
"localhost:27019" : {
"inUse" : 0,
"available" : 1,
"created" : 8,
"refreshing" : 0
},
"localhost:27021" : {
"inUse" : 0,
"available" : 1,
"created" : 8,
"refreshing" : 0
},
"localhost:27025" : {
"inUse" : 0,
"available" : 1,
"created" : 9,
"refreshing" : 0
},
"localhost:27020" : {
"inUse" : 0,
"available" : 1,
"created" : 8,
"refreshing" : 0
},
"localhost:27018" : {
"inUse" : 0,
"available" : 1,
"created" : 7,
"refreshing" : 0
}
}
},
"hosts" : {
"localhost:27026" : {
"inUse" : 0,
"available" : 1,
"created" : 8,
"refreshing" : 0
},
"localhost:27027" : {
"inUse" : 0,
"available" : 3,
"created" : 15,
"refreshing" : 0
},
"localhost:27023" : {
"inUse" : 0,
"available" : 1,
"created" : 7,
"refreshing" : 0
},
"localhost:27024" : {
"inUse" : 0,
"available" : 1,
"created" : 6,
"refreshing" : 0
},
"localhost:27022" : {
"inUse" : 0,
"available" : 1,
"created" : 9,
"refreshing" : 0
},
"localhost:27019" : {
"inUse" : 0,
"available" : 2,
"created" : 9,
"refreshing" : 0
},
"localhost:27021" : {
"inUse" : 0,
"available" : 1,
"created" : 8,
"refreshing" : 0
},
"localhost:27025" : {
"inUse" : 0,
"available" : 1,
"created" : 9,
"refreshing" : 0
},
"localhost:27020" : {
"inUse" : 0,
"available" : 1,
"created" : 8,
"refreshing" : 0
},
"localhost:27018" : {
"inUse" : 0,
"available" : 1,
"created" : 7,
"refreshing" : 0
}
},
"replicaSets" : {
"shard02" : {
"hosts" : [
{
"addr" : "localhost:27021",
"ok" : true,
"ismaster" : true,
"hidden" : false,
"secondary" : false,
"pingTimeMillis" : 0
},
{
"addr" : "localhost:27022",
"ok" : true,
"ismaster" : false,
"hidden" : false,
"secondary" : true,
"pingTimeMillis" : 0
},
{
"addr" : "localhost:27023",
"ok" : true,
"ismaster" : false,
"hidden" : false,
"secondary" : true,
"pingTimeMillis" : 0
}
]
},
"shard03" : {
"hosts" : [
{
"addr" : "localhost:27024",
"ok" : true,
"ismaster" : false,
"hidden" : false,
"secondary" : true,
"pingTimeMillis" : 0
},
{
"addr" : "localhost:27025",
"ok" : true,
"ismaster" : true,
"hidden" : false,
"secondary" : false,
"pingTimeMillis" : 0
},
{
"addr" : "localhost:27026",
"ok" : true,
"ismaster" : false,
"hidden" : false,
"secondary" : true,
"pingTimeMillis" : 0
}
]
},
"configRepl" : {
"hosts" : [
{
"addr" : "localhost:27027",
"ok" : true,
"ismaster" : true,
"hidden" : false,
"secondary" : false,
"pingTimeMillis" : 0
}
]
},
"shard01" : {
"hosts" : [
{
"addr" : "localhost:27018",
"ok" : true,
"ismaster" : false,
"hidden" : false,
"secondary" : true,
"pingTimeMillis" : 0
},
{
"addr" : "localhost:27019",
"ok" : true,
"ismaster" : true,
"hidden" : false,
"secondary" : false,
"pingTimeMillis" : 0
},
{
"addr" : "localhost:27020",
"ok" : true,
"ismaster" : false,
"hidden" : false,
"secondary" : true,
"pingTimeMillis" : 0
}
]
}
},
"ok" : 1,
"operationTime" : Timestamp(1541440424, 1),
"$clusterTime" : {
"clusterTime" : Timestamp(1541440424, 1),
"signature" : {
"hash" : BinData(0,"AAAAAAAAAAAAAAAAAAAAAAAAAAA="),
"keyId" : NumberLong(0)
}
}
}
在此输出中:
-
"totalAvailable"显示了当前 mongod/mongos 实例与分片集群或副本集中其他成员之间可用的所有传出连接的总数。 -
"totalCreated"报告了当前 mongod/mongos 实例与分片集群或副本集中其他成员之间迄今为止创建的所有传出连接的总数。 -
"totalInUse"提供了当前 mongod/mongos 实例与分片集群或副本集中其他成员之间正在使用的所有传出连接的总数。 -
"totalRefreshing"显示了当前 mongod/mongos 实例与分片集群或副本集中其他成员之间当前正在刷新的所有传出连接的总数。 -
"numClientConnections"标识了当前 mongod/mongos 实例与分片集群或副本集中其他成员之间活动和存储的传出同步连接的数量。这些连接是"totalAvailable"、"totalCreated"和"totalInUse"报告的连接的子集。 -
"numAScopedConnection"报告了当前 mongod/mongos 实例与分片集群或副本集中其他成员之间活动和存储的传出作用域同步连接的数量。这些连接是"totalAvailable"、"totalCreated"和"totalInUse"报告的连接的子集。 -
"pools"显示了按连接池分组的连接统计信息(使用中/可用/已创建/正在刷新)。mongod 或 mongos 有两组不同的传出连接池:-
基于 DBClient 的池(“写入路径”,在
"pools"文档中由字段名"global"标识) -
基于 NetworkInterfaceTL 的池(“读取路径”)
-
-
"hosts"显示了按主机分组的连接统计信息(使用中/可用/已创建/正在刷新)。它报告了当前 mongod/mongos 实例与分片集群或副本集中每个成员之间的连接情况。
在 connPoolStats 的输出中,你可能会看到与其他分片的连接。这些指示分片正在连接到其他分片以迁移数据。一个分片的主节点将直接连接到另一个分片的主节点,并“吸取”其数据。
当进行迁移时,分片会设置一个ReplicaSetMonitor(监视复制集健康状况的进程)来跟踪迁移另一侧的分片的健康状况。mongod永远不会销毁此监视器,因此您可能会在一个复制集的日志中看到关于另一个复制集成员的消息。这是完全正常的,不应对您的应用程序产生任何影响。
限制连接数
当客户端连接到mongos时,mongos会创建一个连接到至少一个分片,以传递客户端的请求。因此,每个连接到mongos的客户端连接至少会导致mongos到分片的一个传出连接。
如果您有许多mongos进程,它们可能会创建比您的分片可以处理的连接更多:默认情况下,mongos将接受最多 65536 个连接(与mongod相同),因此如果您有 5 个每个有 10000 个客户端连接的mongos进程,则可能会尝试创建 50000 个连接到分片!
为了防止这种情况发生,您可以在命令行配置中使用--maxConns选项限制mongos可以创建的连接数。可以使用以下公式来计算单个mongos可以处理的最大连接数:
-
maxConns = maxConnsPrimary − (每个复制集成员数 × 3) −
-
(其他 × 3)/ numMongosProcesses
分解此公式的各部分:
maxConnsPrimary
主节点上的最大连接数,通常设置为 20000,以避免mongos对分片的连接过多。
(每个复制集成员数 × 3)
主要的创建了到每个从节点的连接,并且每个从节点创建了两个到主节点的连接,总共是三个连接。
(其他 x 3)
其他是可能连接到您的mongod的其他杂项进程,例如监控或备份代理、直接的 shell 连接(用于管理)或者连接到其他分片进行迁移。
numMongosProcesses
在分片集群中的mongos总数。
请注意,--maxConns仅防止mongos创建超过此数量的连接。当达到此限制时,它不会执行任何特别有用的操作:它将简单地阻塞请求,等待连接“释放”。因此,您必须防止您的应用程序使用这么多连接,特别是随着mongos进程数量的增加。
当 MongoDB 实例正常退出时,它会在停止之前关闭所有连接。连接到它的成员将立即在这些连接上收到套接字错误并能够刷新它们。但是,如果 MongoDB 实例突然因为断电、崩溃或网络问题而离线,它可能不会干净地关闭所有套接字。在这种情况下,集群中的其他服务器可能会认为它们的连接是健康的,直到它们尝试在其上执行操作。此时,它们将会收到一个错误并刷新连接(如果成员此时已经恢复在线)。
当只有少量连接时,这是一个快速的过程。然而,当需要逐个刷新成千上万的连接时,可能会出现很多错误,因为必须尝试每个与停机成员的连接,确定它们是否无效,并重新建立连接。除了重新启动陷入重连风暴的进程外,没有特别好的方法来防止这种情况。
服务器管理
随着集群的增长,您将需要增加容量或更改配置。本节介绍如何在集群中添加和删除服务器。
添加服务器
您可以随时添加新的 mongos 进程。确保它们的 --configdb 选项指定了正确的配置服务器集,并且它们应立即可供客户端连接。
要添加新的分片,请使用 addShard 命令,如 第十五章 所示。
更改分片中的服务器
在使用分片集群时,您可能希望更改各个分片中的服务器。要更改分片的成员资格,请直接连接到分片的主服务器(而不是通过 mongos)并发出副本集重新配置命令。集群配置将捕捉更改并自动更新 config.shards。不要手动修改 config.shards。
唯一的例外是,如果您以独立服务器作为分片启动集群。
将分片从独立服务器更改为副本集
这样做的最简单方法是添加一个新的空副本集分片,然后删除独立服务器分片(如下一节所述)。迁移将负责将您的数据移动到新的分片。
移除分片
通常情况下,不应从集群中删除分片。如果您经常添加和删除分片,会给系统带来比必要的更大压力。如果添加了太多的分片,最好让系统逐步增长,而不是将其删除然后稍后再添加。但是如果有必要,您可以删除分片。
首先确保均衡器已启动。均衡器将负责在过程中移动您想要移除的分片上的所有数据到其他分片,这个过程称为 draining。要开始 draining,运行 removeShard 命令。removeShard 命令需要分片的名称,并将该分片上的所有数据块移动到其他分片上:
> db.adminCommand({"removeShard" : "shard03"})
{
"msg" : "draining started successfully",
"state" : "started",
"shard" : "shard03",
"note" : "you need to drop or movePrimary these databases",
"dbsToMove" : [ ],
"ok" : 1,
"operationTime" : Timestamp(1541450091, 2),
"$clusterTime" : {
"clusterTime" : Timestamp(1541450091, 2),
"signature" : {
"hash" : BinData(0,"AAAAAAAAAAAAAAAAAAAAAAAAAAA="),
"keyId" : NumberLong(0)
}
}
}
如果有大量或大块要移动,draining 可能需要很长时间。如果有巨块 (见 “巨块”),可能需要临时增加块大小以允许 draining 移动它们。
如果您想了解移动了多少数据,请再次运行 removeShard 以获取当前状态:
> db.adminCommand({"removeShard" : "shard02"})
{
"msg" : "draining ongoing",
"state" : "ongoing",
"remaining" : {
"chunks" : NumberLong(3),
"dbs" : NumberLong(0)
},
"note" : "you need to drop or movePrimary these databases",
"dbsToMove" : [
"video"
],
"ok" : 1,
"operationTime" : Timestamp(1541450139, 1),
"$clusterTime" : {
"clusterTime" : Timestamp(1541450139, 1),
"signature" : {
"hash" : BinData(0,"AAAAAAAAAAAAAAAAAAAAAAAAAAA="),
"keyId" : NumberLong(0)
}
}
}
你可以随时运行 removeShard 多次。
可能需要分割块以便移动,因此在 drain 过程中您可能会看到系统中的块数量增加。例如,假设我们有一个包含以下块分布的五分片集群:
test-rs0 10
test-rs1 10
test-rs2 10
test-rs3 11
test-rs4 11
此集群总共有 52 个数据块。如果我们移除 test-rs3,可能会得到以下结果:
test-rs0 15
test-rs1 15
test-rs2 15
test-rs4 15
现在集群有 60 个数据块,其中 18 个来自分片 test-rs3(开始时有 11 个,另外 7 个来自排空分裂创建的数据块)。
一旦所有数据块都已移动,如果仍然有数据库将移除的分片作为它们的主分片,那么在移除分片之前,你需要先移除它们。分片集群中的每个数据库都有一个主分片。如果你要移除的分片也是集群中某个数据库的主分片,removeShard 将在 "dbsToMove" 字段中列出该数据库。要完成移除分片的过程,你必须在将所有数据迁移到其他分片后,移动数据库或者删除数据库,删除相关的数据文件。removeShard 的输出将类似于:
> db.adminCommand({"removeShard" : "shard02"})
{
"msg" : "draining ongoing",
"state" : "ongoing",
"remaining" : {
"chunks" : NumberLong(3),
"dbs" : NumberLong(0)
},
"note" : "you need to drop or movePrimary these databases",
"dbsToMove" : [
"video"
],
"ok" : 1,
"operationTime" : Timestamp(1541450139, 1),
"$clusterTime" : {
"clusterTime" : Timestamp(1541450139, 1),
"signature" : {
"hash" : BinData(0,"AAAAAAAAAAAAAAAAAAAAAAAAAAA="),
"keyId" : NumberLong(0)
}
}
}
要完成移除操作,请使用 movePrimary 命令移动列出的数据库:
> db.adminCommand({"movePrimary" : "video", "to" : "shard01"})
{
"ok" : 1,
"operationTime" : Timestamp(1541450554, 12),
"$clusterTime" : {
"clusterTime" : Timestamp(1541450554, 12),
"signature" : {
"hash" : BinData(0,"AAAAAAAAAAAAAAAAAAAAAAAAAAA="),
"keyId" : NumberLong(0)
}
}
}
一旦你完成了这个步骤,再运行 removeShard 一次:
> db.adminCommand({"removeShard" : "shard02"})
{
"msg" : "removeshard completed successfully",
"state" : "completed",
"shard" : "shard03",
"ok" : 1,
"operationTime" : Timestamp(1541450619, 2),
"$clusterTime" : {
"clusterTime" : Timestamp(1541450619, 2),
"signature" : {
"hash" : BinData(0,"AAAAAAAAAAAAAAAAAAAAAAAAAAA="),
"keyId" : NumberLong(0)
}
}
}
这不是绝对必要的,但它确认你已完成了这个过程。如果没有数据库将这个分片作为它们的主分片,那么当所有数据块都迁移出分片时,你将会得到这个响应。
警告
一旦开始排空一个分片,就没有内置的方法可以停止它。
数据平衡
通常情况下,MongoDB 会自动处理数据的平衡。本节涵盖如何启用和禁用此自动平衡以及如何干预平衡过程。
负载均衡器
关闭负载均衡器是几乎所有管理活动的先决条件。有一个 shell 辅助工具可以使这一过程更容易:
> sh.setBalancerState(false)
{
"ok" : 1,
"operationTime" : Timestamp(1541450923, 2),
"$clusterTime" : {
"clusterTime" : Timestamp(1541450923, 2),
"signature" : {
"hash" : BinData(0,"AAAAAAAAAAAAAAAAAAAAAAAAAAA="),
"keyId" : NumberLong(0)
}
}
}
当负载均衡器关闭时,新的负载均衡轮不会开始,但关闭它不会立即停止正在进行的负载均衡轮——迁移通常不能立即停止。因此,你应该检查 config.locks 集合,以查看负载均衡轮是否仍在进行中:
> db.locks.find({"_id" : "balancer"})["state"]
0
0 意味着负载均衡器已关闭。
平衡会给系统带来负载:目标分片必须查询源分片中所有文档的数据块并插入它们,然后源分片必须删除它们。特别是在两种情况下,迁移可能会导致性能问题:
-
使用热点分片键将强制进行持续的迁移(因为所有新的数据块将创建在热点上)。你的系统必须有能力处理来自热点分片的数据流。
-
添加一个新的分片将触发大量的迁移,因为负载均衡器试图将其填充。
如果发现迁移影响了应用程序的性能,可以在 config.settings 集合中安排一个平衡窗口的时间。运行以下更新以仅允许在下午 1 点到下午 4 点之间进行平衡。首先确保负载均衡器已开启,然后安排窗口:
> sh.setBalancerState( true )
{
"ok" : 1,
"operationTime" : Timestamp(1541451846, 4),
"$clusterTime" : {
"clusterTime" : Timestamp(1541451846, 4),
"signature" : {
"hash" : BinData(0,"AAAAAAAAAAAAAAAAAAAAAAAAAAA="),
"keyId" : NumberLong(0)
}
}
}
> db.settings.update(
{ _id: "balancer" },
{ $set: { activeWindow : { start : "13:00", stop : "16:00" } } },
{ upsert: true }
)
WriteResult({ "nMatched" : 1, "nUpserted" : 0, "nModified" : 1 })
如果设置了平衡窗口,请密切监视以确保 mongos 实际上可以在你分配给它的时间内保持集群的平衡。
如果计划结合手动均衡和自动均衡器,必须小心,因为自动均衡器总是基于集合的当前状态确定移动内容,并不考虑集合的历史。例如,假设您有 shardA 和 shardB,每个分片持有 500 个块。shardA 正在进行大量写入,因此您关闭了均衡器并将最活跃的 30 个块移动到 shardB。如果此时重新启动均衡器,它将立即从 shardB 移回 30 个块(可能是不同的 30 个块),以平衡块计数。
为了防止这种情况发生,在启动均衡器之前,将 30 个静止的块从 shardB 移动到 shardA。这样分片之间就不会存在不平衡,均衡器会乐意保持当前状态。或者,您可以对 shardA 的块执行 30 次分割,以平衡块计数。
请注意,均衡器仅使用块数作为指标,不考虑数据大小。移动一个块称为迁移,是 MongoDB 在集群中平衡数据的方法。因此,一个具有少量大块的分片可能成为从具有许多小块(但数据量较小)的分片迁移的目标。
修改块大小
一个块中可以包含从零到数百万个文档。一般来说,块越大,迁移到另一个分片的时间就越长。在第十四章中,我们使用了 1 MB 的块大小,这样我们可以轻松快速地观察块的移动。但是,这在实时系统中通常是不切实际的;MongoDB 将不必要地工作来保持分片大小接近几兆字节。默认的块大小是 64 MB,一般情况下提供了迁移和移动的良好平衡。
有时您可能发现 64 MB 的块大小迁移时间过长。为了加快迁移速度,您可以减小块大小。为此,通过 shell 连接到 mongos 并更新 config.settings 集合:
> db.settings.findOne()
{
"_id" : "chunksize",
"value" : 64
}
> db.settings.save({"_id" : "chunksize", "value" : 32})
WriteResult({ "nMatched" : 1, "nUpserted" : 0, "nModified" : 1 })
上述更新将把您的块大小更改为 32 MB。然而,现有的块不会立即更改;自动分割仅在插入或更新时发生。因此,如果您减小块大小,可能需要一些时间让所有块分割到新的大小。
分割无法撤销。如果增加块大小,现有的块只能通过插入或更新来增长,直到达到新的大小。块大小的允许范围是 1 到 1,024 MB,包括两端。
这是一个集群范围的设置:它影响所有集合和数据库。因此,如果您需要一个集合的小块大小和另一个集合的大块大小,则可能需要在两个理想值之间进行妥协(或将集合放入不同的集群)。
提示
如果 MongoDB 迁移过多或者您的文档过大,您可能需要增加块大小。
移动块
正如前面提到的,块中的所有数据存储在特定的分片上。如果该分片的块比其他分片的多,MongoDB 将会将一些块从该分片移动出去。
使用moveChunk shell 辅助程序可以手动移动块:
> sh.moveChunk("video.movies", {imdbId: 500000}, "shard02")
{ "millis" : 4079, "ok" : 1 }
这将移动包含 "imdbId" 为 500000 的文档的块到名为 shard02 的分片。您必须使用分片键(在这种情况下为 "imdbId")来查找要移动的块。通常,指定一个块最简单的方法是使用其下界,虽然块中的任何值都可以工作(上界不行,因为它实际上不在块中)。该命令在返回之前将移动块,因此可能需要一段时间才能运行。如果运行时间较长,日志是查看其操作的最佳位置。
如果一个块大于最大块大小,mongos 将拒绝移动它:
> sh.moveChunk("video.movies", {imdbId: NumberLong("8345072417171006784")},
"shard02")
{
"cause" : {
"chunkTooBig" : true,
"estimatedChunkSize" : 2214960,
"ok" : 0,
"errmsg" : "chunk too big to move"
},
"ok" : 0,
"errmsg" : "move failed"
}
在这种情况下,您必须在移动之前手动分割块,使用splitAt命令:
> db.chunks.find({ns: "video.movies", "min.imdbId":
NumberLong("6386258634539951337")}).pretty()
{
"_id" : "video.movies-imdbId_6386258634539951337",
"ns" : "video.movies",
"min" : {
"imdbId" : NumberLong("6386258634539951337")
},
"max" : {
"imdbId" : NumberLong("8345072417171006784")
},
"shard" : "shard02",
"lastmod" : Timestamp(1, 9),
"lastmodEpoch" : ObjectId("5bdf72c021b6e3be02fabe0c"),
"history" : [
{
"validAfter" : Timestamp(1541370559, 4),
"shard" : "shard02"
}
]
}
> sh.splitAt("video.movies", {"imdbId":
NumberLong("7000000000000000000")})
{
"ok" : 1,
"operationTime" : Timestamp(1541453304, 1),
"$clusterTime" : {
"clusterTime" : Timestamp(1541453306, 5),
"signature" : {
"hash" : BinData(0,"AAAAAAAAAAAAAAAAAAAAAAAAAAA="),
"keyId" : NumberLong(0)
}
}
}
> db.chunks.find({ns: "video.movies", "min.imdbId":
NumberLong("6386258634539951337")}).pretty()
{
"_id" : "video.movies-imdbId_6386258634539951337",
"lastmod" : Timestamp(15, 2),
"lastmodEpoch" : ObjectId("5bdf72c021b6e3be02fabe0c"),
"ns" : "video.movies",
"min" : {
"imdbId" : NumberLong("6386258634539951337")
},
"max" : {
"imdbId" : NumberLong("7000000000000000000")
},
"shard" : "shard02",
"history" : [
{
"validAfter" : Timestamp(1541370559, 4),
"shard" : "shard02"
}
]
}
一旦块被分割成更小的片段,它就应该可以移动了。或者,您可以提高最大块大小然后移动它,但应该尽可能地拆分大块。然而,有时候块无法分割——我们将在接下来看看这种情况。^(1)
巨型块
假设您选择 "date" 字段作为分片键。此集合中的 "date" 字段是一个看起来像 ``"year/month/day"` 的字符串,这意味着 mongos 每天最多可以创建一个块。这样做一段时间很好,直到您的应用程序突然爆红,并且某一天的流量比平常高出一千倍。
这一天的块将比其他任何一天的块都要大得多,但也完全无法分割,因为每个文档对于分片键都有相同的值。
一旦一个块大于在 config.settings 中设置的最大块大小,balancer 将不允许移动该块。这些不可分割、不可移动的块称为巨型块,处理起来很不方便。
让我们举个例子。假设你有三个分片,shard1、shard2 和 shard3。如果你使用“升序分片键”中描述的热点分片键模式,那么所有的写操作都将会进入一个分片——比如说 shard1。分片的主 mongod 将请求 balancer 将每个新的顶部块均匀地移动到其他分片,但 balancer 只能移动非巨型块,因此它将所有小块从热点分片迁移出去。
现在,所有的分片将会有大致相同数量的块,但 shard2 和 shard3 的所有块大小都将小于 64 MB。如果创建巨型块,shard1 的块会越来越多超过 64 MB。因此,shard1 将比其他两个分片更快填满,即使三者之间的块数量完全平衡。
因此,您有巨大块问题的一个指标是一个分片的大小增长速度比其他分片快得多。您还可以查看sh.status()的输出,以查看是否有巨大块,它们将用jumbo属性标记:
> sh.status()
...
{ "x" : -7 } -->> { "x" : 5 } on : shard0001
{ "x" : 5 } -->> { "x" : 6 } on : shard0001 jumbo
{ "x" : 6 } -->> { "x" : 7 } on : shard0001 jumbo
{ "x" : 7 } -->> { "x" : 339 } on : shard0001
...
您可以使用dataSize命令检查块大小。首先,使用config.chunks集合找到块范围:
> use config
> var chunks = db.chunks.find({"ns" : "acme.analytics"}).toArray()
然后使用这些块范围找到可能的巨大块:
> use <dbName>
> db.runCommand({"dataSize" : "<dbName.collName>",
... "keyPattern" : {"date" : 1}, // shard key
... "min" : chunks[0].min,
... "max" : chunks[0].max})
{
"size" : 33567917,
"numObjects" : 108942,
"millis" : 634,
"ok" : 1,
"operationTime" : Timestamp(1541455552, 10),
"$clusterTime" : {
"clusterTime" : Timestamp(1541455552, 10),
"signature" : {
"hash" : BinData(0,"AAAAAAAAAAAAAAAAAAAAAAAAAAA="),
"keyId" : NumberLong(0)
}
}
}
不过要小心,dataSize命令确实需要扫描块的数据来确定其大小。如果可能的话,通过使用对数据的了解缩小搜索范围:是否在某个特定日期创建了巨大块?例如,如果 7 月 1 日是一个非常繁忙的一天,请查找具有该日期的块。
提示
如果你正在使用 GridFS 并且按"files_id"分片,你可以查看fs.files集合来找到文件的大小。
分布巨大块
要解决由巨大块导致失衡的集群问题,必须将它们均匀分布在分片之间。
这是一个复杂的手动过程,但不应造成任何停机(可能会导致速度变慢,因为你将迁移大量数据)。在以下描述中,具有巨大块的分片称为“from”分片。将巨大块迁移到的分片称为“to”分片。请注意,您可能有多个希望迁移块的“from”分片。对于每个分片,重复以下步骤:
-
关闭平衡器。在此过程中,您不希望平衡器试图“帮助”:
> sh.setBalancerState(false) -
MongoDB 不允许您移动大于最大块大小的块,因此暂时增加块大小。记下您的原始块大小,然后将其更改为大型值,如
10000。块大小以兆字节指定。> use config > db.settings.findOne({"_id" : "chunksize"}) { "_id" : "chunksize", "value" : 64 } > db.settings.save({"_id" : "chunksize", "value" : 10000}) -
使用
moveChunk命令将巨大块从“from”分片移出。 -
在“from”分片上运行
splitChunk,直到它拥有大致相同数量的块作为“to”分片。 -
将块大小设置回其原始值:
> db.settings.save({"_id" : "chunksize", "value" : 64}) -
启动平衡器:
> sh.setBalancerState(true)
当再次启用平衡器时,它将再次无法移动巨大块;它们基本上是由它们的大小所固定在那里。
防止巨大块
随着存储数据量的增长,前面描述的手动过程变得难以维持。因此,如果您遇到巨大块问题,应优先采取措施防止它们形成。
要防止巨大块,请修改您的分片键以增加细粒度。您希望几乎每个文档的分片键具有唯一值,或者至少从不具有超过块大小值的数据。
例如,如果你之前使用的是年/月/日的键,可以快速通过添加小时、分钟和秒来提升精细度。同样地,如果你在粗粒度的日志级别上进行分片,可以在分片键中添加一个具有很高粒度的第二字段,例如 MD5 哈希或 UUID。这样,即使第一个字段对许多文档相同,你也可以随时分割一个块。
刷新配置
最后的提示是,有时mongos无法正确从配置服务器更新其配置。如果出现意外配置或mongos看起来过时或找不到已知存在的数据,可以使用flushRouterConfig命令手动清除所有缓存:
> db.adminCommand({"flushRouterConfig" : 1})
如果flushRouterConfig无法工作,重新启动所有的mongos或mongod进程可以清除任何缓存数据。
^(1) MongoDB 4.4 计划在moveChunk函数中添加一个新参数(forceJumbo),以及一个新的负载均衡器配置设置attemptToBalanceJumboChunks来解决超大块的问题。具体详情请参阅这个描述工作的 JIRA 票。
第五部分:应用程序管理
第十八章:查看应用程序的运行情况
一旦您的应用程序启动运行,如何知道它在做什么? 本章介绍了如何查明 MongoDB 运行哪些查询、写入了多少数据以及有关实际运行情况的其他细节。 您将了解以下内容:
-
查找慢速操作并终止它们
-
获取和解释有关集合和数据库的统计信息
-
使用命令行工具来获取 MongoDB 正在执行的情况
查看当前操作
查找运行中的操作的简便方法是查看运行情况。 任何慢速操作更可能显示出来,并且可能运行时间更长。 虽然不能保证,但这是查找可能减缓应用程序的第一步。
要查看正在运行的操作,请使用 db.currentOp() 函数:
> db.currentOp()
{
"inprog": [{
"type" : "op",
"host" : "eoinbrazil-laptop-osx:27017",
"desc" : "conn3",
"connectionId" : 3,
"client" : "127.0.0.1:57181",
"appName" : "MongoDB Shell",
"clientMetadata" : {
"application" : {
"name" : "MongoDB Shell"
},
"driver" : {
"name" : "MongoDB Internal Client",
"version" : "4.2.0"
},
"os" : {
"type" : "Darwin",
"name" : "Mac OS X",
"architecture" : "x86_64",
"version" : "18.7.0"
}
},
"active" : true,
"currentOpTime" : "2019-09-03T23:25:46.380+0100",
"opid" : 13594,
"lsid" : {
"id" : UUID("63b7df66-ca97-41f4-a245-eba825485147"),
"uid" : BinData(0,"47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=")
},
"secs_running" : NumberLong(0),
"microsecs_running" : NumberLong(969),
"op" : "insert",
"ns" : "sample_mflix.items",
"command" : {
"insert" : "items",
"ordered" : false,
"lsid" : {
"id" : UUID("63b7df66-ca97-41f4-a245-eba825485147")
},
"$readPreference" : {
"mode" : "secondaryPreferred"
},
"$db" : "sample_mflix"
},
"numYields" : 0,
"locks" : {
"ParallelBatchWriterMode" : "r",
"ReplicationStateTransition" : "w",
"Global" : "w",
"Database" : "w",
"Collection" : "w"
},
"waitingForLock" : false,
"lockStats" : {
"ParallelBatchWriterMode" : {
"acquireCount" : {
"r" : NumberLong(4)
}
},
"ReplicationStateTransition" : {
"acquireCount" : {
"w" : NumberLong(4)
}
},
"Global" : {
"acquireCount" : {
"w" : NumberLong(4)
}
},
"Database" : {
"acquireCount" : {
"w" : NumberLong(4)
}
},
"Collection" : {
"acquireCount" : {
"w" : NumberLong(4)
}
},
"Mutex" : {
"acquireCount" : {
"r" : NumberLong(196)
}
}
},
"waitingForFlowControl" : false,
"flowControlStats" : {
"acquireCount" : NumberLong(4)
}
}],
"ok": 1
}
这显示数据库正在执行的操作列表。 以下是输出中一些较重要的字段:
"opid"
操作的唯一标识符。 您可以使用此编号来终止一个操作(参见 “终止操作”)。
"active"
此操作是否正在运行。 如果此字段为 false,则意味着操作已放弃或正在等待锁。
"secs_running"
此操作持续的秒数。 您可以使用此操作查找执行时间过长的查询。
"microsecs_running"
此操作持续的微秒数。 您可以使用此操作查找执行时间过长的查询。
"op"
操作类型。 一般为 "query"、"insert"、"update" 或 "remove"。 请注意,数据库命令将作为查询处理。
"desc"
客户端的标识符。 这可以与日志中的消息相关联。 我们示例中与连接相关的每条日志消息都将以 [conn3] 作为前缀,因此您可以使用它来在日志中搜索相关信息。
"locks"
此操作获取的锁类型的描述。
"waitingForLock"
此操作当前是否正在阻塞,等待获取锁。
"numYields"
此操作放弃其锁以允许其他操作继续的次数。 一般来说,任何搜索文档(查询、更新和删除)的操作都可以放弃。 只有在排队等待获取锁的其他操作时,操作才会放弃。 基本上,如果没有处于"waitingForLock"状态的操作,则当前操作将不会放弃。
"lockstats.timeAcquiringMicros"
操作需要获取所需锁的等待时间。
您可以将 currentOp 过滤为仅查找满足特定条件的操作,例如在特定命名空间上的操作或已运行了一定时间的操作。 您可以通过传递查询参数来过滤结果:
> db.currentOp(
{
"active" : true,
"secs_running" : { "$gt" : 3 },
"ns" : /^db1\./
}
)
您可以在 currentOp 中查询任何字段,使用所有常规的查询操作符。
查找问题操作
db.currentOp() 的最常见用法是查找慢操作。你可以使用前面章节描述的过滤技术来查找所有执行时间超过一定阈值的查询,这可能暗示缺少索引或不正确的字段过滤。
有时人们会发现意外的查询在运行,通常是因为某个应用服务器运行了旧版或有 bug 的软件。"client" 字段可以帮助你追踪意外操作的来源。
终止操作
如果你找到一个想要停止的操作,你可以通过传递db.killOp()的"opid"来杀死它:
> db.killOp(123)
并非所有操作都可以被终止。一般来说,只有在操作让出时才能终止它们——所以更新、查找和删除都可以被终止,但通常无法终止持有或等待锁的操作。
一旦你向一个操作发送了“kill”消息,它在db.currentOp()输出中将会有一个"killed"字段。但直到它从当前操作列表中消失之前,它实际上并没有死亡。
在 MongoDB 4.0 中,killOP 方法已扩展以允许在mongos上运行。它现在可以终止在集群中跨多个分片运行的查询(读操作)。在之前的版本中,这需要手动在每个分片的主 mongod 上发出终止命令。
误报
如果你查找慢操作,可能会看到一些长时间运行的内部操作被列出。根据你的设置,MongoDB 可能有几个长时间运行的请求。最常见的是复制线程(它将继续从同步源获取更多操作,尽可能长时间运行)和用于分片的写回监听器。可以忽略任何在local.oplog.rs上长时间运行的查询,以及任何writebacklistener命令。
如果你终止这些操作中的任意一个,MongoDB 将会重新启动它们。但一般情况下不建议这样做。终止复制线程将会暂时停止复制,而终止写回监听器可能导致mongos错过合法的写入错误。
防止幽灵操作
有一个奇怪的、特定于 MongoDB 的问题,你可能会遇到,特别是当你将数据大批量加载到一个集合中时。假设你有一个作业正在向 MongoDB 发送数千个更新操作,并且 MongoDB 几乎停顿下来了。你迅速停止了作业并杀死了所有当前正在进行的更新。然而,即使作业不再运行,你仍然会看到新的更新操作出现在你杀死旧操作之后!
如果您使用未确认的写入加载数据,您的应用程序可能会比 MongoDB 更快地向其发送写入请求。如果 MongoDB 被堵塞,这些写入将堆积在操作系统的套接字缓冲区中。当您终止 MongoDB 正在处理的写入时,这将允许 MongoDB 开始处理缓冲区中的写入。即使停止客户端发送写入,任何进入缓冲区的写入也将由 MongoDB 处理,因为它们已经“接收到”(只是尚未处理)。
要防止这些幻写的最佳方法是进行确认的写入:确保每次写入都等到前一次写入完成,而不仅仅是等到前一次写入已经在数据库服务器的缓冲区中。
使用系统分析器
要查找慢速操作,您可以使用系统分析器,它会记录一个特殊的system.profile集合中的操作。分析器可以为您提供大量关于长时间操作的信息,但这也会带来代价:它会减慢mongod的整体性能。因此,您可能只想定期打开分析器来捕获部分流量。如果系统已经负载过重,您可能希望使用本章描述的其他技术来诊断问题。
默认情况下,分析器处于关闭状态,并且不记录任何内容。您可以通过在 shell 中运行db.setProfilingLevel()来打开它:
> db.setProfilingLevel(2)
{ "was" : 0, "slowms" : 100, "ok" : 1 }
级别 2 表示“profile everything”。数据库接收的每个读取和写入请求都将记录在当前数据库的system.profile集合中。分析是按数据库启用的,并且会产生严重的性能损失:每次写入都必须额外写入一次,每次读取都必须获取写入锁(因为它必须将条目写入system.profile集合)。但它将为您提供系统正在做什么的详尽列表:
> db.foo.insert({x:1})
> db.foo.update({},{$set:{x:2}})
> db.foo.remove()
> db.system.profile.find().pretty()
{
"op" : "insert",
"ns" : "sample_mflix.foo",
"command" : {
"insert" : "foo",
"ordered" : true,
"lsid" : {
"id" : UUID("63b7df66-ca97-41f4-a245-eba825485147")
},
"$readPreference" : {
"mode" : "secondaryPreferred"
},
"$db" : "sample_mflix"
},
"ninserted" : 1,
"keysInserted" : 1,
"numYield" : 0,
"locks" : { ... },
"flowControl" : {
"acquireCount" : NumberLong(3)
},
"responseLength" : 45,
"protocol" : "op_msg",
"millis" : 33,
"client" : "127.0.0.1",
"appName" : "MongoDB Shell",
"allUsers" : [ ],
"user" : ""
}
{
"op" : "update",
"ns" : "sample_mflix.foo",
"command" : {
"q" : {
},
"u" : {
"$set" : {
"x" : 2
}
},
"multi" : false,
"upsert" : false
},
"keysExamined" : 0,
"docsExamined" : 1,
"nMatched" : 1,
"nModified" : 1,
"numYield" : 0,
"locks" : { ... },
"flowControl" : {
"acquireCount" : NumberLong(1)
},
"millis" : 0,
"planSummary" : "COLLSCAN",
"execStats" : { ...
"inputStage" : {
...
}
},
"ts" : ISODate("2019-09-03T22:39:33.856Z"),
"client" : "127.0.0.1",
"appName" : "MongoDB Shell",
"allUsers" : [ ],
"user" : ""
}
{
"op" : "remove",
"ns" : "sample_mflix.foo",
"command" : {
"q" : {
},
"limit" : 0
},
"keysExamined" : 0,
"docsExamined" : 1,
"ndeleted" : 1,
"keysDeleted" : 1,
"numYield" : 0,
"locks" : { ... },
"flowControl" : {
"acquireCount" : NumberLong(1)
},
"millis" : 0,
"planSummary" : "COLLSCAN",
"execStats" : { ...
"inputStage" : { ... }
},
"ts" : ISODate("2019-09-03T22:39:33.858Z"),
"client" : "127.0.0.1",
"appName" : "MongoDB Shell",
"allUsers" : [ ],
"user" : ""
}
您可以使用"client"字段查看哪些用户将哪些操作发送到数据库。如果使用了身份验证,您还可以看到每个操作由哪个用户执行。
通常情况下,您可能不关心数据库正在进行的大多数操作,只关心慢速操作。为此,您可以将分析级别设置为 1。默认情况下,级别 1 会记录持续时间超过 100 毫秒的操作。您还可以指定第二个参数,定义对您而言什么是“慢速”操作。这将记录所有持续时间超过 500 毫秒的操作:
> db.setProfilingLevel(1, 500)
{ "was" : 2, "slowms" : 100, "ok" : 1 }
要关闭分析,将分析级别设置为 0:
> db.setProfilingLevel(0)
{ "was" : 1, "slowms" : 500, "ok" : 1 }
通常不建议将slowms设置为一个较低的值。即使关闭了分析,slowms也会对mongod产生影响:它设置在日志中打印慢速操作的阈值。因此,如果将slowms降低以进行分析,然后关闭分析之前可能需要将其提高。
您可以使用db.getProfilingLevel()来查看当前的分析级别。分析级别不是持久的:重新启动数据库会清除级别。
有用于配置分析级别的命令行选项,即--profile *`level`*和--slowms *`time`*,但增加分析级别通常是一种临时调试措施,不是长期配置的内容。
在 MongoDB 4.2 中,扩展了读/写操作的分析器条目和诊断日志消息,以帮助改进慢查询的识别,增加了queryHash和planCacheKey字段。queryHash字符串表示查询形状的哈希,仅依赖于查询形状。每个查询形状都与一个queryHash相关联,这样可以更容易地突出显示使用相同形状的查询。planCacheKey是与查询关联的计划缓存条目的关键的哈希。它包括查询形状的详细信息以及当前可用的索引的详细信息。这些帮助您将分析器的可用信息与查询性能诊断相关联。
如果您启用了性能分析,并且system.profile集合尚不存在,MongoDB 会为其创建一个小的固定大小集合(几兆字节)。如果您希望长时间运行分析器,则这可能不足以记录您需要记录的操作数量。您可以通过关闭分析、删除system.profile集合,并创建所需大小的新system.profile固定大小集合来创建一个较大的system.profile集合。然后在数据库上启用分析。
计算大小
为了正确配置磁盘和 RAM 的数量,了解文档、索引、集合和数据库占用的空间是很有用的。参见“计算工作集”以获取有关计算工作集的信息。
文档
获取文档大小的最简单方法是使用 Shell 的Object.bsonsize()函数。传入任何文档即可获取存储在 MongoDB 中时的大小。
例如,您可以看到将_id存储为ObjectId比存储为字符串更有效:
> Object.bsonsize({_id:ObjectId()})
22
> // ""+ObjectId() converts the ObjectId to a string
> Object.bsonsize({_id:""+ObjectId()})
39
更实际地说,您可以直接从您的集合中传入文档:
> Object.bsonsize(db.users.findOne())
这显示了文档在磁盘上占用了多少字节。然而,这并不包括填充或索引的计数,这些通常是集合大小的重要因素之一。
集合
要查看整个集合的信息,可以使用stats函数:
>db.movies.stats()
{
"ns" : "sample_mflix.movies",
"size" : 65782298,
"count" : 45993,
"avgObjSize" : 1430,
"storageSize" : 45445120,
"capped" : false,
"wiredTiger" : {
"metadata" : {
"formatVersion" : 1
},
"creationString" : "access_pattern_hint=none,allocation_size=4KB,\
app_metadata=(formatVersion=1),assert=(commit_timestamp=none,\
read_timestamp=none),block_allocation=best,block_compressor=\
snappy,cache_resident=false,checksum=on,colgroups=,collator=,\
columns=,dictionary=0,encryption=(keyid=,name=),exclusive=\
false,extractor=,format=btree,huffman_key=,huffman_value=,\
ignore_in_memory_cache_size=false,immutable=false,internal_item_\
max=0,internal_key_max=0,internal_key_truncate=true,internal_\
page_max=4KB,key_format=q,key_gap=10,leaf_item_max=0,leaf_key_\
max=0,leaf_page_max=32KB,leaf_value_max=64MB,log=(enabled=true),\
lsm=(auto_throttle=true,bloom=true,bloom_bit_count=16,bloom_\
config=,bloom_hash_count=8,bloom_oldest=false,chunk_count_limit\
=0,chunk_max=5GB,chunk_size=10MB,merge_custom=(prefix=,start_\
generation=0,suffix=),merge_max=15,merge_min=0),memory_page_image\
_max=0,memory_page_max=10m,os_cache_dirty_max=0,os_cache_max=0,\
prefix_compression=false,prefix_compression_min=4,source=,split_\
deepen_min_child=0,split_deepen_per_child=0,split_pct=90,type=file,\
value_format=u",
"type" : "file",
"uri" : "statistics:table:collection-14--2146526997547809066",
"LSM" : {
"bloom filter false positives" : 0,
"bloom filter hits" : 0,
"bloom filter misses" : 0,
"bloom filter pages evicted from cache" : 0,
"bloom filter pages read into cache" : 0,
"bloom filters in the LSM tree" : 0,
"chunks in the LSM tree" : 0,
"highest merge generation in the LSM tree" : 0,
"queries that could have benefited from a Bloom filter
that did not exist" : 0,
"sleep for LSM checkpoint throttle" : 0,
"sleep for LSM merge throttle" : 0,
"total size of bloom filters" : 0
},
"block-manager" : {
"allocations requiring file extension" : 0,
"blocks allocated" : 1358,
"blocks freed" : 1322,
"checkpoint size" : 39219200,
"file allocation unit size" : 4096,
"file bytes available for reuse" : 6209536,
"file magic number" : 120897,
"file major version number" : 1,
"file size in bytes" : 45445120,
"minor version number" : 0
},
"btree" : {
"btree checkpoint generation" : 22,
"column-store fixed-size leaf pages" : 0,
"column-store internal pages" : 0,
"column-store variable-size RLE encoded values" : 0,
"column-store variable-size deleted values" : 0,
"column-store variable-size leaf pages" : 0,
"fixed-record size" : 0,
"maximum internal page key size" : 368,
"maximum internal page size" : 4096,
"maximum leaf page key size" : 2867,
"maximum leaf page size" : 32768,
"maximum leaf page value size" : 67108864,
"maximum tree depth" : 0,
"number of key/value pairs" : 0,
"overflow pages" : 0,
"pages rewritten by compaction" : 1312,
"row-store empty values" : 0,
"row-store internal pages" : 0,
"row-store leaf pages" : 0
},
"cache" : {
"bytes currently in the cache" : 40481692,
"bytes dirty in the cache cumulative" : 40992192,
"bytes read into cache" : 37064798,
"bytes written from cache" : 37019396,
"checkpoint blocked page eviction" : 0,
"data source pages selected for eviction unable to be evicted" : 32,
"eviction walk passes of a file" : 0,
"eviction walk target pages histogram - 0-9" : 0,
"eviction walk target pages histogram - 10-31" : 0,
"eviction walk target pages histogram - 128 and higher" : 0,
"eviction walk target pages histogram - 32-63" : 0,
"eviction walk target pages histogram - 64-128" : 0,
"eviction walks abandoned" : 0,
"eviction walks gave up because they restarted their walk twice" : 0,
"eviction walks gave up because they saw too many pages
and found no candidates" : 0,
"eviction walks gave up because they saw too many pages
and found too few candidates" : 0,
"eviction walks reached end of tree" : 0,
"eviction walks started from root of tree" : 0,
"eviction walks started from saved location in tree" : 0,
"hazard pointer blocked page eviction" : 0,
"in-memory page passed criteria to be split" : 0,
"in-memory page splits" : 0,
"internal pages evicted" : 8,
"internal pages split during eviction" : 0,
"leaf pages split during eviction" : 0,
"modified pages evicted" : 1312,
"overflow pages read into cache" : 0,
"page split during eviction deepened the tree" : 0,
"page written requiring cache overflow records" : 0,
"pages read into cache" : 1330,
"pages read into cache after truncate" : 0,
"pages read into cache after truncate in prepare state" : 0,
"pages read into cache requiring cache overflow entries" : 0,
"pages requested from the cache" : 3383,
"pages seen by eviction walk" : 0,
"pages written from cache" : 1334,
"pages written requiring in-memory restoration" : 0,
"tracked dirty bytes in the cache" : 0,
"unmodified pages evicted" : 8
},
"cache_walk" : {
"Average difference between current eviction generation
when the page was last considered" : 0,
"Average on-disk page image size seen" : 0,
"Average time in cache for pages that have been visited
by the eviction server" : 0,
"Average time in cache for pages that have not been visited
by the eviction server" : 0,
"Clean pages currently in cache" : 0,
"Current eviction generation" : 0,
"Dirty pages currently in cache" : 0,
"Entries in the root page" : 0,
"Internal pages currently in cache" : 0,
"Leaf pages currently in cache" : 0,
"Maximum difference between current eviction generation
when the page was last considered" : 0,
"Maximum page size seen" : 0,
"Minimum on-disk page image size seen" : 0,
"Number of pages never visited by eviction server" : 0,
"On-disk page image sizes smaller than a single allocation unit" : 0,
"Pages created in memory and never written" : 0,
"Pages currently queued for eviction" : 0,
"Pages that could not be queued for eviction" : 0,
"Refs skipped during cache traversal" : 0,
"Size of the root page" : 0,
"Total number of pages currently in cache" : 0
},
"compression" : {
"compressed page maximum internal page size
prior to compression" : 4096,
"compressed page maximum leaf page size
prior to compression " : 131072,
"compressed pages read" : 1313,
"compressed pages written" : 1311,
"page written failed to compress" : 1,
"page written was too small to compress" : 22
},
"cursor" : {
"bulk loaded cursor insert calls" : 0,
"cache cursors reuse count" : 0,
"close calls that result in cache" : 0,
"create calls" : 1,
"insert calls" : 0,
"insert key and value bytes" : 0,
"modify" : 0,
"modify key and value bytes affected" : 0,
"modify value bytes modified" : 0,
"next calls" : 0,
"open cursor count" : 0,
"operation restarted" : 0,
"prev calls" : 1,
"remove calls" : 0,
"remove key bytes removed" : 0,
"reserve calls" : 0,
"reset calls" : 2,
"search calls" : 0,
"search near calls" : 0,
"truncate calls" : 0,
"update calls" : 0,
"update key and value bytes" : 0,
"update value size change" : 0
},
"reconciliation" : {
"dictionary matches" : 0,
"fast-path pages deleted" : 0,
"internal page key bytes discarded using suffix compression" : 0,
"internal page multi-block writes" : 0,
"internal-page overflow keys" : 0,
"leaf page key bytes discarded using prefix compression" : 0,
"leaf page multi-block writes" : 0,
"leaf-page overflow keys" : 0,
"maximum blocks required for a page" : 1,
"overflow values written" : 0,
"page checksum matches" : 0,
"page reconciliation calls" : 1334,
"page reconciliation calls for eviction" : 1312,
"pages deleted" : 0
},
"session" : {
"object compaction" : 4
},
"transaction" : {
"update conflicts" : 0
}
},
"nindexes" : 5,
"indexBuilds" : [ ],
"totalIndexSize" : 46292992,
"indexSizes" : {
"_id_" : 446464,
"$**_text" : 44474368,
"genres_1_imdb.rating_1_metacritic_1" : 724992,
"tomatoes_rating" : 307200,
"getMovies" : 339968
},
"scaleFactor" : 1,
"ok" : 1
}
stats从命名空间("sample_mflix.movies")开始,接着是集合中所有文档的计数。接下来的几个字段涉及集合的大小。"size"是调用每个元素的Object.bsonsize()时得到的值总和:这是未压缩时集合文档在内存中占用的实际字节数。类似地,如果将"avgObjSize"乘以"count",你将得到未压缩的"size"内存中的值。
正如前文所述,仅统计文档字节总数并未计算集合压缩后的节省空间。"storageSize"可能比"size"小,反映了通过压缩节省的空间。
"nindexes"是集合上的索引数。索引在建立完成之前不计入"nindexes",并且在此列表中出现之前不能使用。通常,索引会比它们存储的数据量大得多。通过使用右平衡索引(如“复合索引简介”中所述),可以最小化这种空闲空间。随机分布的索引通常会有约 50%的空闲空间,而升序索引会有 10%的空闲空间。
随着你的集合变得越来越大,可能会发现阅读stats输出变得困难,因为其尺寸可能达到数十亿字节甚至更大。因此,你可以传入一个缩放因子:1024表示千字节,1024*1024表示兆字节,以此类推。例如,以下命令将以 TB 单位获取集合的统计信息:
> db.big.stats(1024*1024*1024*1024)
数据库
数据库有一个类似于集合的stats函数:
> db.stats()
{
"db" : "sample_mflix",
"collections" : 5,
"views" : 0,
"objects" : 98308,
"avgObjSize" : 819.8680982219148,
"dataSize" : 80599593,
"storageSize" : 53620736,
"numExtents" : 0,
"indexes" : 12,
"indexSize" : 47001600,
"scaleFactor" : 1,
"fsUsedSize" : 355637043200,
"fsTotalSize" : 499963174912,
"ok" : 1
}
首先,我们有数据库的名称、其包含的集合数量以及数据库的视图数。"objects"是此数据库中所有集合中文档的总数。
文档的大部分包含关于数据大小的信息。"fsTotalSize"应始终最大:它是 MongoDB 实例存储数据的文件系统的总容量大小。"fsUsedSize"表示目前 MongoDB 在该文件系统中使用的总空间。这应该对应于数据目录中所有文件使用的总空间。
接下来最大的字段通常会是"dataSize",它是此数据库中保存的未压缩数据的大小。这与"storageSize"不匹配,因为在 WiredTiger 中数据通常是压缩的。"indexSize"是此数据库所有索引占用的空间大小。
db.stats()可以像集合的stats函数一样接受一个缩放参数。如果在不存在的数据库上调用db.stats(),则所有值都将为零。
请记住,在高锁定百分比系统上列出数据库可能会非常缓慢,并阻塞其他操作。如果可能的话,请避免执行此操作。
使用 mongotop 和 mongostat
MongoDB 配备了几个命令行工具,可以通过每隔几秒钟打印统计信息来帮助您确定它正在执行的操作。
mongotop 类似于 Unix 的 top 实用程序:它提供了最繁忙的集合概览。您还可以运行 mongotop --locks 来获取每个数据库的锁定统计信息。
mongostat 提供了全局服务器信息。默认情况下,mongostat 每秒打印一次统计信息,但可以通过命令行传递不同的秒数进行配置。每个字段显示的是自上次打印以来活动发生的次数:
insert/query/update/delete/getmore/command
每种操作的简单计数。
flushes
mongod 将数据刷新到磁盘的次数。
mapped
mongod 映射的内存量。通常大致等于您的数据目录的大小。
vsize
mongod 正在使用的虚拟内存量。这通常是数据目录大小的两倍(一次用于映射文件,再次用于日志记录)。
res
mongod 正在使用的内存量。这通常应尽可能接近机器上所有内存的总量。
locked db
在最后的时间片中锁定时间最长的数据库。此字段报告了数据库被锁定的时间百分比,以及全局锁定持续的时间,因此该值可能超过 100%。
idx miss %
索引访问百分比,由于索引条目或搜索的索引部分不在内存中(因此 mongod 必须访问磁盘),导致页面错误。这是输出中最令人困惑的字段。
qr|qw
读取和写入队列的大小(即有多少读取和写入操作正被阻塞等待处理)。
ar|aw
当前活跃客户端数量(即当前执行读取和写入操作的客户端数)。
netIn
MongoDB 计算的网络字节输入量(与操作系统测量的不一定相同)。
netOut
MongoDB 计算的网络字节输出量。
conn
此服务器打开的连接数,包括传入和传出的连接。
time
统计数据获取时的时间。
您可以在副本集或分片集群上运行 mongostat。如果使用 --discover 选项,mongostat 将尝试从初始连接的成员找到集合或群集的所有成员,并为每个服务器每秒打印一行数据。对于大型群集,这可能会快速变得不可管理,但对于小型群集和可以消费数据并以更可读的形式呈现的工具,这可能是有用的。
mongostat 是快速获取数据库正在执行的操作快照的好方法,但是对于长期监控,推荐使用类似 MongoDB Atlas 或 Ops Manager 的工具(参见 第二十二章)。
第十九章:MongoDB 安全简介
为了保护您的 MongoDB 集群及其保存的数据,您将希望采取以下安全措施:
-
启用授权并强制认证
-
加密通信
-
加密数据
本章展示如何使用 MongoDB 对 x.509 的支持来配置认证和传输层加密,以确保 MongoDB 副本集中客户端和服务器之间的安全通信。我们将在后续章节中讨论在存储层加密数据的方法。
MongoDB 的认证和授权
虽然认证和授权密切相关,但重要的是要注意认证与授权是不同的。认证的目的是验证用户的身份,而授权则确定经过验证的用户对资源和操作的访问权限。
认证机制
在 MongoDB 集群上启用授权强制认证,并确保用户只能执行其角色授权的操作。MongoDB 的社区版支持 SCRAM(Salted Challenge Response Authentication Mechanism)和 x.509 证书认证。除了 SCRAM 和 x.509 之外,MongoDB 企业版还支持 Kerberos 认证和 LDAP 代理认证。有关 MongoDB 支持的各种认证机制的详细信息,请参阅文档。在本章中,我们将重点介绍 x.509 认证。x.509 数字证书使用广泛接受的 x.509 公钥基础设施(PKI)标准来验证公钥属于呈现者。
授权
在 MongoDB 中添加用户时,必须在特定数据库中创建用户。该数据库是用户的认证数据库;您可以使用任何数据库来完成此操作。用户名和认证数据库作为用户的唯一标识符。但是,用户的权限不仅限于其认证数据库。创建用户时,可以指定用户可以对其应具有访问权限的任何资源执行的操作。资源包括集群、数据库和集合。
MongoDB 提供了多个内置角色,为数据库用户授予常见所需权限。其中包括以下内容:
read
在所有非系统集合上读取数据,以及在system.indexes、system.js和system.namespaces系统集合上读取数据。
readWrite
提供与read相同的权限,以及在所有非系统集合和system.js集合上修改数据的能力。
dbAdmin
执行与模式相关的管理任务,如索引和收集统计信息(不授予用户和角色管理的权限)。
userAdmin
创建和修改当前数据库上的角色和用户。
dbOwner
结合了readWrite、dbAdmin和userAdmin角色授予的权限。
clusterManager
在集群上执行管理和监控操作。
clusterMonitor
提供只读访问权限,例如 MongoDB Cloud Manager 和 Ops Manager 监控代理的监控工具。
hostManager
监控和管理服务器。
clusterAdmin
结合 clusterManager、clusterMonitor 和 hostManager 角色授予的权限,以及 dropDatabase 操作。
backup
提供足够的权限使用 MongoDB Cloud Manager 备份代理或 Ops Manager 备份代理,或使用 mongodump 对整个 mongod 实例进行备份。
restore
提供从不包括 system.profile 集合数据的备份中恢复数据所需的权限。
readAnyDatabase
提供与 read 权限相同的所有数据库的特权,除了 local 和 config,还包括整个集群的 listDatabases 操作。
readWriteAnyDatabase
提供与 readWrite 权限相同的所有数据库的特权,除了 local 和 config,还包括整个集群的 listDatabases 操作。
userAdminAnyDatabase
提供与 userAdmin 权限相同的所有数据库的特权,除了 local 和 config(实质上是超级用户角色)。
dbAdminAnyDatabase
提供与 dbAdmin 权限相同的所有数据库的特权,除了 local 和 config,还包括整个集群的 listDatabases 操作。
root
提供操作和所有资源的权限,这些权限结合了 readWriteAnyDatabase、dbAdminAnyDatabase、userAdminAnyDatabase、clusterAdmin、restore 和 backup 角色。
您还可以创建所谓的“用户定义角色”,这些角色是自定义角色,将执行特定操作的授权组合在一起,并使用名称标记,以便您可以轻松地将此权限集合授予多个用户。
深入研究内置角色或用户定义角色超出了本章的范围。但是,此介绍应该让您对 MongoDB 授权的可能性有了很好的了解。更详细的信息,请参阅 MongoDB 文档中的授权部分。
要确保根据需要添加新用户,必须首先创建管理员用户。无论您使用哪种认证模式(x.509 也不例外),MongoDB 在启用认证和授权时都不会创建默认的 root 或 admin 用户。
在 MongoDB 中,默认情况下未启用认证和授权。您必须使用 mongod 命令的 --auth 选项或在 MongoDB 配置文件中的 security.authorization 设置中指定值 "enabled" 明确启用它们。
要配置副本集,请先在未启用认证和授权的情况下启动它,然后创建管理员用户和每个客户端所需的用户。
使用 x.509 证书对成员和客户端进行认证
鉴于所有生产 MongoDB 集群由多个成员组成,为了保护集群,所有在集群内部通信的服务都必须进行相互认证。副本集的每个成员必须与其他成员进行身份验证,以便交换数据。同样,客户端必须与主服务器和任何次要服务器进行身份验证才能进行通信。
对于 x.509,有必要让一个受信任的认证机构(CA)签署所有证书。签署证明了证书主题的命名主体拥有与该证书关联的公钥。CA 充当可信第三方,以防止中间人攻击。
图 19-1 展示了用于保护三成员 MongoDB 副本集的 x.509 身份验证。请注意客户端与副本集成员之间的认证以及与 CA 的信任关系。

图 19-1. 本章使用的三成员副本集的 X.509 身份验证信任层次结构概述
每个成员和客户端都有自己由 CA 签名的证书。对于生产环境使用,您的 MongoDB 部署应该使用由单个证书颁发机构生成和签名的有效证书。您或您的组织可以生成和维护独立的证书颁发机构,或者可以使用第三方 TLS/SSL 供应商生成的证书。
我们将用于内部认证以验证集群成员资格的证书称为成员证书。成员证书和客户端证书(用于验证客户端)的结构如下所示:
Certificate:
Data:
Version: 1 (0x0)
Serial Number: 1 (0x1)
Signature Algorithm: sha256WithRSAEncryption
Issuer: C=US, ST=NY, L=New York, O=MongoDB, CN=CA-SIGNER
Validity
Not Before: Nov 11 22:00:03 2018 GMT
Not After : Nov 11 22:00:03 2019 GMT
Subject: C=US, ST=NY, L=New York, O=MongoDB, OU=MyServers, CN=server1
Subject Public Key Info:
Public Key Algorithm: rsaEncryption
Public-Key: (2048 bit)
Modulus:
00:d3:1c:29:ba:3d:29:44:3b:2b:75:60:95:c8:83:
fc:32:1a:fa:29:5c:56:f3:b3:66:88:7f:f9:f9:89:
ff:c2:51:b9:ca:1d:4c:d8:b8:5a:fd:76:f5:d3:c9:
95:9c:74:52:e9:8d:5f:2e:6b:ca:f8:6a:16:17:98:
dc:aa:bf:34:d0:44:33:33:f3:9d:4b:7e:dd:7a:19:
1b:eb:3b:9e:21:d9:d9:ba:01:9c:8b:16:86:a3:52:
a3:e6:e4:5c:f7:0c:ab:7a:1a:be:c6:42:d3:a6:01:
8e:0a:57:b2:cd:5b:28:ee:9d:f5:76:ca:75:7a:c1:
7c:42:d1:2a:7f:17:fe:69:17:49:91:4b:ca:2e:39:
b4:a5:e0:03:bf:64:86:ca:15:c7:b2:f7:54:00:f7:
02:fe:cf:3e:12:6b:28:58:1c:35:68:86:3f:63:46:
75:f1:fe:ac:1b:41:91:4f:f2:24:99:54:f2:ed:5b:
fd:01:98:65:ac:7a:7a:57:2f:a8:a5:5a:85:72:a6:
9e:fb:44:fb:3b:1c:79:88:3f:60:85:dd:d1:5c:1c:
db:62:8c:6a:f7:da:ab:2e:76:ac:af:6d:7d:b1:46:
69:c1:59:db:c6:fb:6f:e1:a3:21:0c:5f:2e:8e:a7:
d5:73:87:3e:60:26:75:eb:6f:10:c2:64:1d:a6:19:
f3:0b
Exponent: 65537 (0x10001)
Signature Algorithm: sha256WithRSAEncryption
5d:dd:b2:35:be:27:c2:41:4a:0d:c7:8c:c9:22:05:cd:eb:88:
9d:71:4f:28:c1:79:71:3c:6d:30:19:f4:9c:3d:48:3a:84:d0:
19:00:b1:ec:a9:11:02:c9:a6:9c:74:e7:4e:3c:3a:9f:23:30:
50:5a:d2:47:53:65:06:a7:22:0b:59:71:b0:47:61:62:89:3d:
cf:c6:d8:b3:d9:cc:70:20:35:bf:5a:2d:14:51:79:4b:7c:00:
30:39:2d:1d:af:2c:f3:32:fe:c2:c6:a5:b8:93:44:fa:7f:08:
85:f0:01:31:29:00:d4:be:75:7e:0d:f9:1a:f5:e9:75:00:9a:
7b:d0:eb:80:b1:01:00:c0:66:f8:c9:f0:35:6e:13:80:70:08:
5b:95:53:4b:34:ec:48:e3:02:88:5c:cd:a0:6c:b4:bc:65:15:
4d:c8:41:9d:00:f5:e7:f2:d7:f5:67:4a:32:82:2a:04:ae:d7:
25:31:0f:34:e8:63:a5:93:f2:b5:5a:90:71:ed:77:2a:a6:15:
eb:fc:c3:ac:ef:55:25:d1:a1:31:7a:2c:80:e3:42:c2:b3:7d:
5e:9a:fc:e4:73:a8:39:50:62:db:b1:85:aa:06:1f:42:27:25:
4b:24:cf:d0:40:ca:51:13:94:97:7f:65:3e:ed:d9:3a:67:08:
79:64:a1:ba
-----BEGIN CERTIFICATE-----
MIIDODCCAiACAQEwDQYJKoZIhvcNAQELBQAwWTELMAkGA1UEBhMCQ04xCzAJBgNV
BAgMAkdEMREwDwYDVQQHDAhTaGVuemhlbjEWMBQGA1UECgwNTW9uZ29EQiBDaGlu
YTESMBAGA1UEAwwJQ0EtU0lHTkVSMB4XDTE4MTExMTIyMDAwM1oXDTE5MTExMTIy
MDAwM1owazELMAkGA1UEBhMCQ04xCzAJBgNVBAgMAkdEMREwDwYDVQQHDAhTaGVu
emhlbjEWMBQGA1UECgwNTW9uZ29EQiBDaGluYTESMBAGA1UECwwJTXlTZXJ2ZXJz
MRAwDgYDVQQDDAdzZXJ2ZXIxMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC
AQEA0xwpuj0pRDsrdWCVyIP8Mhr6KVxW87NmiH/5+Yn/wlG5yh1M2Lha/Xb108mV
nHRS6Y1fLmvK+GoWF5jcqr800EQzM/OdS37dehkb6zueIdnZugGcixaGo1Kj5uRc
9wyrehq+xkLTpgGOCleyzVso7p31dsp1esF8QtEqfxf+aRdJkUvKLjm0peADv2SG
yhXHsvdUAPcC/s8+EmsoWBw1aIY/Y0Z18f6sG0GRT/IkmVTy7Vv9AZhlrHp6Vy+o
pVqFcqae+0T7Oxx5iD9ghd3RXBzbYoxq99qrLnasr219sUZpwVnbxvtv4aMhDF8u
jqfVc4c+YCZ1628QwmQdphnzCwIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQBd3bI1
vifCQUoNx4zJIgXN64idcU8owXlxPG0wGfScPUg6hNAZALHsqRECyaacdOdOPDqf
IzBQWtJHU2UGpyILWXGwR2FiiT3Pxtiz2cxwIDW/Wi0UUXlLfAAwOS0dryzzMv7C
xqW4k0T6fwiF8AExKQDUvnV+Dfka9el1AJp70OuAsQEAwGb4yfA1bhOAcAhblVNL
NOxI4wKIXM2gbLS8ZRVNyEGdAPXn8tf1Z0oygioErtclMQ806GOlk/K1WpBx7Xcq
phXr/MOs71Ul0aExeiyA40LCs31emvzkc6g5UGLbsYWqBh9CJyVLJM/QQMpRE5SX
f2U+7dk6Zwh5ZKG6
-----END CERTIFICATE-----
在 MongoDB 中使用 x.509 身份验证时,成员证书必须具备以下属性:
-
单个 CA 必须为集群成员颁发所有 x.509 证书。
-
成员证书主题中的专有名称(
DN)必须为以下至少一个属性指定非空值:组织(O)、组织单位(OU)或域组件(DC)。 -
O、OU和DC属性必须与其他集群成员的证书中的属性匹配。 -
CN(Common Name)或主体备用名称(SAN)必须与集群中其他成员使用的服务器主机名匹配。
MongoDB 身份验证和传输层加密教程
在本教程中,我们将设置一个根 CA 和一个中间 CA。最佳实践建议使用中间 CA 签署服务器和客户端证书。
建立 CA
在我们可以为复制集的成员生成签名证书之前,我们必须首先解决证书颁发机构的问题。如前所述,我们可以生成并维护独立的证书颁发机构,也可以使用第三方 TLS/SSL 供应商生成的证书。我们将生成自己的 CA 以在本章的运行示例中使用。请注意,您可以从为本书维护的 GitHub 存储库中访问本章中的所有代码示例。这些示例来自您可以用于部署安全复制集的脚本。您将在这些示例中看到来自此脚本的注释。
生成根 CA
要生成我们的 CA,我们将使用 OpenSSL。请确保您的本地计算机上有 OpenSSL 可用。
根 CA 位于证书链的顶部。这是信任的最终来源。理想情况下,应该使用第三方 CA。然而,在隔离网络(大型企业环境中典型情况)或测试目的中,您将需要使用本地 CA。
首先,我们将初始化一些变量:
dn_prefix="/C=US/ST=NY/L=New York/O=MongoDB"
ou_member="MyServers"
ou_client="MyClients"
mongodb_server_hosts=( "server1" "server2" "server3" )
mongodb_client_hosts=( "client1" "client2" )
mongodb_port=27017
然后,我们将创建一个密钥对并将其存储在文件 root-ca.key 中:
# !!! In production you will want to password-protect the keys
# openssl genrsa -aes256 -out root-ca.key 4096
openssl genrsa -out root-ca.key 4096
接下来,我们将创建一个配置文件来保存我们将用于生成证书的 OpenSSL 设置:
# For the CA policy
[ policy_match ]
countryName = match
stateOrProvinceName = match
organizationName = match
organizationalUnitName = optional
commonName = supplied
emailAddress = optional
[ req ]
default_bits = 4096
default_keyfile = server-key.pem
default_md = sha256
distinguished_name = req_dn
req_extensions = v3_req
x509_extensions = v3_ca # The extensions to add to the self-signed cert
[ v3_req ]
subjectKeyIdentifier = hash
basicConstraints = CA:FALSE
keyUsage = critical, digitalSignature, keyEncipherment
nsComment = "OpenSSL Generated Certificate"
extendedKeyUsage = serverAuth, clientAuth
[ req_dn ]
countryName = Country Name (2-letter code)
countryName_default = US
countryName_min = 2
countryName_max = 2
stateOrProvinceName = State or Province Name (full name)
stateOrProvinceName_default = NY
stateOrProvinceName_max = 64
localityName = Locality Name (eg, city)
localityName_default = New York
localityName_max = 64
organizationName = Organization Name (eg, company)
organizationName_default = MongoDB
organizationName_max = 64
organizationalUnitName = Organizational Unit Name (eg, section)
organizationalUnitName_default = Education
organizationalUnitName_max = 64
commonName = Common Name (eg, YOUR name)
commonName_max = 64
[ v3_ca ]
# Extensions for a typical CA
subjectKeyIdentifier = hash
basicConstraints = critical,CA:true
authorityKeyIdentifier = keyid:always,issuer:always
# Key usage: this is typical for a CA certificate. However, since it will
# prevent it being used as a test self-signed certificate it is best
# left out by default.
keyUsage = critical,keyCertSign,cRLSign
然后,使用 openssl req 命令,我们将创建根证书。由于根是权限链的顶部,我们将使用上一步中创建的私钥(存储在 root-ca.key 中)自签此证书。 -x509 选项告诉 openssl req 命令,我们要使用提供给 -key 选项的私钥自签名证书。输出是一个名为 root-ca.crt 的文件:
openssl req -new -x509 -days 1826 -key root-ca.key -out root-ca.crt \
-config openssl.cnf -subj "$dn_prefix/CN=ROOTCA"
如果您查看 root-ca.crt 文件,您会发现它包含根 CA 的公共证书。您可以通过查看此命令生成的证书的可读版本来验证其内容:
openssl x509 -noout -text -in root-ca.crt
此命令的输出将类似于以下内容:
Certificate:
Data:
Version: 3 (0x2)
Serial Number:
1e:83:0d:9d:43:75:7c:2b:d6:2a:dc:7e:a2:a2:25:af:5d:3b:89:43
Signature Algorithm: sha256WithRSAEncryption
Issuer: C = US, ST = NY, L = New York, O = MongoDB, CN = ROOTCA
Validity
Not Before: Sep 11 21:17:24 2019 GMT
Not After : Sep 10 21:17:24 2024 GMT
Subject: C = US, ST = NY, L = New York, O = MongoDB, CN = ROOTCA
Subject Public Key Info:
Public Key Algorithm: rsaEncryption
RSA Public-Key: (4096 bit)
Modulus:
00:e3:de:05:ae:ba:c9:e0:3f:98:37:18:77:02:35:
e7:f6:62:bc:c3:ae:38:81:8d:04:88:da:6c:e0:57:
c2:90:86:05:56:7b:d2:74:23:54:f8:ca:02:45:0f:
38:e7:e2:0b:69:ea:f6:c8:13:8f:6c:2d:d6:c1:72:
64:17:83:4e:68:47:cf:de:37:ed:6e:38:b2:ab:3a:
e4:45:a8:fa:08:90:a0:f3:0d:3a:14:d8:9a:8d:69:
e7:cf:93:1a:71:53:4f:13:29:50:b0:2f:b6:b8:19:
2a:40:21:15:90:43:e7:d8:d8:f3:51:e5:95:58:87:
6c:45:9f:61:fc:b5:97:cf:5b:4e:4a:1f:72:c9:0c:
e9:8c:4c:d1:ca:df:b3:a4:da:b4:10:83:81:01:b1:
c8:09:22:76:c7:1e:96:c7:e6:56:27:8d:bc:fb:17:
ed:d9:23:3f:df:9c:ef:03:20:cc:c3:c4:55:cc:9f:
ad:d4:8d:81:95:c3:f1:87:f8:d4:5a:5e:e0:a8:41:
27:c8:0d:52:91:e4:2b:db:25:d6:b7:93:8d:82:33:
7a:a7:b8:e8:cd:a8:e2:94:3d:d6:16:e1:4e:13:63:
3f:77:08:10:cf:23:f6:15:7c:71:24:97:ef:1c:a2:
68:0f:82:e2:f7:24:b3:aa:70:1a:4a:b4:ca:4d:05:
92:5e:47:a2:3d:97:82:f6:d8:c8:04:a7:91:6c:a4:
7d:15:8e:a8:57:70:5d:50:1c:0b:36:ba:78:28:f2:
da:5c:ed:4b:ea:60:8c:39:e6:a1:04:26:60:b3:e2:
ee:4f:9b:f9:46:3c:7e:df:82:88:29:c2:76:3e:1a:
a4:81:87:1f:ce:9e:41:68:de:6c:f3:89:df:ae:02:
e7:12:ee:93:20:f1:d2:d6:3d:36:58:ee:71:bf:b3:
c5:e7:5a:4b:a0:12:89:ed:f7:cc:ec:34:c7:b2:28:
a8:1a:87:c6:8b:5e:d2:c8:25:71:ba:ff:d0:82:1b:
5e:50:a9:8a:c6:0c:ea:4b:17:a6:cc:13:0a:53:36:
c6:9d:76:f2:95:cc:ac:b9:64:d5:72:fc:ab:ce:6b:
59:b1:3a:f2:49:2f:2c:09:d0:01:06:e4:f2:49:85:
79:82:e8:c8:bb:1a:ab:70:e3:49:97:9f:84:e0:96:
c2:6d:41:ab:59:0c:2e:70:9a:2e:11:c8:83:69:4b:
f1:19:97:87:c3:76:0e:bb:b0:2c:92:4a:07:03:6f:
57:bf:a9:ec:19:85:d6:3d:f8:de:03:7f:1b:9a:2f:
6c:02:72:28:b0:69:d5:f9:fb:3d:2e:31:8f:61:50:
59:a6:dd:43:4b:89:e9:68:4b:a6:0d:9b:00:0f:9a:
94:61:71
Exponent: 65537 (0x10001)
X509v3 extensions:
X509v3 Subject Key Identifier:
8B:D6:F8:BD:B7:82:FC:13:BC:61:3F:8B:FA:84:24:3F:A2:14:C8:27
X509v3 Basic Constraints: critical
CA:TRUE
X509v3 Authority Key Identifier:
keyid:8B:D6:F8:BD:B7:82:FC:13:BC:61:3F:8B:FA:84:24:3F:A2:14:C8:27
DirName:/C=US/ST=NY/L=New York/O=MongoDB/CN=ROOTCA
serial:1E:83:0D:9D:43:75:7C:2B:D6:2A:DC:7E:A2:A2:25:AF:5D:3B:89:43
X509v3 Key Usage: critical
Certificate Sign, CRL Sign
Signature Algorithm: sha256WithRSAEncryption
c2:cc:79:40:8b:7b:a1:87:3a:ec:4a:71:9d:ab:69:00:bb:6f:
56:0a:25:3b:8f:bd:ca:4d:4b:c5:27:28:3c:7c:e5:cf:84:ec:
2e:2f:0d:37:35:52:6d:f9:4b:07:fb:9b:da:ea:5b:31:0f:29:
1f:3c:89:6a:10:8e:ae:20:30:8f:a0:cf:f1:0f:41:99:6a:12:
5f:5c:ce:15:d5:f1:c9:0e:24:c4:81:70:df:ad:a0:e1:0a:cc:
52:d4:3e:44:0b:61:48:a9:26:3c:a3:3d:2a:c3:ca:4f:19:60:
da:f7:7a:4a:09:9e:26:42:50:05:f8:74:13:4b:0c:78:f1:59:
39:1e:eb:2e:e1:e2:6c:cc:4d:96:95:79:c2:8b:58:41:e8:7a:
e6:ad:37:e4:87:d7:ed:bb:7d:fa:47:dd:46:dd:e7:62:5f:e9:
fe:17:4b:e3:7a:0e:a1:c5:80:78:39:b7:6c:a6:85:cf:ba:95:
d2:8d:09:ab:2d:cb:be:77:9b:3c:22:12:ca:12:86:42:d8:c5:
3c:31:a0:ed:92:bc:7f:3f:91:2d:ec:db:01:bd:26:65:56:12:
a3:56:ba:d8:d3:6e:f3:c3:13:84:98:2a:c7:b3:22:05:68:fa:
8e:48:6f:36:8e:3f:e5:4d:88:ef:15:26:4c:b1:d3:7e:25:84:
8c:bd:5b:d2:74:55:cb:b3:fa:45:3f:ee:ef:e6:80:e9:f7:7f:
25:a6:6e:f2:c4:22:f7:b8:40:29:02:f1:5e:ea:8e:df:80:e0:
60:f1:e5:3a:08:81:25:d5:cc:00:8f:5c:ac:a6:02:da:27:c0:
cc:4e:d3:f3:14:60:c1:12:3b:21:b4:f7:29:9b:4c:34:39:3c:
2a:d1:4b:86:cc:c7:de:f3:f7:5e:8f:9d:47:2e:3d:fe:e3:49:
70:0e:1c:61:1c:45:a0:5b:d6:48:49:be:6d:f9:3c:49:26:d8:
8b:e6:a1:b2:61:10:fe:0c:e8:44:2c:33:cd:3c:1d:c2:de:c2:
06:98:7c:92:7b:c4:06:a5:1f:02:8a:03:53:ec:bd:b7:fc:31:
f3:2a:c1:0e:6a:a5:a8:e4:ea:4d:cc:1d:07:a9:3f:f6:0e:35:
5d:99:31:35:b3:43:90:f3:1c:92:8e:99:15:13:2b:8f:f6:a6:
01:c9:18:05:15:2a:e3:d0:cc:45:66:d3:48:11:a2:b9:b1:20:
59:42:f7:88:15:9f:e0:0c:1d:13:ae:db:09:3d:bf:7a:9d:cf:
b2:41:1e:7a:fa:6b:35:20:03:58:a1:6c:02:19:21:5f:25:fc:
ba:2f:fc:79:d7:92:e7:37:77:14:10:d9:33:b6:e5:fb:7a:46:
ab:d1:86:70:88:92:59:c3
创建用于签署的中间 CA
现在我们已经创建了我们的根 CA,我们将创建一个中间 CA 来签署成员和客户证书。中间 CA 只是使用我们的根证书签署的证书。最佳实践是使用中间 CA 来签署服务器(即成员)和客户端证书。通常,CA 会使用不同的中间 CA 来签署不同类别的证书。如果中间 CA 被 compromise 并且证书需要被撤销,只会影响信任树的一部分,而不是由 CA 签署的所有证书,这种情况会发生在根 CA 用于签署所有证书的情况下。
# again, in production you would want to password protect your signing key:
# openssl genrsa -aes256 -out signing-ca.key 4096
openssl genrsa -out signing-ca.key 4096
openssl req -new -key signing-ca.key -out signing-ca.csr \
-config openssl.cnf -subj "$dn_prefix/CN=CA-SIGNER"
openssl x509 -req -days 730 -in signing-ca.csr -CA root-ca.crt -CAkey \
root-ca.key -set_serial 01 -out signing-ca.crt -extfile openssl.cnf \
-extensions v3_ca
请注意,在上述语句中,我们使用了openssl req命令,然后使用openssl ca命令对我们的签名证书进行签名,使用我们的根证书。openssl req命令创建签名请求,openssl ca命令使用该请求作为输入创建一个已签名的中间(签名)证书。
在创建签名 CA 的最后一步中,我们将我们的根证书(包含我们的根公钥)和签名证书(包含我们的签名公钥)串联成一个单独的 pem 文件。稍后,此文件将作为--tlsCAFile选项的值提供给我们的 mongod 或客户端进程。
cat root-ca.crt > root-ca.pem
cat signing-ca.crt >> root-ca.pem
使用设置的根 CA 和签名 CA,我们现在可以创建用于 MongoDB 集群认证的成员和客户端证书。
生成并签署成员证书
成员证书通常称为 x.509 服务器证书。对mongod和mongos进程使用此类证书。MongoDB 集群的成员使用这些证书来验证其在集群中的成员资格。换句话说,一个mongod使用服务器证书向复制集的其他成员进行身份验证。
为我们的副本集成员生成证书,我们将使用for循环生成多个证书。
# Pay attention to the OU part of the subject in "openssl req" command
for host in "${mongodb_server_hosts[@]}"; do
echo "Generating key for $host"
openssl genrsa -out ${host}.key 4096
openssl req -new -key ${host}.key -out ${host}.csr -config openssl.cnf \
-subj "$dn_prefix/OU=$ou_member/CN=${host}"
openssl x509 -req -days 365 -in ${host}.csr -CA signing-ca.crt -CAkey \
signing-ca.key -CAcreateserial -out ${host}.crt -extfile openssl.cnf \
-extensions v3_req
cat ${host}.crt > ${host}.pem
cat ${host}.key >> ${host}.pem
done
每个证书涉及三个步骤:
-
使用openssl genrsa命令创建新的密钥对。
-
使用openssl req命令为密钥生成一个签名请求。
-
使用openssl x509命令使用签名 CA 签署并输出证书。
注意变量$ou_member。这表示服务器证书和客户端证书之间的差异。服务器和客户端证书在 Distinguished Names 的组织部分必须不同。更具体地说,它们必须在至少一个 O、OU 或 DC 值上有所不同。
生成并签署客户端证书
客户端证书由 mongo shell、MongoDB Compass、MongoDB 工具和应用程序使用 MongoDB 驱动程序使用。生成客户端证书基本上遵循与成员证书相同的过程。唯一的区别在于我们使用的变量$ou_client。这确保了 O、OU 和 DC 值的组合与上述生成的服务器证书不同。
# Pay attention to the OU part of the subject in "openssl req" command
for host in "${mongodb_client_hosts[@]}"; do
echo "Generating key for $host"
openssl genrsa -out ${host}.key 4096
openssl req -new -key ${host}.key -out ${host}.csr -config openssl.cnf \
-subj "$dn_prefix/OU=$ou_client/CN=${host}"
openssl x509 -req -days 365 -in ${host}.csr -CA signing-ca.crt -CAkey \
signing-ca.key -CAcreateserial -out ${host}.crt -extfile openssl.cnf \
-extensions v3_req
cat ${host}.crt > ${host}.pem
cat ${host}.key >> ${host}.pem
done
启动不带认证和授权启用的副本集
我们可以像下面这样启动我们的每个副本集成员而不启用 auth。之前在处理副本集时,我们没有启用 auth,因此这看起来应该很熟悉。在这里,我们再次使用了我们在“生成根 CA”中定义的一些变量(或者查看本章节的完整脚本),以及一个循环来启动每个副本集成员(mongod)。
mport=$mongodb_port
for host in "${mongodb_server_hosts[@]}"; do
echo "Starting server $host in non-auth mode"
mkdir -p ./db/${host}
mongod --replSet set509 --port $mport --dbpath ./db/$host \
--fork --logpath ./db/${host}.log
let "mport++"
done
一旦每个mongod启动,我们可以使用这些mongod初始化一个副本集。
myhostname=`hostname`
cat > init_set.js <<EOF
rs.initiate();
mport=$mongodb_port;
mport++;
rs.add("localhost:" + mport);
mport++;
rs.add("localhost:" + mport);
EOF
mongo localhost:$mongodb_port init_set.js
请注意,上面的代码仅仅构建了一系列命令,将这些命令存储在一个 JavaScript 文件中,然后运行mongo shell 来执行创建的小脚本。当这些命令在mongo shell 中执行时,它们将连接到运行在端口 27017 上的mongod(在“生成根 CA”中设置的$mongodb_port变量的值),启动副本集,然后将其他两个mongod(在端口 27018 和 27019 上)添加到副本集中。
创建管理员用户
现在,我们将基于我们在“生成和签署客户端证书”中创建的客户端证书之一来创建一个管理员用户。我们将在连接到mongo shell 或其他客户端以执行管理任务时,作为此用户进行身份验证。要使用客户端证书进行身份验证,必须首先将客户端证书的主题值添加为 MongoDB 用户。每个唯一的 x.509 客户端证书对应一个单独的 MongoDB 用户;即,您不能使用单个客户端证书来验证多个 MongoDB 用户。我们必须在\(external 数据库中添加用户;即,认证数据库是\)external 数据库。
首先,我们将使用openssl x509命令从我们的客户端证书中获取主题。
openssl x509 -in client1.pem -inform PEM -subject -nameopt RFC2253 | grep subject
这应该导致以下输出:
subject= CN=client1,OU=MyClients,O=MongoDB,L=New York,ST=NY,C=US
要创建我们的管理员用户,我们将首先使用mongo shell 连接到我们副本集的主节点。
mongo --norc localhost:27017
在mongo shell 内部,我们将发出以下命令:
db.getSiblingDB("$external").runCommand(
{
createUser: "CN=client1,OU=MyClients,O=MongoDB,L=New York,ST=NY,C=US",
roles: [
{ role: "readWrite", db: 'test' },
{ role: "userAdminAnyDatabase", db: "admin" },
{ role: "clusterAdmin", db:"admin"}
],
writeConcern: { w: "majority" , wtimeout: 5000 }
}
);
请注意在这个命令中使用的$external 数据库,以及我们已指定我们客户证书的主题作为用户名。
以启用认证和授权的方式重新启动副本集
现在我们有了一个管理员用户,我们可以以启用认证和授权的方式重新启动副本集,并作为客户端连接。没有任何类型的用户,连接到启用了认证的副本集是不可能的。
让我们停止当前形式的副本集(未启用认证)。
kill $(ps -ef | grep mongod | grep set509 | awk '{print $2}')
现在,我们准备通过启用认证重新启动副本集。在生产环境中,我们将每个证书和密钥文件复制到它们对应的主机上。在这里,我们在 localhost 上做所有事情以使事情更简单。为了启动安全的副本集,我们将在每次调用mongod时添加以下命令行选项:
-
--tlsMode -
--clusterAuthMode -
--tlsCAFile—根 CA 文件(root-ca.key) -
--tlsCertificateKeyFile—mongod的证书文件 -
--tlsAllowInvalidHostnames—仅用于测试;允许无效主机名
在这里,我们提供作为tlsCAFile选项值的文件用于建立信任链。正如您回忆的root-ca.key文件包含根 CA 和签名 CA 的证书。通过将此文件提供给mongod进程,我们表明希望信任此文件中包含的证书以及由这些证书签名的所有其他证书。
好的,让我们开始吧。
mport=$mongodb_port
for host in "${mongodb_server_hosts[@]}"; do
echo "Starting server $host"
mongod --replSet set509 --port $mport --dbpath ./db/$host \
--tlsMode requireTLS --clusterAuthMode x509 --tlsCAFile root-ca.pem \
--tlsAllowInvalidHostnames --fork --logpath ./db/${host}.log \
--tlsCertificateKeyFile ${host}.pem --tlsClusterFile ${host}.pem \
--bind_ip 127.0.0.1
let "mport++"
done
使用 x.509 证书对认证和传输层加密的三节点副本集已经安全设置完成。现在唯一剩下的就是使用 mongo shell 进行连接。我们将使用 client1 证书进行认证,因为这是我们创建管理员用户的证书。
mongo --norc --tls --tlsCertificateKeyFile client1.pem --tlsCAFile root-ca.pem \
--tlsAllowInvalidHostnames --authenticationDatabase "\$external" \
--authenticationMechanism MONGODB-X509
连接成功后,我们鼓励您尝试向集合插入一些数据。您还应尝试使用其他用户连接(例如使用 client2.pem)。连接尝试会导致以下类似的错误。
mongo --norc --tls --tlsCertificateKeyFile client2.pem --tlsCAFile root-ca.pem \
--tlsAllowInvalidHostnames --authenticationDatabase "\$external" \
--authenticationMechanism MONGODB-X509
MongoDB shell version v4.2.0
2019-09-11T23:18:31.696+0100 W NETWORK [js] The server certificate does not
match the host name. Hostname: 127.0.0.1 does not match
2019-09-11T23:18:31.702+0100 E QUERY [js] Error: Could not find user
"CN=client2,OU=MyClients,O=MongoDB,L=New York,ST=NY,C=US" for db "$external" :
connect@src/mongo/shell/mongo.js:341:17
@(connect):3:6
2019-09-11T23:18:31.707+0100 F - [main] exception: connect failed
2019-09-11T23:18:31.707+0100 E - [main] exiting with code 1
在本章节的教程中,我们已经看过使用 x.509 证书作为认证基础和用于加密客户端和副本集成员之间通信的示例。同样的方法也适用于分片集群。关于保护 MongoDB 集群安全,请牢记以下事项:
-
在生成和签署成员机器或客户端证书的主机上,需要保护目录、根 CA 和签名 CA,以防止未经授权的访问。
-
为简单起见,在本教程中,根 CA 和签名 CA 密钥没有设置密码保护。在生产环境中,有必要使用密码保护密钥,以防止未经授权的使用。
我们鼓励您下载并尝试本书章节在 GitHub 仓库中提供的演示脚本。
第二十章:耐久性
耐久性是数据库系统的一个属性,确保已提交到数据库的写操作将永久存储。例如,如果票务预订系统报告您的音乐会座位已被预订,那么即使预订系统的某些部分崩溃,您的座位也将保持预订状态。对于 MongoDB,我们需要考虑集群(或更具体地说,副本集)级别的耐久性。
本章将涵盖以下内容:
-
MongoDB 如何通过日志记录在副本集成员级别保证耐久性
-
MongoDB 如何通过写关注(write concern)在集群级别保证耐久性
-
如何配置你的应用程序和 MongoDB 集群以满足你所需的耐久性级别
-
MongoDB 如何通过读关注(read concern)在集群级别保证耐久性
-
如何在副本集中设置事务的耐久性级别
在本章中,我们将讨论副本集中的耐久性。三成员副本集是推荐用于生产应用程序的最基本集群。这里的讨论适用于成员更多的副本集和分片集群。
通过日志记录实现成员级别的耐久性
为了在服务器故障时提供耐久性,MongoDB 使用称为日志的写前日志(WAL)。写前日志是数据库系统中常用的耐久性技术。其基本思想是在将更改应用到数据库本身之前,我们仅将要进行的更改的表示写入持久性介质(即磁盘)。在许多数据库系统中,写前日志用于提供原子性数据库属性。然而,MongoDB 使用其他技术来确保原子写入。
从 MongoDB 4.0 开始,当应用程序向副本集执行写入操作时,MongoDB 为所有复制集合中的数据创建使用与操作日志(oplog)相同格式的日志条目。如在第十一章所述,MongoDB 使用基于语句的复制,基于操作日志(oplog)。oplog 中的语句是对每个文档受到写入影响的实际 MongoDB 更改的表示。因此,oplog 语句易于应用到副本集的任何成员,无论版本、硬件或其他副本集成员之间的差异如何。此外,每个 oplog 语句都是幂等的,这意味着可以多次应用,并且结果总是对数据库的相同更改。
与大多数数据库一样,MongoDB 保持内存中的日志和数据库数据文件的视图。默认情况下,它每 50 毫秒刷新一次日志条目到磁盘,并每 60 秒刷新一次数据库文件到磁盘。刷新数据文件的 60 秒间隔称为检查点。日志用于为自上次检查点以来写入的数据提供持久性。就持久性问题而言,如果服务器突然停止,重新启动时可以使用日志来重放在关闭之前未刷新到磁盘的任何写入。
对于日志文件,MongoDB 在dbPath目录下创建一个名为journal的子目录。WiredTiger(MongoDB 的默认存储引擎)日志文件的名称格式为WiredTigerLog.
如果发生崩溃(或kill -9),mongod将在启动时重新播放其日志文件。默认情况下,丢失写入的最大范围是在最后 100 毫秒内进行的写入,加上刷新日志写入到磁盘所需的时间。
如果您的应用程序需要更短的日志刷新间隔,有两个选项。一个是使用--journalCommitInterval选项来更改mongod命令的间隔。此选项接受从 1 到 500 毫秒的值。另一个选项,我们将在下一节中讨论,是在写关注中指定所有写入应该记录到磁盘。缩短日志到磁盘的间隔会对性能产生负面影响,因此在更改日志记录默认值之前,您需要确保了解对应用程序的影响。
在集群级别使用写关注进行持久性
使用写关注,您可以指定应用程序在响应写入请求时需要什么级别的确认。在副本集中,网络分区、服务器故障或数据中心宕机可能导致写入未被复制到每个成员,甚至大多数成员。当副本集恢复到正常状态时,可能会回滚未复制到大多数成员的写入。在这些情况下,客户端和数据库可能对已提交的数据有不同的视图。
某些情况下,允许写操作回滚的应用程序可能是可以接受的。例如,在某种社交应用程序中回滚少量评论可能是可以接受的。MongoDB 支持在群集级别提供一系列耐久性保证,以便应用程序设计者选择最适合其用例的耐久性级别。
writeConcern的w和wtimeout选项
MongoDB 查询语言支持为所有插入和更新方法指定写关注。例如,假设我们有一个电子商务应用程序,并希望确保所有订单都是耐久的。将订单写入数据库可能看起来像以下示例:
try {
db.products.insertOne(
{ sku: "H1100335456", item: "Electric Toothbrush Head", quantity: 3 },
{ writeConcern: { w : "majority", wtimeout : 100 } }
);
} catch (e) {
print (e);
}
所有插入和更新方法都需要第二个参数,即文档。在该文档中,您可以指定writeConcern的值。在前面的示例中,我们指定的写关注表明,只有在成功将写操作成功复制到应用程序复制集的大多数成员时,我们才希望从服务器收到写完成的确认。此外,如果在 100 毫秒内未将写操作复制到复制集的大多数成员,则应返回错误。在这种错误情况下,MongoDB 不会撤消在写关注超过时间限制之前执行的成功数据修改操作——应用程序将决定如何处理这些情况下的超时。一般来说,您应该配置wtimeout值,以便应用程序只在不寻常的情况下遇到超时,且应用程序在响应超时错误时应确保数据的正确状态。在大多数情况下,您的应用程序应尝试确定超时是否是由于网络通信中的瞬时减速或更重要的原因造成的。
在写关注文档中的w值,您可以指定"majority"(就像在本例中所做的那样)。或者,您可以指定介于零和复制集成员数之间的整数。最后,可以标记复制集成员,例如识别用于报告与 OLTP 工作负载相比的 SSD 与旋转磁盘,或标识用于报告与 OLTP 工作负载相比的成员。您可以将标签集指定为w的值,以确保写入仅在至少一台符合提供的标签集的复制集成员上提交后才得到确认。
writeConcern的j(日志记录)选项
除了为w选项提供一个值外,您还可以使用写关注文档中的j选项请求确认写操作已写入日志。对于j的值为true,MongoDB 仅在所请求的成员数(即w的值)已将操作写入其磁盘日志后才确认成功写入。继续我们的示例,如果我们希望确保所有写入都被大多数成员日志记录,我们可以按以下方式更新代码:
try {
db.products.insertOne(
{ sku: "H1100335456", item: "Electric Toothbrush Head", quantity: 3 },
{ writeConcern: { w : "majority", wtimeout : 100, j : true } }
);
} catch (e) {
print (e);
}
在不等待日志记录的情况下,每个成员大约有 100 毫秒的短暂窗口期,在此期间,如果服务器进程或硬件故障,写操作可能会丢失。然而,等待日志记录后再确认写入到副本集成员会对性能产生影响。
在处理应用程序耐久性问题时,必须仔细评估应用程序的需求,并权衡所选耐久性设置的性能影响。
在集群级别使用读关注的耐久性
在 MongoDB 中,read concerns 允许配置何时读取结果。这可以让客户端在这些写入持久化之前看到写入结果。可以将读关注与写关注一起使用,以控制向应用程序提供的一致性和可用性保证级别。它们不应与 read preferences 混淆,后者处理数据从何处读取;具体而言,读偏好决定从副本集中的数据承载成员读取数据。默认的 read preferences 是从主节点读取。
Read concern 确定正在读取的数据的一致性和隔离特性。默认的 readConcern 是 local,返回的数据不保证已写入到大多数数据承载的副本集成员。这可能导致数据在将来被回滚。majority concern 只返回已被大多数副本集成员确认的持久数据(不会被回滚)。在 MongoDB 3.4 中,添加了 linearizable concern。它确保返回的数据反映了在读操作开始前已完成的所有成功的大多数确认写入。它可能会等待并发执行的写入操作完成后再提供结果。
同样地,使用 write concerns 时,您需要权衡 read concerns 的性能影响,以及它们在选择适当的关注点时提供的耐久性和隔离保证。
使用写关注的事务耐久性
在 MongoDB 中,对单个文档的操作是原子的。您可以使用嵌入文档和数组在单个文档中表达实体之间的关系,而不是使用规范化的数据模型将实体和关系分割到多个集合中。因此,许多应用程序不需要多文档事务。
但是,对于需要对多个文档进行原子更新的用例,MongoDB 提供了针对副本集执行多文档事务的能力。多文档事务可以跨多个操作、文档、集合和数据库使用。
事务要求事务内的所有数据更改都成功。如果任何操作失败,事务将中止并且所有数据更改都将被丢弃。如果所有操作都成功,事务中进行的所有数据更改都将被保存,并且写操作将对未来读取可见。
与单个写操作一样,您可以为事务指定写关注点。您在事务级别设置写关注点,而不是在单个操作级别。在提交时,事务使用事务级别的写关注点来提交写操作。在事务内部设置的单个操作的写关注点将被忽略。
您可以在事务开始时为事务提交设置写关注点。不支持事务的写关注点为0。如果您为事务使用写关注点为1,则如果发生故障转移,可以回滚事务。您可以使用"majority"写关注点来确保在副本集中因网络和服务器故障而导致故障转移时事务的持久性。以下提供了一个示例:
function updateEmployeeInfo(session) {
employeesCollection = session.getDatabase("hr").employees;
eventsCollection = session.getDatabase("reporting").events;
session.startTransaction( {writeConcern: { w: "majority" } } );
try{
employeesCollection.updateOne( { employee: 3 },
{ $set: { status: "Inactive" } } );
eventsCollection.insertOne( { employee: 3, status: { new: "Inactive",
old: "Active" } } );
} catch (error) {
print("Caught exception during transaction, aborting.");
session.abortTransaction();
throw error;
}
commitWithRetry(session);
}
MongoDB 不保证的内容
有几种情况下 MongoDB 无法保证持久性,比如硬件问题或文件系统漏洞。特别是,如果硬盘损坏,MongoDB 无法保护您的数据。
此外,不同类型的硬件和软件可能具有不同的持久性保证。例如,一些较便宜或较旧的硬盘在写入被排队等待时报告写入成功,而不是实际写入时。MongoDB 无法在此级别防止错误报告:如果系统崩溃,可能会丢失数据。
基本上,MongoDB 的安全性取决于底层系统:如果硬件或文件系统破坏数据,MongoDB 无法阻止。使用复制来防御系统问题。如果一台机器故障,希望另一台仍然正常运行。
检查损坏情况
validate命令可用于检查集合是否损坏。要在movies集合上运行validate,请执行:
db.movies.validate({full: true})
{
"ns" : "sample_mflix.movies",
"nInvalidDocuments" : NumberLong(0),
"nrecords" : 45993,
"nIndexes" : 5,
"keysPerIndex" : {
"_id_" : 45993,
"$**_text" : 3671341,
"genres_1_imdb.rating_1_metacritic_1" : 94880,
"tomatoes_rating" : 45993,
"getMovies" : 45993
},
"indexDetails" : {
"$**_text" : {
"valid" : true
},
"_id_" : {
"valid" : true
},
"genres_1_imdb.rating_1_metacritic_1" : {
"valid" : true
},
"getMovies" : {
"valid" : true
},
"tomatoes_rating" : {
"valid" : true
}
},
"valid" : true,
"warnings" : [ ],
"errors" : [ ],
"extraIndexEntries" : [ ],
"missingIndexEntries" : [ ],
"ok" : 1
}
主要的字段是"valid",希望它是true。如果不是,validate将提供有关发现的损坏的详细信息。
validate的大部分输出描述了集合的内部结构和用于理解集群中操作顺序的时间戳。这些对于调试并不特别有用。(详见附录 B 了解更多关于集合内部的信息。)
您只能在集合上运行validate,它还将检查字段indexDetails中的相关索引。但这需要一个完整的validate,可以通过{ full: true }选项进行配置。
^(1) MongoDB 使用不同的格式来写入本地数据库,该数据库存储用于复制过程和其他特定实例数据的数据,但其原理和应用方式类似。
第二十四章:Part VI. 服务器管理
第二十一章:在生产环境中设置 MongoDB
在第二章中,我们讨论了启动 MongoDB 的基础知识。本章将更详细地介绍在生产环境中设置 MongoDB 时重要的选项,包括:
-
常用选项
-
启动和关闭 MongoDB
-
与安全相关的选项
-
日志考虑
从命令行启动
MongoDB 服务器使用mongod可执行文件启动。mongod有许多可配置的启动选项;要查看所有选项,请从命令行运行mongod --help。其中一些选项被广泛使用且非常重要:
--dbpath
指定替代数据目录使用;默认为/data/db/(或者在 Windows 上,在 MongoDB 二进制卷上的\data\db*)。每个mongod进程在机器上需要自己的数据目录,所以如果您在一台机器上运行三个mongod实例,则需要三个单独的数据目录。当mongod启动时,它会在其数据目录中创建一个mongod.lock*文件,这会阻止其他任何mongod进程使用该目录。如果您尝试使用相同的数据目录启动另一个 MongoDB 服务器,它将报错:
exception in initAndListen: DBPathInUse: Unable to lock the
lock file: \ data/db/mongod.lock (Resource temporarily unavailable).
Another mongod instance is already running on the
data/db directory,
\ terminating
--port
指定服务器侦听的端口号。默认情况下,mongod使用端口 27017,这个端口除了其他mongod进程外,不太可能被其他进程使用。如果您希望在单台机器上运行多个mongod进程,则需要为每个进程指定不同的端口号。如果您尝试在已被使用的端口上启动mongod,它将报错:
Failed to set up listener: SocketException: Address already in use.
--fork
在基于 Unix 的系统上,分叉服务器进程,将 MongoDB 作为守护程序运行。
如果您首次启动mongod(使用空数据目录),文件系统可能需要几分钟来分配数据库文件。父进程在分叉直到预分配完成并且mongod准备好开始接受连接之前不会返回。因此,fork可能会看起来像是挂起。您可以 tail 日志查看它在做什么。如果指定了--fork,必须使用--logpath。
--logpath
将所有输出发送到指定的文件而不是在命令行上输出。如果目录有写权限,这将创建文件(如果不存在)。如果日志文件已存在,它将覆盖日志文件,擦除任何较旧的日志条目。如果您想保留旧日志,请使用--logappend选项,还要使用--logpath(强烈推荐)。
--directoryperdb
把每个数据库放在它自己的目录中。这样,如果需要或希望的话,可以把不同的数据库挂载到不同的磁盘上。常见的用法包括把本地数据库放在自己的磁盘上(复制)或者在原始磁盘填满时将数据库移到不同的磁盘上。您还可以将负载更高的数据库放在更快的磁盘上,将负载较低的数据库放在较慢的磁盘上。基本上,这使您在以后移动这些内容时具有更大的灵活性。
--config
使用配置文件添加未在命令行上指定的其他选项。通常用于确保在重新启动之间选项保持不变。有关详细信息,请参阅“基于文件的配置”。
例如,要将服务器作为守护进程启动,并监听端口 5586,并将所有输出发送到mongodb.log,我们可以运行以下命令:
$ ./mongod --dbpath data/db --port 5586 --fork --logpath
mongodb.log --logappend 2019-09-06T22:52:25.376-0500 I CONTROL [main]
Automatically disabling TLS 1.0, \ to force-enable TLS 1.0 specify
--sslDisabledProtocols 'none' about to fork child process, waiting until
server is ready for connections. forked process: 27610 child process
started successfully, parent exiting
当您首次安装并启动 MongoDB 时,建议查看日志。这可能是一个容易忽略的事情,特别是如果 MongoDB 是从 init 脚本启动的,但日志通常包含防止后续错误发生的重要警告。如果在 MongoDB 启动时的日志中没有看到任何警告,则一切正常。(启动警告也会出现在 shell 启动时。)
如果启动横幅中有任何警告,请注意。MongoDB 会警告您有各种问题:您正在运行在 32 位机器上(MongoDB 不适用于此),您已启用 NUMA(这可能使您的应用程序速度变慢),或者您的系统不允许足够的打开文件描述符(MongoDB 使用大量文件描述符)。
当您重新启动数据库时,日志前言不会改变,所以一旦您了解它们的含义,可以从 init 脚本运行 MongoDB 并忽略日志。但是,每次安装、升级或从崩溃中恢复时,最好再次检查,以确保 MongoDB 和您的系统处于同一页面。
当您启动数据库时,MongoDB 将向local.startup_log集合写入一个描述 MongoDB 版本、底层系统和使用的标志的文档。我们可以使用mongo shell 查看此文档:
> use local
switched to db local
> db.startup_log.find().sort({startTime: -1}).limit(1).pretty()
{
"_id" : "server1-1544192927184",
"hostname" : "server1.example.net",
"startTime" : ISODate("2019-09-06T22:50:47Z"),
"startTimeLocal" : "Fri Sep 6 22:57:47.184",
"cmdLine" : {
"net" : {
"port" : 5586
},
"processManagement" : {
"fork" : true
},
"storage" : {
"dbPath" : "data/db"
},
"systemLog" : {
"destination" : "file",
"logAppend" : true,
"path" : "mongodb.log"
}
},
"pid" : NumberLong(27278),
"buildinfo" : {
"version" : "4.2.0",
"gitVersion" : "a4b751dcf51dd249c5865812b390cfd1c0129c30",
"modules" : [
"enterprise"
],
"allocator" : "system",
"javascriptEngine" : "mozjs",
"sysInfo" : "deprecated",
"versionArray" : [
4,
2,
0,
0
],
"openssl" : {
"running" : "Apple Secure Transport"
},
"buildEnvironment" : {
"distmod" : "",
"distarch" : "x86_64",
"cc" : "gcc: Apple LLVM version 8.1.0 (clang-802.0.42)",
"ccflags" : "-mmacosx-version-min=10.10 -fno-omit\
-frame-pointer -fno-strict-aliasing \
-ggdb -pthread -Wall
-Wsign-compare -Wno-unknown-pragmas \
-Winvalid-pch -Werror -O2 -Wno-unused\
-local-typedefs -Wno-unused-function
-Wno-unused-private-field \
-Wno-deprecated-declarations \
-Wno-tautological-constant-out-of\
-range-compare
-Wno-unused-const-variable -Wno\
-missing-braces -Wno-inconsistent\
-missing-override
-Wno-potentially-evaluated-expression \
-Wno-exceptions -fstack-protector\
-strong -fno-builtin-memcmp",
"cxx" : "g++: Apple LLVM version 8.1.0 (clang-802.0.42)",
"cxxflags" : "-Woverloaded-virtual -Werror=unused-result \
-Wpessimizing-move -Wredundant-move \
-Wno-undefined-var-template -stdlib=libc++ \
-std=c++14",
"linkflags" : "-mmacosx-version-min=10.10 -Wl, \
-bind_at_load -Wl,-fatal_warnings \
-fstack-protector-strong \
-stdlib=libc++",
"target_arch" : "x86_64",
"target_os" : "macOS"
},
"bits" : 64,
"debug" : false,
"maxBsonObjectSize" : 16777216,
"storageEngines" : [
"biggie",
"devnull",
"ephemeralForTest",
"inMemory",
"queryable_wt",
"wiredTiger"
]
}
}
这一集合对于跟踪升级和行为变化非常有用。
基于文件的配置
MongoDB 支持从文件中读取配置信息。如果您有一组要使用的选项或正在自动化启动 MongoDB 的任务,则此选项非常有用。要告诉服务器从配置文件获取选项,请使用-f或--config标志。例如,运行mongod --config ~/.mongodb.conf以使用~/.mongodb.conf作为配置文件。
配置文件中支持的选项与命令行接受的选项相同。但是,格式不同。从 MongoDB 2.6 开始,MongoDB 配置文件使用 YAML 格式。以下是一个示例配置文件:
systemLog:
destination: file
path: "mongod.log"
logAppend: true
storage:
dbPath: data/db
processManagement:
fork: true
net:
port: 5586
...
此配置文件指定了我们在使用常规命令行参数启动时使用的相同选项。请注意,这些选项在我们在前一节中查看的 startup_log 集合文档中也有所反映。唯一的真正区别是这些选项是使用 JSON 而不是 YAML 指定的。
在 MongoDB 4.2 中,添加了扩展指令,允许加载特定配置文件选项或加载整个配置文件。扩展指令的优点是不必直接在配置文件中存储机密信息,例如密码和安全证书。--configExpand 命令行选项启用此功能,并必须包括您希望启用的扩展指令。__rest 和 __exec 是 MongoDB 中扩展指令的当前实现。__rest 扩展指令从 REST 端点加载特定配置文件值或加载整个配置文件。__exec 扩展指令从 shell 或终端命令加载特定配置文件值或加载整个配置文件。
停止 MongoDB
安全地停止运行中的 MongoDB 服务器至少与启动一个同样重要。有几种有效的方法可以实现这一点。
关闭运行中服务器的最干净方式是使用 shutdown 命令,{"shutdown" : 1}。这是一个管理员命令,必须在 admin 数据库上运行。Shell 提供了一个帮助函数来简化此操作:
> use admin
switched to db admin
> db.shutdownServer()
server should be down...
在主服务器上运行时,shutdown 命令会降级主服务器并等待辅助服务器追赶上来,然后再关闭服务器。这最大程度地减少了回滚的机会,但不能保证关闭一定成功。如果没有可以在几秒钟内追赶上来的辅助服务器,shutdown 命令将失败,原(主)服务器将不会关闭:
> db.shutdownServer()
{
"closest" : NumberLong(1349465327),
"difference" : NumberLong(20),
"errmsg" : "no secondaries within 10 seconds of my optime",
"ok" : 0
}
您可以使用 force 选项强制执行 shutdown 命令以关闭主服务器:
db.adminCommand({"shutdown" : 1, "force" : true})
这相当于发送 SIGINT 或 SIGTERM 信号(这三个选项都会导致干净的关闭,但可能存在未复制的数据)。如果服务器作为终端中的前台进程运行,可以通过按 Ctrl-C 发送 SIGINT。否则,可以使用像 kill 这样的命令来发送信号。如果 mongod 的 PID 是 10014,则命令为 kill -2 10014(SIGINT)或 kill 10014(SIGTERM)。
当 mongod 收到 SIGINT 或 SIGTERM 时,它将进行干净的关闭。这意味着它将等待任何运行中的操作或文件预分配完成(这可能需要一些时间),关闭所有打开的连接,将所有数据刷新到磁盘并停止。
安全性
不要设置公开可访问的 MongoDB 服务器。你应该尽可能严格地限制外部世界与 MongoDB 之间的访问。最好的方法是设置防火墙,并只允许 MongoDB 在内部网络地址上可达。第二十四章涵盖了 MongoDB 服务器和客户端之间必须允许的连接。
除了防火墙外,你可以向配置文件添加几个选项来增强其安全性:
--bind_ip
指定你希望 MongoDB 监听的接口。通常你希望这是一个内部 IP:应用服务器和集群的其他成员可以访问的 IP,但对外界不可访问。如果在同一台机器上运行应用服务器,对mongos进程来说localhost是可以的。对于配置服务器和分片,它们需要从其他机器可寻址,因此应使用非localhost地址。
从 MongoDB 3.6 开始,默认情况下mongod和mongos进程绑定到localhost。当仅绑定到localhost时,mongod和mongos只接受来自同一台机器上运行的客户端的连接。这有助于限制未安全配置的 MongoDB 实例的暴露。要绑定到其他地址,请使用net.bindIp配置文件设置或--bind_ip命令行选项来指定主机名或 IP 地址列表。
--nounixsocket
禁用在 UNIX 域套接字上的监听。如果你不计划通过文件系统套接字连接,最好禁止它。你只会在同时运行应用服务器的机器上通过文件系统套接字进行连接:必须是本地才能使用文件系统套接字。
--noscripting
禁用服务器端 JavaScript 执行。有些与 MongoDB 报告的安全问题与 JavaScript 有关,因此如果你的应用允许的话,通常最安全的做法是禁用它。
注意
一些 shell 辅助程序假设服务器上有 JavaScript 可用,特别是sh.status()。如果尝试在禁用 JavaScript 的情况下运行任何这些辅助程序,你会看到错误。
数据加密
MongoDB Enterprise 支持数据加密。这些选项在 MongoDB 社区版本中不受支持。
数据加密过程包括以下步骤:
-
生成主密钥。
-
为每个数据库生成密钥。
-
使用数据库密钥加密数据。
-
使用主密钥加密数据库密钥。
当使用数据加密时,所有数据文件在文件系统中都是加密的。数据只在内存和传输过程中解密。要加密 MongoDB 的所有网络流量,你可以使用 TLS/SSL。MongoDB Enterprise 用户可以向其配置文件中添加的数据加密选项有:
--enableEncryption
启用 WiredTiger 存储引擎中的加密。使用此选项,存储在内存和磁盘上的数据将被加密。有时也称为“静态加密”。您必须将其设置为true以传递加密密钥并配置加密。此选项默认为false。
--encryptionCipherMode
在 WiredTiger 中设置数据加密的密码模式。有两种可用模式:AES256-CBC 和 AES256-GCM。AES256-CBC 是 256 位高级加密标准的密码分组链接模式的缩写。AES256-GCM 使用 Galois/Counter 模式。两者都是标准的加密算法。从 MongoDB 4.0 开始,在 Windows 上的 MongoDB 企业版不再支持 AES256-GCM。
--encryptionKeyFile
如果您正在使用与密钥管理互操作性协议(KMIP)不同的过程管理密钥,则需指定本地密钥文件的路径。
MongoDB 企业版还支持使用 KMIP 进行密钥管理。本书不涵盖 KMIP 的讨论。请参阅使用 MongoDB 的 KMIP 的详细文档。
SSL 连接
正如我们在第十八章中所见,MongoDB 支持使用 TLS/SSL 进行传输加密。此功能在 MongoDB 的所有版本中均可用。默认情况下,连接到 MongoDB 的数据是未加密的。但 TLS/SSL 确保传输加密。MongoDB 使用操作系统上可用的本机 TLS/SSL 库。使用--tlsMode选项及相关选项配置 TLS/SSL。有关更多详细信息,请参阅第十八章,并查阅您的驱动程序文档,了解如何使用您的语言创建 TLS/SSL 连接。
日志记录
默认情况下,mongod将其日志发送到 stdout。大多数初始化脚本使用--logpath选项将日志发送到文件中。如果在单台机器上有多个 MongoDB 实例(例如,一个mongod和一个mongos),请确保它们的日志存储在单独的文件中。确保您知道日志的存储位置并具有文件的读取权限。
MongoDB 会生成大量的日志消息,但请不要使用--quiet选项(该选项会抑制部分日志消息)。通常情况下保持默认的日志级别是最佳选择:提供了足够的基本调试信息(比如为什么运行慢,为什么启动失败等),但日志占用的空间不会过多。
如果您正在调试应用程序的特定问题,有几种选项可以从日志中获取更多信息。您可以通过运行setParameter命令或通过将其作为字符串传递使用--setParameter选项在启动时设置日志级别。
> db.adminCommand({"setParameter" : 1, "logLevel" : 3})
您还可以为特定组件更改日志级别。如果您正在调试应用程序的特定方面并需要更多信息,但只来自该组件,则这很有帮助。在此示例中,我们将默认日志详细级别设置为 1,查询组件的详细级别设置为 2:
> db.adminCommand({"setParameter" : 1, logComponentVerbosity:
{ verbosity: 1, query: { verbosity: 2 }}})
记得在调试完成后将日志级别调回到 0,否则你的日志可能会变得不必要地嘈杂。你可以将级别调到 5,此时 mongod 将打印出它执行的几乎每个操作,包括处理的每个请求的内容。这可能会导致大量的 I/O,因为 mongod 将所有内容写入日志文件,这会减慢繁忙系统的速度。如果需要查看每个操作的实时发生情况,打开分析是一个更好的选择。
默认情况下,MongoDB 记录超过 100 毫秒的查询信息。如果 100 毫秒对于你的应用程序来说太短或太长,可以使用 setProfilingLevel 更改阈值:
> // Only log queries that take longer than 500 ms
> db.setProfilingLevel(1, 500)
{ "was" : 0, "slowms" : 100, "ok" : 1 }
> db.setProfilingLevel(0)
{ "was" : 1, "slowms" : 500, "ok" : 1 }
第二行将关闭分析,但第一行中给出的毫秒值将继续作为日志的阈值(跨所有数据库)。你也可以通过使用 --slowms 选项重新启动 MongoDB 来设置此参数。
最后,设置一个 cron 作业,每天或每周轮换一次日志。如果 MongoDB 是通过 --logpath 启动的,向该进程发送 SIGUSR1 信号将使其轮换日志。还有一个 logRotate 命令可以做同样的事情:
> db.adminCommand({"logRotate" : 1})
如果 MongoDB 没有通过 --logpath 启动,则无法轮换日志。
第二十二章:监控 MongoDB
在部署之前,设置某种类型的监控非常重要。监控应该允许您追踪服务器正在做什么,并在出现问题时提醒您。本章将涵盖:
-
如何追踪 MongoDB 的内存使用情况
-
如何跟踪应用程序性能指标
-
如何诊断复制问题
我们将使用 MongoDB Ops Manager 的示例图表来演示监控时要查找的内容(请参阅Ops Manager 的安装说明)。MongoDB Atlas(MongoDB 的云数据库服务)的监控能力非常类似。MongoDB 还提供了一个免费的监控服务,用于监控独立节点和副本集。它将监控数据保留 24 小时,并提供有关操作执行时间、内存使用情况、CPU 使用情况和操作计数的粗略统计信息。
如果您不想使用 Ops Manager、Atlas 或 MongoDB 的免费监控服务,请使用某种类型的监控。它将帮助您在问题造成之前检测潜在问题,并在出现问题时进行诊断。
监控内存使用情况
访问内存中的数据速度很快,而访问磁盘上的数据速度很慢。不幸的是,内存很昂贵(而磁盘很便宜),通常 MongoDB 在使用其他资源之前就会用完内存。本节介绍如何监控 MongoDB 与 CPU、磁盘和内存的交互,并要注意什么。
计算机内存介绍
计算机通常具有少量快速访问的内存和大量慢速访问的磁盘。当您请求存储在磁盘上(尚未在内存中)的数据页时,系统会发生页面错误,并将该页面从磁盘复制到内存中。然后,它可以非常快速地访问内存中的页面。如果您的程序停止定期使用该页面,并且内存填满了其他页面,那么旧页面将从内存中逐出,并且仅在磁盘上再次存在。
将页面从磁盘复制到内存比从内存读取页面需要更长时间。因此,MongoDB 能够尽可能地减少从磁盘复制数据,它将能够更快地访问数据。因此,MongoDB 的内存使用是需要追踪的最重要的统计数据之一。
跟踪内存使用情况
MongoDB 在 Ops Manager 报告了三种“类型”的内存:驻留内存、虚拟内存和映射内存。驻留内存是 MongoDB 明确拥有的 RAM 中的内存。例如,如果您查询一个文档并将其换入内存,则该页将添加到 MongoDB 的驻留内存中。
MongoDB 为该页面分配了一个地址。这个地址并不是页面在 RAM 中的真实地址;它是一个虚拟地址。MongoDB 可以将其传递给内核,内核将查找页面的真实位置。这样,如果内核需要从内存中驱逐页面,MongoDB 仍然可以使用该地址访问它。MongoDB 将向内核请求内存,内核将查看其页面缓存,发现页面不在其中时,会发生页面错误以将页面复制到内存中,并返回给 MongoDB。
如果您的数据完全适合内存,驻留内存应该大致等于您的数据大小。当我们谈论数据“在内存中”时,我们总是指数据在 RAM 中。
MongoDB 映射的内存包括 MongoDB 曾经访问过的所有数据(它拥有地址的所有数据页)。它通常会接近您的数据集大小。
虚拟内存是操作系统提供的一个抽象,隐藏了软件进程对物理存储细节的直接访问。每个进程看到的是一个连续的内存地址空间,可以使用。在 Ops Manager 中,MongoDB 的虚拟内存使用通常是映射内存大小的两倍。
图 22-1 显示了 Ops Manager 关于内存信息的图表,描述了 MongoDB 使用的虚拟、驻留和映射内存量。映射内存仅适用于使用 MMAP 存储引擎的较旧(4.0 之前)的部署。现在 MongoDB 使用 WiredTiger 存储引擎,您应该看到映射内存的使用量为零。在专用于 MongoDB 的机器上,驻留内存应该略小于总内存大小(假设您的工作集大小与内存一样大或更大)。驻留内存是实际跟踪物理 RAM 中数据量的统计数据,但仅凭这一点不能告诉您 MongoDB 如何使用内存。

图 22-1 从顶部到底部依次是:虚拟、驻留和映射内存
如果您的数据完全适合内存,驻留内存应该大致等于您的数据大小。当我们谈论数据“在内存中”时,我们总是指数据在 RAM 中。
正如您可以从图 22-1 看到的那样,内存指标通常保持相对稳定,但随着数据集的增长,虚拟内存(顶部线)也会随之增长。驻留内存(中间线)将增长到可用 RAM 的大小,然后保持稳定。
跟踪页面错误
您可以使用其他统计信息来了解 MongoDB 如何使用内存,而不仅仅是每种类型有多少。一个有用的统计数据是页面错误的数量,它告诉您 MongoDB 查找的数据在 RAM 中不在的频率。图 22-2 和 22-3 是显示随时间变化的页面错误的图表。图 22-3 的页面错误少于 图 22-2,但单独来看这些信息并不是很有用。如果 图 22-2 的磁盘能够处理那么多的错误,并且应用程序可以处理磁盘寻址的延迟,那么拥有这么多错误(或更多)就没有特别的问题。另一方面,如果您的应用程序无法处理从磁盘读取数据的增加延迟,您别无选择,只能将所有数据存储在内存中(或使用 SSD)。

图 22-2. 一个每分钟发生数百次页面错误的系统

图 22-3. 每分钟发生几次页面错误的系统
不管应用程序有多宽容,当磁盘负载过重时,页面错误就会成为一个问题。磁盘可以处理的负载量并非线性:一旦磁盘开始过载,每个操作都必须等待更长的时间,从而产生连锁反应。通常存在一个临界点,磁盘性能会迅速下降。因此,最好避免接近磁盘能处理的最大负载。
注意
随着时间推移跟踪您的页面错误数量。如果您的应用程序在某个页面错误数上表现良好,则对系统可以处理多少页面错误有一个基准。如果页面错误开始逐渐增加并导致性能下降,则有一个警报的阈值。
您可以通过查看 serverStatus 输出的 "page_faults" 字段来查看每个数据库的页面错误统计:
> db.adminCommand({"serverStatus": 1})["extra_info"]
{ "note" : "fields vary by platform", "page_faults" : 50 }
"page_faults" 提供了自启动以来 MongoDB 必须访问磁盘的次数。
I/O 等待
总的来说,页面错误通常与 CPU 空闲等待磁盘的时间密切相关,称为 I/O 等待。某些 I/O 等待是正常的;MongoDB 有时必须访问磁盘,尽管它在这样做时试图不阻塞任何操作,但无法完全避免。重要的是,I/O 等待没有增加或接近 100%,如 图 22-4 所示。这表示磁盘负载过重。

图 22-4. I/O 等待约为 100%
计算工作集
一般来说,内存中的数据越多,MongoDB 的性能越快。因此,应用程序可能按以下顺序从快到慢具有:
-
整个数据集在内存中。这虽然很好,但通常太昂贵或不可行。对于依赖快速响应时间的应用程序可能是必需的。
-
在内存中的工作集。这是最常见的选择。
你的工作集是应用程序使用的数据和索引。这可能是全部数据,但通常有一个核心数据集(例如,用户集合和最近一个月的活动),涵盖了 90%的请求。如果这个工作集适合于 RAM,MongoDB 通常会很快:它只需为少数“不寻常”的请求访问磁盘。
-
内存中的索引。
-
内存中的索引工作集。
-
内存中没有有用的数据子集。如果可能,请避免这种情况。这会很慢。
您必须知道您的工作集是什么(以及有多大),才能知道是否可以将其保留在内存中。计算工作集大小的最佳方法是跟踪常见操作,以了解您的应用程序读取和写入的数据量。例如,假设您的应用程序每周创建 2 GB 的新数据,其中 800 MB 经常访问。用户倾向于访问最多一个月的数据,超过这个时间的数据基本上不用了。您的工作集大小可能约为 3.2 GB(每周 800 MB × 4 周),再加上索引的余地,因此称之为 5 GB。
有一种思考方式是跟踪随时间访问的数据,如图 22-5 所示。如果选择一个包括 90%请求的截止时间,例如图 22-6,那么在该时间段内生成的数据(和索引)形成了您的工作集。您可以测量该时间段以确定数据集增长的情况。请注意,此示例使用时间,但可能存在另一种更适合您的应用程序的访问模式(时间是最常见的一种)。

图 22-5. 数据访问按数据年龄的图形绘制

图 22-6. 工作集是在“频繁请求”截止之前使用的数据(在图中用垂直线标示)
一些工作集示例
假设您有一个 40 GB 的工作集。90%的请求命中工作集,10%命中其他数据。如果您有 500 GB 的数据和 50 GB 的 RAM,则您的工作集完全适合 RAM。一旦应用程序访问了通常访问的数据(称为预热过程),它就不应再为工作集访问磁盘。然后,您有 10 GB 的空间可用于 460 GB 的不经常访问的数据。显然,MongoDB 几乎总是需要访问非工作集数据才能访问磁盘。
另一方面,假设您的工作集不适合 RAM——例如,如果您只有 35 GB 的 RAM。那么工作集通常会占用大部分 RAM。工作集更有可能留在 RAM 中,因为它被更频繁地访问,但在某个时刻,不经常访问的数据将被分页进入,驱逐工作集(或其他不经常访问的数据)。因此,从磁盘访问工作集没有可预测的性能。
性能跟踪
查询性能通常是需要跟踪和保持一致的重要指标。有几种方法可以跟踪 MongoDB 是否在处理当前请求负载时遇到问题。
MongoDB 可能会因为 CPU 受 I/O 限制(通过高 I/O 等待指示)。WiredTiger 存储引擎是多线程的,并且可以利用额外的 CPU 核心。与较旧的 MMAP 存储引擎相比,这可以在 CPU 指标的更高使用水平中看出。然而,如果用户或系统时间接近 100%(或者是您拥有的 CPU 数量乘以 100%),最常见的原因是您在频繁使用的查询上缺少索引。跟踪 CPU 使用率(特别是在部署应用程序的新版本后)是一个好主意,以确保所有查询都表现如预期。
注意,图表显示在 图 22-7 中是正常的:如果页面故障数较少,I/O 等待可能会被其他 CPU 活动所掩盖。只有当其他活动开始增加时,坏的索引可能是罪魁祸首。

图 22-7. 具有最小 I/O 等待时间的 CPU:顶部线为用户,底部线为系统;其他统计数据非常接近 0%
一个类似的度量标准是排队:MongoDB 等待处理的请求数量。当请求等待其需要的锁进行读取或写入时,请求被视为排队。图 22-8 显示了随时间变化的读取和写入队列的图表。没有队列是首选(基本上是一个空图表),但这个图表并不值得担忧。在一个繁忙的系统中,一个操作不得不等待正确的锁变得可用是很正常的事情。

图 22-8. 随时间变化的读取和写入队列
WiredTiger 存储引擎提供文档级并发,允许对同一集合进行多个同时写入。这显著提高了并发操作的性能。所使用的票务系统控制正在使用的线程数,以避免饥饿:它为读取和写入操作分配票据(默认情况下,每种操作分配 128 个),在此之后新的读取或写入操作将排队。serverStatus 的 wiredTiger.concurrentTransactions.read.available 和 wiredTiger.concurrentTransactions.write.available 字段可用于跟踪可用票据数量何时降至零,表示相应的操作现在正在排队。
通过查看排队的请求数量,您可以看出请求是否在堆积。通常情况下,队列大小应该很小。一个大且长期存在的队列表明 mongod 无法跟上其负载。您应该尽快减少该服务器上的负载。
跟踪剩余空间
另一个基本但重要的监控指标是磁盘使用情况。有时用户会等到磁盘空间用完才考虑如何处理。通过监控磁盘使用情况并跟踪剩余磁盘空间,你可以预测当前驱动器足够使用的时间,并提前计划在不足时的处理方法。
当你的空间用完时,有几个选项:
-
如果你正在使用分片,请添加另一个分片。
-
如果有未使用的索引,删除它们。可以使用特定集合的聚合
$indexStats来识别它们。 -
如果你还没有在从节点上运行压缩操作,那么在这方面尝试一下。这通常只在从集合中删除大量数据或索引并且不会被替换时才有用。
-
关闭副本集的每个成员(逐个),将其数据复制到较大的磁盘上,然后可以挂载该磁盘。重新启动成员并继续下一个。
-
用具有较大驱动器的成员替换副本集的成员:删除一个旧成员并添加一个新成员,让它赶上其余成员。对副本集的每个成员重复此操作。
-
如果你使用了
directoryperdb选项,并且你有一个特别快速增长的数据库,将其移到独立的驱动器上。然后在你的数据目录中将该卷挂载为一个目录。这样可以避免移动其余数据。
无论你选择哪种技术,都要提前计划以最小化对你的应用程序的影响。你需要时间来备份数据,逐个修改副本集的每个成员,并将数据从一个地方复制到另一个地方。
监控复制
复制滞后和操作日志长度是重要的监控指标。滞后是指从节点不能跟上主节点的情况。它的计算方法是从从节点上应用的最后一个操作的时间减去主节点上最后一个操作的时间。例如,如果从节点刚刚应用了一个时间戳为下午 3:26:00 的操作,而主节点刚刚应用了一个时间戳为下午 3:29:45 的操作,则从节点滞后了 3 分钟 45 秒。你希望滞后尽可能接近 0,通常是毫秒级的。如果从节点跟上主节点,复制滞后应该看起来像图 22-9 中显示的那样:基本上始终为 0。

图 22-9. 一个没有滞后的副本集;这是你希望看到的情况
如果一个从节点不能像主节点写入那样快速地复制写操作,你会开始看到一个非零的滞后。这种情况的极端情形是复制卡住了:从节点由于某些原因不能再应用任何操作。此时,滞后将每秒增长一秒,形成图 22-10 中显示的陡峭斜坡。这可能是由网络问题或缺少"_id"索引引起的,后者在每个集合上都是复制正常运行所必需的。
小贴士
如果一个集合缺少 "_id" 索引,请将服务器从副本集中移出,作为独立服务器启动,并构建 "_id" 索引。确保将 "_id" 索引创建为唯一索引。一旦创建,"_id" 索引不能被删除或更改(除非删除整个集合)。
如果系统过载,次要节点可能逐渐落后。一些复制仍在进行,因此通常不会在图表中看到典型的“每秒一秒”的斜坡。不过,如果次要节点无法跟上高峰期的流量或者逐渐落后,这一点非常重要。

图 22-10. 复制出现故障,并在 2 月 10 日前开始恢复;垂直线代表服务器重新启动。
主节点不会限制写入速度来“帮助”次要节点追赶,因此在过载系统上次要节点落后是常见的(特别是因为 MongoDB 倾向于优先处理写入而不是读取,这意味着复制可能会在主节点上饿死)。你可以通过在写入关注中使用 "w" 来在一定程度上强制主节点限制。你也可能希望尝试将次要节点上处理的任何请求路由到另一个成员。
如果你的系统极度负载不足,你可能会看到另一种有趣的模式:复制延迟突然上升,如图 22-11 所示。所显示的突然上升实际上不是延迟,而是由于采样变化引起的。mongod 处理每隔几分钟写入一次。因为延迟是通过主节点和次要节点时间戳之间的差值来衡量的,所以在主节点写入之前,测量次要节点的时间戳看起来会晚几分钟。如果增加写入速率,这些突然上升应该会消失。

图 22-11. 低写入系统可能导致“虚假”延迟。
跟踪的另一个重要复制指标是每个成员的操作日志(oplog)长度。可能成为主节点的每个成员都应该有超过一天的操作日志。如果一个成员可能成为另一个成员的同步源,它应该有超过初始同步完成所需时间的操作日志。图 22-12 展示了一个标准的操作日志长度图表。这个操作日志长度很好:1,111 小时相当于一个月的数据!一般来说,操作日志应该尽可能长,只要你负担得起磁盘空间。考虑到它们的使用方式,它们基本上不占用内存,而长操作日志可能意味着在运维体验上的巨大差异,从痛苦到轻松。

图 22-12. 典型的操作日志长度图表。
图 22-13 展示了由于操作日志较短和流量变化导致的稍微不寻常的变化。这仍然是健康的,但是这台机器上的操作日志可能太短了(维护期间为 6 到 11 小时)。管理员可能希望有机会时将操作日志长度延长。

第 22-13 图。一个每天流量高峰的应用程序的操作日志长度图表。
第二十三章:制作备份
定期备份系统非常重要。备份可以有效保护免受大多数类型的故障影响,而通过从干净的备份进行恢复几乎可以解决所有问题。本章涵盖了制作备份的常见选项:
-
单服务器备份,包括快照备份和恢复过程
-
备份副本集的特殊考虑事项
-
构建一个分片集群
仅当您对在紧急情况下部署备份感到有信心时,备份才有用。因此,无论您选择哪种备份技术,请务必练习制作备份和从备份中恢复,直到您熟悉恢复过程。
备份方法
有多种选项可以备份 MongoDB 集群。MongoDB Atlas,官方 MongoDB 云服务,提供连续备份和云提供商快照。连续备份会增量备份集群中的数据,确保备份通常仅落后操作系统几秒钟。云提供商快照使用集群云服务提供商(例如 Amazon Web Services,Microsoft Azure 或 Google Cloud Platform)的快照功能提供本地化备份存储。对于大多数情况,最佳的备份解决方案是连续备份。
MongoDB 还通过 Cloud Manager 和 Ops Manager 提供备份功能。Cloud Manager 是 MongoDB 的托管备份、监控和自动化服务。Ops Manager 是一个本地解决方案,具有与 Cloud Manager 类似的功能。
对于直接管理 MongoDB 集群的个人和团队,有几种备份策略。本章的其余部分将概述这些策略。
备份服务器
有多种方法可以创建备份。无论使用哪种方法,备份都可能对系统造成压力:通常需要将所有数据读入内存。因此,备份通常应该在副本集的次要成员(而不是主要成员)上进行,或者对于独立服务器,在非高峰时间进行。
本节中的技术适用于任何mongod,无论是独立服务器还是副本集的成员,除非另有说明。
文件系统快照
文件系统快照使用系统级工具创建 MongoDB 数据文件所在设备的副本。这些方法完成迅速且可靠,但需要在 MongoDB 之外进行额外的系统配置。
MongoDB 3.2 添加了对使用 WiredTiger 存储引擎进行卷级备份的支持,当这些实例的数据文件和日志文件驻留在不同卷上时。然而,为了创建一个一致的备份,必须锁定数据库并在备份过程中暂停对数据库的所有写操作。
在 MongoDB 3.2 之前,使用 WiredTiger 创建 MongoDB 实例的卷级备份要求数据文件和日志文件驻留在同一个卷上。
快照通过在实时数据和特殊快照卷之间创建指针来工作。这些指针在理论上等同于“硬链接”。随着工作数据与快照的分歧,快照过程采用写时复制策略。因此,快照仅存储修改后的数据。
制作快照后,您将快照映像挂载到文件系统上,并从快照中复制数据。生成的备份包含所有数据的完整副本。
快照拍摄时数据库必须有效。这意味着数据库接受的所有写操作都需要完全写入磁盘:要么写入日志,要么写入数据文件。如果备份时还有未写入磁盘的写操作,则备份将不会反映这些更改。
对于 WiredTiger 存储引擎,数据文件反映了最后一个检查点时的一致状态。检查点每分钟发生一次。
快照创建整个磁盘或卷的映像。除非您需要备份整个系统,否则请考虑将 MongoDB 数据文件、日志(如果适用)和配置隔离在一个不包含任何其他数据的逻辑磁盘上。或者,将所有 MongoDB 数据文件存储在专用设备上,以便您可以备份而不复制多余的数据。
确保您从快照中复制数据到其他系统。这样可以确保数据免受站点故障的影响。
如果您的mongod实例启用了日志记录,则可以使用任何类型的文件系统或卷/块级快照工具来创建备份。
如果您在基于 Linux 的系统上管理自己的基础设施,请使用 Linux 逻辑卷管理器(LVM)配置系统,以提供磁盘包和提供快照功能。LVM 允许动态调整大小的文件系统的灵活组合和分割物理磁盘分区。您还可以在云/虚拟化环境中使用基于 LVM 的设置。
在 LVM 的初始设置中,首先我们为物理卷分配磁盘分区(pvcreate),然后将其中一个或多个分配给卷组(vgcreate),然后创建逻辑卷(lvcreate),引用卷组。我们可以在逻辑卷上建立文件系统(mkfs),创建后可以挂载以供使用(mount)。
快照备份和恢复过程
本节概述了在 Linux 系统上使用 LVM 进行简单备份的过程。虽然工具、命令和路径在您的系统上可能略有不同,但以下步骤提供了备份操作的高级概述。
仅将以下程序用作备份系统和基础设施的指南。生产备份系统必须考虑特定环境中的多个应用程序特定要求和因素。
要使用 LVM 创建快照,请以root用户发出以下格式的命令:
# lvcreate --size 100M --snapshot --name mdb-snap01 /dev/vg0/mongodb
此命令使用 --snapshot 选项创建名为 mdb-snap01 的 LVM 快照,该快照位于 vg0 卷组中的 mongodb 卷上,路径为 /dev/vg0/mdb-snap01。根据您的操作系统的 LVM 配置,系统、卷组和设备的位置和路径可能会略有不同。
由于参数 --size 100M 的限制,快照的容量为 100 MB。这个大小不反映磁盘上数据的总量,而是当前 /dev/vg0/mongodb 状态与快照 (/dev/vg0/mdb-snap01) 之间差异的量。
当命令返回时,快照将存在。您可以随时直接从快照恢复,或者创建一个新的逻辑卷,并从快照恢复到备用映像。
尽管快照非常适合快速创建高质量备份,但作为存储备份数据的格式并不理想。快照通常依赖于并驻留在与原始磁盘映像相同的存储基础设施上。因此,非常重要的是将这些快照归档并存储在其他地方。
创建快照后,挂载快照并将数据复制到独立存储。或者,执行如下过程,对快照图像进行块级复制,例如:
# umount /dev/vg0/mdb-snap01
# dd if=/dev/vg0/mdb-snap01 | gzip > mdb-snap01.gz
此命令序列执行以下操作:
-
确保 /dev/vg0/mdb-snap01 设备未被挂载
-
使用
dd命令执行整个快照图像的块级复制,并将结果压缩为当前工作目录中的一个 gzipped 文件
警告
dd 命令将在当前工作目录中创建一个大的 .gz 文件。确保在有足够空闲空间的文件系统中运行 这个命令。
要恢复使用 LVM 创建的快照,请执行以下命令序列:
# lvcreate --size 1G --name mdb-new vg0
# gzip -d -c mdb-snap01.gz | dd of=/dev/vg0/mdb-new
# mount /dev/vg0/mdb-new /srv/mongodb
此序列执行以下操作:
-
在 /dev/vg0 卷组中创建一个名为 mdb-new 的新逻辑卷。新设备的路径将为 /dev/vg0/mdb-new。您可以使用不同的名称,并将 1G 更改为所需的卷大小。
-
将 mdb-snap01.gz 文件解压并解档到 mdb-new 磁盘映像中。
-
挂载 mdb-new 磁盘映像到 /srv/mongodb 目录。根据需要修改挂载点以对应您的 MongoDB 数据文件位置或其他位置。
恢复的快照将具有陈旧的 mongod.lock 文件。如果不从快照中删除此文件,MongoDB 可能会认为陈旧的锁文件指示了一个不干净的关闭。如果您启用了 storage.journal.enabled 并且没有使用 db.fsyncLock(),则不需要删除 mongod.lock 文件。如果您使用了 db.fsyncLock(),则需要删除该锁。
要恢复备份而不写入压缩的 .gz 文件,请使用以下命令序列:
# umount /dev/vg0/mdb-snap01
# lvcreate --size 1G --name mdb-new vg0
# dd if=/dev/vg0/mdb-snap01 of=/dev/vg0/mdb-new
# mount /dev/vg0/mdb-new /srv/mongodb
可以使用组合过程和 SSH 实现离系统备份。这个序列与之前解释的过程完全相同,只是使用 SSH 在远程系统上存档并压缩备份:
umount /dev/vg0/mdb-snap01
dd if=/dev/vg0/mdb-snap01 | ssh username@example.com gzip > /opt/backup/mdb-snap01.gz
lvcreate --size 1G --name mdb-new vg0
ssh username@example.com gzip -d -c /opt/backup/mdb-snap01.gz | dd of=/dev/vg0/mdb-new
mount /dev/vg0/mdb-new /srv/mongodb
从 MongoDB 3.2 开始,为了使用 WiredTiger 对 MongoDB 实例进行卷级备份,不再需要数据文件和日志驻留在单个卷上。然而,在备份过程中必须锁定数据库并暂停对数据库的所有写入,以确保备份的一致性。
如果您的 mongod 实例正在运行而没有使用日志或将日志文件存储在单独的卷上,则必须将所有写操作刷新到磁盘并锁定数据库以防止备份过程中的写入。如果您有副本集配置,则备份时请使用未接收读取的次要成员(即隐藏成员)。
要执行此操作,请在 mongo shell 中使用 db.fsyncLock() 方法:
> db.fsyncLock();
然后执行先前描述的备份操作。
快照完成后,在 mongo shell 中发出以下命令解锁数据库:
> db.fsyncUnlock();
此过程在以下部分中有更详细的描述。
复制数据文件
另一种创建单服务器备份的方法是复制数据目录中的所有内容。由于不能同时复制所有文件而没有文件系统支持,因此必须在复制数据文件时防止其变化。这可以通过称为 fsyncLock 的命令来完成:
> db.fsyncLock()
此命令锁定数据库以防止进一步的写入,并将所有脏数据刷新到磁盘(fsync),确保数据目录中的文件具有最新的一致信息且不在变化。
运行此命令后,mongod 将排队所有传入的写入。在解锁之前,它不会处理任何进一步的写入。请注意,此命令会停止对所有数据库(而不仅仅是连接到的数据库)的写入。
一旦 fsyncLock 命令返回,将所有数据目录中的文件复制到备份位置。在 Linux 上,可以使用以下命令完成:
$ cp -R /data/db/* /mnt/external-drive/backup
确保将数据目录中的每个文件和文件夹都复制到备份位置。排除文件或目录可能会导致备份无法使用或损坏。
完成数据复制后,请解锁数据库以允许其再次写入:
> db.fsyncUnlock()
你的数据库将会正常处理写操作。
请注意,使用认证和fsyncLock存在一些锁定问题。如果使用认证,请不要在调用fsyncLock和fsyncUnlock之间关闭 Shell。如果断开连接,您可能无法重新连接并且必须重新启动mongod。fsyncLock设置在重新启动之间不会持久化;mongod始终会解锁启动。
作为fsyncLock的替代方法,你可以关闭mongod,复制文件,然后再次启动mongod。关闭mongod会有效地将所有更改刷新到磁盘,并在备份期间防止新的写入操作发生。
要从数据目录的副本中恢复,请确保mongod没有在运行,并且要将要恢复的数据目录清空。将备份的数据文件复制到数据目录,然后启动mongod。例如,以下命令将恢复之前显示的命令备份的文件:
$ cp -R /mnt/external-drive/backup/* /data/db/
$ mongod -f mongod.conf
尽管有关部分数据目录副本的警告,如果您知道要复制的内容以及使用--directoryperdb选项,您可以使用此方法备份单个数据库。要备份一个名为myDB的单个数据库(仅在使用--directoryperdb选项时可用),复制整个myDB目录。部分数据目录副本仅在使用--directoryperdb选项时才可能。
您可以通过仅复制具有正确数据库名称的文件来恢复特定数据库到您的数据目录中。如果要这样逐个还原,必须从干净的关闭开始。如果发生崩溃或硬关闭,请勿尝试从备份中恢复单个数据库:替换整个目录并启动mongod以允许重放日志文件。
警告
永远不要与mongodump(稍后描述)一起使用fsyncLock。根据数据库的其他操作,mongodump可能会永远挂起如果数据库被锁定。
使用 mongodump
最后一种制作单服务器备份的方法是使用mongodump。之所以将mongodump放在最后提到,是因为它有一些缺点。它速度较慢(获取备份和从中恢复)并且在复制集方面存在一些问题,这些问题在“复制集的特定注意事项”中有讨论。但是,它也有一些好处:是备份单个数据库、集合甚至集合子集的好方法。
mongodump有多种选项,可以通过运行mongodump --help查看。在这里,我们将重点介绍用于备份的最有用的选项。
要备份所有数据库,只需运行mongodump。如果在与mongod相同的机器上运行mongodump,只需指定mongod运行的端口即可:
$ mongodump -p 31000
mongodump 将在当前目录下创建一个 dump 目录,其中包含所有数据的备份。这个 dump 目录按数据库和集合分别组织成文件夹和子文件夹。实际数据存储在 .bson 文件中,这些文件只是将每个集合中的每个文档以 BSON 格式串联在一起。你可以使用随 MongoDB 一起提供的 bsondump 工具查看 .bson 文件。
你甚至不需要运行服务器就可以使用 mongodump。你可以使用 --dbpath 选项指定你的数据目录,mongodump 将使用数据文件进行数据复制:
$ mongodump --dbpath /data/db
如果 mongod 在运行,则不应使用 --dbpath。
mongodump 的一个问题是它不是即时备份:备份进行时系统可能正在写入。因此,可能出现这样的情况,用户 A 开始了一个导致 mongodump 转储数据库 A 的备份,但同时用户 B 删除了 A。然而,mongodump 已经完成了转储,因此你将得到一个与原始服务器状态不一致的数据快照。
为了避免这种情况,如果你正在使用 --replSet 运行 mongod,你可以使用 mongodump 的 --oplog 选项。这将跟踪备份期间在服务器上发生的所有操作,因此可以在还原备份时重新播放这些操作。这为你提供了源服务器数据的一致时间点快照。
如果你向 mongodump 提供一个副本集连接字符串(例如,"*`setName`*/*`seed1`*,*`seed2`*,*`seed3`*"),它将自动选择主节点进行备份。如果你想使用副本节点,你可以指定一个 read preference。read preference 可以通过 --uri connection string、uri readPreferenceTags 选项或 --readPreference 命令行选项来指定。有关各种设置和选项的详细信息,请参阅 the mongodump MongoDB documentation page。
要从 mongodump 备份还原数据,请使用 mongorestore 工具:
$ mongorestore -p 31000 --oplogReplay dump/
如果你在转储数据库时使用了 --oplog 选项,你必须在使用 mongorestore 还原时使用 --oplogReplay 选项来获取时间点快照。
如果你正在替换运行中的服务器上的数据,你可能(或者可能不)希望使用 --drop 选项,在还原之前删除集合。
mongodump 和 mongorestore 的行为随着时间的推移而发生了变化。为了防止兼容性问题,请尽量使用这两个工具的相同版本(你可以通过运行 mongodump --version 和 mongorestore --version 来查看它们的版本)。
警告
从 MongoDB 版本 4.2 开始,你不能再将 mongodump 或 mongorestore 用作备份分片集群的策略。这些工具不保证跨分片的事务的原子性。
使用 mongodump 和 mongorestore 移动集合和数据库
你可以将数据恢复到与你备份的完全不同的数据库和集合中。如果不同的环境使用不同的数据库名称(比如dev和prod),但使用相同的集合名称,则这可能非常有用。
要将.bson文件恢复到特定的数据库和集合中,请在命令行上指定目标:
$ mongorestore --db newDb --collection someOtherColl dump/oldDB/oldColl.bson
也可以使用 SSH 与这些工具一起执行数据迁移,使用这些工具的归档功能,无需任何磁盘 I/O。这将三个阶段简化为一个操作,以前你必须备份到磁盘,然后将这些备份文件复制到目标服务器,然后在该服务器上运行mongorestore来恢复备份:
$ ssh eoin@proxy.server.com mongodump --host source.server.com\ --archive
| ssh eoin@target.server.com mongorestore --archive
压缩可以与这些工具的归档功能结合使用,进一步减少执行数据迁移时发送的信息量。以下是使用这些工具的归档和压缩功能执行相同 SSH 数据迁移示例:
$ ssh eoin@proxy.server.com mongodump --host source.server.com\ --archive
--gzip | ssh eoin@target.server.com mongorestore --archive --gzip
唯一索引的管理复杂性
如果你的集合中有唯一索引(除了"_id"以外),你应该考虑使用不同于mongodump/mongorestore的备份类型。唯一索引要求在复制过程中数据不会以违反唯一索引约束的方式发生变化。确保这一点的最安全方法是选择一种“冻结”数据的方法,然后按照前两节中的任一描述进行备份。
如果你决定使用mongodump/mongorestore,在从备份恢复数据时可能需要预处理你的数据。
副本集的特定考虑因素
在备份副本集时的主要额外考虑因素是,除了数据之外,你还必须捕获副本集的状态,以确保对部署进行准确的时间点快照。
通常,应从次要节点进行备份:这样可以减轻主节点的负载,并且你可以锁定次要节点而不影响你的应用程序(只要你的应用程序不发送读请求到次要节点)。你可以使用前面概述的任一三种方法之一来备份副本集成员,但建议使用文件系统快照或数据文件复制。这些技术都可以应用于副本集的次要节点而无需修改。
当启用复制时,使用mongodump不是那么简单。首先,如果你使用mongodump,必须使用--oplog选项进行备份,以获取一个时间点的快照;否则备份的状态将不会与集群中其他成员的状态匹配。当从mongodump备份中恢复时,你还必须创建一个操作日志,否则恢复后的成员将不知道它同步到了哪里。
要从 mongodump 备份中恢复副本集成员,请将目标副本集成员作为独立服务器启动,并使用空数据目录运行 mongorestore(如前一节所述),并使用 --oplogReplay 选项。现在它应该具有完整的数据副本,但仍然需要一个 oplog。使用 createCollection 命令创建一个 oplog:
> use local
> db.createCollection("oplog.rs", {"capped" : true, "size" : 10000000})
指定集合的字节大小。请参阅 “调整 Oplog 大小” 以获取有关 oplog 大小调整的建议。
现在您需要填充 oplog。最简单的方法是将转储的 oplog.bson 文件恢复到 local.oplog.rs 集合中:
$ mongorestore -d local -c oplog.rs dump/oplog.bson
注意,这不是 oplog 本身的转储(dump/local/oplog.rs.bson),而是在转储过程中发生的 oplog 操作。一旦完成此 mongorestore,您可以将此服务器重新启动为副本集成员。
分片集群的特殊考虑因素
使用本章介绍的方法备份分片集群时的主要额外考虑因素是,您只能在它们活跃时备份各个分片,并且在活跃状态下几乎不可能“完美地”备份分片集群:无法在某一时刻获得集群整体状态的快照。然而,一般情况下,随着集群规模的增大,您几乎不太可能需要从备份中恢复整个集群。因此,在处理分片集群时,我们关注备份各个部分:配置服务器和副本集各自的备份。如果您需要能够备份整个集群到特定时间点,或者希望使用自动化解决方案,可以使用 MongoDB 的 Cloud Manager 或 Atlas 备份功能。
在对分片集群执行任何这些操作(备份或恢复)之前,请关闭平衡器。您无法在分片飞来飞去的情况下获取一个一致的世界快照。请参阅 “平衡数据” 以获取有关打开和关闭平衡器的说明。
备份和恢复整个集群
当集群非常小或处于开发阶段时,您可能确实希望转储和恢复整个集群。您可以通过关闭平衡器,然后通过 mongos 运行 mongodump 来实现这一点。这会在运行 mongodump 的机器上备份所有分片的数据。
要从这种类型的备份恢复,请运行连接到 mongos 的 mongorestore。
或者,在关闭平衡器后,您可以获取每个分片和配置服务器的文件系统或数据目录备份。但是,您将不可避免地在略有不同的时间点获得每个备份副本,这可能会或可能不会成为问题。此外,一旦启用平衡器并进行迁移,您从一个分片备份的一些数据将不再存在。
备份和恢复单个分片
大多数情况下,您只需要恢复集群中的一个分片。如果您不是太挑剔的话,可以使用刚才描述的单服务器方法之一从该分片的备份中恢复。
然而,有一个重要问题需要注意。假设您在星期一备份了集群。到了星期四,您的硬盘损坏了,您需要从备份中恢复。在这几天中,新的数据块可能已经移到了该分片。您星期一备份的分片不包含这些新的数据块。您可能可以使用配置服务器的备份来找出星期一消失的数据块在哪里,但这比简单地恢复分片要困难得多。在大多数情况下,恢复分片并丢失这些数据块中的数据是更可取的路径。
您可以直接连接到一个分片来从备份中恢复(而不通过mongos)。
第二十四章:部署 MongoDB
本章提供了用于设置进入生产服务器的建议。具体来说,涵盖了:
-
选择购买什么硬件以及如何设置它
-
使用虚拟化环境
-
重要的内核和磁盘 I/O 设置
-
网络设置:谁需要连接到谁
系统设计
通常希望优化数据安全性和能够负担的最快访问速度。本节讨论在选择磁盘、RAID 配置、CPU 和其他硬件及低级软件组件时实现这些目标的最佳方法。
选择存储介质
按照偏好顺序,我们希望从以下位置存储和检索数据:
-
RAM
-
SSD
-
旋转磁盘
不幸的是,大多数人的预算有限或者数据量足够大,使得将所有内容存储在 RAM 中不切实际,而 SSD 又太昂贵。因此,典型的部署是少量 RAM(相对于总数据量)和大量旋转磁盘空间。如果您处于这种情况,重要的是您的工作集小于 RAM,并且如果工作集变大,您应该准备好进行扩展。
如果您能够在硬件上花费很多钱,购买大量 RAM 和/或 SSD。
从 RAM 读取数据需要几纳秒(比如说,100)。相反,从磁盘读取需要几毫秒(比如说,10)。很难想象这两个数字之间的差异,所以让我们将它们扩展到更容易理解的数字:如果访问 RAM 需要 1 秒,那么访问磁盘将需要超过一天!
100 纳秒 × 10,000,000 = 1 秒
10 毫秒 × 10,000,000 = 1.16 天
这些都是非常粗略的估算(您的磁盘可能稍微快一些或者您的 RAM 可能稍微慢一些),但这种差异的数量级并不会有太大变化。因此,我们希望尽可能少地访问磁盘。
推荐的 RAID 配置
RAID 是一种硬件或软件,可以让您将多个磁盘看作单个磁盘来使用。它可以用于可靠性、性能或两者兼而有之。使用 RAID 的一组磁盘称为 RAID 阵列(有点冗余,因为 RAID 意为冗余的廉价磁盘阵列)。
有许多配置 RAID 的方法,取决于您所寻找的功能组合——通常是一些速度和容错性的结合。以下是最常见的几种类型:
RAID0
为了提高性能而进行磁盘条带化。每个磁盘保存部分数据,类似于 MongoDB 的分片。由于有多个底层磁盘,可以同时写入大量数据。这提高了写入的吞吐量。然而,如果一个磁盘故障并丢失数据,则没有其它副本。这也可能导致读取缓慢,因为某些数据卷可能比其他卷慢。
RAID1
为了提高可靠性而进行镜像。数据的完全副本被写入阵列的每个成员。这比 RAID0 具有较低的性能,因为一个速度较慢的磁盘会拖慢所有写操作。然而,如果一个磁盘故障,你仍然可以在阵列的另一个成员上找到数据的副本。
RAID5
磁盘条带化,以及保留关于已存储数据的其他数据片段,以防服务器故障时丢失数据。基本上,RAID5 可以处理一个磁盘故障,并从用户那里隐藏此故障。然而,它比这里列出的任何其他变体都要慢,因为每次写入数据时都需要计算这个额外的信息。对于 MongoDB 来说尤其昂贵,因为典型的工作负载执行许多小写操作。
RAID10
RAID0 和 RAID1 的结合:数据条带化以提高速度,同时镜像以提高可靠性。
我们建议使用 RAID10:它比 RAID0 更安全,并且可以解决 RAID1 可能出现的性能问题。然而,有些人认为在副本集之上使用 RAID1 过于保守,因此选择了 RAID0。这是一个个人偏好的问题:你愿意为了性能而承担多大的风险?
不要使用 RAID5:它非常非常慢。
CPU
MongoDB 在历史上对 CPU 消耗很轻,但使用 WiredTiger 存储引擎后情况已经改变。WiredTiger 存储引擎是多线程的,可以利用额外的 CPU 核心。因此,你应该在内存和 CPU 之间保持平衡。
在速度和核心数量之间进行选择时,请选择速度。MongoDB 更擅长利用单个处理器上的更多周期,而不是增加并行性。
操作系统
64 位 Linux 是 MongoDB 表现最佳的操作系统。如果可能的话,请使用这些操作系统的某个变体。CentOS 和 Red Hat Enterprise Linux 可能是最流行的选择,但任何变体都应该可以使用(Ubuntu 和 Amazon Linux 也很常见)。请确保使用操作系统的最新稳定版本,因为旧的、有错误的软件包或内核有时可能会引起问题。
64 位 Windows 也得到了很好的支持。
其他 Unix 变体的支持不如 Linux:如果你使用 Solaris 或 BSD 的其中一个,请谨慎处理。在历史上,这些系统的构建存在许多问题。MongoDB 明确于 2017 年 8 月停止支持 Solaris,指出用户对其缺乏采用。
关于跨兼容性的一项重要说明:MongoDB 在所有系统上使用相同的传输协议并以相同的方式布置数据文件,因此你可以在多种操作系统组合上部署。例如,你可以在 Windows 上运行mongos进程,而其作为分片的mongod则在 Linux 上运行。你还可以在 Windows 和 Linux 之间复制数据文件,而无需担心兼容性问题。
自版本 3.4 起,MongoDB 不再支持 32 位 x86 平台。不要在 32 位机器上运行任何类型的 MongoDB 服务器。
MongoDB 可与小端架构一起工作,并支持一种大端架构:IBM 的 zSeries。大多数驱动程序都支持小端和大端系统,因此您可以在任何一个上运行客户端。但服务器通常会在小端机器上运行。
交换空间
在内存限制达到时,应分配少量交换空间,以防止内核杀死 MongoDB。它通常不会使用任何交换空间,但在极端情况下,WiredTiger 存储引擎可能会使用一些。如果发生这种情况,则应考虑增加机器的内存容量或重新审视工作负载,以避免这种对性能和稳定性都具有问题的情况。
MongoDB 使用的大部分内存是“滑动”的:只要系统请求用于其他用途的空间,它就会被刷新到磁盘并用其他内存替换。因此,数据库数据不应该被写入交换空间:它将首先被刷新回磁盘。
然而,偶尔 MongoDB 会使用交换空间来进行需要排序数据的操作:无论是构建索引还是排序。它尽力不会为这些类型的操作使用过多内存,但通过同时执行许多这些操作,可能会强制进行交换。
如果您的应用程序设法使 MongoDB 使用交换空间,应考虑重新设计应用程序或减少对交换服务器的负载。
文件系统
对于 Linux,仅推荐在使用 WiredTiger 存储引擎时使用 XFS 文件系统作为您的数据卷。虽然可以在 WiredTiger 上使用 ext4 文件系统,但请注意已知的性能问题(特别是在 WiredTiger 检查点上可能会出现停顿)。
在 Windows 上,NTFS 或 FAT 都可以。
警告
不要直接将 Network File Storage(NFS)挂载用于 MongoDB 存储。某些客户端版本会关于刷新,随机重新挂载和刷新页面缓存,并不支持排他文件锁定。使用 NFS 可能会导致日志损坏,应尽量避免。
虚拟化
虚拟化是获取廉价硬件并能够快速扩展的好方法。然而,也存在一些缺点 —— 特别是不可预测的网络和磁盘 I/O。本节涵盖了虚拟化特定的问题。
内存过度承诺
memory overcommit Linux 内核设置控制当进程从操作系统请求过多内存时会发生什么。根据设置方式,内核可能会向进程分配内存,即使该内存实际上并不可用(希望在进程需要时会变得可用)。这就是所谓的过度承诺:内核承诺了实际上并不存在的内存。这个操作系统内核设置与 MongoDB 不兼容。
vm.overcommit_memory 的可能值为 0(内核猜测超出量的大小)、1(内存分配总是成功)或 2(不要比交换空间加上超出比率的一小部分更多的虚拟地址空间)。值 2 比较复杂,但是这是目前可用的最佳选项。要设置此选项,请运行:
$ echo 2 > /proc/sys/vm/overcommit_memory
更改操作系统设置后,您无需重新启动 MongoDB。
神秘的内存
有时虚拟化层未正确处理内存分配。因此,您可能有一个虚拟机声称有 100GB 可用的内存,但实际上只能让您访问其中的 60GB。相反地,我们也看到有些人本应有 20GB 内存,最终可以将整个 100GB 的数据集放入内存中!
假设您没有运气,您将无能为力。如果您的操作系统预读设置正确,并且您的虚拟机只是不会使用应有的所有内存,您可能只能切换虚拟机。
处理网络磁盘 I/O 问题
使用虚拟化硬件的最大问题之一是,通常您与其他租户共享磁盘,这会加剧之前提到的磁盘速度缓慢问题,因为每个人都在竞争磁盘 I/O。因此,虚拟化磁盘的性能非常不可预测:它们在您的邻居不忙的时候可能运行良好,但如果有人开始大量使用磁盘,它们可能突然变得运行缓慢。
另一个问题是,这种存储通常未直接连接到运行 MongoDB 的机器上,因此即使您拥有一块独立的硬盘,I/O 的速度也会比使用本地硬盘慢。还有一个不太可能但有可能的情况是,您的 MongoDB 服务器与数据的网络连接中断。
亚马逊拥有可能是最广泛使用的网络块存储,称为弹性块存储(Elastic Block Store,EBS)。EBS 卷可以连接到弹性计算云(Elastic Compute Cloud,EC2)实例,允许您立即为机器提供几乎任意数量的磁盘。如果您正在使用 EC2,还应该在实例类型可用时启用 AWS 增强网络,并禁用动态电压和频率调节(DVFS)以及 CPU 节能模式和超线程。从积极的一面来看,EBS 使备份变得非常简单(从次要源快照,将 EBS 驱动器挂载到另一个实例上,并启动 mongod)。但是,缺点是您可能会遇到性能不稳定的情况。
如果您需要更可预测的性能,有几个选择。一种是在自己的服务器上托管 MongoDB —— 这样,您就知道没有人会拖慢速度。但是,对于很多人来说,这并不是一个选择,因此下一个最好的选择就是在云中获取一个保证每秒 I/O 操作数的实例。有关托管服务的最新建议,请参阅 http://docs.mongodb.org。
如果你不能选择上述任何一种选项,而你需要更多的磁盘 I/O 超过过载的 EBS 卷能够支持的话,还有一种方法可以绕过它。基本上,你可以持续监控 MongoDB 正在使用的卷。如果那个卷变慢了,立即杀掉该实例,然后启动一个新的实例,并使用不同的数据卷。
有几个统计数据需要关注:
-
I/O 利用率飙升(在 Cloud Manager/Atlas 上的“IO wait”),显而易见的原因。
-
页面错误率飙升。请注意,应用程序行为的更改也可能导致工作集的变化:在部署新版本应用程序之前,你应该禁用这个刺杀脚本。
-
丢失的 TCP 数据包数量上升(Amazon 在这方面尤其糟糕:当性能开始下降时,它会随处丢弃 TCP 数据包)。
-
MongoDB 的读写队列飙升(可以在 Cloud Manager/Atlas 或mongostat的
qr/qw列中看到)。
如果你的负载在一天或一周内有所变化,请确保你的脚本考虑到这一点:你不希望一个恶意的定时作业因为周一早晨的异常繁忙而杀掉所有的实例。
这个技巧依赖于你有最近的备份或者数据集同步速度比较快。如果每个实例都持有几 TB 的数据,你可能需要考虑其他方法。此外,这只是可能有效:如果你的新卷也受到负载的影响,它将和旧卷一样慢。
使用非网络连接的磁盘
注意
本节使用了亚马逊特定的术语。然而,它可能适用于其他提供商。
临时驱动器是连接到虚拟机所在物理机器的实际磁盘。它们没有网络存储的许多问题。本地磁盘仍然可能被同一台机器上的其他用户过载,但是在大型机器上,你可以合理地确保你不会与太多其他用户共享磁盘。即使是较小的实例,通常临时驱动器的性能也会比网络驱动器更好,只要其他租户不进行大量的 IOPS 操作。
缺点正如其名:这些磁盘是临时的。如果你的 EC2 实例宕机,重新启动实例时不能保证会回到同一台机器,那么你的数据将会丢失。
因此,临时驱动器应谨慎使用。你应确保不在这些磁盘上存储任何重要或未复制的数据。特别是,不要将日志放在这些临时驱动器上,或者将你的数据库放在网络存储上。总体而言,把临时驱动器看作是一个慢速缓存而不是快速磁盘,并相应地使用它们。
配置系统设置
有几个系统设置可以帮助 MongoDB 运行更顺畅,这些设置大多与磁盘和内存访问有关。本节涵盖了每个选项及其调整方法。
关闭 NUMA
当计算机只有一个 CPU 时,所有 RAM 在访问时间上基本相同。随着计算机开始拥有更多处理器,工程师意识到让所有内存与每个 CPU 等距离(如 图 24-1 所示)不如让每个 CPU 有一些特别靠近它的内存,并且对该 CPU 访问快速的内存(如 图 24-2 所示)更有效率。每个 CPU 都有其自己“本地”内存的这种架构称为非统一内存架构(NUMA)。

图 24-1. 统一内存架构:每个 CPU 对所有内存的访问成本相同

图 24-2. 非统一内存架构:某些内存连接到一个 CPU,使该 CPU 更快地访问该内存;CPU 仍然可以访问其他 CPU 的内存,但与访问自己的内存相比,成本更高
对于许多应用程序来说,NUMA 的效果很好:处理器通常需要不同的数据,因为它们运行不同的程序。然而,这对于数据库来说效果极差,特别是 MongoDB,因为数据库的内存访问模式与其他类型的应用程序大不相同。MongoDB 使用大量内存,并且需要能够访问“本地”于其他 CPU 的内存。然而,许多系统上默认的 NUMA 设置使得这一点很困难。
CPUs 偏向使用与它们连接的内存,而进程倾向于优先使用一个 CPU。这意味着内存通常会不均匀地填满,可能导致一个处理器使用其本地内存的 100%,而其他处理器仅使用它们内存的一小部分,如 图 24-3 所示。

图 24-3. NUMA 系统中的示例内存使用情况
在 图 24-3 的场景中,假设 CPU1 需要一些尚未在内存中的数据。它必须使用其本地内存存储没有“归属”的数据,但其本地内存已满。因此,它必须逐出一些本地内存中的数据,为新数据腾出空间,即使 CPU2 的内存还有大量空间可用!这个过程往往会导致 MongoDB 运行速度远低于预期,因为它只能使用可用内存的一小部分。MongoDB 更倾向于半有效地访问更多数据,而不是极其有效地访问更少的数据。
在 NUMA 硬件上运行 MongoDB 服务器和客户端时,应配置内存交织策略,以使主机以非 NUMA 方式运行。MongoDB 在 Linux 和 Windows 机器上部署时会检查 NUMA 设置。如果 NUMA 配置可能降低性能,MongoDB 将打印警告。
在 Windows 上,必须通过机器的 BIOS 启用内存交织。请参考系统文档获取详细信息。
在 Linux 上运行 MongoDB 时,应使用以下命令之一在 sysctl 设置中禁用区域回收:
echo 0 | sudo tee /proc/sys/vm/zone_reclaim_mode
sudo sysctl -w vm.zone_reclaim_mode=0
然后,您应该使用 numactl 启动您的 mongod 实例,包括配置服务器、mongos 实例和任何客户端。如果您没有 numactl 命令,请参阅您操作系统的文档安装 numactl 软件包。
下面的命令演示了如何使用 numactl 启动 MongoDB 实例:
numactl --interleave=all *<path> <options>*
<路径> 是您要启动的程序的路径,而 <选项> 是要传递给该程序的任何可选参数。
要完全禁用 NUMA 行为,您必须执行这两个操作。有关更多信息,请参阅文档。
设置预读
预读是操作系统读取比实际请求的数据更多的优化方法。这是有用的,因为大多数计算机处理的工作负载是顺序的:如果您加载视频的前 20 MB,您可能会希望获取接下来的几兆字节。因此,系统将从磁盘读取比您实际请求的更多数据,并将其存储在内存中,以防您很快需要它。
对于 WiredTiger 存储引擎,无论存储介质类型(旋转磁盘、SSD 等),都应将预读设置为 8 到 32。将其设置得更高有助于顺序 I/O 操作,但由于 MongoDB 的磁盘访问模式通常是随机的,较高的预读值提供的益处有限,甚至可能导致性能下降。对于大多数工作负载,预读设置为 8 到 32 提供了最佳的 MongoDB 性能。
通常情况下,您应该在此范围内设置预读(readahead),除非测试表明更高的值在可测性、重复性和可靠性方面都有明显的益处。MongoDB 专业支持可以就非零预读配置提供建议和指导。
禁用透明大页(THP)
THP 会引起类似于高预读的问题。除非
-
您的所有数据都适合放入内存。
-
您没有计划让它超出内存范围。
MongoDB 需要将大量小片段的内存分页,因此使用 THP 可能会导致更多的磁盘 I/O。
系统通过页面从磁盘到内存再回来移动数据。页面通常是几千字节(x86 默认为 4,096 字节页面)。如果一台机器有许多吉字节的内存,追踪每个(相对较小的)页面可能比仅追踪几个更大粒度的页面更慢。THP 是一个解决方案,允许您拥有高达 256 MB 的页面(适用于 IA-64 架构)。但是,使用它意味着您正在将来自磁盘一部分的兆字节数据保留在内存中。如果您的数据不适合在 RAM 中,则从磁盘中交换更大的数据块将快速填满您的内存,需要再次交换出去。此外,刷新任何更改到磁盘的速度会更慢,因为磁盘必须写入兆字节的“脏”数据,而不是几千字节。
THP 实际上是为了使数据库受益而开发的,因此对经验丰富的数据库管理员来说可能会感到惊讶。 然而,MongoDB 往往比关系数据库进行较少的顺序磁盘访问。
注意
在 Windows 上,这些称为大页而不是巨大页。 某些 Windows 版本默认启用此功能,某些则没有,因此请检查并确保已关闭。
选择磁盘调度算法
磁盘控制器从操作系统接收请求,并根据调度算法确定的顺序处理它们。 有时更改此算法可以改善磁盘性能。 对于其他硬件和工作负载,可能没有区别。 决定使用哪种算法的最佳方法是在您自己的工作负载上进行测试。 截止时间和完全公平排队(CFQ)通常都是不错的选择。
有几种情况下,noop 调度器(“no-op”的缩写)是最佳选择。 如果您处于虚拟化环境中,请使用 noop 调度器。 此调度程序基本上将操作尽快传递到底层磁盘控制器。 最快的方法是这样做,并让真实的磁盘控制器处理任何需要进行的重新排序。
类似地,在 SSD 上,noop 调度器通常是最佳选择。 SSD 没有与旋转磁盘相同的局部性问题。
最后,如果您使用带有缓存的 RAID 控制器,请使用 noop。 缓存的行为类似于 SSD,将有效地将写操作传播到磁盘。
如果您在未虚拟化的物理服务器上,则操作系统应使用截止时间调度程序。 截止时间调度程序限制每个请求的最大延迟,并保持对于磁盘密集型数据库应用程序最佳的合理磁盘吞吐量。
您可以通过在引导配置中设置--elevator选项来更改调度算法。
注意
这个选项称为“电梯”,因为调度器的行为类似于电梯,从不同的楼层(进程/时间)中接收人们(I/O 请求),并以一种较优的方式将它们送到它们想去的地方。
通常所有的算法表现都很好; 您可能看不出它们之间有多大区别。
禁用访问时间跟踪
默认情况下,系统会跟踪文件上次访问的时间。 由于 MongoDB 使用的数据文件非常频繁,因此通过禁用此跟踪可以提高性能。 您可以在 Linux 上通过在/etc/fstab中将atime更改为noatime来执行此操作:
/dev/sda7 /data xfsf rw,noatime 1 2
您必须重新挂载设备以使更改生效。
在较旧的内核(例如 ext3)上,atime是一个更大的问题; 较新的内核默认使用relatime,它的更新较少。 另外,请注意,设置noatime可能会影响使用该分区的其他程序,例如mutt或备份工具。
同样,在 Windows 上,您应该设置disablelastaccess选项。 要关闭最后访问时间记录,请运行:
C:\> fsutil behavior set disablelastaccess 1
必须重新启动才能使此设置生效。设置这个可能会影响远程存储服务,但您可能不应该使用自动将数据移动到其他磁盘的服务。
修改限制
MongoDB 往往会超出两个限制:一个进程允许生成的线程数和一个进程允许打开的文件描述符数。通常情况下,这两者都应该设置为无限制。
每当 MongoDB 服务器接受一个连接时,它会生成一个线程来处理该连接上的所有活动。因此,如果您有 3000 个连接到数据库的连接,数据库将有 3000 个正在运行的线程(加上一些用于非客户端相关任务的其他线程)。根据您的应用服务器配置,您的客户端可能会生成从几十到数千个连接到 MongoDB 的连接。
如果您的客户端会随着流量增加动态生成更多的子进程(大多数应用服务器会这样做),重要的是确保这些子进程数量不要太多,以至于它们可以超出 MongoDB 的限制。例如,如果您有 20 个应用服务器,每个服务器允许生成 100 个子进程,并且每个子进程可以生成 10 个线程,所有这些都连接到 MongoDB,那么在高峰时段可能会生成 20 × 100 × 10 = 20000 个连接。MongoDB 可能不会很高兴地生成数万个线程,并且如果您的进程线程数达到上限,将简单地开始拒绝新的连接。
另一个需要修改的限制是 MongoDB 允许打开的文件描述符数。每个传入和传出的连接都使用一个文件描述符,因此刚才提到的客户端连接风暴将创建 20000 个打开的文件句柄。
特别是mongos倾向于创建到许多分片的连接。当客户端连接到mongos并发出请求时,mongos会打开到所有必要的分片的连接来完成该请求。因此,如果一个集群有 100 个分片,并且客户端连接到mongos并尝试查询其所有数据,则mongos必须打开 100 个连接:每个分片一个连接。这可能很快导致连接数量激增,正如前面的例子所示。假设一个配置很宽松的应用服务器向mongos进程创建了 100 个连接。这可能被翻译为 100 个入站连接 × 100 个分片 = 10000 个分片的连接!(这假设每个连接上都有一个非目标化的查询,这是一个很糟糕的设计,因此这是一个相对极端的例子。)
因此,需要进行一些调整。许多人有意配置mongos进程,仅允许使用maxConns选项来限制特定数量的传入连接。这是强制确保客户端行为良好的好方法。
您还应增加文件描述符数量限制,因为默认值(通常为 1,024)太低了。将最大文件描述符设置为无限,或者如果对此感到不安,设置为 20,000。每个系统更改这些限制的方式不同,但通常确保同时更改硬限制和软限制。硬限制由内核强制执行,只能由管理员更改,而软限制是可由用户配置的。
如果将最大连接数保留为 1,024,Cloud Manager 将通过在主机列表中以黄色显示主机来警告您。如果低限制是触发警告的原因,则“最后 ping”选项卡应显示与图 24-4 类似的消息。

图 24-4。Cloud Manager 低 ulimit(文件描述符)设置警告
即使您采用非分片设置并且应用程序仅使用少量连接,将硬限制和软限制至少增加到 4,096 是个好主意。这将阻止 MongoDB 提示您有关这些问题,并为您提供某些操作余地以防万一。
配置您的网络
本节涵盖了哪些服务器应与其他服务器建立连接的内容。通常基于网络安全(和合理性)的原因,您可能希望限制 MongoDB 服务器的连接性。请注意,多服务器 MongoDB 部署应处理网络被分割或宕机的情况,但这并不推荐作为一般部署策略。
对于独立服务器,客户端必须能够连接到mongod。
副本集的成员必须能够与其他每个成员建立连接。客户端必须能够连接到所有非隐藏、非仲裁成员。根据网络配置,成员也可能尝试连接自己,因此应允许mongod之间创建连接。
分片稍微复杂。它包括四个组件:mongos服务器、分片、配置服务器和客户端。连接性可以总结为以下三点:
-
客户端必须能够连接到mongos。
-
mongos必须能够连接到分片和配置服务器。
-
分片必须能够连接到其他分片和配置服务器。
完整的连接图表详见表 24-1。
表 24-1。分片连接性
| 连接性 | 来自服务器类型 |
|---|---|
| 到服务器类型 | mongos |
| --- | --- |
| mongos | 不必需 |
| 分片 | 必需 |
| 配置服务器 | 必需 |
| 客户端 | 不必需 |
表中有三个可能的值。“必需”意味着这两个组件之间的连接对于分片按设计工作是必要的。由于网络问题,如果丢失这些连接,MongoDB 将尝试优雅降级,但不应故意配置成这样。
“不需要”意味着这两个元素从未按指定方向通信,因此不需要连接。
“不推荐”意味着这两个元素不应该通信,但由于用户错误可能会发生。例如,建议客户端只连接到mongos,而不是分片,这样客户端就不会不经意地直接向分片发出请求。同样,客户端不应直接访问配置服务器,以防止意外修改配置数据。
注意,mongos 进程和分片与配置服务器通信,但配置服务器不与任何人建立连接,甚至彼此也不例外。
分片在迁移过程中必须进行通信:分片直接连接到彼此以传输数据。
正如前面提到的,组成分片的副本集成员应能够连接到自身。
系统维护
本节涵盖了部署前应注意的一些常见问题。
同步时钟
一般来说,确保系统时钟相差不超过一秒是最安全的。副本集应能处理几乎任何时钟偏差。分片可以处理一些偏差(如果超过几分钟,你将在日志中看到警告),但最好将其最小化。保持时钟同步还能更轻松地从日志中了解发生的情况。
可以使用 Windows 上的w32tm工具和 Linux 上的ntp守护进程来保持时钟同步。
OOM Killer
非常偶尔,MongoDB 会分配足够的内存,以至于会被内存不足(OOM)杀手瞄准。这通常发生在索引构建期间,因为这是 MongoDB 的驻留内存可能对系统造成压力的几个时刻之一。
如果你的 MongoDB 进程突然死机,并且日志中没有任何错误或退出消息,请检查/var/log/messages(或者其他记录这类信息的位置),看看是否有关于终止mongod的消息。
如果内核因内存过度使用而杀死了 MongoDB 进程,你应该在内核日志中看到类似以下的信息:
kernel: Killed process 2771 (mongod)
kernel: init invoked oom-killer: gfp_mask=0x201d2, order=0, oomkilladj=0
如果你使用了日志记录,此时可以简单地重新启动mongod。如果没有,从备份中恢复数据或从副本重新同步数据。
如果没有交换空间并且内存不足,OOM Killer 会变得特别紧张,因此防止它胡作非为的一个好方法是配置适量的交换空间。正如前面提到的,MongoDB 永远不应该使用它,但这会让 OOM Killer 感到满意。
如果 OOM Killer 杀死了一个mongos,你可以简单地重新启动它。
关闭定期任务
检查没有任何可能定期启动并占用资源的 cron 作业、杀毒软件扫描器或守护进程。我们见过的一个罪魁祸首是包管理器的自动更新。这些程序会突然启动,消耗大量的 RAM 和 CPU,然后消失。这不是你希望在生产服务器上运行的东西。
附录 A. 安装 MongoDB
MongoDB 二进制文件适用于 Linux、macOS、Windows 和 Solaris。这意味着在大多数平台上,您可以从MongoDB 下载中心页面下载一个存档文件,解压缩并运行该二进制文件。
MongoDB 服务器需要一个可以写入数据库文件的目录和一个可以监听连接的端口。本节涵盖了两种系统变体的完整安装过程:Windows 和其他系统(Linux/Unix/macOS)。
当我们谈论“安装 MongoDB”时,通常指的是设置 mongod,核心数据库服务器。mongod 可以作为独立服务器使用,也可以作为复制集的成员使用。大多数情况下,这将是您使用的 MongoDB 进程。
选择版本
MongoDB 使用一种相当简单的版本号方案:偶数点版本是稳定版,奇数点版本是开发版本。例如,以 4.2 开头的任何版本都是稳定发布,如 4.2.0、4.2.1 和 4.2.8。以 4.3 开头的任何版本都是开发版本,如 4.3.0、4.3.2 或 4.3.12。让我们以 4.2/4.3 版本发布为例,演示版本时间轴的工作方式:
-
MongoDB 4.2.0 已经发布。这是一个重要的版本,将有一个详尽的变更日志。
-
开发人员开始着手为 4.4(下一个主要稳定版本)制定里程碑计划后,他们发布了 4.3.0。这是新的开发分支,与 4.2.0 相似,但可能会增加一两个额外的功能,也可能有一些 bug。
-
随着开发人员继续添加功能,他们将发布 4.3.1、4.3.2 等版本。这些版本不应该在生产环境中使用。
-
一些次要的 bug 修复可能会被反向移植到 4.2 分支,这将导致发布 4.2.1、4.2.2 等版本。开发人员对反向移植非常谨慎;几乎不会向稳定版添加新功能。通常只会移植 bug 修复。
-
在 4.4.0 的所有主要里程碑达成后,4.3.7(或者最新的开发版本)将被转换为 4.4.0-rc0。
-
在对 4.4.0-rc0 进行了广泛测试后,通常会发现几个需要修复的小 bug。开发人员修复这些 bug 并发布 4.4.0-rc1。
-
开发人员重复第 6 步,直到没有新的 bug 显现,然后 4.4.0-rc2(或者最新版本)被重命名为 4.4.0。
-
开发人员从第 1 步重新开始,将所有版本号增加 0.2。
您可以通过浏览MongoDB bug tracker上的核心服务器路线图来查看生产版本发布的时间。
如果您正在生产环境中运行,应该使用稳定版。如果您计划在生产环境中使用开发版,请先在邮件列表或 IRC 上咨询开发人员的建议。
如果您刚开始开发项目,可能更好选择使用开发版本。到生产阶段时,可能会有一个带有您使用的功能的稳定版本(MongoDB 尝试每 12 个月发布一次稳定版本)。但是,您必须权衡这一点,因为可能会遇到服务器错误,这可能会对新用户产生影响。
Windows 安装
要在 Windows 上安装 MongoDB,请从 MongoDB 下载中心页面 下载 Windows .msi。使用上一节中的建议选择正确的 MongoDB 版本。单击链接后,将下载 .msi。双击 .msi 文件图标启动安装程序。
现在您需要创建一个目录,MongoDB 可以在其中写入数据库文件。默认情况下,MongoDB 尝试在当前驱动器上使用 \data\db 目录作为其数据目录(例如,如果您在 Windows 的 C: 上运行 mongod,它将使用 C:\Program Files\MongoDB\Server&
现在您有了数据目录,请打开命令提示符(cmd.exe)。导航到您解压 MongoDB 二进制文件的目录,并运行以下命令:
$ C:\Program Files\MongoDB\Server\&<VERSION>\bin\mongod.exe
如果选择的目录不是 C:\Program Files\MongoDB\Server&--dbpath 参数:
$ C:\Program Files\MongoDB\Server\&<VERSION>\bin\mongod.exe \
--dbpath C:\Documents and Settings\Username\My Documents\db
有关更常见选项,请参阅第二十一章,或运行 mongod.exe --help 查看所有选项。
安装为服务
MongoDB 也可以作为 Windows 的服务安装。要执行此操作,只需使用完整路径运行,并转义任何空格,并使用 --install 选项。例如:
$ C:\Program Files\MongoDB\Server\4.2.0\bin\mongod.exe \
--dbpath "\"C:\Documents and Settings\Username\My Documents\db\"" \
--install
然后可以通过控制面板启动和停止它。
POSIX(Linux 和 Mac OS X)安装
根据“选择版本”部分的建议选择 MongoDB 的版本。前往 MongoDB 下载中心 并选择适合您操作系统的正确版本。
警告
如果您使用的是 macOS Catalina 10.15+ 的 Mac,应该使用 /System/Volumes/Data/db,而不是 /data/db。此版本进行了更改,使根文件夹为只读,并在重新启动时重置,这将导致 MongoDB 数据文件夹丢失。
您必须创建一个目录,用于存放数据库文件。默认情况下,数据库将使用 /data/db,但您可以指定任何其他目录。如果创建默认目录,请确保具有正确的写权限。您可以通过运行以下命令创建目录并设置权限:
$ mkdir -p /data/db
$ chown -R $USER:$USER /data/db
mkdir -p 创建目录及其所有父目录(如果需要的话,即如果 /data 目录不存在,它将创建 /data 目录,然后创建 /data/db 目录)。 chown 更改 /data/db 的所有权,以便您的用户可以向其写入。当然,您也可以只在您的主目录中创建一个目录,并在启动数据库时指定 MongoDB 应使用该目录,以避免任何权限问题。
解压从 MongoDB 下载中心下载的 .tar.gz 文件:
$ tar zxf mongodb-linux-x86_64-enterprise-rhel62-4.2.0.tgz
$ cd mongodb-linux-x86_64-enterprise-rhel62-4.2.0
现在您可以启动数据库了:
$ bin/mongod
或者,如果您想使用替代的数据库路径,请使用 --dbpath 选项指定:
$ bin/mongod --dbpath ~/db
您可以运行 mongod.exe --help 查看所有可能的选项。
从软件包管理器安装
也有许多软件包管理器可用于安装 MongoDB。如果您喜欢使用其中之一,Red Hat、Debian 和 Ubuntu 都有官方软件包,还有许多其他系统的非官方软件包。如果您使用非官方版本,请确保它安装的是相对较新的版本。
在 macOS 上,有适用于 Homebrew 和 MacPorts 的非官方软件包。要使用MongoDB Homebrew tap,您首先需要安装该 tap,然后通过 Homebrew 安装所需版本的 MongoDB。以下示例突出显示如何安装最新的 MongoDB Community Edition 生产版本。您可以在 macOS 终端会话中添加自定义 tap:
$ brew tap mongodb/brew
然后使用以下命令安装最新的可用生产版本 MongoDB Community Server(包括所有命令行工具):
$ brew install mongodb-community
如果您选择 MacPorts 版本,请注意:编译所有 MongoDB 先决条件 Boost 库需要几个小时的时间。开始下载并让其在夜间运行。
无论您使用哪个软件包管理器,找出它将 MongoDB 日志文件放在何处是一个好主意,在出现问题并需要找到它们之前。确保在可能出现问题之前将其正确保存是非常重要的。
附录 B. MongoDB 内部
若要有效地使用 MongoDB,无需理解其内部机制,但对希望开发工具、贡献代码或简单了解系统底层运行情况的开发者可能会感兴趣。本附录介绍了一些基础知识。MongoDB 源代码可在https://github.com/mongodb/mongo获取。
BSON
MongoDB 中的文档是一个抽象概念,具体的文档表示取决于所使用的驱动程序/语言。由于文档在 MongoDB 中广泛用于通信,因此需要一种所有驱动程序、工具和过程都能共享的文档表示。这种表示称为二进制 JSON 或 BSON(没有人知道 J 去了哪里)。
BSON 是一种轻量级的二进制格式,能够将任何 MongoDB 文档表示为一串字节。数据库理解 BSON,并且 BSON 是文档保存到磁盘的格式。
当驱动程序收到要插入的文档、用作查询等任务时,它会在将其发送到服务器之前将该文档编码为 BSON。同样,从服务器返回给客户端的文档也以 BSON 字符串形式发送。驱动程序将这些 BSON 数据解码为其本机文档表示形式,然后返回给客户端。
BSON 格式有三个主要目标:
效率
BSON 被设计为高效地表示数据,几乎不使用额外的空间。在最坏的情况下,BSON 稍微不如 JSON 高效,在最好的情况下(例如存储二进制数据或大型数字时),它则比 JSON 高效得多。
遍历性能
在某些情况下,BSON 会牺牲空间效率以使格式更易于遍历。例如,字符串值会以长度前缀的方式存储,而不是依赖终止符来表示字符串的结束。这种遍历性能在 MongoDB 服务器需要自省文档时非常有用。
性能
最后,BSON 被设计为快速编码和解码。它使用 C 风格的类型表示,在大多数编程语言中都能快速处理。
关于 BSON 的详细规范,请参见http://www.bsonspec.org。
网络协议
驱动程序使用轻量级的 TCP/IP 网络协议访问 MongoDB 服务器。该协议在MongoDB 文档站点有文档记录,但基本上是 BSON 数据的薄包装。例如,插入消息包括 20 字节的头数据(包括告知服务器执行插入操作的代码和消息长度)、要插入的集合名称以及要插入的 BSON 文档列表。
数据文件
在 MongoDB 数据目录(默认为 /data/db/)中,每个集合和索引都会存储在单独的文件中。文件名不对应集合或索引的名称,但可以使用 mongo shell 中的 stats 来识别特定集合的相关文件。"wiredTiger.uri" 字段将包含要在 MongoDB 数据目录中查找的文件名。
在 sample_mflix 数据库上使用 stats 对 movies 集合执行时,"wiredTiger.uri" 字段将给出 “collection-14--2146526997547809066” 作为结果:
>db.movies.stats()
{
"ns" : "sample_mflix.movies",
"size" : 65782298,
"count" : 45993,
"avgObjSize" : 1430,
"storageSize" : 45445120,
"capped" : false,
"wiredTiger" : {
"metadata" : {
"formatVersion" : 1
},
"creationString" : "access_pattern_hint=none,allocation_size=4KB,\
app_metadata=(formatVersion=1),assert=(commit_timestamp=none,\
read_timestamp=none),block_allocation=best,\
block_compressor=snappy,cache_resident=false,checksum=on,\
colgroups=,collator=,columns=,dictionary=0,\
encryption=(keyid=,name=),exclusive=false,extractor=,format=btree,\
huffman_key=,huffman_value=,ignore_in_memory_cache_size=false,\
immutable=false,internal_item_max=0,internal_key_max=0,\
internal_key_truncate=true,internal_page_max=4KB,key_format=q,\
key_gap=10,leaf_item_max=0,leaf_key_max=0,leaf_page_max=32KB,\
leaf_value_max=64MB,log=(enabled=true),lsm=(auto_throttle=true,\
bloom=true,bloom_bit_count=16,bloom_config=,bloom_hash_count=8,\
bloom_oldest=false,chunk_count_limit=0,chunk_max=5GB,\
chunk_size=10MB,merge_custom=(prefix=,start_generation=0,suffix=),\
merge_max=15,merge_min=0),memory_page_image_max=0,\
memory_page_max=10m,os_cache_dirty_max=0,os_cache_max=0,\
prefix_compression=false,prefix_compression_min=4,source=,\
split_deepen_min_child=0,split_deepen_per_child=0,split_pct=90,\
type=file,value_format=u",
"type" : "file",
"uri" : "statistics:table:collection-14--2146526997547809066",
...
}
然后可以在 MongoDB 数据目录中验证文件的详细信息:
ls -alh collection-14--2146526997547809066.wt
-rw------- 1 braz staff 43M 28 Sep 23:33 collection-14--2146526997547809066.wt
可以使用聚合框架来查找特定集合中每个索引的 URI,方法如下:
db.movies.aggregate([{
$collStats:{storageStats:{}}}]).next().storageStats.indexDetails
{
"_id_" : {
"metadata" : {
"formatVersion" : 8,
"infoObj" : "{ \"v\" : 2, \"key\" : { \"_id\" : 1 },\
\"name\" : \"_id_\", \"ns\" : \"sample_mflix.movies\" }"
},
"creationString" : "access_pattern_hint=none,allocation_size=4KB,\
app_metadata=(formatVersion=8,infoObj={ \"v\" : 2, \"key\" : \
{ \"_id\" : 1 },\"name\" : \"_id_\", \"ns\" : \"sample_mflix.movies\" }),\
assert=(commit_timestamp=none,read_timestamp=none),block_allocation=best,\
block_compressor=,cache_resident=false,checksum=on,colgroups=,collator=,\
columns=,dictionary=0,encryption=(keyid=,name=),exclusive=false,extractor=,\
format=btree,huffman_key=,huffman_value=,ignore_in_memory_cache_size=false,\
immutable=false,internal_item_max=0,internal_key_max=0,\
internal_key_truncate=true,internal_page_max=16k,key_format=u,key_gap=10,\
leaf_item_max=0,leaf_key_max=0,leaf_page_max=16k,leaf_value_max=0,\
log=(enabled=true),lsm=(auto_throttle=true,bloom=true,bloom_bit_count=16,\
bloom_config=,bloom_hash_count=8,bloom_oldest=false,chunk_count_limit=0,\
chunk_max=5GB,chunk_size=10MB,merge_custom=(prefix=,start_generation=0,\
suffix=),merge_max=15,merge_min=0),memory_page_image_max=0,\
memory_page_max=5MB,os_cache_dirty_max=0,os_cache_max=0,\
prefix_compression=true,prefix_compression_min=4,source=,\
split_deepen_min_child=0,split_deepen_per_child=0,split_pct=90,type=file,\
value_format=u",
"type" : "file",
"uri" : "statistics:table:index-17--2146526997547809066",
...
"$**_text" : {
...
"uri" : "statistics:table:index-29--2146526997547809066",
...
"genres_1_imdb.rating_1_metacritic_1" : {
...
"uri" : "statistics:table:index-30--2146526997547809066",
...
}
WiredTiger 将每个集合或索引存储在单个任意大的文件中。影响此文件潜在最大大小的唯一限制是文件系统大小限制。
每当更新文档时,WiredTiger 都会写入该文档的新副本。磁盘上的旧副本会被标记为可重用,并且通常在下一个检查点期间被重写。这样可以回收 WiredTiger 文件中使用的空间。可以运行compact命令来将此文件中的数据移动到开头,从而在末尾留下空白空间。定期间隔时,WiredTiger 通过截断文件来移除这些多余的空白空间。在压缩过程结束时,多余的空间将被返回给文件系统。
命名空间
每个数据库都组织成 命名空间,这些命名空间映射到 WiredTiger 文件。这种抽象将存储引擎的内部细节与 MongoDB 查询层分离。
WiredTiger 存储引擎
MongoDB 的默认存储引擎是 WiredTiger 存储引擎。服务器启动时,它会打开数据文件并开始检查点和日志处理过程。它与操作系统协同工作,后者负责页面数据的进出以及将数据刷新到磁盘。这个存储引擎具有几个重要属性:
-
默认情况下,对集合和索引启用了压缩。默认压缩算法为 Google 的 snappy。其他选项包括 Facebook 的 Zstandard(zstd)和 zlib,或者不进行压缩。这可以在增加 CPU 要求的情况下最大限度地减少数据库中的存储使用。
-
文档级并发允许多个客户端在集合中同时更新不同文档。WiredTiger 使用多版本并发控制(MVCC)来隔离读写操作,确保客户端在操作开始时看到数据的一致时间点视图。
-
检查点创建数据的一致时间点快照,并且每 60 秒执行一次。它涉及将快照中的所有数据写入磁盘并更新相关元数据。
-
使用检查点技术进行日志记录确保在mongod进程失败时不会丢失任何数据。WiredTiger 使用预写日志(日志)在应用修改之前存储这些修改。


浙公网安备 33010602011771号