精通-MongoDB-4-x-全-

精通 MongoDB 4.x(全)

原文:zh.annas-archive.org/md5/BEDE8058C8DB4FDEC7B98D6DECC4CDE7

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

MongoDB 已经发展成为事实上的 NoSQL 数据库,拥有数百万用户,从小型初创公司到财富 500 强公司。解决了基于 SQL 模式的数据库的局限性,MongoDB 开创了 DevOps 关注重点的转变,并提供了分片和复制功能,可以由 DevOps 团队维护。本书基于 MongoDB 4.0,涵盖了从使用 shell、内置驱动程序和流行的 ODM 映射器进行数据库查询,到更高级的主题,如分片、高可用性和与大数据源的集成。

您将了解 MongoDB 的概况,并学习如何发挥其优势,以及相关的用例。之后,您将学习如何有效地查询 MongoDB,并尽可能多地利用索引。接下来的部分涉及 MongoDB 安装的管理,无论是在本地还是在云端。我们在接下来的部分中处理数据库内部,解释存储系统以及它们如何影响性能。本书的最后一部分涉及复制和 MongoDB 扩展,以及与异构数据源的集成。通过本书的学习,您将具备成为认证 MongoDB 开发人员和管理员所需的所有行业技能和知识。

这本书适合谁

《掌握 MongoDB 4.0》是一本面向数据库开发人员、架构师和管理员的书,他们想要更有效和更有成效地使用 MongoDB。如果您有使用 NoSQL 数据库构建应用程序和网站的经验,并且对此感兴趣,那么这本书适合您。

本书涵盖的内容

第一章,《MongoDB-现代 Web 的数据库》,带领我们穿越网络、SQL 和 NoSQL 技术的旅程,从它们的起源到它们当前的状态。

第二章,《模式设计和数据建模》,教您关系数据库和 MongoDB 的模式设计,以及如何从不同的起点实现相同的目标。

第三章,《MongoDB CRUD 操作》,提供了 CRUD 操作的概览。

第四章,《高级查询》,涵盖了使用 Ruby、Python 和 PHP 进行高级查询的概念,使用官方驱动程序和 ODM。

第五章,《多文档 ACID 事务》,探讨了遵循 ACID 特性的事务,这是 MongoDB 4.0 中引入的新功能。

第六章,《聚合》,深入探讨了聚合框架。我们还讨论了何时以及为什么应该使用聚合,而不是 MapReduce 和查询数据库。

第七章,《索引》,探讨了每个数据库中最重要的属性之一,即索引。

第八章,《监控、备份和安全》,讨论了 MongoDB 的运营方面。监控、备份和安全不应该是事后考虑的问题,而是在将 MongoDB 部署到生产环境之前需要处理的必要流程。

第九章,《存储引擎》,教您有关 MongoDB 中不同存储引擎的知识。我们确定了每种存储引擎的优缺点,以及选择每种存储引擎的用例。

第十章,《MongoDB 工具》,涵盖了我们可以在 MongoDB 生态系统中利用的各种不同工具,无论是在本地还是在云端。

第十一章,《使用 MongoDB 利用大数据》,更详细地介绍了 MongoDB 如何适应更广泛的大数据景观和生态系统。

第十二章,《复制》,讨论了副本集以及如何管理它们。从副本集的架构概述和选举周围的副本集内部开始,我们深入探讨了设置和配置副本集。

第十三章,《分片》,探讨了 MongoDB 最有趣的功能之一,即分片。我们从分片的架构概述开始,然后讨论如何设计分片,特别是如何选择正确的分片键。

第十四章,容错和高可用性,试图整合我们在之前章节中未能讨论的信息,并强调了开发人员和数据库管理员应该牢记的安全性和一系列核对表。

为了充分利用本书

您需要以下软件才能顺利阅读本书的各章内容:

  • MongoDB 版本 4+

  • Apache Kafka 版本 1

  • Apache Spark 版本 2+

  • Apache Hadoop 版本 2+

下载示例代码文件

您可以从您在www.packt.com的账户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packt.com/support并注册,以便直接通过电子邮件接收文件。

您可以按照以下步骤下载代码文件:

  1. 登录或在www.packt.com注册。

  2. 选择“支持”选项卡。

  3. 点击“代码下载和勘误”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明进行操作。

下载文件后,请确保使用以下最新版本的解压软件解压文件夹:

  • Windows 的 WinRAR/7-Zip

  • Mac 上的 Zipeg/iZip/UnRarX

  • Linux 的 7-Zip/PeaZip

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Mastering-MongoDB-4.x-Second-Edition。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还有其他代码包,来自我们丰富的图书和视频目录,可在github.com/PacktPublishing/上找到。去看看吧!

使用的约定

在本书中,您会发现一些文本样式,用于区分不同类型的信息。以下是这些样式的一些示例及其含义的解释。

文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄都显示如下:“在分片环境中,每个mongod都应用自己的锁,从而大大提高了并发性。”

代码块设置如下:

db.account.find( { "balance" : { $type : 16 } } );
db.account.find( { "balance" : { $type : "integer" } } );

任何命令行输入或输出都以以下方式书写:

> db.types.insert({"a":4})
WriteResult({ "nInserted" : 1 })

粗体:表示一个新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。例如:“以下截图显示了区域配置摘要:”

警告或重要说明会以这种方式出现。

提示和技巧会以这种方式出现。

保持联系

我们始终欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并发送电子邮件至customercare@packtpub.com

勘误:尽管我们已经尽最大努力确保内容的准确性,但错误是难免的。如果您在本书中发现了错误,我们将不胜感激地接受您的报告。请访问www.packt.com/submit-errata,选择您的书,点击勘误提交表格链接,并输入详细信息。

盗版:如果您在互联网上发现我们作品的任何形式的非法副本,请向我们提供位置地址或网站名称。请通过链接联系我们,链接地址为copyright@packt.com

如果您有兴趣成为作者:如果您在某个专业领域有专长,并且有兴趣撰写或为一本书做出贡献,请访问authors.packtpub.com

评论

请留下评论。当您阅读并使用了这本书之后,为什么不在购买它的网站上留下评论呢?潜在的读者可以看到并使用您的客观意见来做出购买决定,我们在 Packt 可以了解您对我们产品的看法,我们的作者也可以看到您对他们书籍的反馈。谢谢!

关于 Packt 的更多信息,请访问 packt.com

第一部分:基本 MongoDB - 设计目标和架构

在这一部分,我们将回顾数据库的历史,以及我们如何需要非关系型数据库。我们还将学习如何对数据进行建模,以便在 MongoDB 中进行存储和检索尽可能高效。尽管 MongoDB 是无模式的,但设计数据如何组织成文档可能会对性能产生很大影响。

本节包括以下章节:

  • 第一章,MongoDB - 用于现代 Web 的数据库

  • 第二章,模式设计和数据建模

第一章:MongoDB - 为现代网络设计的数据库

在本章中,我们将奠定理解 MongoDB 的基础,以及它声称自己是为现代网络设计的数据库。首先学习和知道如何学习同样重要。我们将介绍有关 MongoDB 的最新信息的参考资料,适用于新用户和有经验的用户。我们将涵盖以下主题:

  • SQL 和 MongoDB 的历史和演变

  • 从 SQL 和其他 NoSQL 技术用户的角度看 MongoDB

  • MongoDB 的常见用例及其重要性

  • MongoDB 的配置和最佳实践

技术要求

您需要安装 MongoDB 版本 4+、Apache Kafka、Apache Spark 和 Apache Hadoop 才能顺利完成本章内容。所有章节中使用的代码可以在以下链接找到:github.com/PacktPublishing/Mastering-MongoDB-4.x-Second-Edition

SQL 和 NoSQL 的演变

结构化查询语言SQL)甚至早于万维网出现。E. F. Codd 博士最初在 1970 年 6 月在计算机协会ACM)期刊ACM 通讯上发表了题为《用于大型共享数据库的关系数据模型》的论文。SQL 最初是由 IBM 的 Chamberlin 和 Boyce 于 1974 年开发的。关系软件(现在是 Oracle 公司)是第一个开发出商业可用的 SQL 实现的公司,目标是美国政府机构。

第一个美国国家标准学会ANSI)SQL 标准于 1986 年发布。自那时起,已经进行了八次修订,最近一次是在 2016 年发布的(SQL:2016)。

SQL 在万维网刚开始时并不特别受欢迎。静态内容可以直接硬编码到 HTML 页面中而不费吹灰之力。然而,随着网站功能的增长,网站管理员希望生成由离线数据源驱动的网页内容,以便生成随时间变化而变化的内容,而无需重新部署代码。

通用网关接口CGI)脚本,开发 Perl 或 Unix shell,驱动着 Web 1.0 时期的数据库驱动网站。随着 Web 2.0 的出现,网络从直接将 SQL 结果注入浏览器发展到使用两层和三层架构,将视图与业务和模型逻辑分离,使得 SQL 查询可以模块化并与网络应用的其余部分隔离开来。

另一方面,Not only SQLNoSQL)是更现代的,是在 Web 2.0 技术兴起的同时出现的。该术语最早由 Carlo Strozzi 于 1998 年创造,用于描述他的开源数据库,该数据库不遵循 SQL 标准,但仍然是关系型的。

这并不是我们当前对 NoSQL 数据库的期望。Johan Oskarsson 在当时是 Last.fm 的开发人员,于 2009 年初重新引入了这个术语,以便对一组正在开发的分布式、非关系型数据存储进行分组。其中许多是基于 Google 的BigtableMapReduce论文,或者是亚马逊的DynamoDB,这是一个高度可用的基于键值的存储系统。

NoSQL 的基础建立在放松的原子性、一致性、隔离性持久性ACID)属性上,这些属性保证了性能、可伸缩性、灵活性和降低了复杂性。大多数 NoSQL 数据库在提供尽可能多的上述特性方面都有所作为,甚至为开发人员提供可调整的保证。以下图表描述了 SQL 和 NoSQL 的演变:

MongoDB 的演变

10gen 于 2007 年开始开发云计算堆栈,并很快意识到最重要的创新是围绕他们构建的面向文档的数据库,即 MongoDB。MongoDB 最初于 2009 年 8 月 27 日发布。

MongoDB 的第 1 版在功能、授权和 ACID 保证方面非常基础,但通过性能和灵活性弥补了这些缺点。

在接下来的章节中,我们将突出 MongoDB 的主要功能,以及它们引入的版本号。

版本 1.0 和 1.2 的主要功能集

版本 1.0 和 1.2 的不同特性如下:

  • 基于文档的模型

  • 全局锁(进程级)

  • 集合索引

  • 文档的 CRUD 操作

  • 无需认证(认证在服务器级别处理)

  • 主从复制

  • MapReduce(自 v1.2 引入)

  • 存储 JavaScript 函数(自 v1.2 引入)

第 2 版

第 2.0 版的不同特性如下:

  • 后台索引创建(自 v1.4 以来)

  • 分片(自 v1.6 以来)

  • 更多的查询操作符(自 v1.6 以来)

  • 日志记录(自 v1.8 以来)

  • 稀疏和覆盖索引(自 v1.8 以来)

  • 紧凑命令以减少磁盘使用

  • 内存使用更高效

  • 并发改进

  • 索引性能增强

  • 副本集现在更可配置,并且数据中心感知

  • MapReduce改进

  • 认证(自 2.0 版,用于分片和大多数数据库命令)

  • 引入地理空间功能

  • 聚合框架(自 v2.2 以来)和增强(自 v2.6 以来)

  • TTL 集合(自 v2.2 以来)

  • 并发改进,其中包括 DB 级别锁定(自 v2.2 以来)

  • 文本搜索(自 v2.4 以来)和集成(自 v2.6 以来)

  • 哈希索引(自 v2.4 以来)

  • 安全增强和基于角色的访问(自 v2.4 以来)

  • V8 JavaScript 引擎取代 SpiderMonkey(自 v2.4 以来)

  • 查询引擎改进(自 v2.6 以来)

  • 可插拔存储引擎 API

  • 引入 WiredTiger 存储引擎,具有文档级锁定,而以前的存储引擎(现在称为 MMAPv1)支持集合级锁定

第 3 版

3.0 版本的不同特性如下:

  • 复制和分片增强(自 v3.2 以来)

  • 文档验证(自 v3.2 以来)

  • 聚合框架增强操作(自 v3.2 以来)

  • 多个存储引擎(自 v3.2 以来,仅适用于企业版)

  • 查询语言和索引排序(自 v3.4 以来)

  • 只读数据库视图(自 v3.4 以来)

  • 线性读关注(自 v3.4 以来)

第 4 版

4.0 版本的不同特性如下:

  • 多文档 ACID 事务

  • 变更流

  • MongoDB 工具(Stitch、Mobile、Sync 和 Kubernetes Operator)

以下图表显示了 MongoDB 的演变:

正如我们所看到的,第 1 版非常基础,而第 2 版引入了当前版本中的大多数功能,如分片、可用和特殊索引、地理空间功能以及内存和并发改进。

从第 2 版到第 3 版的过程中,聚合框架被引入,主要作为老化的(并且从未达到专用框架(如 Hadoop)的水平)MapReduce 框架的补充。然后,添加了文本搜索,并且慢慢但确定地,该框架正在改进性能、稳定性和安全性,以适应使用 MongoDB 的客户的不断增加的企业负载。

随着 WiredTiger 在第 3 版中的引入,对于 MongoDB 来说,锁定不再是一个问题,因为它从进程(全局锁)降至文档级别,几乎是可能的最粒度级别。

第 4 版标志着一个重大转变,通过引入多文档 ACID 事务,将 SQL 和 NoSQL 世界联系起来。这使得更广泛范围的应用程序可以使用 MongoDB,特别是需要强大的实时一致性保证的应用程序。此外,引入变更流允许使用 MongoDB 的实时应用程序更快地上市。还引入了一系列工具,以便于无服务器、移动和物联网开发。

在当前状态下,MongoDB 是一个可以处理从初始 MVP 和 POC 到拥有数百台服务器的企业应用程序的数据库。

SQL 开发人员的 MongoDB

MongoDB 是在 Web 2.0 时代开发的。那时,大多数开发人员一直在使用 SQL 或他们选择的语言中的对象关系映射ORM)工具来访问关系型数据库的数据。因此,这些开发人员需要一种从他们的关系背景中轻松了解 MongoDB 的方法。

值得庆幸的是,已经有几次尝试制作 SQL 到 MongoDB 的速查表,解释了 SQL 术语中的 MongoDB 术语。

在更高的层次上,我们有以下内容:

  • 数据库和索引(SQL 数据库)

  • 集合(SQL 表)

  • 文档(SQL 行)

  • 字段(SQL 列)

  • 嵌入和链接文档(SQL 连接)

以下是一些常见操作的更多示例:

SQL MongoDB
数据库 数据库
集合
索引 索引
文档
字段
连接 嵌入文档或通过DBRef链接
CREATE TABLE employee (name VARCHAR(100)) db.createCollection("employee")
INSERT INTO employees VALUES (Alex, 36) db.employees.insert({name: "Alex", age: 36})
SELECT * FROM employees db.employees.find()
SELECT * FROM employees LIMIT 1 db.employees.findOne()
SELECT DISTINCT name FROM employees db.employees.distinct("name")
UPDATE employees SET age = 37 WHERE name = 'Alex' db.employees.update({name: "Alex"}, {$set: {age: 37}}, {multi: true})
DELETE FROM employees WHERE name = 'Alex' db.employees.remove({name: "Alex"})
CREATE INDEX ON employees (name ASC) db.employees.ensureIndex({name: 1})

更多常见操作的示例可在s3.amazonaws.com/info-mongodb-com/sql_to_mongo.pdf.中查看。

NoSQL 开发人员的 MongoDB

随着 MongoDB 从一种小众数据库解决方案发展为 NoSQL 技术的瑞士军刀,越来越多的开发人员从 NoSQL 背景转向它。

将 SQL 转换为 NoSQL 的差异放在一边,面对最大挑战的是列式数据库的用户。随着 Cassandra 和 HBase 成为最受欢迎的列式数据库管理系统,我们将研究它们之间的差异以及开发人员如何将系统迁移到 MongoDB。MongoDB 针对 NoSQL 开发人员的不同特性如下:

  • 灵活性:MongoDB 的文档概念可以包含在复杂层次结构中嵌套的子文档,这真的很表达和灵活。这类似于 MongoDB 和 SQL 之间的比较,但 MongoDB 更容易地映射到任何编程语言的普通对象,从而实现轻松的部署和维护。

  • 灵活的查询模型:用户可以选择性地索引每个文档的某些部分;基于属性值、正则表达式或范围进行查询;并且应用层可以拥有所需的任意多的对象属性。主索引和辅助索引,以及特殊类型的索引(如稀疏索引),可以极大地提高查询效率。使用 JavaScript shell 和 MapReduce 使大多数开发人员(以及许多数据分析师)能够快速查看数据并获得有价值的见解。

  • 本地聚合:聚合框架为用户提供了一个提取、转换、加载ETL)管道,用户可以从 MongoDB 中提取和转换数据,然后将其加载到新格式中,或者将其从 MongoDB 导出到其他数据源。这也可以帮助数据分析师和科学家在执行数据整理时获得他们需要的数据片段。

  • 无模式模型:这是 MongoDB 设计理念的结果,它赋予应用程序解释集合文档中不同属性的权力和责任。与 Cassandra 或 HBase 的基于模式的方法相比,在 MongoDB 中,开发人员可以存储和处理动态生成的属性。

MongoDB 的关键特点和用例

在本节中,我们将分析 MongoDB 作为数据库的特点。了解 MongoDB 提供的功能可以帮助开发人员和架构师评估手头的需求以及 MongoDB 如何帮助实现它们。此外,我们将从 MongoDB,Inc.的经验中介绍一些常见的用例,这些用例为其用户带来了最佳结果。

关键特点

MongoDB 已经发展成为一个通用的 NoSQL 数据库,提供了关系型数据库管理系统和 NoSQL 世界的最佳特性。一些关键特点如下:

  • 它是一个通用数据库:与为特定目的(例如图形数据库)构建的其他 NoSQL 数据库相比,MongoDB 可以为应用程序中的异构负载和多个目的提供服务。在 4.0 版本引入多文档 ACID 事务后,这一点变得更加真实,进一步扩展了它可以有效使用的用例。

  • 灵活的模式设计:文档导向的方法具有非定义属性,可以在运行时修改,这是 MongoDB 与关系数据库之间的关键对比。

  • 从头开始构建高可用性:在我们这个五个九的可用性时代,这是必须的。配合服务器故障检测后的自动故障转移,这可以帮助实现高可用性。

  • 功能丰富:提供全面的 SQL 等效操作符,以及诸如 MapReduce、聚合框架、生存时间和封闭集合、次要索引等功能,MongoDB 可以适应许多用例,无论需求多么多样化。

  • 可扩展性和负载平衡:它被设计为垂直和(主要)水平扩展。使用分片,架构师可以在不同实例之间共享负载,并实现读写可扩展性。数据平衡通过分片平衡器自动发生(对用户透明)。

  • 聚合框架:在数据库中内置 ETL 框架意味着开发人员可以在数据离开数据库之前执行大部分 ETL 逻辑,从而在许多情况下消除了复杂数据管道的需求。

  • 本地复制:数据将在不复杂的设置情况下在副本集之间复制。

  • 安全功能:考虑到了身份验证和授权,因此架构师可以保护他们的 MongoDB 实例。

  • 用于存储和传输文档的 JSON(BSON 和二进制 JSON)对象:JSON 在网页前端和 API 通信中被广泛使用,因此当数据库使用相同的协议时会更容易。

  • MapReduce:尽管 MapReduce 引擎不像专用框架中那样先进,但它仍然是构建数据管道的好工具。

  • 在 2D 和 3D 中查询和地理空间信息:对于许多应用程序来说可能并不重要,但如果对于您的用例而言,能够在同一个数据库中进行地理空间计算和数据存储是非常方便的。

  • 多文档 ACID 事务:从 4.0 版本开始,MongoDB 支持跨多个文档的 ACID 事务。

  • 成熟的工具:MongoDB 的工具已经发展,支持从 DBaaS 到 Sync、Mobile 和无服务器(Stitch)。

MongoDB 的用例

由于 MongoDB 是一种非常流行的 NoSQL 数据库,因此已经有几个成功的用例,它成功支持了高质量的应用程序,并且交付时间很短。

许多最成功的用例都集中在以下领域:

  • 整合孤立的数据,提供它们的单一视图

  • 物联网

  • 移动应用

  • 实时分析

  • 个性化

  • 目录管理

  • 内容管理

所有这些成功案例都有一些共同特点。我们将尝试按相对重要性的顺序来分解它们。

  • 模式灵活性可能是最重要的特性。能够在集合中存储具有不同属性的文档可以帮助在开发阶段和从可能具有不同属性的异构来源摄取数据时。这与关系型数据库形成对比,在关系型数据库中,列需要预定义,而稀疏数据可能会受到惩罚。在 MongoDB 中,这是正常的,也是大多数用例共享的特性。能够深度嵌套属性到文档中,并将值数组添加到属性中,同时能够搜索和索引这些字段,有助于应用程序开发人员利用 MongoDB 的无模式特性。

  • 扩展和分片是 MongoDB 用例中最常见的模式。使用内置分片轻松扩展,并使用副本集进行数据复制和卸载主服务器的读取负载,可以帮助开发人员有效地存储数据。

  • 许多用例还使用 MongoDB 作为存档数据的一种方式。作为纯数据存储(而不需要定义模式),将数据倾倒到 MongoDB 中以供以后由业务分析人员分析,可以很容易地使用 shell 或一些可以轻松集成 MongoDB 的众多 BI 工具。根据时间限制或文档计数进一步分解数据,可以帮助从 RAM 中提供这些数据集,这是 MongoDB 最有效的用例。

  • 将数据集保留在 RAM 中有助于性能,这也是实践中常用的方法。MongoDB 在大多数版本中使用 MMAP 存储(称为 MMAPv1),直到最近的版本,它将数据映射委托给底层操作系统。这意味着大多数基于 GNU/Linux 的系统,与可以存储在 RAM 中的集合一起工作,将大大提高性能。随着可插拔存储引擎的引入,如 WiredTiger(在第八章中将有更多介绍,监控、备份和安全),这个问题就不那么严重了。

  • 封顶集合也是许多用例中使用的一个特性。封顶集合可以通过文档数量或集合的整体大小来限制集合中的文档。在后一种情况下,我们需要估计每个文档的大小,以便计算有多少文档可以适应我们的目标大小。封顶集合是快速而简单的解决方案,可以回答诸如“给我上一个小时的日志概览”之类的请求,而无需进行维护和运行异步后台作业来清理我们的集合。通常情况下,这些可能被用来快速构建和操作一个排队系统。开发人员可以使用集合来存储消息,然后使用 MongoDB 提供的本地可追加游标来迭代结果,以便在结果堆积并向外部系统提供数据时使用。

  • 低运营开销也是许多用例中的常见模式。在敏捷团队中工作的开发人员可以操作和维护 MongoDB 服务器集群,而无需专门的数据库管理员。MongoDB 管理服务(MMS)可以极大地帮助减少管理开销,而 MongoDB Atlas,MongoDB 公司提供的托管解决方案,意味着开发人员不需要处理运营方面的问题。

  • 在使用 MongoDB 的业务领域中,几乎所有行业都有各种各样的应用。然而,似乎更多的是在需要处理大量数据,但每个数据点的商业价值相对较低的情况下。例如,物联网等领域可以通过利用可用性而非一致性设计来获益,以成本效益的方式存储来自传感器的大量数据。另一方面,金融服务则绝对需要严格的一致性要求,符合适当的 ACID 特性,这使得 MongoDB 更具挑战性。金融交易可能规模较小,但影响巨大,这意味着我们不能不经过适当处理就放任一个消息。

  • 基于位置的数据也是 MongoDB 蓬勃发展的领域之一,Foursquare 是最著名的早期客户之一。MongoDB 提供了丰富的二维和三维地理位置数据功能,包括按距离搜索、地理围栏和地理区域之间的交集等功能。

  • 总的来说,丰富的功能集是不同用例中的共同模式。通过提供可以在许多不同行业和应用中使用的功能,MongoDB 可以成为所有业务需求的统一解决方案,为用户提供最小化运营开销的能力,同时在产品开发中快速迭代。

MongoDB 的批评

MongoDB 的批评与以下几点有关:

  • 多年来,MongoDB 一直备受批评。许多开发人员对其 Web 规模的主张持怀疑态度。反驳的观点是大多数情况下并不需要规模化,重点应该放在其他设计考虑上。虽然这有时可能是真的,但这是一个虚假的二分法,在理想的世界中,我们应该兼而有之。MongoDB 尽可能地将可伸缩性与功能、易用性和上市时间结合在一起。

  • MongoDB 的无模式特性也是一个很大的争论点。在许多用例中,无模式可以带来很多好处,因为它允许将异构数据倾入数据库,而无需复杂的清洗,也不会导致大量空列或文本块堆积在单个列中。另一方面,这是一把双刃剑,因为开发人员可能会在集合中拥有许多文档,这些文档在字段上具有松散的语义,而在代码级别提取这些语义可能会变得非常困难。如果我们的模式设计不够理想,我们可能最终得到的是一个数据存储,而不是一个数据库。

  • 来自关系型数据库世界的一个经常的抱怨是缺乏适当的 ACID 保证。事实上,如果开发人员需要同时访问多个文档,要保证关系型数据库的特性并不容易,因为没有事务。没有事务,也意味着复杂的写操作需要应用级逻辑来回滚。如果需要更新两个集合中的三个文档以标记一个应用级事务完成,但第三个文档由于某种原因没有被更新,应用程序将需要撤销前两次写操作,这可能并不是一件简单的事情。

  • 随着在 4.0 版本中引入多文档事务,MongoDB 可以应对 ACID 事务,但速度会受到影响。虽然这并不理想,事务并不适用于 MongoDB 中的每个 CRUD 操作,但它解决了主要的批评来源。

  • 不赞成设置 MongoDB 的默认写入行为,但不在生产环境中进行操作。多年来,默认的写入行为是写入并忘记;发送写入操作不会在尝试下一个写入操作之前等待确认,导致写入速度极快,在发生故障时行为不佳。认证也是事后考虑,导致成千上万的 MongoDB 数据库在公共互联网上成为任何想要读取存储数据的人的猎物。尽管这些是有意识的设计决策,但它们影响了开发人员对 MongoDB 的看法。

MongoDB 配置和最佳实践

在本节中,我们将介绍一些关于操作、模式设计、耐久性、复制、分片和安全性的最佳实践。关于何时实施这些最佳实践的进一步信息将在后面的章节中介绍。

运营最佳实践

作为数据库,MongoDB 是为开发人员而构建的,并且是在 Web 时代开发的,因此不需要像传统的关系型数据库管理系统那样多的运营开销。尽管如此,仍然需要遵循一些最佳实践,以积极主动并实现高可用性目标。

按重要性顺序,最佳实践如下:

  • 默认情况下打开日志记录:日志记录使用预写式日志,以便在 MongoDB 服务器突然关闭时能够恢复。对于 MMAPv1 存储引擎,日志记录应始终打开。对于 WiredTiger 存储引擎,日志记录和检查点一起使用,以确保数据的耐久性。无论如何,使用日志记录并调整日志和检查点的大小和频率,以避免数据丢失,是一个好习惯。在 MMAPv1 中,默认情况下,日志每 100 毫秒刷新到磁盘一次。如果 MongoDB 在确认写操作之前等待日志记录,那么日志将在 30 毫秒内刷新到磁盘。

  • 您的工作集应该适合内存:再次强调,特别是在使用 MMAPv1 时,工作集最好小于底层机器或虚拟机的 RAM。MMAPv1 使用来自底层操作系统的内存映射文件,如果 RAM 和磁盘之间没有太多的交换发生,这可能是一个很大的好处。另一方面,WiredTiger 在使用内存方面效率更高,但仍然极大地受益于相同的原则。工作集最大是由db.stats()报告的数据大小加上索引大小。

  • 注意数据文件的位置:数据文件可以通过使用--dbpath命令行选项挂载到任何位置。确保数据文件存储在具有足够磁盘空间的分区中,最好是 XFS,或至少是Ext4,这一点非常重要。

  • 保持与版本的更新:即使是主要编号的版本也是稳定的。因此,3.2 是稳定的,而 3.3 不是。在这个例子中,3.3 是将最终实现为稳定版本 3.4 的开发版本。始终更新到最新的安全更新版本(在撰写本书时为 4.0.2),并在下一个稳定版本发布时考虑更新(在这个例子中为 4.2)是一个好习惯。

  • 使用 Mongo MMS 图形监控您的服务:免费的 MongoDB,Inc.监控服务是一个很好的工具,可以概览 MongoDB 集群、通知和警报,并积极应对潜在问题。

  • 如果您的指标显示出重度使用,请扩展规模:不要等到为时已晚。利用超过 65%的 CPU 或 RAM,或开始注意到磁盘交换,都应该是开始考虑扩展的门槛,可以通过垂直扩展(使用更大的机器)或水平扩展(通过分片)。

  • 分片时要小心:分片是对分片键的强烈承诺。如果做出错误决定,从操作角度来看可能会非常困难。在设计分片时,架构师需要深入考虑当前的工作负载(读/写)以及当前和预期的数据访问模式。

  • 使用由 MongoDB 团队维护的应用程序驱动程序:这些驱动程序得到支持,并且往往比没有官方支持的驱动程序更新得更快。如果 MongoDB 尚不支持您使用的语言,请在 MongoDB 的 JIRA 跟踪系统中提交工单。

  • 定期备份计划:无论您使用独立服务器、副本集还是分片,都应该使用定期备份策略作为第二级防止数据丢失的保护。XFS 是一个很好的文件系统选择,因为它可以执行快照备份。

  • 手动备份应该避免:在可能的情况下应该使用定期自动备份。如果我们需要进行手动备份,那么我们可以使用副本集中的隐藏成员来进行备份。我们必须确保在该成员上使用db.fsyncwithlock,以获得节点的最大一致性,同时打开日志记录。如果这个卷在 AWS 上,我们可以立即进行 EBS 快照备份。

  • 启用数据库访问控制:绝对不要在生产系统中放入没有访问控制的数据库。访问控制应该在节点级别实施,通过一个适当的防火墙,只允许特定应用服务器访问数据库,并在数据库级别使用内置角色或定义自定义角色。这必须在启动时使用--auth命令行参数进行初始化,并可以通过admin集合进行配置。

  • 使用真实数据测试部署:由于 MongoDB 是一个无模式、面向文档的数据库,您可能有具有不同字段的文档。这意味着与关系数据库管理系统相比,使用尽可能接近生产数据的数据进行测试更加重要。具有意外值的额外字段的文档可能会导致应用程序在运行时顺利工作或崩溃之间的差异。尝试使用生产级数据部署一个分级服务器,或者至少在分级中使用适当的库(例如 Ruby 的 Faker)伪造生产数据。

模式设计最佳实践

MongoDB 是无模式的,您必须设计您的集合和索引以适应这一事实:

  • 早期和频繁地建立索引:使用 MMS、Compass GUI 或日志识别常见的查询模式,并在项目开始时尽可能多地建立这些索引。

  • 消除不必要的索引:与前面的建议有些相悖,监视数据库的查询模式变化,并删除未被使用的索引。索引将消耗内存和 I/O,因为它需要与数据库中的文档一起存储和更新。使用聚合管道和$indexStats,开发人员可以识别很少被使用的索引并将其删除。

  • 使用复合索引,而不是索引交集:使用多个谓词(ABCDE等)进行查询,通常使用单个复合索引比使用多个简单索引更好。此外,复合索引将其数据按字段排序,我们可以在查询时利用这一点。在字段ABC上的索引将用于查询A(A,B)(A,B,C),但不用于查询(B,C)(C)

  • 低选择性索引:例如,在性别字段上建立索引,统计上会返回一半的文档,而在姓氏上建立索引只会返回少量具有相同姓氏的文档。

  • 使用正则表达式:同样,由于索引是按值排序的,使用具有前置通配符的正则表达式(即/.*BASE/)将无法使用索引。使用具有尾随通配符的正则表达式(即/DATA.*/)可能是有效的,只要表达式中有足够的区分大小写的字符。

  • 避免在查询中使用否定:索引是对值进行索引,而不是它们的缺失。在查询中使用NOT可能导致对整个表的扫描,而不是使用索引。

  • 使用部分索引:如果我们需要对集合中的一部分文档进行索引,部分索引可以帮助我们最小化索引集并提高性能。部分索引将包括我们在所需查询中使用的过滤器上的条件。

  • 使用文档验证:使用文档验证来监视插入文档中的新属性,并决定如何处理它们。通过将文档验证设置为警告,我们可以保留在设计阶段未预期插入具有任意属性的文档的日志,并决定这是设计的错误还是特性。

  • 使用 MongoDB Compass:MongoDB 的免费可视化工具非常适合快速了解我们的数据以及随时间的增长。

  • 尊重 16MB 的最大文档大小:MongoDB 的最大文档大小为 16MB。这是一个相当慷慨的限制,但在任何情况下都不应违反。允许文档无限增长不应是一个选项,尽管嵌入文档可能是高效的,但我们应始终记住这应该是受控制的。

  • 使用适当的存储引擎:自 MongoDB 3.2 版本以来,MongoDB 引入了几个新的存储引擎。内存存储引擎应用于实时工作负载,而加密存储引擎应该是在对数据安全性有严格要求时的首选引擎。

写入耐久性的最佳实践

在 MongoDB 中,写入耐久性可以进行微调,并且根据我们的应用程序设计,应尽可能严格,而不影响我们的性能目标。

在 WiredTiger 存储引擎中微调数据并将其刷新到磁盘间隔,默认情况下是在最后一个检查点后每 60 秒将数据刷新到磁盘,或者在写入 2GB 数据后。这可以通过使用--wiredTigerCheckpointDelaySecs命令行选项进行更改。

在 MMAPv1 中,数据文件每 60 秒刷新到磁盘。这可以通过使用--syncDelay命令行选项进行更改。我们还可以执行各种任务,例如以下内容:

  • 使用 WiredTiger,我们可以使用 XFS 文件系统进行多磁盘一致的快照

  • 我们可以在数据卷中关闭atimediratime

  • 您可以确保有足够的交换空间(通常是内存大小的两倍)

  • 如果在虚拟化环境中运行,可以使用 NOOP 调度程序

  • 我们可以将文件描述符限制提高到数万个

  • 我们可以禁用透明大页,并启用标准的 4-KVM 页

  • 写入安全性应至少记录

  • SSD 读取默认应设置为 16 个块;HDD 应设置为 32 个块

  • 我们可以在 BIOS 中关闭 NUMA

  • 我们可以使用 RAID 10

  • 您可以使用 NTP 同步主机之间的时间,特别是在分片环境中

  • 只使用 64 位构建用于生产;32 位构建已过时,只能支持最多 2GB 的内存

复制的最佳实践

副本集是 MongoDB 提供冗余、高可用性和更高读取吞吐量的机制,在适当的条件下。在 MongoDB 中,复制易于配置并专注于操作术语:

  • 始终使用副本集:即使您的数据集目前很小,而且您不指望它呈指数增长,您也永远不知道什么时候会发生。此外,至少有三个服务器的副本集有助于设计冗余,将工作负载分开为实时和分析(使用次要服务器),并从一开始就构建数据冗余。

  • 充分利用副本集:副本集不仅用于数据复制。我们可以(而且在大多数情况下应该)使用主服务器进行写入,并从其中一个次要服务器进行偏好读取,以卸载主服务器。这可以通过为读取设置读取偏好和正确的写入关注来实现,以确保写入按需传播。

  • 在 MongoDB 副本集中使用奇数个副本:如果一个服务器宕机或者与其他服务器失去连接(网络分区),其他服务器必须投票选举出主服务器。如果我们有奇数个副本集成员,我们可以保证每个服务器子集知道它们属于大多数还是少数的副本集成员。如果我们不能有奇数个副本,我们需要设置一个额外的主机作为仲裁者,唯一目的是在选举过程中进行投票。即使是 EC2 中的微型实例也可以完成这个任务。

分片的最佳实践

分片是 MongoDB 的水平扩展解决方案。在第八章中,监控、备份和安全,我们将更详细地介绍其使用,但以下是一些基于基础数据架构的最佳实践:

  • 考虑查询路由:根据不同的分片键和技术,mongos查询路由器可能会将查询发送到一些(或全部)分片成员。在设计分片时,考虑我们的查询非常重要,这样我们的查询就不会命中所有的分片。

  • 使用标签感知分片:标签可以在分片之间提供更精细的数据分布。使用每个分片的正确标签集,我们可以确保数据子集存储在特定的分片集中。这对于应用服务器、MongoDB 分片和用户之间的数据接近可能非常有用。

安全最佳实践

安全始终是多层次的方法,这些建议只是一些基本的需要在任何 MongoDB 数据库中完成的事项,它们并不构成详尽的清单:

  • 应该禁用 HTTP 状态接口。

  • RESTful API 应该被禁用。

  • JSON API 应该被禁用。

  • 使用 SSL 连接到 MongoDB。

  • 审计系统活动。

  • 使用专用系统用户访问 MongoDB,并具有适当的系统级访问权限。

  • 如果不需要,禁用服务器端脚本。这将影响 MapReduce、内置的db.group()命令和$where操作。如果这些在您的代码库中没有使用,最好在启动时使用--noscripting参数禁用服务器端脚本。

AWS 的最佳实践

当我们使用 MongoDB 时,我们可以在数据中心使用自己的服务器,使用 MongoDB Atlas 等 MongoDB 托管解决方案,或者通过 EC2 从亚马逊获取实例。EC2 实例是虚拟化的,并以透明的方式共享资源,在同一物理主机上放置 VM。因此,如果您选择这条路线,还有一些其他考虑因素需要考虑,如下所示:

  • 使用 EBS 优化的 EC2 实例。

  • 获取具有预留 IOPS 的 EBS 卷,以实现一致的性能。

  • 使用 EBS 快照进行备份和恢复。

  • 为了实现高可用性,可以使用不同的可用性区域,为了灾难恢复,可以使用不同的地区。在每个亚马逊提供的地区内使用不同的可用性区域可以保证我们的数据具有高可用性。不同的地区应该只用于灾难恢复,以防发生灾难性事件摧毁整个地区。一个地区可能是 EU-West-2(伦敦),而一个可用性区域是地区内的一个细分;目前,伦敦有两个可用性区域。

  • 全球部署;本地访问。

  • 对于真正的全球应用程序,用户来自不同的时区,我们应该在不同的地区拥有应用服务器,访问距离他们最近的数据,使用正确的读取偏好配置在每个服务器上。

参考文档

阅读一本书很棒(阅读这本书更棒),但持续学习是保持与 MongoDB 最新的方式。在接下来的章节中,我们将强调您应该去哪里获取更新和开发/运营参考资料。

MongoDB 文档

docs.mongodb.com/manual/上的在线文档是每个开发人员的起点,无论是新手还是老手。

JIRA 跟踪器是查看已修复的错误和即将推出的功能的好地方:jira.mongodb.org/browse/SERVER/

Packt 参考资料

关于 MongoDB 的其他好书如下:

  • 面向 Java 开发人员的 MongoDB,Francesco Marchioni 著

  • MongoDB 数据建模,Wilson da Rocha França 著

  • Kristina Chodorow 的任何一本书

进一步阅读

MongoDB 用户组(groups.google.com/forum/#!forum/mongodb-user)有一个很好的用户问题存档,涉及功能和长期存在的错误。当某些功能不如预期时,这是一个可以去的地方。

在线论坛(Stack Overflow 和 Reddit 等)始终是知识的来源,但需要注意的是,某些内容可能是几年前发布的,可能已经不适用。在尝试之前一定要检查。

最后,MongoDB 大学是保持您的技能最新并了解最新功能和增加的好地方:university.mongodb.com/

总结

在本章中,我们开始了我们的网络、SQL 和 NoSQL 技术之旅,从它们的起源到它们的当前状态。我们确定了 MongoDB 如何在多年来塑造 NoSQL 数据库的世界,以及它如何与其他 SQL 和 NoSQL 解决方案相比。

我们探讨了 MongoDB 的关键特性以及 MongoDB 在生产部署中的使用情况。我们确定了设计、部署和操作 MongoDB 的最佳实践。

最初,我们确定了如何通过查阅文档和在线资源来学习,这些资源可以帮助我们了解最新的功能和发展动态。

在下一章中,我们将深入探讨模式设计和数据建模,看看如何通过使用官方驱动程序和对象文档映射(ODM)来连接到 MongoDB,这是一种用于 NoSQL 数据库的对象关系映射器的变体。

第二章:模式设计和数据建模

本章将重点讨论无模式数据库(如 MongoDB)的模式设计。尽管这听起来有些违反直觉,但在开发 MongoDB 时,我们应该考虑一些因素。我们将了解 MongoDB 支持的模式考虑因素和数据类型。我们还将学习如何通过连接 Ruby、Python 和 PHP 来为 MongoDB 准备文本搜索的数据。

在本章中,我们将涵盖以下主题:

  • 关系模式设计

  • 数据建模

  • 为原子操作建模数据

  • 建模关系

  • 连接到 MongoDB

关系模式设计

在关系数据库中,我们的设计目标是避免异常和冗余。当我们在多个列中存储相同的信息时,异常可能会发生;我们更新其中一个列,但没有更新其他列,因此最终得到相互冲突的信息。当我们无法删除一行而不丢失我们可能需要的信息时,异常也可能发生,可能是在其他引用它的行中。数据冗余可能发生在我们的数据不在正常形式中,但在不同的表中具有重复数据。这可能导致数据不一致,并且难以维护。

在关系数据库中,我们使用正常形式来规范化我们的数据。从基本的第一正常形式1NF)开始,到 2NF、3NF 和 BCNF,我们根据功能依赖关系对我们的数据进行建模,如果我们遵循规则,我们最终可能会得到比领域模型对象更多的表。

实际上,关系数据库建模通常是由我们拥有的数据结构驱动的。在遵循某种模型-视图-控制器MVC)模式的 Web 应用程序中,我们将根据我们的模型来设计我们的数据库,这些模型是根据统一建模语言UML)图表约定进行建模的。像Django的 ORM 或 Rails 的Active Record这样的抽象帮助应用程序开发人员将数据库结构抽象为对象模型。最终,很多时候,我们最终设计我们的数据库是基于可用数据的结构。因此,我们是根据我们可以得到的答案来设计的。

MongoDB 模式设计

与关系数据库相比,在 MongoDB 中,我们必须基于我们特定于应用程序的数据访问模式进行建模。找出我们的用户将会有的问题对于设计我们的实体至关重要。与 RDBMS 相比,数据重复和去规范化更频繁地使用,并且有充分的理由。

MongoDB 使用的文档模型意味着每个文档可以容纳的信息量远远多于或少于下一个文档,即使在同一个集合中也是如此。再加上在嵌入文档级别上 MongoDB 可以进行丰富和详细的查询,这意味着我们可以自由设计我们的文档。当我们了解我们的数据访问模式时,我们可以估计哪些字段需要被嵌入,哪些可以拆分到不同的集合中。

读写比

读写比通常是 MongoDB 建模的重要考虑因素。在读取数据时,我们希望避免散布/聚集的情况,即我们必须向多个分片发出随机 I/O 请求才能获取应用程序所需的数据。

另一方面,在写入数据时,我们希望将写入分散到尽可能多的服务器上,以避免过载任何一个服务器。这些目标表面上看起来是相互冲突的,但一旦我们了解我们的访问模式,并结合应用程序设计考虑,比如使用副本集从辅助节点读取,它们可以结合起来。

数据建模

在本节中,我们将讨论 MongoDB 使用的不同数据类型,它们如何映射到编程语言使用的数据类型,以及我们如何使用 Ruby、Python 和 PHP 在 MongoDB 中建模数据关系。

数据类型

MongoDB 使用 BSON,这是一种用于 JSON 文档的二进制编码序列化。 BSON 扩展了 JSON 数据类型,例如提供了原生数据和二进制数据类型。

与协议缓冲区相比,BSON 允许更灵活的模式,但以空间效率为代价。总的来说,BSON 在编码/解码操作中是空间高效、易于遍历和时间高效的,如下表所示。(请参阅 MongoDB 文档docs.mongodb.com/manual/reference/bson-types/):

类型 数字 别名 备注
双精度 1 double
String 2 string
对象 3 object
数组 4 array
二进制数据 5 binData
ObjectID 7 objectId
布尔 8 bool
日期 9 date
10 null
正则表达式 11 regex
JavaScript 13 javascript
JavaScript(带作用域) 15 javascriptWithScope
32 位整数 16 int
时间戳 17 timestamp
64 位整数 18 long
Decimal128 19 decimal 3.4 版中的新功能
最小键 -1 minKey
最大键 127 maxKey
未定义 6 undefined 已弃用
DBPointer 12 dbPointer 已弃用
符号 14 symbol 已弃用

在 MongoDB 中,我们可以在给定字段的文档中具有不同值类型,并且在使用$type运算符进行查询时,我们对它们进行区分。

例如,如果我们在 GBP 中有一个 32 位整数和double数据类型的balance字段,如果balance中有便士或没有,我们可以轻松查询所有帐户,这些帐户具有任何以下查询中显示的四舍五入的balance

db.account.find( { "balance" : { $type : 16 } } );
db.account.find( { "balance" : { $type : "integer" } } );

我们将在以下部分比较不同的数据类型。

比较不同的数据类型

由于 MongoDB 的性质,在同一字段中具有不同数据类型的对象是完全可以接受的。这可能是意外发生的,也可能是有意为之(即,在字段中有空值和实际值)。

不同类型数据的排序顺序,从高到低,如下所示:

  1. 内部类型的最大键

  2. 正则表达式

  3. 时间戳

  4. 日期

  5. 布尔值

  6. ObjectID

  7. 二进制数据

  8. 数组

  9. 对象

  10. 符号,字符串

  11. 数字(intlongdouble

  12. 内部类型的最小键

不存在的字段会按照在相应字段中具有null的方式进行排序。比较数组比较字段更复杂。比较的升序(或<)将比较每个数组的最小元素。比较的降序(或>)将比较每个数组的最大元素。

例如,查看以下情景:

> db.types.find()
{ "_id" : ObjectId("5908d58455454e2de6519c49"), "a" : [ 1, 2, 3 ] }
{ "_id" : ObjectId("5908d59d55454e2de6519c4a"), "a" : [ 2, 5 ] }

按升序排列,如下所示:

> db.types.find().sort({a:1})
{ "_id" : ObjectId("5908d58455454e2de6519c49"), "a" : [ 1, 2, 3 ] }
{ "_id" : ObjectId("5908d59d55454e2de6519c4a"), "a" : [ 2, 5 ] }

然而,按降序排列,如下所示:

> db.types.find().sort({a:-1})
{ "_id" : ObjectId("5908d59d55454e2de6519c4a"), "a" : [ 2, 5 ] }
{ "_id" : ObjectId("5908d58455454e2de6519c49"), "a" : [ 1, 2, 3 ] }

当比较数组与单个数字值时,也是如下示例所示。插入一个整数值为4的新文档的操作如下:

> db.types.insert({"a":4})
WriteResult({ "nInserted" : 1 })

以下示例显示了降序sort的代码片段:

> db.types.find().sort({a:-1})
{ "_id" : ObjectId("5908d59d55454e2de6519c4a"), "a" : [ 2, 5 ] }
{ "_id" : ObjectId("5908d73c55454e2de6519c4c"), "a" : 4 }
{ "_id" : ObjectId("5908d58455454e2de6519c49"), "a" : [ 1, 2, 3 ] }

以下示例是升序sort的代码片段:

> db.types.find().sort({a:1})
{ "_id" : ObjectId("5908d58455454e2de6519c49"), "a" : [ 1, 2, 3 ] }
{ "_id" : ObjectId("5908d59d55454e2de6519c4a"), "a" : [ 2, 5 ] }
{ "_id" : ObjectId("5908d73c55454e2de6519c4c"), "a" : 4 }

在每种情况下,我们都突出显示了要比较的值。

我们将在以下部分了解数据类型。

日期类型

日期以毫秒为单位存储,从 1970 年 1 月 1 日(纪元时间)开始生效。它们是 64 位有符号整数,允许在 1970 年之前和之后的 135 百万年范围内。负日期值表示 1970 年 1 月 1 日之前的日期。BSON 规范将date类型称为 UTCDateTime

MongoDB 中的日期存储在 UTC 中。与一些关系数据库中的timestamp带有timezone数据类型不同。需要根据本地时间访问和修改时间戳的应用程序应该将timezone偏移量与日期一起存储,并在应用程序级别上偏移日期。

在 MongoDB shell 中,可以使用以下 JavaScript 格式来完成:

var now = new Date();
db.page_views.save({date: now,
 offset: now.getTimezoneOffset()});

然后您需要应用保存的偏移量来重建原始本地时间,就像以下示例中所示:

var record = db.page_views.findOne();
var localNow = new Date( record.date.getTime() - ( record.offset * 60000 ) );

在下一节中,我们将介绍ObjectId

ObjectId

ObjectId是 MongoDB 的特殊数据类型。每个文档从创建到销毁都有一个_id字段。它是集合中每个文档的主键,并且必须是唯一的。如果我们在create语句中省略了这个字段,它将自动分配一个ObjectId

擅自更改ObjectId是不可取的,但我们可以小心使用它来达到我们的目的。

ObjectId具有以下区别:

  • 它有 12 个字节

  • 它是有序的

  • 按 _id 排序将按每个文档的创建时间进行排序

  • 存储创建时间可以通过在 shell 中使用.getTimeStamp()来访问

ObjectId的结构如下:

  • 一个 4 字节的值,表示自 Unix 纪元以来的秒数

  • 一个 3 字节的机器标识符

  • 一个 2 字节的进程 ID

  • 一个 3 字节的计数器,从一个随机值开始

下图显示了 ObjectID 的结构:

按其结构,ObjectId对于所有目的都是唯一的;但是,由于这是在客户端生成的,您应该检查底层库的源代码,以验证实现是否符合规范。

在下一节中,我们将学习有关建模原子操作的数据。

建模原子操作的数据

MongoDB 正在放宽许多在关系型数据库中找到的典型原子性、一致性、隔离性和持久性ACID)约束。在没有事务的情况下,有时很难在操作中保持状态一致,特别是在发生故障时。

幸运的是,一些操作在文档级别上是原子的:

  • update()

  • findandmodify()

  • remove()

这些都是针对单个文档的原子(全部或无)。

这意味着,如果我们在同一文档中嵌入信息,我们可以确保它们始终同步。

一个示例是库存应用程序,每个库存中的物品都有一个文档,我们需要统计库存中剩余的可用物品数量,购物车中已放置的物品数量,并将这些数据用于计算总可用物品数量。

对于total_available = 5available_now = 3shopping_cart_count = 2,这个用例可能如下所示:{available_now : 3, Shopping_cart_by: ["userA", "userB"] }

当有人将商品放入购物车时,我们可以发出原子更新,将他们的用户 ID 添加到shopping_cart_by字段中,并同时将available_now字段减少一个。

此操作将在文档级别上保证是原子的。如果我们需要在同一集合中更新多个文档,更新操作可能会成功完成,而不修改我们打算修改的所有文档。这可能是因为该操作不能保证跨多个文档更新是原子的。

这种模式在某些情况下有所帮助,但并非所有情况都适用。在许多情况下,我们需要对所有文档或甚至集合应用多个更新,要么全部成功,要么全部失败。

一个典型的例子是两个账户之间的银行转账。我们想要从用户 A 那里减去 x 英镑,然后将 x 添加到用户 B 那里。如果我们无法完成这两个步骤中的任何一个,我们将返回到两个余额的原始状态。

这种模式的细节超出了本书的范围,但大致上,想法是实现一个手工编码的两阶段提交协议。该协议应该为每个转账创建一个新的事务条目,并在该事务中的每个可能状态(如初始、挂起、应用、完成、取消中、已取消)中创建一个新的事务条目,并根据每个事务留下的状态,对其应用适当的回滚函数。

如果您发现自己不得不在一个旨在避免它们的数据库中实现事务,请退一步,重新思考为什么需要这样做。

写隔离

我们可以节约地使用$isolated来隔离对多个文档的写入,以防其他写入者或读取者对这些文档进行操作。在前面的例子中,我们可以使用$isolated来更新多个文档,并确保在其他人有机会进行双倍花费并耗尽资金源账户之前,我们更新两个余额。

然而,这不会给我们带来原子性,即全有或全无的方法。因此,如果更新只部分修改了两个账户,我们仍然需要检测并取消处于挂起状态的任何修改。

$isolated在整个集合上使用独占锁,无论使用哪种存储引擎。这意味着在使用它时会有严重的速度惩罚,特别是对于 WiredTiger 文档级别的锁定语义。

$isolated在分片集群中不起作用,当我们决定从副本集转到分片部署时可能会成为一个问题。

读取隔离和一致性

在传统的关系数据库管理系统定义中,MongoDB 的读取操作将被描述为读取未提交。这意味着,默认情况下,读取可能会获取到最终不会持久到磁盘上的值,例如,数据丢失或副本集回滚操作。

特别是,在使用默认写入行为更新多个文档时,缺乏隔离可能会导致以下问题:

  • 读取可能会错过在更新操作期间更新的文档

  • 非串行化操作

  • 读取操作不是即时的

这些可以通过使用$isolated运算符来解决,但会带来严重的性能惩罚。

在某些情况下,不使用.snapshot()的游标查询可能会得到不一致的结果。如果查询的结果游标获取了一个文档,而在查询仍在获取结果时该文档接收到更新,并且由于填充不足,最终位于磁盘上的不同物理位置,超出了查询结果游标的位置。.snapshot()是这种边缘情况的解决方案,但有以下限制:

  • 它不适用于分片

  • 它不适用于使用sort()hint()来强制使用索引

  • 它仍然不会提供即时读取行为

如果我们的集合大部分是静态数据,我们可以在查询字段中使用唯一索引来模拟snapshot(),并且仍然能够对其应用sort()

总的来说,我们需要在应用程序级别应用保障措施,以确保我们不会得到意外的结果。

从版本 3.4 开始,MongoDB 提供了可线性化的读关注。通过从副本集的主要成员和大多数写关注中使用线性化的读关注,我们可以确保多个线程可以读取和写入单个文档,就好像单个线程在依次执行这些操作一样。在关系型数据库管理系统中,这被认为是一个线性化的调度,MongoDB 称之为实时顺序。

建模关系

在接下来的章节中,我们将解释如何将关系数据库管理系统理论中的关系转换为 MongoDB 的文档集合层次结构。我们还将研究如何在 MongoDB 中为文本搜索建模我们的数据。

一对一

从关系数据库世界来看,我们通过它们的关系来识别对象。一个一对一的关系可能是一个人和一个地址。在关系数据库中对其进行建模很可能需要两个表:一个Person表和一个Address表,Address表中有一个person_id外键,如下图所示:

在 MongoDB 中,完美的类比是两个集合,PersonAddress,如下代码所示:

> db.Person.findOne()
{
"_id" : ObjectId("590a530e3e37d79acac26a41"), "name" : "alex"
}
> db.Address.findOne()
{
"_id" : ObjectId("590a537f3e37d79acac26a42"),
"person_id" : ObjectId("590a530e3e37d79acac26a41"),
"address" : "N29DD"
}

现在,我们可以像在关系数据库中一样使用相同的模式从address中查找Person,如下例所示:

> db.Person.find({"_id": db.Address.findOne({"address":"N29DD"}).person_id})
{
"_id" : ObjectId("590a530e3e37d79acac26a41"), "name" : "alex"
}

这种模式在关系世界中是众所周知的,并且有效。

在 MongoDB 中,我们不必遵循这种模式,因为有更适合模型这些关系的方式。

在 MongoDB 中,我们通常会通过嵌入来建模一对一或一对多的关系。如果一个人有两个地址,那么同样的例子将如下所示:

{ "_id" : ObjectId("590a55863e37d79acac26a43"), "name" : "alex", "address" : [ "N29DD", "SW1E5ND" ] }

使用嵌入数组,我们可以访问此用户拥有的每个address。嵌入查询丰富而灵活,因此我们可以在每个文档中存储更多信息,如下例所示:

{ "_id" : ObjectId("590a56743e37d79acac26a44"),
"name" : "alex",
"address" : [ { "description" : "home", "postcode" : "N29DD" },
{ "description" : "work", "postcode" : "SW1E5ND" } ] }

这种方法的优点如下:

  • 无需跨不同集合进行两次查询

  • 它可以利用原子更新来确保文档中的更新对于其他读取此文档的读者来说是全有或全无的

  • 它可以在多个嵌套级别中嵌入属性,创建复杂的结构

最显著的缺点是文档的最大大小为 16 MB,因此这种方法不能用于任意数量的属性。在嵌入数组中存储数百个元素也会降低性能。

一对多和多对多

当关系的方的元素数量可以无限增长时,最好使用引用。引用可以有两种形式:

  1. 从关系的方,存储多边元素的数组,如下例所示:
> db.Person.findOne()
{ "_id" : ObjectId("590a530e3e37d79acac26a41"), "name" : "alex", addresses:
[ ObjectID('590a56743e37d79acac26a44'),
ObjectID('590a56743e37d79acac26a46'),
ObjectID('590a56743e37d79acac26a54') ] }
  1. 这样我们可以从一方获取addresses数组,然后使用in查询获取多方的所有文档,如下例所示:
> person = db.Person.findOne({"name":"mary"})
> addresses = db.Addresses.find({_id: {$in: person.addresses} })

将这种一对多转换为多对多就像在关系的两端(即PersonAddress集合)都存储这个数组一样容易。

  1. 从关系的多方,存储对一方的引用,如下例所示:
> db.Address.find()
{ "_id" : ObjectId("590a55863e37d79acac26a44"), "person":  ObjectId("590a530e3e37d79acac26a41"), "address" : [ "N29DD" ] }
{ "_id" : ObjectId("590a55863e37d79acac26a46"), "person":  ObjectId("590a530e3e37d79acac26a41"), "address" : [ "SW1E5ND" ] }
{ "_id" : ObjectId("590a55863e37d79acac26a54"), "person":  ObjectId("590a530e3e37d79acac26a41"), "address" : [ "N225QG" ] }
> person = db.Person.findOne({"name":"alex"})
> addresses = db.Addresses.find({"person": person._id})

正如我们所看到的,无论哪种设计,我们都需要对数据库进行两次查询以获取信息。第二种方法的优势在于它不会让任何文档无限增长,因此它可以用于一对多是一对数百万的情况。

为关键字搜索建模数据

在许多应用程序中,搜索文档中的关键字是一个常见的操作。如果这是一个核心操作,使用专门的搜索存储,如Elasticsearch是有意义的;然而,直到规模要求转移到不同的解决方案之前,MongoDB 可以有效地使用。

关键字搜索的基本需求是能够搜索整个文档中的关键字。例如,在products集合中的文档,如下例所示:

{ name : "Macbook Pro late 2016 15in" ,
  manufacturer : "Apple" ,
  price: 2000 ,
  keywords : [ "Macbook Pro late 2016 15in", "2000", "Apple", "macbook", "laptop", "computer" ]
 }

我们可以在keywords字段中创建多键索引,如下例所示:

> db.products.createIndex( { keywords: 1 } )

现在我们可以在keywords字段中搜索任何名称、制造商、价格,以及我们设置的任何自定义关键字。这不是一种高效或灵活的方法,因为我们需要保持关键字列表同步,我们不能使用词干处理,也不能对结果进行排名(更像是过滤而不是搜索)。这种方法的唯一优点是它实现起来稍微快一些。

自 2.4 版本以来,MongoDB 就有了特殊的文本索引类型。它可以在一个或多个字段中声明,并支持词干处理、标记化、精确短语(" ")、否定(-)和加权结果。

在三个字段上声明具有自定义权重的索引如下例所示:

db.products.createIndex({
 name: "text",
 manufacturer: "text",
 price: "text"
 },
 {
 weights: { name: 10,
 manufacturer: 5,
 price: 1 },
 name: "ProductIndex"
 })

在这个例子中,nameprice重要的程度是10倍,但比manufacturer只重要两倍。

可以使用通配符声明text索引,匹配与模式匹配的所有字段,如下例所示:

db.collection.createIndex( { "$**": "text" } )

这在我们有非结构化数据并且可能不知道它们将带有哪些字段时非常有用。我们可以像处理任何其他索引一样,通过名称删除索引。

然而,最大的优势是,除了所有的功能之外,所有的记录都是由数据库完成的。

在下一节中,我们将学习如何连接到 MongoDB。

连接到 MongoDB

有两种连接到 MongoDB 的方式。第一种是使用您的编程语言的驱动程序。第二种是使用 ODM 层以透明的方式将模型对象映射到 MongoDB。在本节中,我们将涵盖使用 Web 应用程序开发中最流行的三种语言:Ruby、Python 和 PHP 的两种方式。

使用 Ruby 连接

Ruby 是第一批得到 MongoDB 官方驱动程序支持的语言之一。在 GitHub 上,官方的 MongoDB Ruby 驱动程序是连接到 MongoDB 实例的推荐方式。执行以下步骤使用 Ruby 连接 MongoDB:

  1. 安装就像将其添加到 Gemfile 一样简单,如下例所示:
gem 'mongo', '~> 2.6'

您需要安装 Ruby,然后从rvm.io/rvm/install安装 RVM,最后运行gem install bundler

  1. 然后,在我们的类中,我们可以连接到数据库,如下例所示:
require 'mongo'
client = Mongo::Client.new([ '127.0.0.1:27017' ], database: 'test')
  1. 这是可能的最简单的例子:连接到我们的localhost中名为test的单个数据库实例。在大多数情况下,我们至少会有一个副本集要连接,如下面的代码片段所示:
client_host = ['server1_hostname:server1_ip, server2_hostname:server2_ip']
 client_options = {
  database: 'YOUR_DATABASE_NAME',
  replica_set: 'REPLICA_SET_NAME',
  user: 'YOUR_USERNAME',
  password: 'YOUR_PASSWORD'
 }
client = Mongo::Client.new(client_host, client_options)
  1. client_host服务器正在为客户端驱动程序提供服务器以尝试连接。一旦连接,驱动程序将根据主/次读取或写入配置确定要连接的服务器。replica_set属性需要匹配REPLICA_SET_NAME才能连接。

  2. userpassword是可选的,但在任何 MongoDB 实例中都强烈建议使用。在mongod.conf文件中默认启用身份验证是一个良好的做法,我们将在第八章中了解更多信息,监控、备份和安全

  3. 连接到分片集群与连接到副本集类似,唯一的区别是,我们需要连接到充当 MongoDB 路由器的 MongoDB 进程,而不是提供服务器主机/端口。

Mongoid ODM

使用低级驱动程序连接到 MongoDB 数据库通常不是最有效的方法。低级驱动程序提供的所有灵活性都抵消了更长的开发时间和用于将我们的模型与数据库粘合在一起的代码。

ODM 可以是这些问题的答案。就像 ORM 一样,ODM 弥合了我们的模型和数据库之间的差距。在 Rails 中,作为 Ruby 最广泛使用的 MVC 框架的 Mongoid 可以用于以类似于 Active Record 的方式对我们的数据进行建模。

安装gem类似于 Mongo Ruby 驱动程序,通过在 Gemfile 中添加一个文件,如下面的代码所示:

gem 'mongoid', '~> 7.0'

根据 Rails 的版本,我们可能还需要将以下内容添加到application.rb中:

config.generators do |g|
g.orm :mongoid
end

通过配置文件mongoid.yml连接到数据库,配置选项以语义缩进的键值对形式传递。其结构类似于用于关系数据库的database.yml

我们可以通过mongoid.yml文件传递的一些选项如下表所示:

选项值 描述
Database 数据库名称。
Hosts 我们的数据库主机。
Write/w 写入关注(默认为 1)。
Auth_mech 认证机制。有效选项包括::scram:mongodb_cr:mongodb_x509:plain。3.0 的默认选项是:scram,而 2.4 和 2.6 的默认选项是:plain
Auth_source 我们认证机制的认证源。
Min_pool_size/max_pool_size 连接的最小和最大池大小。
SSLssl_certssl_keyssl_key_pass_phrasessl_verify 一组关于与数据库的 SSL 连接的选项。
Include_root_in_json 在 JSON 序列化中包含根模型名称。
Include_type_for_serialization 在序列化 MongoDB 对象时包含_type字段。
Use_activesupport_time_zone 在服务器和客户端之间转换时间戳时使用 active support 的时区。

下一步是修改我们的模型以存储在 MongoDB 中。这就像在模型声明中包含一行代码那样简单,如下例所示:

class Person
  include Mongoid::Document
 End

我们还可以使用以下代码:

include Mongoid::Timestamps

我们用它来生成类似于 Active Record 的 created_atupdated_at 字段。在我们的模型中,数据字段不需要按类型声明,但这样做是个好习惯。支持的数据类型如下:

  • Array

  • BigDecimal

  • Boolean

  • Date

  • DateTime

  • Float

  • Hash

  • Integer

  • BSON::ObjectId

  • BSON::Binary

  • Range

  • Regexp

  • String

  • Symbol

  • Time

  • TimeWithZone

如果字段的类型未定义,字段将被转换为对象并存储在数据库中。这样稍微快一些,但不支持所有类型。如果我们尝试使用 BigDecimalDateDateTimeRange,将会收到错误信息。

使用 Mongoid 模型进行继承

以下代码是使用 Mongoid 模型进行继承的示例:

class Canvas
  include Mongoid::Document
  field :name, type: String
  embeds_many :shapes
end

class Shape
  include Mongoid::Document
  field :x, type: Integer
  field :y, type: Integer
  embedded_in :canvas
end

class Circle < Shape
  field :radius, type: Float
end

class Rectangle < Shape
  field :width, type: Float
  field :height, type: Float
end

现在,我们有一个具有许多嵌入的 Shape 对象的 Canvas 类。Mongoid 将自动创建一个字段,即 _type,以区分父节点和子节点字段。在从字段继承文档的情况下,关系、验证和作用域会复制到其子文档中,但反之则不会。

embeds_manyembedded_in 对将创建嵌入式子文档以存储关系。如果我们想通过引用 ObjectId 来存储这些关系,可以通过将它们替换为 has_manybelongs_to 来实现。

使用 Python 进行连接

与 Ruby 和 Rails 相媲美的是 Python 和 Django。类似于 Mongoid,还有 MongoEngine 和官方的 MongoDB 低级驱动程序 PyMongo。

使用 pipeasy_install 安装 PyMongo,如下代码所示:

python -m pip install pymongo
python -m easy_install pymongo

然后,在我们的类中,我们可以连接到数据库,如下例所示:

>>> from pymongo import MongoClient
>>> client = MongoClient()

连接到副本集需要一组种子服务器,客户端可以找出集合中的主、从或仲裁节点,如下例所示:

client = pymongo.MongoClient('mongodb://user:passwd@node1:p1,node2:p2/?replicaSet=rsname')

使用连接字符串 URL,我们可以在单个字符串中传递用户名、密码和 replicaSet 名称。连接字符串 URL 的一些最有趣的选项在下一节中。

连接到分片需要 MongoDB 路由器的服务器主机和 IP,这是 MongoDB 进程。

PyMODM ODM

与 Ruby 的 Mongoid 类似,PyMODM 是 Python 的 ODM,紧随 Django 内置的 ORM。通过 pip 安装 pymodm,如下代码所示:

pip install pymodm

然后我们需要编辑 settings.py,将数据库 ENGINE 替换为 dummy 数据库,如下代码所示:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.dummy'
    }
}

然后我们在 settings.py 的任何位置添加我们的连接字符串,如下代码所示:

from pymodm import connect
connect("mongodb://localhost:27017/myDatabase", alias="MyApplication")

在这里,我们必须使用具有以下结构的连接字符串:

mongodb://[username:password@]host1[:port1][,host2[:port2],...[,hostN[:portN]]][/[database][?options]]

选项必须是name=value对,每对之间用&分隔。一些有趣的对如下表所示:

名称 描述
minPoolSize/maxPoolSize 连接的最小和最大池大小。
w 写关注选项。
wtimeoutMS 写关注操作的超时时间。
Journal 日志选项。
readPreference 用于副本集的读取偏好。可用选项包括:primaryprimaryPreferredsecondarysecondaryPreferrednearest
maxStalenessSeconds 指定从主服务器滞后的数据可以在客户端停止使用之前的秒数。
SSL 使用 SSL 连接到数据库。
authSource 与用户名一起使用,指定与用户凭据关联的数据库。当我们使用外部认证机制时,LDAP 或 Kerberos 应该是 $external
authMechanism 可用于连接的身份验证机制。MongoDB 的可用选项有:SCRAM-SHA-1MONGODB-CRMONGODB-X.509。MongoDB 企业版(付费版本)提供了两个更多的选项:GSSAPI(Kerberos),PLAINLDAP SASL

模型类需要继承自MongoModel。以下代码显示了一个示例类的样子:

from pymodm import MongoModel, fields
class User(MongoModel):
    email = fields.EmailField(primary_key=True)
    first_name = fields.CharField()
    last_name = fields.CharField()

这里有一个User类,有first_namelast_nameemail字段,其中email是主要字段。

PyMODM 模型的继承

在 MongoDB 中处理一对一和一对多关系可以使用引用或嵌入。下面的例子展示了两种方式,即用户模型的引用和评论模型的嵌入:

from pymodm import EmbeddedMongoModel, MongoModel, fields

class Comment(EmbeddedMongoModel):
    author = fields.ReferenceField(User)
    content = fields.CharField()

class Post(MongoModel):
    title = fields.CharField()
    author = fields.ReferenceField(User)
    revised_on = fields.DateTimeField()
    content = fields.CharField()
    comments = fields.EmbeddedDocumentListField(Comment)

类似于 Ruby 的 Mongoid,我们可以根据设计决定将关系定义为嵌入式或引用式。

使用 PHP 连接

两年前,MongoDB PHP 驱动程序从头开始重写,以支持 PHP 5、PHP 7 和 HHVM 架构。当前的架构如下图所示:

目前,我们对所有三种架构都有官方驱动程序,完全支持底层功能。

安装是一个两步过程。首先,我们需要安装 MongoDB 扩展。这个扩展依赖于我们安装的 PHP(或 HHVM)的版本,可以使用 macOS 中的brew来完成。以下示例是使用 PHP 7.0:

brew install php70-mongodb

然后,像下面的例子一样使用composer(PHP 中广泛使用的依赖管理器):

composer require mongodb/mongodb

可以通过使用连接字符串 URL 或通过传递一个选项数组来连接到数据库。

使用连接字符串 URL,我们有以下代码:

$client = new MongoDB\Client($uri = 'mongodb://127.0.0.1/', array $uriOptions = [], array $driverOptions = [])

例如,要使用 SSL 身份验证连接到副本集,我们使用以下代码:

$client = new MongoDB\Client('mongodb://myUsername:myPassword@rs1.example.com,rs2.example.com/?ssl=true&replicaSet=myReplicaSet&authSource=admin');

或者我们可以使用$uriOptions参数来传递参数,而不使用连接字符串 URL,如下面的代码所示:

$client = new MongoDB\Client(
 'mongodb://rs1.example.com,rs2.example.com/'
 [
 'username' => 'myUsername',
 'password' => 'myPassword',
 'ssl' => true,
 'replicaSet' => 'myReplicaSet',
 'authSource' => 'admin',
 ],
);

可用的$uriOptions和连接字符串 URL 选项与用于 Ruby 和 Python 的选项类似。

Doctrine ODM

Laravel是 PHP 中最广泛使用的 MVC 框架之一,类似于 Python 和 Ruby 世界中的 Django 和 Rails 的架构。我们将通过配置我们的模型使用 Laravel,Doctrine 和 MongoDB。本节假设 Doctrine 已安装并与 Laravel 5.x 一起使用。

Doctrine 实体是Plain Old PHP ObjectsPOPO),与Eloquent不同,Laravel 的默认 ORM 不需要继承Model类。Doctrine 使用Data Mapper Pattern,而 Eloquent 使用 Active Record。跳过get()set()方法,一个简单的类将如下所示:

use Doctrine\ORM\Mapping AS ORM;
use Doctrine\Common\Collections\ArrayCollection;
/**
* @ORM\Entity
* @ORM\Table(name="scientist")
*/
class Scientist
{
   /**
    * @ORM\Id
    * @ORM\GeneratedValue
    * @ORM\Column(type="integer")
    */
   protected $id;
   /**
    * @ORM\Column(type="string")
    */
   protected $firstname;
   /**
    * @ORM\Column(type="string")
    */
   protected $lastname;
   /**
   * @ORM\OneToMany(targetEntity="Theory", mappedBy="scientist", cascade={"persist"})
   * @var ArrayCollection|Theory[]
   */
   protected $theories;
   /**
   * @param $firstname
   * @param $lastname
   */
   public function __construct($firstname, $lastname)
   {
       $this->firstname = $firstname;
       $this->lastname  = $lastname;
       $this->theories = new ArrayCollection;
   }
...
   public function addTheory(Theory $theory)
   {
       if(!$this->theories->contains($theory)) {
           $theory->setScientist($this);
           $this->theories->add($theory);
       }
   }

这个基于 POPO 的模型使用注释来定义需要在 MongoDB 中持久化的字段类型。例如,@ORM\Column(type="string")定义了 MongoDB 中的一个字段,string类型的firstnamelastname作为属性名称,在相应的行中。

这里有一整套可用的注释:doctrine2.readthedocs.io/en/latest/reference/annotations-reference.html

如果我们想要将 POPO 结构与注释分开,我们也可以使用 YAML 或 XML 来定义它们,而不是在我们的 POPO 模型类中使用注释。

Doctrine 的继承

可以通过注释、YAML 或 XML 来建模一对一和一对多关系。使用注释,我们可以在我们的文档中定义多个嵌入的子文档,如下例所示:

/** @Document */
class User
{
   // ...
   /** @EmbedMany(targetDocument="Phonenumber") */
   private $phonenumbers = array();
   // ...
}
/** @EmbeddedDocument */
class Phonenumber
{
   // ...
}

在这里,一个User文档嵌入了许多phonenumbers@EmbedOne()将嵌入一个子文档,用于建模一对一关系。

引用与嵌入类似,如下例所示:

/** @Document */
class User
{
   // ...
   /**
    * @ReferenceMany(targetDocument="Account")
    */
   private $accounts = array();
   // ...
}
/** @Document */
class Account
{
   // ...
}

@ReferenceMany()@ReferenceOne()用于通过引用到单独的集合来建模一对多和一对一关系。

摘要

在本章中,我们学习了关系数据库和 MongoDB 的模式设计,以及如何从不同的起点开始实现相同的目标。

在 MongoDB 中,我们必须考虑读写比例,用户在最常见情况下可能会遇到的问题,以及关系之间的基数。

我们学习了关于原子操作以及如何构建查询,以便在没有事务开销的情况下具有 ACID 属性。

我们还了解了 MongoDB 的数据类型,它们如何进行比较,以及一些特殊的数据类型,比如ObjectId,它可以被数据库和我们自己利用。

从建模简单的一对一关系开始,我们经历了一对多关系和多对多关系建模,而无需像在关系数据库中那样使用中间表,可以使用引用或嵌入文档。

我们学习了如何为关键字搜索建模数据,这是大多数应用程序在 Web 环境中需要支持的功能之一。

最后,我们探讨了在三种最流行的 Web 编程语言中使用 MongoDB 的不同用例。我们看到了使用官方驱动程序和 Mongoid ODM 的 Ruby 的示例。然后我们探讨了如何使用官方驱动程序和 PyMODM ODM 连接 Python,最后,我们通过使用官方驱动程序和 Doctrine ODM 在 PHP 中的示例进行了工作。

对于所有这些语言(以及许多其他语言),都有官方驱动程序提供支持和完全访问底层数据库操作功能,还有对象数据建模框架,用于轻松建模我们的数据和快速开发。

在下一章中,我们将深入探讨 MongoDB shell 以及我们可以使用它实现的操作。我们还将掌握使用驱动程序对我们的文档进行 CRUD 操作。

第二部分:高效查询

在本部分,我们将涵盖更高级的 MongoDB 操作。我们将从 CRUD 操作开始,这是最常用的操作。然后,我们将转向更高级的查询概念,接着是在 4.0 版本中引入的多文档 ACID 事务。接下来要讨论的话题是聚合框架,它可以帮助用户以结构化和高效的方式处理大数据。最后,我们将学习如何对数据进行索引,以使读取速度更快,但不影响写入性能。

本部分包括以下章节:

  • 第三章,MongoDB CRUD 操作

  • 第四章,高级查询

  • 第五章,多文档 ACID 事务

  • 第六章,聚合

  • 第七章,索引

第三章:MongoDB CRUD 操作

在本章中,我们将学习如何使用 mongo shell 进行数据库管理操作。从简单的创建读取更新删除(CRUD)操作开始,我们将掌握从 shell 进行脚本编写。我们还将学习如何从 shell 编写 MapReduce 脚本,并将其与聚合框架进行对比,我们将在第六章中深入探讨聚合。最后,我们将探讨使用 MongoDB 社区及其付费版本企业版进行身份验证和授权。

在本章中,我们将涵盖以下主题:

    • 使用 shell 进行 CRUD
  • 管理

  • 聚合框架

  • 保护 shell

  • 使用 MongoDB 进行身份验证

使用 shell 进行 CRUD

mongo shell 相当于关系数据库使用的管理控制台。连接到 mongo shell 就像输入以下代码一样简单:

$ mongo

对于独立服务器或副本集,请在命令行上键入此代码。在 shell 中,您可以通过输入以下代码简单查看可用的数据库:

$ db

然后,您可以通过输入以下代码连接到数据库:

> use <database_name>

mongo shell 可用于查询和更新我们的数据库中的数据。可以通过以下方式将此文档插入到books集合中:

> db.books.insert({title: 'mastering mongoDB', isbn: '101'})
WriteResult({ "nInserted" : 1 })

然后,我们可以通过输入以下内容从名为books的集合中查找文档:

> db.books.find()
{ "_id" : ObjectId("592033f6141daf984112d07c"), "title" : "mastering mongoDB", "isbn" : "101" }

我们从 MongoDB 得到的结果告诉我们写入成功,并在数据库中插入了一个新文档。

删除这个文档有类似的语法,并导致以下代码的结果:

> db.books.remove({isbn: '101'})
WriteResult({ "nRemoved" : 1 })

您可以尝试按照以下代码块中所示更新相同的文档:

> db.books.update({isbn:'101'}, {price: 30})
WriteResult({ "nMatched" : 1, "nUpserted" : 0, "nModified" : 1 })
> db.books.find()
{ "_id" : ObjectId("592034c7141daf984112d07d"), "price" : 30 }

在这里,我们注意到了一些事情:

  • update命令中的类似 JSON 格式的字段是我们搜索要更新的文档的查询

  • WriteResult对象通知我们查询匹配了一个文档并修改了一个文档

  • 最重要的是,该文档的内容完全被第二个类似 JSON 格式的字段的内容替换,但我们丢失了titleisbn的信息

默认情况下,MongoDB 中的update命令将使用我们在第二个参数中指定的文档替换我们文档的内容。如果我们想要更新文档并向其添加新字段,我们需要使用$set运算符,如下所示:

> db.books.update({isbn:'101'}, {$set: {price: 30}})
WriteResult({ "nMatched" : 1, "nUpserted" : 0, "nModified" : 1 })

现在,我们的文档与我们的预期相匹配:

> db.books.find()
{ "_id" : ObjectId("592035f6141daf984112d07f"), "title" : "mastering mongoDB", "isbn" : "101", "price" : 30 }

但是,删除文档可以通过多种方式完成,最简单的方式是通过其唯一的ObjectId

> db.books.remove("592035f6141daf984112d07f")
WriteResult({ "nRemoved" : 1 })
> db.books.find()
>

您可以在这里看到,当没有结果时,mongo shell 除了 shell 提示本身之外不会返回任何内容:>

为 mongo shell 编写脚本

使用内置命令管理数据库是有帮助的,但这并不是使用 shell 的主要原因。mongo shell 的真正强大之处在于它是一个 JavaScript shell。

我们可以在 shell 中声明和分配变量,如下所示:

> var title = 'MongoDB in a nutshell'
> title
MongoDB in a nutshell
> db.books.insert({title: title, isbn: 102})
WriteResult({ "nInserted" : 1 })
> db.books.find()
{ "_id" : ObjectId("59203874141daf984112d080"), "title" : "MongoDB in a nutshell", "isbn" : 102 }

在前面的例子中,我们声明了一个名为title的新变量,值为MongoDB in a nutshell,并使用该变量将一个新文档插入到我们的books集合中,如下面的代码所示。

由于它是一个 JavaScript shell,我们可以使用它来生成复杂结果的函数和脚本:

> queryBooksByIsbn = function(isbn) { return db.books.find({isbn: isbn})}

使用这个一行代码,我们创建了一个名为queryBooksByIsbn的新函数,它接受一个参数,即isbn值。有了我们在集合中的数据,我们可以使用我们的新函数并按isbn获取书籍,如下面的代码所示:

> queryBooksByIsbn("101")
{ "_id" : ObjectId("592035f6141daf984112d07f"), "title" : "mastering mongoDB", "isbn" : "101", "price" : 30 }

使用 shell,我们可以编写和测试这些脚本。一旦我们满意,我们可以将它们存储在.js文件中,并直接从命令行调用它们:

$ mongo <script_name>.js

以下是关于这些脚本的默认行为的一些有用注释:

  • 写操作将使用默认的写关注1,这是 MongoDB 当前版本的全局默认值。写关注1将请求确认写操作已传播到独立的mongod服务器或副本集中的主服务器。

  • 要将脚本中的操作结果返回到标准输出,我们必须使用 JavaScript 的内置print()函数或 mongo 特定的printjson()函数,它以 JSON 格式打印出结果。

脚本编写 mongo shell 和直接使用之间的区别

在为 mongo shell 编写脚本时,我们不能使用 shell 助手。MongoDB 的命令,如use <database_name>show collections和其他助手内置在 shell 中,因此无法从 JavaScript 上下文中使用,而我们的脚本将在其中执行。幸运的是,有它们的等价物可以从 JavaScript 执行上下文中使用,如下表所示:

Shell helpers JavaScript equivalents
show dbs, show databases db.adminCommand('listDatabases')
use <database_name> db = db.getSiblingDB('<database_name>')
show collections db.getCollectionNames()
show users db.getUsers()
show roles db.getRoles({showBuiltinRoles: true})
show log <logname> db.adminCommand({ 'getLog' : '<logname>' })
show logs db.adminCommand({ 'getLog' : '*' })

| it | cursor = db.collection.find() if ( cursor.hasNext() ){

 cursor.next();

} |

在上表中,it是迭代光标,当我们查询并返回太多结果以显示在一个批处理中时,mongo shell 返回的。

使用 mongo shell,我们可以编写几乎任何我们从客户端编写的脚本,这意味着我们有一个非常强大的原型工具,可以快速了解我们的数据。

使用 shell 进行批量插入

在使用 shell 时,我们经常需要以编程方式插入大量文档。由于我们有一个 JavaScript shell,最直接的实现方式是通过循环迭代,逐步生成每个文档,并在每次循环迭代中执行写操作,如下所示:

> authorMongoFactory = function() {for(loop=0;loop<1000;loop++) {db.books.insert({name: "MongoDB factory book" + loop})}}
function () {for(loop=0;loop<1000;loop++) {db.books.insert({name: "MongoDB factory book" + loop})}}

在这个简单的例子中,我们为一个作者创建了一个authorMongoFactory()方法,他写了1000本关于 MongoDB 的书,每本书的名字略有不同:

> authorMongoFactory()

这将导致向数据库发出1000次写入。虽然从开发的角度来看很简单,但这种方法会给数据库带来压力。

相反,使用bulk写入,我们可以使用事先准备好的1000个文档发出单个数据库insert命令,如下所示:

> fastAuthorMongoFactory = function() {
var bulk = db.books.initializeUnorderedBulkOp();
for(loop=0;loop<1000;loop++) {bulk.insert({name: "MongoDB factory book" + loop})}
bulk.execute();
}

最终结果与之前相同,在我们的books集合中插入了1000个文档,结构如下:

> db.books.find()
{ "_id" : ObjectId("59204251141daf984112d851"), "name" : "MongoDB factory book0" }
{ "_id" : ObjectId("59204251141daf984112d852"), "name" : "MongoDB factory book1" }
{ "_id" : ObjectId("59204251141daf984112d853"), "name" : "MongoDB factory book2" }
…
{ "_id" : ObjectId("59204251141daf984112d853"), "name" : "MongoDB factory book999" }

从用户的角度来看,区别在于执行速度和对数据库的减轻压力。

在前面的例子中,我们使用了initializeUnorderedBulkOp()来设置bulk操作构建器。我们这样做的原因是因为我们不关心插入的顺序与我们使用bulk.insert()命令将它们添加到我们的bulk变量的顺序相同。

当我们可以确保所有操作彼此无关或幂等时,这是有意义的。

如果我们关心插入的顺序相同,我们可以使用initializeOrderedBulkOp();通过更改函数的第二行,我们得到以下代码片段:

var bulk = db.books.initializeOrderedBulkOp();

使用 mongo shell 进行批量操作

在插入的情况下,我们通常可以期望操作的顺序并不重要。

然而,bulk可以用于比插入更多的操作。在下面的例子中,我们在bookOrders集合中有一本书,isbn:101nameMastering MongoDB,在available字段中有可购买的可用副本数量,有99本可供购买:

> db.bookOrders.find()
{ "_id" : ObjectId("59204793141daf984112dc3c"), "isbn" : 101, "name" : "Mastering MongoDB", "available" : 99 }

通过一系列操作在单个bulk操作中,我们将向库存中添加一本书,然后订购100本书,最终总共可用的副本为零:

> var bulk = db.bookOrders.initializeOrderedBulkOp();
> bulk.find({isbn: 101}).updateOne({$inc: {available : 1}});
> bulk.find({isbn: 101}).updateOne({$inc: {available : -100}});
> bulk.execute();

使用代码,我们将得到以下输出:

使用initializeOrderedBulkOp(),我们可以确保在订购100本书之前添加一本书,以便我们永远不会缺货。相反,如果我们使用initializeUnorderedBulkOp(),我们就无法得到这样的保证,我们可能会在添加新书之前收到 100 本书的订单,导致应用程序错误,因为我们没有那么多书来满足订单。

在执行有序操作列表时,MongoDB 将操作分成1000个批次,并按操作分组。例如,如果我们有1002个插入,998个更新,1004个删除,最后5个插入,我们最终会得到以下结果:

[1000 inserts]
[2 inserts]
[998 updates]
[1000 deletes]
[4 deletes]
[5 inserts] 

前面的代码可以解释如下:

这不会影响操作系列,但隐含意味着我们的操作将以1000的批次离开数据库。这种行为不能保证在将来的版本中保持不变。

如果我们想要检查bulk.execute()命令的执行,我们可以在输入execute()后立即发出bulk.getOperations()

自 3.2 版本以来,MongoDB 提供了批量写入的替代命令bulkWrite()

bulkWrite参数是我们要执行的操作系列。WriteConcern(默认值再次为1),以及写操作系列是否应按照它们在数组中出现的顺序应用(默认情况下将按顺序排列):

> db.collection.bulkWrite(
 [ <operation 1>, <operation 2>, ... ],
 {
 writeConcern : <document>,
 ordered : <boolean>
 }
)

以下操作与bulk支持的操作相同:

  • insertOne

  • updateOne

  • updateMany

  • deleteOne

  • deleteMany

  • replaceOne

updateOnedeleteOnereplaceOne具有匹配的过滤器;如果它们匹配多个文档,它们只会对第一个文档进行操作。重要的是要设计这些查询,以便它们不匹配多个文档,否则行为将是未定义的。

管理

在大多数情况下,使用 MongoDB 应该对开发人员尽可能透明。由于没有模式,因此不需要迁移,通常情况下,开发人员发现自己在数据库世界中花费的时间较少。

也就是说,有几个任务是经验丰富的 MongoDB 开发人员或架构师可以执行以保持 MongoDB 的速度和性能。

管理通常在三个不同的级别上执行,从更通用到更具体:进程集合索引

在进程级别上,有shutDown命令来关闭 MongoDB 服务器。

在数据库级别上,我们有以下命令:

  • dropDatabase

  • listCollections

  • copyDBclone以在本地克隆远程数据库

  • repairDatabase:当我们的数据库由于不干净的关闭而处于不一致状态时

相比之下,在集合级别上,使用以下命令:

  • drop:删除集合

  • create:创建集合

  • renameCollection:重命名集合

  • cloneCollection:将远程集合克隆到我们的本地数据库

  • cloneCollectionAsCapped:将集合克隆到新的封顶集合

  • convertToCapped:将集合转换为封顶集合

在索引级别上,我们可以使用以下命令:

  • createIndexes

  • listIndexes

  • dropIndexes

  • reIndex

我们还将介绍一些更重要的管理命令。

fsync

MongoDB 通常每 60 秒将所有操作写入磁盘。fsync 将强制数据立即和同步地持久保存到磁盘。

如果我们想备份数据库,我们还需要应用锁。在 fsync 操作时,锁定将阻止所有写入和一些读取。

在几乎所有情况下,最好使用日志记录,并参考我们的备份和恢复技术,这将在第八章 监控、备份和安全中进行介绍,以获得最大的可用性和性能。

compact

MongoDB 文档在磁盘上占据指定的空间。如果我们执行一个更新,增加了文档的大小,这可能会导致它被移出存储块的顺序,在存储中创建一个空洞,导致此更新的执行时间增加,并可能导致它在运行查询时被忽略。紧缩操作将对空间进行碎片整理,并减少使用的空间。

我们可以通过添加额外的 10 个字节来更新一个文档,展示它将如何被移动到存储块的末尾,并在物理存储中创建一个空间:

compact也可以接受paddingFactor参数,如下所示:

> db.runCommand ( { compact: '<collection>', paddingFactor: 2.0 } )

paddingFactor是每个文档中预分配的空间,范围从1.0(即没有填充,这是默认值)到4.0,用于计算每个文档空间的100字节所需的300字节填充。

添加填充可以帮助缓解更新移动文档的问题,但需要更多的磁盘空间来创建每个文档。通过为每个文档添加填充,我们为其分配了更多的空间,这将防止它被移动到预分配的存储空间的末尾,如果我们更新的文档仍然可以适应预分配的存储空间。

currentOp 和 killOp

db.currentOp()将显示数据库中当前正在运行的操作,并尝试终止它。在运行killOp()之前,我们需要运行use admin命令。毋庸置疑,不建议或建议使用killOp()来终止内部 MongoDB 操作,因为数据库可能会处于未定义的状态。killOp()命令可以如下使用:

> db.runCommand( { "killOp": 1, "op": <operationId> } )

collMod

collMod用于通过修改底层数据库的行为来向集合传递标志。

自版本 3.2 以来,我们可以传递给集合的最有趣的一组标志是文档验证。

文档验证可以指定一组规则,应用于对集合的新更新和插入。这意味着如果修改了当前文档,将会对当前文档进行检查。

如果我们将validationLevel设置为moderate,我们只能对已经有效的文档应用验证。通过指定validationAction,我们可以通过将其设置为warn来记录无效的文档,或者通过将其设置为error来完全阻止更新。

例如,对于之前的bookOrders示例,我们可以在每次插入或更新时设置isbnname字段的validator,如下面的代码所示:

> db.runCommand( { collMod: "bookOrders",
"validator" : {
 "$and" : [
 {
 "isbn" : {
 "$exists" : true
 }
 },
 {
 "name" : {
 "$exists" : true
 }
 }
 ]
 }
})

在这里,我们得到了以下代码:

{ "ok" : 1 }

然后,如果我们尝试插入一个只有isbn字段的新文档,我们会收到一个错误:

> db.bookOrders.insert({isbn: 102})
WriteResult({
"nInserted" : 0,
"writeError" : {
"code" : 121,
"errmsg" : "Document failed validation"
}
})
>

我们收到错误是因为我们的验证失败了。从 shell 中管理验证非常有用,因为我们可以编写脚本来管理它,并确保一切就位。

touch

touch命令将从存储中加载数据和/或索引数据到内存中。如果我们的脚本随后将使用这些数据,这通常是有用的,可以加快执行速度:

> db.runCommand({ touch: "bookOrders", data: true/false, index: true/false })

在生产系统中应谨慎使用此命令,因为将数据和索引加载到内存中将会将现有数据从中移除。

在 mongo shell 中的 MapReduce

在整个 MongoDB 历史中,一个被低估并且没有得到广泛支持的最有趣的功能之一,是能够在 shell 中原生地编写 MapReduce。

MapReduce 是一种从大量数据中获取聚合结果的数据处理方法。其主要优势在于它本质上是可并行化的,这可以通过 Hadoop 等框架来证明。

当用于实现数据管道时,MapReduce 非常有用。多个 MapReduce 命令可以链接在一起产生不同的结果。一个例子是通过使用不同的报告周期(如小时、天、周、月和年)对数据进行聚合,我们使用每个更精细的报告周期的输出来生成一个不太精细的报告。

在我们的例子中,MapReduce 的一个简单示例是,假设我们的输入书籍集合如下:

> db.books.find()
{ "_id" : ObjectId("592149c4aabac953a3a1e31e"), "isbn" : "101", "name" : "Mastering MongoDB", "price" : 30 }
{ "_id" : ObjectId("59214bc1aabac954263b24e0"), "isbn" : "102", "name" : "MongoDB in 7 years", "price" : 50 }
{ "_id" : ObjectId("59214bc1aabac954263b24e1"), "isbn" : "103", "name" : "MongoDB for experts", "price" : 40 }

我们的 map 和 reduce 函数定义如下:

> var mapper = function() {
 emit(this.id, 1);
 };

在这个mapper中,我们只是输出每个文档的id键和值1

> var reducer = function(id, count) {
 return Array.sum(count);
 };

reducer中,我们对所有值求和(每个值都是1):

> db.books.mapReduce(mapper, reducer, { out:"books_count" });
{
"result" : "books_count",
"timeMillis" : 16613,
"counts" : {
"input" : 3,
"emit" : 3,
"reduce" : 1,
"output" : 1
},
"ok" : 1
}
> db.books_count.find()
{ "_id" : null, "value" : 3 }
>

我们的最终输出将是一个没有 ID 的文档,因为我们没有输出任何 ID 的值,以及一个值为六的文档,因为输入数据集中有六个文档。

使用 MapReduce,MongoDB 将对每个输入文档应用映射,在映射阶段结束时发出键值对。然后,每个 reducer 将获得具有与输入相同键的键值对,处理所有多个值。reducer 的输出将是每个键的单个键值对。

可选地,我们可以使用finalize函数进一步处理mapperreducer的结果。MapReduce 函数使用 JavaScript 并在mongod进程中运行。MapReduce 可以作为单个文档内联输出,受到 16MB 文档大小限制的限制,或者作为输出集合中的多个文档输出。输入和输出集合可以进行分片。

MapReduce 并发

MapReduce 操作将放置几个短暂的锁,不应影响操作。然而,在reduce阶段结束时,如果我们将数据输出到现有集合,则mergereducereplace等输出操作将为整个服务器获取独占全局写锁,阻止对db实例的所有其他写入。如果我们想避免这种情况,那么我们应该以以下方式调用mapReduce

> db.collection.mapReduce(
 mapper,
 reducer,
 {
 out: { merge/reduce: bookOrders, nonAtomic: true  }
 })

我们只能将nonAtomic应用于mergereduce操作。replace将只是替换bookOrders中文档的内容,这也不会花费太多时间。

使用merge操作,如果输出集合已经存在,新结果将与现有结果合并。如果现有文档具有与新结果相同的键,则它将覆盖现有文档。

使用reduce操作,如果输出集合已经存在,新结果将与现有结果一起处理。如果现有文档具有与新结果相同的键,则它将对新文档和现有文档应用reduce函数,并用结果覆盖现有文档。

尽管 MapReduce 自 MongoDB 的早期版本以来就存在,但它的发展不如数据库的其他部分,导致其使用量不及专门的 MapReduce 框架(如 Hadoop)多,我们将在第十一章中更多地了解有关利用 MongoDB 进行大数据处理

增量 MapReduce

增量 MapReduce 是一种模式,我们使用 MapReduce 来聚合先前计算的值。一个例子是对不同的报告周期(即按小时、天或月)中的集合进行计数非不同用户,而无需每小时重新计算结果。

为了设置我们的数据进行增量 MapReduce,我们需要做以下工作:

  • 将我们的减少数据输出到不同的集合

  • 在每个小时结束时,只查询进入集合的数据

  • 使用我们减少的数据输出,将我们的结果与上一个小时的计算结果合并

继续上一个例子,假设我们的输入数据集中每个文档都有一个published字段,如下所示:

> db.books.find()
{ "_id" : ObjectId("592149c4aabac953a3a1e31e"), "isbn" : "101", "name" : "Mastering MongoDB", "price" : 30, "published" : ISODate("2017-06-25T00:00:00Z") }
{ "_id" : ObjectId("59214bc1aabac954263b24e0"), "isbn" : "102", "name" : "MongoDB in 7 years", "price" : 50, "published" : ISODate("2017-06-26T00:00:00Z") }

使用我们之前计算书籍数量的例子,我们将得到以下代码:

var mapper = function() {
 emit(this.id, 1);
 };
var reducer = function(id, count) {
 return Array.sum(count);
 };
> db.books.mapReduce(mapper, reducer, { out: "books_count" })
{
"result" : "books_count",
"timeMillis" : 16700,
"counts" : {
"input" : 2,
"emit" : 2,
"reduce" : 1,
"output" : 1
},
"ok" : 1
}
> db.books_count.find()
{ "_id" : null, "value" : 2 }

现在我们在我们的mongo_book集合中得到了第三本书,内容如下:

{ "_id" : ObjectId("59214bc1aabac954263b24e1"), "isbn" : "103", "name" : "MongoDB for experts", "price" : 40, "published" : ISODate("2017-07-01T00:00:00Z") }
> db.books.mapReduce( mapper, reducer, { query: { published: { $gte: ISODate('2017-07-01 00:00:00') } }, out: { reduce: "books_count" } } )
> db.books_count.find()
{ "_id" : null, "value" : 3 }

在前面的代码中发生的是,通过查询 2017 年 7 月的文档,我们只得到了查询中的新文档,然后使用它的值将值与我们的books_count文档中已经计算的值2进行减少,将1添加到最终的3文档的总和中。

这个例子虽然有些牵强,但展示了 MapReduce 的一个强大特性:能够重新减少结果以逐渐计算聚合。

故障排除 MapReduce

多年来,MapReduce 框架的主要缺点之一是与简单的非分布式模式相比,故障排除的固有困难。大多数时候,最有效的工具是使用log语句进行调试,以验证输出值是否与我们预期的值匹配。在 mongo shell 中,这是一个 JavaScript shell,只需使用console.log()函数提供输出即可。

深入了解 MongoDB 中的 MapReduce,我们可以通过重载输出值来调试映射和减少阶段。

通过调试mapper阶段,我们可以重载“emit()”函数来测试输出键值将是什么,如下所示:

> var emit = function(key, value) {
 print("debugging mapper's emit");
 print("key: " + key + "  value: " + tojson(value));
}

然后我们可以手动调用它来验证我们得到了预期的键值对:

> var myDoc = db.orders.findOne( { _id: ObjectId("50a8240b927d5d8b5891743c") } );
> mapper.apply(myDoc);

reducer函数有点复杂。MapReducereducer函数必须满足以下标准:

  • 它必须是幂等的

  • 它必须是可交换的

  • 来自mapper函数的值的顺序对于减少器的结果并不重要

  • reducer函数必须返回与mapper函数相同类型的结果

我们将分解以下每个要求,以了解它们真正的含义:

  • 它必须是幂等的:MapReduce 的设计可能会多次调用reducer函数,对于来自mapper阶段的相同键的多个值。它也不需要减少键的单个实例,因为它只是添加到集合中。无论执行顺序如何,最终值应该是相同的。这可以通过编写我们自己的verifier函数并强制reducer重新减少,或者像下面的代码片段中所示执行多次reducer来验证:
reduce( key, [ reduce(key, valuesArray) ] ) == reduce( key, valuesArray )
  • 它必须是可交换的:由于对于相同的“键”,可能会多次调用reducer函数,如果它有多个值,以下代码应该成立:
reduce(key, [ C, reduce(key, [ A, B ]) ] ) == reduce( key, [ C, A, B ] )
  • 来自 mapper 函数的值的顺序对于 reducer 的结果并不重要:我们可以测试mapper的值的顺序是否改变了reducer的输出,通过以不同的顺序将文档传递给mapper并验证我们得到了相同的结果:
reduce( key, [ A, B ] ) == reduce( key, [ B, A ] )
  • 减少函数必须返回与映射函数相同类型的结果:与第一个要求紧密相关,reduce函数返回的对象类型应与mapper函数的输出相同。

聚合框架

自 2.2 版本以来,MongoDB 提供了一种更好的处理聚合的方式,这种方式一直得到支持、采用和定期增强。聚合框架是模仿数据处理管道的。

在数据处理管道中,有三个主要操作:像查询一样操作的过滤器,过滤文档,以及文档转换,以准备好进行下一阶段的转换。

SQL 到聚合

聚合管道可以在 shell 中替换和增强查询操作。开发的常见模式如下:

  • 验证我们是否有正确的数据结构,并使用一系列 shell 中的查询快速获得结果

  • 使用聚合框架原型管道结果

  • 根据需要进行细化和重构,可以通过 ETL 过程将数据放入专用数据仓库,也可以通过更广泛地使用应用程序层来获得所需的见解

在下表中,我们可以看到 SQL 命令如何映射到聚合框架操作符:

SQL 聚合框架
WHERE / HAVING $match
分组 $group
选择 $project
ORDER BY $sort
LIMIT $limit
sum() / count() $sum
连接 $lookup

聚合与 MapReduce

在 MongoDB 中,我们可以通过三种方法从数据库中获取数据:查询、聚合框架和 MapReduce。这三种方法都可以相互链接,很多时候这样做是有用的;然而,重要的是要理解何时应该使用聚合,何时 MapReduce 可能是更好的选择。

我们可以在分片数据库中同时使用聚合和 MapReduce。

聚合基于管道的概念。因此,能够对我们的数据进行建模,从输入到最终输出,在一系列的转换和处理中,可以让我们达到目标,这一点非常重要。当我们的中间结果可以单独使用或者供并行管道使用时,它也是非常有用的。我们的操作受到来自 MongoDB 的可用操作符的限制,因此确保我们可以使用可用的命令计算出所有需要的结果非常重要。

另一方面,MapReduce 可以通过将一个 MapReduce 作业的输出链接到下一个作业的输入,通过一个中间集合来构建管道,但这不是它的主要目的。

MapReduce 最常见的用例是定期计算大型数据集的聚合。有了 MongoDB 的查询,我们可以增量计算这些聚合,而无需每次都扫描整个输入表。此外,它的强大之处在于其灵活性,我们可以使用 JavaScript 定义映射器和减速器,完全灵活地计算中间结果。由于没有聚合框架提供的操作符,我们必须自己实现它们。

在许多情况下,答案不是二选一。我们可以(也应该)使用聚合框架来构建 ETL 管道,并在尚未得到足够支持的部分使用 MapReduce。

在《第六章》《聚合》中提供了一个完整的聚合和 MapReduce 用例。

保护 shell

MongoDB 是一个以开发便利性为目标开发的数据库。因此,数据库级别的安全性并不是从一开始就内置的,而是由开发人员和管理员来保护 MongoDB 主机不被外部应用服务器访问。

不幸的是,这意味着早在 2015 年,就发现有 39,890 个数据库对外开放,没有配置安全访问。其中许多是生产数据库,其中一个属于法国电信运营商,包含了超过 800 万条客户记录。

现在,没有任何借口可以让任何 MongoDB 服务器在任何开发阶段都保持默认的关闭认证设置。

认证和授权

认证和授权密切相关,有时会引起混淆。认证是验证用户对数据库的身份。认证的一个例子是安全套接字层(SSL),在这里,Web 服务器验证其身份——即它向用户所声称的身份。

授权是确定用户对资源可以执行哪些操作。在接下来的章节中,我们将根据这些定义讨论认证和授权。

MongoDB 的授权

MongoDB 最基本的授权依赖于用户名/密码方法。默认情况下,MongoDB 不会启用授权。要启用它,我们需要使用--auth参数启动服务器。

$ mongod --auth

为了设置授权,我们需要在没有授权的情况下启动服务器以设置用户。设置管理员用户很简单:

> use admin
> db.createUser(
 {
 user: <adminUser>,
 pwd: <password>,
 roles: [ { role: <adminRole>, db: "admin" } ]
 }
)

在这里,<adminUser>是我们要创建的用户的名称,<password>是密码,<adminRole>可以是以下列表中从最强大到最弱的任何一个值:

  • root

  • dbAdminAnyDatabase

  • userAdminAnyDatabase

  • readWriteAnyDatabase

  • readAnyDatabase

  • dbOwner

  • dbAdmin

  • userAdmin

  • readWrite

  • read

在这些角色中,root是允许访问所有内容的超级用户。除了特殊情况,不建议使用这个角色。

所有的AnyDatabase角色都提供对所有数据库的访问权限,其中dbAdminAnyDatabase结合了userAdminAnyDatabasereadWriteAnyDatabase范围,再次成为所有数据库中的管理员。

其余的角色是在我们希望它们应用的数据库中定义的,通过更改前面的db.createUser()的角色子文档;例如,要为我们的mongo_book数据库创建dbAdmin,我们将使用以下代码:

> db.createUser(
 {
 user: <adminUser>,
 pwd: <password>,
 roles: [ { role: "dbAdmin", db: "mongo_book" } ]
 }
)

集群管理还有更多的角色,我们将在第十二章 复制中更深入地介绍。

最后,当我们使用--auth标志重新启动我们的数据库时,我们可以使用命令行或连接字符串(来自任何驱动程序)作为admin连接并创建具有预定义或自定义角色的新用户:

mongodb://[username:password@]host1[:port1][,host2[:port2],...[,hostN[:portN]]][/[database][?options]]

MongoDB 的安全提示

常见的软件系统安全预防措施也适用于 MongoDB。我们将在这里概述其中一些,并学习如何启用它们。

使用 TLS/SSL 加密通信

mongodmongos服务器与客户端 mongo shell 或应用程序之间的通信应该是加密的。这在大多数 MongoDB 发行版中从 3.0 版本开始就得到支持;但是,我们需要注意下载具有 SSL 支持的正确版本。

之后,我们需要从受信任的证书颁发机构获取签名证书,或者自己签名。对于预生产系统来说,使用自签名证书是可以的,但在生产中,这将意味着 MongoDB 服务器无法验证我们的身份,使我们容易受到中间人攻击的影响;因此强烈建议使用正确的证书。

要使用 SSL 启动我们的 MongoDB 服务器,我们需要以下代码:

$ mongod --sslMode requireSSL --sslPEMKeyFile <pem> --sslCAFile <ca>

在这里,<pem>是我们的.pem签名证书文件,<ca>是证书颁发机构的.pem根证书,其中包含根证书链。

这些选项也可以在我们的配置文件mongod.confmongos.conf中以 YAML 文件格式定义如下:

net:
  ssl:
     mode: requireSSL
     PEMKeyFile: /etc/ssl/mongodb.pem
     CAFile: /etc/ssl/ca.pem
     disabledProtocols: TLS1_0,TLS1_1,TLS1_2

在这里,我们指定了PEMKeyFileCAFile,并且我们不允许服务器使用TLS1_0TLS1_1TLS1_2版本的证书启动。这些是当前可用的disabledProtocols版本。

加密数据

使用 WiredTiger 强烈建议用于对数据进行加密,因为它从 3.2 版本开始就原生支持。

对于社区版的用户,可以在他们选择的存储中实现这一点;例如,在亚马逊网络服务AWS)中使用弹性块存储EBS)加密存储卷。

此功能仅适用于 MongoDB 企业版。

限制网络暴露

保护任何服务器的最古老的安全方法是禁止它接受来自未知来源的连接。在 MongoDB 中,这是在配置文件中通过一行简单的代码完成的,如下所示:

net:
  bindIp: <string>

在这里,<string>是 MongoDB 服务器将接受连接的 IP 的逗号分隔列表。

防火墙和 VPN

除了在服务器端限制网络暴露之外,我们还可以使用防火墙阻止外部互联网对我们网络的访问。VPN 也可以在我们的服务器之间提供隧道流量,但无论如何,它们都不应该作为我们唯一的安全机制。

审计

无论系统有多安全,我们都需要从审计的角度密切关注系统,以确保我们及时发现可能的违规行为并尽快停止它们。

此功能仅适用于 MongoDB 企业版。

对于社区版用户,我们必须通过在应用程序层记录文档和集合的更改来手动设置审计,可能在完全不同的数据库中。这将在下一章中讨论,该章节涵盖了使用客户端驱动程序进行高级查询。

使用安全配置选项

毫无疑问,应该使用相同的配置选项。我们必须使用以下之一:

  • MapReduce

  • mongo shell 组操作或来自客户端驱动程序的组操作

  • $where JavaScript 服务器评估

如果我们不这样做,我们应该在启动服务器时使用命令行上的--noscripting选项来禁用服务器端脚本。

如前面的列表中所述,mongo shell 组操作可能会有些棘手,因为许多驱动程序在发出组命令时可能会使用 MongoDB 的group()命令。然而,考虑到group()在性能和输出文档方面的限制,我们应该重新考虑我们的设计,使用聚合框架或应用程序端的聚合。

还必须通过不使用以下任何命令来禁用 Web 界面:

  • net.http.enabled

  • net.http.JSONPEnabled

  • net.http.RESTInterfaceEnabled

相反,wireObjectCheck需要保持默认启用,以确保mongod实例存储的所有文档都是有效的 BSON。

使用 MongoDB 进行身份验证

默认情况下,MongoDB 使用 SCRAM-SHA-1 作为默认的挑战和响应身份验证机制。这是一种基于 SHA-1 的用户名/密码身份验证机制。所有驱动程序和 mongo shell 本身都具有内置方法来支持它。

自 MongoDB 3.0 版本以来,MongoDB 中的身份验证协议已经发生了变化。在旧版本中,使用了不太安全的 MONGODB-CR。

企业版

MongoDB 的企业版是一种付费订阅产品,提供了更多关于安全性和管理的功能。

Kerberos 身份验证

MongoDB 企业版还提供 Kerberos 身份验证。Kerberos 是根据希腊神话中的角色 Kerberos(或 Cerberus)命名的,它是地府之神哈迪斯的凶猛的三头看门犬,专注于客户端和服务器之间的相互认证,防止窃听和重放攻击。

Kerberos 在 Windows 系统中广泛使用,通过与微软的 Active Directory 集成。要安装 Kerberos,我们需要启动未设置 Kerberos 的mongod,然后连接到$external数据库(而不是我们通常用于管理授权的 admin),并创建具有 Kerberos 角色和权限的用户:

use $external
db.createUser(
  {
    user: "mongo_book_user@packt.net",
    roles: [ { role: "read", db: "mongo_book" } ]
  }
)

在上面的示例中,我们授权mongo_book_user@packt.net用户读取我们的mongo_book数据库,就像我们在管理系统中使用用户一样。

之后,我们需要通过传递authenticationMechanisms参数来启动支持 Kerberos 的服务器,如下所示:

--setParameter authenticationMechanisms=GSSAPI

现在我们可以从我们的服务器或命令行连接,如下所示:

$ mongo.exe --host <mongoserver> --authenticationMechanism=GSSAPI --authenticationDatabase='$external' --username mongo_book_user@packt.net

LDAP 身份验证

与 Kerberos 身份验证类似,我们也只能在 MongoDB 企业版中使用轻量级目录访问协议(LDAP)。用户设置必须在$external数据库中完成,并且必须与身份验证 LDAP 名称匹配。名称可能需要经过转换,这可能会导致 LDAP 名称与$external数据库中的用户条目不匹配。

设置 LDAP 身份验证超出了本书的范围,但需要考虑的重要事情是 LDAP 服务器的任何更改可能需要对 MongoDB 服务器进行更改,这不会自动发生。

摘要

在本章中,我们只是触及了 CRUD 操作的冰山一角。从 mongo shell 开始,我们学习了如何插入、删除、读取和修改文档。我们还讨论了一次性插入和批量插入的性能差异。

接下来,我们讨论了管理任务以及如何在 mongo shell 中执行它们。本章还讨论了 MapReduce 及其后继者聚合框架,包括它们的比较、如何使用它们以及如何将 SQL 查询转换为聚合框架管道命令。

最后,我们讨论了 MongoDB 的安全性和认证。保护我们的数据库至关重要;我们将在第八章《监控、备份和安全》中学到更多内容。

在下一章中,我们将深入探讨使用三种最流行的 Web 开发语言进行 CRUD 操作:Ruby、Python 和 PHP(超文本预处理器)。

第四章:高级查询

在上一章中,我们学习了如何以安全的方式使用 mongo shell 进行脚本编写、管理和开发。在本章中,我们将更深入地使用来自 Ruby,Python 和 PHP 的驱动程序和流行框架与 MongoDB 一起使用:超文本预处理器PHP)。

我们还将展示使用这些语言的最佳实践以及 MongoDB 在数据库级别支持的各种比较和更新运算符,这些运算符可以通过 Ruby,Python 和 PHP 访问。

在本章中,我们将学习以下主题:

  • MongoDB 操作

  • 使用 Ruby,Mongoid,Python,PyMODM,PHP 和 Doctrine 进行 CRUD

  • 比较运算符

  • 更改流

MongoDB CRUD 操作

在本节中,我们将分别使用官方 MongoDB 驱动程序和每种语言的一些流行框架,使用 Ruby,Python 和 PHP 进行 CRUD 操作。

使用 Ruby 驱动程序进行 CRUD

在第三章中,MongoDB CRUD 操作,我们介绍了如何使用驱动程序和 ODM 从 Ruby,Python 和 PHP 连接到 MongoDB。在本章中,我们将探索使用官方驱动程序和最常用的 ODM 框架进行createreadupdatedelete操作。

创建文档

使用第二章中描述的过程,模式设计和数据建模,我们假设我们有一个@collection实例变量,指向mongo_book数据库中127.0.0.1:27017默认数据库中的books集合:

@collection = Mongo::Client.new([ '127.0.0.1:27017' ], :database => 'mongo_book').database[:books]

我们插入一个具有我们定义的单个文档,如下所示:

document = { isbn: '101', name: 'Mastering MongoDB', price: 30}

这可以通过一行代码执行如下:

result = @collection.insert_one(document)

生成的对象是Mongo::Operation::Result类,其内容与我们在 shell 中看到的内容相似,如下面的代码所示:

{"n"=>1, "ok"=>1.0}

这里,n是受影响的文档数量;1表示我们插入了一个对象,ok表示1true)。

在一步中创建多个文档与此类似。对于具有isbn 102103的两个文档,并且使用insert_many而不是insert_one,我们有以下代码:

documents = [ { isbn: '102', name: 'MongoDB in 7 years', price: 50 },
            { isbn: '103', name: 'MongoDB for experts', price: 40 } ]
result = @collection.insert_many(documents)

生成的对象现在是Mongo::BulkWrite::Result类,这意味着使用了BulkWrite接口以提高性能。

主要区别在于我们现在有一个属性inserted_ids,它将返回从BSON::ObjectId类中插入的对象的ObjectId

读取

查找文档的工作方式与创建它们的方式相同,即在集合级别:

@collection.find( { isbn: '101' } )

可以链接多个搜索条件,并且相当于 SQL 中的AND运算符:

@collection.find( { isbn: '101', name: 'Mastering MongoDB' } )

mongo-ruby-driver API 提供了几个查询选项来增强查询;最常用的查询选项列在以下表中:

选项 描述
allow_partial_results 这是用于分片集群。如果一个分片关闭,它允许查询从打开的分片返回结果,可能只得到部分结果。
batch_size(Integer) 这可以改变游标从 MongoDB 获取的批量大小。这是在每个GETMORE操作(例如,在 mongo shell 上输入)上完成的。
comment(String) 使用此命令,我们可以为了文档目的在我们的查询中添加注释。
hint(Hash) 我们可以使用hint()强制使用索引。
limit(Integer) 我们可以将结果集限制为Integer指定的文档数量。
max_scan(Integer) 我们可以限制将被扫描的文档数量。这将返回不完整的结果,并且在执行我们希望保证不会花费很长时间的操作时非常有用,例如当我们连接到我们的生产数据库时。
no_cursor_timeout 如果我们不指定此参数,MongoDB 将在 600 秒后关闭任何不活动的游标。使用此参数,我们的游标将永远不会关闭。
projection(Hash) 我们可以使用这个参数来获取或排除结果中的特定属性。这将减少通过网络的数据传输。例如,client[:books].find.projection(:price => 1)
read(Hash) 我们可以指定一个读取偏好,仅应用于此查询:client[:books].find.read(:mode => :secondary_preferred)
show_disk_loc(Boolean) 如果我们想要查找结果在磁盘上的实际位置,应该使用此选项。
skip(Integer) 这可以用于跳过指定数量的文档。对于结果的分页很有用。
snapshot 这可以用于以快照模式执行我们的查询。当我们需要更严格的一致性时,这是很有用的。
sort(Hash) 我们可以使用这个来对结果进行排序,例如,client[:books].find.sort(:name => -1)

除了查询选项之外,mongo-ruby-driver 还提供了一些辅助函数,可以在方法调用级别进行链接,如下所示:

  • .count:前面查询的总计数

  • .distinct(:field_name):通过 :field_name 区分前面查询的结果

Find() 返回一个包含结果集的游标,我们可以像其他对象一样在 Ruby 中使用 .each 进行迭代:

result = @collection.find({ isbn: '101' })
result.each do |doc|
  puts doc.inspect
end

我们的 books 集合的输出如下:

{"_id"=>BSON::ObjectId('592149c4aabac953a3a1e31e'), "isbn"=>"101", "name"=>"Mastering MongoDB", "price"=>30.0, "published"=>2017-06-25 00:00:00 UTC}

在 find() 中链接操作

find() 默认使用 AND 运算符来匹配多个字段。如果我们想使用 OR 运算符,我们的查询需要如下所示:

result = @collection.find('$or' => [{ isbn: '101' }, { isbn: '102' }]).to_a
puts result

前面代码的输出如下:

{"_id"=>BSON::ObjectId('592149c4aabac953a3a1e31e'), "isbn"=>"101", "name"=>"Mastering MongoDB", "price"=>30.0, "published"=>2017-06-25 00:00:00 UTC}{"_id"=>BSON::ObjectId('59214bc1aabac954263b24e0'), "isbn"=>"102", "name"=>"MongoDB in 7 years", "price"=>50.0, "published"=>2017-06-26 00:00:00 UTC}

在前面的示例中,我们也可以使用 $and 而不是 $or

result = @collection.find('$and' => [{ isbn: '101' }, { isbn: '102' }]).to_a
puts result

当然,这将不返回任何结果,因为没有文档可以同时具有 isbn 101102

一个有趣且难以发现的 bug 是如果我们多次定义相同的键,就像以下代码中一样:

result = @collection.find({ isbn: '101', isbn: '102' })
puts result
{"_id"=>BSON::ObjectId('59214bc1aabac954263b24e0'), "isbn"=>"102", "name"=>"MongoDB in 7 years", "price"=>50.0, "published"=>2017-06-26 00:00:00 UTC}

相反的顺序将导致返回带有 isbn 101 的文档:

result = @collection.find({ isbn: '102', isbn: '101' })
puts result
{"_id"=>BSON::ObjectId('592149c4aabac953a3a1e31e'), "isbn"=>"101", "name"=>"Mastering MongoDB", "price"=>30.0, "published"=>2017-06-25 00:00:00 UTC}

这是因为在 Ruby 哈希中,默认情况下,除了最后一个之外,所有重复的键都会被静默忽略。这可能不会发生在前面示例中所显示的简单形式中,但如果我们以编程方式创建键,这种情况很容易发生。

嵌套操作

在 mongo-ruby-driver 中访问嵌入式文档就像使用点表示法一样简单:

result = @collection.find({'meta.authors': 'alex giamas'}).to_a
puts result
"_id"=>BSON::ObjectId('593c24443c8ca55b969c4c54'), "isbn"=>"201", "name"=>"Mastering MongoDB, 2nd Edition", "meta"=>{"authors"=>"alex giamas"}}

我们需要用引号 ('') 括起键名来访问嵌入对象,就像我们需要对以 $ 开头的操作一样,比如 '$set'

更新

使用 mongo-ruby-driver 更新文档是通过查找它们进行链接的。使用我们的示例 books 集合,我们可以执行以下操作:

@collection.update_one( { 'isbn': 101}, { '$set' => { name: 'Mastering MongoDB, 2nd Edition' } } )

这将找到具有 isbn 101 的文档,并将其名称更改为 Mastering MongoDB, 2nd Edition

类似于 update_one,我们可以使用 update_many 来更新通过方法的第一个参数检索到的多个文档。

如果我们不使用 $set 运算符,文档的内容将被新文档替换。

假设 Ruby 版本 >=2.2,键可以是带引号或不带引号的;但是,以 $ 开头的键需要按如下方式带引号:

@collection.update( { isbn: '101'}, { "$set": { name: "Mastering MongoDB, 2nd edition" } } )

更新后的对象将包含有关操作的信息,包括以下方法:

  • ok?:一个布尔值,显示操作是否成功

  • matched_count:匹配查询的文档数量

  • modified_count:受影响的文档数量(已更新)

  • upserted_count:如果操作包括 $set,则插入的文档数量

  • upserted_id:如果有的话,插入文档的唯一 ObjectId

修改字段大小恒定的更新将是 原地 进行的;这意味着它们不会将文档从物理位置上移动。这包括对 IntegerDate 字段进行的操作,如 $inc$set

可能会导致文档大小增加的更新可能会导致文档从磁盘上的物理位置移动到文件末尾的新位置。在这种情况下,查询可能会错过或多次返回文档。为了避免这种情况,我们可以在查询时使用$snapshot: true

删除

删除文档的工作方式与查找文档类似。我们需要找到文档,然后应用删除操作。

例如,对于我们之前使用的books集合,我们可以发出以下代码:

@collection.find( { isbn: '101' } ).delete_one

这将删除单个文档。在我们的情况下,由于每个文档的isbn都是唯一的,这是预期的。如果我们的find()子句匹配了多个文档,那么delete_one将只删除find()返回的第一个文档,这可能是我们想要的,也可能不是。

如果我们使用delete_one与匹配多个文档的查询,结果可能会出乎意料。

如果我们想要删除与我们的find()查询匹配的所有文档,我们必须使用delete_many,如下所示:

@collection.find( { price: { $gte: 30 } ).delete_many

在前面的示例中,我们正在删除所有价格大于或等于30的书籍。

批量操作

我们可以使用BulkWrite API 进行批量操作。在我们之前插入多个文档的示例中,操作如下:

@collection.bulk_write([ { insertMany: documents
                     }],
                   ordered: true)

BulkWrite API 可以接受以下参数:

  • insertOne

  • updateOne

  • updateMany

  • replaceOne

  • deleteOne

  • deleteMany

这些命令的一个版本将insert/update/replace/delete单个文档,即使我们指定的过滤器匹配多个文档。在这种情况下,为了避免意外行为,重要的是要有一个匹配单个文档的过滤器。

bulk_write命令的第一个参数中包含多个操作也是可能的,也是一个完全有效的用例。这允许我们在有相互依赖的操作并且我们想要根据业务逻辑批量处理它们的情况下按照逻辑顺序发出命令。任何错误都将停止ordered:true批量写入,我们将需要手动回滚我们的操作。一个值得注意的例外是writeConcern错误,例如,请求我们的副本集成员中的大多数确认我们的写入。在这种情况下,批量写入将继续进行,我们可以在writeConcernErrors结果字段中观察到错误:

old_book = @collection.findOne(name: 'MongoDB for experts')
new_book = { isbn: 201, name: 'MongoDB for experts, 2nd Edition', price: 55 }
@collection.bulk_write([ {deleteOne: old_book}, { insertOne: new_book
                     }],
                   ordered: true)

在前面的示例中,我们确保在添加新的(更昂贵的)版本我们的MongoDB for experts书之前删除了原始书籍。

BulkWrite可以批处理最多 1,000 个操作。如果我们的命令中有超过 1,000 个基础操作,这些操作将被分成数千个块。如果可能的话,最好尽量将写操作保持在一个批次中,以避免意外行为。

Mongoid 中的 CRUD

在本节中,我们将使用 Mongoid 执行createreadupdatedelete操作。所有这些代码也可以在 GitHub 上找到:github.com/agiamas/mastering-mongodb/tree/master/chapter_4

读取

回到第二章模式设计和数据建模中,我们描述了如何安装、连接和设置模型,包括对 Mongoid 的继承。在这里,我们将介绍 CRUD 的最常见用例。

使用类似于Active RecordAR)的 DSL 来查找文档。与使用关系数据库的 AR 一样,Mongoid 将一个类分配给一个 MongoDB 集合(表),并将任何对象实例分配给一个文档(关系数据库中的行):

Book.find('592149c4aabac953a3a1e31e')

这将通过ObjectId查找文档并返回具有isbn 101的文档,与通过名称属性进行的查询一样:

Book.where(name: 'Mastering MongoDB')

与通过属性动态生成的 AR 查询类似,我们可以使用辅助方法:

Book.find_by(name: 'Mastering MongoDB')

这通过属性名称查询,相当于之前的查询。

我们应该启用QueryCache以避免多次命中数据库相同的查询,如下所示:

Mongoid::QueryCache.enabled = true

这可以添加在我们想要启用的任何代码块中,或者在 Mongoid 的初始化程序中。

范围查询

我们可以使用类方法在 Mongoid 中范围查询,如下所示:

Class Book
...
  def self.premium
     where(price: {'$gt': 20'})
   end
End

然后我们将使用这个查询:

Book.premium

它将查询价格大于 20 的书籍。

创建、更新和删除

用于创建文档的 Ruby 接口类似于活动记录:

Book.where(isbn: 202, name: 'Mastering MongoDB, 3rd Edition').create

如果创建失败,这将返回错误。

我们可以使用感叹号版本来强制引发异常,如果保存文档失败:

Book.where(isbn: 202, name: 'Mastering MongoDB, 3rd Edition').create!

截至 Mongoid 版本 6.x,不支持BulkWrite API。解决方法是使用 mongo-ruby-driver API,它不会使用mongoid.yml配置或自定义验证。否则,您可以使用insert_many([array_of_documents]),它将逐个插入文档。

要更新文档,我们可以使用updateupdate_all。使用update将仅更新查询部分检索到的第一个文档,而update_all将更新所有文档:

Book.where(isbn: 202).update(name: 'Mastering MongoDB, THIRD Edition')
Book.where(price: { '$gt': 20 }).update_all(price_range: 'premium')

删除文档类似于创建文档,提供delete以跳过回调,以及如果我们想要执行受影响文档中的任何可用回调,则使用destroy

delete_alldestroy_all是用于多个文档的便捷方法。

如果可能的话,应该避免使用destroy_all,因为它将加载所有文档到内存中以执行回调,因此可能会占用大量内存。

使用 Python 驱动程序进行 CRUD

PyMongo 是 MongoDB 官方支持的 Python 驱动程序。在本节中,我们将使用 PyMongo 在 MongoDB 中创建、读取、更新和删除文档。

创建和删除

Python 驱动程序提供了与 Ruby 和 PHP 一样的 CRUD 方法。在第二章“模式设计和数据建模”之后,指向我们的books集合的books变量,我们将编写以下代码块:

from pymongo import MongoClient
from pprint import pprint

>>> book = {
 'isbn': '301',
 'name': 'Python and MongoDB',
 'price': 60
}
>>> insert_result = books.insert_one(book)
>>> pprint(insert_result)

<pymongo.results.InsertOneResult object at 0x104bf3370>

>>> result = list(books.find())
>>> pprint(result)

[{u'_id': ObjectId('592149c4aabac953a3a1e31e'),
 u'isbn': u'101',
 u'name': u'Mastering MongoDB',
 u'price': 30.0,
 u'published': datetime.datetime(2017, 6, 25, 0, 0)},
{u'_id': ObjectId('59214bc1aabac954263b24e0'),
 u'isbn': u'102',
 u'name': u'MongoDB in 7 years',
 u'price': 50.0,
 u'published': datetime.datetime(2017, 6, 26, 0, 0)},
{u'_id': ObjectId('593c24443c8ca55b969c4c54'),
 u'isbn': u'201',
 u'meta': {u'authors': u'alex giamas'},
 u'name': u'Mastering MongoDB, 2nd Edition'},
{u'_id': ObjectId('594061a9aabac94b7c858d3d'),
 u'isbn': u'301',
 u'name': u'Python and MongoDB',
 u'price': 60}]

在前面的示例中,我们使用insert_one()来插入单个文档,我们可以使用 Python 字典表示法来定义它;然后我们可以查询它以获取集合中的所有文档。

insert_one的结果对象和insert_many的结果对象有两个感兴趣的字段:

  • Acknowledged:如果插入成功则为true,如果插入失败则为false,或者写关注为0(即插即忘写)。

  • inserted_id对于insert_one:写入文档的ObjectIdinserted_ids对于insert_many:写入文档的ObjectIds数组。

我们使用pprint库对find()结果进行漂亮打印。通过使用以下代码来迭代结果集的内置方式:

for document in results:
   print(document)

删除文档的工作方式与创建它们类似。我们可以使用delete_one来删除第一个实例,或者使用delete_many来删除匹配查询的所有实例:

>>> result = books.delete_many({ "isbn": "101" })
>>> print(result.deleted_count)
1

deleted_count实例告诉我们删除了多少个文档;在我们的案例中,它是1,即使我们使用了delete_many方法。

要从集合中删除所有文档,我们可以传入空文档{}

要删除集合,我们可以使用drop()

>>> books.delete_many({})
>>> books.drop()

查找文档

要根据顶级属性查找文档,我们可以简单地使用字典:

>>> books.find({"name": "Mastering MongoDB"})

[{u'_id': ObjectId('592149c4aabac953a3a1e31e'),
 u'isbn': u'101',
 u'name': u'Mastering MongoDB',
 u'price': 30.0,
 u'published': datetime.datetime(2017, 6, 25, 0, 0)}]

要在嵌入文档中查找文档,我们可以使用点表示法。在下面的示例中,我们使用meta.authors来访问meta文档内的authors嵌入文档:

>>> result = list(books.find({"meta.authors": {"$regex": "aLEx", "$options": "i"}}))
>>> pprint(result)

[{u'_id': ObjectId('593c24443c8ca55b969c4c54'),
 u'isbn': u'201',
 u'meta': {u'authors': u'alex giamas'},
 u'name': u'Mastering MongoDB, 2nd Edition'}]

在此示例中,我们使用正则表达式来匹配aLEx,它是不区分大小写的,在meta.authors嵌入文档中提到字符串的每个文档中。PyMongo 在 MongoDB 文档中称之为正则表达式查询的$regex表示法。第二个参数是$regex的选项参数,我们将在本章后面的“使用正则表达式”部分详细解释。

还支持比较运算符,完整列表将在本章后面的“比较运算符”部分中给出:

>>> result = list(books.find({ "price": {  "$gt":40 } }))
>>> pprint(result)

[{u'_id': ObjectId('594061a9aabac94b7c858d3d'),
 u'isbn': u'301',
 u'name': u'Python and MongoDB',
 u'price': 60}]

在我们的查询中添加多个字典会导致逻辑AND查询:

>>> result = list(books.find({"name": "Mastering MongoDB", "isbn": "101"}))
>>> pprint(result)

[{u'_id': ObjectId('592149c4aabac953a3a1e31e'),
 u'isbn': u'101',
 u'name': u'Mastering MongoDB',
 u'price': 30.0,
 u'published': datetime.datetime(2017, 6, 25, 0, 0)}]

对于同时具有isbn=101name=Mastering MongoDB的书籍,要使用$or$and等逻辑运算符,我们必须使用以下语法:

>>> result = list(books.find({"$or": [{"isbn": "101"}, {"isbn": "102"}]}))
>>> pprint(result)

[{u'_id': ObjectId('592149c4aabac953a3a1e31e'),
 u'isbn': u'101',
 u'name': u'Mastering MongoDB',
 u'price': 30.0,
 u'published': datetime.datetime(2017, 6, 25, 0, 0)},
{u'_id': ObjectId('59214bc1aabac954263b24e0'),
 u'isbn': u'102',
 u'name': u'MongoDB in 7 years',
 u'price': 50.0,
 u'published': datetime.datetime(2017, 6, 26, 0, 0)}]

对于具有isbn101102的书籍,如果我们想要结合ANDOR运算符,我们必须使用$and运算符,如下所示:

>>> result = list(books.find({"$or": [{"$and": [{"name": "Mastering MongoDB", "isbn": "101"}]}, {"$and": [{"name": "MongoDB in 7 years", "isbn": "102"}]}]}))
>>> pprint(result)
[{u'_id': ObjectId('592149c4aabac953a3a1e31e'),
 u'isbn': u'101',
 u'name': u'Mastering MongoDB',
 u'price': 30.0,
 u'published': datetime.datetime(2017, 6, 25, 0, 0)},
{u'_id': ObjectId('59214bc1aabac954263b24e0'),
 u'isbn': u'102',
 u'name': u'MongoDB in 7 years',
 u'price': 50.0,
 u'published': datetime.datetime(2017, 6, 26, 0, 0)}]

对于两个查询之间的OR结果,请考虑以下内容:

  • 第一个查询是要求具有isbn=101 AND name=Mastering MongoDB的文档

  • 第二个查询是要求在 7 年内具有isbn=102 AND name=MongoDB的文档。

  • 结果是这两个数据集的并集

更新文档

在下面的代码块中,您可以看到使用update_one辅助方法更新单个文档的示例。

此操作在搜索阶段匹配一个文档,并根据要应用于匹配文档的操作修改一个文档:

>>> result = books.update_one({"isbn": "101"}, {"$set": {"price": 100}})
>>> print(result.matched_count)
1
>>> print(result.modified_count)
1

类似于插入文档时,更新文档时,我们可以使用update_oneupdate_many

  • 这里的第一个参数是匹配将要更新的文档的过滤文档

  • 第二个参数是要应用于匹配文档的操作

  • 第三个(可选)参数是使用upsert=false(默认值)或true,用于在找不到文档时创建新文档

另一个有趣的参数是bypass_document_validation=false(默认值)或true,这是可选的。这将忽略集合中文档的验证(如果有的话)。

结果对象将具有matched_count,表示匹配过滤查询的文档数量,以及modified_count,表示受update部分影响的文档数量。

在我们的示例中,我们通过$set更新运算符为具有isbn=101的第一本书设置price=100。所有更新运算符的列表将在本章后面的更新运算符部分中显示。

如果我们不使用更新运算符作为第二个参数,匹配文档的内容将完全被新文档替换。

使用 PyMODM 进行 CRUD

PyMODM 是一个核心 ODM,提供简单且可扩展的功能。它由 MongoDB 的工程师开发和维护,他们可以获得最新稳定版本的快速更新和支持。

在第二章模式设计和数据建模中,我们探讨了如何定义不同的模型并连接到 MongoDB。使用 PyMODM 进行 CRUD,与使用低级驱动程序相比,更简单。

创建文档

可以使用单行代码创建一个新的user对象,如第二章模式设计和数据建模中所定义的:

>>> user = User('alexgiamas@packt.com', 'Alex', 'Giamas').save()

在这个例子中,我们按照user模型中定义的顺序使用位置参数来为user模型属性赋值。

我们也可以使用关键字参数或两者的混合,如下所示:

>>> user = User(email='alexgiamas@packt.com', 'Alex', last_name='Giamas').save()

可以通过将用户数组传递给bulk_create()来进行批量保存:

>>> users = [ user1, user2,...,userN]
>>>  User.bulk_create(users)

更新文档

我们可以通过直接访问属性并再次调用save()来修改文档:

>>> user.first_name = 'Alexandros'
>>> user.save()

如果我们想要更新一个或多个文档,我们必须使用raw()来过滤将受影响的文档,并链接update()来设置新值:

>>> User.objects.raw({'first_name': {'$exists': True}})
              .update({'$set': {'updated_at': datetime.datetime.now()}})

在上面的示例中,我们搜索所有具有名字的User文档,并设置一个新字段updated_at为当前时间戳。raw()方法的结果是QuerySet,这是 PyMODM 中用于处理查询和批量处理文档的类。

删除文档

删除 API 与更新 API 类似-通过使用QuerySet查找受影响的文档,然后链接.delete()方法来删除它们:

>>> User.objects.raw({'first_name': {'$exists': True}}).delete()

在撰写本书时(2018 年 12 月),BulkWriteAPI 仍不受支持,相关的票号为 PYMODM-43。例如bulk_create()方法将在幕后向数据库发出多个命令。

查询文档

查询是使用QuerySet进行的,如在updatedelete操作之前所述。

一些可用的便利方法包括以下内容:

  • all()

  • count()

  • first()

  • exclude(*fields) 从结果中排除一些字段

  • only(*fields) 只包括结果中的一些字段(可以链接以获取字段的并集)

  • limit(limit)

  • order_by(ordering)

  • reverse() 如果我们想要反转order_by()的顺序

  • skip(number)

  • values() 返回 Python 字典实例而不是模型实例

通过使用raw(),我们可以使用与前面 PyMongo 部分中描述的相同查询,同时利用 ODM 层提供的灵活性和便利方法。

使用 PHP 驱动程序进行 CRUD

在 PHP 中,有一个名为mongo-php-library的新驱动程序,应该代替已弃用的 MongoClient。总体架构在第二章模式设计和数据建模中有解释。在这里,我们将介绍 API 的更多细节以及如何使用它执行 CRUD 操作。

创建和删除

以下命令将插入一个包含两个键/值对数组的单个$document

$document = array( "isbn" => "401", "name" => "MongoDB and PHP" );
$result = $collection->insertOne($document);
var_dump($result);

var_dump($result)命令的输出如下所示:

MongoDB\InsertOneResult Object
(
   [writeResult:MongoDB\InsertOneResult:private] => MongoDB\Driver\WriteResult Object
       (
           [nInserted] => 1
           [nMatched] => 0
           [nModified] => 0
           [nRemoved] => 0
           [nUpserted] => 0
           [upsertedIds] => Array
               (
               )

           [writeErrors] => Array
               (
               )

           [writeConcernError] =>
           [writeConcern] => MongoDB\Driver\WriteConcern Object
               (
               )

       )

   [insertedId:MongoDB\InsertOneResult:private] => MongoDB\BSON\ObjectID Object
       (
           [oid] => 5941ac50aabac9d16f6da142
       )

   [isAcknowledged:MongoDB\InsertOneResult:private] => 1
)

这个相当冗长的输出包含了我们可能需要的所有信息。我们可以获取插入文档的ObjectId;通过以n为前缀的字段获取insertedmatchedmodifiedremovedupserted文档的数量;以及关于writeErrorwriteConcernError的信息。

如果我们想要获取信息,$result对象中还有一些便利方法:

  • $result->getInsertedCount(): 获取插入对象的数量

  • $result->getInsertedId(): 获取插入文档的ObjectId

我们还可以使用->insertMany()方法一次插入多个文档,如下所示:

$documentAlpha = array( "isbn" => "402", "name" => "MongoDB and PHP, 2nd Edition" );$documentBeta  = array( "isbn" => "403", "name" => "MongoDB and PHP, revisited" );
$result = $collection->insertMany([$documentAlpha, $documentBeta]);

print_r($result);

结果如下所示:

(
   [writeResult:MongoDB\InsertManyResult:private] => MongoDB\Driver\WriteResult Object
       (
           [nInserted] => 2
           [nMatched] => 0
           [nModified] => 0
           [nRemoved] => 0
           [nUpserted] => 0
           [upsertedIds] => Array
               (
               )

           [writeErrors] => Array
               (
               )

           [writeConcernError] =>
           [writeConcern] => MongoDB\Driver\WriteConcern Object
               (
               )

       )

   [insertedIds:MongoDB\InsertManyResult:private] => Array
       (
           [0] => MongoDB\BSON\ObjectID Object
               (
                   [oid] => 5941ae85aabac9d1d16c63a2
               )

           [1] => MongoDB\BSON\ObjectID Object
               (
                   [oid] => 5941ae85aabac9d1d16c63a3
               )

       )

   [isAcknowledged:MongoDB\InsertManyResult:private] => 1
)

再次,$result->getInsertedCount()将返回2,而$result->getInsertedIds()将返回一个包含两个新创建的ObjectIds的数组:

array(2) {
 [0]=>
 object(MongoDB\BSON\ObjectID)#13 (1) {
   ["oid"]=>
   string(24) "5941ae85aabac9d1d16c63a2"
 }
 [1]=>
 object(MongoDB\BSON\ObjectID)#14 (1) {
   ["oid"]=>
   string(24) "5941ae85aabac9d1d16c63a3"
 }
}

删除文档与插入文档类似,但是使用deleteOne()deleteMany()方法;deleteMany()的示例如下所示:

$deleteQuery = array( "isbn" => "401");
$deleteResult = $collection->deleteMany($deleteQuery);
print($deleteResult->getDeletedCount());

以下代码块显示了输出:

MongoDB\DeleteResult Object
(
   [writeResult:MongoDB\DeleteResult:private] => MongoDB\Driver\WriteResult Object
       (
           [nInserted] => 0
           [nMatched] => 0
           [nModified] => 0
           [nRemoved] => 2
           [nUpserted] => 0
           [upsertedIds] => Array
               (
               )

           [writeErrors] => Array
               (
               )

           [writeConcernError] =>
           [writeConcern] => MongoDB\Driver\WriteConcern Object
               (
               )

       )

   [isAcknowledged:MongoDB\DeleteResult:private] => 1
)
2

在这个例子中,我们使用->getDeletedCount()来获取受影响文档的数量,这个数量在输出的最后一行打印出来。

BulkWrite

新的 PHP 驱动程序支持BulkWrite接口,以最小化对 MongoDB 的网络调用:

$manager = new MongoDB\Driver\Manager('mongodb://localhost:27017');
$bulk = new MongoDB\Driver\BulkWrite(array("ordered" => true));
$bulk->insert(array( "isbn" => "401", "name" => "MongoDB and PHP" ));
$bulk->insert(array( "isbn" => "402", "name" => "MongoDB and PHP, 2nd Edition" ));
$bulk->update(array("isbn" => "402"), array('$set' => array("price" => 15)));
$bulk->insert(array( "isbn" => "403", "name" => "MongoDB and PHP, revisited" ));

$result = $manager->executeBulkWrite('mongo_book.books', $bulk);
print_r($result);

结果如下所示:

MongoDB\Driver\WriteResult Object
(
   [nInserted] => 3
   [nMatched] => 1
   [nModified] => 1
   [nRemoved] => 0
   [nUpserted] => 0
   [upsertedIds] => Array
       (
       )

   [writeErrors] => Array
       (
       )

   [writeConcernError] =>
   [writeConcern] => MongoDB\Driver\WriteConcern Object
       (
       )

)

在上面的例子中,我们按顺序执行了两次插入,一次更新和第三次插入。WriteResult对象包含了总共三个插入文档和一个修改文档。

与简单的创建/删除查询相比的主要区别是,executeBulkWrite()MongoDB\Driver\Manager类的一个方法,我们在第一行实例化它。

读取

查询接口类似于插入和删除,使用findOne()find()方法来检索查询的第一个结果或所有结果:

$document = $collection->findOne( array("isbn" => "401") );
$cursor = $collection->find( array( "name" => new MongoDB\BSON\Regex("mongo", "i") ) );

在第二个例子中,我们使用正则表达式搜索具有值mongo(不区分大小写)的键名。

使用.符号可以查询嵌入文档,就像我们在本章前面检查的其他语言一样:

$cursor = $collection->find( array('meta.price' => 50) );

我们这样做是为了查询meta键字段内嵌的price文档。

与 Ruby 和 Python 类似,在 PHP 中,我们可以使用比较运算符进行查询,如下面的代码所示:

$cursor = $collection->find( array( 'price' => array('$gte'=> 60) ) );

PHP 驱动程序支持的比较运算符的完整列表可在本章末尾找到。

使用多个键值对进行查询是隐式的AND,而使用$or$in$ninAND$and)与$or组合的查询可以通过嵌套查询实现:

$cursor = $collection->find( array( '$or' => array(
                                            array("price" => array( '$gte' => 60)),
                                            array("price" => array( '$lte' => 20))
                                   )));

这将找到price>=60 OR price<=20的文档。

更新文档

更新文档与->updateOne() OR ->updateMany()方法具有类似的接口。

第一个参数是用于查找文档的查询,第二个参数将更新我们的文档。

我们可以使用本章末尾解释的任何更新操作符来进行原地更新,或者指定一个新文档来完全替换查询中的文档:

$result = $collection->updateOne(  array( "isbn" => "401"),
   array( '$set' => array( "price" => 39 ) )
);

我们可以使用单引号或双引号作为键名,但如果我们有以$开头的特殊操作符,我们需要使用单引号。我们可以使用array( "key" => "value" )["key" => "value"]。在本书中,我们更喜欢更明确的array()表示法。

->getMatchedCount()->getModifiedCount()方法将返回查询部分匹配的文档数量或从查询中修改的文档数量。如果新值与文档的现有值相同,则不会计为修改。

使用 Doctrine 进行 CRUD

继续我们在第二章模式设计和数据建模中的 Doctrine 示例,我们将对这些模型进行 CRUD 操作。

创建、更新和删除

创建文档是一个两步过程。首先,我们创建我们的文档并设置属性值:

$book = new Book();
$book->setName('MongoDB with Doctrine');
$book->setPrice(45);

接着,我们要求 Doctrine 在下一次flush()调用中保存$book

$dm->persist($book);

我们可以通过手动调用flush()来强制保存,如下所示:

$dm->flush();

在这个例子中,$dm是一个DocumentManager对象,我们用它来连接到我们的 MongoDB 实例,如下所示:

$dm = DocumentManager::create(new Connection(), $config);

更新文档就像给属性赋值一样简单:

$book->price = 39;
$book->persist($book);

这将以新价格39保存我们的MongoDB with Doctrine书。

在原地更新文档时使用QueryBuilder接口。

Doctrine 提供了几个围绕原子更新的辅助方法,列举如下:

  • set($name, $value, $atomic = true)

  • setNewObj($newObj)

  • inc($name, $value)

  • unsetField($field)

  • push($field, $value)

  • pushAll($field, array $valueArray)

  • addToSet($field, $value)

  • addManyToSet($field, array $values)

  • popFirst($field)

  • popLast($field)

  • pull($field, $value)

  • pullAll($field, array $valueArray)

update默认会更新查询找到的第一个文档。如果我们想要更改多个文档,我们需要使用->updateMany()

$dm->createQueryBuilder('Book')
   ->updateMany()
   ->field('price')->set(69)
   ->field('name')->equals('MongoDB with Doctrine')
   ->getQuery()
   ->execute();

在上面的例子中,我们将书名为'MongoDB with Doctrine'的书的价格设置为69。Doctrine 中的比较运算符列表在以下读取部分中可用。

我们可以链接多个比较运算符,得到一个AND查询,也可以链接多个辅助方法,得到对多个字段的更新。

删除文档与创建文档类似,如下面的代码块所示:

$dm->remove($book);

最好使用QueryBuilder接口来删除多个文档,我们将在下一节中进一步探讨:

$qb = $dm->createQueryBuilder('Book');
$qb->remove()
   ->field('price')->equals(50)
   ->getQuery()
   ->execute();

读取

Doctrine 为 MongoDB 提供了一个QueryBuilder接口来构建查询。鉴于我们已经在第二章模式设计和数据建模中描述了我们的模型,我们可以这样做来获取一个名为$dbQueryBuilder接口的实例,获取默认的查找所有查询,并执行它,如下所示:

$qb = $dm->createQueryBuilder('Book');
$query = $qb->getQuery();
$books = $query->execute();

$books变量现在包含了一个可迭代的懒加载数据加载游标,用于遍历我们的结果集。

QueryBuilder对象上使用$qb->eagerCursor(true)将返回一个急切的游标,一旦我们开始遍历结果,就会从 MongoDB 中获取所有数据。

这里列出了一些用于查询的辅助方法:

  • ->getSingleResult(): 这相当于findOne()

  • ->select('name'): 这将仅返回我们的books集合中'key'属性的值。ObjectId将始终被返回。

  • ->hint('book_name_idx'): 强制查询使用此索引。我们将在第七章 索引中更多地了解索引。

  • ->distinct('name'): 这将按名称返回不同的结果。

  • ->limit(10): 这将返回前10个结果。

  • ->sort('name', 'desc'): 这将按名称排序(如descasc)。

当从 MongoDB 获取文档时,Doctrine 使用了水合概念。水合定义了查询的结果模式。例如,我们可以配置水合以返回对象集合、单个标量值或表示不同记录的数组数组。使用身份映射,它将在内存中缓存 MongoDB 结果,并在访问数据库之前查询此映射。通过使用->hydration(false)可以在每个查询中禁用水合,或者可以在全局范围内使用第二章模式设计和数据建模中解释的配置。

我们还可以通过在$qb上使用->refresh()来强制 Doctrine 从 MongoDB 中的查询刷新身份映射中的数据。

我们可以使用的比较运算符如下:

  • where($javascript)

  • in($values)

  • notIn($values)

  • equals($value)

  • notEqual($value)

  • gt($value)

  • gte($value)

  • lt($value)

  • lte($value)

  • range($start, $end)

  • size($size)

  • exists($bool)

  • type($type)

  • all($values)

  • mod($mod)

  • addOr($expr)

  • addAnd($expr)

  • references($document)

  • includesReferenceTo($document)

考虑以下查询作为示例:

$qb = $dm->createQueryBuilder('Book')
                ->field('price')->lt(30);

这将返回所有价格低于 30 的书籍。

addAnd()可能看起来多余,因为在 Doctrine 中链接多个查询表达式隐式是AND,但如果我们想要执行AND ( (A OR B), (C OR D) ),其中ABCD是独立的表达式,它是有用的。

要嵌套多个OR运算符与外部AND查询,以及其他同样复杂的情况,需要使用->expr()将嵌套的OR作为表达式进行评估:

$expression = $qb->expr()->field('name')->equals('MongoDB with Doctrine')

$expression是一个独立的表达式,可以与$qb->addOr($expression)一起使用,类似地使用addAnd()

最佳实践

使用 Doctrine 与 MongoDB 的一些最佳实践如下:

  • 不要使用不必要的级联。

  • 不要使用不必要的生命周期事件。

  • 不要在类、字段、表或列名称中使用特殊字符,因为 Doctrine 目前不支持 Unicode。

  • 在模型的构造函数中初始化集合引用。

  • 尽可能限制对象之间的关系。避免模型之间的双向关联,并消除不需要的关联。这有助于性能、松散耦合,并产生更简单、更易维护的代码。

比较运算符

以下是 MongoDB 支持的所有比较运算符的列表:

名称 描述
$eq 匹配等于指定值的值
$gt 匹配大于指定值的值
$gte 匹配大于或等于指定值的值
$lt 匹配小于指定值的值
$lt 匹配小于指定值的值
$ne 匹配所有不等于指定值的值
$in 匹配数组中指定的任何值
$nin 匹配数组中未指定的值

更新运算符

以下是 MongoDB 支持的所有更新运算符的列表:

名称 描述
$inc 将字段的值增加指定的数量
$mul 将字段的值乘以指定的数量
$rename 重命名字段。
$setOnInsert 如果更新导致文档插入,则设置字段的值。它不影响更新操作和修改现有文档。
$set 设置文档中字段的值。
$unset 从文档中删除指定的字段。
$min 仅在指定值小于现有字段值时更新字段。
$max 仅在指定值大于现有字段值时更新字段。
$currentDate 将字段的值设置为当前日期,可以是日期或时间戳。

智能查询

在查询 MongoDB 时,我们必须考虑几个因素。以下是一些使用正则表达式、查询结果、游标以及删除文档的最佳实践。

使用正则表达式

MongoDB 提供了丰富的接口,用于使用正则表达式进行查询。在其最简单的形式中,我们可以通过修改查询字符串来使用正则表达式:

> db.books.find({"name": /mongo/})

这是为了在我们的books集合中搜索包含mongo名称的书籍。这相当于 SQL 的LIKE查询。

MongoDB 使用Perl Compatible Regular ExpressionPCRE)版本 8.39,并支持 UTF-8。

在查询时,我们还可以使用一些选项:

选项 描述
i 此选项查询不区分大小写。
m 对于包含锚点(即^表示开头,`
--- ---
i 此选项查询不区分大小写。
表示结尾)的模式,此选项将在每行的开头或结尾匹配多行值的字符串。如果模式不包含锚点,或者字符串值没有换行符(例如\n),则m选项不起作用。

在我们的先前示例中,如果我们想搜索mongoMongoMONGO以及任何其他不区分大小写的变体,我们需要使用i选项,如下所示:

> db.books.find({"name": /mongo/i})

或者,我们可以使用$regex运算符,这样可以提供更多的灵活性。

使用$regex进行相同的查询将写成如下形式:

> db.books.find({'name': { '$regex': /mongo/ } })
> db.books.find({'name': { '$regex': /mongo/i } })

通过使用$regex运算符,我们还可以使用以下两个选项:

选项 描述
x 扩展功能,忽略$regex模式中的所有空白字符,除非它们已被转义或包含在字符类中。此外,它还忽略未转义的井号(#£)字符及其后的字符,以便在复杂模式中包含注释。这仅适用于数据字符;空白字符永远不会出现在模式中特殊字符序列中。x选项不影响对 VT 字符的处理。
s 此选项允许点字符(即.)匹配所有字符,包括换行符。

使用正则表达式扩展匹配文档会使我们的查询执行速度变慢。

使用正则表达式的索引只能在我们的正则表达式查询字符串的开头进行使用;也就是说,以^\A开头的正则表达式。如果我们只想使用starts with正则表达式进行查询,我们应该避免编写更长的正则表达式,即使它们会匹配相同的字符串。

以以下代码块为例:

> db.books.find({'name': { '$regex': /mongo/ } })
> db.books.find({'name': { '$regex': /^mongo.*/ } })

这两个查询都将匹配以mongo开头的名称值(区分大小写),但第一个查询将更快,因为它在每个名称值中的第六个字符时就会停止匹配。

查询结果和游标

MongoDB 不支持事务意味着我们在 RDBMS 中认为理所当然的几个语义工作方式不同。

如前所述,更新可能会修改文档的大小。修改大小可能会导致 MongoDB 将文档移动到存储文件末尾的新位置。

当我们有多个线程查询和更新单个集合时,可能会导致一个文档在结果集中出现多次。

这将发生在以下情况下:

  • 线程A开始查询集合并匹配文档A1

  • 线程B更新文档A1,增加其大小,并迫使 MongoDB 将其移动到存储文件末尾的不同物理位置。

  • 线程A仍在查询集合。它到达集合末尾,并再次找到文档A1,其新值如下图所示:

这很少见,但在生产中可能会发生;如果我们无法在应用层保护免受这种情况,我们可以使用snapshot()来防止它发生。

snapshot()由官方驱动程序和 shell 支持,通过将其附加到返回游标的操作中:

> db.books.find().snapshot()

$snapshot不能与分片集合一起使用。$snapshot必须在查询返回第一个文档之前应用。快照不能与hint()sort()操作符一起使用。

我们可以通过使用hint({id :1})来模拟snapshot()的行为,从而强制查询引擎使用id索引,就像$snapshot操作符一样。

如果我们的查询在一个字段的唯一索引上运行,而这个字段的值在查询期间不会被修改,我们应该使用这个查询来获得相同的查询行为。即使如此,snapshot()也无法保护我们免受插入或删除发生在查询中间的影响。$snapshot操作符将遍历每个集合在id字段上具有的内置索引,使其固有地缓慢。这应该只作为最后的手段使用。

如果我们想要在进行updateinsertdelete多个文档时,不让其他线程在操作进行时看到操作的结果,我们可以使用$isolated操作符:

> db.books.remove( { price: { $gt: 30 }, $isolated: 1 } )

在这个例子中,查询books集合的线程将看到价格大于30的所有书籍,或者根本看不到任何书籍。隔离操作符将在整个查询期间为集合获取独占写锁,无论存储引擎支持什么,都会导致这个集合的争用。

隔离操作仍然不是事务;它们不提供原子性("全有或全无")。因此,如果它们在中途失败,我们需要手动回滚操作,使我们的数据库处于一致状态。

再次强调,这应该是最后的手段,只在使得多个线程随时看到不一致信息变得至关重要的情况下使用。

删除操作的存储考虑

在 MongoDB 中删除文档不会回收其使用的磁盘空间。如果我们的 MongoDB 使用了 10GB 的磁盘空间,我们删除了所有文档,我们仍然会使用 10GB。在幕后发生的是,MongoDB 会将这些文档标记为已删除,并可能使用空间来存储新文档。

这将导致我们的磁盘有未使用的空间,但不会为操作系统释放。如果我们想要收回它,我们可以使用compact()来收回任何未使用的空间:

> db.books.compact()

或者,我们可以使用--repair选项启动mongod服务器。

更好的选择是启用压缩,这在 3.0 版本中可用,且仅适用于 WiredTiger 存储引擎。我们可以使用 snappy 或 zlib 算法来压缩我们的文档大小。这将再次不会防止存储空洞,但如果我们的磁盘空间紧张,这比修复和压缩的繁重操作更可取。

存储压缩使用更少的磁盘空间,但以 CPU 使用为代价,但这种权衡大多是值得的。

在运行可能导致灾难性数据丢失的操作之前,始终进行备份。修复或压缩将在单个线程中运行,阻塞整个数据库的其他操作。在生产系统中,始终先在从库上执行这些操作;然后切换主从角色,并压缩原主服务器,现在的从服务器实例。

变更流

变更流功能在 3.6 版本中引入,并在 4.0 版本中增强,使其成为监听数据库变更的安全有效的方式。

介绍

改变流解决的基本问题是应用程序需要立即对基础数据的变化做出反应。现代 Web 应用程序需要对数据变化做出反应,并在不重新加载整个页面的情况下刷新页面视图。这是前端框架(如 Angular、React 和 Vue.js)正在解决的问题之一。当用户执行操作时,前端框架将异步地向服务器提交请求,并根据服务器的响应刷新页面的相关片段。

考虑到多用户 Web 应用程序,存在数据库更改可能是由另一个用户的操作引起的情况。例如,在项目管理看板中,用户 A 可能正在查看看板,而另一个用户 B 可能正在将一个工单的状态从“待办”更改为“进行中”。

用户 A 的视图需要实时更新,以反映用户 B 所做的更改,无需刷新页面。目前已经有三种方法来解决这个问题,如下所示:

  • 最简单的方法是每隔 X 秒轮询数据库并确定是否有变化。通常,此代码将需要使用某种状态、时间戳或版本号,以避免多次获取相同的变化。这种方法简单,但效率低,因为它无法随着大量用户的增加而扩展。成千上万的用户同时轮询数据库将导致数据库锁定率高。

  • 为了克服第一种方法带来的问题,已经实现了数据库和应用程序级触发器。数据库触发器依赖于底层数据库对数据库更改做出响应执行一些代码。然而,主要的缺点与第一种方法类似,即我们向数据库添加的触发器越多,我们的数据库就会变得越慢。它也与数据库耦合,而不是应用程序代码库的一部分。

  • 最后,我们可以使用数据库事务或复制日志来查询最新的更改并对其做出反应。这是前面提到的三种方法中最有效和可扩展的方法,因为它不会对数据库造成压力。数据库会将写入此日志,通常是仅追加的,我们的后台任务会按顺序读取日志中的条目。这种方法的缺点是它是最复杂的实现方法,如果没有正确实现,可能会导致严重的错误。

改变流提供了一种解决这个问题的方式,这种方式对开发人员友好,易于实现和维护。它基于 oplog,本质上是 MongoDB 的操作日志,包含服务器范围内所有数据库中发生的每个操作。这样,开发人员就不必处理服务器范围内的 oplog 或可追溯的游标,这些通常不会从 MongoDB 特定语言驱动程序中公开或易于处理。此外,开发人员也不必解密和理解为 MongoDB 的利益而设计和构建的任何内部 oplog 数据结构。

改变流在安全方面还有其他优势:用户只能在具有读取权限的集合、数据库或部署上创建改变流。

改变流也是幂等的设计。即使应用程序无法获取绝对最新的改变流事件通知 ID,它也可以从先前已知的 ID 开始应用,并最终达到相同的状态。

最后,更改流是可恢复的。每个更改流响应文档都包括一个恢复令牌。如果应用程序与数据库不同步,它可以将最新的恢复令牌发送回数据库,并从那里继续处理。这个令牌需要在应用程序中持久化,因为 MongoDB 驱动程序不会保留应用程序的故障和重新启动。它只会在瞬态网络故障和 MongoDB 副本集选举的情况下保持状态并重试。

设置

更改流可以针对集合、数据库或整个部署(如副本集或分片集群)进行打开。更改流不会对系统集合或 admin、config 和 local 数据库中的任何集合的更改做出反应。

更改流需要 WiredTiger 存储引擎和副本集协议版本 1(pv1)。从 MongoDB 4.0 开始,pv1 是唯一受支持的版本。更改流与使用加密存储的部署兼容。

使用更改流

要使用更改流,我们需要连接到我们的副本集。副本集是使用更改流的先决条件。由于更改流内部使用 oplog,没有 oplog 是不可能工作的。更改流还将输出在副本集设置中不会回滚的文档,因此它们需要遵循大多数读取关注。无论如何,使用副本集进行本地开发和测试是一个好习惯,因为这是生产的推荐部署方式。例如,我们将在名为streams的数据库中使用signals集合。

我们将使用以下示例 Python 代码:

from pymongo import MongoClient

class MongoExamples:
   def __init__(self):
       self.client = MongoClient('localhost', 27017)
       db = self.client.streams
       self.signals = db.signals
   # a basic watch on signals collection
   def change_books(self):
       with self.client.watch() as stream:
           for change in stream:
               print(change)
def main():
   MongoExamples().change_books()
if __name__ == '__main__':
   main()

我们可以打开一个终端并使用python change_streams.py运行它。

然后,在另一个终端中,我们使用以下代码连接到我们的 MongoDB 副本集:

> mongo
> use streams
> db.signals.insert({value: 114.3, signal:1})

回到我们的第一个终端窗口,我们现在可以观察到输出类似于以下代码块:

{'_id': {'_data': '825BB7A25E0000000129295A1004A34408FB07864F8F960BF14453DFB98546645F696400645BB7A25EE10ED33145BCF7A70004'}, 'operationType': 'insert', 'clusterTime': Timestamp(1538761310, 1), 'fullDocument': {'_id': ObjectId('5bb7a25ee10ed33145bcf7a7'), 'value': 114.3, 'signal': 1.0}, 'ns': {'db': 'streams', 'coll': 'signals'}, 'documentKey': {'_id': ObjectId('5bb7a25ee10ed33145bcf7a7')}}

这里发生的是,我们已经打开了一个游标,监视整个streams数据库的更改。我们数据库中的每个数据更新都将被记录并输出到控制台。

例如,如果我们回到 mongo shell,我们可以发出以下代码:

> db.a_random_collection.insert({test: 'bar'})

Python 代码输出应该类似于以下代码:

{'_id': {'_data': '825BB7A3770000000229295A10044AB37F707D104634B646CC5810A40EF246645F696400645BB7A377E10ED33145BCF7A80004'}, 'operationType': 'insert', 'clusterTime': Timestamp(1538761591, 2), 'fullDocument': {'_id': ObjectId('5bb7a377e10ed33145bcf7a8'), 'test': 'bar'}, 'ns': {'db': 'streams', 'coll': 'a_random_collection'}, 'documentKey': {'_id': ObjectId('5bb7a377e10ed33145bcf7a8')}}

这意味着我们会收到关于数据库中所有集合的每个数据更新的通知。

然后,我们可以将我们的代码的第 11 行更改为以下内容:

> with self.signals.watch() as stream:

这将导致只观察signals集合,这应该是最常见的用例。

PyMongo 的watch命令可以采用几个参数,如下所示:

watch(pipeline=None, full_document='default', resume_after=None, max_await_time_ms=None, batch_size=None, collation=None, start_at_operation_time=None, session=None)

最重要的参数如下:

  • Pipeline:这是一个可选参数,我们可以使用它来定义要在每个与watch()匹配的文档上执行的聚合管道。因为更改流本身使用聚合管道,我们可以将事件附加到它。我们可以使用的聚合管道事件如下:
$match
$project
$addFields
$replaceRoot
$redact
  • Full_document:这是一个可选参数,我们可以通过将其设置为'updateLookup'来使用,以使更改流返回描述文档更改的增量和在部分更新的情况下从更改发生后的一段时间内更改的整个文档的副本。

  • Start_at_operation_time:这是一个可选参数,我们可以使用它来仅观察在指定时间戳之后或之后发生的更改。

  • Session:这是一个可选参数,如果我们的驱动程序支持传递ClientSession对象以侦听更新。

更改流响应文档的大小不能超过 16 MB。这是 MongoDB 对 BSON 文档的全局限制,更改流必须遵循此规则。

规范

以下文档显示了更改事件响应可能包括或不包括的所有可能字段,具体取决于实际发生的更改:

{  _id : { <BSON Object> },
  "operationType" : "<operation>",
  "fullDocument" : { <document> },
  "ns" : {
     "db" : "<database>",
     "coll" : "<collection"
  },
  "documentKey" : { "_id" : <ObjectId> },
  "updateDescription" : {
     "updatedFields" : { <document> },
     "removedFields" : [ "<field>", ... ]
  }
  "clusterTime" : <Timestamp>,
  "txnNumber" : <NumberLong>,
  "lsid" : {
     "id" : <UUID>,
     "uid" : <BinData>
  }
}

最重要的字段如下:

| fullDocument | 这是文档的新状态,可以包括以下内容:

  • 如果是删除操作,则该字段被省略,因为文档已不再存在。

  • 如果是插入或替换操作,则将是文档的新值。

  • 如果是更新操作,并且我们已启用'updateLookup',那么它将具有更新操作修改的文档的最近主要提交的版本。

|

operationType 这是操作的类型;它可以是insertdeletereplaceupdateinvalidate中的任何一个。
documentKey 这是操作影响的文档的ObjectID
updateDescription.updatedFields / removedFields 这是一个文档或相应的键数组,分别显示了更新或删除操作更新或删除的数据。
txnNumber 这是事务号。仅当操作是多文档 ACID 事务的一部分时才适用。
lsid 这是事务的会话标识符。仅当操作是多文档 ACID 事务的一部分时才适用。

重要说明

在使用分片数据库时,更改流需要针对 MongoDB 服务器打开。在使用副本集时,更改流只能针对承载数据的实例打开。从 4.0.2 版本开始,每个更改流将打开一个新连接。如果我们想要并行地有大量的更改流,我们需要增加连接池(根据 SERVER-32946 JIRA MongoDB 票)以避免严重的性能下降。

生产建议

更改流是 MongoDB 数据库的一个相当新的添加。因此,生产部署的以下建议可能会在以后的版本中更改。这些是 MongoDB 和专家架构师在撰写本章时推荐的指南。

副本集

更改流只会处理已写入大多数处理数据成员的事件。如果我们失去了存储数据服务器的大多数,或者我们依赖仲裁者来建立多数,它将暂停。

使事件无效,例如删除或重命名集合,将关闭更改流。在使事件无效后关闭更改流后,我们无法恢复更改流。

由于更改流依赖于 oplog 大小,我们需要确保 oplog 大小足够大,以容纳事件,直到应用程序处理完毕。

分片集群

除了副本集的考虑之外,对于分片集群还有一些需要牢记的事项。它们如下:

  • 更改流在集群中的每个分片上执行,并且速度将取决于最慢的分片。

  • 为了避免为孤立文档创建更改流事件,如果我们在分片下有多文档更新,我们需要使用 ACID 兼容事务的新功能。

在对未分片集合进行分片(即从副本集迁移至分片)时,更改流通知文档的documentKey将包括_id,直到更改流追上第一个分块迁移。

摘要

在本章中,我们通过使用官方驱动程序和 ODM,使用 Ruby、Python 和 PHP,讨论了使用这些语言进行高级查询的概念。

使用 Ruby 和 Mongoid ODM,Python 和 PyMODM ODM,以及 PHP 和 Doctrine ODM,我们通过代码示例探讨了如何创建读取更新删除文档。

我们还讨论了性能和最佳实践的批处理操作。我们提供了 MongoDB 使用的比较和更新操作符的详尽列表。

最后,我们讨论了智能查询,查询中的游标工作原理,删除时应考虑的存储性能,以及如何使用正则表达式。

在下一章中,我们将学习聚合框架,使用一个涉及从以太坊区块链处理交易数据的完整用例。

第五章:多文档 ACID 事务

MongoDB 在 2018 年 7 月发布的 4.0 版本中引入了多文档原子性、一致性、隔离性和持久性(ACID)事务。事务是关系数据库的一个组成部分。从早期开始,每个关系数据库管理系统(RDBMS)都依赖事务来实现 ACID。在非关系型数据库中实现这些功能是一个突破,可以从根本上改变开发人员和数据库架构师设计软件系统的方式。

在上一章中,我们学习了如何使用 Ruby、Python 和 PHP 的驱动程序和框架查询 MongoDB。在本章中,我们将学习以下主题:

  • 多文档 ACID 事务

  • 使用 Ruby 和 Python 进行事务处理

背景

MongoDB 是一个非关系型数据库,对 ACID 提供了很少的保证。MongoDB 中的数据建模并不是围绕 BCNF、2NF 和 3NF 规范化,而是相反的方向。

在 MongoDB 中,很多时候,最好的方法是将我们的数据嵌入子文档中,这样可以得到比在关系数据库管理系统中单行数据更自包含的文档。这意味着一个逻辑事务可以多次影响单个文档。在 MongoDB 中,单文档事务是符合 ACID 的,这意味着多文档 ACID 事务对于 MongoDB 的开发并不是必不可少的。

然而,有几个原因说明为什么实现多文档事务是一个好主意。多年来,MongoDB 已经从一个小众数据库发展成为一个多用途数据库,被各种公司广泛使用,从初创公司到财富 500 强公司。在许多不同的用例中,不可避免地会有一些情况,数据建模无法或不应该将数据放入子文档和数组中。此外,即使对于数据架构师来说,今天最好的解决方案是嵌入数据,他们也无法确定这种情况会一直持续下去。这使得选择正确的数据库层变得困难。

关系型数据库数据建模已经存在了 40 多年,是一个众所周知和理解的数据建模过程。帮助数据架构师以熟悉的方式工作总是一个额外的好处。

在引入多文档事务之前,唯一的解决方法是在应用层以定制的方式实现它们。这既耗时又容易出错。在应用层实现两阶段提交过程也可能更慢,并导致增加数据库锁。

在本章中,我们将专注于使用原生的 MongoDB 事务,因为这是 MongoDB 公司强烈推荐的。

ACID

ACID 代表原子性、一致性、隔离性和持久性。在接下来的章节中,我们将解释这些对于我们的数据库设计和架构意味着什么。

原子性

原子性指的是事务需要是原子的概念。要么成功并且其结果对每个后续用户都是可见的,要么失败并且每个更改都被回滚到开始之前的状态。事务中的所有操作要么全部发生,要么全部不发生。

一个简单的例子来理解原子性是将钱从账户A转账到账户B。需要从账户A中存入钱,然后转入账户B。如果操作在中途失败,那么账户AB都需要恢复到操作开始之前的状态。

在 MongoDB 中,即使操作跨多个子文档或数组,单个文档中的操作也总是原子的。

跨多个文档的操作需要使用 MongoDB 事务来实现原子性。

一致性

一致性指的是数据库始终处于一致的状态。每个数据库操作可能成功完成、失败或中止;然而,最终,我们的数据库必须处于一个数据一致的状态。

数据库约束必须始终得到尊重。任何未来的事务也必须能够查看过去事务更新的数据。在实践中,分布式数据系统中最常用的一致性模型是最终一致性。

最终一致性保证一旦我们停止更新数据,所有未来的读取将最终读取最新提交的写入值。在分布式系统中,这是性能方面唯一可接受的模型,因为数据需要在不同服务器之间的网络上复制。

相比之下,最不受欢迎的强一致性模型保证每次未来读取都将始终读取上次提交的写入值。这意味着每次更新都会在下一次读取之前传播并提交到每个服务器,这将对这些系统的性能造成巨大压力。

MongoDB 介于最终一致性和严格一致性之间。事实上,MongoDB 采用因果一致性模型。在因果一致性中,任何事务执行顺序都与如果所有因果相关的读/写操作按照反映它们因果关系的顺序执行的顺序相同。

在实践中,这意味着并发操作可能以不同的顺序被看到,读取对应于最新写入的值,与它们因果依赖的写入相关。

最终,这是一个权衡,即同时发生多少并发操作和应用程序读取的数据一致性。

隔离

隔离指的是事务操作对其他并行操作的可见性。

隔离级别至关重要的一个例子描述如下情景:

  • 事务A将用户 1 的账户余额从 50 更新为 100,但不提交事务。

  • 事务B将用户 1 的账户余额读取为 100。

  • 事务A被回滚,将用户 1 的账户余额恢复为 50。

  • 事务B认为用户 1 有 100 英镑,而实际上只有 50 英镑。

  • 事务B更新用户 2 的值,增加 100 英镑。用户 2 从用户 1 那里凭空得到 100 英镑,因为用户 1 的账户中只有 50 英镑。我们的想象中的银行陷入了麻烦。

隔离通常有四个级别,从最严格到最不严格,如下所示:

  • 可串行化

  • 重复读取

  • 读已提交

  • 读未提交

我们可能遇到的问题,从最不严重到最严重,取决于隔离级别,如下所示:

  • 幻读

  • 不可重复读取

  • 脏读

  • 丢失更新

在任何数据库中,丢失有关操作更新的数据是最糟糕的事情,因为这将使我们的数据库无法使用,并使其成为一个不可信任的数据存储。这就是为什么在每个隔离级别中,即使是读取未提交的隔离,也不会丢失数据。

然而,其他三个问题也可能出现。我们将在以下部分简要解释这些问题是什么。

幻读

幻读发生在事务过程中,另一个事务通过添加或删除属于其结果集的行来修改其结果集。一个例子是:

  • 事务A查询所有用户。返回 1,000 个用户,但事务没有提交。

  • 事务B添加另一个用户;现在我们的数据库中有 1,001 个用户。

  • 事务A第二次查询所有用户。现在返回 1,001 个用户。事务A现在提交。

在严格的可串行化隔离级别下,事务B应该被阻止添加新用户,直到事务A提交其事务。当然,这可能会在数据库中引起巨大的争用,并导致性能下降,因为每个更新操作都需要等待读取提交其事务。这就是为什么通常在实践中很少使用可串行化。

不可重复读取

当在事务期间检索一行两次并且行的值在每次读取操作时都不同时,就会发生不可重复读取。

根据先前的资金转移示例,我们可以类似地说明不可重复读取:

  • 事务B读取用户 1 的账户余额为 50。

  • 事务A将用户 1 的账户余额从 50 更新为 100,并提交事务。

  • 事务B再次读取用户 1 的账户余额,并获得新值 100,然后提交事务。

问题在于事务B在其事务过程中得到了不同的值,因为它受到了事务A的更新的影响。这是一个问题,因为事务B在其自己的事务中得到了不同的值。然而,在实践中,它解决了在用户不存在时在用户之间转移资金的问题。

这就是为什么读取提交的隔离级别在实践中是最常用的隔离级别,它不会阻止不可重复读取,但会阻止脏读。

脏读

先前的例子中,我们通过虚拟货币赚了钱,最终从只有 50 英镑余额的账户中转出了 100 英镑,这是脏读的典型例子。

读取未提交的隔离级别不能保护我们免受脏读的影响,这就是为什么它在生产级系统中很少使用的原因。

以下是隔离级别与潜在问题的对比:

隔离级别 丢失更新 脏读 不可重复读 幻读
读取未提交 不会发生 可能发生 可能发生 可能发生
读取提交 不会发生 不会发生 可能发生 可能发生
可重复读取 不会发生 不会发生 不会发生 可能发生
可串行化 不会发生 不会发生 不会发生 不会发生

PostgreSQL 使用默认(和可配置的)读取提交的隔离级别。由于 MongoDB 本质上不是关系数据库管理系统,对每个操作使用事务会使情况变得更加复杂。

在这些术语中,等价的隔离级别是读取未提交。根据先前给出的例子,这可能看起来很可怕,但另一方面,在 MongoDB 中,(再次强调,一般情况下)没有事务的概念或回滚事务。读取未提交指的是在使其持久化之前将更改可见。有关持久化部分的更多详细信息将在持久性的以下部分中提供。

耐久性

在关系数据库系统中,持久性是指每个成功提交的事务将在面对故障时幸存的属性。这通常指的是将已提交事务的内容写入持久存储(如硬盘或 SDD)。关系数据库管理系统总是通过将每个已提交的事务写入事务日志或预写式日志WAL)来遵循持久性概念。MongoDB 使用 WiredTiger 存储引擎,每 60 毫秒将写入使用 WAL 提交到其基于持久存储的日志,并且在所有实际目的上是持久的。由于持久性很重要,每个数据库系统都更喜欢首先放松 ACID 的其他方面,而持久性通常是最后放松的。

我们何时需要在 MongoDB 中使用 ACID?

现有的原子性保证,对于单文档操作,MongoDB 可以满足大多数现实世界应用程序的完整性需求。然而,一些传统上受益于 ACID 事务的用例在 MongoDB 中建模可能比使用众所周知的 ACID 范式要困难得多。

毫不奇怪,许多这些情况来自金融行业。处理资金和严格的监管框架意味着每个操作都需要被存储,有时需要严格的执行顺序,记录,验证,并且在需要时可以进行审计。构建数字银行需要在 MongoDB 中将多个账户之间的交互表示为文档。

管理用户或执行高频交易的算法产生的大量财务交易,还需要验证每一笔交易。这些交易可能涉及多个文档,因为它们可能再次涉及多个帐户。

使用多文档 ACID 事务的一般模式是当我们可以拥有无限数量的实体时,有时可能达到数百万。在这种情况下,对实体进行子文档和数组建模是行不通的,因为文档最终会超出 MongoDB 中内置的 16 MB 文档大小限制。

使用 MongoDB 构建数字银行

多文档 ACID 事务的最常见用例来自金融领域。在本节中,我们将使用事务模拟数字银行,并逐渐介绍如何利用事务来获益的更复杂的示例。

银行必须提供的基本功能是帐户和在它们之间转移货币金额。在引入事务之前,MongoDB 开发人员有两个选择。第一种选择是 MongoDB 的处理方式,即将数据嵌入文档中,可以是子文档或值数组。对于帐户,这可能会导致以下代码块中的数据结构:

{accounts: [ {account_id: 1, account_name: ‘alex’, balance: 100}, {account_id: 2, account_name: ‘bob’, balance: 50}]}

然而,即使在这种简单的格式中,它也会很快超出 MongoDB 中固定的 16 MB 文档限制。这种方法的优势在于,由于我们必须处理单个文档,所有操作都将是原子的,从而在我们将资金从一个帐户转移到另一个帐户时产生强一致性保证。

除了使用关系数据库之外,唯一可行的替代方案是在应用程序级别实现保证,以模拟具有适当代码的事务,以便在出现错误时撤消部分或全部事务。这种方法可以奏效,但会导致更长的上市时间,并且更容易出现错误。

MongoDB 的多文档 ACID 事务方法类似于我们在关系数据库中处理事务的方式。从 MongoDB Inc.于 2018 年 6 月发布的《MongoDB 多文档 ACID 事务》白皮书中,最简单的例子是,MongoDB 中的通用事务将如下代码块所示:

s.start_transaction()
orders.insert_one(order, session=s)
stock.update_one(item, stockUpdate, session=s)
s.commit_transaction()

然而,在 MySQL 中进行相同的事务将如下所示:

db.start_transaction()
cursor.execute(orderInsert, orderData)
cursor.execute(stockUpdate, stockData)
db.commit()

也就是说,在现代 Web 应用程序框架中,大多数情况下,事务都隐藏在对象关系映射(ORM)层中,对应用程序开发人员不可见。框架确保 Web 请求被包装在传递到底层数据库层的事务中。这在 ODM 框架中还没有实现,但可以预期这种情况可能会发生改变。

设置我们的数据

我们将使用一个包含两个帐户的示例init_data.json文件。Alex 有 100 个 hypnotons 虚拟货币,而 Mary 有 50 个:

{"collection": "accounts", "account_id": "1", "account_name": "Alex", "account_balance":100}{"collection": "accounts", "account_id": "2", "account_name": "Mary", "account_balance":50}

使用以下 Python 代码,我们可以将这些值插入到我们的数据库中:

import json
class InitData:
   def __init__(self):
       self.client = MongoClient('localhost', 27017)
       self.db = self.client.mongo_bank
       self.accounts = self.db.accounts
       # drop data from accounts collection every time to start from a clean slate
       self.accounts.drop()
       # load data from json and insert them into our database
       init_data = InitData.load_data(self)
       self.insert_data(init_data)
   @staticmethod
   def load_data(self):
       ret = []
       with open('init_data.json', 'r') as f:
           for line in f:
               ret.append(json.loads(line))
       return ret
   def insert_data(self, data):
       for document in data:
           collection_name = document['collection']
           account_id = document['account_id']
           account_name = document['account_name']
           account_balance = document['account_balance']
           self.db[collection_name].insert_one({'account_id': account_id, 'name': account_name, 'balance': account_balance})

这将导致我们的mongo_bank数据库在我们的accounts集合中具有以下文档:

> db.accounts.find()
{ "_id" : ObjectId("5bc1fa7ef8d89f2209d4afac"), "account_id" : "1", "name" : "Alex", "balance" : 100 }
{ "_id" : ObjectId("5bc1fa7ef8d89f2209d4afad"), "account_id" : "2", "name" : "Mary", "balance" : 50 }

帐户之间的转移-第一部分

作为 MongoDB 开发人员,模拟事务的最常见方法是在代码中实现基本检查。对于我们的示例帐户文档,您可能会尝试实现帐户转移如下:

   def transfer(self, source_account, target_account, value):
       print(f'transferring {value} Hypnotons from {source_account} to {target_account}')
       with self.client.start_session() as ses:
           ses.start_transaction()
           self.accounts.update_one({'account_id': source_account}, {'$inc': {'balance': value*(-1)} })
           self.accounts.update_one({'account_id': target_account}, {'$inc': {'balance': value} })
           updated_source_balance = self.accounts.find_one({'account_id': source_account})['balance']
           updated_target_balance = self.accounts.find_one({'account_id': target_account})['balance']
           if updated_source_balance < 0 or updated_target_balance < 0:
               ses.abort_transaction()
           else:
               ses.commit_transaction()

在 Python 中调用此方法将从帐户 1 转移 300 个 hypnotons 到帐户 2:

>>> obj = InitData.new
>>> obj.transfer('1', '2', 300)

这将导致以下结果:

> db.accounts.find()
{ "_id" : ObjectId("5bc1fe25f8d89f2337ae40cf"), "account_id" : "1", "name" : "Alex", "balance" : -200 }
{ "_id" : ObjectId("5bc1fe26f8d89f2337ae40d0"), "account_id" : "2", "name" : "Mary", "balance" : 350 }

这里的问题不在于updated_source_balanceupdated_target_balance的检查。这两个值都反映了分别为-200350的新值。问题也不在于abort_transaction()操作。相反,问题在于我们没有使用会话。

在 MongoDB 中学习有关事务的最重要的一点是,我们需要使用会话对象来包装事务中的操作;但与此同时,在事务代码块内部仍然可以执行事务范围之外的操作。

这里发生的是,我们启动了一个事务会话,如下所示:

       with self.client.start_session() as ses:

然后完全忽略了这一点,通过非事务方式进行所有更新。然后我们调用了abort_transaction,如下所示:

               ses.abort_transaction()

要中止的事务本质上是无效的,没有任何需要回滚的内容。

账户之间的转账-第二部分

实现事务的正确方法是在每个我们想要在最后提交或回滚的操作中使用会话对象,如下所示:

   def tx_transfer_err(self, source_account, target_account, value):
       print(f'transferring {value} Hypnotons from {source_account} to {target_account}')
       with self.client.start_session() as ses:
           ses.start_transaction()
           res = self.accounts.update_one({'account_id': source_account}, {'$inc': {'balance': value*(-1)} }, session=ses)
           res2 = self.accounts.update_one({'account_id': target_account}, {'$inc': {'balance': value} }, session=ses)
           error_tx = self.__validate_transfer(source_account, target_account)

           if error_tx['status'] == True:
               print(f"cant transfer {value} Hypnotons from {source_account} ({error_tx['s_bal']}) to {target_account} ({error_tx['t_bal']})")
               ses.abort_transaction()
           else:
               ses.commit_transaction()

现在唯一的区别是我们在两个更新语句中都传递了session=ses。为了验证我们是否有足够的资金来实际进行转账,我们编写了一个辅助方法__validate_transfer,其参数是源和目标账户 ID:

   def __validate_transfer(self, source_account, target_account):
       source_balance = self.accounts.find_one({'account_id': source_account})['balance']
       target_balance = self.accounts.find_one({'account_id': target_account})['balance']

       if source_balance < 0 or target_balance < 0:
          return {'status': True, 's_bal': source_balance, 't_bal': target_balance}
       else:
           return {'status': False}

不幸的是,这次尝试也会失败。原因与之前相同。当我们在事务内部时,对数据库进行的更改遵循 ACID 原则。事务内部的更改对外部查询不可见,直到它们被提交。

账户之间的转账-第三部分

解决转账问题的正确实现将如下代码所示(完整的代码示例附在代码包中):

from pymongo import MongoClient
import json

class InitData:
   def __init__(self):
       self.client = MongoClient('localhost', 27017, w='majority')
       self.db = self.client.mongo_bank
       self.accounts = self.db.accounts

       # drop data from accounts collection every time to start from a clean slate
       self.accounts.drop()

       init_data = InitData.load_data(self)
       self.insert_data(init_data)
       self.transfer('1', '2', 300)

   @staticmethod
   def load_data(self):
       ret = []
       with open('init_data.json', 'r') as f:
           for line in f:
               ret.append(json.loads(line))
       return ret

   def insert_data(self, data):
       for document in data:
           collection_name = document['collection']
           account_id = document['account_id']
           account_name = document['account_name']
           account_balance = document['account_balance']

           self.db[collection_name].insert_one({'account_id': account_id, 'name': account_name, 'balance': account_balance})

   # validating errors, using the tx session
   def tx_transfer_err_ses(self, source_account, target_account, value):
       print(f'transferring {value} Hypnotons from {source_account} to {target_account}')
       with self.client.start_session() as ses:
           ses.start_transaction()
           res = self.accounts.update_one({'account_id': source_account}, {'$inc': {'balance': value * (-1)}}, session=ses)
           res2 = self.accounts.update_one({'account_id': target_account}, {'$inc': {'balance': value}}, session=ses)
           error_tx = self.__validate_transfer_ses(source_account, target_account, ses)

           if error_tx['status'] == True:
               print(f"cant transfer {value} Hypnotons from {source_account} ({error_tx['s_bal']}) to {target_account} ({error_tx['t_bal']})")
               ses.abort_transaction()
           else:
               ses.commit_transaction()

   # we are passing the session value so that we can view the updated values
   def __validate_transfer_ses(self, source_account, target_account, ses):
       source_balance = self.accounts.find_one({'account_id': source_account}, session=ses)['balance']
       target_balance = self.accounts.find_one({'account_id': target_account}, session=ses)['balance']
       if source_balance < 0 or target_balance < 0:
           return {'status': True, 's_bal': source_balance, 't_bal': target_balance}
       else:
           return {'status': False}

def main():
   InitData()

if __name__ == '__main__':
   main()

在这种情况下,通过传递会话对象的ses值,我们确保可以使用update_one()在数据库中进行更改,并且还可以使用find_one()查看这些更改,然后执行abort_transaction()操作或commit_transaction()操作。

事务无法执行数据定义语言DDL)操作,因此drop()create_collection()和其他可能影响 MongoDB 的 DDL 的操作在事务内部将失败。这就是为什么我们在MongoClient对象中设置w='majority',以确保当我们在开始事务之前删除集合时,此更改将对事务可见。

即使我们明确注意在事务期间不创建或删除集合,也有一些操作会隐式执行此操作。

我们需要确保在尝试插入或更新(更新和插入)文档之前集合存在。

最后,如果需要回滚,使用事务时我们不需要跟踪先前的账户余额值,因为 MongoDB 将放弃事务范围内所做的所有更改。

继续使用 Ruby 进行相同的示例,我们有第三部分的以下代码:

require 'mongo'

class MongoBank
  def initialize
    @client = Mongo::Client.new([ '127.0.0.1:27017' ], database: :mongo_bank)
    db = @client.database
    @collection = db[:accounts]

    # drop any existing data
    @collection.drop

    @collection.insert_one('collection': 'accounts', 'account_id': '1', 'account_name': 'Alex', 'account_balance':100)
    @collection.insert_one('collection': 'accounts', 'account_id': '2', 'account_name': 'Mary', 'account_balance':50)

    transfer('1', '2', 30)
    transfer('1', '2', 300)
  end

  def transfer(source_account, target_account, value)
    puts "transferring #{value} Hypnotons from #{source_account} to #{target_account}"
    session = @client.start_session

    session.start_transaction(read_concern: { level: :snapshot }, write_concern: { w: :majority })
    @collection.update_one({ account_id: source_account }, { '$inc' => { account_balance: value*(-1)} }, session: session)
    @collection.update_one({ account_id: target_account }, { '$inc' => { account_balance: value} }, session: session)

    source_account_balance = @collection.find({ account_id: source_account }, session: session).first['account_balance']

    if source_account_balance < 0
      session.abort_transaction
    else
      session.commit_transaction
    end
  end

end

# initialize class
MongoBank.new

除了 Python 示例中提出的所有观点之外,我们还发现可以根据事务自定义read_concernwrite_concern

多文档 ACID 事务的可用read_concern级别如下:

  • majority:复制集中大多数服务器已确认数据。为了使事务按预期工作,它们还必须使用write_concernmajority

  • local:只有本地服务器已确认数据。

  • snapshot:截至 MongoDB 4.0,事务的默认read_concern级别。如果事务以write_concernmajority提交,所有事务操作将从大多数提交数据的快照中读取,否则无法做出保证。

事务的读关注点设置在事务级别或更高级别(会话或最终客户端)。不支持在单个操作中设置读关注点,并且通常不建议这样做。

多文档 ACID 事务的可用write_concern级别与 MongoDB 中的其他地方相同,除了不支持w:0(无确认)。

使用 MongoDB 的电子商务

对于我们的第二个示例,我们将在三个不同的集合上使用更复杂的事务用例。

我们将使用 MongoDB 模拟电子商务应用程序的购物车和付款交易过程。使用我们将在本节末尾提供的示例代码,我们将首先用以下数据填充数据库。

我们的第一个集合是users集合,每个用户一个文档:

> db.users.find()
{ "_id" : ObjectId("5bc22f35f8d89f2b9e01d0fd"), "user_id" : 1, "name" : "alex" }
{ "_id" : ObjectId("5bc22f35f8d89f2b9e01d0fe"), "user_id" : 2, "name" : "barbara" }

然后我们有一个carts集合,每个购物车一个文档,通过user_id与我们的用户相关联:

> db.carts.find()
{ "_id" : ObjectId("5bc2f9de8e72b42f77a20ac8"), "cart_id" : 1, "user_id" : 1 }
{ "_id" : ObjectId("5bc2f9de8e72b42f77a20ac9"), "cart_id" : 2, "user_id" : 2 }

payments集合保存通过的任何已完成付款,存储cart_iditem_id以链接到它所属的购物车和已支付的商品:

> db.payments.find()
{ "_id" : ObjectId("5bc2f9de8e72b42f77a20aca"), "cart_id" : 1, "name" : "alex", "item_id" : 101, "status" : "paid" }

最后,inventories集合保存我们当前可用的物品数量(按item_id),以及它们的价格和简短描述:

> db.inventories.find()
{ "_id" : ObjectId("5bc2f9de8e72b42f77a20acb"), "item_id" : 101, "description" : "bull bearing", "price" : 100, "quantity" : 5 }

在这个例子中,我们将演示使用 MongoDB 的模式验证功能。使用 JSON 模式,我们可以定义一组验证,每次插入或更新文档时都会在数据库级别进行检查。这是一个相当新的功能,因为它是在 MongoDB 3.6 中引入的。在我们的情况下,我们将使用它来确保我们的库存中始终有正数的物品数量。

MongoDB shell 格式中的validator对象如下:

validator = { validator:
 { $jsonSchema:
 { bsonType: "object",
 required: ["quantity"],
 properties:
 { quantity:
 { bsonType: ["long"],
 minimum: 0,
 description: "we can’t have a negative number of items in our inventory"
 }
 }
 }
 }
}

JSON 模式可用于实现我们在 Rails 或 Django 中通常在模型中具有的许多验证。我们可以定义这些关键字如下表所示:

关键字 验证类型 描述
enum 所有 字段中允许的值的枚举。
type 所有 字段中允许的类型的枚举。
minimum/maximum 数值 数值字段的最小值和最大值。
minLength/maxLength 字符串 字符串字段允许的最小和最大长度。
pattern 字符串 字符串字段必须匹配的正则表达式模式。
required 对象 文档必须包含在 required 属性数组中定义的所有字符串。
minItems/maxItems 数组 数组中的项的最小和最大长度。
uniqueItems 数组 如果设置为 true,则数组中的所有项必须具有唯一值。
title N/A 开发人员使用的描述性标题。
description N/A 开发人员使用的描述。

使用 JSON 模式,我们可以将验证从我们的模型转移到数据库层和/或使用 MongoDB 验证作为 Web 应用程序验证的额外安全层。

要使用 JSON 模式,我们必须在创建集合时指定它,如下所示:

> db.createCollection("inventories", validator)

回到我们的例子,我们的代码将模拟拥有五个滚珠轴承的库存,并下两个订单;用户 Alex 订购两个滚珠轴承,然后用户 Barbara 订购另外四个滚珠轴承。

如预期的那样,第二个订单不会通过,因为我们的库存中没有足够的滚珠来满足它。我们将在以下代码中看到这一点:

from pymongo import MongoClient
from pymongo.errors import ConnectionFailure
from pymongo.errors import OperationFailure

class ECommerce:
   def __init__(self):
       self.client = MongoClient('localhost', 27017, w='majority')
       self.db = self.client.mongo_bank
       self.users = self.db['users']
       self.carts = self.db['carts']
       self.payments = self.db['payments']
       self.inventories = self.db['inventories']
       # delete any existing data
       self.db.drop_collection('carts')
       self.db.drop_collection('payments')
       self.db.inventories.remove()
       # insert new data
       self.insert_data()
       alex_order_cart_id = self.add_to_cart(1,101,2)
       barbara_order_cart_id = self.add_to_cart(2,101,4)
       self.place_order(alex_order_cart_id)
       self.place_order(barbara_order_cart_id)
   def insert_data(self):
       self.users.insert_one({'user_id': 1, 'name': 'alex' })
       self.users.insert_one({'user_id': 2, 'name': 'barbara'})
       self.carts.insert_one({'cart_id': 1, 'user_id': 1})
       self.db.carts.insert_one({'cart_id': 2, 'user_id': 2})
       self.db.payments.insert_one({'cart_id': 1, 'name': 'alex', 'item_id': 101, 'status': 'paid'})
       self.db.inventories.insert_one({'item_id': 101, 'description': 'bull bearing', 'price': 100, 'quantity': 5.0})

   def add_to_cart(self, user, item, quantity):
       # find cart for user
       cart_id = self.carts.find_one({'user_id':user})['cart_id']
       self.carts.update_one({'cart_id': cart_id}, {'$inc': {'quantity': quantity}, '$set': { 'item': item} })
       return cart_id

   def place_order(self, cart_id):
           while True:
               try:
                   with self.client.start_session() as ses:
                       ses.start_transaction()
                       cart = self.carts.find_one({'cart_id': cart_id}, session=ses)
                       item_id = cart['item']
                       quantity = cart['quantity']
                       # update payments
                       self.db.payments.insert_one({'cart_id': cart_id, 'item_id': item_id, 'status': 'paid'}, session=ses)
                       # remove item from cart
                       self.db.carts.update_one({'cart_id': cart_id}, {'$inc': {'quantity': quantity * (-1)}}, session=ses)
                       # update inventories
                       self.db.inventories.update_one({'item_id': item_id}, {'$inc': {'quantity': quantity*(-1)}}, session=ses)
                       ses.commit_transaction()
                       break
               except (ConnectionFailure, OperationFailure) as exc:
                   print("Transaction aborted. Caught exception during transaction.")
                   # If transient error, retry the whole transaction
                   if exc.has_error_label("TransientTransactionError"):
                       print("TransientTransactionError, retrying transaction ...")
                       continue
                   elif str(exc) == 'Document failed validation':
                       print("error validating document!")
                       raise
                   else:
                       print("Unknown error during commit ...")
                       raise
def main():
   ECommerce()
if __name__ == '__main__':
   main()

我们将把前面的例子分解为有趣的部分,如下所示:

   def add_to_cart(self, user, item, quantity):
       # find cart for user
       cart_id = self.carts.find_one({'user_id':user})['cart_id']
       self.carts.update_one({'cart_id': cart_id}, {'$inc': {'quantity': quantity}, '$set': { 'item': item} })
       return cart_id

add_to_cart()方法不使用事务。原因是因为我们一次只更新一个文档,这些操作是原子操作。

然后,在place_order()方法中,我们启动会话,然后随后在此会话中启动事务。与前一个用例类似,我们需要确保在我们想要在事务上下文中执行的每个操作的末尾添加session=ses参数:

    def place_order(self, cart_id):
 while True:
 try:
 with self.client.start_session() as ses:
 ses.start_transaction()
 …
 # update payments
 self.db.payments.insert_one({'cart_id': cart_id, 'item_id': item_id, 'status': 'paid'}, session=ses)
 # remove item from cart
 self.db.carts.update_one({'cart_id': cart_id}, {'$inc': {'quantity': quantity * (-1)}}, session=ses)
 # update inventories
 self.db.inventories.update_one({'item_id': item_id}, {'$inc': {'quantity': quantity*(-1)}}, session=ses)
 ses.commit_transaction()
 break
 except (ConnectionFailure, OperationFailure) as exc:
 print("Transaction aborted. Caught exception during transaction.")
 # If transient error, retry the whole transaction
 if exc.has_error_label("TransientTransactionError"):
 print("TransientTransactionError, retrying transaction ...")
 continue
 elif str(exc) == 'Document failed validation':
 print("error validating document!")
 raise
 else:
 print("Unknown error during commit ...")
 raise

在这种方法中,我们使用可重试事务模式。我们首先将事务上下文包装在while True块中,从本质上使其永远循环。然后我们在一个try块中包含我们的事务,它将监听异常。

transient transaction类型的异常,具有TransientTransactionError错误标签,将导致在while True块中继续执行,从而从头开始重试事务。另一方面,验证失败或任何其他错误将在记录后重新引发异常。

session.commitTransaction()session.abortTransaction()操作将被 MongoDB 重试一次,无论我们是否重试事务。

在这个例子中,我们不需要显式调用abortTransaction(),因为 MongoDB 会在面对异常时中止它。

最后,我们的数据库看起来像下面的代码块:

> db.payments.find()
{ "_id" : ObjectId("5bc307178e72b431c0de385f"), "cart_id" : 1, "name" : "alex", "item_id" : 101, "status" : "paid" }
{ "_id" : ObjectId("5bc307178e72b431c0de3861"), "cart_id" : 1, "item_id" : 101, "status" : "paid" }

我们刚刚进行的付款没有名称字段,与我们在滚动事务之前插入数据库的示例付款相反:

> db.inventories.find()
{ "_id" : ObjectId("5bc303468e72b43118dda074"), "item_id" : 101, "description" : "bull bearing", "price" : 100, "quantity" : 3 }

我们的库存中有正确数量的滚珠轴承,三个(五减去 Alex 订购的两个),如下面的代码块所示:

> db.carts.find()
{ "_id" : ObjectId("5bc307178e72b431c0de385d"), "cart_id" : 1, "user_id" : 1, "item" : 101, "quantity" : 0 }
{ "_id" : ObjectId("5bc307178e72b431c0de385e"), "cart_id" : 2, "user_id" : 2, "item" : 101, "quantity" : 4 }

我们的购物车中有正确的数量。 Alex 的购物车(cart_id=1)没有物品,而 Barbara 的购物车(cart_id=2)仍然有四个,因为我们没有足够的滚珠轴承来满足她的订单。我们的支付集合中没有 Barbara 订单的条目,库存中仍然有三个滚珠轴承。

我们的数据库状态是一致的,并且通过在应用程序级别实现中止事务和对账数据逻辑来节省大量时间。

在 Ruby 中继续使用相同的示例,我们有以下代码块:

require 'mongo'

class ECommerce
 def initialize
   @client = Mongo::Client.new([ '127.0.0.1:27017' ], database: :mongo_bank)
   db = @client.database
   @users = db[:users]
   @carts = db[:carts]
   @payments = db[:payments]
   @inventories = db[:inventories]

   # drop any existing data
   @users.drop
   @carts.drop
   @payments.drop
   @inventories.delete_many

   # insert data
   @users.insert_one({ "user_id": 1, "name": "alex" })
   @users.insert_one({ "user_id": 2, "name": "barbara" })

   @carts.insert_one({ "cart_id": 1, "user_id": 1 })
   @carts.insert_one({ "cart_id": 2, "user_id": 2 })

   @payments.insert_one({"cart_id": 1, "name": "alex", "item_id": 101, "status": "paid" })
   @inventories.insert_one({"item_id": 101, "description": "bull bearing", "price": 100, "quantity": 5 })

   alex_order_cart_id = add_to_cart(1, 101, 2)
   barbara_order_cart_id = add_to_cart(2, 101, 4)

   place_order(alex_order_cart_id)
   place_order(barbara_order_cart_id)
 end

 def add_to_cart(user, item, quantity)
   session = @client.start_session
   session.start_transaction
   cart_id = @users.find({ "user_id": user}).first['user_id']
   @carts.update_one({"cart_id": cart_id}, {'$inc': { 'quantity': quantity }, '$set': { 'item': item } }, session: session)
   session.commit_transaction
   cart_id
 end

 def place_order(cart_id)
   session = @client.start_session
   session.start_transaction
   cart = @carts.find({'cart_id': cart_id}, session: session).first
   item_id = cart['item']
   quantity = cart['quantity']
   @payments.insert_one({'cart_id': cart_id, 'item_id': item_id, 'status': 'paid'}, session: session)
   @carts.update_one({'cart_id': cart_id}, {'$inc': {'quantity': quantity * (-1)}}, session: session)
   @inventories.update_one({'item_id': item_id}, {'$inc': {'quantity': quantity*(-1)}}, session: session)
   quantity = @inventories.find({'item_id': item_id}, session: session).first['quantity']
   if quantity < 0
     session.abort_transaction
   else
     session.commit_transaction
   end
 end
end

ECommerce.new

与 Python 代码示例类似,我们在每个操作中传递session: session参数,以确保我们在事务内进行操作。

在这里,我们没有使用可重试的事务模式。无论如何,MongoDB 都会在抛出异常之前重试提交或中止事务一次。

多文档 ACID 事务的最佳实践和限制

在开发中使用 MongoDB 4.0.3 版本的事务时,目前存在一些限制和最佳实践:

  • 事务超时设置为 60 秒。

  • 作为最佳实践,任何事务不应尝试修改超过 1,000 个文档。在事务期间读取文档没有限制。

  • oplog 将记录事务的单个条目,这意味着这受到 16MB 文档大小限制的影响。对于更新文档的事务来说,这并不是一个大问题,因为 oplog 只会记录增量。然而,当事务插入新文档时,这可能会成为一个问题,此时 oplog 将记录新文档的全部内容。

  • 我们应该添加应用程序逻辑来处理失败的事务。这可能包括使用可重试写入,或者在错误无法重试或我们已经耗尽重试时执行一些业务逻辑驱动的操作(通常意味着自定义 500 错误)。

  • 诸如修改索引、集合或数据库之类的 DDL 操作将排队等待活动事务。在 DDL 操作仍在进行时尝试访问命名空间的事务将立即中止。

  • 事务只在副本集中起作用。从 MongoDB 4.2 开始,事务也将适用于分片集群。

  • 节制使用;在开发中使用 MongoDB 事务时可能要考虑的最重要的一点是,它们并不是用来替代良好模式设计的。只有在没有其他方法可以对数据进行建模时才应该使用它们。

总结

在本章中,我们了解了在 MongoDB 的上下文中关于 ACID 的教科书关系数据库理论。

然后,我们专注于多文档 ACID 事务,并在 Ruby 和 Python 中应用它们到两个用例中。我们了解了何时使用 MongoDB 事务以及何时不使用它们,如何使用它们,它们的最佳实践和限制。

在下一章中,我们将处理 MongoDB 最常用的功能之一 - 聚合。

第六章:聚合

在第五章《多文档 ACID 事务》中,我们使用 Ruby 和 Python 的代码解决了新事务功能的两个用例。在本章中,我们将更深入地了解聚合框架,学习它如何有用。我们还将看看 MongoDB 支持的操作符。

为了了解这些信息,我们将使用聚合来处理以太坊区块链的交易数据。完整的源代码可在github.com/PacktPublishing/Mastering-MongoDB-4.x-Second-Edition上找到。

在本章中,我们将涵盖以下主题:

  • 为什么要使用聚合?

  • 不同的聚合操作符

  • 限制

为什么要使用聚合?

聚合框架是由 MongoDB 在 2.2 版本中引入的(在开发分支中是 2.1 版本)。它作为 MapReduce 框架和直接查询数据库的替代方案。

使用聚合框架,我们可以在服务器上执行GROUP BY操作。因此,我们可以只投影结果集中需要的字段。使用$match$project操作符,我们可以减少通过管道传递的数据量,从而加快数据处理速度。

自连接——也就是在同一集合内连接数据——也可以使用聚合框架来执行,正如我们将在我们的用例中看到的那样。

将聚合框架与仅使用 shell 或其他驱动程序提供的查询进行比较时,重要的是要记住两者都有用途。

对于选择和投影查询,几乎总是更好使用简单的查询,因为开发、测试和部署聚合框架操作的复杂性很难超过使用内置命令的简单性。查找具有( db.books.find({price: 50} {price: 1, name: 1}) )的文档,或者没有( db.books.find({price: 50}) )只投影一些字段,是简单且足够快速,不需要使用聚合框架。

另一方面,如果我们想使用 MongoDB 执行GROUP BY和自连接操作,可能需要使用聚合框架。在 MongoDB shell 中group()命令的最重要限制是结果集必须适合一个文档,这意味着它的大小不能超过 16MB。此外,任何group()命令的结果不能超过 20,000 个。最后,group()不适用于分片输入集合,这意味着当我们的数据量增加时,我们必须重新编写我们的查询。

与 MapReduce 相比,聚合框架在功能和灵活性上更有限。在聚合框架中,我们受到可用操作符的限制。但好的一面是,聚合框架的 API 比 MapReduce 更容易理解和使用。在性能方面,聚合框架在 MongoDB 早期版本中比 MapReduce 快得多,但在 MapReduce 性能改进后,似乎与最新版本持平。

最后,还有一种选择,就是使用数据库作为数据存储,并使用应用程序执行复杂操作。有时这可能会很快开发,但应该避免,因为最终可能会产生内存、网络和性能成本。

在下一节中,我们将在使用实际用例之前描述可用的操作符。

聚合操作符

在本节中,我们将学习如何使用聚合操作符。聚合操作符分为两类。在每个阶段中,我们使用表达式操作符来比较和处理值。在不同阶段之间,我们使用聚合阶段操作符来定义将从一个阶段传递到下一个阶段的数据,因为它被认为是以相同格式呈现的。

聚合阶段操作符

聚合管道由不同的阶段组成。这些阶段在数组中声明并按顺序执行,每个阶段的输出都是下一个阶段的输入。

$out 阶段必须是聚合管道中的最终阶段,通过替换或添加到现有文档将数据输出到输出集合:

  • $group:最常用于按标识符表达式分组,并应用累加器表达式。它输出每个不同组的一个文档。

  • $project:用于文档转换,每个输入文档输出一个文档。

  • $match:根据条件从输入中过滤文档。

  • $lookup:用于从输入中过滤文档。输入可以是同一数据库中另一个集合中的文档,由外部左连接选择。

  • $out:将此管道阶段的文档输出到输出集合,以替换或添加到已存在于集合中的文档。

  • $limit:根据预定义的条件限制传递到下一个聚合阶段的文档数量。

  • $count:返回管道阶段的文档数量。

  • $skip:跳过一定数量的文档,防止它们传递到管道的下一阶段。

  • $sort:根据条件对文档进行排序。

  • $redact:作为 $project$match 的组合,这将从每个文档中选择的字段进行 redact,并将它们传递到管道的下一阶段。

  • $unwind:这将数组中的 n 个元素转换为 n 个文档,将每个文档映射到数组的一个元素。然后将这些文档传递到管道的下一阶段。

  • $collStats:返回有关视图或集合的统计信息。

  • $indexStats:返回集合索引的统计信息。

  • $sample:从输入中随机选择指定数量的文档。

  • $facet:在单个阶段内组合多个聚合管道。

  • $bucket:根据预定义的选择标准和桶边界将文档分割成桶。

  • $bucketAuto:根据预定义的选择标准将文档分割成桶,并尝试在桶之间均匀分布文档。

  • $sortByCount:根据表达式的值对传入的文档进行分组,并计算每个桶中的文档数量。

  • $addFields:这将向文档添加新字段,并输出与输入相同数量的文档,带有添加的字段。

  • $replaceRoot:用指定的字段替换输入文档的所有现有字段(包括 standard _id 字段)。

  • $geoNear:根据与指定字段的接近程度返回文档的有序列表。输出文档包括一个计算出的 distance 字段。

  • $graphLookup:递归搜索集合,并在每个输出文档中添加一个包含搜索结果的数组字段。

表达式运算符

在每个阶段中,我们可以定义一个或多个表达式运算符来应用我们的中间计算。本节将重点介绍这些表达式运算符。

表达式布尔运算符

布尔运算符用于将 truefalse 的值传递到我们聚合管道的下一阶段。

我们也可以选择传递原始的 integerstring 或任何其他类型的值。

我们可以像在任何编程语言中一样使用 $and$or$not 运算符。

表达式比较运算符

比较运算符可以与布尔运算符结合使用,构建我们需要评估为 true/false 的表达式,以输出管道阶段的结果。

最常用的运算符如下:

  • $eq ( equal )

  • $ne ( not equal)

  • $gt (greater than)

  • $gte (greater than or equal)

  • $lt

  • $lte

所有上述运算符返回 truefalse 的布尔值。

唯一不返回布尔值的运算符是$cmp,如果两个参数相等则返回0,如果第一个值大于第二个值则返回1,如果第二个值大于第一个值则返回-1

集合表达式和数组运算符

与大多数编程语言一样,集合操作会忽略重复的条目和元素的顺序,将它们视为集合。结果的顺序是未指定的,并且重复的条目将在结果集中被去重。集合表达式不会递归应用于集合的元素,而只会应用于顶层。这意味着,如果一个集合包含,例如,一个嵌套数组,那么这个数组可能包含重复项,也可能不包含。

可用的集合运算符如下:

  • $setEquals:如果两个集合具有相同的不同元素,则为true

  • $setIntersection:返回所有输入集合的交集(即出现在所有输入集合中的文档)

  • $setUnion:返回所有输入集合的并集(即出现在所有输入集合中的至少一个文档)

  • $setDifference:返回出现在第一个输入集合中但不在第二个输入集合中的文档

  • $setIsSubset:如果第一个集合中的所有文档都出现在第二个集合中,则为true,即使这两个集合是相同的。

  • $anyElementTrue:如果集合中的任何元素求值为true,则为true

  • $allElementsTrue:如果集合中的所有元素求值为true,则为true

可用的数组运算符如下:

  • $arrayElemAt:返回数组索引位置的元素。

  • $concatArrays:返回一个连接的数组。

  • $filter:根据指定的条件返回数组的子集。

  • $indexOfArray:返回满足搜索条件的数组的索引。如果没有,则返回-1

  • $isArray:如果输入是数组,则返回true;否则返回false

  • $range:根据用户定义的输入输出包含一系列整数的数组。

  • $reverseArray:返回元素顺序相反的数组。

  • $reduce:根据指定的输入将数组的元素减少为单个值。

  • $size:返回数组中的项目数。

  • $slice:返回数组的子集。

  • $zip:返回合并的数组。

  • $in:如果指定的值在数组中,则返回true;否则返回false

表达式日期运算符

日期运算符用于从日期字段中提取日期信息,当我们想要基于一周/月/年的统计数据计算时,使用管道:

  • $dayOfYear 用于获取一年中的日期,范围为 1 到 366(闰年)

  • $dayOfMonth 用于获取一个月中的日期,范围为 1 到 31

  • $dayOfWeek 用于获取一周中的日期,范围为 1 到 7,其中 1 代表星期日,7 代表星期六(使用英文星期几)

  • $isoDayOfWeek 返回 ISO 8601 日期格式中的星期几编号,范围为 1 到 7,其中 1 代表星期一,7 代表星期日

  • $week 是 0 到 53 范围内的周数,0 代表每年年初的部分周,53 代表有闰周的年份

  • $isoWeek 返回 ISO 8601 日期格式中的周数,范围为 1 到 53,1 代表包含星期四的年份的第一周,53 代表有闰周的年份

  • $year$month$hour$minute$milliSecond 返回日期的相关部分,从零开始编号,除了$month,它返回从 1 到 12 的值

  • $isoWeekYear 根据 ISO 8601 日期格式返回日期的年份,该日期是 ISO 8601 日期格式中最后一周结束的日期(例如,2016/1/1 仍然返回 2015)

  • $second 返回 0 到 60 的值,包括闰秒

  • $dateToString 将日期输入转换为字符串

表达式字符串运算符

与日期运算符一样,字符串运算符用于在我们想要将数据从管道的一个阶段转换到下一个阶段时使用。潜在的用例包括预处理文本字段以提取相关信息,以便在管道的后续阶段中使用:

  • $concat: 这用于连接字符串。

  • $split: 这用于根据分隔符拆分字符串。如果找不到分隔符,则返回原始字符串。

  • $strcasecmp: 这用于不区分大小写的字符串比较。如果字符串相等,则返回0,如果第一个字符串较大,则返回1;否则返回-1

  • $toLower/$toUpper: 这用于将字符串转换为全小写或全大写。

  • $indexOfBytes: 这用于返回字符串中子字符串的第一个出现的字节位置。

  • $strLenBytes: 这是输入字符串的字节数。

  • $substrBytes: 这返回子字符串的指定字节。

代码点的等效方法(Unicode 中的一个值,不考虑其表示中的基础字节)如下:

  • $indexOfCP

  • $strLenCP

  • $substrCP

表达式算术运算符

在管道的每个阶段,我们可以应用一个或多个算术运算符来执行中间计算。这些运算符在以下列表中显示:

  • $abs: 这是绝对值。

  • $add: 这可以将数字或日期加上一个数字以得到一个新的日期。

  • $ceil/$floor: 这些分别是向上取整和向下取整函数。

  • $divide: 这用于由两个输入进行除法。

  • $exp: 这将自然数e提升到指定的指数幂。

  • $pow: 这将一个数字提升到指定的指数幂。

  • $ln/$log/$log10: 这些用于计算自然对数、自定义底数的对数或以十为底的对数。

  • $mod: 这是模值。

  • $multiply: 这用于将输入相乘。

  • $sqrt: 这是输入的平方根。

  • $subtract: 这是从第二个值中减去第一个值的结果。如果两个参数都是日期,则返回它们之间的差值。如果一个参数是日期(这个参数必须是第一个参数),另一个是数字,则返回结果日期。

  • $trunc: 这用于截断结果。

聚合累加器

累加器可能是最广泛使用的运算符,因为它们允许我们对我们组中的每个成员进行求和、平均值、获取标准偏差统计数据以及执行其他操作。以下是聚合累加器的列表:

  • $sum: 这是数值的总和。它会忽略非数值。

  • $avg: 这是数值的平均值。它会忽略非数值。

  • $first/$last: 这是通过管道阶段的第一个和最后一个值。它仅在组阶段中可用。

  • $max/$min: 这分别获取通过管道阶段的最大值和最小值。

  • $push: 这将一个新元素添加到输入数组的末尾。它仅在组阶段中可用。

  • $addToSet: 这将一个元素(仅当它不存在时)添加到数组中,有效地将其视为一个集合。它仅在组阶段中可用。

  • $stdDevPop/$stdDevSamp: 这些用于在$project$match阶段获取总体/样本标准偏差。

这些累加器在组或项目管道阶段中都可用,除非另有说明。

条件表达式

表达式可以根据布尔真值测试将不同的数据输出到我们管道中的下一阶段:

$cond

$cond短语将评估格式为if...then...else的表达式,并根据if语句的结果返回then语句或else分支的值。输入可以是三个命名参数或有序列表中的三个表达式。

$ifNull

$ifNull短语将评估一个表达式,并在其不为 null 时返回第一个表达式,如果第一个表达式为 null,则返回第二个表达式。Null 可以是一个缺失的字段或一个具有未定义值的字段:

$switch

类似于编程语言的switch语句,$switch将在评估为true时执行指定的表达式,并跳出控制流。

类型转换运算符

在 MongoDB 4.0 中引入的类型转换运算符允许我们将值转换为指定的类型。命令的通用语法如下:

{
   $convert:
      {
         input: <expression>,
         to: <type expression>,
         onError: <expression>,  // Optional.
         onNull: <expression>    // Optional.
      } }

在此语法中,inputto(唯一的强制参数)可以是任何有效的表达式。在其最简单的形式中,我们可以,例如,有以下内容:

$convert: { input: "true", to: "bool" } 

将值为true的字符串转换为布尔值true

onError短语可以是任何有效的表达式,指定了在转换过程中 MongoDB 遇到错误时将返回的值,包括不支持的类型转换。其默认行为是抛出错误并停止处理。

onNull短语也可以是任何有效的表达式,指定了如果输入为 null 或缺失时 MongoDB 将返回的值。默认行为是返回 null。

MongoDB 还为最常见的$convert操作提供了一些辅助函数。这些函数如下:

  • $toBool

  • $toDate

  • $toDecimal

  • $toDouble

  • $toInt

  • $toLong

  • $toObjectId

  • $toString

这些更简单易用。我们可以将前面的示例重写为以下形式:

{ $toBool: "true" }

其他操作符

有一些操作符并不常用,但在特定用例中可能很有用。其中最重要的列在以下部分中。

文本搜索

$meta运算符用于访问文本搜索元数据。

变量

$map运算符将子表达式应用于数组的每个元素,并返回结果值的数组。它接受命名参数。

$let运算符为子表达式的范围内定义变量,并返回子表达式的结果。它接受命名参数。

字面值

$literal运算符将返回一个不经解析的值。它用于聚合管道可能解释为表达式的值。例如,您可以将$literal表达式应用于以$开头的字符串,以避免解析为字段路径。

解析数据类型

$type运算符返回字段的BSON数据类型。

限制

聚合管道可以以以下三种不同的方式输出结果:

  • 内联作为包含结果集的文档

  • 在一个集合中

  • 返回结果集的游标

内联结果受BSON最大文档大小 16 MB 的限制,这意味着我们只能在最终结果是固定大小时使用它。一个例子是从电子商务网站输出前五个最常订购商品的ObjectId

与此相反的例子是输出前 1,000 个最常订购的商品,以及产品信息,包括描述和其他大小可变的字段。

如果我们想对数据进行进一步处理,将结果输出到集合是首选解决方案。我们可以将结果输出到新集合,或替换现有集合的内容。聚合输出结果只有在聚合命令成功后才会可见;否则,它将根本不可见。

输出集合不能是分片的或有上限的集合(截至 v3.4)。如果聚合输出违反索引(包括每个文档的唯一ObjectId上的内置索引)或文档验证规则,聚合将失败。

每个管道阶段可以有超过 16MB 限制的文档,因为这些由 MongoDB 在内部处理。然而,每个管道阶段只能使用最多 100MB 的内存。如果我们期望在我们的阶段中有更多的数据,我们应该将allowDiskUse:设置为true,以允许多余的数据溢出到磁盘,以换取性能。

$graphLookup运算符不支持超过 100MB 的数据集,并将忽略allowDiskUse上的任何设置。

聚合使用案例

在这个相当冗长的部分中,我们将使用聚合框架来处理以太坊区块链的数据。

使用我们的 Python 代码,我们已经从以太坊中提取了数据,并将其加载到我们的 MongoDB 数据库中。区块链与我们的数据库的关系如下图所示:

我们的数据驻留在两个集合中:blockstransactions

样本区块文档具有以下字段:

  • 交易数量

  • 承包内部交易的数量

  • 区块哈希

  • 父区块哈希

  • 挖矿难度

  • 使用的燃气

  • 区块高度

以下代码显示了区块的输出数据:

> db.blocks.findOne()
{
"_id" : ObjectId("595368fbcedea89d3f4fb0ca"),
"number_transactions" : 28,
"timestamp" : NumberLong("1498324744877"),
"gas_used" : 4694483,
"number_internal_transactions" : 4,
"block_hash" : "0x89d235c4e2e4e4978440f3cc1966f1ffb343b9b5cfec9e5cebc331fb810bded3",
"difficulty" : NumberLong("882071747513072"),
"block_height" : 3923788
}

样本交易文档具有以下字段:

  • 交易哈希

  • 它所属的区块高度

  • 从哈希地址

  • 到哈希地址

  • 交易价值

  • 交易费用

以下代码显示了交易的输出数据:

> db.transactions.findOne()
{
"_id" : ObjectId("59535748cedea89997e8385a"),
"from" : "0x3c540be890df69eca5f0099bbedd5d667bd693f3",
"txfee" : 28594,
"timestamp" : ISODate("2017-06-06T11:23:10Z"),
"value" : 0,
"to" : "0x4b9e0d224dabcc96191cace2d367a8d8b75c9c81",
"txhash" : "0xf205991d937bcb60955733e760356070319d95131a2d9643e3c48f2dfca39e77",
"block" : 3923794
}

我们的数据库的样本数据可在 GitHub 上找到:github.com/PacktPublishing/Mastering-MongoDB-4.x-Second-Edition

作为使用这种新型区块链技术的好奇开发人员,我们想要分析以太坊交易。我们特别希望做到以下几点:

  • 找到交易发起的前十个地址

  • 找到交易结束的前十个地址

  • 找到每笔交易的平均值,并统计偏差

  • 找到每笔交易所需的平均费用,并统计偏差

  • 找到网络在一天中的哪个时间更活跃,根据交易的数量或价值

  • 找到网络在一周中的哪一天更活跃,根据交易的数量或价值

我们找到了交易发起的前十个地址。为了计算这个指标,我们首先计算每个from字段的值为1的出现次数,然后按from字段的值对它们进行分组,并将它们输出到一个名为count的新字段中。

之后,我们按照count字段的值按降序(-1)排序,最后,我们将输出限制为通过管道的前十个文档。这些文档是我们正在寻找的前十个地址。

以下是一些示例 Python 代码:

   def top_ten_addresses_from(self):
       pipeline = [
           {"$group": {"_id": "$from", "count": {"$sum": 1}}},
           {"$sort": SON([("count", -1)])},
           {"$limit": 10},
       ]
       result = self.collection.aggregate(pipeline)
       for res in result:
           print(res)

前面代码的输出如下:

{u'count': 38, u'_id': u'miningpoolhub_1'}
{u'count': 31, u'_id': u'Ethermine'}
{u'count': 30, u'_id': u'0x3c540be890df69eca5f0099bbedd5d667bd693f3'}
{u'count': 27, u'_id': u'0xb42b20ddbeabdc2a288be7ff847ff94fb48d2579'}
{u'count': 25, u'_id': u'ethfans.org'}
{u'count': 16, u'_id': u'Bittrex'}
{u'count': 8, u'_id': u'0x009735c1f7d06faaf9db5223c795e2d35080e826'}
{u'count': 8, u'_id': u'Oraclize'}
{u'count': 7, u'_id': u'0x1151314c646ce4e0efd76d1af4760ae66a9fe30f'}
{u'count': 7, u'_id': u'0x4d3ef0e8b49999de8fa4d531f07186cc3abe3d6e'}

现在我们找到了交易结束的前十个地址。就像我们对from所做的那样,对to地址的计算也完全相同,只是使用to字段而不是from进行分组,如下面的代码所示:

   def top_ten_addresses_to(self):
       pipeline = [
           {"$group": {"_id": "$to", "count": {"$sum": 1}}},
           {"$sort": SON([("count", -1)])},
           {"$limit": 10},
       ]
       result = self.collection.aggregate(pipeline)
       for res in result:
           print(res)

前面代码的输出如下:

{u'count': 33, u'_id': u'0x6090a6e47849629b7245dfa1ca21d94cd15878ef'}
{u'count': 30, u'_id': u'0x4b9e0d224dabcc96191cace2d367a8d8b75c9c81'}
{u'count': 25, u'_id': u'0x69ea6b31ef305d6b99bb2d4c9d99456fa108b02a'}
{u'count': 23, u'_id': u'0xe94b04a0fed112f3664e45adb2b8915693dd5ff3'}
{u'count': 22, u'_id': u'0x8d12a197cb00d4747a1fe03395095ce2a5cc6819'}
{u'count': 18, u'_id': u'0x91337a300e0361bddb2e377dd4e88ccb7796663d'}
{u'count': 13, u'_id': u'0x1c3f580daeaac2f540c998c8ae3e4b18440f7c45'}
{u'count': 12, u'_id': u'0xeef274b28bd40b717f5fea9b806d1203daad0807'}
{u'count': 9, u'_id': u'0x96fc4553a00c117c5b0bed950dd625d1c16dc894'}
{u'count': 9, u'_id': u'0xd43d09ec1bc5e57c8f3d0c64020d403b04c7f783'}

让我们找到每笔交易的平均值,并统计标准偏差。在这个示例中,我们使用$avg$stdDevPop操作符来计算value字段的统计数据。使用简单的$group操作,我们输出一个具有我们选择的 ID(这里是value)和averageValues的单个文档,如下面的代码所示:

   def average_value_per_transaction(self):
       pipeline = [
           {"$group": {"_id": "value", "averageValues": {"$avg": "$value"}, "stdDevValues": {"$stdDevPop": "$value"}}},
       ]
       result = self.collection.aggregate(pipeline)
       for res in result:
           print(res)

前面代码的输出如下:

{u'averageValues': 5.227238976440972, u'_id': u'value', u'stdDevValues': 38.90322689649576}

让我们找到每笔交易所需的平均费用,返回有关偏差的统计数据。平均费用类似于平均值,只是将$value替换为$txfee,如下面的代码所示:

   def average_fee_per_transaction(self):
       pipeline = [
           {"$group": {"_id": "value", "averageFees": {"$avg": "$txfee"}, "stdDevValues": {"$stdDevPop": "$txfee"}}},
       ]
       result = self.collection.aggregate(pipeline)
       for res in result:
           print(res)

前面代码片段的输出如下:

{u'_id': u'value', u'averageFees': 320842.0729166667, u'stdDevValues': 1798081.7305142984} 

我们找到网络在特定时间更活跃的时间。

为了找出交易最活跃的小时,我们使用$hour运算符从我们存储了datetime值并称为timestampISODate()字段中提取hour字段,如下面的代码所示:

   def active_hour_of_day_transactions(self):
       pipeline = [
           {"$group": {"_id": {"$hour": "$timestamp"}, "transactions": {"$sum": 1}}},
           {"$sort": SON([("transactions", -1)])},
           {"$limit": 1},
       ]
       result = self.collection.aggregate(pipeline)
       for res in result:
           print(res)

输出如下:

{u'_id': 11, u'transactions': 34} 

以下代码将计算一天中交易价值最高的小时的交易总值:

  def active_hour_of_day_values(self):
 pipeline = [
 {"$group": {"_id": {"$hour": "$timestamp"}, "transaction_values": {"$sum": "$value"}}},
 {"$sort": SON([("transactions", -1)])},
 {"$limit": 1},
 ]
 result = self.collection.aggregate(pipeline)
 for res in result:
 print(res)

上述代码的输出如下:

{u'transaction_values': 33.17773841, u'_id': 20} 

让我们找出网络活动最频繁的一天是一周中的哪一天,根据交易数量或交易价值。与一天中的小时一样,我们使用$dayOfWeek运算符从ISODate()对象中提取一周中的哪一天,如下面的代码所示。按照美国的惯例,星期天为一,星期六为七:

   def active_day_of_week_transactions(self):
       pipeline = [
           {"$group": {"_id": {"$dayOfWeek": "$timestamp"}, "transactions": {"$sum": 1}}},
           {"$sort": SON([("transactions", -1)])},
           {"$limit": 1},
       ]
       result = self.collection.aggregate(pipeline)
       for res in result:
           print(res)

上述代码的输出如下:

{u'_id': 3, u'transactions': 92} 

以下代码将计算一周中交易价值最高的一天的交易总值:

  def active_day_of_week_values(self):
       pipeline = [
           {"$group": {"_id": {"$dayOfWeek": "$timestamp"}, "transaction_values": {"$sum": "$value"}}},
           {"$sort": SON([("transactions", -1)])},
           {"$limit": 1},
       ]
       result = self.collection.aggregate(pipeline)
 for res in result:
 print(res)

上述代码的输出如下:


 {u'transaction_values': 547.62439312, u'_id': 2} 

我们计算的聚合可以用以下图表描述:

在区块方面,我们想了解以下内容:

  • 每个区块的平均交易数量,包括总体交易数量和合约内部交易的总体交易数量。

  • 每个区块的平均燃气使用量。

  • 每个交易到区块的平均燃气使用量。是否有机会在一个区块中提交我的智能合约?

  • 每个区块的平均难度及其偏差。

  • 每个区块的平均交易数量,总交易数量以及合约内部交易的平均交易数量。

通过对number_transactions字段进行平均,我们可以得到每个区块的交易数量,如下面的代码所示:

   def average_number_transactions_total_block(self):
       pipeline = [
           {"$group": {"_id": "average_transactions_per_block", "count": {"$avg": "$number_transactions"}}},
       ]
       result = self.collection.aggregate(pipeline)
       for res in result:
           print(res)

上述代码的输出如下:

 {u'count': 39.458333333333336, u'_id': u'average_transactions_per_block'}

而使用以下代码,我们可以得到每个区块的内部交易的平均数量:

  def average_number_transactions_internal_block(self):
       pipeline = [
           {"$group": {"_id": "average_transactions_internal_per_block", "count": {"$avg": "$number_internal_transactions"}}},
       ]
       result = self.collection.aggregate(pipeline)
       for res in result:
           print(res)

上述代码的输出如下:

{u'count': 8.0, u'_id': u'average_transactions_internal_per_block'}

每个区块使用的平均燃气量可以通过以下方式获得:

def average_gas_block(self):
       pipeline = [
           {"$group": {"_id": "average_gas_used_per_block",
                       "count": {"$avg": "$gas_used"}}},
       ]
       result = self.collection.aggregate(pipeline)
       for res in result:
           print(res)

输出如下:

{u'count': 2563647.9166666665, u'_id': u'average_gas_used_per_block'} 

每个区块的平均难度及其偏差可以通过以下方式获得:

  def average_difficulty_block(self):
       pipeline = [
           {"$group": {"_id": "average_difficulty_per_block",
                       "count": {"$avg": "$difficulty"}, "stddev": {"$stdDevPop": "$difficulty"}}},
       ]
       result = self.collection.aggregate(pipeline)
       for res in result:
           print(res)

输出如下:

{u'count': 881676386932100.0, u'_id': u'average_difficulty_per_block', u'stddev': 446694674991.6385} 

我们的聚合描述如下模式:

现在我们已经计算了基本统计数据,我们想要提高我们的水平,并了解有关我们的交易的更多信息。通过我们复杂的机器学习算法,我们已经确定了一些交易是欺诈或首次代币发行(ICO),或者两者兼而有之。

在这些文档中,我们已经在一个名为tags的数组中标记了这些属性,如下所示:

{
 "_id" : ObjectId("59554977cedea8f696a416dd"),
 "to" : "0x4b9e0d224dabcc96191cace2d367a8d8b75c9c81",
 "txhash" : "0xf205991d937bcb60955733e760356070319d95131a2d9643e3c48f2dfca39e77",
 "from" : "0x3c540be890df69eca5f0099bbedd5d667bd693f3",
 "block" : 3923794,
 "txfee" : 28594,
 "timestamp" : ISODate("2017-06-10T09:59:35Z"),
 "tags" : [
 "scam",
 "ico"
 ],
 "value" : 0
 }

现在我们想要获取 2017 年 6 月的交易,移除_id字段,并根据我们已经识别的标签生成不同的文档。因此,在我们的示例中,我们将在我们的新集合scam_ico_documents中输出两个文档,以便进行单独处理。

通过聚合框架进行此操作的方式如下所示:

def scam_or_ico_aggregation(self):
 pipeline = [
 {"$match": {"timestamp": {"$gte": datetime.datetime(2017,6,1), "$lte": datetime.datetime(2017,7,1)}}},
 {"$project": {
 "to": 1,
 "txhash": 1,
 "from": 1,
 "block": 1,
 "txfee": 1,
 "tags": 1,
 "value": 1,
 "report_period": "June 2017",
 "_id": 0,
 }

 },
 {"$unwind": "$tags"},
 {"$out": "scam_ico_documents"}
 ]
 result = self.collection.aggregate(pipeline)
 for res in result:
 print(res)

在聚合框架管道中,我们有以下四个不同的步骤:

  1. 使用$match,我们只提取具有timestamp字段值为 2017 年 6 月 1 日的文档。

  2. 使用$project,我们添加一个名为report_period的新字段,其值为2017 年 6 月,并通过将其值设置为0来移除_id字段。我们通过使用值1保持其余字段不变,如前面的代码所示。

  3. 使用$unwind,我们在我们的$tags数组中为每个标签输出一个新文档。

  4. 最后,使用$out,我们将所有文档输出到一个新的scam_ico_documents集合中。

由于我们使用了$out运算符,在命令行中将得不到任何结果。如果我们注释掉{"$out": "scam_ico_documents"},我们将得到以下类似的文档:

{u'from': u'miningpoolhub_1', u'tags': u'scam', u'report_period': u'June 2017', u'value': 0.52415349, u'to': u'0xdaf112bcbd38d231b1be4ae92a72a41aa2bb231d', u'txhash': u'0xe11ea11df4190bf06cbdaf19ae88a707766b007b3d9f35270cde37ceccba9a5c', u'txfee': 21.0, u'block': 3923785}

我们数据库中的最终结果将如下所示:

{
 "_id" : ObjectId("5955533be9ec57bdb074074e"),
 "to" : "0x4b9e0d224dabcc96191cace2d367a8d8b75c9c81",
 "txhash" : "0xf205991d937bcb60955733e760356070319d95131a2d9643e3c48f2dfca39e77",
 "from" : "0x3c540be890df69eca5f0099bbedd5d667bd693f3",
 "block" : 3923794,
 "txfee" : 28594,
 "tags" : "scam",
 "value" : 0,
 "report_period" : "June 2017"
 }

现在,我们在scam_ico_documents集合中有了明确定义的文档,我们可以很容易地进行进一步的分析。这种分析的一个例子是在一些骗子上附加更多信息。幸运的是,我们的数据科学家已经提供了一些额外的信息,我们已经提取到一个新的集合scam_details中,它看起来是这样的:

{
 "_id" : ObjectId("5955510e14ae9238fe76d7f0"),
 "scam_address" : "0x3c540be890df69eca5f0099bbedd5d667bd693f3",
 Email_address": example@scammer.com"
 }

现在,我们可以创建一个新的聚合管道作业,将我们的scam_ico_documentsscam_details集合连接起来,并将这些扩展结果输出到一个新的集合中,名为scam_ico_documents_extended,就像这样:

def scam_add_information(self):
 client = MongoClient()
 db = client.mongo_book
 scam_collection = db.scam_ico_documents
 pipeline = [
 {"$lookup": {"from": "scam_details", "localField": "from", "foreignField": "scam_address", "as": "scam_details"}},
 {"$match": {"scam_details": { "$ne": [] }}},
 {"$out": "scam_ico_documents_extended"}
 ]
 result = scam_collection.aggregate(pipeline)
 for res in result:
 print(res)

在这里,我们使用以下三步聚合管道:

  1. 使用$lookup命令,从scam_details集合和scam_address字段中的数据与我们的本地集合(scam_ico_documents)中的数据进行连接,基于本地集合属性from的值等于scam_details集合的scam_address字段中的值。如果它们相等,那么管道将在文档中添加一个名为scam_details的新字段。

  2. 接下来,我们只匹配具有scam_details字段的文档,即与查找聚合框架步骤匹配的文档。

  3. 最后,我们将这些文档输出到一个名为scam_ico_documents_extended的新集合中。

现在这些文档看起来是这样的:

> db.scam_ico_documents_extended.findOne()
 {
 "_id" : ObjectId("5955533be9ec57bdb074074e"),
 "to" : "0x4b9e0d224dabcc96191cace2d367a8d8b75c9c81",
 "txhash" : "0xf205991d937bcb60955733e760356070319d95131a2d9643e3c48f2dfca39e77",
 "from" : "0x3c540be890df69eca5f0099bbedd5d667bd693f3",
 "block" : 3923794,
 "txfee" : 28594,
 "tags" : "scam",
 "value" : 0,
 "report_period" : "June 2017",
 "scam_details_data" : [
 {
 "_id" : ObjectId("5955510e14ae9238fe76d7f0"),
 "scam_address" : "0x3c540be890df69eca5f0099bbedd5d667bd693f3",
 email_address": example@scammer.com"
 }]}

使用聚合框架,我们已经确定了我们的数据,并且可以快速高效地处理它。

前面的步骤可以总结如下图所示:

总结

在本章中,我们深入探讨了聚合框架。我们讨论了为什么以及何时应该使用聚合,而不是简单地使用 MapReduce 或查询数据库。我们详细介绍了聚合的各种选项和功能。

我们讨论了聚合阶段和各种运算符,如布尔运算符、比较运算符、集合运算符、数组运算符、日期运算符、字符串运算符、表达式算术运算符、聚合累加器、条件表达式和变量,以及文字解析数据类型运算符。

使用以太坊用例,我们通过工作代码进行了聚合,并学习了如何解决工程问题。

最后,我们了解了聚合框架目前存在的限制以及何时应避免使用它。

在下一章中,我们将继续讨论索引的主题,并学习如何为我们的读写工作负载设计和实现高性能索引。

第七章:索引

本章将探讨任何数据库中最重要的属性之一:索引。与书籍索引类似,数据库索引可以加快数据检索速度。在关系型数据库管理系统中,索引被广泛使用(有时被滥用)以加快数据访问速度。在 MongoDB 中,索引在模式和查询设计中起着至关重要的作用。MongoDB 支持各种索引类型,您将在本章中了解到,包括单字段、复合、多键、地理空间、哈希、部分等等。除了审查不同类型的索引,我们还将向您展示如何为单服务器部署以及复杂的分片环境构建和管理索引。

在本章中,我们将涵盖以下主题:

  • 索引内部

  • 索引类型

  • 构建和管理索引

  • 索引的高效使用

索引内部

在大多数情况下,索引是 B 树数据结构的变体。由 Rudolf Bayer 和 Ed McCreight 于 1971 年在波音研究实验室工作时发明,B 树数据结构允许在对数时间内执行搜索、顺序访问、插入和删除。对数时间属性适用于平均情况性能和最坏情况性能,当应用程序无法容忍性能行为的意外变化时,这是一个很好的属性。

为了进一步说明对数时间的重要性,我们将向您展示 Big-O 复杂度图表,该图表来自bigocheatsheet.com/

在这个图表中,您可以看到对数时间性能作为图表的x轴平行的一条直线。随着元素数量的增加,常数时间(O(n))算法表现更差,而二次时间算法(O(n²))则超出了图表范围。对于我们依赖的算法来尽快将数据返回给我们,时间性能至关重要。

B 树的另一个有趣特性是它是自平衡的,这意味着它将自动调整以始终保持这些属性。它的前身和最接近的亲戚是二叉搜索树,这是一种数据结构,每个父节点只允许两个子节点。

从图表上看,B 树的结构如下图所示,也可以在commons.wikimedia.org/w/index.php?curid=11701365上看到:

在上图中,我们有一个父节点,其值为716,指向三个子节点。

如果我们搜索值为9,知道它大于7且小于16,我们将直接被引导到包含该值的中间子节点。

由于这种结构,我们在每一步都将搜索空间几乎减半,最终达到log n的时间复杂度。与顺序扫描每个元素相比,每一步将元素数量减半,使我们的收益呈指数增长,因为我们需要搜索的元素数量增加。

索引类型

MongoDB 为不同的需求提供了各种索引类型。在接下来的章节中,我们将确定不同类型的索引以及它们各自满足的需求。

单字段索引

最常见和简单的索引类型是单字段索引。单字段和键索引的一个例子是在每个 MongoDB 集合中默认生成的ObjectId_id)索引。ObjectId索引也是唯一的,防止另一个文档在集合中具有相同的ObjectId

基于我们在前几章中使用的mongo_book数据库的单字段索引定义如下:

> db.books.createIndex( { price: 1 } )

在这里,我们按照索引创建的顺序对字段名称创建索引。对于降序,相同的索引将如下创建:

> db.books.createIndex( { price: -1 } )

索引创建的顺序对于我们期望查询优先考虑存储在索引中的第一个文档的值的情况很重要。然而,由于索引具有极其高效的时间复杂度,这对于最常见的用例来说并不重要。

索引可以用于字段值的精确匹配查询或范围查询。在前一种情况下,一旦我们的指针在O(log n)时间后到达值,搜索就可以停止。

在范围查询中,由于我们在 B 树索引中按顺序存储值,一旦我们在 B 树的节点中找到范围查询的边界值,我们将知道其所有子节点中的所有值都将成为我们结果集的一部分,从而允许我们结束我们的搜索。

示例如下:

删除索引

删除索引与创建索引一样简单。我们可以通过名称或由其组成的字段引用索引:

> db.books.dropIndex( { price: -1 } ) > db.books.dropIndex( "price_index" )

索引嵌入字段

作为文档数据库,MongoDB 支持在同一文档的嵌套复杂层次结构中嵌入字段和整个文档。自然地,它也允许我们对这些字段进行索引。

在我们的books集合示例中,我们可以有以下类似的文档:

{
"_id" : ObjectId("5969ccb614ae9238fe76d7f1"),
"name" : "MongoDB Indexing Cookbook",
"isbn" : "1001",
"available" : 999,
"meta_data" : {
"page_count" : 256,
"average_customer_review" : 4.8
}
} 

在这里,meta_data字段本身是一个文档,具有page_countaverage_customer_review字段。同样,我们可以按照以下方式在page_count上创建索引:

db.books.createIndex( { "meta_data.page_count": 1 } )

这可以回答关于meta_data.page_count字段的相等和范围比较的查询,如下所示:

> db.books.find({"meta_data.page_count": { $gte: 200 } })
> db.books.find({"meta_data.page_count": 256 })

要访问嵌入字段,我们使用点表示法,并且需要在字段名称周围包含引号("")。

索引嵌入文档

我们还可以像索引嵌入字段一样索引整个嵌入文档:

> db.books.createIndex( { "meta_data": 1 } )

在这里,我们正在索引整个文档,期望针对其整体进行查询,如下所示:

> db.books.find({"meta_data": {"page_count":256, "average_customer_review":4.8}})

主要区别在于当我们索引嵌入字段时,我们可以使用索引对它们执行范围查询,而当我们索引嵌入文档时,我们只能使用索引执行比较查询。

db.books.find({"meta_data.average_customer_review": { $gte: 4.8}, "meta_data.page_count": { $gte: 200 } })命令不会使用我们的meta_data索引,而db.books.find({"meta_data": {"page_count":256, "average_customer_review":4.8}})会使用它。

后台索引

索引可以在前台创建,阻塞集合中的所有操作,直到它们建立完成,或者可以在后台创建,允许并发操作。通过传递background: true参数来在后台构建索引:

> db.books.createIndex( { price: 1 }, { background: true } )

后台索引在本章的最后一节构建和管理索引中有一些限制,我们将在最后一节中重新讨论。

复合索引

复合索引是单键索引的泛化,允许多个字段包含在同一个索引中。当我们期望查询跨多个字段的文档时,以及当我们开始在集合中拥有太多索引时,它们非常有用。

复合索引最多可以有 31 个字段。它们不能有散列索引类型。

复合索引的声明方式与单个索引类似,通过定义要索引的字段和索引的顺序来定义:

> db.books.createIndex({"name": 1, "isbn": 1})

使用复合索引进行排序

索引的顺序对于排序结果很有用。在单字段索引中,MongoDB 可以双向遍历索引,因此我们定义的顺序并不重要。

然而,在多字段索引中,排序可以决定我们是否可以使用此索引进行排序。在前面的示例中,与我们索引创建的排序方向匹配的查询将使用我们的索引,如下所示:

> db.books.find().sort( { "name": 1, "isbn": 1 })

它还将使用所有sort字段反转的sort查询:

> db.books.find().sort( { "name": -1, "isbn": -1 })

在这个查询中,由于我们否定了两个字段,MongoDB 可以使用相同的索引,从末尾到开头遍历它。

另外两种排序顺序如下:

> db.books.find().sort( { "name": -1, "isbn": 1 })
> db.books.find().sort( { "name": 1, "isbn": -1 })

它们不能使用索引进行遍历,因为我们想要的sort顺序在我们的索引 B 树数据结构中不存在。

重用复合索引

复合索引的一个重要属性是它们可以用于对索引字段的前缀进行多个查询。当我们想要在随着时间在我们的集合中堆积的索引进行合并时,这是有用的。

考虑我们之前创建的复合(多字段)索引:

> db.books.createIndex({"name": 1, "isbn": 1})

这可以用于对name{name, isbn}进行查询:

> db.books.find({"name":"MongoDB Indexing"})
> db.books.find({"isbn": "1001", "name":"MongoDB Indexing"})

查询中字段的顺序并不重要;MongoDB 将重新排列字段以匹配我们的查询。

然而,我们索引中字段的顺序是重要的。仅针对isbn字段的查询无法使用我们的索引:

> db.books.find({"isbn": "1001"})

根本原因是我们字段的值存储在索引中作为次要、第三等等;每个值都嵌入在前一个值中,就像俄罗斯套娃一样。这意味着当我们在多字段索引的第一个字段上进行查询时,我们可以使用最外层的套娃来找到我们的模式,而当我们搜索前两个字段时,我们可以在最外层的套娃上匹配模式,然后深入到内部的套娃中。

这个概念被称为前缀索引,以及索引交集,它是索引合并的最强大工具,正如你将在本章后面看到的。

多键索引

在前面的部分中已经解释了标量(单一)值的索引。然而,我们从使用 MongoDB 中获得的优势之一是能够轻松地以数组的形式存储向量值。

在关系世界中,存储数组通常是不受欢迎的,因为它违反了正常形式。在 MongoDB 这样的面向文档的数据库中,它经常是我们设计的一部分,因为我们可以轻松地存储和查询数据的复杂结构。

通过使用多键索引可以对文档数组进行索引。多键索引可以存储标量值数组和嵌套文档数组。

创建多键索引与创建常规索引相同:

> db.books.createIndex({"tags":1})

假设我们已经在我们的books集合中创建了一个文档,使用以下命令:

> db.books.insert({"name": "MongoDB Multikeys Cheatsheet", "isbn": "1002", "available": 1, "meta_data": {"page_count":128, "average_customer_review":3.9}, "tags": ["mongodb", "index","cheatsheet","new"] })

我们的新索引将是一个多键索引,允许我们找到包含数组中任何标签的文档:

> db.books.find({tags:"new"})
{
"_id" : ObjectId("5969f4bc14ae9238fe76d7f2"),
"name" : "MongoDB Multikeys Cheatsheet",
"isbn" : "1002",
"available" : 1,
"meta_data" : {
"page_count" : 128,
"average_customer_review" : 3.9
},
"tags" : [
"mongodb",
"index",
"cheatsheet",
"new"
]
}
>

我们还可以使用多键索引创建复合索引,但每个索引文档中最多只能有一个数组。鉴于在 MongoDB 中我们不指定每个字段的类型,这意味着创建具有两个或更多字段的数组值的索引将在创建时失败,并且尝试插入具有两个或更多字段的数组的文档将在插入时失败。

例如,如果我们的数据库中有以下文档,那么在tagsanalytics_data上创建的复合索引将无法创建:

{
"_id" : ObjectId("5969f71314ae9238fe76d7f3"),
"name": "Mastering parallel arrays indexing",
"tags" : [
"A",
"B"
],
"analytics_data" : [
"1001",
"1002"
]
}

> db.books.createIndex({tags:1, analytics_data:1})
{
"ok" : 0,
"errmsg" : "cannot index parallel arrays [analytics_data] [tags]",
"code" : 171,
"codeName" : "CannotIndexParallelArrays"
}

因此,如果我们首先在空集合上创建索引,然后尝试插入此文档,插入将失败,并显示以下错误:

> db.books.find({isbn:"1001"}).hint("international_standard_book_number_index").explain()
{
 "queryPlanner" : {
 "plannerVersion" : 1,
 "namespace" : "mongo_book.books",
 "indexFilterSet" : false,
 "parsedQuery" : {
 "isbn" : {
 "$eq" : "1001"
 }
 },
 "winningPlan" : {
 "stage" : "FETCH",
 "inputStage" : {
 "stage" : "IXSCAN",
 "keyPattern" : {
 "isbn" : 1
 },
 "indexName" : "international_standard_book_numbe
r_index",
 "isMultiKey" : false,
 "multiKeyPaths" : {
 "isbn" : [ ]
 },
 "isUnique" : false,
 "isSparse" : false,
 "isPartial" : false,
 "indexVersion" : 2,
 "direction" : "forward",
 "indexBounds" : {
 "isbn" : [
 "[\"1001\", \"1001\"]"
 ]
 }
 }
 },
 "rejectedPlans" : [ ]
 },
 "serverInfo" : {
 "host" : "PPMUMCPU0142",
 "port" : 27017,
 "version" : "3.4.7",
 "gitVersion" : "cf38c1b8a0a8dca4a11737581beafef4fe120bcd"
 },
 "ok" : 1

散列索引不能是多键索引。

当我们尝试微调我们的数据库时,我们可能会遇到的另一个限制是多键索引无法完全覆盖查询。使用索引覆盖查询意味着我们可以完全从索引中获取我们的结果数据,而根本不访问我们数据库中的数据。这可能会导致性能大幅提升,因为索引很可能存储在 RAM 中。

在多键索引中查询多个值将从索引的角度产生一个两步过程。

在第一步中,索引将用于检索数组的第一个值,然后顺序扫描将运行数组中其余的元素;示例如下:

> db.books.find({tags: [ "mongodb", "index", "cheatsheet", "new" ] })

这将首先搜索具有mongodb值的多键index标签的所有条目,然后顺序扫描它们以找到也具有indexcheatsheetnew标签的条目。

多键索引不能用作分片键。但是,如果分片键是多键索引的前缀索引,则可以使用。我们将在第十三章 分片中更多地介绍这一点。

特殊类型的索引

除了通用索引外,MongoDB 还支持特殊用例的索引。在本节中,我们将确定并探讨如何使用它们。

文本索引

文本索引是对字符串值字段的特殊索引,用于支持文本搜索。本书基于文本索引功能的第 3 版,自第 3.2 版起可用。

文本索引可以类似于常规索引进行指定,方法是用单词text替换索引排序顺序(-1,`1),如下所示:

> db.books.createIndex({"name": "text"})

一个集合最多可以有一个文本索引。这个文本索引可以支持多个字段,无论是文本还是其他。它不能支持其他特殊类型,如多键或地理空间。即使它们只是复合索引的一部分,文本索引也不能用于排序结果。

由于每个集合只有一个文本索引,因此我们需要明智地选择字段。重建此文本索引可能需要相当长的时间,并且每个集合只有一个文本索引使得维护非常棘手,正如您将在本章末尾看到的那样。

幸运的是,此索引也可以是复合索引:

> db.books.createIndex( { "available": 1, "meta_data.page_count": 1,  "$**": "text" } )

具有text字段的复合索引遵循本章前面解释的排序和前缀索引规则。我们可以使用此索引来查询available,或availablemeta_data.page_count的组合,或者如果排序顺序允许在任何方向遍历我们的索引,则对它们进行排序。

我们还可以盲目地对包含字符串的每个字段进行text索引:

> db.books.createIndex( { "$**": "text" } )

这可能导致无限制的索引大小,应该避免使用;但是,如果我们有非结构化数据(例如,直接来自应用程序日志,我们不知道哪些字段可能有用,并且希望能够查询尽可能多的字段),这可能是有用的。

文本索引将应用词干处理(删除常见后缀,例如英语单词的复数s/es)并从索引中删除停用词(aanthe等)。

文本索引支持 20 多种语言,包括西班牙语,中文,乌尔都语,波斯语和阿拉伯语。文本索引需要特殊配置才能正确地索引英语以外的语言。

文本索引的一些有趣属性如下所述:

  • 大小写不敏感和变音符号不敏感:文本索引是大小写和变音符号不敏感的。文本索引的第 3 版(随第 3.4 版一起发布)支持常见的C,简单的S和特殊的T大小写折叠,如Unicode 字符数据库UCD)8.0 大小写折叠中所述。除了大小写不敏感外,文本索引的第 3 版还支持变音符号不敏感。这将扩展对带有小写和大写字母形式的重音符号的字符的不敏感性。例如,eèéêë及其大写字母对应物,在使用文本索引进行比较时都可能相等。在文本索引的先前版本中,这些被视为不同的字符串。

  • 标记化分隔符:文本索引的第 3 版支持标记化分隔符,定义为DashHyphenPattern_SyntaxQuotation_MarkTerminal_PunctuationWhite_Space,如 UCD 8.0 大小写折叠中所述。

散列索引

散列索引包含索引字段的hashed值:

> db.books.createIndex( { name: "hashed" } )

这将在我们的books集合的每本书的名称上创建一个哈希索引。哈希索引非常适合相等匹配,但不能用于范围查询。如果我们希望对字段执行一系列查询,我们可以创建一个常规索引(或包含该字段的复合索引),并且还可以创建一个用于相等匹配的哈希索引。哈希索引在 MongoDB 内部用于基于哈希的分片,我们将在第十三章 分片中讨论。哈希索引将浮点字段截断为整数。在可能的情况下,应尽量避免对哈希字段使用浮点数。

生存时间索引

生存时间TTL)索引用于在过期时间后自动删除文档。它们的语法如下:

> db.books.createIndex( { "created_at_date": 1 }, { expireAfterSeconds: 86400 } )

created_at_date字段的值必须是日期或日期数组(将使用最早的日期)。在这个例子中,文档将在created_at_date之后的一天(86400秒)被删除。

如果字段不存在或值不是日期,则文档将不会过期。换句话说,TTL 索引会默默失败,不会在失败时返回任何错误。

数据将通过每 60 秒运行一次的后台作业进行删除。因此,关于文档在其过期日期之后还会持续存在多长时间,没有明确的准确性保证。

TTL 索引是常规的单字段索引。它可以用于像常规索引一样的查询。TTL 索引不能是复合索引,不能在封顶集合上操作,也不能使用_id字段。_id字段隐含地包含了文档创建时间的时间戳,但不是一个Date字段。如果我们希望每个文档在不同的自定义日期点过期,我们必须设置{expireAfterSeconds: 0},并手动设置 TTL 索引的Date字段为我们希望文档过期的日期。

部分索引

集合上的部分索引是仅适用于满足partialFilterExpression查询的文档的索引。

我们将使用我们熟悉的books集合,如下所示:

> db.books.createIndex(
 { price: 1, name: 1 },
 { partialFilterExpression: { price: { $gt: 30 } } }
)

使用这个,我们可以为只有价格大于30的书籍创建一个索引。部分索引的优点是在创建和维护上更轻量,并且使用更少的存储空间。

partialFilterExpression过滤器支持以下运算符:

  • 相等表达式(即field: value,或使用$eq运算符)

  • $exists: true表达式

  • $gt$gte$lt$lte表达式

  • $type表达式

  • $and运算符,仅在顶层

只有当查询可以完全满足部分索引时,才会使用部分索引。

如果我们的查询匹配或比partialFilterExpression过滤器更严格,那么将使用部分索引。如果结果可能不包含在部分索引中,则索引将被完全忽略。

partialFilterExpression不需要是稀疏索引字段的一部分。以下索引是有效的稀疏索引:


 > db.books.createIndex({ name: 1 },{ partialFilterExpression: { price: { $gt: 30 } } })

然而,要使用这个部分索引,我们需要查询nameprice都等于或大于30

优先选择部分索引而不是稀疏索引。稀疏索引提供了部分索引提供的功能的子集。部分索引是在 MongoDB 3.2 中引入的,因此如果您有早期版本的稀疏索引,升级它们可能是一个好主意。_id字段不能是部分索引的一部分。分片键索引不能是部分索引。partialFilterExpression不能与sparse选项结合使用。

稀疏索引

稀疏索引类似于部分索引,但比它早几年(自 1.8 版本以来就可用)。

sparse索引只索引包含以下字段的值:

> db.books.createIndex( { "price": 1 }, { sparse: true } )

它只会创建一个包含包含price字段的文档的索引。

由于其性质,有些索引始终是稀疏的:

  • 2d2dsphere(版本 2)

  • geoHaystack

  • text

稀疏和唯一的索引将允许多个文档缺少索引键。它不会允许具有相同索引字段值的文档。具有地理空间索引(2d2dspheregeoHaystack)的稀疏和复合索引将索引文档,只要它具有geospatial字段。

具有text字段的稀疏和复合索引将索引文档,只要它具有text字段。没有前两种情况的稀疏和复合索引将索引文档,只要它至少有一个字段。

在 MongoDB 的最新版本中避免创建新的稀疏索引;改用部分索引。

唯一索引

唯一索引类似于 RDBMS 唯一索引,禁止索引字段的重复值。MongoDB 默认在每个插入的文档的_id字段上创建唯一索引:

> db.books.createIndex( { "name": 1 }, { unique: true } )

这将在书的name上创建一个unique索引。唯一索引也可以是复合嵌入字段或嵌入文档索引。

在复合索引中,唯一性是在索引的所有字段的值的组合中强制执行的;例如,以下内容不会违反唯一索引:

> db.books.createIndex( { "name": 1, "isbn": 1 }, { unique: true } )
> db.books.insert({"name": "Mastering MongoDB", "isbn": "101"})
> db.books.insert({"name": "Mastering MongoDB", "isbn": "102"})

这是因为即使名称相同,我们的索引也在寻找nameisbn的唯一组合,而这两个条目在isbn上有所不同。

唯一索引不适用于散列索引。如果集合已包含索引字段的重复值,则无法创建唯一索引。唯一索引不会阻止同一文档具有多个值。

如果文档缺少索引字段,则将插入该字段。如果第二个文档缺少索引字段,则不会插入。这是因为 MongoDB 将缺少的字段值存储为 null,只允许字段中缺少一个文档。

唯一和部分组合的索引只会在应用部分索引后应用唯一索引。这意味着如果它们不是部分过滤的一部分,可能会有几个具有重复值的文档。

不区分大小写

大小写敏感是索引中的常见问题。我们可能会将数据存储在混合大小写中,并且需要索引在查找存储的数据时忽略大小写。直到 3.4 版本,这是在应用程序级别处理的,方法是创建所有小写字符的重复字段,并将所有小写字段索引以模拟不区分大小写的索引。

使用collation参数,我们可以创建不区分大小写的索引,甚至可以创建行为不区分大小写的集合。

通常,collation允许用户指定特定于语言的字符串比较规则。可能的(但不是唯一的)用法是用于不区分大小写的索引和查询。

使用我们熟悉的books集合,我们可以在名称上创建一个不区分大小写的索引,如下所示:

> db.books.createIndex( { "name" : 1 },
 { collation: {
 locale : 'en',
 strength : 1
 }
 } )

strength参数是collation参数之一:用于区分大小写比较的定义参数。强度级别遵循国际 Unicode 组件ICU)比较级别。它接受的值如下:

强度值 描述
1a 比较的主要级别。基于字符串值的比较,忽略任何其他差异,如大小写和变音符。
2 比较的次要级别,基于主要级别的比较,如果相等,则比较变音符(即重音)。
3(默认) 第三级比较。与级别2相同,添加大小写和变体。
4 第四级。仅限于特定用例,考虑标点符号,当级别 1-3 忽略标点符号时,或用于处理日文文本。
5 相同级别。仅限于特定用例:决定胜负者。

使用collation创建索引不足以获得不区分大小写的结果。我们需要在查询中指定collation,如下所示:

> db.books.find( { name: "Mastering MongoDB" } ).collation( { locale: 'en', strength: 1 } )

如果我们在查询中指定与我们的索引相同级别的collation,那么将使用该索引。我们可以按如下方式指定不同级别的collation

> db.books.find( { name: "Mastering MongoDB" } ).collation( { locale: 'en', strength: 2 } )

在这里,我们无法使用索引,因为我们的索引具有collation级别 1,而我们的查询寻找collation级别2

如果我们在查询中不使用任何collation,我们将得到默认级别为 3 的结果,即区分大小写。

使用与默认不同的collation创建的集合中的索引将自动继承此collation级别。

假设我们创建了一个collation级别为 1 的集合,如下所示:

> db.createCollection("case_sensitive_books", { collation: { locale: 'en_US', strength: 1 } } )

以下索引也将具有name: 1的排序:

> db.case_sensitive_books.createIndex( { name: 1 } )

对该集合的默认查询将使用排序strength: 1,区分大小写。如果我们想在查询中覆盖这一点,我们需要在查询中指定不同级别的collation,或者完全忽略strength部分。以下两个查询将返回case_sensitive_books集合中不区分大小写的默认collation级别结果:

> db.case_sensitive_books.find( { name: "Mastering MongoDB" } ).collation( { locale: 'en', strength: 3 } ) // default collation strength value
> db.case_sensitive_books.find( { name: "Mastering MongoDB" } ).collation( { locale: 'en'  } ) // no value for collation, will reset to global default (3) instead of default for case_sensitive_books collection (1)

排序在 MongoDB 中是一个相当强大且相对较新的概念,因此我们将在不同章节中继续探讨它。

地理空间索引

地理空间索引在 MongoDB 早期就被引入,而 Foursquare 是 MongoDB(当时是 10gen Inc.)最早的客户和成功案例之一,这可能并非巧合。在本章中,我们将探讨三种不同类型的地理空间索引,并将在以下部分中进行介绍。

2D 地理空间索引

2d地理空间索引将地理空间数据存储为二维平面上的点。它主要用于传统原因,用于 MongoDB 2.2 之前创建的坐标对,并且在大多数情况下,不应该与最新版本一起使用。

2dsphere 地理空间索引

2dsphere地理空间索引支持在类似地球的平面上计算几何。它比简单的2d索引更精确,并且可以支持 GeoJSON 对象和坐标对作为输入。

自 MongoDB 3.2 以来的当前版本是版本 3。默认情况下,它是稀疏索引,只索引具有2dsphere字段值的文档。假设我们的books集合中有一个位置字段,跟踪每本书的主要作者的家庭地址,我们可以按如下方式在该字段上创建索引:

> db.books.createIndex( { "location" : "2dsphere" } )

location字段需要是一个 GeoJSON 对象,就像这样一个:

location : { type: "Point", coordinates: [ 51.5876, 0.1643 ] }

2dsphere索引也可以作为复合索引的一部分,作为第一个字段或其他字段:

> db.books.createIndex( { name: 1, location : "2dsphere" } )

geoHaystack 索引

当我们需要在一个小区域内搜索基于地理位置的结果时,geoHaystack索引非常有用。就像在干草堆中搜索针一样,使用geoHaystack索引,我们可以定义地理位置点的存储桶,并返回属于该区域的所有结果。

我们将创建一个geoHaystack索引,如下所示:

> db.books.createIndex( { "location" : "geoHaystack" ,
 "name": 1 } ,
 { bucketSize: 2 } )

这将在每个文档周围的纬度或经度2单位内创建文档的存储桶。

在这里,使用前面的location示例:

location : { type: "Point", coordinates: [ 51.5876, 0.1643 ] }

基于bucketSize: 2,每个具有location [49.5876..53.5876, -2.1643..2.1643]的文档将属于与我们的位置相同的存储桶。

一个文档可以出现在多个存储桶中。如果我们想使用球面几何,2dsphere是一个更好的解决方案。geoHaystack索引默认是稀疏的。

如果我们需要计算最接近我们位置的文档,而它超出了我们的bucketSize(即,在我们的示例中大于 2 个纬度/经度单位),查询将是低效的,可能不准确。对于这样的查询,请使用2dsphere索引。

构建和管理索引

索引可以使用 MongoDB shell 或任何可用的驱动程序构建。默认情况下,索引是在前台构建的,会阻塞数据库中的所有其他操作。这样更快,但通常是不可取的,特别是在生产实例中。

我们还可以通过在 shell 中的索引命令中添加{background: true}参数来在后台构建索引。后台索引只会阻塞当前连接/线程。我们可以打开一个新连接(即在命令行中使用mongo)连接到同一个数据库:

> db.books.createIndex( { name: 1 }, { background: true } )

后台索引构建可能比前台索引构建需要更长的时间,特别是如果索引无法适应可用的 RAM。

尽早创建索引,并定期重新审视索引以进行合并。查询不会看到部分索引结果。只有在索引完全创建后,查询才会开始从索引中获取结果。

不要使用主要应用程序代码来创建索引,因为这可能会导致不可预测的延迟。相反,从应用程序获取索引列表,并在维护窗口期间标记这些索引进行创建。

强制使用索引

我们可以通过应用hint()参数来强制 MongoDB 使用索引:

> db.books.createIndex( { isbn: 1 }, { background: true } )
{
"createdCollectionAutomatically" : false,
"numIndexesBefore" : 8,
"numIndexesAfter" : 9,
"ok" : 1
}

createIndex的输出通知我们索引已创建("ok" : 1),索引创建过程中没有自动创建集合("createdCollectionAutomatically" : false),在此索引创建之前,该集合中的索引数量为8,现在总共有九个索引。

现在,如果我们尝试通过isbn搜索书籍,我们可以使用explain()命令来查看winningPlan子文档,从中我们可以找到使用的查询计划:

> db.books.find({isbn: "1001"}).explain()
…
"winningPlan" : {
"stage" : "FETCH",
"inputStage" : {
"stage" : "IXSCAN",
"keyPattern" : {
"isbn" : 1,
"name" : 1
},
"indexName" : "isbn_1_name_1",
...

这意味着使用了具有isbn1name1的索引,而不是我们新创建的索引。我们还可以在输出的rejectedPlans子文档中查看我们的索引,如下所示:

…
"rejectedPlans" : [
{
"stage" : "FETCH",
"inputStage" : {
"stage" : "IXSCAN",
"keyPattern" : {
"isbn" : 1
},
"indexName" : "isbn_1",
...

事实上,这是正确的,因为 MongoDB 正在尝试重用比通用索引更具体的索引。

在我们的isbn_1索引表现比isbn_1_name_1更好的情况下,我们可能不确定。

我们可以强制 MongoDB 使用我们新创建的索引,如下所示:

> db.books.find({isbn: "1001"}).hint("international_standard_book_number_index")
.explain()
{
...
 "winningPlan" : {
 "stage" : "FETCH",
 "inputStage" : {
 "stage" : "IXSCAN",
 "keyPattern" : {
 "isbn" : 1
 },
...

现在,winningPlan子文档包含我们的索引isbn_1,并且没有rejectedPlans元素。结果集中是一个空数组。

我们不能在特殊类型的文本索引上使用hint()

提示和稀疏索引

根据设计,稀疏索引不包括索引中的某些文档,根据字段的存在/缺失。可能包含不在索引中的文档的查询将不使用稀疏索引。

使用稀疏索引的hint()可能导致不正确的计数,因为它强制 MongoDB 使用可能不包含我们想要的所有结果的索引。

旧版本的2dsphere2dgeoHaystack和文本索引默认是稀疏的。应谨慎使用hint(),并在仔细考虑其影响后使用。

在副本集上构建索引

在副本集中,如果我们发出createIndex()命令,主服务器完成创建后,次要服务器将开始创建索引。同样,在分片环境中,主服务器将开始构建索引,而分片的每个次要服务器将在主服务器完成后开始。

在副本集中构建索引的推荐方法如下:

  • 停止副本集中的一个次要节点

  • 在不同端口上重新启动为独立服务器

  • 在 shell 中构建独立索引

  • 重启副本集中的次要节点

  • 允许次要节点赶上主节点

我们需要在主服务器中有足够大的 oplog 大小,以确保辅助服务器在重新连接后能够追赶上来。oplog 大小在配置中以 MB 定义,它定义了主服务器中日志中将保留多少个操作。如果 oplog 大小只能容纳主服务器中发生的最后 100 个操作,而发生了 101 个或更多的操作,这意味着辅助服务器将无法与主服务器同步。这是主服务器没有足够的内存来跟踪其操作并通知辅助服务器的后果。在副本集中构建索引是一个手动过程,涉及每个主服务器和辅助服务器的几个步骤。

这种方法可以在副本集中的每个辅助服务器上重复。然后,对于主服务器,我们可以执行以下操作之一:

  • 在后台构建索引

  • 使用rs.stepDown()将主服务器降级,然后使用服务器作为辅助服务器重复前面的过程

使用第二种方法时,当主服务器降级时,我们的集群将在一段时间内不接受任何写入。我们的应用程序在此期间不应该超时(通常不到 30-60 秒)。

在主服务器后台构建索引也会在辅助服务器上后台构建。这可能会影响索引创建期间我们服务器的写入,但好处是没有手动步骤。在生产环境中建立一个与生产环境相似的临时环境,并在其中运行影响实时集群的操作,以避免意外。

管理索引

在本节中,您将学习如何为您的索引指定人性化的名称,以及一些特殊的考虑和限制,我们必须牢记索引。

命名索引

默认情况下,索引名称是根据字段索引和索引方向(1-1)自动分配的。如果我们想在创建时分配自己的name,我们可以这样做:

> db.books.createIndex( { isbn: 1 }, { name: "international_standard_book_number_index" } )

现在,我们有一个名为international_standard_book_number_index的新索引,而不是 MongoDB 将会命名的("isbn_1")。

我们可以使用db.books.getIndexes()来查看我们的books集合中的所有索引。完全限定的索引名称必须少于或等于 128 个字符。这也包括database_namecollection_name和它们之间的点。

特殊考虑

以下是一些关于索引的限制需要牢记:

  • 索引条目必须少于 1,024 字节。这主要是一个内部考虑,但如果我们在索引方面遇到问题,我们可以牢记这一点。

  • 一个集合最多可以有 64 个索引。

  • 复合索引最多可以有 31 个字段。

  • 特殊索引不能在查询中组合使用。这包括必须使用特殊索引的特殊查询操作符,例如文本索引的$text和地理空间索引的$near。这是因为 MongoDB 可以使用多个索引来满足查询,但并非在所有情况下都可以。关于这个问题将在索引交集部分有更多内容。

  • 多键和地理空间索引无法覆盖查询。这意味着仅仅使用索引数据将不足以满足查询,MongoDB 需要处理底层文档才能获取完整的结果集。

  • 索引对字段有唯一约束。我们不能在相同的字段上创建多个索引,只是选项不同。这是稀疏和部分索引的限制,因为我们不能创建多个这些索引的变体,这些变体只在过滤查询上有所不同。

高效使用索引

创建索引是一个不应轻率对待的决定。尽管通过 shell 创建索引很容易,但如果我们最终拥有太多或效率不高的索引,它可能会在后续出现问题。在本节中,您将学习如何测量现有索引的性能,一些改进性能的技巧,以及如何合并索引数量,以便拥有性能更好的索引。

测量性能

学习如何使用explain()命令将有助于您优化和理解索引的性能。当与查询一起使用时,explain()命令将返回 MongoDB 为此查询使用的查询计划,而不是实际结果。

通过在查询末尾链接它来调用它,如下所示:

> db.books.find().explain()

它可以有三个选项:queryPlanner(默认值),executionStatsallPlansExecution

让我们使用最详细的输出,allPlansExecution

> db.books.find().explain("allPlansExecution")

在这里,我们可以获取获胜查询计划的信息,以及在规划阶段考虑过但被拒绝的查询计划的部分信息,因为查询规划程序认为它们更慢。explain()命令无论如何都会返回相当冗长的输出,允许深入了解查询计划如何工作以返回我们的结果。

乍一看,我们需要关注应该使用的索引是否被使用,以及扫描的文档数量是否尽可能与返回的文档数量匹配。

对于第一个,我们可以检查stage字段并查找IXSCAN,这意味着使用了索引。然后,在兄弟indexName字段中,我们应该看到我们期望的索引名称。

对于第二个,我们需要比较keysExaminednReturned字段。理想情况下,我们希望我们的索引在查询方面尽可能具有选择性,这意味着为了返回 100 个文档,这些将是我们的索引检查的 100 个文档。

当然,这是一个权衡,因为索引在我们的集合中数量和大小增加。我们每个集合可以有限数量的索引,而且我们的 RAM 可以容纳这些索引的数量是有限的,因此我们必须在拥有最佳可用索引和这些索引不适合我们的内存并变慢之间取得平衡。

提高性能

一旦我们开始熟悉测量用户最常见和重要查询的性能,我们就可以开始尝试改进它们。

总体思路是,当我们期望(或已经有)重复查询开始运行缓慢时,我们需要索引。索引并非免费,因为它们在创建和维护时会带来性能损失,但对于频繁查询来说,它们是非常值得的,并且可以减少数据库中的锁定百分比,如果设计正确的话。

回顾上一节的建议,我们希望我们的索引能够做到以下几点:

  • 适应 RAM

  • 确保选择性

  • 用于对查询结果进行排序

  • 用于我们最常见和重要的查询

通过在我们的集合中使用getIndexes()并确保我们不会通过检查系统级可用 RAM 和是否使用交换来创建大型索引来确保适应 RAM。

如前所述,通过比较每个查询的IXSCAN阶段中的nReturnedkeysExamined来确保选择性。我们希望这两个数字尽可能接近。

确保我们的索引用于对查询结果进行排序是使用复合索引(将作为整体使用,也用于任何基于前缀的查询)并声明我们的索引方向与我们最常见的查询一致的组合。

最后,将索引与我们的查询对齐是应用使用模式的问题,这可以揭示大部分时间使用的查询,然后通过在这些查询上使用explain()来识别每次使用的查询计划。

索引交集

索引交集是指使用多个索引来满足查询的概念。这是最近添加的功能,还不完美;然而我们可以利用它来 consolodate 我们的索引。

我们可以通过在查询上使用explain()并在执行的查询计划中观察AND_SORTEDAND_HASH阶段来验证查询中是否发生了索引交集。

索引交集可能发生在我们使用OR($or)查询时,通过为每个OR子句使用不同的索引。索引交集可能发生在我们使用AND查询时,我们对每个AND子句都有完整的索引或者对一些(或全部)子句有索引前缀。

例如,考虑对我们的books集合的查询如下:

> db.books.find({ "isbn":"101", "price": { $gt: 20 }})

在这里,使用两个索引(一个在isbn上,另一个在price上),MongoDB 可以使用每个索引来获取相关结果,然后在索引结果上进行交集运算以获取结果集。

使用复合索引,正如您在本章中之前学到的,我们可以使用索引前缀来支持包含复合索引的前 1…n-1 个字段的查询。

我们无法通过复合索引支持寻找复合索引中字段的查询,其中一个或多个之前定义的字段缺失。

复合索引中的顺序很重要。

为了满足这些查询,我们可以在各个字段上创建索引,然后使用索引交集来满足我们的需求。这种方法的缺点是,随着字段数(n)的增加,我们需要创建的索引数量呈指数增长,因此增加了我们对存储和内存的需求。

索引交集不适用于sort()。我们不能使用一个索引来查询,然后使用不同的索引对结果应用sort()

然而,如果我们有一个索引可以满足查询的一部分和sort()字段,那么这个索引将被使用。

进一步阅读

您可以参考以下链接以获取更多信息:

总结

在本章中,您了解了索引和索引内部的基础知识。然后我们探讨了如何使用 MongoDB 中可用的不同索引类型,如单字段、复合和多键索引,以及一些特殊类型,如文本、哈希、TTL、部分、解析、唯一、不区分大小写和地理空间。

在本章的下一部分,您将学习如何使用 shell 构建和管理索引,这是管理和数据库管理的基本部分,即使对于 NoSQL 数据库也是如此。最后,我们讨论了如何在高层次上改进我们的索引,以及我们如何在实践中使用索引交集,以便 consolodate 索引数量。

在下一章中,我们将讨论如何监视我们的 MongoDB 集群并保持一致的备份。您还将学习如何处理 MongoDB 中的安全性。

第三部分:管理和数据管理

在本节中,我们将介绍操作概念以及 MongoDB 与数据处理生态系统的交互。我们将首先学习 MongoDB 如何处理监控、备份和安全性,然后概述 MongoDB 中可用的不同存储引擎。在 MongoDB 工具章节中,我们将了解所有工具,包括 Stitch 和 Atlas,我们可以用来与 MongoDB 交互,然后是一个涵盖如何使用 MongoDB 处理大数据的用例章节。

本节包括以下章节:

  • 第八章,监控、备份和安全性

  • 第九章,存储引擎

  • 第十章,MongoDB 工具

  • 第十一章,利用 MongoDB 处理大数据

第八章:监控、备份和安全性

监控、备份和安全性不应该是事后才考虑的,而是在将 MongoDB 部署到生产环境之前必须进行的过程。此外,监控可以(并且应该)用于在开发阶段排除故障和提高性能。

在本章中,我们将讨论 MongoDB 的运营方面。本章将涵盖制定正确和一致的备份策略以及确保我们的备份策略在需要备份时能够正常工作的内容。最后,我们将讨论 MongoDB 的安全性,包括身份验证、授权、网络级安全性以及如何审计我们的安全设计。

本章将重点关注以下三个领域:

  • 监控

  • 备份

  • 安全

监控

当我们设计软件系统时,我们进行了许多明确和隐含的假设。我们总是试图根据我们的知识做出最佳决策,但可能有一些参数我们低估了或没有考虑到。

通过监控,我们可以验证我们的假设,并验证我们的应用程序是否按预期执行并扩展。良好的监控系统对于检测软件错误和帮助我们及早发现潜在的安全事件也至关重要。

我们应该监控什么?

迄今为止,在 MongoDB 中监视的最重要的指标是内存使用情况。MongoDB(以及每个数据库系统)广泛使用系统内存来提高性能。无论我们使用 MMAPv1 还是 WiredTiger 存储引擎,使用的内存都是我们应该关注的第一件事。

了解计算机内存的工作原理可以帮助我们评估监控系统的指标。这些是与计算机内存相关的最重要的概念。

页面错误

RAM 速度快,但价格昂贵。硬盘驱动器或固态硬盘相对便宜,速度较慢,并且在系统和电源故障的情况下为我们的数据提供耐用性。我们所有的数据都存储在磁盘上,当我们执行查询时,MongoDB 将尝试从内存中获取数据。如果数据不在内存中,它将从磁盘中获取数据并将其复制到内存中。这是一个页面错误事件,因为内存中的数据是以页面形式组织的。

随着页面错误的发生,内存被填满,最终,一些页面需要被清除以便将最新的数据放入内存。这被称为页面驱逐事件。除非我们有一个非常静态的数据集,否则我们无法完全避免页面错误,但我们确实希望尽量减少页面错误。这可以通过将我们的工作集保留在内存中来实现。

常驻内存

常驻内存大小是 MongoDB 在 RAM 中拥有的总内存量。这是要监视的基本指标,应该小于可用内存的 80%。

虚拟和映射内存

当 MongoDB 请求内存地址时,操作系统将返回一个虚拟地址。这可能是 RAM 中的实际地址,也可能不是,这取决于数据所在的位置。MongoDB 将使用这个虚拟地址来请求底层数据。当我们启用日志记录(几乎总是应该启用),MongoDB 将为日志记录的数据保留另一个地址。虚拟内存指的是 MongoDB 请求的所有数据的大小,包括日志记录。

映射内存不包括日志记录引用。

所有这些意味着,随着时间的推移,我们的映射内存将大致等于我们的工作集,而虚拟内存将大约是我们映射内存的两倍。

工作集

工作集是 MongoDB 使用的数据大小。在事务性数据库的情况下,这将成为 MongoDB 持有的数据大小,但也可能存在一些集合根本没有被使用,不会对我们的工作集产生影响。

监控 WiredTiger 中的内存使用情况

理解 MMAPv1 中的内存使用相对比较简单。MMAPv1 在底层使用mmap()系统调用来将内存页的责任传递给底层操作系统。这就是为什么当我们使用 MMAPv1 时,内存使用量会不受限制地增长,因为操作系统试图尽可能多地将我们的数据集放入内存中。

另一方面,使用 WiredTiger,我们可以在启动时定义内部缓存的内存使用情况。默认情况下,内部缓存最多占用我们 RAM 的一半,即 1GB 或 256MB 之间。

除了内部缓存之外,MongoDB 还可以为其他操作分配内存,比如维护连接和数据处理(内存排序,MapReduce,聚合等)。

MongoDB 进程也会使用底层操作系统的文件系统缓存,就像在 MMAPv1 中一样。文件系统缓存中的数据是压缩的。

我们可以通过 mongo shell 查看 WiredTiger 缓存的设置,如下所示:

> db.serverStatus().wiredTiger.cache

我们可以使用storage.wiredTiger.engineConfig.cacheSizeGB参数来调整其大小。

一般的建议是将 WiredTiger 内部缓存大小保持默认。如果我们的数据具有较高的压缩比,可能值得将内部缓存大小减少 10%至 20%,以释放更多内存用于文件系统缓存。

跟踪页面错误

页面错误的数量可以保持相对稳定,不会对性能产生显著影响。然而,一旦页面错误数量达到一定阈值,我们的系统将迅速严重地受到影响。对于 HDD 来说更加明显,但对固态硬盘(SSD)也有影响。

确保我们不会遇到页面错误的方法是始终拥有一个与我们生产环境设置相同的临时环境。这个环境可以用来压力测试我们的系统可以处理多少页面错误,而不会降低性能。通过比较我们生产系统中实际的页面错误数量和从临时系统计算出的最大页面错误数量,我们可以找出我们还剩下多少余地。

查看页面错误的另一种方法是通过 shell,查看serverStatus输出的extra_info字段:

> db.adminCommand({"serverStatus" : 1})['extra_info']
{ "note" : "fields vary by platform", "page_faults" : 3465 }

正如note所述,这些字段可能不会出现在每个平台上。

跟踪 B 树未命中

正如您在前一章中看到的,适当的索引是保持 MongoDB 响应和高性能的最佳方法。B 树未命中指的是当我们尝试访问 B 树索引时发生的页面错误。索引通常被频繁使用,与我们的工作集和可用内存相比相对较小,因此它们应该始终在内存中。

如果 B 树未命中的数量或 B 树命中比例增加,或者 B 树未命中的数量减少,这表明我们的索引已经增长或者设计不够优化。B 树未命中也可以通过 MongoDB Cloud Manager 或 shell 进行监控。

在 shell 中,我们可以使用集合统计来定位它。

I/O 等待

I/O 等待指的是操作系统等待 I/O 操作完成的时间。它与页面错误有很强的正相关性。如果我们看到 I/O 等待随时间增加,这是页面错误即将发生的强烈迹象。我们应该努力保持 I/O 等待在健康的操作集群中低于 60%至 70%。设定这样的阈值将为我们争取一些时间,以便在突然增加的负载情况下进行升级。

读写队列

查看 I/O 等待和页面错误的另一种方法是通过读写队列。当出现页面错误和 I/O 等待时,请求将不可避免地开始排队进行读取或写入。队列是效果,而不是根本原因,所以当队列开始积累时,我们知道我们有问题要解决。

锁定百分比

这在较早版本的 MongoDB 中更为常见,在使用 WiredTiger 存储引擎时则不太常见。锁定百分比显示了数据库被锁定等待使用独占锁的操作释放的时间百分比。通常应该很低:最多为 10%至 20%。超过 50%意味着有问题。

后台刷新

默认情况下,MongoDB 每分钟将数据刷新到磁盘。后台刷新指的是数据持久化到磁盘所需的时间。对于每 1 分钟的时间段,它不应超过 1 秒。

修改刷新间隔可能有助于后台刷新时间;通过更频繁地写入磁盘,将减少需要写入的数据。在某些情况下,这可能会加快写入速度。

后台刷新时间受写入负载影响的事实意味着,如果我们的后台刷新时间开始变得过长,我们应该考虑对数据库进行分片,以增加写入容量。

跟踪空闲空间

使用 MMAPv1(使用 WiredTiger 时较少)时的常见问题是空闲磁盘空间。与内存一样,我们需要跟踪磁盘空间的使用情况,并且要有预见性,而不是被动应对。要保持监控磁盘空间的使用情况,并在达到 40%、60%或 80%时发出适当的警报,特别是对于快速增长的数据集。

磁盘空间问题通常是管理员、DevOps 和开发人员头疼的问题,因为移动数据需要花费时间。

directoryperdb选项可以帮助确定数据大小,因为我们可以将存储分割成不同的物理挂载磁盘。

监控复制

副本集使用操作日志oplog)来保持同步状态。每个操作都会应用在主服务器上,然后写入主服务器的操作日志中,这是一个有上限的集合。辅助服务器会异步读取此操作日志,并逐个应用这些操作。

如果主服务器负载过重,那么辅助服务器将无法快速读取和应用操作,从而产生复制延迟。复制延迟是指主服务器上应用的最后一个操作与辅助服务器上应用的最后一个操作之间的时间差,存储在操作日志的有上限的集合中。

例如,如果时间是下午 4:30:00,而辅助服务器刚刚应用了在我们的主服务器上下午 4:25:00 应用的操作,这意味着辅助服务器落后于我们的主服务器五分钟。

在我们的生产集群中,复制延迟应该接近(或等于)零。

操作日志大小

副本集中的每个成员都会在db.oplog.rs()中有一个操作日志的副本。原因是,如果主服务器下线,其中一个辅助服务器将被选举,并且它需要有最新版本的操作日志,以便新的辅助服务器进行跟踪。

操作日志大小是可配置的,我们应该尽可能设置得更大。操作日志大小不会影响内存使用情况,并且在操作问题的情况下可能会使数据库出现问题。

原因是,如果复制延迟随时间增加,最终会导致辅助服务器落后到无法从主服务器的操作日志中读取的地步,因为主服务器的操作日志中最旧的条目将晚于在辅助服务器上应用的最新条目。

一般来说,操作日志应至少包含一到两天的操作。出于之前详细说明的同样原因,操作日志应比初始同步所需的时间更长。

工作集计算

工作集是我们内存需求的最强指标。理想情况下,我们希望整个数据集都在内存中,但大多数情况下,这是不可行的。下一个最好的选择是将我们的工作集放在内存中。工作集可以直接或间接地计算出来。

直接地,我们可以从 shell 中调用serverStatus中的workingSet标志,如下所示:

> db.adminCommand({"serverStatus" : 1, "workingSet" : 1})

不幸的是,这在 3.0 版本中被移除,因此我们将专注于计算工作集的间接方法。

间接地,我们的工作集是我们需要满足 95%或更多用户请求的数据大小。为了计算这一点,我们需要从日志中识别用户发出的查询以及他们使用的数据集。为了满足索引内存需求,我们可以将其增加 30%到 50%,从而得出工作集的计算。

另一种间接估计工作大小的方法是通过页面错误的数量。如果我们没有页面错误,那么我们的工作集适合内存。通过反复试验,我们可以估计页面错误开始发生的点,并了解我们的系统可以处理多大负载。

如果我们不能将工作集放入内存中,那么我们至少应该有足够的内存,使索引可以放入内存中。在上一章中,我们描述了如何计算索引内存需求,以及如何使用这个计算来相应地调整我们的 RAM 大小。

监控工具

有几种监控选项。在本节中,我们将讨论如何使用 MongoDB 自己的工具或第三方工具进行监控。

托管工具

MongoDB, Inc.自己的工具 MongoDB Cloud Manager(以前称为 MongoDB Monitoring Service)是一个强大的工具,用于监控之前描述的所有指标。MongoDB Cloud Manager 有一个有限的免费套餐和一个 30 天的试用期。

使用 MongoDB Cloud Manager 的另一个选择是通过 MongoDB Atlas,MongoDB, Inc.的 DBaaS 产品。这也有一个有限的免费套餐,并且在三个主要的云提供商(亚马逊、谷歌和微软)中都可用。

开源工具

所有主要的开源工具,如NagiosMuninCacti等,都为 MongoDB 提供了插件支持。虽然这超出了本书的范围,但运维和 DevOps 应该熟悉之前描述的设置和理解指标,以有效地解决 MongoDB 的故障并在问题变得严重之前预先解决问题。

在 mongo shell 中,mongotopmongostat命令和脚本也可以用于临时监控。然而,这种手动过程的一个风险是脚本的任何失败可能会危及我们的数据库。如果有为您的监控需求而知名且经过测试的工具,请避免编写自己的工具。

备份

一句来自著名格言的引语如下:

“抱最好的希望,为最坏的打算。”

  • 约翰·杰伊(1813 年)

这应该是我们设计 MongoDB 备份策略时的方法。有几种不同的故障事件可能发生。

备份应该是我们灾难恢复策略的基石,以防发生意外。一些开发人员可能依赖于复制进行灾难恢复,因为似乎有三份数据已经足够。如果其中一份数据丢失,我们可以从其他两份数据重新构建集群。

这在磁盘故障事件中是适用的。磁盘故障是生产集群中最常见的故障之一,一旦磁盘开始接近其平均故障时间MTBF)时间,故障就会发生。

然而,这并不是唯一可能发生的故障事件。安全事件或纯粹的人为错误同样可能发生,并且应该成为我们计划的一部分。一旦所有副本集成员同时丢失,如火灾、洪水、地震或不满的员工,这些事件不应导致生产数据丢失。

一个有用的临时选择,处于复制和实施适当备份之间的中间地带,可能是设置一个延迟的副本集成员。这个成员可以滞后于主服务器几个小时或几天,这样就不会受到主服务器中恶意更改的影响。需要注意的重要细节是,操作日志需要配置成可以保持几个小时的延迟。此外,这个解决方案只是一个临时解决方案,因为它没有考虑到我们需要灾难恢复的全部原因,但肯定可以帮助解决其中的一部分。

这被称为灾难恢复。灾难恢复是一类需要定期进行备份的故障,而且还需要使用一个过程来将它们(无论是地理上还是访问规则上)与我们的生产数据隔离开。

备份选项

根据我们的部署策略,我们可以选择不同的备份选项。

基于云的解决方案

如果我们使用云 DBaaS 解决方案,最直接的解决方案就是在 MongoDB 的例子中,我们可以通过 GUI 管理备份。

如果我们在自己的服务器上托管 MongoDB,我们可以使用 MongoDB, Inc.的 MongoDB Cloud Manager。Cloud Manager 是一个 SaaS,我们可以将其指向我们自己的服务器来监视和备份我们的数据。它使用与复制相同的操作日志,并且可以备份副本集和分片集群。

如果我们不想(或者出于安全原因不能)将我们的服务器指向外部的 SaaS 服务,我们可以在本地使用 MongoDB Cloud Manager 的功能,使用 MongoDB Ops Manager。要获得 MongoDB Ops Manager,我们需要为我们的集群订阅 MongoDB 企业高级版。

文件系统快照备份

过去最常见的备份方法,也是目前广泛使用的方法,依赖于底层文件系统的时间点快照功能来备份我们的数据。

EBS on EC2 和 Linux 上的逻辑卷管理器LVM)支持时间点快照。

如果我们使用最新版本的 MongoDB 和 WiredTiger,我们可以进行卷级备份,即使我们的数据和日志文件存储在不同的卷中。

我们可以按照以下步骤备份副本集:

  • 要备份副本集,我们需要为我们的数据库保持一致的状态。这意味着我们的所有写操作要么已经提交到磁盘,要么在我们的日志文件中。

  • 如果我们使用 WiredTiger 存储,我们的快照将与最新的检查点一致,这要么是 2GB 的数据,要么是最后一分钟的备份。

确保将快照存储在离线卷中,以备灾难恢复之需。您需要启用日志记录以使用时间点快照。无论如何,启用日志记录都是一个好的做法。

备份分片集群

如果我们想备份整个分片集群,我们需要在开始之前停止平衡器。原因是,如果在我们拍摄快照时有不同分片之间的数据块迁移,我们的数据库将处于不一致状态,拥有在我们拍摄快照时正在传输的不完整或重复的数据块。

整个分片集群的备份将是近似时间的。如果我们需要时间点精度,我们需要停止数据库中的所有写操作,这通常对于生产系统来说是不可能的。

首先,我们需要通过 mongo shell 连接到我们的 mongos 来禁用平衡器:

> use config
> sh.stopBalancer()

然后,如果我们的辅助服务器没有启用日志记录,或者如果我们的日志和数据文件存储在不同的卷中,我们需要锁定所有分片和配置服务器副本集的辅助 mongo 实例。

我们还需要在这些服务器上设置足够的操作日志大小,以便它们可以在我们解锁它们后赶上主服务器;否则,我们将需要从头开始重新同步它们。

假设我们不需要锁定我们的辅助副本,下一步是备份配置服务器。在 Linux(使用 LVM),这类似于执行以下操作:

$ lvcreate --size 100M --snapshot --name snap-14082017 /dev/vg0/mongodb

然后,我们需要为每个分片中每个副本集的单个成员重复相同的过程。

最后,我们需要使用相同的 mongo shell 重新启动平衡器,该 shell 用于停止它:

> sh.setBalancerState(true)

不详细介绍,显而易见的是,备份分片集是一个复杂且耗时的过程。它需要事先规划和广泛测试,以确保它不仅可以在最小干扰下工作,而且我们的备份可用且可以恢复到我们的集群中。

使用 mongodump 进行备份

mongodump工具是一个可以备份我们 MongoDB 集群中数据的命令行工具。因此,缺点是在恢复时需要重新创建所有索引,这可能是一个耗时的操作。

mongodump工具的主要缺点是,为了将数据写入磁盘,它需要首先将数据从内部 MongoDB 存储器带到内存中。这意味着在承受压力运行的生产集群中,mongodump将使内存中的数据无效,从而使工作集中的数据与常规操作下不会驻留在内存中的数据相混合。这会降低我们集群的性能。

另一方面,当我们使用mongodump时,我们可以继续在我们的集群中进行写入,并且如果我们有一个副本集,我们可以使用--oplog选项将mongodump操作期间发生的条目包括在其输出 oplog 中。

如果我们选择这个选项,我们需要在使用mongorestore工具将数据恢复到 MongoDB 集群时使用--oplogReplay

mongodump是单服务器部署的好工具,但一旦我们扩大规模,我们应该考虑使用不同(并且更好计划的)方法来备份我们的数据。

通过复制原始文件进行备份

如果我们不想使用前面概述的任何选项,我们的最后选择是使用cp/rsync或类似的东西复制原始文件。一般来说,这是不推荐的,原因如下:

  • 在复制文件之前,我们需要停止所有写入操作

  • 备份大小将更大,因为我们需要复制索引和任何底层填充和碎片化存储开销。

  • 我们无法通过这种方法为副本集实现恢复到特定时间点,并且以一种一致且可预测的方式从分片集群中复制数据是非常困难的

除非真的没有其他选择,否则应避免通过复制原始文件进行备份。

使用排队进行备份

实际上使用的另一种策略是利用排队系统,拦截我们的数据库和前端软件系统。在我们的数据库中插入/更新/删除之前使用类似 ActiveMQ 队列的东西意味着我们可以安全地将数据发送到不同的接收端,这些接收端可以是 MongoDB 服务器或独立存储库中的日志文件。像延迟副本集方法一样,这种方法对于一类备份问题可能有用,但对于其他一些问题可能会失败。

这是一个有用的临时解决方案,但不应作为永久解决方案。

EC2 备份和恢复

MongoDB Cloud Manager 可以自动从 EC2 卷中进行备份;而且,由于我们的数据在云中,为什么不使用 Cloud Manager 呢?

如果由于某种原因我们无法使用它,我们可以编写一个脚本来通过实施以下步骤进行备份:

  1. 假设我们已经启用了日志记录(我们确实应该这样做),并且我们已经将包含数据和日志文件的dbpath映射到单个 EBS 卷上,我们首先需要使用ec2-describe-instances找到与运行实例相关联的 EBS 块实例。

  2. 下一步是使用lvdisplay找到我们的 MongoDB 数据库的dbpath映射到的逻辑卷。

  3. 一旦我们从逻辑卷中确定了逻辑设备,我们可以使用ec2-create-snapshot来创建新的快照。我们需要包括每一个映射到我们的dbpath目录的逻辑设备。

为了验证我们的备份是否有效,我们需要基于快照创建新卷并将新卷挂载在那里。最后,mongod进程应该能够开始挂载新数据,并且我们应该使用 MongoDB 进行连接以验证这些内容。

增量备份

每次进行完整备份对于一些部署来说可能是可行的,但是当大小达到一定阈值时,完整备份会花费太多时间和空间。

在这一点上,我们会想要偶尔进行完整备份(例如每月一次),并在此期间进行增量备份(例如每晚)。

Ops Manager 和 Cloud Manager 都支持增量备份,如果我们达到这个规模,使用工具进行备份可能是一个好主意,而不是自己开发。

如果我们不想(或不能)使用这些工具,我们可以通过 oplog 进行恢复,如下所示:

  1. 使用之前描述的任何方法进行完整备份

  2. 锁定我们副本集的辅助服务器的写入

  3. 注意 oplog 中的最新条目

  4. 在 oplog 中的最新条目之后导出条目:

> mongodump --host <secondary> -d local -c oplog.rs -o /mnt/mongo-oldway_backup
 --query '{ "ts" : { $gt :  Timestamp(1467999203, 391) } }'
  1. 在辅助服务器上解锁写入

要恢复,我们可以使用刚刚导出的oplog.rs文件,并使用mongorestore选项--oplogReplay

> mongorestore -h <primary> --port <port> --oplogReplay <data_file_position>

这种方法需要锁定写入,并且在将来的版本中可能无法使用。

更好的解决方案是使用逻辑卷管理(LVM)文件系统进行增量备份,但这取决于底层的 LVM 实现,我们可能无法进行调整。

安全性

安全性是 MongoDB 集群中的一个多方面目标。在本章的其余部分,我们将研究不同的攻击向量以及我们如何保护自己免受攻击。除了这些最佳实践之外,开发人员和管理员必须始终使用常识,以便安全性只在操作目标所需的程度上干扰。

认证

认证是指验证客户端的身份。这可以防止冒充他人以获取其数据的行为。

最简单的认证方式是使用usernamepassword对。可以通过两种方式之一在 shell 中完成,第一种方式如下:

> db.auth( <username>, <password> )

传递逗号分隔的usernamepassword将假定其余字段的默认值:

> db.auth( {
 user: <username>,
 pwd: <password>,
 mechanism: <authentication mechanism>,
 digestPassword: <boolean>
} )

如果我们传递一个文档对象,我们可以定义比username/password更多的参数。

(认证)mechanism参数可以采用几种不同的值,默认值为SCRAM-SHA-1。参数值MONGODB-CR用于与 3.0 之前的版本向后兼容。

MONGODB-x.509 用于 TLS/SSL 认证。用户和内部副本集服务器可以通过使用 SSL 证书进行认证,这些证书可以是自动生成和签名的,也可以来自受信任的第三方机构。

要为副本集成员的内部认证配置 x.509,我们需要提供以下参数之一。

以下是配置文件的内容:

security.clusterAuthMode / net.ssl.clusterFile

以下是在命令行上使用的:

--clusterAuthMode and --sslClusterFile
> mongod --replSet <name> --sslMode requireSSL --clusterAuthMode x509 --sslClusterFile <path to membership certificate and key PEM file> --sslPEMKeyFile <path to SSL certificate and key PEM file> --sslCAFile <path to root CA PEM file>

MongoDB 企业版是 MongoDB,Inc.提供的付费产品,增加了两个认证选项,如下所示:

  • 第一个添加的选项是通用安全服务应用程序接口GSSAPI)Kerberos。Kerberos 是一个成熟和强大的认证系统,可用于基于 Windows 的 Active Directory 部署等场景。

  • 第二个添加的选项是 PLAIN(LDAP SASL)。LDAP 就像 Kerberos 一样:是一种成熟和健壮的身份验证机制。使用 PLAIN 身份验证机制时的主要考虑因素是凭据以明文形式在网络上传输。这意味着我们应该通过 VPN 或 TSL/SSL 连接来保护客户端和服务器之间的路径,以避免中间人窃取我们的凭据。

授权

在我们配置了身份验证以验证用户在连接到我们的 MongoDB 服务器时是否是他们声称的身份后,我们需要配置每个用户在我们数据库中拥有的权限。

这是权限的授权方面。MongoDB 使用基于角色的访问控制来控制不同用户类别的权限。

每个角色都有权限在资源上执行一些操作。

资源可以是一个集合/多个集合或一个数据库/多个数据库。

命令的格式如下:

{ db: <database>, collection: <collection> }

如果我们为dbcollection指定了""(空字符串),这意味着任何dbcollection。例如:

{ db: "mongo_books", collection: "" }

这将应用我们的操作到mongo_books数据库中的每个collection

如果数据库不是admin数据库,则不会包括系统集合。系统集合,如<db>.system.profile<db>.system.jsadmin.system.usersadmin.system.roles,需要明确定义。

与前面的选项类似,我们可以定义以下内容:

{ db: "", collection: "" }

我们定义这个规则,将其应用到所有数据库的所有集合,当然除了系统集合。

我们还可以应用规则到整个集群,如下:

{ resource: { cluster : true }, actions: [ "addShard" ] }

前面的示例授予了在整个集群中执行addShard操作(向系统添加新的分片)的权限。集群资源只能用于影响整个集群而不是集合或数据库的操作(例如shutdownreplSetReconfigappendOplogNoteresynccloseAllDatabasesaddShard)。

接下来是一个广泛的特定于集群的操作列表,以及一些最常用的操作。

最常用操作的列表如下:

  • 查找

  • 插入

  • 删除

  • 更新

  • 绕过文档验证

  • 查看角色/查看用户

  • 创建角色/删除角色

  • 创建用户/删除用户

  • inprog

  • killop

  • replSetGetConfig/replSetConfigure/replSetStateChange/resync

  • 获取分片映射/获取分片版本/列出分片/移动分片/移除分片/添加分片

  • 删除数据库/删除索引/fsync/修复数据库/关闭

  • 服务器状态/顶部/验证

特定于集群的操作如下:

  • 解锁

  • authSchemaUpgrade

  • 清理孤立

  • cpuProfiler

  • inprog

  • 使用户缓存无效

  • killop

  • 追加操作日志注释

  • replSetConfigure

  • replSetGetConfig

  • replSetGetStatus

  • replSetHeartbeat

  • replSetStateChange

  • 重新同步

  • 添加分片

  • 刷新路由器配置

  • 获取分片映射

  • 列出分片

  • 移除分片

  • 分片状态

  • 应用消息

  • 关闭所有数据库

  • connPoolSync

  • fsync

  • 获取参数

  • 主机信息

  • 日志轮转

  • 设置参数

  • 关闭

  • 触摸

  • connPoolStats

  • 游标信息

  • 诊断日志

  • 获取 CmdLineOpts

  • 获取日志

  • 列出数据库

  • netstat

  • 服务器状态

  • 顶部

如果听起来太复杂,那是因为它确实如此!MongoDB 允许在资源上配置不同操作的灵活性意味着我们需要研究和理解之前描述的广泛列表。

幸运的是,一些最常见的操作和资源已经包含在内置角色中。

我们可以使用这些内置角色来建立我们将授予用户的权限基线,然后根据广泛的列表进行细化。

用户角色

我们可以指定两种不同的通用用户角色,如下:

  • 读取:在非系统集合和以下系统集合上的只读角色:system.indexessystem.jssystem.namespaces集合

  • readWrite:在非系统集合和system.js集合上具有读写权限

数据库管理角色

有三种特定于数据库的管理角色,如下所示:

  • dbAdmin:可以执行与模式相关的任务、索引和收集统计信息的基本管理员用户角色。dbAdmin不能执行用户和角色管理。

  • userAdmin:创建和修改角色和用户。这是dbAdmin角色的补充。

userAdmin可以修改自身以成为数据库中的超级用户,或者,如果范围限定为admin数据库,则可以成为 MongoDB 集群的超级用户。

  • dbOwner:结合了readWritedbAdminuserAdmin角色,这是最强大的管理员用户角色。

集群管理角色

以下是可用的集群范围管理角色:

  • hostManager:监视和管理集群中的服务器。

  • clusterManager:提供对集群的管理和监控操作。拥有此角色的用户可以访问用于分片和复制的配置和本地数据库。

  • clusterMonitor:只读访问权限,用于监控工具,如 MongoDB Cloud Manager 和 Ops Manager 代理提供的工具。

  • clusterAdmin:提供最大的集群管理访问权限。该角色结合了clusterManagerclusterMonitorhostManager角色授予的权限。此外,该角色提供dropDatabase操作。

备份和恢复角色

基于角色的授权角色可以在备份和恢复的粒度级别中定义:

  • backup:提供备份数据所需的权限。该角色提供足够的权限来使用 MongoDB Cloud Manager 备份代理、Ops Manager 备份代理或mongodump

  • restore:提供使用mongorestore还原数据所需的权限,但不包括--oplogReplay选项或system.profile集合数据。

所有数据库中的角色

同样,以下是所有数据库中可用的角色集合:

  • readAnyDatabase:提供与read相同的只读权限,但适用于集群中除了本地和配置数据库之外的所有数据库。该角色还在整个集群上提供listDatabases操作。

  • readWriteAnyDatabase:提供与readWrite相同的读写权限,但适用于集群中除了本地和配置数据库之外的所有数据库。该角色还在整个集群上提供listDatabases操作。

  • userAdminAnyDatabase:提供与userAdmin相同的用户管理操作权限,但适用于集群中除了本地和配置数据库之外的所有数据库。由于userAdminAnyDatabase角色允许用户向任何用户授予任何权限,包括自己,该角色间接地提供了超级用户访问权限。

  • dbAdminAnyDatabase:提供与dbAdmin相同的数据库管理操作权限,但适用于集群中除了本地和配置数据库之外的所有数据库。该角色还在整个集群上提供listDatabases操作。

超级用户

最后,以下是可用的超级用户角色:

  • root:提供对readWriteAnyDatabasedbAdminAnyDatabaseuserAdminAnyDatabaseclusterAdminrestorebackup的操作和所有资源的访问权限

  • __internal:类似于 root 用户,任何__internal用户都可以对服务器上的任何对象执行任何操作。

应避免使用超级用户角色,因为它们可能对服务器上的所有数据库具有潜在破坏性的权限。

网络级安全

除了 MongoDB 特定的安全措施,还有针对网络级安全建立的最佳实践:

  • 只允许服务器之间的通信,并且只打开用于它们之间通信的端口。

  • 始终使用 TLS/SSL 进行服务器之间的通信。这可以防止中间人攻击冒充客户端。

  • 始终使用不同的开发、暂存和生产环境以及安全凭据。理想情况下,为每个环境创建不同的帐户,并在暂存和生产环境中启用双因素身份验证。

审计安全

无论我们如何计划我们的安全措施,来自我们组织之外的第二或第三双眼睛可以对我们的安全措施提供不同的视角,并发现我们可能低估或忽视的问题。不要犹豫,要请安全专家和白帽黑客对服务器进行渗透测试。

特殊情况

出于数据隐私原因,医疗或金融应用程序需要增加安全级别。

如果我们正在构建一个涉及医疗保健领域的应用程序,访问用户的个人身份信息,我们可能需要获得 HIPAA 认证。

如果我们正在构建一个与支付交互并管理持卡人信息的应用程序,我们可能需要符合 PCI/DSS 标准。

每个认证的具体细节超出了本书的范围,但重要的是要知道 MongoDB 在这些领域有使用案例,满足要求,并且在适当的设计前可以成为正确的工具。

概述

总结涉及安全的最佳实践建议,我们有以下内容:

  • 强制进行身份验证:始终在生产环境中启用身份验证。

  • 启用访问控制:首先创建一个系统管理员,然后使用该管理员创建更有限的用户。为每个用户角色提供所需的最少权限。

  • 定义细粒度的访问控制角色:不要给予每个用户比所需权限更多的权限。

  • 加密客户端和服务器之间的通信:在生产环境中,始终使用 TLS/SSL 进行客户端和服务器之间的通信。对于mongodmongos或配置服务器之间的通信,也应始终使用 TLS/SSL。

  • 加密静止数据:MongoDB 企业版提供了在存储时加密数据的功能,使用 WiredTiger 静止加密。

或者,我们可以使用文件系统、设备或物理加密来加密数据。在云中,我们通常也可以选择加密(例如,在 Amazon EC2 上使用 EBS)。

  • 限制网络暴露:MongoDB 服务器应该只连接到应用程序服务器和其他必需的服务器。除了我们为 MongoDB 通信设置的端口之外,不应该对外界开放其他端口。如果我们想要调试 MongoDB 的使用,重要的是设置一个代理服务器,以受控访问与我们的数据库进行通信。

  • 审计服务器以查找异常活动:MongoDB 企业版提供了一个审计实用程序。通过使用它,我们可以将事件输出到控制台、JSON 文件、BSON 文件或 syslog。无论如何,重要的是确保审计事件存储在对系统用户不可用的分区中。

  • 使用专用操作系统用户来运行 MongoDB。确保专用操作系统用户可以访问 MongoDB,但不具备不必要的权限。

  • 如果不需要,禁用 JavaScript 服务器端脚本。

MongoDB 可以使用 JavaScript 进行服务器端脚本,使用以下命令:mapReduce()group()$where。如果我们不需要这些命令,我们应该在命令行上使用--noscripting选项禁用服务器端脚本。

总结

在本章中,您了解了 MongoDB 的三个操作方面:监控、备份和安全。

我们讨论了在 MongoDB 中应该监控的指标,以及如何监控它们。在此之后,我们讨论了如何进行备份并确保我们可以使用它们来恢复我们的数据。最后,您了解了身份验证和授权概念以及网络级安全以及如何对其进行审计。

设计、构建和根据需要扩展我们的应用程序同样重要,同样重要的是要确保在运营过程中我们能够心无旁骛,并且能够防范意外事件,比如人为错误和内部或外部恶意用户。

在下一章中,您将了解可插拔存储引擎,这是在 MongoDB 3.0 版本中引入的新概念。可插拔存储引擎允许满足不同的用例,特别是在具有特定和严格的数据处理和隐私要求的应用领域。

第九章:存储引擎

MongoDB 在 3.0 版本中引入了可插拔存储引擎的概念。在收购 WiredTiger 之后,它首先将其存储引擎作为可选引擎引入,然后作为当前版本 MongoDB 的默认存储引擎。在本章中,我们将深入探讨存储引擎的概念,它们的重要性以及如何根据我们的工作负载选择最佳存储引擎。

我们将涵盖以下主题:

  • 可插拔存储引擎

  • WiredTiger

  • 加密

  • 内存中

  • MMAPv1

  • MongoDB 中的锁定

可插拔存储引擎

随着 MongoDB 从 Web 应用程序范式中分离出来进入具有不同要求的领域,存储已成为一个越来越重要的考虑因素。

使用多个存储引擎可以被视为使用基础架构堆栈中不同存储解决方案和数据库的替代方式。这样,我们可以减少操作复杂性,并且应用层对基础存储层是不可知的,从而缩短开发时间。

MongoDB 目前提供了四种不同的存储引擎,我们将在接下来的章节中更详细地讨论。

WiredTiger

从版本 3.2 开始,WiredTiger 是默认的存储引擎,也是大多数工作负载的最佳选择。通过提供文档级别的锁定,它克服了 MongoDB 早期版本中最显著的缺点之一——在高负载下的锁争用。

我们将在接下来的章节中探讨一些 WiredTiger 的好处。

文档级别的锁定

锁定是如此重要,以至于我们将在本节末尾更详细地解释细粒度锁定的性能影响。与 MMAPv1 集合级别锁定相比,具有文档级别锁定可以在许多实际用例中产生巨大的差异,并且是选择 WiredTiger 而不是 MMAPv1 的主要原因之一。

快照和检查点

WiredTiger 使用多版本并发控制MVCC)。MVCC 基于这样一个概念,即数据库保留对象的多个版本,以便读者能够查看在读取期间不会发生变化的一致数据。

在数据库中,如果我们有多个读者在写入者修改数据的同时访问数据,我们可能会出现读者查看此数据的不一致视图的情况。解决这个问题的最简单和最容易的方法是阻止所有读者,直到写入者完成对数据的修改。

这当然会导致严重的性能下降。MVCC 通过为每个读者提供数据库的快照来解决这个问题。当读取开始时,每个读者都保证查看数据与读取开始时的时间点完全一致。写入者进行的任何更改只有在写入完成后才会被读者看到,或者在数据库术语中,只有在事务提交后读者才能看到。

为了实现这个目标,当写入数据时,更新后的数据将被保存在磁盘的一个单独位置,并且 MongoDB 将标记受影响的文档为过时。MVCC 被认为提供了时点一致的视图。这相当于传统 RDBMS 系统中的读提交隔离级别。

对于每个操作,WiredTiger 将在发生操作的确切时刻对我们的数据进行快照,并为应用程序提供一致的应用程序数据视图。当我们写入数据时,WiredTiger 将在每 2GB 的日志数据或 60 秒内创建一个快照,以先到者为准。在故障情况下,WiredTiger 依赖于其内置日志来恢复最新检查点之后的任何数据。

我们可以使用 WiredTiger 禁用日志记录,但如果服务器崩溃,我们将丢失最后一个检查点之后的任何数据。

日志记录

正如在快照和检查点部分中所解释的,日志记录是 WiredTiger 崩溃恢复保护的基石。

WiredTiger 使用 snappy 压缩算法压缩日志。我们可以使用以下设置来设置不同的压缩算法:

storage.wiredTiger.engineConfig.journalCompressor

我们还可以通过将以下设置为 false 来禁用 WiredTiger 的日志记录:

storage.journal.enabled

如果我们使用副本集,我们可能能够从次要节点中恢复数据,该节点将被选举为主节点并开始接受写入,以防我们的主节点发生故障。建议始终使用日志记录,除非我们了解并能够承受不使用它的后果。

数据压缩

MongoDB 默认使用 snappy 压缩算法来压缩数据和索引前缀。索引前缀压缩意味着相同的索引键前缀仅存储一次在内存页中。压缩不仅减少了存储空间,还会增加每秒的 I/O 操作,因为需要存储和从磁盘移动的数据更少。如果我们的工作负载是 I/O 限制而不是 CPU 限制,使用更激进的压缩可以带来性能提升。

我们可以通过将以下参数设置为 false 来定义 .zlib 压缩而不是 snappy 或无压缩:

storage.wiredTiger.collectionConfig.blockCompressor

数据压缩使用更少的存储空间,但会增加 CPU 的使用。.zlib 压缩以牺牲更高的 CPU 使用率来实现更好的压缩,与默认的 snappy 压缩算法相比。

我们可以通过将以下参数设置为 false 来禁用索引前缀压缩:

storage.wiredTiger.indexConfig.prefixCompression

我们还可以在创建过程中使用以下参数为每个索引配置存储:

{ <storage-engine-name>: <options> }

内存使用

WiredTiger 在使用 RAM 方面与 MMAPv1 有显著不同。MMAPv1 本质上是使用底层操作系统的文件系统缓存来将数据从磁盘分页到内存,反之亦然。

相反,WiredTiger 引入了 WiredTiger 内部缓存的新概念。

WiredTiger 内部缓存默认为以下两者中的较大者:

  • 50% 的 RAM 减去 1 GB

  • 256 MB

这意味着如果我们的服务器有 8 GB RAM,我们将得到以下结果:

max(3 GB , 256 MB) = WiredTiger 将使用 3 GB 的 RAM

如果我们的服务器有 2,512 MB RAM,我们将得到以下结果:

max(256 MB, 256 MB) = WiredTiger 将使用 256 MB 的 RAM

基本上,对于任何 RAM 小于 2,512 MB 的服务器,WiredTiger 将使用 256 MB 作为其内部缓存。

我们可以通过设置以下方式改变 WiredTiger 内部缓存的大小:

storage.wiredTiger.engineConfig.cacheSizeGB

我们也可以使用以下命令行来执行此操作:

--wiredTigerCacheSizeGB

除了未压缩以获得更高性能的 WiredTiger 内部缓存外,MongoDB 还使用了压缩的文件系统缓存,就像 MMAPv1 一样,在大多数情况下将使用所有可用内存。

WiredTiger 内部缓存可以提供类似于内存存储的性能。因此,尽可能地扩大它是很重要的。

使用多核处理器时,使用 WiredTiger 可以获得更好的性能。与 MMAPv1 相比,这也是一个很大的优势,因为后者的扩展性不如 WiredTiger。

我们可以,也应该,使用 Docker 或其他容器化技术来隔离 mongod 进程,并确保我们知道每个进程在生产环境中可以使用多少内存。不建议将 WiredTiger 内部缓存增加到其默认值以上。文件系统缓存不应少于总 RAM 的 20%。

readConcern

WiredTiger 支持多个 readConcern 级别。就像 writeConcern 一样,它被 MongoDB 中的每个存储引擎支持,通过 readConcern,我们可以自定义副本集中必须确认查询结果的服务器数量,以便将文档返回到结果集中。

读关注的可用选项如下:

  • local:默认选项。将从服务器返回最近的数据。数据可能已经传播到副本集中的其他服务器,也可能没有,我们面临回滚的风险。

  • 线性化

  • 仅适用于从主节点读取

  • 仅适用于返回单个结果的查询

  • 数据返回满足两个条件:

  • majority, writeConcern

  • 数据在读操作开始前已被确认

此外,如果我们将writeConcernMajorityJournalDefault设置为true,我们可以确保数据不会被回滚。

如果我们将writeConcernMajorityJournalDefault设置为false,MongoDB 在确认写入之前不会等待majority写入变得持久。在这种情况下,如果复制集中的成员丢失,我们的数据可能会被回滚。返回的数据已经从大多数服务器传播和确认后才开始读取。

当使用linearizablemajority读取关注级别时,我们需要使用maxTimeMS,以防我们无法建立majority writeConcern而永远等待响应。在这种情况下,操作将返回超时错误。

MMAPv1 是较旧的存储引擎,在许多方面被认为是废弃的,但仍然有许多部署在使用它。

locallinearizable读取关注对 MMAPv1 也可用。

WiredTiger 集合级选项

当我们创建一个新的集合时,可以像这样向 WiredTiger 传递选项:

> db.createCollection(
 "mongo_books",
 { storageEngine: { wiredTiger: { configString: "<key>=<value>" } } }
)

这有助于创建我们的mongo_books集合,并从 WiredTiger 通过其 API 公开的可用选项中选择一个键值对。一些最常用的键值对如下:

block_allocation 最佳或首选
allocation_size 512 字节到 4KB;默认 4KB
block_compressor 无,.lz4.snappy.zlib.zstd,或根据配置的自定义压缩器标识符字符串
memory_page_max 512 字节到 10TB;默认 5MB
os_cache_max 大于零的整数;默认为零

这直接取自 WiredTiger 文档中的定义,位于source.wiredtiger.com/mongodb-3.4/struct_w_t___s_e_s_s_i_o_n.html

int WT_SESSION::create()

集合级选项允许灵活配置存储,但应在开发/暂存环境中经过仔细测试后谨慎使用。

如果应用于复制集中的主要服务器,集合级选项将传播到辅助服务器。block_compressor也可以通过使用--wiredTigerCollectionBlockCompressor选项全局配置数据库的命令行来进行配置。

WiredTiger 性能策略

正如本章前面讨论的,WiredTiger 使用内部缓存来优化性能。此外,操作系统(和 MMAPv1)使用文件系统缓存来从磁盘中获取数据。

默认情况下,我们将 50%的 RAM 专用于文件系统缓存,另外 50%专用于 WiredTiger 内部缓存。

文件系统缓存将保持数据在存储在磁盘上时的压缩状态。内部缓存将按如下方式解压缩:

  • 策略 1:将 80%或更多分配给内部缓存。这样可以将我们的工作集适应 WiredTiger 的内部缓存中。

  • 策略 2:将 80%或更多分配给文件系统缓存。我们的目标是尽可能避免使用内部缓存,并依赖文件系统缓存来满足我们的需求。

  • 策略 3:使用 SSD 作为快速搜索时间的基础存储,并将默认值保持在 50-50%的分配。

  • 策略 4:通过 MongoDB 的配置在我们的存储层启用压缩,以节省存储空间,并通过减小工作集大小来提高性能。

我们的工作负载将决定我们是否需要偏离默认的策略 1。一般来说,我们应该尽可能使用 SSD,并且通过 MongoDB 的可配置存储,我们甚至可以在需要最佳性能的一些节点上使用 SSD,并将 HDD 用于分析工作负载。

WiredTiger B 树与 LSM 索引

B 树是不同数据库系统中索引的最常见数据结构。WiredTiger 提供了使用日志结构合并LSM)树而不是 B 树进行索引的选项。

当我们有随机插入的工作负载时,LSM 树可以提供更好的性能,否则会导致页面缓存溢出,并开始从磁盘中分页数据以保持我们的索引最新。

LSM 索引可以像这样从命令行中选择:

> mongod --wiredTigerIndexConfigString "type=lsm,block_compressor=zlib"

前面的命令选择lsm作为type,并且在这个mongod实例中,block_compressorzlib

加密

加密存储引擎是为支持一系列特殊用例而添加的,主要围绕金融、零售、医疗保健、教育和政府。

如果我们必须遵守一系列法规,包括以下内容,我们需要对其余数据进行加密:

  • 处理信用卡信息的 PCI DSS

  • 医疗保健应用的 HIPAA

  • 政府的 NIST

  • 政府的 FISMA

  • 政府的 STIG

这可以通过几种方式来实现,云服务提供商(如 EC2)提供了内置加密的 EBS 存储卷。加密存储支持英特尔的 AES-NI 配备的 CPU,以加速加密/解密过程。

支持的加密算法如下:

  • AES-256,CBC(默认)

  • AES-256,GCM

  • FIPS,FIPS-140-2

加密支持页面级别的更好性能。当文档中进行更改时,只需修改受影响的页面,而不是重新加密/解密整个底层文件。

加密密钥管理是加密存储安全性的一个重要方面。大多数先前提到的规范要求至少每年进行一次密钥轮换。

MongoDB 的加密存储使用每个节点的内部数据库密钥。这个密钥由一个外部(主)密钥包装,必须用于启动节点的mongod进程。通过使用底层操作系统的保护机制,如mlockVirtualLock,MongoDB 可以保证外部密钥永远不会因页面错误从内存泄漏到磁盘。

外部(主)密钥可以通过使用密钥管理互操作性协议KMIP)或通过使用密钥文件进行本地密钥管理来管理。

MongoDB 可以通过对副本集成员执行滚动重启来实现密钥轮换。使用 KMIP,MongoDB 可以仅轮换外部密钥而不是底层数据库文件。这带来了显著的性能优势。

使用 KMIP 是加密数据存储的推荐方法。加密存储基于 WiredTiger,因此可以使用加密来享受其所有优势。加密存储是 MongoDB 企业版的一部分,这是 MongoDB 的付费产品。

使用 MongoDB 的加密存储可以提高性能,相对于加密存储卷。与第三方加密存储解决方案相比,MongoDB 的加密存储的开销约为 15%,而第三方加密存储解决方案的开销为 25%或更高。

在大多数情况下,如果我们需要使用加密存储,我们将在应用程序设计阶段提前知道,并且可以对不同的解决方案进行基准测试,以选择最适合我们用例的解决方案。

内存中

在内存中存储 MongoDB 是一项高风险的任务,但回报很高。将数据保留在内存中的速度可能比在磁盘上持久存储快 100,000 倍。

使用内存存储的另一个优势是,我们在写入或读取数据时可以实现可预测的延迟。一些用例要求延迟不论操作是什么都不偏离正常。

另一方面,通过将数据保留在内存中,我们面临断电和应用程序故障的风险,可能会丢失所有数据。使用副本集可以防范某些类别的错误,但如果我们将数据存储在内存中而不是存储在磁盘上,我们将始终更容易面临数据丢失。

然而,有一些用例,我们可能不太在乎丢失旧数据。例如,在金融领域,我们可能有以下情况:

  • 高频交易/算法交易,高流量情况下更高的延迟可能导致交易无法完成

  • 在欺诈检测系统中,我们关心的是尽可能快地进行实时检测,并且我们可以安全地将只需要进一步调查的案例或明确的阳性案例存储到持久存储中。

  • 信用卡授权、交易订单对账和其他需要实时答复的高流量系统

在 Web 应用程序生态系统中,我们有以下内容:

  • 在入侵检测系统中,如欺诈检测,我们关心的是尽可能快地检测入侵,而对假阳性案例并不那么关心。

  • 在产品搜索缓存的情况下,数据丢失并不是使命关键,而是从客户的角度来看是一个小不便。

  • 对于实时个性化产品推荐来说,数据丢失的风险较低。即使我们遭受数据丢失,我们也可以重新构建索引。

内存存储引擎的一个主要缺点是我们的数据集必须适合内存。这意味着我们必须了解并跟踪我们的数据使用情况,以免超出服务器的内存。

总的来说,在某些边缘用例中使用 MongoDB 内存存储引擎可能是有用的,但在数据库系统中缺乏耐久性可能是其采用的一个阻碍因素。

内存存储是 MongoDB 企业版的一部分,这是 MongoDB 的付费产品。

MMAPv1

随着 WiredTiger 的引入及其许多好处,如文档级别锁定,许多 MongoDB 用户开始质疑是否还值得讨论 MMAPv1。

实际上,我们应该考虑在以下情况下使用 MMAPv1 而不是 WiredTiger:

  • 传统系统:如果我们有一个适合我们需求的系统,我们可以升级到 MongoDB 3.0+,而不转换到 WiredTiger。

  • 版本降级:一旦我们升级到 MongoDB 3.0+并将存储转换为 WiredTiger,我们就无法降级到低于 2.6.8 的版本。如果我们希望在以后有灵活性进行降级,这一点应该牢记在心。

正如前面所示,WiredTiger 比 MMAPv1 更好,我们应该在有机会时使用它。本书以 WiredTiger 为中心,并假设我们将能够使用 MongoDB 的最新稳定版本(写作时为 3.4)。

从 3.4 版本开始,MMAPv1 仅支持集合级别的锁定,而不支持 WiredTiger 支持的文档级别锁定。这可能会导致高争用数据库负载的性能损失,这是我们尽可能使用 WiredTiger 的主要原因之一。

MMAPv1 存储优化

MongoDB 默认使用二次幂分配策略。创建文档时,它将被分配为二次幂大小。也就是说,ceiling(document_size)

例如,如果我们创建一个 127 字节的文档,MongoDB 将分配 128 字节(2⁷),而如果我们创建一个 129 字节的文档,MongoDB 将分配 256 字节(2⁸)。这在更新文档时很有帮助,因为我们可以更新它们而不移动底层文档,直到超出分配的空间。

如果文档在磁盘上移动(即向文档的数组中添加一个新的子文档或元素,使其大小超过分配的存储空间),将使用新的二次幂分配大小。

如果操作不影响其大小(即将整数值从一个更改为两个),文档将保持存储在磁盘上的相同物理位置。这个概念被称为填充。我们也可以使用紧凑的管理命令来配置填充。

当我们在磁盘上移动文档时,我们存储的是非连续的数据块,实质上是存储中的空洞。我们可以通过在集合级别设置paddingFactor来防止这种情况发生。

paddingFactor的默认值为1.0(无填充),最大值为4.0(将文档大小扩展三倍)。例如,paddingFactor1.4将允许文档在被移动到磁盘上的新位置之前扩展 40%。

例如,对于我们喜爱的books集合,要获得 40%的额外空间,我们将执行以下操作:

> db.runCommand ( { compact: 'books', paddingFactor: 1.4 } )

我们还可以根据每个文档的字节设置填充。这样我们就可以从集合中每个文档的初始创建中获得x字节的填充:

> db.runCommand ( { compact: 'books', paddingBytes: 300 } )

这将允许一个在 200 字节时创建的文档增长到 500 字节,而一个在 4000 字节时创建的文档将被允许增长到 4300 字节。

我们可以通过运行compact命令来完全消除空洞,但这意味着每次增加文档大小的更新都必须移动文档,从根本上在存储中创建新的空洞。

混合使用

当我们的应用程序以 MongoDB 作为基础数据库时,我们可以在应用程序级别为不同操作设置不同的副本集,以满足它们的需求。

例如,在我们的金融应用程序中,我们可以使用一个连接池来进行欺诈检测模块,利用内存节点,并为我们系统的其他部分使用另一个连接池,如下所示:

此外,MongoDB 中的存储引擎配置是针对每个节点应用的,这允许一些有趣的设置。

如前面的架构图所示,我们可以在副本集的不同成员中使用不同的存储引擎的混合。在这种情况下,我们在主节点中使用内存引擎以获得最佳性能,而其中一个从节点使用 WiredTiger 以确保数据的持久性。我们可以在内存从节点中使用priority=1来确保,如果主节点失败,从节点将立即被选中。如果我们不这样做,我们就有可能在系统负载很高时出现主服务器故障,而从节点没有及时跟上主服务器的内存写入。

混合存储方法广泛应用于微服务架构中。通过解耦服务和数据库,并针对每个用例使用适当的数据库,我们可以轻松地水平扩展我们的基础架构。

所有存储引擎都支持一些共同的基线功能,例如以下内容:

  • 查询

  • 索引

  • 复制

  • 分片

  • Ops 和 Cloud Manager 支持

  • 认证和授权语义

其他存储引擎

模块化的 MongoDB 架构允许第三方开发他们自己的存储引擎。

RocksDB

RocksDB 是一个用于键值数据的嵌入式数据库。它是LevelDB的一个分支,存储任意字节数组中的键值对。它于 2012 年在 Facebook 启动,现在作为名为CockroachDB的开源 DB 的后端服务,该 DB 受到 Google Spanner 的启发。

MongoRocks 是由 Percona 和 Facebook 支持的项目,旨在将 RocksDB 后端引入 MongoDB。对于某些工作负载,RocksDB 可以实现比 WiredTiger 更高的性能,并值得研究。

TokuMX

另一个广泛使用的存储引擎是 Percona 的 TokuMX。TokuMX 是为 MySQL 和 MongoDB 设计的,但自 2016 年以来,Percona 已将其重点放在了 MySQL 版本上,而不是切换到RocksDB以支持 MongoDB 存储。

MongoDB 中的锁定

文档级和集合级锁定在本章中以及本书的其他几章中都有提到。了解锁定的工作原理以及其重要性是很重要的。

数据库系统使用锁的概念来实现 ACID 属性。当有多个读取或写入请求并行进行时,我们需要锁定我们的数据,以便所有读者和写入者都能获得一致和可预测的结果。

MongoDB 使用多粒度锁定。可用的粒度级别按降序排列如下:

  • 全局

  • 数据库

  • 集合

  • 文档

MongoDB 和其他数据库使用的锁按粒度顺序如下:

  • IS:意向共享

  • IX:意向排他

  • S:共享

  • X:排他

如果我们在粒度级别使用SX锁,那么所有更高级别都需要使用相同类型的意向锁进行锁定。

锁的其他规则如下:

  • 一个数据库可以同时以ISIX模式被锁定

  • 排他(X)锁不能与任何其他锁共存

  • 共享(S)锁只能与IS锁共存

读取和写入请求锁通常按照先进先出FIFO)顺序排队。MongoDB 实际上会做的唯一优化是根据队列中的下一个请求重新排序请求以便服务。

这意味着,如果我们有一个IS(1)请求即将到来,而我们当前的队列如下IS(1)->IS(2)->X(3)->S(4)->IS(5),如下截图所示:

然后 MongoDB 会重新排序请求,如下,IS(1)->IS(2)->S(4)->IS(5)->X(3),如下截图所示:

如果在服务过程中,IS(1)请求、新的ISS请求进来,比如IS(6)S(7),它们仍将被添加到队列的末尾,并且在X(3)请求完成之前不会被考虑。

我们的新队列现在看起来是IS(2)->S(4)->IS(5)->X(3)->IS(6)->S(7)

这是为了防止X(3)请求被饿死,因为新的ISS请求不断进来而不断被推迟。重要的是要理解意向锁和锁本身之间的区别。WiredTiger 存储引擎只会在全局、数据库和集合级别使用意向锁。

当新请求进来时,它在更高级别(即集合、数据库、全局)使用意向锁,并根据以下兼容性矩阵:

MongoDB 在获取文档本身的锁之前,会首先获取所有祖先的意向锁。这样,当新请求进来时,它可以快速确定是否无法基于更少粒度的锁提供服务。

WiredTiger 将在文档级别使用SX锁。唯一的例外是通常不频繁和/或短暂的涉及多个数据库的操作。这些操作仍然需要全局锁,类似于 MongoDB 在 2.x 之前版本的行为。

管理操作,例如删除集合,仍然需要独占数据库锁。

正如之前解释的那样,MMAPv1 使用集合级别的锁。跨越单个集合但可能或可能不跨越单个文档的操作仍然会锁定整个集合。这是为什么 WiredTiger 是所有新部署的首选存储解决方案的主要原因。

锁报告

我们可以使用以下任何工具和命令来检查锁状态:

  • 通过locks文档的db.serverStatus()

  • 通过locks字段的db.currentOp()

  • mongotop

  • mongostat

  • MongoDB Cloud Manager

  • MongoDB Ops Manager

锁争用是一个非常重要的指标,因为如果它失控,可能会使我们的数据库陷入困境。

如果我们想终止一个操作,我们必须使用db.killOp() shell 命令。

锁让渡

具有数据库级别锁的数据库在压力下将不会真正有用,并且最终将大部分时间被锁定。在 MongoDB 早期版本中的一个聪明的解决方案是根据一些启发式原则使操作释放它们的锁。

影响多个文档的update()命令将释放它们的X锁以提高并发性。

在 MongoDB 早期版本中,MMAPv1 的前身会使用这些启发式方法来预测请求的操作之前数据是否已经在内存中。如果没有,它会释放锁,直到底层操作系统将数据加载到内存中,然后重新获取锁以继续处理请求。

最显著的例外是索引扫描,该操作不会释放其锁,并且会在等待数据从磁盘加载时阻塞。

由于 WiredTiger 仅在集合级别及以上使用意向锁,因此它实际上不需要这些启发式方法,因为意向锁不会阻塞其他读者和写者。

常用命令和锁

常用命令和锁如下:

命令
find() S
it() (查询游标) S
insert() X
remove() X
update() X
mapreduce() 根据情况为SX。一些 MapReduce 块可以并行运行。
index()
  • 前台索引:数据库锁。

  • 后台索引:无锁,除了会返回错误的管理命令。此外,后台索引将花费更多的时间。

|

aggregate() S

需要数据库锁的命令

以下命令需要数据库锁。在生产环境中发布这些命令之前,我们应该提前计划:

  • db.collection.createIndex() 使用(默认)前台模式

  • reIndex

  • compact

  • db.repairDatabase()

  • db.createCollection() 如果创建一个多 GB 的固定大小集合

  • db.collection.validate()

  • db.copyDatabase(),可能会锁定多个数据库

我们还有一些命令会在非常短的时间内锁定整个数据库:

  • db.collection.dropIndex()

  • db.getLastError()

  • db.isMaster()

  • 任何rs.status()命令

  • db.serverStatus()

  • db.auth()

  • db.addUser()

这些命令不应该花费超过几毫秒的时间,所以我们不用担心,除非我们有使用这些命令的自动化脚本,那么我们必须注意限制它们发生的频率。

在分片环境中,每个mongod都会应用自己的锁,从而大大提高并发性。

在副本集中,我们的主服务器必须执行所有写操作。为了正确地将它们复制到辅助节点,我们必须同时锁定保存操作的 oplog 的本地数据库和我们的主要文档/集合/数据库。这通常是一个短暂的锁,我们不用担心。

副本集中的辅助节点将从主要本地数据库的 oplog 中获取写操作,应用适当的X锁,并在X锁完成后应用服务读取。

从前面的长篇解释中,很明显在 MongoDB 中应该尽量避免锁定。我们应该设计我们的数据库,以尽量避免尽可能多的X锁,并且当我们需要在一个或多个数据库上获取X锁时,在维护窗口中执行,并制定备份计划以防操作时间超出预期。

进一步阅读

您可以参考以下链接以获取更多信息:

总结

在本章中,我们学习了 MongoDB 中不同的存储引擎。我们确定了每种存储引擎的优缺点以及选择每种存储引擎的用例。

我们学习了如何使用多个存储引擎,我们如何使用它们以及它们的好处。本章的很大一部分也专门讨论了数据库锁定,它可能发生的原因,为什么它是不好的,以及我们如何避免它。

我们根据它们需要的锁将操作分开。这样,当我们设计和实现应用程序时,我们可以确保我们有一个尽可能少锁定我们数据库的设计。

在下一章中,我们将学习 MongoDB 以及如何使用它来摄取和处理大数据。

第十章:MongoDB Tooling

功能、稳定性和良好的驱动程序支持都很重要;然而,另一个对软件产品成功至关重要的领域是围绕它构建的生态系统。MongoDB(最初名为 10gen Inc.)在 8 年前的 2011 年推出了 MMS,并当时被视为一项创新。在本章中,我们将介绍 MongoDB 可用的一套不同工具,并探讨它们如何提高生产力:

  • MongoDB 企业 Kubernetes 运算符

  • MongoDB Mobile

  • MongoDB Stitch

  • MongoDB Sync

介绍

MongoDB 监控服务MMS)是一个大多数免费的软件即服务SaaS)解决方案,可以监视和访问任何注册到它的数据库的诊断信息。当它推出时,它极大地帮助了 10gen 的工程师解决客户遇到的任何问题。从那时起,工具已成为 MongoDB 演进的核心。

MongoDB Atlas

MongoDB Atlas 是 MongoDB 的数据库即服务DBaaS)产品。它作为多云产品提供,支持亚马逊网络服务AWS)、微软 Azure 和谷歌云平台。

使用 DBaaS,补丁和小版本升级会自动应用,无需任何停机时间。使用图形用户界面GUI),开发人员可以部署地理分布式数据库实例,以避免任何单点故障。对于访问量大的网站,这也可以通过将数据库服务器放置在接近访问其数据的用户的地方来帮助。这是 MongoDB 战略和产品的关键部分,因为他们支持让数据靠近用户。

与大多数 DBaaS 产品类似,Atlas 允许用户使用 GUI 扩展部署。每个部署都位于自己的虚拟专用云VPC)上,并可以利用 MongoDB 企业服务器的功能,如加密密钥管理、轻量目录访问协议LDAP)和审计功能。

实时迁移服务可用于从现有部署(本地部署、三个支持的云提供商之一或其他 DBaaS 服务,如mLabComposeObjectRocket)迁移数据集,使用相同的 GUI。

创建新的集群

使用 MongoDB Atlas 创建新的集群就像点击并通过配置选项进行选择一样简单。在下面的屏幕截图中,我们可以看到创建新集群时可用的所有选项:

以下屏幕截图显示了区域配置摘要:

MongoDB Atlas 中的一个改变游戏规则的设置是能够立即在不同区域和日期中心(对于三个主要云提供商)之间提供地理分布式服务器,目标是使我们的数据尽可能靠近我们的用户。这对性能和法规原因(如通用数据保护条例GDPR)对欧盟)都很有用。

通过启用全局写入,我们可以开始配置此设置。使用任何两个模板——全局性能或优秀的全局性能——管理员可以创建服务器配置,使其距离世界各地的任何用户都不到 120 毫秒或 80 毫秒。管理员还可以定义自己的自定义分配,从区域到数据中心。

在区域配置摘要中,我们可以看到我们的设置将如何影响性能的概述。M30 是启用了分片的 MongoDB Atlas 计划,该配置正在(在幕后)为每个区域创建一个分片。我们可以在每个区域创建更多的分片,但目前不建议这样做。

在所有区域启用本地读取配置将在除了用于写入数据的区域之外的每个区域创建本地只读副本集节点。因此,如果我们有三个区域(ABC),我们最终会发现A的写入会发送到A,但来自A的读取将在A区域的服务器上进行,或者BC,取决于哪个服务器对用户更近。对于BC区域也是一样的。

这一部分对于复杂的多区域部署可能是最重要的,应该非常小心对待。

接下来是配置我们想要用于我们集群的服务器:

这类似于我们在 EC2 或 Microsoft Azure 中选择服务器的方式。需要注意的主要点是我们可以选择自定义的 IOPS(每秒 I/O 操作数)性能,并且我们应该选择自动扩展存储选项,以避免磁盘容量不足。除此选项外,始终有必要关注存储分配,以避免在结算周期结束时产生过多费用。

在下一个面板中,我们可以为我们的集群配置备份和高级选项。以下截图显示了连续备份的附加设置:

以下截图显示了启用 BI 连接器的高级设置选项:

以下截图显示了可用的更多配置选项:

重要提示

MongoDB 在 MongoDB Atlas 中提供了一些有用的提示,包括以下内容:

  • 尽可能使用最新版本的 MongoDB。

  • 在撰写时,使用最新的传输层安全性TLS)版本,即 1.3。

  • 静态加密不能与连续备份一起使用。我们需要选择云提供商的快照才能使用此功能。

  • 除非我们知道为什么需要,否则最好禁用服务器端 JavaScript,例如当我们有传统的 MapReduce 作业时。

  • 对所有查询需要索引可能是有用的,如果我们有一个明确定义的业务案例和对如何使用数据库的要求,和/或者我们预期我们的数据集会非常大,以至于在没有索引的情况下查询几乎是不可能的。

  • 最后,我们可以选择我们的集群名称。创建后无法更改,因此在单击“创建集群”按钮之前与团队成员达成一致意见非常重要。

经过一段时间的等待,我们的集群将投入运行,我们将能够通过普通的旧 MongoDB URI 连接到它。

MongoDB Cloud Manager

Cloud Manager 以前被称为MongoDB 管理服务MMS),在此之前被称为MongoDB 监控服务MMS),是一个托管的 SaaS,用于本地部署的 MongoDB。

作为 DBaaS 解决方案的 Atlas 可以为数据库管理提供端到端的解决方案。对于许多用例来说,这可能是不可行的。在这种情况下,可能有意义以按需付费的方式使用一些功能。

Cloud Manager 有一个有限的免费层和几个付费层。

以下是 Cloud Manager 的一些关键特性:

  • 自动备份

  • 超过 100 个数据库指标和关键绩效指标KPIs)可用于跟踪 MongoDB 的性能

  • 定制的警报,可以与 PagerDuty、电子邮件和短信等第三方系统集成

  • 统一的操作视图,可以通过直接查询其 JSON API,或者将其与 New Relic 等流行的性能跟踪解决方案集成

高级计划还提供关于性能和索引的建议。Cloud Manager 的唯一要求是在我们的应用程序中安装所需的代理。

MongoDB Ops Manager

在许多方面,Ops Manager 与 Cloud Manager 不同。与 Cloud Manager 相比,它是一个可下载的可执行文件,适用于 Windows Server、Red Hat Enterprise LinuxRHEL)或 Ubuntu。

在此基础上,用户需要在自己的基础设施中安装和管理服务。

除了这个区别,Ops Manager 还可以帮助实现与 Cloud Manager 类似的目标:

  • 监控超过 100 个性能指标

  • 自动安装和升级集群;加索引维护可以实现零停机

  • 用于连续、增量备份和恢复到特定时间点

  • 查询优化

  • 索引建议

Ops Manager 的一个示例拓扑如下:

除了 Ops Manager 和 MongoDB 节点,如果启用了备份,我们还需要快照存储。

如果我们需要一个本地解决方案来保障安全性或其他原因,Ops Manager 可能是 Cloud Manager 的更好选择。这是 MongoDB Enterprise Server 付费解决方案的一部分。

MongoDB Charts

MongoDB Charts 是一个从 MongoDB 数据生成可视化的工具。它使非技术人员可以使用 GUI 查询 MongoDB 数据库,并与同事分享结果。

MongoDB Charts 可以创建一系列图表,包括以下内容:

  • 柱状图和条形图参考

  • 线性和面积图参考

  • 网格图表:

  • 热力图参考

  • 散点图参考

  • 圆环图参考

  • 文本图表:数字图表参考

与 Ops Manager 类似,它是一个独立的可执行文件,利用 Docker 在本地安装和管理。

使用副本集辅助节点进行图表查询。理想情况下,使用辅助、隐藏、不可选举节点作为副本集中的分析节点。

MongoDB Compass

MongoDB Compass 类似于 MongoDB Charts,但在图表功能方面功能较少,更加重视运行临时查询并连接到我们的数据库,而无需使用命令行界面。

Compass 提供了通过 GUI 查询 MongoDB 和可视化构建查询的功能。它可以对结果数据集提供丰富的可视化,并帮助通过点和点击界面构建聚合查询。

Compass 还为大多数围绕查询和索引性能的管理查询提供可视化,因此可以从数据库管理员的角度监视和排除集群。它公开了一个 API,可用于导入或开发插件。

非技术用户的一个有用功能是能够下载一个只读版本,以限制对非破坏性操作的访问。此工具还有一个隔离版本,可用于限制连接到单个选择的服务器。这些请求也将进行 TLS 加密。

Compass 可在 Windows、OSX、Red Hat 和 Ubuntu 上作为可执行下载文件提供。MongoDB Compass 有一个有限的免费版本,完整功能集可通过 MongoDB 订阅包获得。

MongoDB 业务智能连接器(BI)

MongoDB Connector for BI 是非开发人员最有用的工具之一。它是 MongoDB Enterprise Advanced 订阅的一部分,可以使用标准 SQL 查询与 BI 工具集成。

它使 MongoDB 能够与 Tableau、Qlik、Spotfire、Cognos、MicroStrategy 和 SAP BusinessObjects 等企业工具集成。

它可作为可执行下载文件提供给 Amazon Linux、Debian、OSX、Red Hat、SUSE、Ubuntu 和 Windows 平台,并且可以与本地数据库和 MongoDB Atlas 一起使用。一旦安装和配置正确,它可以提供大多数 BI 工具可以使用的开放数据库连接ODBC数据源名称DSN)。

Kubernetes 简介

Kubernetes (kubernetes.io)是一个用于自动化部署、扩展和管理容器化应用程序的开源容器编排系统。通俗地说,我们可以使用 Kubernetes(通常称为 k8s)来管理通过容器部署的应用程序。Kubernetes 最初是在 Google 开发的,现在由Cloud Native Computing Foundation (CNCF)维护。

最广泛使用的容器技术可能是 Docker。我们可以在任何 PC 上下载和安装 Docker,并通过几个命令安装一个与我们的主机系统隔离并包含我们的应用程序代码的 Docker 镜像。Docker 执行操作系统级虚拟化,所有容器都由主机的操作系统内核运行。这导致容器比完整虚拟机(VM)更轻量级。

可以使用Docker Swarm来编排多个 Docker 容器。这类似于 Kubernetes,有时这两个系统会直接进行比较。

MongoDB 提供了可以帮助管理员使用 Kubernetes 部署和管理 MongoDB 集群的工具。

企业 Kubernetes Operator

从 MongoDB 4.0 开始,MongoDB Enterprise Operator for Kubernetes使用户能够直接从 Kubernetes API 部署和管理 MongoDB 集群。这避免了直接连接到 Cloud Manager 或 Ops Manager 的需要,并简化了 Kubernetes 集群的部署和管理。

Cloud Manager 在大多数方面相当于 Ops Manager 的 SaaS 版本。

可以使用 Helm,Kubernetes 的软件包管理器,安装企业 Kubernetes Operator。首先,我们必须从 MongoDB 克隆 GitHub 存储库:github.com/mongodb/mongodb-enterprise-kubernetes.git

当我们将目录更改为我们的本地副本后,我们可以发出以下命令:

helm install helm_chart/ --name mongodb-enterprise

然后我们将安装本地副本;下一步是配置它。

通过配置我们的本地安装,我们需要应用一个 Kubernetes ConfigMap文件。我们需要从 Ops Manager 或 Cloud Manager 复制的配置设置如下:

  • 基本 URL:Ops Manager 或 Cloud Manager 的 URL。对于 Cloud Manager,这将是cloud.mongodb.com;对于 Ops Manager,这应该类似于http://<MY_SERVER_NAME>:8080/

  • 项目 ID:Ops Manager 项目的 ID,Enterprise Kubernetes Operator 将部署到该项目中。这应该在 Ops Manager 或 Cloud Manager 中创建,并且是用于组织 MongoDB 集群并为项目提供安全边界的唯一 ID。它应该是一个 24 位十六进制字符串。

  • 用户:现有的 Ops Manager 用户名。这是 Ops Manager 中用户的电子邮件,我们希望 Enterprise Kubernetes Operator 在连接到 Ops Manager 时使用。

  • 公共 API 密钥:这是 Enterprise Kubernetes Operator 用于连接到 Ops Manager REST API 端点的密钥。

这是通过在 Ops Manager 控制台上点击用户名并选择帐户来创建的。在下一个屏幕上,我们可以点击公共 API 访问,然后点击“生成”按钮并提供描述。下一个屏幕将显示我们需要的公共 API 密钥。

这是我们唯一一次查看此 API 密钥的机会,所以我们需要把它写下来,否则我们将需要重新生成一个新的密钥。

一旦我们有了这些值,我们就可以创建 Kubernetes ConfigMap文件,文件名可以任意,只要是.yaml文件即可。在我们的情况下,我们将命名为mongodb-project.yaml

其结构将如下所示:

apiVersion: v1
kind: ConfigMap
metadata:
 name:<<any sample name we choose(1)>>
 namespace: mongodb
data:
 projectId:<<Project ID from above>>
 baseUrl: <<BaseURI from above>>

然后我们可以使用以下命令将此文件应用到 Kubernetes:

kubectl apply -f mongodb-project.yaml

我们需要采取的最后一步是创建 Kubernetes 秘钥。可以使用以下命令来完成:

kubectl -n mongodb create secret generic <<any sample name for credentials we choos>> --from-literal="user=<<User as above>>" --from-literal="publicApiKey=<<our public api key as above>>"

我们需要记下凭据名称,因为我们在后续步骤中会用到它。

现在我们准备使用 Kubernetes 部署我们的副本集!我们可以创建一个名为replica-set.yaml的文件,其结构如下:

apiVersion: mongodb.com/v1
kind: MongoDbReplicaSet
metadata:
 name: <<any replica set name we choose>>
 namespace: mongodb
spec:
 members: 3
 version: 3.6.5
persistent: false
project: <<the name value (1) that we chose in metadata.name of ConfigMap file above>>
credentials: <<the name of credentials secret that we chose above>>

我们使用kubectl apply应用新配置:

kubectl apply -f replica-set.yaml

我们将能够在 Ops Manager 中看到我们的新副本集。

要使用 Kubernetes 对 MongoDB 进行故障排除和识别问题,我们可以使用

kubectl logs用于检查日志,kubectl exec用于进入运行 MongoDB 的容器之一。

MongoDB Mobile

MongoDB Mobile 是 MongoDB 数据库的移动版本。它针对智能手机和物联网传感器,通过嵌入式 MongoDB。MongoDB Mobile 有两个核心部分:

  • 在设备上本地运行的 MongoDB 数据库服务器,实现对数据的离线访问。该数据库是 MongoDB Server Community Edition 的精简版本,不包含 Mobile 不需要的任何功能(例如复制)。

  • 本机 Java 和 Android SDK 提供对数据库的低级访问,并与本地 Mobile 数据库和任何 MongoDB Stitch 后端进行交互。

Mobile SDK 有两种操作模式。在本地模式下,SDK 只允许访问本地 Mobile 数据库,并且无法与 Atlas 中的任何外部源进行同步。在远程模式下,SDK 可以访问 MongoDB Atlas 和 MongoDB Mobile 数据库,并在它们之间进行同步。

以下是 MongoDB Mobile 相对于服务器版本的一些限制:

  • 不支持复制

  • 不支持分片

  • 没有数据库身份验证;但是,MongoDB Mobile 数据库只接受源自应用程序的连接

  • 没有 SSL

  • 静态加密

  • 不支持更改流

  • 没有服务器端 JavaScript 评估(出于性能原因)

  • 没有多文档 ACID 事务

要设置 MongoDB Mobile,我们需要先下载并安装 MongoDB Stitch SDK。然后,创建和查询本地 MongoDB 数据库就像几行代码一样简单(此示例为 Android):

Import packages:
// Base Stitch Packages
import com.mongodb.stitch.android.core.Stitch;
import com.mongodb.stitch.android.core.StitchAppClient;
// Packages needed to interact with MongoDB and Stitch
import com.mongodb.client.MongoClient;
import com.mongodb.client.MongoCollection;
// Necessary component for working with MongoDB Mobile
import com.mongodb.stitch.android.services.mongodb.local.LocalMongoDbService;

初始化数据库如下:

// Create the default Stitch Client
final StitchAppClient client =
  Stitch.initializeDefaultAppClient("<APP ID>");
// Create a Client for MongoDB Mobile (initializing MongoDB Mobile)
final MongoClient mobileClient =
  client.getServiceClient(LocalMongoDbService.clientFactory);

接下来,获取对数据库的引用:

MongoCollection<Document> localCollection =
  mobileClient.getDatabase("my_db").getCollection("my_collection");

插入document如下:

localCollection.insertOne(document);

然后,使用first()找到第一个文档:

Document doc = localCollection.find().first();

与 MongoDB Stitch 一起使用时,MongoDB Mobile 的功能最强大,我们将在下一节中探讨。

MongoDB Stitch

MongoDB Stitch 是 MongoDB 的无服务器平台。它基于功能的四个不同领域:

  • 第一个领域是 QueryAnywhere。QueryAnywhere 允许客户端应用程序使用其查询语言访问 MongoDB。我们可以在 Stitch 服务器上按照每个集合的基础定义数据访问规则,以允许我们根据用户数据(userId)过滤结果。

  • 第二个领域是 Stitch 函数。这些是简单的 JavaScript 函数,可以在 Stitch 平台内部无需服务器执行。通过使用 Stitch 函数,我们可以实现应用程序逻辑,公开 API,并与第三方服务构建集成。这项服务与亚马逊的 AWS Lambda 非常相似。

  • 第三个领域是 Stitch 触发器。类似于 MongoDB 服务器的更改流和触发器,它们用于关系数据库,Stitch 触发器通过响应数据库状态的变化实时执行用户定义的函数。

  • 最后,还有 Stitch Mobile Sync,它将 Stitch 无服务器提供与 Mobile MongoDB 的桥接。通过使用它,我们可以开发一个在智能手机上具有本地 MongoDB 数据库的 Mobile 服务,该数据库与我们在云中的 MongoDB Atlas 数据库完美同步。

通过这种方式,我们可以在应用程序中本地查询数据,无需延迟,甚至在离线状态下,依靠 Stitch Mobile Sync 来保持我们的数据存储最新。

Stitch 可用于 Web(JavaScript)、Android 和 macOS(Swift)。

QueryAnywhere

QueryAnywhere 允许直接从客户端应用程序查询 MongoDB 服务器数据。一个关键的区分和功能,允许我们安全地定义数据访问规则,以根据文档内容或登录用户过滤结果。

规则

MongoDB 规则是角色和分配给该角色的权限的组合。角色定义了一组用户,这些用户将具有对文档的相同读/写访问权限。Stitch 中的角色可以使用apply-when规则进行定义。

这可以使用%%变量表示法来定义:

{
  "createdBy": "%%user.id"
}

每个角色可以有一个或多个权限,定义了他们可以在文档中读取和/或写入哪些字段。

MongoDB Stitch 还提供了四个预定义角色和权限的模板,围绕最常见的用例。

  • 用户只能读取和写入自己的数据。

  • 用户可以读取所有数据,但只能写入自己的数据。

  • 用户只能读取所有数据。

  • 用户可以读取和写入自己的数据。属于共享列表的用户可以读取该数据。

授权在规则之前应用。如果用户未经授权访问集合,它们的规则将根本不会被评估。

函数

Stitch 函数可用于执行服务器端应用程序逻辑。它们是用 JavaScript ES6+编写的,不需要服务器。

以下是函数的一些关键限制:

  • 它们一旦返回就停止执行

  • 它们可以运行长达 60 秒,使用高达 256 MB 的内存

  • 它们不能导入模块或使用一些核心 JavaScript 功能,例如全局对象类型、数学、数字、字符串、数组和对象 API

Stitch 函数可以通过 CLI 或从 Stitch UI 导入。对于我们命名为multiply的简单函数,我们可以在 UI 中添加以下代码:

exports = function(a, b) {
 return a * b;
};

然后我们可以从另一个函数、webhook 或 Stitch 中的触发器调用它:

context.functions.execute("multiply", a, b);

我们还可以在 Stitch JSON 表达式中使用%function触发其执行:

{
 "%%true": {
   "%function": {
     "name": "multiply",
     "arguments": [3,4]
   }
 }
}

我们甚至可以使用 Stitch SDK(JavaScript、Android 或 macOS)从我们的客户端应用程序调用此函数:

const client = Stitch.defaultAppClient;
client.callFunction("multiply", [3, 4]).then(result => {
console.log(result) // Output: 12
});

触发器

触发器是基于 Stitch 函数构建的,用于在数据库触发器发生数据库集合更改时执行,或者在使用身份验证触发器修改用户时执行身份验证逻辑。

数据库触发器可以在INSERTUPDATEREPLACEDELETE数据库操作中执行。

所有这些值都需要区分大小写。

我们需要定义链接函数,即触发器触发后将执行的函数。对于UPDATE操作的一个有趣选项是fullDocument。当设置为true时,这将包括操作的完整结果。这始终受到 16 MB 文档大小限制的限制,因此接近 16 MB 限制的文档的更新可能会失败,因为结果将超出限制。

另一方面,身份验证触发器允许我们在身份验证事件上执行自定义代码。这些可以在以下提供程序的CREATELOGINDELETE操作类型上触发:

  • oauth2-google

  • oauth2-facebook

  • custom-token

  • local-userpass

  • api-key

  • anon-user

身份验证操作类型区分大小写,需要全部大写。最多可以同时执行 50 个触发器。如果我们尝试调用更多,它们将排队等待以先进先出FIFO)的方式进行处理。

触发器与 RDBMS 触发器功能非常相似,而且它们易于灵活地通过 Stitch 触发器的 GUI 控制台进行管理。

Mobile Sync

MongoDB Stitch Mobile Sync 中的最新添加之一可以在 MongoDB Mobile 和服务器后端之间无缝同步数据(在撰写本文时,它必须托管在 MongoDB Atlas 上)。Mobile Sync 还基于更改流来监听本地和远程数据库之间的数据更改。随着本地 Mobile 数据库中的数据更改,我们可能会遇到本地和远程状态之间的冲突。这就是为什么我们需要定义一些处理程序来指定在这种情况下应该发生什么。我们需要为我们的模型实现三个接口:

  • ConflictHandler

  • ErrorListener

  • ChangeEventListener

ConflictHandler有一个方法,参数是冲突本地和远程事件的documentId,返回冲突的解决方案,如下所示:

DocumentT resolveConflict(BsonValue documentId,
                         ChangeEvent<DocumentT> localEvent,
                         ChangeEvent<DocumentT> remoteEvent)

ErrorListener不返回任何内容,并在发生documentId和非网络相关异常的错误时调用:

void onError(BsonValue documentId,Exception error)

最后,ChangeEventListener也不返回任何值,并在给定documentId的任何更改event发生时调用:

void onEvent(BsonValue documentId, ChangeEvent<DocumentT> event)

总结

在这一章中,我们通过不同的 MongoDB 工具,并学习如何使用它们来提高生产力。从 MongoDB Atlas 开始,这是托管的 DBaaS 解决方案,我们接着介绍了 Cloud Manager 和 Ops Manager,并探讨了它们之间的区别。

然后,我们深入了解了 MongoDB Charts 和 MongoDB Compass——基于 GUI 的 MongoDB 管理工具。我们了解了 MongoDB Connector for BI 以及它如何对我们的目的有用。然后我们讨论了 Kubernetes,它与 Docker 和 Docker Swarm 的比较,以及我们如何将 Kubernetes 与 MongoDB Enterprise Operator 一起使用。接下来的部分专门介绍了 MongoDB Mobile 和 Stitch——MongoDB 4.0 中的两个重大增强。我们介绍了使用 Stitch 功能的实际示例,特别是 QueryAnywhere、触发器和函数。最后,我们简要介绍了 Mobile Sync,这是 MongoDB 武器库中最新的增加之一,并探讨了它如何用于将我们的移动应用程序与基于云的数据库同步。

在下一章中,我们将转变方向,处理如何使用 MongoDB 处理大数据,以摄取和处理大型流式和批处理数据集。

第十一章:利用 MongoDB 进行大数据处理

MongoDB 通常与大数据管道一起使用,因为它具有高性能、灵活性和缺乏严格的数据模式。本章将探讨大数据领域以及 MongoDB 如何与消息队列、数据仓库和 ETL 管道配合使用。

我们将在本章讨论以下主题:

  • 什么是大数据?

  • 消息队列系统

  • 数据仓库

  • 使用 Kafka、Spark 在 HDFS 上以及 MongoDB 的大数据用例

什么是大数据?

在过去的五年里,访问和使用互联网的人数几乎翻了一番,从不到 20 亿增加到约 37 亿。全球一半的人口现在都在网上。

随着互联网用户数量的增加,以及网络的发展,每年都会向现有数据集中添加更多的数据。2016 年,全球互联网流量为 1.2 泽字节(相当于 1.2 亿兆字节),预计到 2021 年将增长到 3.3 泽字节。

每年产生的大量数据意味着数据库和数据存储通常必须能够高效扩展和处理我们的数据。

大数据这个术语最早是由 John Mashey 在 1980 年代提出的(static.usenix.org/event/usenix99/invited_talks/mashey.pdf),并且在过去的十年中随着互联网的爆炸性增长而开始流行起来。大数据通常指的是那些传统数据处理系统无法处理的过大和复杂的数据集,因此需要一些专门的系统架构来处理。

大数据的定义特征通常如下:

  • 容量

  • 多样性

  • 速度

  • 真实性

  • 变异性

多样性和变异性指的是我们的数据以不同的形式出现,我们的数据集存在内部不一致性。这些需要通过数据清洗和规范化系统进行平滑处理,然后我们才能实际处理我们的数据。

真实性指的是数据质量的不确定性。数据质量可能会有所不同,对于某些日期来说是完美的数据,而对于其他日期来说则是缺失的数据集。这影响了我们的数据管道以及我们可以投入到数据平台中的数量,因为即使在今天,三分之一的商业领导人也不完全信任他们用来做出商业决策的信息。

最后,速度可能是大数据最重要的定义特征(除了明显的容量属性),它指的是大数据集不仅具有大量数据,而且增长速度加快。这使得传统的存储方式,比如索引,成为一项困难的任务。

大数据领域

大数据已经发展成一个影响经济各个领域的复杂生态系统。从炒作到不切实际的期望,再到现实,如今大多数财富 1000 强公司都实施和部署了大数据系统,为企业创造了真正的价值。

如果我们按行业对参与大数据领域的公司进行分段,可能会得出以下几个部分:

  • 基础设施

  • 分析

  • 应用-企业

  • 应用-行业

  • 跨基础设施分析

  • 数据来源和 API

  • 数据资源

  • 开源

从工程角度来看,我们可能更关心的是底层技术,而不是它们在不同行业领域的应用。

根据我们的业务领域,我们可能会从不同的来源获取数据,比如事务性数据库、物联网传感器、应用服务器日志、通过 Web 服务 API 的其他网站,或者只是纯粹的网页内容提取:

消息队列系统

在先前描述的大多数流程中,我们有数据被提取、转换、加载(ETL)到企业数据仓库(EDW)。为了提取和转换这些数据,我们需要一个消息队列系统来处理流量激增、临时不可用的端点以及可能影响系统可用性和可伸缩性的其他问题。

消息队列还可以在消息的生产者和消费者之间提供解耦。这通过将我们的消息分成不同的主题/队列来实现更好的可伸缩性。

最后,使用消息队列,我们可以拥有不关心消息生产者所在位置的位置不可知服务,这提供了不同系统之间的互操作性。

在消息队列世界中,目前在生产中最受欢迎的系统是 RabbitMQ、ActiveMQ 和 Kafka。在我们深入研究使用案例之前,我们将对它们进行简要概述。

Apache ActiveMQ

Apache ActiveMQ 是一个用 Java 编写的开源消息代理,配有完整的 Java 消息服务(JMS)客户端。

它是我们在这里检查的三种实现中最成熟的,有着成功的生产部署的悠久历史。许多公司提供商业支持,包括 Red Hat。

这是一个相当简单的排队系统,可以轻松设置和管理。它基于 JMS 客户端协议,是 Java EE 系统的首选工具。

RabbitMQ

另一方面,RabbitMQ 是用 Erlang 编写的,基于高级消息队列协议(AMQP)协议。AMQP 比 JMS 更强大和复杂,因为它允许点对点消息传递、请求/响应和发布/订阅模型,用于一对一或一对多的消息消费。

在过去的 5 年中,RabbitMQ 变得越来越受欢迎,现在是搜索量最大的排队系统。

RabbitMQ 的架构概述如下:

RabbitMQ 系统的扩展是通过创建一组 RabbitMQ 服务器集群来完成的。集群共享数据和状态,这些数据和状态是复制的,但消息队列在每个节点上是独立的。为了实现高可用性,我们还可以在不同节点中复制队列。

Apache Kafka

另一方面,Kafka 是由 LinkedIn 首先为其自身内部目的开发的排队系统。它是用 Scala 编写的,从根本上设计为水平可伸缩和尽可能高的性能。

专注于性能是 Apache Kafka 的关键区别因素,但这意味着为了实现性能,我们需要牺牲一些东西。Kafka 中的消息没有唯一的 ID,而是通过它们在日志中的偏移量来寻址。Apache Kafka 消费者不受系统跟踪;这是应用程序设计的责任。消息排序是在分区级别实现的,消费者有责任确定消息是否已经被传递。

语义学是在 0.11 版本中引入的,并且是最新的 1.0 版本的一部分,因此消息现在可以在分区内严格排序,并且每个消费者始终只能到达一次:

数据仓库

使用消息队列系统只是我们数据管道设计的第一步。在消息队列的另一端,我们通常会有一个数据仓库来处理大量到达的数据。那里有很多选择,本书的重点不是讨论这些选择或进行比较。然而,我们将简要介绍 Apache 软件基金会中最广泛使用的两个选项:Apache Hadoop 和 Apache Spark。

Apache Hadoop

第一个,也可能仍然是最广泛使用的大数据处理框架是 Apache Hadoop。它的基础是Hadoop 分布式文件系统HDFS)。在 2000 年代由 Yahoo!开发,最初是作为Google 文件系统GFS)的开源替代品,GFS 是谷歌用于分布式存储其搜索索引的文件系统。

Hadoop 还实现了一个 MapReduce 替代方案,用于谷歌专有系统的 Hadoop MapReduce。与 HDFS 一起,它们构成了一个分布式存储和计算的框架。用 Java 编写,具有大多数编程语言的绑定和许多提供抽象和简单功能的项目,有时基于 SQL 查询,这是一个可靠地用于存储和处理几十亿甚至拍它字节数据的系统。

在后续版本中,Hadoop 通过引入Yet Another Resource NegotiatorYARN)变得更加模块化,为应用程序提供了在 Hadoop 之上开发的抽象。这使得几个应用程序可以部署在 Hadoop 之上,例如StormTezOpenMPIGiraph,当然还有Apache Spark,我们将在接下来的部分中看到。

Hadoop MapReduce 是一个面向批处理的系统,意味着它依赖于批量处理数据,并不适用于实时用例。

Apache Spark

Apache Spark 是加州大学伯克利分校 AMPLab 的集群计算框架。Spark 并不是完整的 Hadoop 生态系统的替代品,而主要是 Hadoop 集群的 MapReduce 方面。而 Hadoop MapReduce 使用磁盘批处理操作来处理数据,Spark 则同时使用内存和磁盘操作。预期地,对于适合内存的数据集,Spark 更快。这就是为什么它对于实时流应用更有用,但也可以轻松处理不适合内存的数据集。

Apache Spark 可以在 HDFS 上使用 YARN 或独立模式运行,如下图所示:

这意味着在某些情况下(例如我们将在下面的用例中使用的情况),如果我们的问题确实在 Spark 的能力范围内得到了很好的定义和限制,我们可以完全放弃 Hadoop 而选择 Spark。

对于内存操作,Spark 可能比 Hadoop MapReduce 快 100 倍。Spark 为 Scala(其本地语言),Java,Python 和 Spark SQL(SQL92 规范的变体)提供了用户友好的 API。Spark 和 MapReduce 都具有容错性。Spark 使用分布在整个集群中的 RDD。

从总体上看,根据 Spark 的架构,我们可以有几个不同的 Spark 模块一起工作,满足不同的需求,从 SQL 查询到流处理和机器学习库。

将 Spark 与 Hadoop MapReduce 进行比较

Hadoop MapReduce 框架更常与 Apache Spark 进行比较,后者是一种旨在解决类似问题空间中问题的新技术。它们最重要的属性总结在下表中:

Hadoop MapReduce Apache Spark
编写语言 Java Scala
编程模型 MapReduce RDD
客户端绑定 大多数高级语言 Java,Scala,Python
使用便捷性 中等,具有高级抽象(Pig,Hive 等) 良好
性能 批处理高吞吐量 流处理和批处理模式高吞吐量
使用 磁盘(I/O 受限) 内存,如果需要磁盘会降低性能
典型节点 中等 中等大

从上述比较可以看出,这两种技术都有优缺点。Spark 在性能方面可能更好,特别是在使用较少节点的问题上。另一方面,Hadoop 是一个成熟的框架,具有出色的工具,几乎可以覆盖每种用例。

MongoDB 作为数据仓库

Apache Hadoop 经常被描述为大数据框架中的 800 磅大猩猩。另一方面,Apache Spark 更像是一只 200 磅的猎豹,因为它的速度、敏捷性和性能特点,使其能够很好地解决 Hadoop 旨在解决的一部分问题。

另一方面,MongoDB 可以被描述为 NoSQL 世界中的 MySQL 等效物,因为它的采用和易用性。MongoDB 还提供聚合框架、MapReduce 功能和使用分片进行水平扩展,这实质上是在数据库级别进行数据分区。因此,一些人自然会想知道为什么我们不使用 MongoDB 作为我们的数据仓库来简化我们的架构。

这是一个相当有说服力的论点,也许使用 MongoDB 作为数据仓库是有道理的,也可能不是。这样做的优势如下:

  • 更简单的架构

  • 消息队列的需求减少,减少了系统的延迟

缺点如下:

  • MongoDB 的 MapReduce 框架不能替代 Hadoop 的 MapReduce。尽管它们都遵循相同的理念,但 Hadoop 可以扩展以容纳更大的工作负载。

  • 使用分片来扩展 MongoDB 的文档存储将在某个时候遇到瓶颈。尽管 Yahoo!报告称其最大的 Hadoop 集群使用了 42,000 台服务器,但最大的 MongoDB 商业部署仅达到 50 亿(Craigslist),而百度的节点数和数据量达到了 600 个节点和 PB 级数据,这家互联网巨头主导着中国互联网搜索市场等领域。

在扩展方面存在一个数量级的差异。

MongoDB 主要设计为基于磁盘上存储数据的实时查询数据库,而 MapReduce 是围绕使用批处理设计的,Spark 是围绕使用数据流设计的。

一个大数据用例

将所有这些付诸实践,我们将开发一个完全工作的系统,使用数据源、Kafka 消息代理、在 HDFS 上运行的 Apache Spark 集群,供应 Hive 表,以及 MongoDB 数据库。我们的 Kafka 消息代理将从 API 摄取数据,为 XMR/BTC 货币对流动市场数据。这些数据将传递给 HDFS 上的 Apache Spark 算法,以根据以下内容计算下一个 ticker 时间戳的价格:

  • 已经存储在 HDFS 上的历史价格语料库

  • 从 API 到达的流动市场数据

然后,这个预测的价格将使用 MongoDB Connector for Hadoop 存储在 MongoDB 中。MongoDB 还将直接从 Kafka 消息代理接收数据,将其存储在一个特殊的集合中,文档过期日期设置为一分钟。这个集合将保存最新的订单,旨在被我们的系统用来购买或出售,使用来自 Spark ML 系统的信号。

例如,如果价格当前为 10,我们出价为 9.5,但我们预计下一个市场 tick 价格会下降,那么系统会等待。如果我们预计下一个市场 tick 价格会上涨,那么系统会将出价提高到 10.01 以匹配下一个 ticker 的价格。

同样,如果价格为 10,我们出价为 10.5,但预计价格会下降,我们会调整我们的出价为 9.99,以确保我们不会为此支付过多。但是,如果预计价格会上涨,我们会立即购买,以在下一个市场 tick 中获利。

在图表上,我们的架构如下:

API 通过将 JSON 消息发布到名为xmr_btc的 Kafka 主题来模拟。另一方面,我们有一个 Kafka 消费者将实时数据导入 MongoDB。

我们还有另一个 Kafka 消费者将数据导入 Hadoop,供我们的算法使用,发送推荐数据(信号)到 Hive 表。最后,我们将数据从 Hive 表导出到 MongoDB。

设置 Kafka

建立大数据用例环境的第一步是建立一个 Kafka 节点。Kafka 本质上是一个 FIFO 队列,因此我们将使用最简单的单节点(broker)设置。Kafka 使用主题、生产者、消费者和代理来组织数据。

重要的 Kafka 术语如下:

  • 代理本质上是一个节点。

  • 生产者本质上是一个写入数据到消息队列的过程。

  • 消费者是从消息队列中读取数据的过程。

  • 主题是我们写入和读取数据的特定队列。

Kafka 主题进一步分为多个分区。我们可以在写入主题时,以及在队列的另一端读取数据时,将特定主题的数据拆分为多个代理(节点)。

在我们的本地机器上安装 Kafka,或者选择任何云提供商(有很好的 EC2 教程可以找到),我们可以使用以下单个命令创建一个主题:

$ kafka-topics  --create --zookeeper localhost:2181 --replication-factor 1  --partitions 1 --topic xmr-btc
Created topic "xmr-btc".

这将创建一个名为xmr-btc的新主题。

删除主题与创建主题类似,使用以下命令:

$ kafka-topics --delete --zookeeper localhost:2181 --topic xmr-btc

我们可以通过发出以下命令来获取所有主题的列表:

$ kafka-topics --list --zookeeper localhost:2181
xmr-btc

然后我们可以为我们的主题创建一个命令行生产者,只是为了测试我们是否可以将消息发送到队列,就像这样:

$ kafka-console-producer --broker-list localhost:9092 --topic xmr-btc

每行的数据将作为字符串编码的消息发送到我们的主题,我们可以通过发送SIGINT信号(通常是Ctrl + C)来结束这个过程。

之后,我们可以通过启动一个消费者来查看等待在我们队列中的消息:

$ kafka-console-consumer --zookeeper localhost:2181 --topic xmr-btc --from-beginning

这个消费者将从我们的xmr-btc主题中读取所有消息,从历史的开始。这对我们的测试目的很有用,但在实际应用中我们会更改这个配置。

在命令中,除了提到kafka,您还会看到zookeeper。Apache Zookeeper 与 Apache Kafka 一起使用,是一个集中式服务,由 Kafka 内部用于维护配置信息、命名、提供分布式同步和提供组服务。

现在我们已经设置好了我们的代理,我们可以使用github.com/agiamas/mastering-mongodb/tree/master/chapter_9上的代码来开始读取消息并将消息写入队列。对于我们的目的,我们使用了由 Zendesk 开发的ruby-kafka gem。

为简单起见,我们使用一个单一的类来从磁盘上存储的文件中读取数据,并将其写入我们的 Kafka 队列。

我们的produce方法将用于将消息写入 Kafka,如下所示:

def produce
  options = { converters: :numeric, headers: true }
   CSV.foreach('xmr_btc.csv', options) do |row|
    json_line = JSON.generate(row.to_hash)
    @kafka.deliver_message(json_line, topic: 'xmr-btc')
  end
end

我们的consume方法将从 Kafka 中读取消息,如下所示:

def consume
  consumer = @kafka.consumer(group_id: 'xmr-consumers')
  consumer.subscribe('xmr-btc', start_from_beginning: true)
  trap('TERM') { consumer.stop }
  consumer.each_message(automatically_mark_as_processed: false) do |message|
    puts message.value
    if valid_json?(message.value)
      MongoExchangeClient.new.insert(message.value)
      consumer.mark_message_as_processed(message)
    end
  end
  consumer.stop
end

请注意,我们使用了消费者组 API 功能(在 Kafka 0.9 中添加)来使多个消费者通过将每个分区分配给单个消费者来访问单个主题。在消费者故障的情况下,其分区将重新分配给组的其余成员。

下一步是将这些消息写入 MongoDB,如下所示:

  1. 首先,我们创建我们的集合,以便我们的文档在一分钟后过期。在mongo shell 中输入以下内容:
> use exchange_data
> db.xmr_btc.createIndex( { "createdAt": 1 }, { expireAfterSeconds: 60 })
{
"createdCollectionAutomatically" : true,
"numIndexesBefore" : 1,
"numIndexesAfter" : 2,
"ok" : 1
}

这样,我们创建了一个名为exchange_data的新数据库,其中包含一个名为xmr_btc的新集合,该集合在一分钟后自动过期。要使 MongoDB 自动过期文档,我们需要提供一个带有datetime值的字段,以将其值与当前服务器时间进行比较。在我们的情况下,这是createdAt字段。

  1. 对于我们的用例,我们将使用低级别的 MongoDB Ruby 驱动程序。MongoExchangeClient的代码如下:
class MongoExchangeClient
 def initialize
   @collection = Mongo::Client.new([ '127.0.0.1:27017' ], database: :exchange_data).database[:xmr_btc]
 end
 def insert(document)
   document = JSON.parse(document)
   document['createdAt'] = Time.now
   @collection.insert_one(document)
 end
end

此客户端连接到我们的本地数据库,为 TTL 文档过期设置createdAt字段,并将消息保存到我们的集合中。

有了这个设置,我们可以将消息写入 Kafka,在队列的另一端读取它们,并将它们写入我们的 MongoDB 集合。

设置 Hadoop

我们可以安装 Hadoop,并使用单个节点来完成本章的用例,使用 Apache Hadoop 网站上的说明hadoop.apache.org/docs/stable/hadoop-project-dist/hadoop-common/SingleCluster.html

按照这些步骤后,我们可以在本地机器上的http://localhost:50070/explorer.html#/上浏览 HDFS 文件。假设我们的信号数据写在 HDFS 的/user/<username>/signals目录下,我们将使用 MongoDB Connector for Hadoop 将其导出并导入到 MongoDB 中。

MongoDB Connector for Hadoop 是官方支持的库,允许将 MongoDB 数据文件或 BSON 格式的 MongoDB 备份文件用作 Hadoop MapReduce 任务的源或目的地。

这意味着当我们使用更高级别的 Hadoop 生态系统工具时,例如 Pig(一种过程化高级语言)、Hive(一种类似 SQL 的高级语言)和 Spark(一种集群计算框架)时,我们也可以轻松地导出和导入数据到 MongoDB。

Hadoop 设置步骤

设置 Hadoop 的不同步骤如下:

  1. Maven 库下载 JAR。

  2. oss.sonatype.org/content/repositories/releases/org/mongodb/mongodb-driver/3.5.0/下载mongo-java-driver

  3. 创建一个目录(在我们的情况下,命名为mongo_lib),并使用以下命令将这两个 JAR 复制到其中:

export HADOOP_CLASSPATH=$HADOOP_CLASSPATH:<path_to_directory>/mongo_lib/

或者,我们可以将这些 JAR 复制到share/hadoop/common/目录下。由于这些 JAR 需要在每个节点上都可用,对于集群部署,使用 Hadoop 的DistributedCache将 JAR 分发到所有节点更容易。

  1. 下一步是从hive.apache.org/downloads.html安装 Hive。在本例中,我们使用了 MySQL 服务器来存储 Hive 的元数据。这可以是用于开发的本地 MySQL 服务器,但建议在生产环境中使用远程服务器。

  2. 一旦我们设置好了 Hive,我们只需运行以下命令:

> hive
  1. 然后,我们添加之前下载的三个 JAR(mongo-hadoop-coremongo-hadoop-drivermongo-hadoop-hive):
hive> add jar /Users/dituser/code/hadoop-2.8.1/mongo-hadoop-core-2.0.2.jar;
Added [/Users/dituser/code/hadoop-2.8.1/mongo-hadoop-core-2.0.2.jar] to class path
Added resources: [/Users/dituser/code/hadoop-2.8.1/mongo-hadoop-core-2.0.2.jar]
hive> add jar /Users/dituser/code/hadoop-2.8.1/mongodb-driver-3.5.0.jar;
Added [/Users/dituser/code/hadoop-2.8.1/mongodb-driver-3.5.0.jar] to class path
Added resources: [/Users/dituser/code/hadoop-2.8.1/mongodb-driver-3.5.0.jar]
hive> add jar /Users/dituser/code/hadoop-2.8.1/mongo-hadoop-hive-2.0.2.jar;
Added [/Users/dituser/code/hadoop-2.8.1/mongo-hadoop-hive-2.0.2.jar] to class path
Added resources: [/Users/dituser/code/hadoop-2.8.1/mongo-hadoop-hive-2.0.2.jar]
hive>

然后,假设我们的数据在表交换中:

**customerid                                             ** int
pair String
time TIMESTAMP
recommendation int

我们还可以使用 Gradle 或 Maven 在我们的本地项目中下载 JAR。如果我们只需要 MapReduce,那么我们只需下载mongo-hadoop-core JAR。对于 Pig、Hive、Streaming 等,我们必须从

repo1.maven.org/maven2/org/mongodb/mongo-hadoop/

一些有用的 Hive 命令包括:show databases;

创建表交换(客户 ID int,对 String,时间时间戳,建议 int);

  1. 现在我们已经准备好了,我们可以创建一个由我们本地 Hive 数据支持的 MongoDB 集合:
hive> create external table exchanges_mongo (objectid STRING, customerid INT,pair STRING,time STRING, recommendation INT) STORED BY 'com.mongodb.hadoop.hive.MongoStorageHandler' WITH SERDEPROPERTIES('mongo.columns.mapping'='{"objectid":"_id", "customerid":"customerid","pair":"pair","time":"Timestamp", "recommendation":"recommendation"}') tblproperties('mongo.uri'='mongodb://localhost:27017/exchange_data.xmr_btc');
  1. 最后,我们可以按照以下方式将exchanges Hive 表中的所有数据复制到 MongoDB 中:
hive> Insert into table exchanges_mongo select * from exchanges;

这样,我们已经建立了 Hadoop 和 MongoDB 之间的管道,使用 Hive,而不需要任何外部服务器。

使用 Hadoop 到 MongoDB 的管道

使用 MongoDB Connector for Hadoop 的替代方法是使用我们选择的编程语言从 Hadoop 中导出数据,然后使用低级驱动程序或 ODM 将数据写入 MongoDB,如前几章所述。

例如,在 Ruby 中,有一些选项:

  • 在 GitHub 上的WebHDFS,它使用 WebHDFS 或HttpFS Hadoop API 从 HDFS 获取数据

  • 系统调用,使用 Hadoop 命令行工具和 Ruby 的system()调用

而在 Python 中,我们可以使用以下命令:

  • HdfsCLI,它使用 WebHDFS 或 HttpFS Hadoop API

  • libhdfs,它使用基于 JNI 的本地 C 封装的 HDFS Java 客户端

所有这些选项都需要我们的 Hadoop 基础设施和 MongoDB 服务器之间的中间服务器,但另一方面,允许在导出/导入数据的 ETL 过程中更灵活。

设置 Spark 到 MongoDB

MongoDB 还提供了一个工具,可以直接查询 Spark 集群并将数据导出到 MongoDB。Spark 是一个集群计算框架,通常作为 Hadoop 中的 YARN 模块运行,但也可以独立在其他文件系统之上运行。

MongoDB Spark Connector 可以使用 Java、Scala、Python 和 R 从 Spark 读取和写入 MongoDB 集合。它还可以在创建由 Spark 支持的数据集的临时视图后,对 MongoDB 数据进行聚合和运行 SQL 查询。

使用 Scala,我们还可以使用 Spark Streaming,这是构建在 Apache Spark 之上的数据流应用程序的 Spark 框架。

进一步阅读

您可以参考以下参考资料获取更多信息:

摘要

在本章中,我们了解了大数据领域以及 MongoDB 与消息队列系统和数据仓库技术的比较和对比。通过一个大数据用例,我们从实际角度学习了如何将 MongoDB 与 Kafka 和 Hadoop 集成。

在下一章中,我们将转向复制和集群操作,并讨论副本集、选举的内部情况以及我们的 MongoDB 集群的设置和管理。

第四部分:扩展和高可用性

在本节中,我们将首先介绍复制,以及如何使用它来确保我们不会遭受任何数据丢失。分片是下一个主题,它帮助我们在 MongoDB 中实现水平扩展。最后,我们将学习在使用 MongoDB 时实现高可用性和容错性的最佳实践和技巧。

本节包括以下章节:

  • 第十二章,复制

  • 第十三章,分片

  • 第十四章,容错和高可用性

第十二章:复制

自从 MongoDB 的早期以来,复制一直是最有用的功能之一。 一般来说,复制是指在不同服务器之间同步数据的过程。 复制的好处包括防止数据丢失和数据的高可用性。 复制还提供灾难恢复,避免维护停机时间,扩展读取(因为我们可以从多个服务器读取)和扩展写入(只有我们可以写入多个服务器时)。

在本章中,我们将涵盖以下主题:

  • 架构概述,选举和复制的用例

  • 设置副本集

  • 连接到副本集

  • 副本集管理

  • 使用云提供商部署副本集的最佳实践

  • 副本集限制

复制

复制有不同的方法。 MongoDB 采取的方法是主从的逻辑复制,我们将在本章后面更详细地解释。

逻辑或物理复制

通过复制,我们在多个服务器之间同步数据,提供数据可用性和冗余。 即使由于硬件或软件故障而丢失服务器,通过使用复制,我们将有多个副本可以用来恢复我们的数据。 复制的另一个优点是我们可以使用其中一个服务器作为专用报告或备份服务器。

在逻辑复制中,我们的主/主服务器执行操作; 从/次要服务器从主服务器尾随操作队列,并按相同顺序应用相同的操作。 以 MongoDB 为例,操作日志oplog)跟踪主服务器上发生的操作,并按相同顺序在次要服务器上应用它们。

逻辑复制对各种应用非常有用,例如信息共享,数据分析和在线分析处理OLAP)报告。

在物理复制中,数据在物理级别上被复制,比数据库操作的更低级别。 这意味着我们不是应用操作,而是复制受这些操作影响的字节。 这也意味着我们可以获得更好的效率,因为我们使用低级结构来传输数据。 我们还可以确保数据库的状态完全相同,因为它们是相同的,逐字节相同。

物理复制通常缺少有关数据库结构的知识,这意味着更难(如果不是不可能)从数据库复制一些集合并忽略其他集合。

物理复制通常适用于更罕见的情况,例如灾难恢复,在这种情况下,一切(包括数据,索引,数据库内部状态在日志中的重做/撤消日志)的完整和精确副本对于将应用程序恢复到确切状态至关重要。

不同的高可用性类型

在高可用性中,有几种配置可以使用。 我们的主服务器称为热服务器,因为它可以处理每一个请求。 我们的次要服务器可以处于以下任何状态:

  • 温暖

次要冷服务器是一个服务器,仅在主服务器离线时存在,而不期望它保存主服务器的数据和状态。

次要温暖服务器定期从主服务器接收数据更新,但通常不会完全与主服务器同步。 它可以用于一些非实时分析报告,以卸载主服务器,但通常情况下,如果主服务器宕机,它将无法承担事务负载。

次要热服务器始终保持与主服务器的数据和状态的最新副本。 它通常处于热备状态,准备在主服务器宕机时接管。

MongoDB 具有热服务器和温服务器功能,我们将在接下来的部分中探讨。

大多数数据库系统都采用类似的主/次服务器概念,因此从概念上讲,MongoDB 的所有内容也适用于那里。

架构概述

MongoDB 的复制在以下图表中提供:

主服务器是唯一可以随时进行写入的服务器。次要服务器处于热备状态,一旦主服务器故障,它们就可以接管。一旦主服务器故障,就会进行选举,确定哪个次要服务器将成为主服务器。

我们还可以有仲裁节点。仲裁节点不保存任何数据,它们唯一的目的是参与选举过程。

我们必须始终有奇数个节点(包括仲裁者)。三、五和七都可以,这样在主服务器(或更多服务器)故障时,我们在选举过程中有多数选票。

当副本集的其他成员在 10 秒以上(可配置)没有收到来自主服务器的消息时,一个合格的次要成员将开始选举过程,投票选举出新的主服务器。首个进行选举并赢得多数的次要成员将成为新的主服务器。所有剩余的服务器现在将从新的主服务器复制,保持它们作为次要服务器的角色,但从新的主服务器同步。

从 MongoDB 3.6 开始,客户端驱动程序可以在检测到主服务器宕机时重试一次写操作。副本集最多可以有 50 个成员,但其中只有最多七个可以参与选举过程。

新选举后我们副本集的设置如下:

在下一节中,我们将讨论选举的工作原理。

选举是如何工作的?

副本集中的所有服务器都通过心跳定期与每个其他成员保持通信。心跳是一个小数据包,定期发送以验证所有成员是否正常运行。

次要成员还与主服务器通信,从 oplog 获取最新更新并将其应用于自己的数据。

这里的信息是指最新的复制选举协议,即版本 1,它是在 MongoDB v3.2 中引入的。

从图表中,我们可以看到它是如何工作的。

当主成员下线时,所有次要成员都会错过一个或多个心跳。它们将等待直到settings.electionTimeoutMillis时间过去(默认为 10 秒),然后次要成员将开始一轮或多轮选举,以找到新的主服务器。

要从次要服务器中选举出主服务器,它必须具备两个属性:

  • 属于拥有50% + 1选票的选民组

  • 成为这个组中最新的次要

在一个简单的例子中,有三个服务器,每个服务器一票,一旦我们失去主服务器,其他两个服务器将各自有一票(因此总共是三分之二),因此,拥有最新 oplog 的服务器将被选举为主服务器。

现在,考虑一个更复杂的设置,如下:

  • 七个服务器(一个主服务器,六个次要服务器)

  • 每个节点一票

我们失去了主服务器,剩下的六个服务器出现了网络连接问题,导致网络分区:

这些分区可以描述如下:

  • 北区:三个服务器(每个一票)

  • 南区:三个服务器(每个一票)

任何一个分区都不知道其他服务器发生了什么。现在,当它们进行选举时,没有一个分区能够建立多数,因为它们有七票中的三票。没有主服务器会从任何一个分区中被选举出来。这个问题可以通过例如拥有一个拥有三票的服务器来解决。

现在,我们的整体集群设置如下:

  • 服务器#1:一票

  • 服务器#2:一票

  • 服务器#3:一票

  • 服务器#4:一票

  • 服务器#5:一票

  • 服务器#6:一票

  • 服务器#7:三票

在失去服务器#1 后,我们的分区现在如下:

北分区如下:

  • 服务器#2:一票

  • 服务器#3:一票

  • 服务器#4:一票

南分区如下:

  • 服务器#5:一票

  • 服务器#6:一票

  • 服务器#7:三票

南分区有三个服务器,共有九票中的五票。服务器#5、#6 和#7 中最新(根据其 oplog 条目)的辅助服务器将被选为主服务器。

副本集的用例是什么?

MongoDB 提供了使用副本集的大部分优势,其中一些列举如下:

  • 防止数据丢失

  • 数据的高可用性

  • 灾难恢复

  • 避免维护停机时间

  • 扩展读取,因为我们可以从多个服务器读取

  • 帮助设计地理分散服务

  • 数据隐私

从列表中缺少的最显着的项目是扩展写入。这是因为在 MongoDB 中,我们只能有一个主服务器,只有这个主服务器才能从我们的应用服务器接收写入。

当我们想要扩展写性能时,通常会设计和实现分片,这将是下一章的主题。MongoDB 复制实现的两个有趣特性是地理分散服务和数据隐私。

我们的应用服务器通常位于全球多个数据中心。使用复制,我们可以尽可能将辅助服务器靠近应用服务器。这意味着我们的读取将很快,就像本地一样,并且我们只会为写入获得延迟性能惩罚。当然,这需要在应用程序级别进行一些规划,以便我们可以维护两个不同的数据库连接池,这可以通过使用官方的 MongoDB 驱动程序或使用更高级别的 ODM 轻松完成。

MongoDB 复制设计的第二个有趣特性是实现数据隐私。当我们在不同数据中心地理分散的服务器上,我们可以启用每个数据库的复制。通过将数据库排除在复制过程之外,我们可以确保我们的数据保持在我们需要的数据中心内。我们还可以在同一个 MongoDB 服务器上为每个数据库设置不同的复制模式,以满足我们的数据隐私需求,如果某些服务器不符合我们的数据隐私规定,可以将其排除在副本集之外。

设置副本集

在本节中,我们将介绍设置副本集的最常见部署程序。这些包括将独立服务器转换为副本集或从头开始设置副本集。

将独立服务器转换为副本集

要将独立服务器转换为副本集,我们首先需要干净地关闭mongo服务器:

> use admin
> db.shutdownServer()

然后,我们通过命令行使用--replSet配置选项启动服务器(我们将在这里执行),或者使用配置文件,如我们将在下一节中解释的那样:

  1. 首先,我们通过 mongo shell 连接到新的启用了副本集的实例,如下所示:
> rs.initiate()
  1. 现在,我们有了副本集的第一个服务器。我们可以使用 mongo shell 添加其他服务器(这些服务器也必须使用--replSet启动),如下所示:
> rs.add("<hostname><:port>")

通过使用rs.conf()来双重检查副本集配置。通过使用rs.status()来验证副本集状态。

创建副本集

作为副本集的一部分启动 MongoDB 服务器就像通过命令行在配置中设置它一样容易:

> mongod --replSet "xmr_cluster"

这对开发目的来说是可以的。对于生产环境,建议使用配置文件:

> mongod --config <path-to-config>

在这里,<path-to-config>可以如下:

/etc/mongod.conf

此配置文件必须采用 YAML 格式。

YAML 不支持制表符。请使用您选择的编辑器将制表符转换为空格。

一个简单的配置文件示例如下:

systemLog:
  destination: file
  path: "/var/log/mongodb/mongod.log"
  logAppend: true
storage:
  journal:
     enabled: true
processManagement:
  fork: true
net:
  bindIp: 127.0.0.1
  port: 27017
replication:
  oplogSizeMB: <int>
  replSetName: <string>

根级选项通过嵌套定义叶级选项适用于的部分。关于复制,强制选项是oplogSizeMB(成员的 oplog 大小,以 MB 为单位)和replSetName(副本集名称,例如xmr_cluster)。

我们还可以在与replSetName相同级别上设置以下内容:

secondaryIndexPrefetch: <string>

这仅适用于 MMAPv1 存储引擎,并且指的是在应用操作之前将加载到内存中的次要服务器上的索引。

它默认为all,可用选项为none_id_only,以便不将索引加载到内存中,只加载在_id字段上创建的默认索引:

enableMajorityReadConcern: <boolean>

这是启用此成员的majority读取偏好的配置设置。

在不同节点上启动了所有副本集进程后,我们可以使用适当的host:port从命令行使用mongo登录到其中一个节点。然后,我们需要从一个成员初始化集群。

我们可以使用以下配置文件:

> rs.initiate()

或者,我们可以将配置作为文档参数传递,如下所示:

> rs.initiate( {
 _id : "xmr_cluster",
 members: [ { _id : 0, host : "host:port" } ]
})

我们可以使用rs.conf()在 shell 中验证集群是否已初始化。

接下来,我们通过使用我们在网络设置中定义的host:port,将每个其他成员添加到我们的副本集中:

> rs.add("host2:port2")
> rs.add("host3:port3")

我们必须为 HA 副本集使用的最小服务器数量是3。我们可以用仲裁者替换其中一个服务器,但这并不推荐。一旦我们添加了所有服务器并等待了一会儿,我们可以使用rs.status()来检查我们集群的状态。默认情况下,oplog 将是空闲磁盘空间的 5%。如果我们想在创建副本集时定义它,我们可以通过传递命令行参数--oplogSizeMB或在配置文件中使用replication.oplogSizeMB来这样做。oplog 大小不能超过 50GB。

读取偏好

默认情况下,所有写入和读取都来自主服务器。次要服务器复制数据,但不用于查询。

在某些情况下,更改这一点并开始从次要服务器读取可能是有益的。

MongoDB 官方驱动程序支持五个级别的读取偏好:

读取偏好模式 描述
primary 这是默认模式,其中读取来自副本集的primary服务器。
primaryPreferred 使用此模式,应用程序将从primary读取数据,除非它不可用,在这种情况下,读取将来自secondary成员。
secondary 读取仅来自secondary服务器。
secondaryPreferred 使用此模式,应用程序将从secondary成员读取数据,除非它们不可用,在这种情况下,读取将来自primary成员。
nearest 应用程序将从副本集中在网络延迟方面最接近的成员读取数据,而不考虑成员的类型。

除了primary之外的任何读取偏好对于非常时间敏感的异步操作可能是有益的。例如,报告服务器可以从次要服务器读取,而不是从主服务器读取,因为我们可能对聚合数据的小延迟可以接受,而又能在主服务器上产生更多的读取负载。

地理分布的应用程序也将受益于从次要服务器读取,因为这些服务器的延迟会显著较低。尽管这可能有违直觉,但仅将读取偏好从primary更改为secondary不会显著增加集群的总读取容量。这是因为我们集群的所有成员都在承受来自客户端写入的相同写入负载,并分别复制主服务器和次要服务器的数据。

然而,从辅助节点读取可能会返回过期数据,这必须在应用程序级别处理。从可能具有可变复制延迟的不同辅助节点读取(与我们的主要写入相比)可能导致读取文档的插入顺序不一致(非单调读取)。

尽管存在所有上述警告,如果我们的应用程序设计支持,从辅助节点读取仍然是一个好主意。可以帮助我们避免读取过期数据的另一个配置选项是maxStalenessSeconds

根据每个辅助节点对于与主节点相比落后程度的粗略估计,我们可以将其设置为 90(秒)或更高的值,以避免读取过期数据。鉴于辅助节点知道它们与主节点的落后程度(但并不准确或积极地估计),这应被视为一种近似,而不是我们设计的基础。

写关注

在 MongoDB 副本集中,默认情况下,写操作将在主服务器确认写入后得到确认。如果我们想要更改此行为,可以通过两种不同的方式进行:

  • 在某些情况下,我们可以针对每个操作请求不同的写关注,以确保写入在标记为完成之前已传播到我们副本集的多个成员,如下所示:
> db.mongo_books.insert(
 { name: "Mastering MongoDB", isbn: "1001" },
 { writeConcern: { w: 2, wtimeout: 5000 } }
)

在上面的示例中,我们正在等待两个服务器(主服务器加上任何一个辅助服务器)确认写入。我们还设置了5000毫秒的超时,以避免在网络速度慢或我们没有足够的服务器来确认请求的情况下阻塞我们的写入。

  • 我们还可以通过以下方式更改整个副本集的默认写关注:
> cfg = rs.conf()
> cfg.settings.getLastErrorDefaults = { w: "majority", wtimeout: 5000 }
> rs.reconfig(cfg)

在这里,我们将写关注设置为majority,超时为5秒。写关注majority确保我们的写入将传播到至少n/2+1个服务器,其中n是我们的副本集成员的数量。

写关注majority在我们的读取偏好为majority时非常有用,因为它确保每个带有w: "majority"的写入也将以相同的读取偏好可见。如果设置了w>1,还可以设置wtimeout: <milliseconds>wtimeout将在达到超时后从我们的写操作返回,因此不会无限期地阻塞我们的客户端。建议还设置j: truej: true将等待我们的写操作在确认之前写入日志。w>1j: true一起将等待我们指定的服务器数量在确认之前写入日志。

自定义写关注

我们还可以使用不同的标签(即reporting,东海岸服务器和总部服务器)标识我们的副本集成员,并针对每个操作指定自定义写关注,如下所示:

  1. 使用 mongo shell 连接到主服务器的常规过程如下:
> conf = rs.conf()
> conf.members[0].tags = { "location": "UK", "use": "production", "location_uk":"true"  }
> conf.members[1].tags = { "location": "UK", "use": "reporting", "location_uk":"true"  }
> conf.members[2].tags = { "location": "Ireland", "use": "production"  }
  1. 现在,我们可以设置自定义写关注,如下所示:
> conf.settings = { getLastErrorModes: { UKWrites : { "location_uk": 2} } }
  1. 应用此设置后,我们使用reconfig命令:
> rs.reconfig(conf)
  1. 现在,我们可以通过以下方式在我们的写入中设置writeConcern
> db.mongo_books.insert({<our insert object>}, { writeConcern: { w: "UKWrites" } })

这意味着我们的写入只有在满足UKWrites写关注时才会得到确认,而UKWrites写关注将由至少两个带有location_uk标签的服务器验证。由于我们只有两台位于英国的服务器,因此通过此自定义写关注,我们可以确保将数据写入到我们所有的英国服务器。

副本集成员的优先级设置

MongoDB 允许我们为每个成员设置不同的优先级级别。这允许实现一些有趣的应用程序和拓扑结构。

在设置完集群后更改优先级,我们必须使用 mongo shell 连接到我们的主服务器并获取配置对象(在本例中为cfg):

> cfg = rs.conf()

然后,我们可以将members子文档的priority属性更改为我们选择的值:

> cfg.members[0].priority = 0.778
> cfg.members[1].priority = 999.9999

每个成员的默认priority1priority可以从0(永远不成为主要)设置为1000,以浮点精度。

优先级较高的成员将是主服务器下台时首先发起选举的成员,并且最有可能赢得选举。

应该考虑不同网络分区来配置自定义优先级。错误地设置优先级可能导致选举无法选举主服务器,从而停止所有对我们 MongoDB 副本集的写入。

如果我们想要阻止次要服务器成为主服务器,我们可以将其priority设置为0,如我们将在下一节中解释的那样。

零优先级副本集成员

在某些情况下(例如,如果我们有多个数据中心),我们将希望一些成员永远无法成为主服务器。

在具有多个数据中心复制的情况下,我们的主要数据中心可能有一个基于英国的主服务器和一个次要服务器,以及一个位于俄罗斯的次要服务器。在这种情况下,我们不希望我们基于俄罗斯的服务器成为主服务器,因为这将给我们位于英国的应用服务器带来延迟。在这种情况下,我们将设置我们基于俄罗斯的服务器的priority0

priority0的副本集成员也不能触发选举。在所有其他方面,它们与副本集中的每个其他成员相同。要更改副本集成员的priority,我们必须首先通过连接(通过 mongo shell)到主服务器获取当前的副本集配置:

> cfg = rs.conf()

这将提供包含副本集中每个成员配置的配置文档。在members子文档中,我们可以找到priority属性,我们必须将其设置为0

> cfg.members[2].priority = 0

最后,我们需要使用更新后的配置重新配置副本集:

rs.reconfig(cfg)

确保每个节点中运行的 MongoDB 版本相同,否则可能会出现意外行为。避免在高流量时期重新配置副本集群。重新配置副本集可能会强制进行新主要选举,这将关闭所有活动连接,并可能导致 10-30 秒的停机时间。尝试识别最低流量时间窗口来运行维护操作,始终在发生故障时有恢复计划。

隐藏的副本集成员

隐藏的副本集成员用于特殊任务。它们对客户端不可见,在db.isMaster() mongo shell 命令和类似的管理命令中不会显示,并且对客户端不会被考虑(即读取首选项选项)。

它们可以投票选举,但永远不会成为主服务器。隐藏的副本集成员只会同步到主服务器,并不会从客户端读取。因此,它具有与主服务器相同的写入负载(用于复制目的),但自身没有读取负载。

由于前面提到的特性,报告是隐藏成员最常见的应用。我们可以直接连接到此成员并将其用作 OLAP 的数据源。

要设置隐藏的副本集成员,我们遵循与priority0类似的过程。在通过 mongo shell 连接到我们的主服务器后,我们获取配置对象,识别在成员子文档中对应于我们想要设置为hidden的成员的成员,并随后将其priority设置为0,将其hidden属性设置为true。最后,我们必须通过调用rs.reconfig(config_object)并将config_object作为参数使用来应用新配置:

> cfg = rs.conf()
> cfg.members[0].priority = 0
> cfg.members[0].hidden = true
> rs.reconfig(cfg)

hidden副本集成员也可以用于备份目的。然而,正如您将在下一节中看到的,我们可能希望在物理级别或逻辑级别复制数据时使用其他选项。在这些情况下,考虑使用延迟副本集。

延迟副本集成员

在许多情况下,我们希望有一个节点在较早的时间点保存我们的数据副本。这有助于从大量人为错误中恢复,比如意外删除集合或升级出现严重问题。

延迟的副本集成员必须是 priority = 0hidden = true。延迟的副本集成员可以投票进行选举,但永远不会对客户端可见(hidden = true),也永远不会成为主服务器(priority = 0)。

一个示例如下:

> cfg = rs.conf()
> cfg.members[0].priority = 0
> cfg.members[0].hidden = true
> cfg.members[0].slaveDelay = 7200
> rs.reconfig(cfg)

这将把 members[0] 设置为延迟 2 小时。决定主服务器和延迟次要服务器之间时间间隔的两个重要因素如下:

  • 主要副本中足够的 oplog 大小

  • 在延迟成员开始获取数据之前,足够的维护时间

下表显示了副本集的延迟时间(以小时为单位):

维护窗口,以小时为单位 延迟 主要副本的 oplog 大小,以小时为单位
0.5 [0.5,5) 5

生产考虑

在单独的物理主机上部署每个 mongod 实例。如果使用虚拟机,请确保它们映射到不同的基础物理主机。使用 bind_ip 选项确保服务器映射到特定的网络接口和端口地址。

使用防火墙阻止对任何其他端口的访问和/或仅允许应用程序服务器和 MongoDB 服务器之间的访问。更好的做法是设置 VPN,以便您的服务器以安全的加密方式相互通信。

连接到副本集

连接到副本集与连接到单个服务器本质上没有太大不同。在本节中,我们将展示一些使用官方 mongo-ruby-driver 的示例。我们将按以下步骤进行副本集的操作:

  1. 首先,我们需要设置我们的 hostoptions 对象:
client_host = ['hostname:port']
client_options = {
 database: 'signals',
 replica_set: 'xmr_btc'
}

在上述示例中,我们准备连接到 hostname:port,在 replica_set xmr_btc 数据库中的信号。

  1. Mongo::Client 上调用初始化器现在将返回一个包含连接到我们的副本集和数据库的 client 对象:
client = Mongo::Client.new(client_host, client_options)

client 对象在连接到单个服务器时具有相同的选项。

连接到副本集后,MongoDB 在连接到我们的 client_host 后使用自动发现来识别副本集的其他成员,无论它们是主服务器还是次要服务器。client 对象应该作为单例使用,创建一次并在整个代码库中重复使用。

  1. 在某些情况下,可以覆盖使用单例 client 对象的规则。如果我们有不同类别的连接到副本集,应该创建不同的 client 对象。

例如,对于大多数操作使用一个 client 对象,然后对于只从次要服务器读取的操作使用另一个 client 对象:

client_reporting = client.with(:read => { :mode => :secondary })
  1. 这个 Ruby MongoDB client 命令将返回一个包含读取偏好为次要的 MongoDB:Client 对象的副本,例如,用于报告目的。

我们在 client_options 初始化对象中可以使用的一些最有用的选项如下:

选项 描述 类型 默认
replica_set 在我们的示例中使用:副本集名称。 字符串
write write 关注选项作为 hash 对象;可用选项为 wwtimeoutjfsync。也就是说,要指定写入到两个服务器,启用日志记录,刷新到磁盘(fsync)为 true,并设置超时为 1 秒:{ write: { w: 2, j: true, wtimeout: 1000, fsync: true } } 哈希 { w: 1 }

| read | 读取偏好模式作为哈希。可用选项为 modetag_sets。也就是说,限制从具有标签 UKWrites 的次要服务器读取:{ read:  { mode: :secondary,

   tag_sets: [ "UKWrites" ]

 }

} | 哈希 | { mode: primary } |

user 要进行身份验证的用户的名称。 字符串
password 要进行身份验证的用户的密码。 字符串
connect 使用:direct,我们可以强制将副本集成员视为独立服务器,绕过自动发现。其他选项包括::direct:replica_set:sharded 符号
heartbeat_frequency 副本集成员定期通信以检查它们是否都存活的频率。 浮点数 10
database 数据库连接。 字符串 admin

与连接到独立服务器类似,SSL 和身份验证也有相同的选项。

我们还可以通过设置以下代码来配置连接池:

min_pool_size(defaults to 1 connection),
max_pool_size(defaults to 5),
wait_queue_timeout(defaults to 1 in seconds).

如果可用,MongoDB 驱动程序将尝试重用现有连接,否则将打开新连接。一旦达到池限制,驱动程序将阻塞,等待连接被释放以使用它。

副本集管理

副本集的管理可能比单服务器部署所需的要复杂得多。在本节中,我们将重点放在一些最常见的管理任务上,而不是试图详尽地涵盖所有不同的情况,以及如何执行这些任务。

如何对副本集执行维护

如果我们有一些在副本集的每个成员中都必须执行的维护任务,我们总是从辅助节点开始。我们通过执行以下步骤来执行维护:

  1. 首先,我们通过 mongo shell 连接到其中一个辅助节点。然后,我们停止该辅助节点:
> use admin
> db.shutdownServer()
  1. 然后,使用在上一步中连接到 mongo shell 的相同用户,我们在不同的端口上将 mongo 服务器重新启动为独立服务器:
> mongod --port 95658 --dbpath <wherever our mongoDB data resides in this host>
  1. 下一步是连接到使用dbpathmongod服务器:
> mongo --port 37017
  1. 在这一点上,我们可以安全地执行所有独立服务器上的管理任务,而不会影响我们的副本集操作。完成后,我们以与第一步相同的方式关闭独立服务器。

  2. 然后,我们可以通过使用命令行或我们通常使用的配置脚本来重新启动副本集中的服务器。最后一步是通过连接到副本集服务器并获取其副本集status来验证一切是否正常:

> rs.status()

服务器最初应处于state: RECOVERING状态,一旦它赶上了辅助服务器,它应该回到state: SECONDARY状态,就像在开始维护之前一样。

我们将为每个辅助服务器重复相同的过程。最后,我们必须对主服务器进行维护。主服务器的过程唯一的不同之处在于,在每一步之前,我们将首先将主服务器降级为辅助服务器:

> rs.stepDown(600)

通过使用上述参数,我们可以防止我们的辅助节点在 10 分钟内被选为主节点。这应该足够的时间来关闭服务器并继续进行维护,就像我们对辅助节点所做的那样。

重新同步副本集的成员

辅助节点通过重放 oplog 的内容与主节点同步。如果我们的 oplog 不够大,或者如果我们遇到网络问题(分区、网络性能不佳,或者辅助服务器的故障)的时间超过 oplog,那么 MongoDB 将无法使用 oplog 来赶上主节点。

在这一点上,我们有两个选择:

  • 更直接的选择是删除我们的dbpath目录并重新启动mongod进程。在这种情况下,MongoDB 将从头开始进行初始同步。这种选择的缺点是对我们的副本集和网络造成压力。

  • 更复杂(从操作角度)的选项是从副本集的另一个表现良好的成员复制数据文件。这回到了第八章的内容,监控、备份和安全性。要记住的重要事情是,简单的文件复制可能不够,因为数据文件在我们开始复制到复制结束的时间内已经发生了变化。

因此,我们需要能够在我们的data目录下拍摄文件系统的快照副本。

另一个需要考虑的问题是,当我们使用新复制的文件启动次要服务器时,我们的 MongoDB 次要服务器将尝试再次使用 oplog 与主服务器同步。因此,如果我们的 oplog 已经落后于主服务器,以至于它无法在主服务器上找到条目,这种方法也会失败。

保持足够大小的 oplog。不要让任何副本集成员的数据失控。尽早设计、测试和部署分片。

更改 oplog 的大小

与前面的操作提示相辅相成,随着数据的增长,我们可能需要重新考虑和调整 oplog 的大小。随着数据的增长,操作变得更加复杂和耗时,我们需要调整 oplog 的大小来适应。更改 oplog 大小的步骤如下:

  1. 第一步是将我们的 MongoDB 次要服务器重新启动为独立服务器,这是在如何对副本集执行维护部分中描述的操作。

  2. 然后我们备份我们现有的 oplog:

> mongodump --db local --collection 'oplog.rs' --port 37017
  1. 我们保留这些数据的副本,以防万一。然后我们连接到我们的独立数据库:
> use local
> db = db.getSiblingDB('local')
> db.temp.drop()

到目前为止,我们已连接到local数据库并删除了temp集合,以防它有任何剩余文档。

  1. 下一步是获取我们当前 oplog 的最后一个条目,并将其保存在temp集合中:
> db.temp.save( db.oplog.rs.find( { }, { ts: 1, h: 1 } ).sort( {$natural : -1} ).limit(1).next() )
  1. 当我们重新启动次要服务器时,将使用此条目,以跟踪它在 oplog 复制中的进度:
> db = db.getSiblingDB('local')
> db.oplog.rs.drop()
  1. 现在,我们删除我们现有的 oplog,在下一步中,我们将创建一个大小为4GB 的新 oplog:
> db.runCommand( { create: "oplog.rs", capped: true, size: (4 * 1024 * 1024 * 1024) } )
  1. 下一步是将我们的temp集合中的一个条目复制回我们的 oplog:
> db.oplog.rs.save( db.temp.findOne() )
  1. 最后,我们从admin数据库中干净地关闭服务器,使用db.shutdownServer()命令,然后将我们的次要服务器重新启动为副本集的成员。

  2. 我们对所有次要服务器重复此过程,最后一步是对我们的主要成员重复该过程,这是在使用以下命令将主服务器降级之后完成的:

> rs.stepDown(600)

在我们失去大多数服务器时重新配置副本集

这只是一个临时解决方案,也是在面临停机和集群操作中断时的最后手段。当我们失去大多数服务器,但仍有足够的服务器可以启动一个副本集(可能包括一些快速生成的仲裁者)时,我们可以强制只使用幸存成员进行重新配置。

首先,我们获取副本集配置文档:

> cfg = rs.conf()

使用printjson(cfg),我们确定仍在运行的成员。假设这些成员是123

> cfg.members = [cfg.members[1] , cfg.members[2] , cfg.members[3]]
> rs.reconfig(cfg, {force : true})

通过使用force:true,我们强制进行此重新配置。当然,我们需要至少有三个幸存成员在我们的副本集中才能使其工作。

尽快删除故障服务器非常重要,方法是终止进程和/或将它们从网络中移除,以避免意外后果;这些服务器可能认为它们仍然是集群的一部分,而集群已不再承认它们。

链式复制

在 MongoDB 中,复制通常发生在主服务器和次要服务器之间。在某些情况下,我们可能希望从另一个次要服务器复制,而不是从主服务器复制。链式复制有助于减轻主服务器的读取负载,但与此同时,它会增加选择从次要服务器复制的次要服务器的平均复制延迟。这是有道理的,因为复制必须从主服务器到次要服务器(1),然后从这台服务器到另一个次要服务器(2)。

可以使用以下cfg命令启用(或分别禁用)链式复制:

> cfg.settings.chainingAllowed = true

printjson(cfg)不显示设置子文档的情况下,我们需要首先创建一个空文档:

> cfg.settings = { }

如果已经存在一个settings文档,上述命令将导致删除其设置,可能导致数据丢失。

副本集的云选项

我们可以从我们自己的服务器上设置和操作副本集,但是我们可以通过使用数据库即服务DBaaS)提供商来减少我们的运营开销。最广泛使用的两个 MongoDB 云提供商是 mLab(以前是 MongoLab)和 MongoDB Atlas,后者是 MongoDB, Inc.的原生产品。

在本节中,我们将讨论这些选项以及它们与使用我们自己的硬件和数据中心相比的优劣。

mLab

mLab 是 MongoDB 最受欢迎的云 DBaaS 提供商之一。自 2011 年以来一直提供,并被认为是一个稳定和成熟的提供商。

注册后,我们可以在一组云服务器上轻松部署副本集群,而无需任何运营开销。配置选项包括 AWS、Microsoft Azure 或 Google Cloud 作为基础服务器提供商。

最新的 MongoDB 版本有多个大小选项。在撰写本书时,MMAPv1 存储引擎没有支持。每个提供商都有多个地区(美国、欧洲和亚洲)。值得注意的是,缺少的地区是 AWS 中国、AWS 美国政府和 AWS 德国地区。

MongoDB Atlas

MongoDB Atlas 是 MongoDB, Inc.的一个较新的产品,于 2016 年夏季推出。与 mLab 类似,它通过 Web 界面提供单服务器、副本集或分片集群的部署。

它提供了最新的 MongoDB 版本。唯一的存储选项是 WiredTiger。每个提供商都有多个地区(美国、欧洲和亚洲)。

值得注意的是,缺少的地区是 AWS 中国和 AWS 美国政府地区。

在这两个(以及大多数其他)提供商中,我们无法拥有跨区域的副本集。如果我们想要部署一个真正全球的服务,为来自全球多个数据中心的用户提供服务,并且希望我们的 MongoDB 服务器尽可能靠近应用服务器,这是不利的。

云托管服务的运行成本可能会比在我们自己的服务器上设置要高得多。我们在便利性和上市时间上所获得的可能需要以运营成本来支付。

副本集的限制

当我们了解为什么需要副本集以及它不能做什么时,副本集就非常好。副本集的不同限制如下:

  • 它不会进行水平扩展;我们需要分片来实现。

  • 如果我们的网络不稳定,我们将引入复制问题。

  • 如果我们使用辅助服务器进行读取,那么调试问题将变得更加复杂,而且这些辅助服务器已经落后于我们的主服务器。

另一方面,正如我们在本章的前几节中所解释的,副本集对于复制、数据冗余、符合数据隐私、备份甚至从人为错误或其他原因引起的错误中恢复来说都是一个很好的选择。

总结

在本章中,我们讨论了副本集以及如何对其进行管理。从副本集的架构概述和涉及选举的副本集内部开始,我们深入到了设置和配置副本集。

您学会了如何使用副本集执行各种管理任务,并了解了将操作外包给云 DBaaS 提供商的主要选项。最后,我们确定了 MongoDB 目前副本集存在的一些限制。

在下一章中,我们将继续讨论 MongoDB 中最有趣的概念之一(帮助其实现水平扩展的概念):分片。

第十三章:分片

分片是通过将数据集分区到不同服务器(分片)上来横向扩展我们的数据库的能力。这是 MongoDB 自 2010 年 8 月发布 1.6 版本以来的一个特性。Foursquare 和 Bitly 是 MongoDB 最著名的早期客户之一,从其推出一直到其正式发布都使用了分片功能。

在本章中,我们将学习以下主题:

  • 如何设计分片集群以及如何做出关于其使用的最重要决定——选择分片键

  • 不同的分片技术以及如何监视和管理分片集群

  • mongos路由器及其用于在不同分片之间路由我们的查询的方式

  • 我们如何从分片中恢复错误

为什么要使用分片?

在数据库系统和计算系统中,我们有两种方法来提高性能。第一种方法是简单地用更强大的服务器替换我们的服务器,保持相同的网络拓扑和系统架构。这被称为垂直扩展

垂直扩展的优点是从操作的角度来看很简单,特别是像亚马逊这样的云服务提供商只需点击几下就可以用m2.extralarge服务器实例替换m2.medium。另一个优点是我们不需要进行任何代码更改,因此几乎没有什么东西会出现灾难性的错误。

垂直扩展的主要缺点是存在限制;我们只能获得与云服务提供商提供给我们的服务器一样强大的服务器。

相关的缺点是获得更强大的服务器通常会带来成本的增加,这种增加不是线性的而是指数级的。因此,即使我们的云服务提供商提供更强大的实例,我们在部门信用卡的成本效益限制之前就会遇到成本效益的障碍。

提高性能的第二种方法是使用相同容量的相同服务器并增加它们的数量。这被称为水平扩展

水平扩展的优点在于理论上能够呈指数级扩展,同时对于现实世界的应用来说仍然足够实用。主要缺点是在操作上可能更加复杂,需要进行代码更改并在系统设计上进行仔细设计。水平扩展在系统方面也更加复杂,因为它需要在不太可靠的网络链接上的不同服务器之间进行通信,而不是在单个服务器上进行进程间通信。以下图表显示了水平和垂直扩展之间的区别:

要理解扩展,重要的是要了解单服务器系统的限制。服务器通常受以下一个或多个特征的限制:

  • CPU:CPU 受限系统是受 CPU 速度限制的系统。例如,可以放入 RAM 的矩阵相乘任务将受到 CPU 限制,因为 CPU 必须执行特定数量的步骤,而不需要进行任何磁盘或内存访问即可完成任务。在这种情况下,CPU 使用率是我们需要跟踪的指标。

  • I/O:输入输出受限系统同样受到存储系统(HDD 或 SSD)速度的限制。例如,从磁盘读取大文件加载到内存中的任务将受到 I/O 限制,因为在 CPU 处理方面几乎没有什么要做的;大部分时间都花在从磁盘读取文件上。需要跟踪的重要指标是与磁盘访问相关的所有指标,每秒读取次数和每秒写入次数,与我们存储系统的实际限制相比。

  • 内存和缓存:受内存限制和缓存限制的系统受到可用 RAM 内存和/或我们分配给它们的缓存大小的限制。一个将矩阵乘以大于我们 RAM 大小的任务将受到内存限制,因为它将需要从磁盘中分页数据来执行乘法。要跟踪的重要指标是已使用的内存。在 MongoDB MMAPv1 中,这可能会产生误导,因为存储引擎将通过文件系统缓存分配尽可能多的内存。

另一方面,在 WiredTiger 存储引擎中,如果我们没有为核心 MongoDB 进程分配足够的内存,内存不足的错误可能会导致其崩溃,这是我们要尽一切努力避免的。

监控内存使用量必须通过操作系统直接进行,并间接地通过跟踪分页数据来进行。增加的内存分页数通常表明我们的内存不足,操作系统正在使用虚拟地址空间来跟上。

作为数据库系统的 MongoDB 通常受到内存和 I/O 的限制。为我们的节点投资 SSD 和更多内存几乎总是一个不错的投资。大多数系统是前述限制的一个或多个组合。一旦我们增加了更多内存,我们的系统可能会变得 CPU 受限,因为复杂的操作几乎总是 CPU、I/O 和内存使用的组合。

MongoDB 的分片设置和操作非常简单,这也是它多年来取得巨大成功的原因,因为它提供了横向扩展的优势,而不需要大量的工程和运营资源。

也就是说,从一开始就正确地进行分片非常重要,因为一旦设置好了,从操作的角度来看,要更改配置是非常困难的。分片不应该是一个事后想法,而应该是设计过程早期的一个关键架构设计决策。

架构概述

一个分片集群由以下元素组成:

  • 两个或更多分片。每个分片必须是一个副本集。

  • 一个或多个查询路由器(mongos)。mongos提供了我们的应用程序和数据库之间的接口。

  • 一个副本集的配置服务器。配置服务器存储整个集群的元数据和配置设置。

这些元素之间的关系如下图所示:

从 MongoDB 3.6 开始,分片必须实现为副本集。

开发、持续部署和暂存环境

在预生产环境中,使用完整服务器集可能是过度的。出于效率原因,我们可能选择使用更简化的架构。

我们可以为分片部署的最简单配置如下:

  • 一个mongos路由器

  • 一个分片副本集,有一个 MongoDB 服务器和两个仲裁者

  • 一个配置服务器的副本集,有一个 MongoDB 服务器和两个仲裁者

这应严格用于开发和测试,因为这种架构违背了副本集提供的大多数优势,如高可用性、可扩展性和数据复制。

强烈建议在暂存环境中镜像我们的生产环境,包括服务器、配置和(如果可能)数据集要求,以避免在部署时出现意外。

提前计划分片

正如我们将在接下来的部分中看到的,分片在操作上是复杂且昂贵的。重要的是要提前计划,并确保我们在达到系统极限之前很久就开始分片过程。

一些关于何时需要开始分片的大致指导原则如下:

  • 当平均 CPU 利用率低于 70%时

  • 当 I/O(尤其是写入)容量低于 80%时

  • 当平均内存利用率低于 70%时

由于分片有助于写入性能,重要的是要关注我们的 I/O 写入容量和应用程序的要求。

不要等到最后一刻才开始在已经忙碌到极致的 MongoDB 系统中进行分片,因为这可能会产生意想不到的后果。

分片设置

分片是在集合级别执行的。我们可以有一些我们不想或不需要分片的集合,有几个原因。我们可以将这些集合保持为未分片状态。

这些集合将存储在主分片中。在 MongoDB 中,每个数据库的主分片都不同。在分片环境中创建新数据库时,MongoDB 会自动选择主分片。MongoDB 将选择在创建时存储数据最少的分片。

如果我们想在任何其他时间更改主分片,我们可以发出以下命令:

> db.runCommand( { movePrimary : "mongo_books", to : "UK_based" } )

有了这个,我们将名为mongo_books的数据库移动到名为UK_based的分片中。

选择分片键

选择我们的分片键是我们需要做出的最重要的决定:一旦我们分片我们的数据并部署我们的集群,更改分片键就变得非常困难。首先,我们将经历更改分片键的过程。

更改分片键

在 MongoDB 中,没有命令或简单的程序可以更改分片键。更改分片键的唯一方法涉及备份和恢复所有数据,这在高负载生产环境中可能从极其困难到不可能。

以下是我们需要经历的步骤,以更改分片键:

  1. 从 MongoDB 导出所有数据

  2. 删除原始的分片集合

  3. 使用新键配置分片

  4. 预先拆分新的分片键范围

  5. 将我们的数据恢复到 MongoDB 中

在这些步骤中,步骤 4 是需要进一步解释的步骤。

MongoDB 使用块来分割分片集合中的数据。如果我们从头开始引导 MongoDB 分片集群,MongoDB 将自动计算块。然后,MongoDB 将这些块分布到不同的分片上,以确保每个分片中有相等数量的块。

唯一不能真正做到这一点的时候是当我们想要将数据加载到新的分片集合中。

这样做的原因有三个:

  • MongoDB 仅在insert操作后创建拆分。

  • 块迁移将从一个分片复制该块中的所有数据到另一个分片。

  • floor(n/2)块迁移可以在任何时间发生,其中n是我们拥有的分片数量。即使有三个分片,这也只是一次floor(1.5)=1块迁移。

这三个限制意味着让 MongoDB 自行解决这个问题肯定会花费更长时间,并且可能最终导致失败。这就是为什么我们希望预先拆分我们的数据,并为 MongoDB 提供一些关于我们的块应该放在哪里的指导。

在我们的示例中,mongo_books数据库和books集合如下:

> db.runCommand( { split : "mongo_books.books", middle : { id : 50 } } )

middle命令参数将在我们的键空间中拆分文档,这些文档的id小于或等于50,以及id大于50的文档。我们的集合中没有必要存在id等于50的文档,因为这只会作为我们分区的指导值。

在这个例子中,我们选择了50,因为我们假设我们的键在值范围从0100中遵循均匀分布(即,每个值的键数量相同)。

我们应该努力创建至少 20-30 个块,以赋予 MongoDB 在潜在迁移中的灵活性。如果我们想手动定义分区键,我们也可以使用boundsfind而不是middle,但是这两个参数在应用它们之前需要数据存在于我们的集合中。

选择正确的分片键

在前面的部分之后,现在很明显我们需要考虑我们的分片键的选择,因为这是一个我们必须坚持的决定。

一个很好的分片键具有三个特点:

  • 高基数

  • 低频率

  • 值的非单调变化

我们将首先介绍这三个属性的定义,以了解它们的含义:

  • 高基数:这意味着分片键必须具有尽可能多的不同值。布尔值只能取true/false,因此不是一个好的分片键选择。一个可以取从−(2⁶³)2⁶³−1的任何值的 64 位长值字段在基数方面是一个好的选择。

  • 低频率:它直接关系到高基数的论点。低频率的分片键将具有接近完全随机/均匀分布的值分布。以我们 64 位长值的例子,如果我们一直观察到零和一这样的值,那么它对我们几乎没有用处。事实上,这和使用布尔字段一样糟糕,因为布尔字段也只能取两个值。如果我们有一个高频率值的分片键,我们最终会得到不可分割的块。这些块无法进一步分割,并且会增长,负面影响包含它们的分片的性能。

  • 非单调变化的值:这意味着我们的分片键不应该是一个每次新插入都增加的整数,例如。如果我们选择一个单调递增的值作为我们的分片键,这将导致所有写入最终都进入我们所有分片中的最后一个,从而限制我们的写入性能。

如果我们想要使用单调变化的值作为分片键,我们应该考虑使用基于哈希的分片。

在下一节中,我们将描述不同的分片策略,包括它们的优点和缺点。

基于范围的分片

默认和最广泛使用的分片策略是基于范围的分片。这种策略将把我们集合的数据分成块,将具有相邻值的文档分组到同一个分片中。

对于我们的示例数据库和集合,分别是mongo_booksbooks,我们有以下内容:

> sh.shardCollection("mongo_books.books", { id: 1 } )

这将在id上创建一个基于范围的分片键,并且是升序的。我们的分片键的方向将决定哪些文档将最终出现在第一个分片中,哪些文档出现在随后的分片中。

如果我们计划进行基于范围的查询,这是一个很好的策略,因为这些查询将被定向到保存结果集的分片,而不必查询所有分片。

基于哈希的分片

如果我们没有一个达到前面提到的三个目标的分片键(或者无法创建一个),我们可以使用替代策略,即使用基于哈希的分片。在这种情况下,我们正在用数据分布来交换查询隔离。

基于哈希的分片将获取我们的分片键的值,并以一种接近均匀分布的方式进行哈希。这样,我们可以确保我们的数据将均匀分布在分片中。缺点是只有精确匹配查询将被路由到持有该值的确切分片。任何范围查询都必须从所有分片中获取数据。

对于我们的示例数据库和集合(分别是mongo_booksbooks),我们有以下内容:

> sh.shardCollection("mongo_books.books", { id: "hashed" } )

与前面的示例类似,我们现在将id字段作为我们的哈希分片键。

假设我们使用浮点值字段进行基于哈希的分片。如果我们的浮点数的精度超过2⁵³,那么我们将会遇到碰撞。在可能的情况下,应该避免使用这些字段。

提出我们自己的键

基于范围的分片不需要局限于单个键。事实上,在大多数情况下,我们希望结合多个键来实现高基数和低频率。

一个常见的模式是将低基数的第一部分(但仍具有两倍于我们拥有的分片数量的不同值的数量)与高基数键作为其第二字段组合。这既从分片键的第一部分实现了读取和写入分布,又从第二部分实现了基数和读取局部性。

另一方面,如果我们没有范围查询,那么我们可以在主键上使用基于哈希的分片,因为这将精确地定位我们要查找的分片和文档。

使事情变得更加复杂的是,这些考虑因我们的工作负载而改变。几乎完全由读取(比如 99.5%)组成的工作负载不会关心写入分布。我们可以使用内置的_id字段作为我们的分片键,这只会给最后一个分片增加 0.5%的负载。我们的读取仍然会分布在各个分片上。不幸的是,在大多数情况下,情况并不简单。

基于位置的数据

由于政府法规和希望将数据尽可能靠近用户,通常存在对特定数据中心中的数据进行限制和需要的约束。通过将不同的分片放置在不同的数据中心,我们可以满足这一要求。

每个分片本质上都是一个副本集。我们可以像连接副本集一样连接到它进行管理和维护操作。我们可以直接查询一个分片的数据,但结果只会是完整分片结果集的子集。

分片管理和监控

与单服务器或副本集部署相比,分片的 MongoDB 环境具有一些独特的挑战和限制。在本节中,我们将探讨 MongoDB 如何使用 chunks 平衡我们的数据跨分片,并在需要时如何调整它们。我们将一起探讨一些分片设计的限制。

平衡数据-如何跟踪和保持我们的数据平衡

在 MongoDB 中分片的一个优点是,它对应用程序基本上是透明的,并且需要最少的管理和运营工作。

MongoDB 需要不断执行的核心任务之一是在分片之间平衡数据。无论我们实现基于范围还是基于哈希的分片,MongoDB 都需要计算哈希字段的边界,以便确定将每个新文档插入或更新到哪个分片。随着数据的增长,这些边界可能需要重新调整,以避免出现大部分数据都集中在一个热分片上的情况。

为了举例说明,假设有一种名为extra_tiny_int的数据类型,其整数值范围为-12, 12)。如果我们在这个extra_tiny_int字段上启用分片,那么我们数据的初始边界将是由$minKey: -12$maxKey: 11表示的整个值范围。

在我们插入一些初始数据后,MongoDB 将生成 chunks 并重新计算每个 chunk 的边界,以尝试平衡我们的数据。

默认情况下,MongoDB 创建的初始 chunk 数量是2 × 分片数量

在我们的情况下,有两个分片和四个初始 chunk,初始边界将如下计算:

Chunk1: [-12..-6)

Chunk2:  [-6..0)

Chunk3:  [0..6)

Chunk4:  [6,12)其中'['是包含的,')'是不包含的

以下图表说明了前面的解释:

![在我们插入一些数据后,我们的 chunks 将如下所示:+ ShardA:+ Chunk1😗 -12,-8,-7+ Chunk2:  -6+ ShardB:+ Chunk3: 0, 2      *+ Chunk4: 7,8,9,10,11,11,11,11以下图表说明了前面的解释:

在这种情况下,我们观察到 chunk4的项比任何其他 chunk 都多。MongoDB 将首先将chunk4分成两个新的 chunk,试图保持每个 chunk 的大小在一定的阈值以下(默认为 64 MB)。

现在,我们有chunk4A7,8,9,10chunk4B11,11,11,11,而不是 chunk4

以下图表说明了先前的解释:

其新边界如下:

  • chunk4A: 6,11)

  • chunk4B: [11,12)

请注意,chunk4B只能容纳一个值。这现在是一个不可分割的分片,无法再分割成更小的分片,并且将无限增长,可能会导致性能问题。

这解释了为什么我们需要使用高基数字段作为我们的分片键,以及为什么像布尔值这样只有true/false值的字段是分片键的不良选择。

在我们的情况下,ShardA现在有两个分片,ShardB有三个分片。让我们看看下表:

分片数量 迁移阈值
≤19 2
20-79 4
≥80 8

我们还没有达到迁移阈值,因为3-2 = 1

迁移阈值是根据拥有最多分片的分片和拥有最少分片的分片数量计算得出的,如下所示:

  • Shard1 -> 85 chunks

  • Shard2 -> 86 chunks

  • Shard3 -> 92 chunks

在上面的例子中,直到Shard3(或Shard2)达到93个分片之前,平衡都不会发生,因为迁移阈值对于≥80个分片是8,而Shard1Shard3之间的差距仍然是7个分片(92-85)。

如果我们继续在chunk4A中添加数据,它最终将被分割成chunk4A1chunk4A2

现在ShardB有四个分片(chunk3chunk4A1chunk4A2chunk4B),ShardA有两个分片(chunk1chunk2)。

以下图表说明了分片与分片之间的关系:

![MongoDB 平衡器现在将从ShardB迁移一个分片到ShardA,因为4-2 = 2,达到了少于20个分片的迁移阈值。平衡器将调整两个分片之间的边界,以便能够更有效地查询(有针对性的查询)。以下图表说明了先前的解释:

从上图表中可以看出,MongoDB 将尝试将>64 MB 的分片一分为二。如果我们的数据分布不均匀,那么两个结果分片之间的边界可能会完全不均匀。MongoDB 可以将分片分割成更小的分片,但不能自动合并它们。我们需要手动合并分片,这是一个复杂且操作成本高昂的过程。

分片管理

大多数情况下,我们应该让 MongoDB 来管理分片。我们应该在开始时手动管理分片,在接收到初始数据负载时,当我们将配置从副本集更改为分片时。

移动分片

要手动移动一个分片,我们需要在连接到mongosadmin数据库后发出以下命令:

> db.runCommand( { moveChunk : 'mongo_books.books' ,
 find : {id: 50},
 to : 'shard1.packtdb.com' } )

使用上述命令,我们将包含id: 50的文档(这必须是分片键)从mongo_books数据库的books集合移动到名为shard1.packtdb.com的新分片。

我们还可以更明确地定义我们要移动的分片的边界。现在的语法如下:

> db.runCommand( { moveChunk : 'mongo_books.books' ,
 bounds :[ { id : <minValue> } ,
 { id : <maxValue> } ],
 to : 'shard1.packtdb.com' } )

在这里,minValuemaxValue是我们从db.printShardingStatus()中获取的值。

在先前的示例中,对于chunk2minValue将是-6maxValue将是0

在基于哈希的分片中不要使用find。使用bounds代替。

更改默认的分片大小

要更改默认的分片大小,我们需要连接到mongos路由器,因此连接到config数据库。

然后我们发出以下命令将我们的全局chunksize更改为16 MB:

> db.settings.save( { _id:"chunksize", value: 16 } )

更改chunksize的主要原因来自于默认的 64 MB chunksize可能会导致比我们的硬件处理能力更多的 I/O。在这种情况下,定义较小的chunksize将导致更频繁但数据密度较小的迁移。

更改默认块大小有以下缺点:

  • 通过定义较小的块大小创建更多的拆分无法自动撤消。

  • 增加块大小不会强制进行任何块迁移;相反,块将通过插入和更新而增长,直到达到新的大小。

  • 降低块大小可能需要相当长的时间才能完成。

  • 如果较低的块大小需要遵守新的块大小,那么只有在插入或更新时才会自动拆分。我们可能有一些块不会进行任何写操作,因此大小不会改变。

块大小可以为 1 到 1024 MB。

巨型块

在罕见情况下,我们可能会遇到巨型块,即大于块大小且无法由 MongoDB 拆分的块。如果我们的块中的文档数量超过最大文档限制,也可能遇到相同的情况。

这些块将启用jumbo标志。理想情况下,MongoDB 将跟踪它是否可以拆分块,并且一旦可以,它将被拆分;但是,我们可能决定在 MongoDB 之前手动触发拆分。

这样做的方法如下:

  1. 通过 shell 连接到您的mongos路由器并运行以下命令:
> sh.status(true)
  1. 使用以下代码标识具有jumbo的块:
databases:
…
mongo_books.books
...
chunks:
…
 shardB  2
 shardA  2
 { "id" : 7 } -->> { "id" : 9 } on : shardA Timestamp(2, 2) jumbo
  1. 调用splitAt()splitFind()手动在mongo_books数据库的books集合上拆分id等于8的块,使用以下代码:
> sh.splitAt( "mongo_books.books", { id: 8 })

splitAt()函数将根据我们定义的拆分点进行拆分。两个新的拆分可能平衡也可能不平衡。

或者,如果我们想让 MongoDB 找到拆分块的位置,我们可以使用splitFind,如下所示:

> sh.splitFind("mongo_books.books", {id: 7})

splitFind短语将尝试找到id:7查询所属的块,并自动定义拆分块的新边界,使它们大致平衡。

在这两种情况下,MongoDB 将尝试拆分块,如果成功,它将从中删除jumbo标志。

  1. 如果前面的操作不成功,那么只有在这种情况下,我们应该首先尝试停止平衡器,同时验证输出并等待任何待处理的迁移完成,如下所示:
> sh.stopBalancer()
> sh.getBalancerState() > use config
while( sh.isBalancerRunning() ) {
 print("waiting...");
 sleep(1000);
} 

这应该返回false

  1. 等待任何waiting…消息停止打印,然后以与之前相同的方式找到带有jumbo标志的块。

  2. 然后在mongos路由器的config数据库中更新chunks集合,如下所示:

> db.getSiblingDB("config").chunks.update(
 { ns: "mongo_books.books", min: { id: 7 }, jumbo: true },
 { $unset: { jumbo: "" } }
)

前面的命令是一个常规的update()命令,第一个参数是find()部分,用于查找要更新的文档,第二个参数是要应用于它的操作($unset: jumbo flag)。

  1. 完成所有这些操作后,我们重新启用平衡器,如下所示:
> sh.setBalancerState(true)
  1. 然后,我们连接到admin数据库,将新配置刷新到所有节点,如下所示:
> db.adminCommand({ flushRouterConfig: 1 } )

在手动修改任何状态之前,始终备份config数据库。

合并块

正如我们之前所看到的,通常情况下,MongoDB 将调整每个分片的块边界,以确保我们的数据均匀分布。在某些情况下,这可能不起作用,特别是当我们手动定义块时,如果我们的数据分布出奇地不平衡,或者我们的分片中有许多delete操作。

拥有空块将引发不必要的块迁移,并使 MongoDB 对需要迁移的块产生错误印象。正如我们之前解释的那样,块迁移的阈值取决于每个分片持有的块的数量。拥有空块可能会触发平衡器,也可能不会在需要时触发平衡器。

只有当至少有一个块为空时,块合并才会发生,并且只会发生在相邻块之间。

要找到空块,我们需要连接到要检查的数据库(在我们的情况下是mongo_books),并使用runCommand,设置dataSize如下:

> use mongo_books
> db.runCommand({
 "dataSize": "mongo_books.books",
 "keyPattern": { id: 1 },
 "min": { "id": -6 },
 "max": { "id": 0 }
})

dataSize短语遵循database_name.collection_name模式,而keyPattern是我们为这个集合定义的分片键。

minmax值应该由我们在这个集合中拥有的数据块计算得出。在我们的情况下,我们已经在本章前面的示例中输入了chunkB的详细信息。

如果我们的查询边界(在我们的情况下是chunkB的边界)没有返回任何文档,结果将类似于以下内容:

{ "size" : 0, "numObjects" : 0, "millis" : 0, "ok" : 1 }

现在我们知道chunkB没有数据,我们可以像这样将它与另一个数据块(在我们的情况下,只能是chunkA)合并:

> db.runCommand( { mergeChunks: "mongo_books.books",
 bounds: [ { "id": -12 },
 { id: 0 } ]
 } )

成功后,这将返回 MongoDB 的默认ok状态消息,如下所示:

{ "ok" : 1 }

然后,我们可以通过再次调用sh.status()来验证ShardA上只有一个数据块。

添加和移除分片

向我们的集群添加一个新的分片就像连接到mongos,连接到admin数据库,并使用以下命令调用runCommand一样简单:

> db.runCommand( {
addShard: "mongo_books_replica_set/rs01.packtdb.com:27017", maxSize: 18000, name: "packt_mongo_shard_UK"
} )

这将从rs01.packtdb.com主机的端口27017上运行的mongo_books_replica_set复制集中添加一个新的分片。我们还将为这个分片定义数据的maxSize18000 MB(或者我们可以将其设置为0以不设限),新分片的名称为packt_mongo_shard_UK

这个操作将需要相当长的时间来完成,因为数据块将需要重新平衡和迁移到新的分片。

另一方面,移除一个分片需要更多的参与,因为我们必须确保在这个过程中不会丢失任何数据。我们按照以下步骤进行:

  1. 首先,我们需要确保负载均衡器已启用,使用sh.getBalancerState()。然后,在使用sh.status()db.printShardingStatus()listShards admin命令中任何一个来识别我们想要移除的分片后,我们连接到admin数据库并按以下方式调用removeShard
> use admin
> db.runCommand( { removeShard: "packt_mongo_shard_UK" } )

输出应该包含以下内容:

...
 "msg" : "draining started successfully",
 "state" : "started",
...
  1. 然后,如果我们再次调用相同的命令,我们会得到以下结果:
> db.runCommand( { removeShard: "packt_mongo_shard_UK" } )
…
"msg" : "draining ongoing",
 "state" : "ongoing",
 "remaining" : {
 "chunks" : NumberLong(2),
 "dbs" : NumberLong(3)
 },
…

结果中剩下的文档包含仍在传输的chunksdbs的数量。在我们的情况下,分别是23

所有命令都需要在admin数据库中执行。

移除分片时可能会出现额外的复杂情况,如果我们要移除的分片作为它包含的一个或多个数据库的主分片。主分片是在我们启用分片时由 MongoDB 分配的,因此当我们移除分片时,我们需要手动将这些数据库移动到一个新的分片。

  1. 我们可以通过查看removeShard()结果中的以下部分来确定是否需要执行此操作:
...
"note" : "you need to drop or movePrimary these databases",
 "dbsToMove" : [
 "mongo_books"
 ],
...

我们需要删除或movePrimary我们的mongo_books数据库。首先要确保我们连接到admin数据库。

在运行此命令之前,我们需要等待所有数据块完成迁移。

  1. 在继续之前,请确保结果包含以下内容:
 ..."remaining" : {
 "chunks" : NumberLong(0) }...
  1. 只有在我们确保要移动的数据块已经减少到零后,我们才能安全地运行以下命令:
> db.runCommand( { movePrimary: "mongo_books", to: "packt_mongo_shard_EU" })
  1. 这个命令将调用一个阻塞操作,当它返回时,应该有以下结果:
{ "primary" : "packt_mongo_shard_EU", "ok" : 1 }
  1. 在我们完成所有操作后再次调用相同的removeShard()命令应该返回以下结果:
> db.runCommand( { removeShard: "packt_mongo_shard_UK" } )

... "msg" : "removeshard completed successfully",
 "state" : "completed",
 "shard" : "packt_mongo_shard_UK"
 "ok" : 1
...
  1. 一旦statecompletedok1,就可以安全地移除我们的packt_mongo_shard_UK分片。

移除分片自然比添加分片更复杂。在对我们的实时集群执行潜在破坏性操作时,我们需要留出一些时间,希望一切顺利,并为最坏的情况做好准备。

分片限制

分片提供了很大的灵活性。不幸的是,在执行一些操作的方式上存在一些限制。

我们将在以下列表中突出显示最重要的部分:

  • group()数据库命令不起作用。无论如何都不应该使用group()命令;而是使用aggregate()和聚合框架,或者mapreduce()

  • db.eval()命令不起作用,出于安全原因,在大多数情况下应禁用。

  • 更新的$isolated选项不起作用。这是分片环境中缺少的功能。update()$isolated选项提供了保证,即如果我们一次更新多个文档,其他读者和写入者将不会看到一些文档被更新为新值,而其他文档仍将保留旧值。在非分片环境中实现这一点的方式是通过保持全局写锁和/或将操作序列化到单个线程,以确保update()受影响的文档的每个请求不会被其他线程/操作访问。这种实现意味着它不具备性能,并且不支持任何并发,这使得在分片环境中允许$isolated运算符成本过高。

  • 不支持查询的$snapshot运算符。find()游标中的$snapshot运算符防止文档在更新后由于移动到磁盘上的不同位置而出现多次在结果中。$snapshot运算符在操作上是昂贵的,通常不是硬性要求。替代它的方法是在查询中使用一个字段的索引,这个字段的键在查询期间不会改变。

  • 如果我们的查询不包含分片键,索引将无法覆盖我们的查询。在分片环境中,结果将来自磁盘,而不仅仅来自索引。唯一的例外是如果我们仅在内置的_id字段上进行查询,并且仅返回_id字段,那么 MongoDB 仍然可以使用内置索引来覆盖查询。

  • update()remove()操作的工作方式不同。在分片环境中,所有update()remove()操作必须包括要受影响的文档的_id或分片键;否则,mongos路由器将不得不在所有集合、数据库和分片上进行全表扫描,这在操作上将非常昂贵。

  • 跨分片的唯一索引需要包含分片键作为索引的前缀。换句话说,为了实现跨分片的文档唯一性,我们需要遵循 MongoDB 为分片所遵循的数据分布。

  • 分片键的大小必须达到 512 字节。分片键索引必须按照分片的关键字段以及可选的其他字段的升序排列,或者对其进行哈希索引。

文档中的分片键值也是不可变的。如果我们的User集合的分片键是email,那么在设置后我们就不能更新任何用户的email值。

查询分片数据

使用 MongoDB 分片查询我们的数据与单服务器部署或副本集不同。我们不是连接到单个服务器或副本集的主服务器,而是连接到决定要请求我们的数据的分片的mongos路由器。在本节中,我们将探讨查询路由器的操作方式,并使用 Ruby 来说明对开发人员来说这与副本集有多相似。

查询路由器

查询路由器,也称为mongos进程,充当我们 MongoDB 集群的接口和入口。应用程序连接到它,而不是连接到底层的分片和副本集;mongos执行查询,收集结果并将其传递给我们的应用程序。

mongos进程不持有任何持久状态,通常对系统资源要求较低。

mongos进程通常托管在与应用程序服务器相同的实例中。

它充当请求的代理。当查询进来时,mongos将检查并决定哪些分片需要执行查询,并在每个分片中建立一个游标。

查找

如果我们的查询包括分片键或分片键的前缀,mongos将执行有针对性的操作,只查询持有我们寻找的键的分片。

例如,在我们的User集合上使用{ _id,email,address }的复合分片键,我们可以使用以下任何查询进行有针对性的操作:

> db.User.find({_id: 1})
> db.User.find({_id: 1, email: 'alex@packt.com'})
> db.User.find({_id: 1, email: 'janluc@packt.com', address: 'Linwood Dunn'})

这些查询由前缀(与前两个情况相同)或完整的分片键组成。

另一方面,对{email,address}{address}的查询将无法定位正确的分片,导致广播操作。广播操作是不包括分片键或分片键前缀的任何操作,并且它们导致mongos查询每个分片并从中收集结果。它们也被称为分散和聚集操作扇出查询

这种行为是索引组织方式的直接结果,并且类似于我们在索引章节中确定的行为。

排序/限制/跳过

如果我们想对结果进行排序,我们有以下两个选项:

  • 如果我们在排序标准中使用分片键,那么mongos可以确定查询分片的顺序。这将导致高效且再次是有针对性的操作。

  • 如果我们在排序标准中不使用分片键,那么与没有任何排序标准的查询一样,它将成为一个扇出查询。在不使用分片键时对结果进行排序时,主分片在将排序结果集传递给mongos之前在本地执行分布式合并排序。

对每个单独的分片强制执行查询的限制,然后再次在mongos级别进行,因为可能来自多个分片的结果。另一方面,skip操作符无法传递给各个分片,并且在本地检索所有结果后由mongos应用。

如果我们结合skiplimit操作符,mongos将通过将两个值传递给各个分片来优化查询。这在分页等情况下特别有用。如果我们在没有sort的情况下查询,而结果来自多个分片,mongos将在分片之间进行轮询以获取结果。

更新/删除

在文档修改操作中,例如updateremove,我们与find中看到的情况类似。如果我们在修改器的find部分中有分片键,那么mongos可以将查询定向到相关的分片。

如果我们在find部分没有分片键,那么它将再次成为一个扇出操作。

UpdateOnereplaceOneremoveOne操作必须具有分片键或_id值。

以下表总结了我们可以在分片中使用的操作:

操作类型 查询拓扑
插入 必须具有分片键
更新 可以具有分片键
具有分片键的查询 目标操作
没有分片键的查询 分散和聚集操作/扇出查询
具有分片键的索引、排序查询 目标操作
具有分片键的索引、排序查询而没有分片键 分布式排序合并

使用 Ruby 进行查询

使用 Ruby 连接到分片集群与连接到副本集没有区别。使用官方的 Ruby 驱动程序,我们必须配置client对象以定义一组mongos服务器,如下面的代码所示:

client = Mongo::Client.new('mongodb://key:password@mongos-server1-host:mongos-server1-port,mongos-server2-host:mongos-server2-port/admin?ssl=true&authSource=admin')

然后mongo-ruby-driver将返回一个client对象,这与从 Mongo Ruby 客户端连接到副本集没有区别。然后我们可以像在之前的章节中一样使用client对象,包括关于分片行为与独立服务器或具有查询和性能方面的副本集不同的所有注意事项。

与副本集的性能比较

开发人员和架构师总是在寻找比较副本集和分片配置性能的方法。

MongoDB 实现分片的方式是基于副本集。生产中的每个分片都应该是一个副本集。性能上的主要区别来自于扇出查询。当我们在没有分片键的情况下进行查询时,MongoDB 的执行时间受到最差表现的副本集的限制。此外,当使用没有分片键的排序时,主服务器必须在整个数据集上实现分布式归并排序。这意味着它必须收集来自不同分片的所有数据,对它们进行归并排序,并将它们作为排序后的数据传递给mongos。在这两种情况下,网络延迟和带宽限制可能会减慢操作的速度,而在副本集中则不会出现这种情况。

另一方面,通过拥有三个分片,我们可以将工作集需求分布在不同的节点上,从而从 RAM 中提供结果,而不是访问底层存储,HDD 或 SSD。

另一方面,写操作可以显著加快,因为我们不再受限于单个节点的 I/O 容量,而且我们可以在所有分片中进行写操作。总而言之,在大多数情况下,特别是在使用分片键的情况下,查询和修改操作都将因分片而显著加快。

分片键是分片中最重要的决定,并且应反映和应用于我们最常见的应用程序用例。

分片恢复

在本节中,我们将探讨不同的故障类型以及在分片环境中如何进行恢复。在分布式系统中,故障可能以多种形式出现。在本节中,我们将涵盖所有可能的情况,从像mongos这样的无状态组件失败到整个分片宕机的最简单情况。

mongos

mongos进程是一个相对轻量级的进程,不保存状态。如果该进程失败,我们只需重新启动它或在不同的服务器上启动一个新进程。建议将mongos进程放置在与我们的应用程序相同的服务器上,因此从我们的应用程序使用我们在应用程序服务器中共同放置的一组mongos服务器连接是有意义的,以确保mongos进程的高可用性。

mongod

在分片环境中,mongod进程失败与在副本集中失败没有区别。如果是一个 secondary,primary 和其他 secondary(假设是三节点副本集)将继续正常运行。

如果是一个mongod进程充当 primary,那么选举将开始选举该分片(实际上是一个副本集)中的新 primary。

在这两种情况下,我们应该积极监视并尽快修复节点,因为我们的可用性可能会受到影响。

配置服务器

从 MongoDB 3.4 开始,配置服务器也被配置为副本集。配置服务器的故障与常规的mongod进程故障没有区别。我们应该监视、记录和修复该进程。

一个分片宕机

失去整个分片是非常罕见的,在许多情况下可以归因于网络分区而不是失败的进程。当一个分片宕机时,所有会发送到该分片的操作都将失败。我们可以(而且应该)在应用程序级别实现容错,使我们的应用程序能够恢复完成的操作。

选择一个可以轻松映射到我们操作方面的分片键也可以帮助;例如,如果我们的分片键是基于位置的,我们可能会失去 EU 分片,但仍然能够通过我们的 US 分片写入和读取关于美国客户的数据。

整个集群宕机

如果我们失去整个集群,除了尽快恢复运行之外,我们无法做任何其他事情。重要的是要进行监视,并制定适当的流程,以了解如果发生这种情况,需要在何时以及由谁来完成。

当整个集群崩溃时,恢复基本上涉及从备份中恢复并设置新的分片,这很复杂并且需要时间。在测试环境中进行干测试也是明智的选择,另外,通过 MongoDB Ops Manager 或任何其他备份解决方案进行定期备份也是明智的选择。

为了灾难恢复目的,每个分片的副本集成员可能位于不同的位置。

进一步阅读

以下资源建议您深入学习分片:

摘要

在本章中,我们探讨了 MongoDB 最有趣的功能之一,即分片。我们从分片的架构概述开始,然后讨论了如何设计分片并选择正确的分片键。

我们学习了监控、管理和分片带来的限制。我们还学习了mongos,MongoDB 的分片路由器,它将我们的查询定向到正确的分片。最后,我们讨论了在 MongoDB 分片环境中从常见故障类型中恢复。

下一章关于容错和高可用性将提供一些有用的技巧和窍门,这些内容在其他 11 章中没有涉及。

第十四章:容错和高可用性

在本章中,我们将尝试整合我们在之前章节中没有讨论的信息,并且我们将强调一些其他主题。在之前的 13 章中,我们从基本概念一直到有效查询,到管理和数据管理,到扩展和高可用性概念都有所涉及。

在本章中,我们将涵盖以下主题:

  • 我们将讨论我们的应用程序设计应该如何适应和积极应对我们的数据库需求。

  • 我们还将讨论日常运营,包括可以帮助我们避免未来不愉快的惊喜的提示和最佳实践。

  • 鉴于勒索软件最近试图感染和挟持 MongoDB 服务器,我们将提供更多关于安全性的建议。

  • 最后,我们将尝试总结已经给出的一系列应该遵循以确保最佳实践得到适当设置和遵循的建议清单。

应用程序设计

在本节中,我们将描述一些应用设计的有用提示,这些提示在之前的章节中我们没有涵盖或强调足够。

无模式并不意味着无模式设计

MongoDB 成功的一个重要原因是其 ORM/ODM 的日益流行。特别是对于像 JavaScript 和 MEAN 堆栈这样的语言,开发人员可以从前端(Angular/Express)到后端(Node.js)再到数据库(MongoDB)使用 JavaScript。这经常与一个 ODM 结合使用,它将数据库的内部抽象出来,将集合映射到 Node.js 模型。

主要优点是开发人员不需要纠缠数据库模式设计,因为这是由 ODM 自动提供的。缺点是数据库集合和模式设计留给了 ODM,它没有不同领域和访问模式的业务领域知识。

在 MongoDB 和其他基于 NoSQL 的数据库的情况下,这归结为基于不仅是即时需求,还有未来需求的架构决策。在架构层面上,这可能意味着我们可以通过使用图数据库进行图相关查询,使用关系数据库进行分层、无限数据的查询,以及使用 MongoDB 进行 JSON 检索、处理和存储,而不是采用单块方法。

事实上,MongoDB 成功的许多用例来自于它并不被用作一刀切的解决方案,而只用于有意义的用例。

读取性能优化

在本节中,我们将讨论一些优化读取性能的提示。读取性能与查询数量及其复杂性直接相关。在没有复杂嵌套数据结构和数组的模式中执行较少的查询通常会导致更好的读取性能。然而,很多时候,为了优化读取性能可能意味着写入性能会下降。这是需要记住并在进行 MongoDB 性能优化时不断测量的事情。

整合读取查询

我们应该尽量减少查询。这可以通过将信息嵌入子文档中而不是拥有单独的实体来实现。这可能会导致写入负载增加,因为我们必须在多个文档中保留相同的数据点,并在一个地方更改时在所有地方维护它们的值。

这里的设计考虑如下:

  • 读取性能受益于数据复制/去规范化。

  • 数据完整性受益于数据引用(DBRef或在应用程序代码中,使用属性作为外键)。

我们应该去规范化,特别是如果我们的读/写比太高(我们的数据很少更改值,但在中间被多次访问),如果我们的数据可以承受短暂时间的不一致,最重要的是,如果我们绝对需要我们的读取尽可能快,并且愿意以一致性/写入性能为代价。

我们应该对需要去规范化(嵌入)的字段进行特别处理。如果我们有一个属性或文档结构,我们不打算单独查询它,而只作为包含属性/文档的一部分,那么将其嵌入而不是放在单独的文档/集合中是有意义的。

使用我们的 MongoDB books示例,一本书可以有一个相关的数据结构,指的是书的读者的评论。如果我们最常见的用例是显示一本书以及其相关的评论,那么我们可以将评论嵌入到书的文档中。

这种设计的缺点是,当我们想要找到用户的所有书评时,这将是昂贵的,因为我们将不得不迭代所有书籍以获取相关的评论。对用户进行去规范化并嵌入他们的评论可以解决这个问题。

反例是可以无限增长的数据。在我们的例子中,将评论与大量元数据一起嵌入可能会导致问题,如果我们达到了 16 MB 文档大小限制。解决方案是区分我们预期会快速增长的数据结构和那些不会快速增长的数据结构,并通过监控过程来关注它们的大小,这些监控过程在非高峰时间查询我们的实时数据集,并报告可能会在未来造成风险的属性。

不要嵌入可以无限增长的数据。

当我们嵌入属性时,我们必须决定是使用子文档还是封闭数组。

当我们有一个唯一标识符来访问子文档时,我们应该将其嵌入为子文档。如果我们不确定如何访问它,或者我们需要灵活性来查询属性的值,那么我们应该将其嵌入到数组中。

例如,对于我们的 books 集合,如果我们决定将评论嵌入到每个书籍文档中,我们有以下两种设计选项:

  • 带有数组的书籍文档:
{
Isbn: '1001',
Title: 'Mastering MongoDB',
Reviews: [
{ 'user_id': 1, text: 'great book', rating: 5 },
{ 'user_id': 2, text: 'not so bad book', rating: 3 },
]
}
  • 嵌入文档的书籍:
{
Isbn: '1001',
Title: 'Mastering MongoDB',
Reviews:
{ 'user_id': 1, text: 'great book', rating: 5 },
{ 'user_id': 2, text: 'not so bad book', rating: 3 },
}

数组结构具有优势,我们可以通过嵌入的数组 reviews 直接查询 MongoDB 中所有评分大于 4 的评论。

另一方面,使用嵌入文档结构,我们可以以与使用数组相同的方式检索所有评论,但如果我们想要对其进行过滤,则必须在应用程序端进行,而不是在数据库端进行。

防御性编码

更多的是一个通用原则,防御性编码是指一组确保软件在意外情况下继续功能的实践和软件设计。

它优先考虑代码质量、可读性和可预测性可读性是由 John F. Woods 在 1991 年 9 月 24 日的comp.lang.c++*帖子中最好地解释的:

“编码时要像最终维护您的代码的人是一个知道您住在哪里的暴力精神病患者一样编码。为了可读性而编码。”

我们的代码应该对人类可读和理解,也应该对机器可读。通过静态分析工具派生的代码质量指标、代码审查和报告/解决的错误,我们可以估计我们的代码库的质量,并在每个冲刺或准备发布时,以及在每个冲刺或准备发布时,都可以达到一定的阈值。另一方面,代码的可预测性意味着我们应该始终期望在意外输入和程序状态下获得结果。

这些原则适用于每个软件系统。在使用 MongoDB 进行系统编程的情况下,我们必须采取一些额外的步骤,以确保代码的可预测性,以及随后的质量由产生的错误数量来衡量。

应该定期监控并评估导致数据库功能丧失的 MongoDB 限制,如下所示:

  • 文档大小限制:我们应该密切关注我们预计文档增长最多的集合,运行后台脚本来检查文档大小,并在接近限制(16 MB)的文档或平均大小自上次检查以来显着增长时向我们发出警报。

  • 数据完整性检查:如果我们使用反规范化进行读取优化,那么检查数据完整性是一个很好的做法。通过软件错误或数据库错误,我们可能会在集合中得到不一致的重复数据。

  • 模式检查:如果我们不想使用 MongoDB 的文档验证功能,而是想要一个宽松的文档模式,定期运行脚本来识别文档中存在的字段及其频率仍然是一个好主意。然后,结合相对访问模式,我们可以确定这些字段是否可以被识别和合并。如果我们从另一个系统中摄取数据,其中数据输入随时间变化,这可能导致我们端上文档结构变化很大,这个检查就非常有用。

  • 数据存储检查:这主要适用于使用 MMAPv1 时,其中文档填充优化可以提高性能。通过关注文档大小相对于其填充的情况,我们可以确保我们的大小修改更新不会导致文档在物理存储中移动。

这些是我们在为 MongoDB 应用程序进行防御性编码时应该实施的基本检查。除此之外,我们还需要在应用程序级别的代码上进行防御性编码,以确保当 MongoDB 发生故障时,我们的应用程序将继续运行——可能会有性能下降,但仍然可以运行。

一个例子是副本集故障转移和故障恢复。当我们的副本集主服务器失败时,会有一个短暂的时间来检测这个故障,并选举、提升和运行新的主服务器。在这个短暂的时间内,我们应该确保我们的应用程序继续以只读模式运行,而不是抛出 500 错误。在大多数情况下,选举新的主服务器只需要几秒钟,但在某些情况下,我们可能会处于网络分区的少数端,并且长时间无法联系主服务器。同样,一些次要服务器可能会处于恢复状态(例如,如果它们在复制方面落后于主服务器);在这种情况下,我们的应用程序应该能够选择另一个次要服务器。

设计用于次要访问的是防御性编码中最有用的例子之一。我们的应用程序应该权衡只能由主服务器访问的字段,以确保数据一致性,以及可以在几乎实时而不是实时更新的字段,在这种情况下,我们可以从次要服务器读取这些字段。通过使用自动化脚本跟踪我们次要服务器的复制延迟,我们可以了解我们集群的负载情况以及启用此功能的安全性。

另一个防御性编码实践是始终使用日志记录进行写入。日志记录有助于从服务器崩溃和电源故障中恢复。

最后,我们应该尽早使用副本集。除了性能和工作负载的改进外,它们还可以帮助我们从服务器故障中恢复。

监控集成

所有这些加起来都导致了对监控工具和服务的更广泛采用。尽管我们可以对其中一些进行脚本编写,但与云和本地监控工具集成可以帮助我们在更短的时间内取得更多成果。

我们跟踪的指标应该做到以下几点:

  • 检测故障:故障检测是一个被动的过程,我们应该制定清晰的协议,以应对每个故障检测标志触发时会发生什么。例如,如果我们失去了一个服务器、一个副本集或一个分片,应该采取什么恢复步骤?

  • 预防故障:另一方面,故障预防是一种积极的过程,旨在帮助我们在将来成为潜在故障源之前捕捉问题。例如,CPU/存储/内存使用情况应该被积极监控,并且应该制定清晰的流程,以确定在达到任一阈值时我们应该做什么。

操作

连接到我们的生产 MongoDB 服务器时,我们希望确保我们的操作尽可能轻量级(并且肯定不会破坏性地)并且不会以任何方式改变数据库状态。

我们可以将以下两个有用的实用程序链接到我们的查询中:

> db.collection.find(query).maxTimeMS(999)

我们的query将最多花费999毫秒的时间,然后返回超过时间限制的错误:

> db.collection.find(query).maxScan(1000)

我们的query将最多检查1000个文档,以查找结果然后返回(不会引发错误)。

在我们可以的情况下,我们应该通过时间或文档结果大小来限制我们的查询,以避免运行意外长时间的查询,这可能会影响我们的生产数据库。访问我们的生产数据库的常见原因是故障排除降级的集群性能。这可以通过云监控工具进行调查,正如我们在前几章中所描述的。

通过 MongoDB shell 的db.currentOp()命令,我们可以得到所有当前操作的列表。然后,我们可以分离出具有较大.secs_running值的操作,并通过.query字段对其进行识别。

如果我们想要终止长时间运行的操作,我们需要注意.opid字段的值,并将其传递给db.killOp(<opid>)

最后,从运营的角度来看,重要的是要认识到一切都可能出错。我们必须有一个一致实施的备份策略。最重要的是,我们应该练习从备份中恢复,以确保它按预期工作。

安全

在最近的勒索软件波之后,这些勒索软件锁定了不安全的 MongoDB 服务器,并要求管理员以加密货币支付赎金来解锁 MongoDB 服务器,许多开发人员变得更加注重安全。安全是我们作为开发人员可能没有高度优先考虑的检查表上的一项,这是由于我们乐观地认为这种情况不会发生在我们身上。事实上,在现代互联网环境中,每个人都可能成为自动化或有针对性攻击的目标,因此安全性应该始终被考虑在内,从设计的早期阶段到生产部署之后。

默认情况下启用安全性

每个数据库(除了本地开发服务器,也许)都应该在mongod.conf文件中设置如下内容:

auth = true

应该始终启用 SSL,正如我们在相关第八章中所描述的,监控、备份和安全

REST 和 HTTP 状态接口应通过向mongod.conf添加以下行来禁用:

nohttpinterface = true
rest = false

访问应该仅限于应用服务器和 MongoDB 服务器之间的通信,并且仅限于所需的接口。使用bind_ip,我们可以强制 MongoDB 监听特定接口,而不是默认绑定到每个可用接口的行为:

bind_ip = 10.10.0.10,10.10.0.20

隔离我们的服务器

我们应该使用 AWS VPC 或我们选择的云提供商的等效物来保护我们的基础设施边界。作为额外的安全层,我们应该将我们的服务器隔离在一个独立的云中,只允许外部连接到达我们的应用服务器,永远不允许它们直接连接到我们的 MongoDB 服务器:

我们应该投资于基于角色的授权。安全性不仅在于防止外部行为者造成的数据泄漏,还在于确保内部行为者对我们的数据具有适当的访问级别。通过 MongoDB 级别的基于角色的授权,我们可以确保我们的用户具有适当的访问级别。

考虑企业版用于大规模部署。企业版提供了一些方便的安全功能,更多地集成了知名工具,并且应该在大规模部署中进行评估,以满足随着我们从单个副本集过渡到企业复杂架构的不断变化的需求。

检查表

运营需要完成许多任务和复杂性。一个好的做法是保持一套包含所有需要执行的任务及其重要性顺序的检查表。这将确保我们不会漏掉任何事情。例如,部署和安全检查表可能如下所示:

  • 硬件

  • 存储:每个节点需要多少磁盘空间?增长率是多少?

  • 存储技术:我们是否需要 SSD 还是 HDD?我们的存储吞吐量是多少?

  • RAM:预期的工作集是多少?我们能否将其放入 RAM 中?如果不能,我们是否可以接受 SSD 而不是 HDD?增长率是多少?

  • CPU:这通常对 MongoDB 不是一个问题,但如果我们计划在我们的集群中运行 CPU 密集型作业(例如,聚合或 MapReduce),它可能是一个问题。

  • 网络:服务器之间的网络链接是什么?如果我们使用单个数据中心,这通常是微不足道的,但如果我们有多个数据中心和/或用于灾难恢复的离站服务器,情况可能会变得复杂。

  • 安全

  • 启用认证。

  • 启用 SSL。

  • 禁用 REST/HTTP 接口。

  • 隔离我们的服务器(例如,VPC)。

  • 已启用授权。伴随着强大的权力而来的是巨大的责任。确保强大的用户是您信任的用户。不要将潜在破坏性的权力赋予经验不足的用户。

监控和运营检查表可能如下所示:

  • 监控

  • 使用硬件(CPU、内存、存储和网络)。

  • 健康检查,使用 Pingdom 或等效服务,以确保我们在其中一个服务器失败时收到通知。

  • 客户端性能监控:定期集成神秘购物者测试,以客户的方式手动或自动化地进行,从端到端的角度,以找出它是否表现如预期。我们不希望从客户那里了解应用性能问题。

  • 使用 MongoDB Cloud Manager 监控;它有免费层,可以提供有用的指标,是 MongoDB 工程师在我们遇到问题并需要他们的帮助时可以查看的工具,特别是作为支持合同的一部分。

  • 灾难恢复

  • 评估风险:从业务角度来看,丢失 MongoDB 数据的风险是多少?我们能否重新创建这个数据集?如果可以,从时间和精力方面来看,成本是多少?

  • 制定计划:针对每种故障场景制定计划,包括我们需要采取的确切步骤。

  • 测试计划:对每个恢复策略进行干预与实施一样重要。在灾难恢复中可能会出现许多问题,拥有一个不完整的计划(或者在每个目的中失败的计划)是我们在任何情况下都不应该允许发生的事情。

  • 制定计划的备选方案:无论我们制定计划和测试计划有多么完善,计划、测试或执行过程中都可能出现问题。我们需要为我们的计划制定备用计划,以防我们无法使用计划 A 恢复我们的数据。这也被称为计划 B,或最后的后备计划。它不必高效,但应该减轻任何业务声誉风险。

  • 负载测试:我们应该确保在部署之前对我们的应用进行端到端的负载测试,使用真实的工作负载。这是确保我们的应用行为符合预期的唯一方法。

进一步阅读

您可以参考以下链接获取更多信息:

摘要

在本章中,我们涵盖了一些在之前章节中没有详细介绍的主题。根据我们的工作负载要求,应用最佳实践非常重要。阅读性能通常是我们要优化的内容;这就是为什么我们讨论了查询合并和数据去规范化。

当我们从部署转向确保集群的持续性能和可用性时,运营也很重要。安全性是我们经常忽视直到它影响我们的东西。这就是为什么我们应该事先投入时间来计划,并确保我们已经采取措施足够安全。

最后,我们介绍了清单的概念,以跟踪我们的任务,并确保在主要运营事件(部署、集群升级、从副本集迁移到分片等)之前完成所有任务。

posted @   绝不原创的飞龙  阅读(39)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 一个费力不讨好的项目,让我损失了近一半的绩效!
· 实操Deepseek接入个人知识库
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· 【.NET】调用本地 Deepseek 模型
· Plotly.NET 一个为 .NET 打造的强大开源交互式图表库
点击右上角即可分享
微信分享提示