MongoDB-数据建模-全-

MongoDB 数据建模(全)

原文:zh.annas-archive.org/md5/3D36993E61CA808CF2348E9B049B1823

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

即使在今天,仍然很常见说计算机科学是一个年轻和新的领域。然而,当我们观察其他领域时,这种说法变得有些矛盾。与其他领域不同,计算机科学是一个不断以超出正常速度发展的学科。我敢说,计算机科学现在已经为医学和工程等其他领域的发展设定了进化的路径。在这种情况下,作为计算机科学学科领域的数据库系统不仅促进了其他领域的增长,而且还充分利用了许多技术领域的进化和进步,如计算机网络和计算机存储。

从形式上来说,自 20 世纪 60 年代以来,数据库系统一直是一个活跃的研究课题。从那时起,我们经历了几代,IT 行业中出现了一些大名鼎鼎的人物,并开始主导市场的趋势。

在 2000 年代,随着世界互联网接入的增长,形成了一个新的网络流量模式,社交网络的蓬勃发展,NoSQL 这个术语变得很普遍。许多人认为这是一个矛盾和有争议的话题,有些人认为这是一代新技术,是对过去十年经历的所有变化的回应。

MongoDB 就是其中之一。诞生于 21 世纪初,它成为了世界上最受欢迎的 NoSQL 数据库。不仅是世界上最受欢迎的数据库,自 2015 年 2 月以来,根据 DB-Engines 排名(db-engines.com/en/),MongoDB 成为了第四受欢迎的数据库系统,超过了著名的 PostgreSQL 数据库。

然而,流行度不应该与采用混淆。尽管 DB-Engines 排名显示 MongoDB 在搜索引擎(如 Google)上负责一些流量,有工作搜索活动,并且在社交媒体上有相当大的活动,但我们无法确定有多少应用程序正在使用 MongoDB 作为数据源。事实上,这不仅仅是 MongoDB 的问题,而是每一种 NoSQL 技术都存在的问题。

好消息是采用 MongoDB 并不是一个很艰难的决定。它是开源的,所以你可以免费从 MongoDB Inc. (www.mongodb.com)下载,那里有广泛的文档。你也可以依靠一个庞大且不断增长的社区,他们像你一样,总是在书籍、博客和论坛上寻找新的东西;分享知识和发现;并合作推动 MongoDB 的发展。

《MongoDB 数据建模》的撰写目的是为您提供另一个研究和参考来源。在其中,我们将介绍用于创建可扩展数据模型的技术和模式。我们将介绍基本的数据库建模概念,并提供一个专注于 MongoDB 建模的概述。最后,您将看到一个实际的逐步示例,对一个现实问题进行建模。

主要来说,有一些 MongoDB 背景的数据库管理员将受益于《MongoDB 数据建模》。然而,从开发人员到所有下载了 MongoDB 的好奇者都会从中受益。

本书侧重于 MongoDB 3.0 版本。MongoDB 3.0 版本是社区期待已久的版本,被 MongoDB Inc.认为是迄今为止最重要的发布。这是因为在这个版本中,我们被介绍了新的、高度灵活的存储架构 WiredTiger。性能和可扩展性的增强意图加强 MongoDB 在数据库系统技术中的重要性,并将其定位为现代应用程序的标准数据库。

本书涵盖的内容

第一章 介绍数据建模,向您介绍了基本的数据建模概念和 NoSQL 领域。

第二章,“使用 MongoDB 进行数据建模”,为您提供了 MongoDB 的面向文档的架构概述,并向您展示了文档及其特征以及如何构建它。

第三章,“查询文档”,通过 MongoDB API 引导您查询文档,并向您展示查询如何影响我们的数据建模过程。

第四章,“索引”,解释了如何通过使用索引来改善查询的执行,并因此改变我们建模数据的方式。

第五章,“优化查询”,帮助您使用 MongoDB 的本机工具来优化您的查询。

第六章,“管理数据”,侧重于数据的维护。这将教会你在开始数据建模之前查看数据操作和管理的重要性。

第七章,“扩展”,向您展示了 MongoDB 的自动共享特性有多强大,以及我们如何认为我们的数据模型是分布式的。

第八章,“使用 MongoDB 进行日志记录和实时分析”,带您了解了一个真实问题示例的模式设计。

您需要为本书准备什么

要成功理解本书的每一章,您需要访问 MongoDB 3.0 实例。

您可以选择在何处以及如何运行它。我们知道有许多方法可以做到这一点。所以,选择一个。

要执行查询和命令,我建议您在 mongo shell 上执行此操作。每次我在 mongo shell 之外执行此操作时,我都会警告您。

在第八章,“使用 MongoDB 进行日志记录和实时分析”,您需要在计算机上安装 Node.js,并且应该可以访问您的 MongoDB 实例。

这本书是为谁准备的

本书假定您已经与 MongoDB 有过初次接触,并且具有一些 JavaScript 经验。本书适用于数据库管理员、开发人员或任何希望了解一些数据建模概念以及它们如何适用于 MongoDB 世界的人。它不会教您 JavaScript 或如何在计算机上安装 MongoDB。如果您是 MongoDB 初学者,您可以找到一些很好的 Packt Publishing 图书,这些图书将帮助您获得足够的经验,以更好地理解本书。

约定

在本书中,您将找到许多区分不同类型信息的文本样式。以下是这些样式的一些示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:“我们可以将关系存储在Group文档中。”

代码块设置如下:

  collection.update({resource: resource, date: today},
    {$inc : {daily: 1}}, {upsert: true},
    function(error, result){
      assert.equal(error, null);
      assert.equal(1, result.result.n);
      console.log("Daily Hit logged");
      callback(result);
  });

当我们希望引起您对代码块的特定部分的注意时,相关的行或项目将以粗体显示:

var logMinuteHit = function(db, resource, callback) {
 // Get the events collection
  var collection = db.collection('events');
 // Get current minute to update
  var currentDate = new Date();
  var minute = currentDate.getMinutes();
  var hour = currentDate.getHours();
 // We calculate the minute of the day
  var minuteOfDay = minute + (hour * 60);
  var minuteField = util.format('minute.%s', minuteOfDay);

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

db.customers.find(
{"username": "johnclay"},
{_id: 1, username: 1, details: 1}
)

新术语重要单词以粗体显示。

注意

警告或重要说明以这样的框出现。

提示

提示和技巧看起来像这样。

读者反馈

我们的读者的反馈总是受欢迎的。让我们知道您对本书的看法 - 您喜欢或不喜欢的内容。读者的反馈对我们很重要,因为它可以帮助我们开发您真正能够从中获益的标题。

要向我们发送一般反馈,只需发送电子邮件至<feedback@packtpub.com>,并在消息主题中提及书名。

如果您对某个主题有专业知识,并且有兴趣撰写或为一本书做出贡献,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在您是 Packt 图书的自豪所有者,我们有一些事情可以帮助您充分利用您的购买。

下载示例代码

您可以从您在www.packtpub.com的账户中下载示例代码文件,这适用于您购买的所有 Packt Publishing 图书。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便文件直接发送到您的邮箱。

勘误表

尽管我们已经非常注意确保内容的准确性,但错误是难免的。如果您在我们的书籍中发现错误——也许是文本或代码中的错误——我们将不胜感激,如果您能向我们报告。通过这样做,您可以帮助其他读者避免挫折,并帮助我们改进本书的后续版本。如果您发现任何勘误,请访问www.packtpub.com/submit-errata,选择您的书,点击勘误提交表格链接,并输入您的勘误详情。一旦您的勘误被验证,您的提交将被接受,并且勘误将被上传到我们的网站或添加到该标题的勘误部分的任何现有勘误列表中。

要查看先前提交的勘误表,请访问www.packtpub.com/books/content/support,并在搜索框中输入书名。所需信息将出现在勘误表部分下。

盗版

互联网上盗版受版权保护的材料是一个持续存在的问题,涉及各种媒体。在 Packt,我们非常重视版权和许可的保护。如果您在互联网上发现我们作品的任何非法副本,请立即向我们提供位置地址或网站名称,以便我们采取补救措施。

请通过<copyright@packtpub.com>与我们联系,并附上疑似盗版材料的链接。

我们感谢您帮助我们保护作者和为您提供有价值内容的能力。

问题

如果您对本书的任何方面有问题,可以通过<questions@packtpub.com>与我们联系,我们将尽力解决问题。

第一章:介绍数据建模

数据建模是一个长期讨论的话题。因此,该领域的各种作者可能有不同的观点。不久前,当主要讨论集中在关系数据库上时,数据建模是领域中数据发现和分析过程的一部分。这是一个整体的视野,最终目标是拥有一个能够支持任何类型应用的强大数据库。

由于 NoSQL 数据库的灵活性,数据建模已经成为一个内部过程,您需要事先了解应用程序的需求或性能特征,才能最终得到一个良好的数据模型。

在本章中,我们将简要介绍多年来数据建模过程的历史,向您展示重要的概念。我们将涵盖以下主题:

  • MongoDB 和 NoSQL 的关系

  • 介绍 NoSQL

  • 数据库设计

MongoDB 和 NoSQL 的关系

如果你在 Google 上搜索 MongoDB,你会找到大约 10,900,000 个结果。同样,如果你在 Google 上搜索 NoSQL,你会得到不少于 13,000,000 个结果。

现在,在 Google 趋势上,这是一个显示一个术语相对于全球所有搜索术语的搜索频率的工具,我们可以看到对这两个主题的兴趣增长是相当相似的:

MongoDB 和 NoSQL 之间的关系

自 2009 年以来,NoSQL 和 MongoDB 术语的 Google 趋势搜索比较

但是,除了 MongoDB 是一个 NoSQL 数据库之外,这种关系实际上存在什么?

自 2009 年首次开源发布以来,由一家名为 10gen 的公司发布,MongoDB 成为了 Web 上许多玩家的选择,因此 DB-Engines(db-engines.com/en/)成为了第四受欢迎的数据库,也是最受欢迎的 NoSQL 数据库系统。

10gen 于 2013 年 8 月 27 日转变为 MongoDB Inc.,显示所有人的目光都集中在 MongoDB 及其生态系统上。向开源项目的转变对这一变化过程至关重要。特别是因为社区的采用量是巨大的。

根据 MongoDB 的现任主席兼联合创始人 Dwight Merriman:

“我们的开源平台导致 MongoDB 在项目推出后的五年内被下载了 800 万次,这对于社区采用来说是一个非常快的速度。”

此外,MongoDB Inc.推出了产品和服务,以支持这个社区并丰富 MongoDB 生态系统。其中包括:

  • MongoDB 企业:MongoDB 的商业支持

  • MongoDB 管理服务:一种 SaaS 监控工具

  • MongoDB 大学:EdX 合作伙伴,提供免费的在线培训

与 NoSQL 运动一样,MongoDB 的发展也遵循了 Web 2.0 的挑战和机遇,NoSQL 运动已经有了很大的发展。

介绍 NoSQL(不仅仅是 SQL)

尽管这个概念是新的,但 NoSQL 是一个备受争议的话题。如果你进行广泛搜索,你可能会找到许多不同的解释。由于我们没有任何意图创造一个新的解释,让我们来看一下最常用的解释。

正如我们今天所知的,NoSQL 这个术语是由 Eric Evans 引入的,是在 Last.fm 的 Johan Oskarsson 组织的一次见面会后引入的。

事实上,Oskarsson 和其他参加 2009 年 6 月 11 日在旧金山举行的历史性会议的人已经讨论了许多今天我们称之为 NoSQL 数据库的数据库,比如 Cassandra、HBase 和 CouchDB。正如 Oskarsson 所描述的,会议是关于开源、分布式、非关系型数据库的,针对那些“…在传统关系数据库方面遇到了限制…”的人,目的是“…弄清楚为什么这些新潮的 Dynamo 克隆和 BigTables 最近变得如此受欢迎。”

四个月后,Evans 在他的博客中写道,除了 NoSQL 运动的增长和正在讨论的一切,他认为它们毫无意义。然而,Neo4J 的创始人兼 CEO Emil Eifren 在将术语命名为“Not Only SQL”时是正确的。

介绍 NoSQL(不仅仅是 SQL)

Emil Eifrem 在 Twitter 上发布了介绍术语“Not Only SQL”的帖子

比起给 NoSQL 这个术语下一个定义,所有这些事件更重要的是作为讨论 NoSQL 真正含义的起点。如今,人们似乎普遍认为 NoSQL 是作为对关系数据库无法解决的问题的回应而诞生的。

值得注意的是,我们现在可以区分信息系统从 70 年代到今天必须解决的问题。那时,单片架构足以满足需求,与我们现在观察到的情况不同。

你有没有想过你已经在多少网站上拥有账户,比如社交网络、电子邮件提供商、流媒体服务和在线游戏?还有,你家里有多少设备现在连接到互联网?

不用担心如果你不能准确回答上面的问题。你并不孤单。随着每一个新的研究项目,全球范围内拥有互联网访问权限的用户数量增加,移动互联网访问所占的份额也越来越重要。

这意味着每一秒都在世界各地产生大量的非结构化或半结构化数据。数据量无法估计,因为用户是信息的主要来源。因此,越来越难以预测这种数据量何时或为何会发生变化。这只是世界上某个地方发生了不可预测的事件,比如进球、大罢工、大规模示威或飞机失事,就会导致交通变化,从而用户生成的内容增加。

作为对此的回应,NoSQL 技术的发展带来了各种不同的方法。

NoSQL 数据库类型

正如之前所述,亚马逊和谷歌在 NoSQL 的发展方面处于前沿地位,借助 Amazon DynamoDB 和 Google BigTable。由于风格的多样性,我们不断开发新类型的 NoSQL 数据库。然而,基于数据模型,已知有四种基本类型:键值存储、宽列存储、文档数据库和图数据库,下面对它们进行了解释:

  • 键值存储:键值是最简单和直接的数据模型之一,每个记录都存储为一个键和它的值。键值存储的例子有 Amazon Dynamo、Riak 和 Redis。

提示

Redis 可以被描述为一个高级的键值缓存和存储。由于它的键可以存储许多不同的数据类型并对这些类型运行原子操作,我们可以假设 Redis 是一个数据结构服务器。

  • 宽列存储:在概念上,最接近关系数据库,因为它的数据是以表格形式表示的。然而,数据库存储的是数据列而不是行。宽列存储的例子有 Google BigTable、Cassandra 和 HBase。

  • 文档数据库:顾名思义,这个数据库的数据模型以文档为主要概念。文档是存储数据的复杂结构,可以包含许多键值对、键数组对,甚至是嵌套文档。文档数据库的例子有 MongoDB、Apache CouchDB 和 Amazon SimpleDB。

  • 图数据库:图数据库是存储关系最适合表示为图的数据项的最佳方式,比如网络拓扑和社交网络。节点、边和属性是存储数据的结构。图数据库的例子有 Neo4J 和 HyperGraphDB。

动态模式、可扩展性和冗余

尽管如前所述,NoSQL 数据库类型基于不同的数据模型,但它们有一些共同的特点。

为了支持非结构化或半结构化数据,NoSQL 数据库没有预定义的模式。动态模式使得在插入新数据时更容易进行实时更改,并且在需要数据迁移时更具成本效益。

为了处理不可预测的大量数据,NoSQL 数据库使用自动分片进行水平扩展,并确保数据的持续可用性。自动分片允许用户自动将数据和流量分布到多台服务器上。

NoSQL 数据库也支持本地复制,这使得您可以以快速简便的方式实现高可用性和恢复。随着我们分发数据的方式越来越多,我们的恢复策略也在改变,我们可能需要微调我们的一致性级别。

数据库设计和数据建模

在我开始写这一章之前(或者也许是在开始写这本书之前),我考虑过如何处理这个主题。首先,因为我猜想这是你的期望之一。其次,因为这是几乎每一本文献中都存在的一个主题,我不想(也不打算)引发这场讨论。

事实上,关于理论与实践的讨论,直到现在,我更倾向于实践方面。因此,我调查了许多不同的来源,希望能够阅读更多关于这个主题的内容,并可能在这本书中总结到目前为止关于这个主题的所有内容。

我在研究初期发现的许多内容都显示了数据库设计和数据建模之间的明显分离。然而,最终我的结论是,这两个概念之间的相似性要大于分歧。为了得出这个结论,我以 C.J. Date 在数据库系统导论中提到的一个事实为出发点,Pearson Education

在其中,C.J. Date 说他更喜欢不使用术语数据建模,因为它可能指的是数据模型这个术语,这种关系可能会引起一些混淆。C.J. Date 提醒我们,在文献中术语数据模型有两个含义。第一个是数据模型是一般数据的模型,第二个是数据模型是与特定企业相关的持久数据的模型。Date 在他的书中选择了第一个定义。

正如 C.J. Date 所说:

"我们相信,在非关系型系统中进行数据库设计的正确方法是首先进行清晰的关系设计,然后,作为一个单独的后续步骤,将该关系设计映射到目标 DBMS 支持的任何非关系型结构(例如层次结构)中。"

因此,谈论数据库设计是一个很好的开始。因此,C.J. Date 采用了语义建模或概念建模这个术语,并将这一活动定义为数据库设计过程中的一种辅助。

提示

如果你想了解更多,你可以在数据库系统导论,第 8 版第十四章第 410 页中找到。

我发现的另一个重要来源,以某种方式补充了 C.J. Date 的论点,是 Graeme Simsion 在数据管理通讯上发表的出版物,www.tdan.com以及他在书籍数据建模:理论与实践中的出版物,Technics Publications LLC。Graeme Simsion 是一位数据建模师,撰写了两本数据建模书籍,并且是墨尔本大学的研究员。

在大多数出版物中,Simsion 都在讨论数据库设计和数据建模的主题,并得出结论,数据建模是数据库设计的一门学科,因此数据模型是设计的单一和最重要的组成部分。

我们注意到,与 C.J. Date 不同,Graeme Simsion 使用了数据建模这个术语。

在其中一篇出版物中,Simsion 给我们带来了一个关于数据建模概念作为数据库设计过程的一部分的重要事实。他通过一些历史事实和与直接参与数据建模的人进行的研究来解释这一点。

从历史的角度来看,他提到了 3 模式架构对数据建模概念演变的重要性。

要理解这一演变,我们必须回到 1975 年。在那一年,美国国家标准协会的标准规划和需求委员会,也被称为 ANSI/SPARC/X3 数据管理系统研究小组,由查尔斯·巴赫曼领导,发表了一份报告,提出了一个 DBMS 架构。

这份报告介绍了一个抽象的 DBMS 架构,适用于任何数据模型,即一种方式,可以多重用户视图并感知数据。

3 模式架构是为了描述最终产品——数据库,而不是设计过程。然而,正如前面提到的,3 模式架构引入了直接影响数据库设计过程的概念,包括数据建模。在接下来的部分中,我们将通过 3 模式架构的概念来更好地理解数据建模概念。

ANSI-SPARC 架构

ANSI-SPARC 架构建议使用三个视图(或三个模式)来:

  • 隐藏用户对物理存储实现的细节

  • 确保 DBMS 将为用户提供一致的数据访问,这意味着所有用户都有自己的视图

  • 允许数据库管理员在不影响用户视图的情况下在物理级别上进行更改

外部级别

外部级别,也称为用户视图,详细说明了每个特定用户如何看待数据库。这个级别允许每个用户以不同的方式查看数据。因此,这也是保留用户特定要求信息的适当级别。外部模式描述了数据库为不同用户视图而结构化的方式。因此,我们可以为一个数据库拥有许多外部模式。

概念级别

尽管被许多人认为是最重要的级别,概念级别是架构中最后出现的级别。这个级别旨在展示数据库的逻辑结构。我们可以说这是数据库中存储的数据的一个抽象视图。

概念级别充当用户视图和数据库实现之间的层。因此,在这个级别上,不考虑有关物理实现和用户视图的细节和特殊性。

一旦概念级别到位,数据库管理员在这个架构级别中扮演着重要的角色,我们有一个数据库的全局视图。他们有责任定义逻辑结构。

关于概念级别非常有趣的一点是,我们必须记住这个级别与硬件或软件是独立的。概念模式定义了逻辑数据结构以及数据库中数据之间的关系。

内部级别

内部级别表示数据的存储方式。该模式定义了物理存储结构,如索引、数据字段和表示。数据库只有一个内部模式,但可能有多个概念模式的内部模式。

内部级别

ANSI/SPARC/X3 数据库架构

查尔斯·巴赫曼和 ANSI/SPARC/X3 成员所展示的概念的引入非常有意义。他们带来了一种看待数据库的新方式,并引入了有助于发展数据建模学科的概念。

数据建模

正如我们之前所述,数据建模不再被视为一个独立的过程。它是数据库设计过程中的一个阶段,必须与业务分析一起完成的步骤。作为建模过程的最终结果,我们应该有逻辑数据模型。

这个建模过程引发了一个有争议的问题,即我们使用哪种方法。这个讨论的核心是什么是学术的,或者我们在实践中看到的。

对于 Matthew West 和 Julian Fowler 来说,看建模过程的一种方式如下图所示:

数据建模

数据建模过程

Graeme Simsion 有一整篇关于这个讨论的文章。这篇文章展示了学术视角与现实视角对建模过程的不同看法。两者都给建模阶段起了名字,这些名字是完全不同的。

在撰写本章的过程中,我试图呈现的不仅是 Simsion 的研究,还有自从我开始与信息系统一起工作以来所经历的一切,以及对建模概念的广泛研究,以及我在许多其他来源中看到的无数观点。

此外,正如之前所述,并且 Simsion 所观察到的,三模式 ANSI-SPARC 架构在形成我们今天拥有的基本概念方面发挥了关键作用。随着关系模型和基于它的数据库管理系统的传播,支持旧的数据库架构,如分层和基于网络的架构的需求已经过去。然而,我们将建模过程分为两个阶段的方式仍然保留,一个阶段反映了与用户观点非常接近的概念,然后是自动转换为概念模式。

我们可以说,我们现在所知道的数据建模过程的阶段来自于 3 模式架构。不仅是概念,我们用来命名每个阶段的名字也是如此。

因此,我们最常见的是三种数据模型:概念模型、逻辑模型和物理模型。

概念模型

概念模型是一个实体和关系的地图,带有一些属性来说明。这是一个高层次的、抽象的视图,其目的是识别基本概念,非常接近用户感知数据的方式,而不是专注于业务的特定想法。

如果我们的受众是商业人士,那就是正确的模型。它经常用于描述通用领域概念,并且应该是与 DBMS 无关的。例如,我们可以提到实体,如人员、商店、产品、讲师、学生和课程。

在学术文献和实践中,广泛使用关系符号来表示概念模型,即使目标实现不是关系型数据库管理系统。事实上,这是一个很好的方法,正如 C.J. Date 所说。

概念模型的常见图形表示是流行的“鸦脚符号”。

概念模型

鸦脚符号

人们常说,将概念模型限制在一页打印上是最佳实践。概念模型可以是一个图表,也可以是描述您已经确定的一切的文件。

逻辑模型

逻辑模型是更加符合业务的模型。这个模型也应该是与 DBMS 无关的,并且是从概念模型中派生出来的。

在这个模型中描述业务需求是很常见的。因此,在这个时候,数据建模者将更多地关注项目的范围。关系属性的基数和可空性以及数据类型和约束等细节也在这个模型中映射。与概念模型一样,通常使用关系符号来表示逻辑模型。数据建模者必须更多地在逻辑模型上工作。这是因为逻辑模型是建模者将探索所有可能性和不同想法的地方。

一般来说,逻辑模型是一个图形表示。最广泛使用的是 1976 年由 Peter Chen 提出的实体-关系ER)模型。ER 模型具有符合逻辑模型所有需求的图形符号。

逻辑模型

实体-关系图

物理模型

物理模型是一个更详细、不太通用的数据模型。在这个模型中,我们应该知道应该使用哪种技术。在这里,我们可以包括表、列名、键、索引、安全角色、验证规则,以及您作为数据建模者认为必要的任何细节。

为了清晰地将三级架构与物理模型联系起来,物理模型在某种程度上与架构的内部层级相关,因为在这个层级上,我们处理存储的数据如何呈现给用户。这个阶段的目标是拥有一个已实施的数据库。

总结

数据建模是数据库设计过程中的重要步骤。通过让所有利益相关者参与,有许多方法可以确保这个过程的高质量。在对数据进行建模后,您可能会对自己的数据有更好的了解。

话虽如此,我们应该始终考虑我们的数据,并使用一种技术来对其进行建模。

在本章中,您了解了 NoSQL 的历史,并全面探讨了数据库设计和数据建模。我们回顾了数据库架构,您也学习了概念、逻辑和物理模型。

现在您对数据建模有了更多了解,我们将在下一章中深入了解 MongoDB 数据模型及这些概念的应用。

第二章:使用 MongoDB 进行数据建模

数据建模是应用程序构思过程中非常重要的一步,因为这一步将帮助您定义数据库构建所需的必要要求。这个定义正是在数据建模过程中获得的数据理解的结果。

如前所述,无论选择的数据模型如何,这个过程通常分为两个阶段:一个非常接近用户视图,另一个是将这个视图转换为概念模式的阶段。在关系数据库建模的场景中,主要挑战是从这两个阶段构建一个强大的数据库,以确保在应用程序生命周期中对其进行任何影响的更新。

与关系数据库相比,NoSQL 数据库在这一点上更灵活,因为它可以使用无模式模型,理论上可以在数据模型需要修改时对用户视图造成较小的影响。

尽管 NoSQL 提供了灵活性,但在建模 NoSQL 数据库时,事先了解如何使用数据是很重要的。即使在 NoSQL 数据库中,也最好不要计划要持久化的数据格式。此外,乍一看,这是数据库管理员,对关系世界非常熟悉的人,变得更加不舒服的地方。

关系数据库标准,如 SQL,通过制定规则、规范和标准,为我们带来了安全感和稳定性。另一方面,我们敢说,这种安全感使数据库设计人员远离了要存储的数据所在的领域。

应用程序开发人员也遇到了同样的问题。他们与数据库管理员之间存在明显的利益分歧,特别是在数据模型方面。

NoSQL 数据库实际上带来了数据库专业人员和应用程序之间的一种接近的需求,也需要开发人员和数据库之间的一种接近。

因此,即使您可能是数据建模师/设计师或数据库管理员,如果我们从现在开始讨论超出您舒适区域的主题,也不要害怕。准备好开始使用应用程序开发人员的观点常见的词汇,并将其添加到您的词汇表中。本章将介绍 MongoDB 数据模型以及用于开发和维护该模型的主要概念和结构。

本章将涵盖以下内容:

  • 介绍您的文档和集合

  • 文档的特征和结构

  • 展示文档的设计和模式

介绍文档和集合

MongoDB 将文档作为数据的基本单元。MongoDB 中的文档以JavaScript 对象表示法JSON)表示。

集合是文档的组合。打个比方,集合类似于关系模型中的表,文档是该表中的记录。最后,集合属于 MongoDB 中的数据库。

文档以一种称为二进制 JSONBSON)的格式序列化在磁盘上,这是 JSON 文档的二进制表示。

文档的示例是:

{
   "_id": 123456,
   "firstName": "John",
   "lastName": "Clay",
   "age": 25,
   "address": {
      "streetAddress": "131 GEN. Almério de Moura Street",
      "city": "Rio de Janeiro",
      "state": "RJ",
      "postalCode": "20921060"
   },
   "phoneNumber":[
      {
         "type": "home",
         "number": "+5521 2222-3333"
      },
      {
         "type": "mobile",
         "number": "+5521 9888-7777"
      }
   ]
}

与关系模型不同,您必须声明表结构,集合不会强制执行文档的特定结构。一个集合可能包含完全不同结构的文档。

例如,在同一个users集合中,我们可以有:

{
   "_id": "123456",
   "username": "johnclay",
   "age": 25,
   "friends":[
      {"username": "joelsant"},
      {"username": "adilsonbat"}
   ],
   "active": true,
   "gender": "male"
}

我们还可以有:

{
   "_id": "654321",
   "username": "santymonty",
   "age": 25,
   "active": true,
   "gender": "male",
   "eyeColor": "brown"
}

除此之外,MongoDB 的另一个有趣特性是不仅数据由文档表示。基本上,所有用户与 MongoDB 的交互都是通过文档进行的。除了数据记录,文档还是一种:

  • 定义可以在查询中读取、写入和/或更新的数据

  • 定义将要更新的字段

  • 创建索引

  • 配置复制

  • 从数据库中查询信息

在我们深入讨论文档的技术细节之前,让我们探索它们的结构。

JSON

JSON是一种用于数据的开放标准表示的文本格式,非常适合数据传输。要深入探索 JSON 格式,您可以查看ECMA-404 JSON 数据交换标准,其中对 JSON 格式进行了全面描述。

注意

JSON 由两个标准描述:ECMA-404 和 RFC 7159。第一个更注重 JSON 语法和语法,而第二个提供了语义和安全性考虑。

顾名思义,JSON 源自 JavaScript 语言。它作为解决方案出现,用于在 Web 服务器和浏览器之间传输对象状态。尽管它是 JavaScript 的一部分,但几乎所有最流行的编程语言(如 C、Java 和 Python)都可以找到 JSON 的生成器和读取器。

JSON 格式也被认为非常友好和易读。JSON 不依赖于所选择的平台,其规范基于两种数据结构:

  • 一组或一组键/值对

  • 一个值有序列表

因此,为了澄清任何疑问,让我们谈谈对象。对象是一组非有序的键/值对,由以下模式表示:

{
   "key" : "value"
}

关于值有序列表,集合表示如下:

["value1", "value2", "value3"]

在 JSON 规范中,值可以是:

  • " "括起来的字符串

  • 一个带或不带符号的数字,以十进制(基数 10)为基础。这个数字可以有一个由句点(.)分隔的小数部分,或者是一个指数部分,后面跟着eE

  • 布尔值(truefalse

  • 一个null

  • 另一个对象

  • 另一个值有序数组

以下图表显示了 JSON 值结构:

JSON

以下是描述一个人的 JSON 代码示例:

{
   "name" : "Han",
   "lastname" : "Solo",
   "position" : "Captain of the Millenium Falcon",
   "species" : "human",
   "gender":"male",
   "height" : 1.8
}

BSON

BSON意味着Binary JSON,换句话说,是 JSON 文档的二进制编码序列化。

注意

如果您想了解更多关于 BSON 的知识,我建议您查看bsonspec.org/上的 BSON 规范。

如果我们将 BSON 与其他二进制格式进行比较,BSON 具有更灵活的模型优势。此外,其特点之一是它的轻量级-这是 Web 上数据传输非常重要的特性。

BSON 格式被设计为在大多数基于 C 的编程语言中易于导航,并且以非常高效的方式进行编码和解码。这就是为什么 BSON 被选择为 MongoDB 磁盘持久化的数据格式的原因。

BSON 中的数据表示类型有:

  • 字符串 UTF-8(string

  • 整数 32 位(int32

  • 整数 64 位(int64

  • 浮点数(double

  • 文档(document

  • 数组(document

  • 二进制数据(binary

  • 布尔值 false(\x00或字节 0000 0000)

  • 布尔值 true(\x01或字节 0000 0001)

  • UTC 日期时间(int64)- int64 是自 Unix 纪元以来的 UTC 毫秒数

  • 时间戳(int64)-这是 MongoDB 复制和分片中使用的特殊内部类型;前 4 个字节是增量,最后 4 个字节是时间戳

  • 空值()

  • 正则表达式(cstring

  • JavaScript 代码(string

  • JavaScript 代码 w/范围(code_w_s

  • Min key() - 比所有其他可能的 BSON 元素值都要低的特殊类型

  • Max key() - 比所有其他可能的 BSON 元素值都要高的特殊类型

  • 对象 ID(byte*12)

文档的特征

在我们详细讨论如何对文档进行建模之前,我们需要更好地了解一些其特征。这些特征可以决定您对文档建模的决定。

文档大小

我们必须记住,BSON 文档的最大长度为 16 MB。根据 BSON 规范,这个长度非常适合通过 Web 进行数据传输,并且可以避免过度使用 RAM。但这只是一个建议。如今,通过使用 GridFS,文档可以超过 16 MB 的长度。

注意

GridFS 允许我们将大于 BSON 最大大小的文档存储在 MongoDB 中,方法是将其分成部分或块。每个块都是一个新的文档,大小为 255K。

文档中字段的名称和值

有一些关于文档中字段的名称和值的事情你必须知道。首先,文档中任何字段的名称都是一个字符串。通常情况下,我们对字段名称有一些限制。它们是:

  • _id字段保留用于主键

  • 你不能以字符$开头

  • 名称不能有空字符,或(.

此外,具有索引字段的文档必须遵守索引字段的大小限制。值不能超过 1,024 字节的最大大小。

文档的主键

如前一节所示,_id字段保留用于主键。默认情况下,这个字段必须是文档中的第一个字段,即使在插入时它不是第一个要插入的字段。在这种情况下,MongoDB 会将其移动到第一个位置。此外,根据定义,唯一索引将在此字段中创建。

_id字段可以有任何 BSON 类型的值,除了数组。此外,如果创建文档时没有指定_id字段,MongoDB 将自动创建一个 ObjectId 类型的_id字段。但这不是唯一的选择。只要是唯一的,你可以使用任何值来标识你的文档。还有另一种选择,即基于支持集合或乐观循环生成自增值。

支持集合

在这种方法中,我们使用一个单独的集合来保存序列中最后使用的值。要增加序列,首先我们应该查询最后使用的值。之后,我们可以使用$inc操作符来增加值。

注意

有一个名为system.js的集合,可以保存 JavaScript 代码以便重用。请注意不要在这个集合中包含应用程序逻辑。

让我们看一个例子:

db.counters.insert(
 {
 _id: "userid",
 seq: 0
 }
)

function getNextSequence(name) {
 var ret = db.counters.findAndModify(
 {
 query: { _id: name },
 update: { $inc: { seq: 1 } },
 new: true
 }
 );
 return ret.seq;
}

db.users.insert(
 {
 _id: getNextSequence("userid"),
 name: "Sarah C."
 }
)

乐观循环

通过乐观循环生成_id字段是通过递增每次迭代,然后尝试将其插入新文档:

function insertDocument(doc, targetCollection) {
    while (1) {
        var cursor = targetCollection.find( {}, { _id: 1 } ).sort( { _id: -1 } ).limit(1);
        var seq = cursor.hasNext() ? cursor.next()._id + 1 : 1;
        doc._id = seq;
        var results = targetCollection.insert(doc);
        if( results.hasWriteError() ) {
            if( results.writeError.code == 11000 /* dup key */ )
                continue;
            else
                print( "unexpected error inserting data: " + tojson( results ) );
        }
        break;
    }
}

在这个函数中,迭代执行以下操作:

  1. targetCollection中搜索_id的最大值。

  2. _id设置下一个值。

  3. 设置要插入的文档的值。

  4. 插入文档。

  5. 在由于重复的_id字段而导致的错误情况下,循环会重复自身,否则迭代结束。

注意

这里展示的要点是理解这个工具可以提供的所有可能性和方法的基础。但是,尽管我们可以为 MongoDB 使用自增字段,但我们必须避免使用它们,因为这个工具不适用于大数据量的情况。

设计文档

在这一点上,我相信你一定会问自己:如果文档的基本结构是 JSON(一种如此简单和文本化的东西),那么创建 NoSQL 数据库会有什么复杂之处呢?

让我们看看!首先,是的!你是对的。NoSQL 数据库可以非常简单。但是,这并不意味着它们的结构会比关系数据库更简单。它会有所不同!

如前所述,集合不会强制你预先定义文档的结构。但这肯定是你必须在某个时候做出的决定。这个决定将影响重要的方面,特别是与查询性能有关的方面。

到目前为止,你可能也问过自己应用程序如何表示文档之间的关系。如果你直到现在才想到这个问题,那不是你的错。我们习惯于思考关系世界,比如想知道学生和他们的课程之间的关系,或者产品和订单之间的关系。

MongoDB 也有自己表示这种关系的方式。事实上,有两种方式:

  • 嵌入式文档

  • 引用

使用嵌入式文档

通过使用子文档,我们可以构建更复杂和优化的数据结构。因此,当我们建模一个文档时,我们可以选择在一个文档中嵌入相关数据。

决定在一个文档中嵌入数据往往与意图获得更好的读取性能有关,因为只需一个查询,我们就可以完全检索所需的信息。

看下面的例子:

{
   id: 1,
   title: "MongoDB Data Modeling Blog post",
   body: "MongoDB Data Modeling....",
   author: "Wilson da Rocha França",
   date: ISODate("2014-11-19"),
   comments: [
      {
         name: "Mike",
         email : "mike@mike.com",
         comment: "Mike comment...."
      },
      {
         name: "Tom",
         email : "tom@tom.com",
         comment: "Tom comment...."
      },
      {
         name: "Yuri",
         email : "yuri@yuri.com",
         comment: "Yuri comment...."
      }
   ],
   tags: ["mongodb", "modeling", "nosql"]
}

正如我们可以推断的,这个文档代表了一篇博客文章。这种文档的优势在于,只需一个查询,我们就可以获得向用户展示所需的所有数据。更新也是一样:只需一个查询,我们就可以修改这个文档的内容。然而,当我们决定嵌入数据时,我们必须确保文档不超过 16MB 的 BSON 大小限制。

在 MongoDB 中嵌入数据没有规则,但总体上,我们应该观察:

  • 我们是否在文档之间有一对一的关系。

  • 我们是否在文档之间有一对多的关系,以及“多”部分与“一”部分的关系非常依赖于“一”部分。这意味着,例如,每次我们展示“一”部分时,我们也会展示关系的“多”部分。

如果我们的模型符合前述情况之一,我们应该考虑使用嵌入式文档。

使用引用

规范化是帮助构建关系数据模型的基本过程。为了最小化冗余,在这个过程中我们将较大的表分成较小的表,并在它们之间定义关系。我们可以说,在 MongoDB 中创建引用是我们“规范化”模型的方式。这个引用将描述文档之间的关系。

你可能会困惑为什么我们在非关系型的世界中考虑关系,尽管这并不意味着在 NoSQL 数据库中不存在关系。我们经常会使用关系建模的概念来解决常见问题。正如前面所述,为了消除冗余,文档可以相互引用。

但等等!现在有一件非常重要的事情你应该知道:MongoDB 不支持连接。这意味着,即使有对另一个文档的引用,你仍然需要至少执行两次查询才能获取完整的所需信息。

看下面的例子:

{
   _id: 1,
   name : "Product 1",
   description: "Product 1 description",
   price: "$10,00",
   supplier : { 
      name: "Supplier 1", 
      address: "St.1", 
      telephone: "+552199999999" 
   }
}

{
   _id: 2,
   name : "Product 2",
   description: "Product 2 description",
   price: "$10,00",
   supplier : { 
      name: "Supplier 1", 
      address: "St.1", 
      telephone: "+552199999999" 
   }
}

{
   _id: 3,
   name : "Product 3",
   description: "Product 3 description",
   price: "$10,00",
   supplier : { 
      name: "Supplier 1", 
      address: "St.1", 
      telephone: "+552199999999" 
   }
}

在前面的例子中,我们有来自products集合的文档。我们可以看到,在这三个产品实例中,供应商键的值是相同的。除了这些重复的数据,我们可以有两个集合:productssuppliers,就像下面的例子中所示:

suppliers

{
   _id: 1
   name: "Supplier 1", 
   address: "St.1", 
   telephone: "+552199999999",
   products: [1, 2, 3]
}

products 

{
   _id: 1,
   name : "Product 1",
   description: "Product 1 description",
   price: "$10,00"
}

{
   _id: 2,
   name : "Product 2",
   description: "Product 2 description",
   price: "$10,00"
}

{
   _id: 3,
   name : "Product 3",
   description: "Product 3 description",
   price: "$10,00"
}

在这种特殊情况下,对于供应商的少量产品,基于供应商引用产品是一个不错的选择。然而,如果情况相反,更好的方法是:

suppliers

{
   _id: 1
   name: "Supplier 1", 
   address: "St.1", 
   telephone: "+552199999999"
}

products 

{
   _id: 1,
   name : "Product 1",
   description: "Product 1 description",
   price: "$10,00",
   supplier: 1
}

{
   _id: 2,
   name : "Product 2",
   description: "Product 2 description",
   price: "$10,00",
   supplier: 1
}

{
   _id: 3,
   name : "Product 3",
   description: "Product 3 description",
   price: "$10,00",
   supplier: 1
}

在 MongoDB 中使用引用没有规则,但总体上,我们应该观察:

  • 我们是否在嵌入数据时重复相同的信息多次(这会影响读取性能)

  • 我们是否需要表示多对多的关系

  • 我们的模型是否是一个层次结构

如果我们的模型符合前述情况之一,我们应该考虑使用引用。

原子性

在设计文档时,会影响我们决策的另一个重要概念是原子性。在 MongoDB 中,操作在文档级别是原子的。这意味着我们可以一次修改一个文档。即使我们的操作在集合中的多个文档中进行,这个操作也会一次在一个文档中进行。

因此,当我们决定使用嵌入数据来建模文档时,我们只需编写操作,因为我们需要的所有数据都在同一个文档中。这与选择引用数据时的情况相反,那时我们需要许多不是原子的写操作。

常见的文档模式

现在我们了解了如何设计我们的文档,让我们来看一些现实生活中的问题示例,比如如何编写更好地描述实体之间关系的数据模型。

本节将向您展示一些模式,说明何时嵌入或引用文档。到目前为止,我们已经考虑了一个决定性因素:

  • 是否一致性是优先级

  • 是否读取是优先级

  • 是否写入是优先级

  • 我们将进行哪些更新查询

  • 文档增长

一对一

一对一关系比其他关系更简单。大多数情况下,我们将使用嵌入文档来映射这种关系,特别是如果它是一个“包含”关系的话。

以下示例显示了一个客户的文档。在第一种情况下,在customerDetails文档中有一个引用;在第二种情况下,我们看到了一个带有嵌入数据的引用:

  • 引用数据:
customer
{ 
   "_id": 5478329cb9617c750245893b
   "username" : "John Clay",
   "email": "johnclay@crgv.com",
   "password": "bf383e8469e98b44895d61b821748ae1"
}
customerDetails
{
   "customer_id": "5478329cb9617c750245893b",
   "firstName": "John",
   "lastName": "Clay",
   "gender": "male",
   "age": 25
}
  • 使用嵌入数据:
customer
{ 
   _id: 1
   "username" : "John Clay",
   "email": "johnclay@crgv.com",
   "password": "bf383e8469e98b44895d61b821748ae1"
   "details": {
 "firstName": "John",
 "lastName": "Clay",
 "gender": "male",
 "age": 25
 }
}

使用嵌入文档表示关系的优势在于,当我们查询客户时,客户详细数据始终可用。因此,我们可以说客户的详细信息本身并没有意义,只有与客户数据一起才有意义。

一对多

一对多关系比一对一关系更复杂。为了决定何时嵌入或引用,我们必须考虑关系的“多”方。如果多方应该与其父级一起显示,那么我们应该选择嵌入数据;否则,我们可以在父级上使用引用。

让我们看一个customer和客户的地址的例子:

customer
{ 
   _id: 1
   "username" : "John Clay",
   "email": "johnclay@crgv.com",
   "password": "bf383e8469e98b44895d61b821748ae1"
   "details": {
      "firstName": "John",
      "lastName": "Clay",
      "gender": "male",
      "age": 25
   }
}

address
{
   _id: 1,
   "street": "Address 1, 111",
   "city": "City One",
   "state": "State One",
   "type": "billing",
   "customer_id": 1
}
{
   _id: 2,
   "street": "Address 2, 222",
   "city": "City Two",
   "state": "State Two",
   "type": "shipping",
   "customer_id": 1
}
{
   _id: 3,
   "street": "Address 3, 333",
   "city": "City Three",
   "state": "State Three",
   "type": "shipping",
   "customer_id": 1
}

如果每次您想要显示客户的地址时,还需要显示客户的姓名,那么建议使用嵌入文档:

customer
{ 
   _id: 1
   "username" : "John Clay",
   "email": "johnclay@crgv.com",
   "password": "bf383e8469e98b44895d61b821748ae1"
   "details": {
      "firstName": "John",
      "lastName": "Clay",
      "gender": "male",
      "age": 25
   }
   "billingAddress": [{
      "street": "Address 1, 111",
      "city": "City One",
      "state": "State One",
      "type": "billing",
   }],

   "shippingAddress": [{
      "street": "Address 2, 222",
      "city": "City Two",
      "state": "State Two",
      "type": "shipping"
   },
   {
      "street": "Address 3, 333",
      "city": "City Three",
      "state": "State Three",
      "type": "shipping"
   }]
}

多对多

一对多关系并不是一件微不足道的事情,即使在关系型宇宙中也是如此。在关系世界中,这种关系通常被表示为连接表,而在非关系世界中,它可以以许多不同的方式表示。

在以下代码中,我们将看到一个usergroup关系的经典示例:

user

{
   _id: "5477fdea8ed5881af6541bf1",
   "username": "user_1",
   "password" : "3f49044c1469c6990a665f46ec6c0a41"
}

{
   _id: "54781c7708917e552d794c59",
   "username": "user_2",
   "password" : "15e1576abc700ddfd9438e6ad1c86100"
}

group

{
   _id: "54781cae13a6c93f67bdcc0a",
   "name": "group_1"
}

{
   _id: "54781d4378573ed5c2ce6100",
   "name": "group_2"
}

现在让我们在User文档中存储关系:

user

{
   _id: "5477fdea8ed5881af6541bf1",
   "username": "user_1",
   "password" : "3f49044c1469c6990a665f46ec6c0a41",
   "groups": [
      {
         _id: "54781cae13a6c93f67bdcc0a",
         "name": "group_1"
      },
      {
         _id: "54781d4378573ed5c2ce6100",
         "name": "group_2"
      }

   ]
}

{
   _id: "54781c7708917e552d794c59",
   "username": "user_2",
   "password" : "15e1576abc700ddfd9438e6ad1c86100",
   "groups": [
      {
         _id: "54781d4378573ed5c2ce6100",
         "name": "group_2"
      }

   ]
}

group
{
   _id: "54781cae13a6c93f67bdcc0a",
   "name": "group_1"
}

{
   _id: "54781d4378573ed5c2ce6100",
   "name": "group_2"
}

或者我们可以在group文档中存储关系:

user
{
   _id: "5477fdea8ed5881af6541bf1",
   "username": "user_1",
   "password" : "3f49044c1469c6990a665f46ec6c0a41"
}
{
   _id: "54781c7708917e552d794c59",
   "username": "user_2",
   "password" : "15e1576abc700ddfd9438e6ad1c86100"
}
group
{
   _id: "54781cae13a6c93f67bdcc0a",
   "name": "group_1",
   "users": [
      {
         _id: "54781c7708917e552d794c59",
         "username": "user_2",
         "password" : "15e1576abc700ddfd9438e6ad1c86100"
      }

   ]
}
{
   _id: "54781d4378573ed5c2ce6100",
   "name": "group_2",
   "users": [
      {
         _id: "5477fdea8ed5881af6541bf1",
         "username": "user_1",
         "password" :  "3f49044c1469c6990a665f46ec6c0a41"
      },
      {
         _id: "54781c7708917e552d794c59",
         "username": "user_2",
         "password" :  "15e1576abc700ddfd9438e6ad1c86100"
      }

   ]
}

最后,让我们在两个文档中存储关系:

user
{
   _id: "5477fdea8ed5881af6541bf1",
   "username": "user_1",
   "password" : "3f49044c1469c6990a665f46ec6c0a41",
   "groups": ["54781cae13a6c93f67bdcc0a", "54781d4378573ed5c2ce6100"]
}
{
   _id: "54781c7708917e552d794c59",
   "username": "user_2",
   "password" : "15e1576abc700ddfd9438e6ad1c86100",
   "groups": ["54781d4378573ed5c2ce6100"]
}
group
{
   _id: "54781cae13a6c93f67bdcc0a",
   "name": "group_1",
   "users": ["5477fdea8ed5881af6541bf1"]
}
{
   _id: "54781d4378573ed5c2ce6100",
   "name": "group_2",
   "users": ["5477fdea8ed5881af6541bf1", "54781c7708917e552d794c59"]
}

摘要

在本章中,您了解了如何在 MongoDB 中构建文档,检查了它们的特性,并了解了它们是如何组织成集合的。

您现在了解了已经知道应用程序领域对于设计最佳模型有多么重要,您也看到了一些可以帮助您决定如何设计文档的模式。

在下一章中,我们将看到如何查询这些集合并修改其中存储的数据。

第三章:查询文档

在 NoSQL 数据库中,比如 MongoDB,规划查询是一项非常重要的任务,根据您要执行的查询,您的文档可能会有很大的变化。

在第二章中,使用 MongoDB 进行数据建模,决定在集合中引用或包含文档,在很大程度上是我们规划的结果。确定我们是否偏向于在集合中进行读取或写入是至关重要的。

在这里,我们将看到如何规划查询可以帮助我们更有效地创建文档,我们还将考虑更明智的问题,比如原子性和事务。

本章将重点关注以下主题:

  • 读取操作

  • 写操作

  • 写入关注点

  • 批量写入文档

理解读取操作

在数据库中,读取是最常见和基本的操作。很难想象一个仅用于写入信息的数据库,这些信息从不被读取。顺便说一句,我从未听说过这种方法。

在 MongoDB 中,我们可以通过find接口执行查询。find接口可以接受查询作为条件和投影作为参数。这将产生一个游标。游标有可以用作执行查询的修饰符的方法,比如limitmapskipsort。例如,看一下以下查询:

db.customers.find({"username": "johnclay"})

这将返回以下文档:

{
 "_id" : ObjectId("54835d0ff059b08503e200d4"),
 "username" : "johnclay",
 "email" : "johnclay@crgv.com",
 "password" : "bf383e8469e98b44895d61b821748ae1",
 "details" : {
 "firstName" : "John",
 "lastName" : "Clay",
 "gender" : "male",
 "age" : 25
 },
 "billingAddress" : [
 {
 "street" : "Address 1, 111",
 "city" : "City One",
 "state" : "State One"
 }
 ],
 "shippingAddress" : [
 {
 "street" : "Address 2, 222",
 "city" : "City Two",
 "state" : "State Two"
 },
 {
 "street" : "Address 3,333",
 "city" : "City Three",
 "state" : "State Three"
 }
 ]
}

我们可以使用find接口在 MongoDB 中执行查询。find接口将选择集合中的文档,并返回所选文档的游标。

与 SQL 语言相比,find接口应该被视为select语句。类似于select语句,我们可以使用表达式和谓词确定子句,find接口允许我们使用条件和投影作为参数。

如前所述,我们将在这些find接口参数中使用 JSON 文档。我们可以以以下方式使用find接口:

db.collection.find(
 {criteria}, 
 {projection}
)

在这个例子中:

  • criteria是一个 JSON 文档,将使用一些运算符指定集合中文档的选择条件

  • projection是一个 JSON 文档,将指定集合中将作为查询结果返回的文档字段

这两个都是可选参数,我们稍后将更详细地讨论这些。

让我们执行以下示例:

db.customers.find(
{"username": "johnclay"}, 
{_id: 1, username: 1, details: 1}
)

在这个例子中:

  • {"username": "johnclay"}是条件

  • {_id: 1, username: 1, details: 1}是投影

这个查询将产生这个文档:

{
 "_id" : ObjectId("54835d0ff059b08503e200d4"),
 "username" : "johnclay",
 "details" : {
 "firstName" : "John",
 "lastName" : "Clay",
 "gender" : "male",
 "age" : 25
 }
}

选择所有文档

如前所述,在find接口中,条件和投影参数都是可选的。在没有任何参数的情况下使用find接口意味着选择集合中的所有文档。

注意

请注意,查询结果是一个包含所有所选文档的游标。

因此,products集合中的查询以这种方式执行:

db.products.find()

它将返回:

{ 
 "_id" : ObjectId("54837b61f059b08503e200db"), 
 "name" : "Product 1", 
 "description" : "Product 1 description", 
 "price" : 10, 
 "supplier" : { 
 "name" : "Supplier 1", 
 "telephone" : "+552199998888" 
 } 
}
{ 
 "_id" : ObjectId("54837b65f059b08503e200dc"), 
 "name" : "Product 2", 
 "description" : "Product 2 description", 
 "price" : 20, 
 "supplier" : { 
 "name" : "Supplier 2", 
 "telephone" : "+552188887777" 
 } 
}
…

使用条件选择文档

尽管方便,但选择集合中的所有文档可能会因为集合的长度而变得不切实际。举个例子,如果一个集合中有数百、数千或数百万条记录,就必须创建一个标准,以便只选择我们想要的文档。

然而,没有什么可以阻止查询结果变得非常庞大。在这种情况下,根据执行查询的所选驱动器,我们必须迭代返回的游标。

注意

请注意,在 mongo shell 中,返回记录的默认值为 20。

让我们检查以下示例查询。我们想选择属性名称为Product 1的文档:

db.products.find({name: "Product 1"});

这将给我们一个结果:

{
 "_id" : ObjectId("54837b61f059b08503e200db"),
 "name" : "Product 1",
 "description" : "Product 1 description",
 "price" : 10,
 "supplier" : {
 "name" : "Supplier 1",
 "telephone" : "+552199998888"
 }
}

上述查询通过相等性{name: "Product 1"}选择文档。还可以在条件接口上使用运算符。

以下示例演示了如何选择所有价格大于 10 的文档:

db.products.find({price: {$gt: 10}});

这将产生如下结果:

{ 
 "_id" : ObjectId("54837b65f059b08503e200dc"), 
 "name" : "Product 2", 
 "description" : "Product 2 description", 
 "price" : 20, 
 "supplier" : { 
 "name" : "Supplier 2", 
 "telephone" : "+552188887777" 
 } 
}
{ 
 "_id" : ObjectId("54837b69f059b08503e200dd"), 
 "name" : "Product 3", 
 "description" : "Product 3 description", 
 "price" : 30, 
 "supplier" : { 
 "name" : "Supplier 3", 
 "telephone" : "+552177776666" 
 }
}

当我们使用 $gt 运算符执行查询时,只有价格信息大于 10 的文档将作为游标结果返回。

此外,还有其他运算符,如比较、逻辑、元素、评估、地理和数组运算符。

例如,我们从 products 集合中选择的文档如下所示:

{
 "_id" : ObjectId("54837b61f059b08503e200db"),
 "name" : "Product 1",
 "description" : "Product 1 description",
 "price" : 10,
 "supplier" : {
 "name" : "Supplier 1",
 "telephone" : "+552199998888"
 },
 "review" : [
 {
 "customer" : {
 "email" : "customer@customer.com"
 },
 "stars" : 5
 },
 {
 "customer" : {
 "email" : "customer2@customer.com"
 },
 "stars" : 6
 }
 ]
}
{
 "_id" : ObjectId("54837b65f059b08503e200dc"),
 "name" : "Product 2",
 "description" : "Product 2 description",
 "price" : 20,
 "supplier" : {
 "name" : "Supplier 2",
 "telephone" : "+552188887777"
 },
 "review" : [
 {
 "customer" : {
 "email" : "customer@customer.com"
 },
 "stars" : 10
 },
 {
 "customer" : {
 "email" : "customer2@customer.com"
 },
 "stars" : 2
 }
 ]
}
{
 "_id" : ObjectId("54837b69f059b08503e200dd"),
 "name" : "Product 3",
 "description" : "Product 3 description",
 "price" : 30,
 "supplier" : {
 "name" : "Supplier 3",
 "telephone" : "+552177776666"
 },
 "review" : [
 {
 "customer" : {
 "email" : "customer@customer.com"
 },
 "stars" : 5
 },
 {
 "customer" : {
 "email" : "customer2@customer.com"
 },
 "stars" : 9
 }
 ]
}

比较运算符

MongoDB 为我们提供了一种定义值之间相等关系的方式。通过比较运算符,我们可以比较 BSON 类型的值。让我们看看这些运算符:

  • $gte 运算符负责搜索等于或大于查询中指定值的值。如果我们执行查询 db.products.find({price: {$gte: 20}}),它将返回:
{
 "_id" : ObjectId("54837b65f059b08503e200dc"),
 "name" : "Product 2",
 "description" : "Product 2 description",
 "price" : 20,
 "supplier" : {
 "name" : "Supplier 2",
 "telephone" : "+552188887777"
 },
 "review" : [
 {
 "customer" : {
 "email" : "customer@customer.com"
 },
 "stars" : 10
 },
 {
 "customer" : {
 "email" : "customer2@customer.com"
 },
 "stars" : 2
 }
 ]
}
{
 "_id" : ObjectId("54837b69f059b08503e200dd"),
 "name" : "Product 3",
 "description" : "Product 3 description",
 "price" : 30,
 "supplier" : {
 "name" : "Supplier 3",
 "telephone" : "+552177776666"
 },
 "review" : [
 {
 "customer" : {
 "email" : "customer@customer.com"
 },
 "stars" : 5
 },
 {
 "customer" : {
 "email" : "customer2@customer.com"
 },
 "stars" : 9
 }
 ]
}

  • 使用 $lt 运算符,可以搜索小于查询中请求的值的值。查询 db.products.find({price: {$lt: 20}}) 将返回:
{
 "_id" : ObjectId("54837b61f059b08503e200db"),
 "name" : "Product 1",
 "description" : "Product 1 description",
 "price" : 10,
 "supplier" : {
 "name" : "Supplier 1",
 "telephone" : "+552199998888"
 },
 "review" : [
 {
 "customer" : {
 "email" : "customer@customer.com"
 },
 "stars" : 5
 },
 {
 "customer" : {
 "email" : "customer2@customer.com"
 },
 "stars" : 6
 }
 ]
}

  • $lte 运算符搜索小于或等于查询中请求的值的值。如果我们执行查询 db.products.find({price: {$lte: 20}}),它将返回:
{
 "_id" : ObjectId("54837b61f059b08503e200db"),
 "name" : "Product 1",
 "description" : "Product 1 description",
 "price" : 10,
 "supplier" : {
 "name" : "Supplier 1",
 "telephone" : "+552199998888"
 },
 "review" : [
 {
 "customer" : {
 "email" : "customer@customer.com"
 },
 "stars" : 5
 },
 {
 "customer" : {
 "email" : "customer2@customer.com"
 },
 "stars" : 6
 }
 ]
}
{
 "_id" : ObjectId("54837b65f059b08503e200dc"),
 "name" : "Product 2",
 "description" : "Product 2 description",
 "price" : 20,
 "supplier" : {
 "name" : "Supplier 2",
 "telephone" : "+552188887777"
 },
 "review" : [
 {
 "customer" : {
 "email" : "customer@customer.com"
 },
 "stars" : 10
 },
 {
 "customer" : {
 "email" : "customer2@customer.com"
 },
 "stars" : 2
 }
 ]
}

  • $in 运算符能够搜索任何字段值等于查询中请求的数组中指定的值的文档。执行查询 db.products.find({price:{$in: [5, 10, 15]}}) 将返回:
{
 "_id" : ObjectId("54837b61f059b08503e200db"),
 "name" : "Product 1",
 "description" : "Product 1 description",
 "price" : 10,
 "supplier" : {
 "name" : "Supplier 1",
 "telephone" : "+552199998888"
 },
 "review" : [
 {
 "customer" : {
 "email" : "customer@customer.com"
 },
 "stars" : 5
 },
 {
 "customer" : {
 "email" : "customer2@customer.com"
 },
 "stars" : 6
 }
 ]
}

  • $nin 运算符将匹配不包含在指定数组中的值。执行 db.products.find({price:{$nin: [10, 20]}}) 查询将产生:
{
 "_id" : ObjectId("54837b69f059b08503e200dd"),
 "name" : "Product 3",
 "description" : "Product 3 description",
 "price" : 30,
 "supplier" : {
 "name" : "Supplier 3",
 "telephone" : "+552177776666"
 },
 "review" : [
 {
 "customer" : {
 "email" : "customer@customer.com"
 },
 "stars" : 5
 },
 {
 "customer" : {
 "email" : "customer2@customer.com"
 },
 "stars" : 9
 }
 ]
}

  • $ne 运算符将匹配任何不等于查询中指定值的值。执行 db.products.find({name: {$ne: "Product 1"}}) 查询将产生:
{
 "_id" : ObjectId("54837b65f059b08503e200dc"),
 "name" : "Product 2",
 "description" : "Product 2 description",
 "price" : 20,
 "supplier" : {
 "name" : "Supplier 2",
 "telephone" : "+552188887777"
 },
 "review" : [
 {
 "customer" : {
 "email" : "customer@customer.com"
 },
 "stars" : 10
 },
 {
 "customer" : {
 "email" : "customer2@customer.com"
 },
 "stars" : 2
 }
 ]
}
{
 "_id" : ObjectId("54837b69f059b08503e200dd"),
 "name" : "Product 3",
 "description" : "Product 3 description",
 "price" : 30,
 "supplier" : {
 "name" : "Supplier 3",
 "telephone" : "+552177776666"
 },
 "review" : [
 {
 "customer" : {
 "email" : "customer@customer.com"
 },
 "stars" : 5
 },
 {
 "customer" : {
 "email" : "customer2@customer.com"
 },
 "stars" : 9
 }
 ]
}

逻辑运算符

逻辑运算符是我们在 MongoDB 中定义值之间逻辑关系的方式。这些源自布尔代数,布尔值的真值可以是 truefalse。让我们看看 MongoDB 中的逻辑运算符:

  • $and 运算符将在表达式数组中执行逻辑 AND 操作,并返回匹配所有指定条件的值。执行 db.products.find({$and: [{price: {$lt: 30}}, {name: "Product 2"}]}) 查询将产生:
{
 "_id" : ObjectId("54837b65f059b08503e200dc"),
 "name" : "Product 2",
 "description" : "Product 2 description",
 "price" : 20,
 "supplier" : {
 "name" : "Supplier 2",
 "telephone" : "+552188887777"
 },
 "review" : [
 {
 "customer" : {
 "email" : "customer@customer.com"
 },
 "stars" : 10
 },
 {
 "customer" : {
 "email" : "customer2@customer.com"
 },
 "stars" : 2
 }
 ]
}

  • $or 运算符将在表达式数组中执行逻辑 OR 操作,并返回匹配任一指定条件的所有值。执行 db.products.find({$or: [{price: {$gt: 50}}, {name: "Product 3"}]}) 查询将产生:
{
 "_id" : ObjectId("54837b69f059b08503e200dd"),
 "name" : "Product 3",
 "description" : "Product 3 description",
 "price" : 30,
 "supplier" : {
 "name" : "Supplier 3",
 "telephone" : "+552177776666"
 },
 "review" : [
 {
 "customer" : {
 "email" : "customer@customer.com"
 },
 "stars" : 5
 },
 {
 "customer" : {
 "email" : "customer2@customer.com"
 },
 "stars" : 9
 }
 ]
}

  • $not 运算符反转查询效果,并返回不匹配指定运算符表达式的值。它用于否定任何操作。执行 db.products.find({price: {$not: {$gt: 10}}}) 查询将产生:
{
 "_id" : ObjectId("54837b61f059b08503e200db"),
 "name" : "Product 1",
 "description" : "Product 1 description",
 "price" : 10,
 "supplier" : {
 "name" : "Supplier 1",
 "telephone" : "+552199998888"
 },
 "review" : [
 {
 "customer" : {
 "email" : "customer@customer.com"
 },
 "stars" : 5
 },
 {
 "customer" : {
 "email" : "customer2@customer.com"
 },
 "stars" : 6
 }
 ]
}

  • $nor 运算符将在表达式数组中执行逻辑 NOR 操作,并返回所有未能匹配数组中所有指定表达式的值。执行 db.products.find({$nor:[{price:{$gt: 35}}, {price:{$lte: 20}}]}) 查询将产生:
{
 "_id" : ObjectId("54837b69f059b08503e200dd"),
 "name" : "Product 3",
 "description" : "Product 3 description",
 "price" : 30,
 "supplier" : {
 "name" : "Supplier 3",
 "telephone" : "+552177776666"
 },
 "review" : [
 {
 "customer" : {
 "email" : "customer@customer.com"
 },
 "stars" : 5
 },
 {
 "customer" : {
 "email" : "customer2@customer.com"
 },
 "stars" : 9
 }
 ]
}

元素运算符

要查询集合关于我们文档字段的信息,我们可以使用元素运算符。

$exists 运算符将返回查询中具有指定字段的所有文档。执行 db.products.find({sku: {$exists: true}}) 将不会返回任何文档,因为它们都没有 sku 字段。

评估运算符

评估运算符是我们在 MongoDB 中对表达式进行评估的方式。我们必须小心使用这种类型的运算符,特别是如果我们正在使用的字段没有索引。让我们考虑评估运算符:

  • $regex 运算符将返回所有匹配正则表达式的值。执行 db.products.find({name: {$regex: /2/}}) 将返回:
{
 "_id" : ObjectId("54837b65f059b08503e200dc"),
 "name" : "Product 2",
 "description" : "Product 2 description",
 "price" : 20,
 "supplier" : {
 "name" : "Supplier 2",
 "telephone" : "+552188887777"
 },
 "review" : [
 {
 "customer" : {
 "email" : "customer@customer.com"
 },
 "stars" : 10
 },
 {
 "customer" : {
 "email" : "customer2@customer.com"
 },
 "stars" : 2
 }
 ]
}

数组运算符

当我们在查询中使用数组时,应该使用数组运算符。让我们考虑数组运算符:

  • $elemMatch操作符将返回所有指定数组字段值至少有一个与查询条件匹配的元素的文档。

db.products.find({review: {$elemMatch: {stars: {$gt: 5}, customer: {email: "customer@customer.com"}}}})查询将查看所有集合文档,其中review字段有文档,stars字段值大于5,并且customer emailcustomer@customer.com

{
 "_id" : ObjectId("54837b65f059b08503e200dc"),
 "name" : "Product 2",
 "description" : "Product 2 description",
 "price" : 20,
 "supplier" : {
 "name" : "Supplier 2",
 "telephone" : "+552188887777"
 },
 "review" : [
 {
 "customer" : {
 "email" : "customer@customer.com"
 },
 "stars" : 10
 },
 {
 "customer" : {
 "email" : "customer2@customer.com"
 },
 "stars" : 2
 }
 ]
}

注意

除了已呈现的操作符外,我们还有:$mod$text$where$all$geoIntersects$geoWithin$nearSphere$near$size$comment。您可以在 MongoDB 手册参考中找到更多关于这方面的信息docs.mongodb.org/manual/reference/operator/query/

投影

到目前为止,我们执行的查询中呈现的结果是文档在 MongoDB 中持久化的样子。但是,为了优化 MongoDB 与其客户端之间的网络开销,我们应该使用投影。

正如您在本章开头看到的,find接口允许我们使用两个参数。第二个参数是投影。

通过在上一节中使用的相同示例集合,具有投影的查询示例将是:

db.products.find({price: {$not: {$gt: 10}}}, {name: 1, description: 1})

这个查询产生:

{
 "_id" : ObjectId("54837b61f059b08503e200db"),
 "name" : "Product 1",
 "description" : "Product 1 description"
}

投影是一个 JSON 文档,其中包含我们想要呈现或隐藏的所有字段,后面跟着01,取决于我们的需求。

当一个字段后面跟着0,那么这个字段将不会显示在结果文档中。另一方面,如果字段后面跟着1,那么这意味着它将显示在结果文档中。

注意

默认情况下,_id字段的值为1

db.products.find({price: {$not: {$gt: 10}}}, {_id: 0, name: 1, "supplier.name": 1})查询将显示以下文档:

{ "name" : "Product 1", "supplier" : { "name" : "Supplier 1" } }

在具有数组值的字段中,我们可以使用$elemMatch$split$slice$等操作符。

db.products.find({price: {$gt: 20}}, {review: {$elemMatch: {stars: 5}}}) 查询将产生:

{
 "_id" : ObjectId("54837b69f059b08503e200dd"),
 "review" : [
 {
 "customer" : {
 "email" : "customer@customer.com"
 },
 "stars" : 5
 }
 ]
}

介绍写操作

在 MongoDB 中,我们有三种写操作:插入、更新和删除。为了运行这些操作,MongoDB 提供了三个接口:db.document.insertdb.document.updatedb.document.remove。MongoDB 中的写操作针对特定集合,并且在单个文档级别上是原子的。

在 MongoDB 中,当我们对文档进行建模时,写操作和读操作一样重要。单个文档级别的原子性可以决定我们是否嵌入文档。我们将在第七章扩展中更详细地讨论这个问题,但选择分片键的活动将决定我们是否写入操作的性能,因为根据键的选择,我们将在一个或多个分片上进行写入。

此外,写操作性能的另一个决定因素与 MongoDB 物理模型有关。10gen 提出了许多建议,但让我们专注于对我们的开发产生最大影响的建议。由于 MongoDB 的更新模型是基于随机 I/O 操作的,建议您使用固态硬盘或 SSD。与旋转硬盘相比,固态硬盘在随机 I/O 操作方面具有更高的性能。尽管旋转硬盘更便宜,基于这种硬件的基础设施扩展成本也不是很昂贵,但使用 SSD 或增加 RAM 仍然更有效。关于这个主题的研究表明,SSD 在随机 I/O 操作方面比旋转硬盘性能提高了 100 倍。

关于写操作的另一个重要事项是了解 MongoDB 如何实际将文档写入磁盘。MongoDB 使用日志记录机制来写入操作,该机制在写入数据文件之前使用日志来写入更改操作。这在发生脏关闭时非常有用。当mongod进程重新启动时,MongoDB 将使用日志文件将数据库状态恢复到一致状态。

如第二章中所述,“使用 MongoDB 进行数据建模”,BSON 规范允许我们拥有最大大小为 16MB 的文档。自其 2.6 版本以来,MongoDB 使用了一种名为“二次幂大小分配”的记录或文档的空间分配策略。正如其名称所示,MongoDB 将为每个文档分配一个字节大小,即其大小的二次幂(例如,32、64、128、256、512,...),考虑到文档的最小大小为 32 字节。该策略分配的空间比文档实际需要的空间更多,从而为其提供更多的增长空间。

插入

insert接口是在 MongoDB 中创建新文档的可能方式之一。insert接口具有以下语法:

db.collection.insert(
 <document or array of documents>, 
 { 
 writeConcern: <document>, 
 ordered: <boolean> 
 }
)

在这里:

  • 文档或文档数组是一个文档或一个包含一个或多个文档的数组,应该在目标集合中创建。

  • writeConcern是表示写入关注的文档。

  • ordered应该是一个布尔值,如果为 true,将在数组的文档上执行有序过程,如果文档中有错误,MongoDB 将停止处理它。否则,如果值为 false,将执行无序过程,如果发生错误,将不会停止。默认情况下,值为true

在下面的示例中,我们可以看到如何使用insert操作:

db.customers.insert({
 username: "customer1", 
 email: "customer1@customer.com", 
 password: hex_md5("customer1paswd")
})

由于我们没有为_id字段指定值,它将自动生成具有唯一ObjectId值的值。此insert操作创建的文档是:

{ 
 "_id" : ObjectId("5487ada1db4ff374fd6ae6f5"), 
 "username" : "customer1", 
 "email" : "customer1@customer.com", 
 "password" : "b1c5098d0c6074db325b0b9dddb068e1" 
}

正如您在本节的第一段中观察到的,insert接口不是在 MongoDB 中创建新文档的唯一方式。通过在更新上使用upsert选项,我们也可以创建新文档。现在让我们更详细地了解一下这个。

更新

update接口用于修改 MongoDB 中先前存在的文档,甚至创建新文文档。为了选择要更改的文档,我们将使用条件。更新可以修改文档的字段值或整个文档。

更新操作一次只会修改一个文档。如果条件匹配多个文档,则需要通过multi参数为true的文档传递给 update 接口。如果条件不匹配任何文档,并且upsert参数为true,则将创建一个新文档,否则将更新匹配的文档。

update接口表示为:

db.collection.update(
 <query>,
 <update>,
 { 
 upsert: <boolean>, 
 multi: <boolean>, 
 writeConcern: <document> 
 }
)

在这里:

  • query是条件

  • update是包含要应用的修改的文档

  • upsert是一个布尔值,如果为 true,则在集合中没有匹配任何文档的情况下创建一个新文档

  • multi是一个布尔值,如果为 true,则更新满足条件的每个文档

  • writeConcern是表示写入关注的文档

使用上一节中创建的文档,示例更新将是:

db.customers.update(
 {username: "customer1"}, 
 {$set: {email: "customer1@customer1.com"}}
)

修改后的文档是:

{ 
 "_id" : ObjectId("5487ada1db4ff374fd6ae6f5"), 
 "username" : "customer1", 
 "email" : "customer1@customer1.com", 
 "password" : "b1c5098d0c6074db325b0b9dddb068e1"
}

$set运算符允许我们仅更新匹配文档的email字段。

否则,您可能会有此更新:

db.customers.update(
 {username: "customer1"}, 
 {email: "customer1@customer1.com"}
)

在这种情况下,修改后的文档将是:

{ 
 "_id" : ObjectId("5487ada1db4ff374fd6ae6f5"), 
 "email" : "customer1@customer1.com" 
}

也就是说,没有$set运算符,我们使用传递给更新的参数修改旧文档。除了$set运算符之外,我们还有其他重要的更新运算符:

  • $inc增加具有指定值的字段的值:
db.customers.update(
 {username: "johnclay"}, 
 {$inc: {"details.age": 1}}
)

此更新将在匹配文档中将字段details.age增加 1。

  • $rename将重命名指定的字段:
db.customers.update(
 {email: "customer1@customer1.com"}, 
 {$rename: {username: "login"}}
)

此更新将在匹配的文档中将字段username重命名为login

  • $unset将从匹配的文档中删除字段:
db.customers.update(
 {email: "customer1@customer1.com"}, 
 {$unset: {login: ""}}
)

此更新将从匹配的文档中删除login字段。

由于写操作在单个文档级别是原子的,我们可以在使用前面的操作符时放心大胆。所有这些操作符都可以安全使用。

写关注点

围绕非关系型数据库的许多讨论与 ACID 概念有关。作为数据库专业人员、软件工程师、架构师和开发人员,我们对关系型宇宙非常熟悉,并且花费了大量时间开发而不关心 ACID 问题。

尽管如此,我们现在应该明白为什么我们真的必须考虑这个问题,以及这些简单的四个字母在非关系型世界中是如此重要。在本节中,我们将讨论D这个字母,在 MongoDB 中意味着持久性。

数据库系统中的持久性是一个属性,告诉我们写入操作是否成功,事务是否已提交,数据是否已写入非易失性存储器中,例如硬盘。

与关系型数据库系统不同,NoSQL 数据库中对写操作的响应由客户端确定。再次,我们有可能在数据建模上做出选择,满足客户的特定需求。

在 MongoDB 中,成功写入操作的响应可以有多个级别的保证。这就是我们所说的写入关注点。这些级别从弱到强不等,客户端确定保证的强度。在同一个集合中,我们可以有一个需要强写入关注点的客户端,另一个需要弱写入关注点的客户端。

MongoDB 提供给我们的写入关注点级别是:

  • 未确认

  • 确认

  • 已记录

  • 副本已确认

未确认

顾名思义,使用未确认的写入关注点,客户端将不会尝试响应写入操作。如果可能的话,只会捕获网络错误。以下图表显示驱动程序将不等待 MongoDB 确认接收写入操作:

![未确认](img / B04075_03_01.jpg)

在以下示例中,我们在customers集合中进行了一个未确认的写入操作:

db.customers.insert(
{username: "customer1", email: "customer1@customer.com", password: hex_md5("customer1paswd")}, 
{writeConcern: {w: 0}}
)

确认

使用此写入关注点,客户端将收到写入操作的确认,并看到它已在 MongoDB 的内存视图上写入。在这种模式下,客户端可以捕获网络错误和重复键等问题。自 MongoDB 2.6 版本以来,这是默认的写入关注点。

正如您之前看到的,我们无法保证 MongoDB 的内存视图上的写入将持久保存在磁盘上。如果 MongoDB 发生故障,内存视图中的数据将丢失。以下图表显示驱动程序等待 MongoDB 确认接收写入操作,并将更改应用于数据的内存视图:

![已确认](img / B04075_03_02.jpg)

在以下示例中,我们在customers集合中进行了一个已确认的写入操作:

db.customers.insert(
{username: "customer1", email: "customer1@customer.com", password: hex_md5("customer1paswd")}, 
{writeConcert: {w: 1}}
)

已记录

使用已记录的写入关注点,客户端将收到确认写入操作已在日志中提交的确认。因此,客户端将保证数据将持久保存在磁盘上,即使发生了 MongoDB 的故障。

为了减少使用已记录的写入关注点时的延迟,MongoDB 将将操作提交到日志的频率从默认值 100 毫秒降低到 30 毫秒。以下图表显示驱动程序将等待 MongoDB 确认接收写入操作,只有在将数据提交到日志后才会等待:

![已记录](img / B04075_03_03.jpg)

在下面的示例中,我们在customers集合中使用了一个日志写关注的insert

db.customers.insert(
{username: "customer1", email: "customer1@customer.com", password: hex_md5("customer1paswd")}, 
{writeConcern: {w: 1, j: true}}

)

副本已确认

当我们使用副本集时,重要的是要确保写操作不仅在主节点上成功,而且还传播到副本集的成员。为此,我们使用了一个副本已确认的写关注。

通过将默认写关注更改为副本已确认,我们可以确定我们希望从副本集的成员中获得写操作确认的数量。下图显示了驱动程序将等待 MongoDB 确认在指定数量的副本集成员上接收写操作:

副本已确认

在下面的示例中,我们将等待写操作传播到主节点和至少两个辅助节点:

db.customers.insert(
{username: "customer1", email: "customer1@customer.com", password: hex_md5("customer1paswd")}, 
{writeConcern: {w: 3}}
)

我们应该在毫秒级别包含一个超时属性,以避免写操作在节点故障的情况下仍然被阻塞。

在下面的示例中,我们将等待写操作传播到主节点和至少两个辅助节点,并设置了三秒的超时。如果我们期望响应的两个辅助节点中的一个失败,那么该方法将在三秒后超时:

db.customers.insert(
{username: "customer1", email: "customer1@customer.com", password: hex_md5("customer1paswd")}, 
{writeConcern: {w: 3, wtimeout: 3000}}
)

批量编写文档

有时,一次插入、更新或删除集合中的多条记录是非常有用的。MongoDB 为我们提供了执行批量写操作的能力。批量操作在单个集合中工作,可以是有序的或无序的。

insert方法一样,有序批量操作的行为是按顺序处理记录,如果发生错误,MongoDB 将返回而不处理任何剩余的操作。

无序操作的行为是并行处理,因此如果发生错误,MongoDB 仍将处理剩余的操作。

我们还可以确定批量写操作所需的确认级别。自其 2.6 版本以来,MongoDB 引入了新的批量方法,我们可以使用这些方法插入、更新或删除文档。但是,我们只能通过在insert方法上传递文档数组来进行批量插入。

在下面的示例中,我们使用insert方法进行批量插入:

db.customers.insert(
[
{username: "customer3", email: "customer3@customer.com", password: hex_md5("customer3paswd")}, 
{username: "customer2", email: "customer2@customer.com", password: hex_md5("customer2paswd")}, 
{username: "customer1", email: "customer1@customer.com", password: hex_md5("customer1paswd")}
]
)

在下面的示例中,我们使用新的批量方法进行无序批量插入:

var bulk = db.customers.initializeUnorderedBulkOp();
bulk.insert({username: "customer1", email: "customer1@customer.com", password: hex_md5("customer1paswd")});
bulk.insert({username: "customer2", email: "customer2@customer.com", password: hex_md5("customer2paswd")});
bulk.insert({username: "customer3", email: "customer3@customer.com", password: hex_md5("customer3paswd")});
bulk.execute({w: "majority", wtimeout: 3000});

我们应该利用 MongoDB 提供给我们的所有强大工具,但不要忽视任何可能的注意事项。MongoDB 一次最多执行 1,000 个批量操作的限制。因此,如果超过此限制,MongoDB 将把操作分成最多 1,000 个批量操作的组。

摘要

在本章中,您希望能够更好地理解 MongoDB 中的读写操作。此外,现在,您还应该明白为什么在文档建模过程之前就已经知道需要执行的查询是很重要的。最后,您学会了如何使用 MongoDB 的属性,比如原子性,在文档级别上,并看到它如何帮助我们生成更好的查询。

在下一章中,您将看到一种称为索引的特殊数据结构如何改进我们查询的执行。

第四章:索引

正如您在关系数据库的主题中所看到的,索引是在考虑性能提升时重要的结构。实际上,索引非常重要,以至于对于大多数数据库管理员来说,它们是搜索持续改进数据库性能的关键工具。

在 MongoDB 等 NoSQL 数据库中,索引是更大策略的一部分,这将使我们能够在性能上获得许多收益,并为我们的数据库分配重要的行为,这对数据模型的维护至关重要。

这是因为在 MongoDB 中,我们可以有具有非常特殊属性的索引。例如,我们可以定义一个日期类型字段的索引,该索引将控制何时从集合中删除文档。

因此,在本章中我们将看到:

  • 索引文档

  • 索引类型

  • 特殊索引属性

索引文档

在本书迄今为止讨论的所有主题中,这是我们最熟悉的地方。索引概念几乎存在于每个关系数据库中,因此如果您对此有任何基本的先前知识,您在本章中很可能不会有困难。

但是,如果您觉得自己对索引的概念不够熟悉,理解它们的简单方法是与书籍进行类比。假设我们有一本书,其索引如下:

索引文档

有了这个,如果我们决定阅读有关互联网的信息,我们知道在第4页上会找到有关这个主题的信息。另一方面,如果没有页码,我们如何能找到我们正在寻找的信息呢?答案很简单:逐页浏览整本书,直到找到“互联网”这个词。

正如您可能已经知道的那样,索引是保存来自我们主要数据源的数据部分的数据结构。在关系数据库中,索引保存表的部分,而在 MongoDB 中,由于索引是在集合级别上的,这些将保存文档的部分。与关系数据库类似,索引在实现级别使用 B-Tree 数据结构。

根据我们应用程序的要求,我们可以创建字段的索引或嵌入文档的字段。当我们创建索引时,它将保存我们选择的字段的排序值集。

因此,当我们执行查询时,如果有一个覆盖查询条件的索引,MongoDB 将使用该索引来限制要扫描的文档数量。

我们有一个customers集合,我们在第三章中使用过,查询文档,其中包含这些文档:

{
 "_id" : ObjectId("54aecd26867124b88608b4c9"),
 "username" : "customer1",
 "email" : "customer1@customer.com",
 "password" : "b1c5098d0c6074db325b0b9dddb068e1"
}

我们可以在 mongo shell 上使用createIndex方法在username字段上创建索引:

db.customers.createIndex({username: 1})

以下查询将使用先前创建的索引:

db.customers.find({username: "customer1"})

注意

自 3.0.0 版本以来,ensureIndex方法已被弃用,并且是createIndex方法的别名。

我们可以说这是在 MongoDB 中创建和使用索引的最简单方法。除此之外,我们还可以在多键字段或嵌入文档的字段上创建索引,例如。

在下一节中,我们将介绍所有这些索引类型。

对单个字段进行索引

正如我们在上一节中所述,在 MongoDB 上创建索引的最简单方法是在单个字段上这样做。索引可以在文档集合中的任何类型的字段上创建。

考虑到我们之前使用过的customers集合,对其进行了一些修改以适应本节的工作:

{
 "_id" : ObjectId("54aecd26867124b88608b4c9"),
 "username" : "customer1",
 "email" : "customer1@customer.com",
 "password" : "b1c5098d0c6074db325b0b9dddb068e1",
 "age" : 25,
 "address" : {

 "street" : "Street 1",
 "zipcode" : "87654321",
 "state" : "RJ"

 }
}

以下命令在username字段中创建一个升序索引:

db.customers.createIndex({username: 1})

为了在 MongoDB 中创建索引,我们使用createIndex方法。在前面的代码中,我们只是将单个文档作为参数传递给createIndex方法。文档{username: 1}包含对应于应该创建索引的字段和顺序的引用:1 表示升序,-1 表示降序。

创建相同的索引的另一种方法,但按降序顺序进行:

db.customers.createIndex({username: -1})

在下面的查询中,MongoDB 将使用在username字段中创建的索引来减少应该检查的customers集合中文档的数量:

db.customers.find({username: "customer1"})

除了在集合文档中的字符串或数字字段上创建索引,我们还可以在嵌入式文档的字段上创建索引。因此,这样的查询将使用创建的索引:

db.customers.createIndex({"address.state": 1})

以下代码创建了嵌入地址文档的state字段的索引:

db.customers.find({"address.state": "RJ"})

虽然有点复杂,但我们也可以创建整个嵌入式文档的索引:

db.customers.createIndex({address: 1})

以下查询将使用索引:

db.customers.find(
{
 "address" : 
 { 
 "street" : "Street 1", 
 "zipcode" : "87654321", 
 "state" : "RJ"
 }
}
)

但是,这些查询都不会这样做:

db.customers.find({state: "RJ"})

db.customers.find({address: {zipcode: "87654321"}})

这是因为为了匹配嵌入式文档,我们必须精确匹配整个文档,包括字段顺序。以下查询也不会使用索引:

db.customers.find(
{
 "address" : 
 { 
 "state" : "RJ", 
 "street" : "Street 1", 
 "zipcode" : "87654321" 
 }
}
)

尽管文档包含所有字段,但这些字段的顺序不同。

在继续下一种索引类型之前,让我们回顾一下您在第三章中学到的一个概念,即_id字段。对于集合中创建的每个新文档,我们应该指定_id字段。如果我们不指定,MongoDB 会自动为我们创建一个ObjectId类型的_id。此外,每个集合都会自动创建_id字段的唯一升序索引。也就是说,我们可以说_id字段是文档的主键。

索引多个字段

在 MongoDB 中,我们可以创建一个保存多个字段值的索引。我们应该称这种索引为复合索引。单字段索引和复合索引之间没有太大的区别。最大的区别在于排序顺序。在我们继续讨论复合索引的特点之前,让我们使用customers集合来创建我们的第一个复合索引:

{
 "_id" : ObjectId("54aecd26867124b88608b4c9"),
 "username" : "customer1",
 "email" : "customer1@customer.com",
 "password" : "b1c5098d0c6074db325b0b9dddb068e1",
 "age" : 25,
 "address" : {
 "street" : "Street 1",
 "zipcode" : "87654321",
 "state" : "RJ"
 }
}

我们可以想象一个应用程序,它想要使用usernamepassword字段一起在查询中对客户进行身份验证。

db.customers.find(
{
username: "customer1", 
password: "b1c5098d0c6074db325b0b9dddb068e1"
}
)

为了在执行此查询时获得更好的性能,我们可以创建usernamepassword字段的索引:

db.customers.createIndex({username: 1, password: 1})

尽管如此,对于以下查询,MongoDB 是否使用复合索引?

#Query 1
db.customers.find({username: "customer1"})
#Query 2
db.customers.find({password: "b1c5098d0c6074db325b0b9dddb068e1"})
#Query 3
db.customers.find(
{
 password: "b1c5098d0c6074db325b0b9dddb068e1", 
 username: "customer1"
}
)

对于Query 1Query 3的答案是肯定的。如前所述,顺序在创建复合索引时非常重要。创建的索引将引用按username字段排序的文档,并在每个用户名条目内,按密码条目排序。因此,只有password字段作为条件的查询将不使用索引。

假设我们在customers集合中有以下索引:

db.customers.createIndex(
{
 "address.state":1, 
 "address.zipcode": 1, 
 "address.street": 1
})

您可能会问哪些查询将使用我们的新复合索引?在回答这个问题之前,我们需要了解 MongoDB 中的复合索引概念:前缀。复合索引中的前缀是索引字段的子集。顾名思义,它是索引中优先于其他字段的字段。在我们的例子中,{"address.state":1}{"address.state":1, "address.zipcode": 1}都是索引前缀。

具有任何索引前缀的查询都将使用复合索引。因此,我们可以推断出:

  • 包括address.state字段的查询将使用复合索引

  • 包括address.stateaddress.zipcode字段的查询也将使用复合索引

  • 具有address.stateaddress.zipcodeaddress.street的查询也将使用复合索引

  • 同时具有address.stateaddress.street的查询也将使用复合索引

复合索引不会在以下查询中使用:

  • 只有address.zipcode字段

  • 只有address.street字段

  • 同时具有address.zipcodeaddress.street字段

注意

我们应该注意,尽管查询同时使用address.stateaddress.street字段使用索引,如果我们为每个字段单独创建单个索引,我们可以在此查询中获得更好的性能。这是因为复合索引首先按address.state排序,然后按address.zipcode字段排序,最后按address.street字段排序。因此,MongoDB 检查此索引要比检查其他两个索引要昂贵得多。

因此,对于此查询:

db.customers.find(
{
 "address.state": "RJ", 
 "address.street": "Street 1"
}
)

如果我们有这个索引将更有效:

db.customers.createIndex({"address.state": 1, "address.street": 1})

多键字段的索引

在 MongoDB 中创建索引的另一种方法是创建数组字段的索引。这些索引可以包含原始值的数组,例如字符串和数字,甚至包含文档的数组。

在创建多键索引时,我们必须特别注意。特别是当我们想要创建复合多键索引时。无法创建两个数组字段的复合索引。

注意

我们无法创建并行数组的索引的主要原因是因为它们将要求索引包括复合键的笛卡尔积中的条目,这将导致一个大型索引。

考虑具有以下文档的customers集合:

{
 "_id" : ObjectId("54aecd26867124b88608b4c9"),
 "username" : "customer1",
 "email" : "customer1@customer.com",
 "password" : "b1c5098d0c6074db325b0b9dddb068e1",
 "age" : 25,
 "address" : {
 "street" : "Street 1",
 "zipcode" : "87654321",
 "state" : "RJ"
 },
 "followedSellers" : [
 "seller1",
 "seller2",
 "seller3"
 ],
 "wishList" : [
 {
 "sku" : 123,
 "seller" : "seller1"
 },
 {
 "sku" : 456,
 "seller" : "seller2"
 },
 {
 "sku" : 678,
 "seller" : "seller3"
 }
 ]
}

我们可以为此集合创建以下索引:

db.customers.createIndex({followedSellers: 1})

db.customers.createIndex({wishList: 1})

db.customers.createIndex({"wishList.sku": 1})

db.customers.createIndex({"wishList.seller": 1})

但是无法创建以下索引:

db.customers.createIndex({followedSellers: 1, wishList: 1}

用于文本搜索的索引

自 2.4 版本以来,MongoDB 为我们提供了创建索引以帮助我们进行文本搜索的机会。尽管有许多专门的工具,例如 Apache Solr、Sphinx 和 ElasticSearch,用于此目的,但大多数关系型和 NoSQL 数据库都具有本地全文搜索功能。

可以在集合中创建字符串或字符串字段数组的文本索引。对于以下示例,我们将使用我们在第三章中也使用的products集合,查询文档,但进行了一些修改:

{ 
 "_id" : ObjectId("54837b61f059b08503e200db"), 
 "name" : "Product 1", 
 "description" : 
 "Product 1 description", 
 "price" : 10, 
 "supplier" : { 
 "name" : "Supplier 1", 
 "telephone" : "+552199998888" 
 }, 
 "review" : [ 
 { 
 "customer" : { 
 "email" : "customer@customer.com" 
 }, 
 "stars" : 5 
 }
 ],
 "keywords" : [ "keyword1", "keyword2", "keyword3" ] 
}

我们可以通过在createIndex方法中指定text参数来创建文本索引:

db.products.createIndex({name: "text"})

db.products.createIndex({description: "text"})

db.products.createIndex({keywords: "text"})

所有上述命令都可以创建products集合的文本索引。但是,MongoDB 有一个限制,即每个集合只能有一个文本索引。因此,只能为products集合执行先前的命令中的一个。

尽管每个集合只能创建一个文本索引的限制,但可以创建复合文本索引:

db.products.createIndex({name: "text", description: "text"})

上述命令为namedescription字段创建了一个text索引字段。

注意

创建集合的文本索引的一种常见且有用的方法是为集合的所有文本字段创建索引。有一个特殊的语法用于创建此索引,您可以如下所示:

db.products.createIndex({"$**","text"})

要使用文本索引进行查询,我们应该在其中使用$text运算符。为了更好地理解如何创建有效的查询,了解索引的创建方式是很好的。事实上,使用$text运算符执行查询时使用相同的过程。

总结该过程,我们可以将其分为三个阶段:

  • 标记化

  • 删除后缀和/或前缀,或词干处理

  • 删除停用词

为了优化我们的查询,我们可以指定我们在文本字段中使用的语言,因此在我们的文本索引中使用的语言,以便 MongoDB 将在索引过程的所有三个阶段中使用单词列表。

自 2.6 版本以来,MongoDB 支持以下语言:

  • dadanish

  • nldutch

  • enenglish

  • fifinnish

  • frfrench

  • degerman

  • huhungarian

  • ititalian

  • nbnorwegian

  • ptportuguese

  • roromanian

  • rurussian

  • esspanish

  • svswedish

  • trturkish

具有语言的索引创建示例可能是:

db.products.createIndex({name: "text"},{ default_language: "pt"})

我们还可以选择不使用任何语言,只需使用none值创建索引:

db.products.createIndex({name: "text"},{ default_language: "none"})

通过使用none值选项,MongoDB 将仅执行标记化和词干处理;它不会加载任何停用词列表。

当我们决定使用文本索引时,我们应该始终加倍注意。每一个细节都会对我们设计文档的方式产生副作用。在 MongoDB 的早期版本中,在创建文本索引之前,我们应该将所有集合的分配方法更改为usePowerOf2Sizes。这是因为文本索引被认为是较大的索引。

另一个主要关注点发生在创建索引的时刻。根据现有集合的大小,索引可能非常大,要创建一个非常大的索引,我们需要很多时间。因此,最好安排这个过程在更及时的机会发生。

最后,我们必须预测文本索引对我们的写操作的影响。这是因为,对于我们集合中创建的每条新记录,还将创建一个引用所有索引值字段的索引条目。

创建特殊索引

除了我们到目前为止创建的所有索引类型,无论是升序还是降序,还是文本类型,我们还有三种特殊的索引:生存时间、唯一和稀疏。

生存时间索引

生存时间TTL)索引是基于生存时间的索引。该索引仅在日期类型的字段中创建。它们不能是复合索引,并且它们将在一定时间后自动从文档中删除。

这种类型的索引可以从日期向量创建。文档将在达到较低数组值时过期。MongoDB 负责通过后台任务在 60 秒的间隔内控制文档的过期。例如,让我们使用本章中一直在使用的customers集合:

{ 
"_id" : ObjectId("5498da405d0ffdd8a07a87ba"), 
"username" : "customer1", 
"email" : "customer1@customer.com", 
"password" : "b1c5098d0c6074db325b0b9dddb068e1", "accountConfirmationExpireAt" : ISODate("2015-01-11T20:27:02.138Z") 
}

基于accountConfirmationExpireAt字段的生存时间索引的创建命令将如下所示:

db.customers.createIndex(
{accountConfirmationExpireAt: 1}, {expireAfterSeconds: 3600}
)

该命令指示超过expireAfterSeconds字段中请求的秒值的每个文档将被删除。

还有另一种基于生存时间创建索引的方法,即定时方式。以下示例向我们展示了这种实现方法:

db.customers.createIndex({
accountConfirmationExpireAt: 1}, {expireAfterSeconds: 0}
)

这将确保您在上一个示例中看到的文档在 2015 年 1 月 11 日 20:27:02 过期。

这种类型的索引对于使用机器生成的事件、日志和会话信息的应用程序非常有用,这些信息只需要在特定时间内持久存在,正如您将在第八章中再次看到的那样,“使用 MongoDB 进行日志记录和实时分析”。

唯一索引

与绝大多数关系数据库一样,MongoDB 具有唯一索引。唯一索引负责拒绝索引字段中的重复值。唯一索引可以从单个字段或多键字段以及复合索引创建。创建唯一复合索引时,值的组合必须是唯一的。

如果我们在insert操作期间没有设置任何值,唯一字段的默认值将始终为 null。正如您之前所见,对于集合的_id字段创建的索引是唯一的。考虑customers集合的最后一个示例,可以通过执行以下操作创建唯一索引:

db.customers.createIndex({username: 1}, {unique: true})

该命令将创建一个username字段的索引,不允许重复的值。

稀疏索引

稀疏索引是仅在文档具有将被索引的字段值时才创建的索引。我们可以仅使用文档中的一个字段或使用更多字段来创建稀疏索引。这种情况被称为复合索引。当我们创建复合索引时,至少一个字段必须具有非空值。

customers集合中的以下文档为例:

{ "_id" : ObjectId("54b2e184bc471cf3f4c0a314"), "username" : "customer1", "email" : "customer1@customer.com", "password" : "b1c5098d0c6074db325b0b9dddb068e1" }
{ "_id" : ObjectId("54b2e618bc471cf3f4c0a316"), "username" : "customer2", "email" : "customer2@customer.com", "password" : "9f6a4a5540b8ebdd3bec8a8d23efe6bb" }
{ "_id" : ObjectId("54b2e629bc471cf3f4c0a317"), "username" : "customer3", "email" : "customer3@customer.com" }

使用以下示例命令,我们可以在customers集合中创建一个sparse索引:

db.customers.createIndex({password: 1}, {sparse: true})

以下示例查询使用了创建的索引:

db.customers.find({password: "9f6a4a5540b8ebdd3bec8a8d23efe6bb"})

另一方面,下面的示例查询,请求按索引字段的降序排列,将不使用索引:

db.customers.find().sort({password: -1})

总结

在本章中,我们看到索引是数据模型维护中非常重要的工具。通过在查询规划阶段包括索引创建,这将带来许多好处,尤其是在所谓的查询文档性能方面。

因此,您学会了如何创建单个、复合和多键索引。接下来,我们讨论了在 MongoDB 上如何以及何时使用索引进行文本搜索。然后我们介绍了特殊的索引类型,如 TTL、唯一和稀疏索引。

在下一章中,您将看到如何分析查询,从而以更高效的方式创建它们。

第五章:优化查询

现在,我们已经在理解如何使用索引来提高读写性能方面迈出了重要的一步,让我们看看如果这些索引表现如预期,我们如何分析它们,以及索引如何影响数据库的生命周期。除此之外,通过这种分析,我们将能够评估和优化创建的查询和索引。

因此,在本章中,我们将学习查询计划的概念以及 MongoDB 如何处理它。这包括理解查询覆盖和查询选择性,以及在分片环境和副本集中使用这些计划时的行为。

理解查询计划

当我们运行查询时,MongoDB 将通过从 MongoDB 查询优化器执行的查询分析中提取的一组可能性中选择最佳方式来执行查询。这些可能性称为查询计划

要更好地理解查询计划,我们必须回到游标概念和游标方法之一:explain()explain()方法是 MongoDB 3.0 版本中的重大变化之一。由于新的查询内省系统的出现,它得到了显着增强。

输出不仅发生了变化,正如我们之前看到的那样,使用方式也发生了变化。现在,我们可以向explain()方法传递一个选项参数,该参数指定explain输出的详细程度。可能的模式是"queryPlanner""executionStats""allPlansExecution"。默认模式是"queryPlanner"

  • "queryPlanner"模式下,MongoDB 运行查询优化器选择评估中的获胜计划,并将信息返回给评估方法。

  • "executionStats"模式下,MongoDB 运行查询优化器选择获胜计划,执行它,并将信息返回给评估方法。如果我们对写操作执行explain()方法,则返回有关将执行的操作的信息,但实际上不执行它。

  • 最后,在"allPlansExecution"模式下,MongoDB 运行查询优化器选择获胜计划,执行它,并将信息返回给评估方法,以及其他候选计划的信息。

提示

您可以在 MongoDB 3.0 参考指南的docs.mongodb.org/manual/reference/method/db.collection.explain/#db.collection.explain中找到有关explain()方法的更多信息。

explain执行的输出将查询计划显示为阶段树。从叶子到根,每个阶段将其结果传递给父节点。第一个阶段发生在叶节点上,访问集合或索引并将结果传递给内部节点。这些内部节点操作结果,最终阶段或根节点从中派生结果集。

有四个阶段:

  • COLLSCAN:这意味着在此阶段发生了完整的集合扫描

  • IXSCAN:这表示在此阶段发生了索引键扫描

  • FETCH:这是当我们检索文档时的阶段

  • SHARD_MERGE:这是来自每个分片的结果被合并并传递给父阶段的阶段

获胜计划阶段的详细信息可以在explain()执行输出的explain.queryPlanner.winningPlan键中找到。explain.queryPlanner.winningPlan.stage键向我们展示了根阶段的名称。如果有一个或多个子阶段,该阶段将具有一个inputStageinputStages键,取决于我们有多少阶段。子阶段将由explain()执行输出的explain.queryPlanner.winningPlan.inputStageexplain.queryPlanner.winningPlan.inputStages键表示。

注意

要了解更多关于explain()方法的信息,请访问 MongoDB 3.0 手册页面docs.mongodb.org/manual/reference/explain-results/

explain()方法的执行和输出的所有这些变化主要是为了提高 DBA 的生产力。与以前的 MongoDB 版本相比,最大的优势之一是explain()不需要执行查询来计算查询计划。它还将查询内省暴露给了更广泛的操作,包括 find、count、update、remove、group 和 aggregate,使 DBA 有能力优化每种类型的查询。

评估查询

直截了当地说,explain方法将为我们提供查询执行的统计信息。例如,我们将在这些统计信息中看到是否使用了游标或索引。

让我们以以下products集合为例:

{
 "_id": ObjectId("54bee5c49a5bc523007bb779"),
 "name": "Product 1",
 "price": 56
}
{
 "_id": ObjectId("54bee5c49a5bc523007bb77a"),
 "name": "Product 2",
 "price": 64
}
{
 "_id": ObjectId("54bee5c49a5bc523007bb77b"),
 "name": "Product 3",
 "price": 53
}
{
 "_id": ObjectId("54bee5c49a5bc523007bb77c"),
 "name": "Product 4",
 "price": 50
}
{
 "_id": ObjectId("54bee5c49a5bc523007bb77d"),
 "name": "Product 5",
 "price": 89
}
{
 "_id": ObjectId("54bee5c49a5bc523007bb77e"),
 "name": "Product 6",
 "price": 69
}
{
 "_id": ObjectId("54bee5c49a5bc523007bb77f"),
 "name": "Product 7",
 "price": 71
}
{
 "_id": ObjectId("54bee5c49a5bc523007bb780"),
 "name": "Product 8",
 "price": 40
}
{
 "_id": ObjectId("54bee5c49a5bc523007bb781"),
 "name": "Product 9",
 "price": 41
}
{
 "_id": ObjectId("54bee5c49a5bc523007bb782"),
 "name": "Product 10",
 "price": 53
}

正如我们已经看到的,当集合被创建时,_id字段上会自动添加一个索引。为了获取集合中的所有文档,我们将在 mongod shell 中执行以下查询:

db.products.find({price: {$gt: 65}})

查询的结果将是以下内容:

{
 "_id": ObjectId("54bee5c49a5bc523007bb77d"),
 "name": "Product 5",
 "price": 89
}
{
 "_id": ObjectId("54bee5c49a5bc523007bb77e"),
 "name": "Product 6",
 "price": 69
}
{
 "_id": ObjectId("54bee5c49a5bc523007bb77f"),
 "name": "Product 7",
 "price": 71
}

为了帮助您理解 MongoDB 是如何得出这个结果的,让我们在通过find命令返回的游标上使用explain方法:

db.products.find({price: {$gt: 65}}).explain("executionStats")

这个操作的结果是一个包含有关所选查询计划信息的文档:

{
 "queryPlanner" : {
 "plannerVersion" : 1,
 "namespace" : "ecommerce.products",
 "indexFilterSet" : false,
 "parsedQuery" : {
 "price" : {
 "$gt" : 65
 }
 },
 "winningPlan" : {
 "stage" : "COLLSCAN",
 "filter" : {
 "price" : {
 "$gt" : 65
 }
 },
 "direction" : "forward"
 },
 "rejectedPlans" : [ ]
 },
 "executionStats" : {
 "executionSuccess" : true,
 "nReturned" : 3,
 "executionTimeMillis" : 0,
 "totalKeysExamined" : 0,
 "totalDocsExamined" : 10,
 "executionStages" : {
 "stage" : "COLLSCAN",
 "filter" : {
 "price" : {
 "$gt" : 65
 }
 },
 "nReturned" : 3,
 "executionTimeMillisEstimate" : 0,
 "works" : 12,
 "advanced" : 3,
 "needTime" : 8,
 "needFetch" : 0,
 "saveState" : 0,
 "restoreState" : 0,
 "isEOF" : 1,
 "invalidates" : 0,
 "direction" : "forward",
 "docsExamined" : 10
 }
 },
 "serverInfo" : {
 "host" : "c516b8098f92",
 "port" : 27017,
 "version" : "3.0.2",
 "gitVersion" : "6201872043ecbbc0a4cc169b5482dcf385fc464f"
 },
 "ok" : 1
}

最初,让我们只检查这个文档中的四个字段:queryPlanner.winningPlan.stagequeryPlanner.executionStats.nReturnedqueryPlanner.executionStats.totalKeysExaminedqueryPlanner.executionStats.totalDocsExamined

  • queryPlanner.winningPlan.stage字段显示了将执行完整的集合扫描。

  • queryPlanner.executionStats.nReturned字段显示了有多少文档符合查询条件。换句话说,它显示了有多少文档将从查询执行中返回。在这种情况下,结果将是三个文档。

  • queryPlanner.executionStats.totalDocsExamined字段是将要扫描的集合中的文档数。在这个例子中,所有的文档都被扫描了。

  • queryPlanner.executionStats.totalKeysExamined字段显示了扫描的索引条目数。

  • 在执行集合扫描时,就像前面的例子中一样,nscanned也代表了在集合中扫描的文档数。

如果我们为我们的集合的price字段创建一个索引会发生什么?让我们看看:

db.products.createIndex({price: 1})

显然,查询结果将是在先前执行中返回的相同的三个文档。然而,explain命令的结果将是以下内容:

{
 "queryPlanner" : {
 "plannerVersion" : 1,
 "namespace" : "ecommerce.products",
 "indexFilterSet" : false,
 "parsedQuery" : {
 …
 },
 "winningPlan" : {
 "stage" : "FETCH",
 "inputStage" : {
 "stage" : "IXSCAN",
 "keyPattern" : {
 "price" : 1
 },
 "indexName" : "price_1",
 ...
 }
 },
 "rejectedPlans" : [ ]
 },
 "executionStats" : {
 "executionSuccess" : true,
 "nReturned" : 3,
 "executionTimeMillis" : 20,
 "totalKeysExamined" : 3,
 "totalDocsExamined" : 3,
 "executionStages" : {
 "stage" : "FETCH",
 "nReturned" : 3,
 ...
 "inputStage" : {
 "stage" : "IXSCAN",
 "nReturned" : 3,
 ...
 }
 }
 },
 "serverInfo" : {
 ...
 },
 "ok" : 1
}

返回的文档与之前的文档有很大的不同。再次,让我们专注于这四个字段:queryPlanner.winningPlan.stagequeryPlanner.executionStats.nReturnedqueryPlanner.executionStats.totalKeysExaminedqueryPlanner.executionStats.totalDocsExamined

这一次,我们可以看到我们没有进行完整的集合扫描。而是有一个带有子IXSCAN阶段的FETCH阶段,正如我们在queryPlanner.winningPlan.inputStage.stage字段中所看到的。这意味着查询使用了索引。索引的名称可以在字段queryPlanner.winningPlan.inputStage.indexName中找到,在这个例子中是price_1

此外,这个结果的平均差异是,queryPlanner.executionStats.totalDocsExaminedqueryPlanner.executionStats.totalKeysExamined都返回了值3,显示了扫描了三个文档。这与在没有索引的情况下执行查询时看到的 10 个文档非常不同。

我们应该指出的一点是,扫描的文档和键的数量与queryPlanner.executionStats.totalDocsExaminedqueryPlanner.executionStats.totalKeysExamined中所示的相同。这意味着我们的查询未被索引覆盖。在下一节中,我们将看到如何使用索引覆盖查询以及其好处。

覆盖查询

有时我们可以选择根据它们在查询中出现的频率创建一个或多个字段的索引。我们还可以选择创建索引以提高查询性能,不仅用于匹配条件,还用于从索引本身提取结果。

我们可以说,当查询中的所有字段都是索引的一部分,且查询中的所有字段都是同一个索引的一部分时,此查询将被索引覆盖。

在前一节中所示的示例中,我们创建了products集合的price字段的索引:

db.products.createIndex({price: 1})

当我们执行以下查询时,该查询检索price字段的值大于65的文档,但投影中排除了结果中的_id字段,只包括price字段,我们将得到与之前显示的结果不同的结果:

db.products.find({price: {$gt: 65}}, {price: 1, _id: 0})

结果将是:

{ "price" : 69 }
{ "price" : 71 }
{ "price" : 89 }

然后我们使用explain命令分析查询,如下所示:

db.products.explain("executionStats")
.find({price: {$gt: 65}}, {price: 1, _id: 0})

通过这样做,我们还得到了与之前示例不同的结果:

{
 "queryPlanner" : {
 "plannerVersion" : 1,
 "namespace" : "ecommerce.products",
 "indexFilterSet" : false,
 "parsedQuery" : {
 "price" : {
 "$gt" : 65
 }
 },
 "winningPlan" : {
 "stage" : "PROJECTION",
 ...
 "inputStage" : {
 "stage" : "IXSCAN",
 ...

 }
 },
 "rejectedPlans" : [ ]
 },
 "executionStats" : {
 "executionSuccess" : true,
 "nReturned" : 3,
 "executionTimeMillis" : 0,
 "totalKeysExamined" : 3,
 "totalDocsExamined" : 0,
 "executionStages" : {
 ...
 }
 },
 "serverInfo" : {
 ...
 },
 "ok" : 1
}

我们注意到的第一件事是queryPlanner.executionStats.totalDocsExamined的值为0。这可以解释为我们的查询被索引覆盖。这意味着我们不需要扫描集合中的文档。我们将使用索引返回结果,正如我们在queryPlanner.executionStats.totalKeysExamined字段的值3中观察到的那样。

另一个不同之处是IXSCAN阶段不是FETCH阶段的子级。每当索引覆盖查询时,IXSCAN都不会是FETCH阶段的后代。

注意

被索引覆盖的查询可能非常快。这是因为索引键通常比文档本身要小得多,而且索引通常位于易失性内存或磁盘顺序写入模式中。

不幸的是,我们并不总是能够覆盖查询,即使我们有相同的条件。

考虑以下customers集合:

{
 "_id": ObjectId("54bf0d719a5bc523007bb78f"),
 "username": "customer1",
 "email": "customer1@customer.com",
 "password": "1185031ff57bfdaae7812dd705383c74",
 "followedSellers": [
 "seller3",
 "seller1"
 ]
}
{
 "_id": ObjectId("54bf0d719a5bc523007bb790"),
 "username": "customer2",
 "email": "customer2@customer.com",
 "password": "6362e1832398e7d8e83d3582a3b0c1ef",
 "followedSellers": [
 "seller2",
 "seller4"
 ]
}
{
 "_id": ObjectId("54bf0d719a5bc523007bb791"),
 "username": "customer3",
 "email": "customer3@customer.com",
 "password": "f2394e387b49e2fdda1b4c8a6c58ae4b",
 "followedSellers": [
 "seller2",
 "seller4"
 ]
}
{
 "_id": ObjectId("54bf0d719a5bc523007bb792"),
 "username": "customer4",
 "email": "customer4@customer.com",
 "password": "10619c6751a0169653355bb92119822a",
 "followedSellers": [
 "seller1",
 "seller2"
 ]
}
{
 "_id": ObjectId("54bf0d719a5bc523007bb793"),
 "username": "customer5",
 "email": "customer5@customer.com",
 "password": "30c25cf1d31cbccbd2d7f2100ffbc6b5",
 "followedSellers": [
 "seller2",
 "seller4"
 ]
}

并且创建了followedSellers字段的索引,执行以下命令在 mongod shell 上:

db.customers.createIndex({followedSellers: 1})

如果我们在 mongod shell 上执行以下查询,该查询应该被索引覆盖,因为我们在查询条件中使用了followedSellers

db.customers.find(
{
 followedSellers: {
 $in : ["seller1", "seller3"]
 }
}, 
{followedSellers: 1, _id: 0}
)

当我们使用 mongod shell 上的explain命令分析此查询以查看查询是否被索引覆盖时,我们可以观察到:

db.customers.explain("executionStats").find(
{
 followedSellers: {
 $in : ["seller1", "seller3"]
 }
}, 
{followedSellers: 1, _id: 0}
)

我们有以下文档作为结果。我们可以看到,尽管在条件中使用了索引中的字段并将结果限制为此字段,但返回的输出将FETCH阶段作为IXSCAN阶段的父级。此外,totalDocsExaminedtotalKeysExamined的值是不同的:

{
 "queryPlanner" : {
 "plannerVersion" : 1,
 "namespace" : "ecommerce.customers",
 ...
 "winningPlan" : {
 "stage" : "PROJECTION",
 ...
 "inputStage" : {
 "stage" : "FETCH",
 "inputStage" : {
 "stage" : "IXSCAN",
 "keyPattern" : {
 "followedSellers" : 1
 },
 "indexName" : "followedSellers_1",
 ...
 }
 }
 },
 "rejectedPlans" : [ ]
 },
 "executionStats" : {
 "executionSuccess" : true,
 "nReturned" : 2,
 "executionTimeMillis" : 0,
 "totalKeysExamined" : 4,
 "totalDocsExamined" : 2,
 "executionStages" : {
 ...
 }
 },
 "serverInfo" : {
 ...
},
 "ok" : 1
}

totalDocsExamined字段返回2,这意味着需要扫描集合中的五个文档中的两个。与此同时,totalKeysExamined字段返回4,表明需要扫描四个索引条目以获取返回结果。

另一种情况是,当查询执行使用嵌入文档的字段的索引时,我们无法通过索引覆盖查询。

让我们使用supplier.name字段的索引检查已经在第四章中使用的products集合的示例:

db.products.createIndex({"supplier.name": 1})

以下查询将不被索引覆盖:

db.products.find(
 {"supplier.name": "Supplier 1"}, 
 {"supplier.name": 1, _id: 0}
)

注意

请记住,尽管此查询未被索引覆盖,但它将在计划中使用索引。

最后,当我们在分片集合中通过mongos执行查询时,此查询永远不会被索引覆盖。

查询优化器

现在您已经了解了使用explain()方法评估查询性能以及如何利用索引覆盖查询,我们将继续介绍在 MongoDB 中选择和维护查询计划的重大责任,即查询优化器。

查询优化器负责处理和选择查询的最佳和最有效的查询计划。为此,它考虑了所有集合索引。

查询优化器执行的过程并不是一门精确的科学,这意味着它有点经验主义,换句话说,是基于试错的。

当我们第一次执行查询时,查询优化器将针对集合的所有可用索引运行查询并选择最有效的索引。此后,每当我们运行相同的查询或具有相同模式的查询时,所选的索引将用于查询计划。

在本章前面使用的相同的products集合中,以下查询将通过相同的查询计划运行,因为它们具有相同的模式:

db.products.find({name: 'Product 1'})
db.products.find({name: 'Product 5'})

随着集合数据的变化,查询优化器会重新评估。此外,随着集合的增长(更准确地说,每进行 1,000 次写操作,每次索引创建,mongod进程重新启动,或者我们调用explain()方法),优化器会重新评估自身。

即使有了这个被称为查询优化器的神奇自动过程,我们可能还想选择我们想要使用的索引。为此,我们使用hint方法。

假设我们的先前的products集合中有这些索引:

db.products.createIndex({name: 1, price: -1})
db.products.createIndex({price: -1})

如果我们想检索所有price字段值大于 10 的产品,并按name字段降序排序,可以使用以下命令来执行:

db.products.find({price: {$gt: 10}}).sort({name: -1})

查询优化器选择的索引将是在nameprice字段上创建的索引,我们可以通过运行explain()方法来查看:

db.products.explain("executionStats").find({price: {$gt: 10}}).sort({name: -1})

结果是:

{
 "queryPlanner" : {
 "plannerVersion" : 1,
 "namespace" : "ecommerce.products",
 ...
 "winningPlan" : {
 "stage" : "FETCH",
 ...
 "inputStage" : {
 "stage" : "IXSCAN",
 "keyPattern" : {
 "name" : 1,
 "price" : -1
 },
 "indexName" : "name_1_price_-1"
 ...
 }
 },
 ...
 },
 "executionStats" : {
 "executionSuccess" : true,
 "nReturned" : 10,
 "executionTimeMillis" : 0,
 "totalKeysExamined" : 10,
 "totalDocsExamined" : 10,
 "executionStages" : {
 ...
 }
 },
 "serverInfo" : {
 ...
},
 "ok" : 1
}

然而,我们只能强制使用price字段的索引,如下所示:

db.products.find(
 {price: {$gt: 10}}
).sort({name: -1}).hint({price: -1})

为了确定,我们使用explain方法:

db.products.explain("executionStats").find(
 {price: {$gt: 10}}).sort({name: -1}
).hint({price: -1})

这产生了以下文档:

{
 "queryPlanner" : {
 "plannerVersion" : 1,
 "namespace" : "ecommerce.products",
 ...
 "winningPlan" : {
 "stage" : "SORT",
 ...
 "inputStage" : {
 "stage" : "KEEP_MUTATIONS",
 "inputStage" : {
 "stage" : "FETCH",
 "inputStage" : {
 "stage" : "IXSCAN",
 "keyPattern" : {
 "price" : -1
 },
 "indexName" : "price_-1",
 ...
 }
 }
 }
 },
 "rejectedPlans" : [ ]
 },
 "executionStats" : {
 "executionSuccess" : true,
 "nReturned" : 10,
 "executionTimeMillis" : 0,
 "totalKeysExamined" : 10,
 "totalDocsExamined" : 10,
 "executionStages" : {
 ...
 }
 },
 "serverInfo" : {
 ...
 },
 "ok" : 1
}

从多个 MongoDB 实例中读取

到目前为止,我们已经大谈特谈了从一个 MongoDB 实例中读取。然而,重要的是我们简要谈一下从分片环境或副本集中读取。

从多个 MongoDB 实例中读取

当我们从分片中读取时,重要的是将分片键作为查询条件的一部分。这是因为当我们有分片键时,我们将针对一个特定的分片执行,而如果我们没有分片键,我们将强制在集群中的所有分片上执行。因此,在分片环境中查询的性能在很大程度上取决于分片键。

默认情况下,在 MongoDB 中有一个副本集时,我们总是从主节点读取。我们可以修改此行为,通过修改读取偏好来强制在辅助节点上执行读取操作。

假设我们有一个包含三个节点的副本集:rs1s1rs1s2rs1s3rs1s1是主节点,rs1s2rs1s3是辅助节点。要执行一个读操作并强制在辅助节点上进行读取,我们可以这样做:

db.customers.find().readPref({mode: 'secondary'})

此外,我们还有以下读取偏好选项:

  • primary,这是默认选项,将强制用户从主节点读取。

  • primaryPreferred,它将优先从主节点读取,但在不可用的情况下将从辅助节点读取。

  • secondaryPreferred,它将从辅助节点读取,但在不可用的情况下将从主节点读取。

  • nearest,它将从集群中网络延迟最低的节点读取。换句话说,就是从网络距离最短的节点读取,无论它是主节点还是辅助节点。

简而言之,如果我们的应用程序希望最大化一致性,那么我们应该优先考虑在主节点上进行读取;当我们寻求可用性时,我们应该使用primaryPreferred,因为我们可以保证大多数读取的一致性。当主节点出现问题时,我们可以依靠任何辅助节点。最后,如果我们寻求最低的延迟,我们可以使用nearest,提醒自己我们没有数据一致性的保证,因为我们优先考虑最低延迟的网络节点。

总结

在本章中,您学会了使用 MongoDB 的原生工具分析查询性能,并优化我们的查询。

在下一章中,我们将讨论如何通过功能或地理分离更好地管理我们的数据库和其集合。您还将了解如何维护应支持高读写吞吐量的集合。

第六章:管理数据

  • 计划数据库操作是数据模型维护中最重要的阶段之一。在 MongoDB 中,根据数据的性质,我们可以通过功能或地理分组来隔离应用程序的操作。

在本章中,我们将回顾一些在第五章中已经介绍的概念,如读取偏好和写入关注。但这次我们将专注于理解这些功能如何帮助我们通过 MongoDB 部署分割操作,例如,分离读取和写入操作,或者考虑应用程序特性,通过副本集节点进行写入传播来确保信息一致性。

您还将了解如何通过探索特殊属性来支持高读/写吞吐量的集合,这对某些应用程序至关重要。

因此,在本章中,您将了解:

  • 操作隔离

  • 有限集合

    • 数据自动过期

操作隔离

到目前为止,我们已经看到我们应用程序的查询如何影响了我们对文档设计的决策。然而,读取偏好和写入关注概念还有更多内容需要探讨。

MongoDB 为我们提供了一系列功能,允许我们通过功能或地理分组来隔离应用程序操作。在使用功能隔离时,我们可以指示负责报告生成的应用程序仅使用特定的 MongoDB 部署。地理隔离意味着我们可以针对距离 MongoDB 部署的地理距离来定位操作。

- 优先考虑读操作

可以想象一旦构建了一个应用程序,营销或商业人员将要求提供应用程序数据的新报告,顺便说一句,这将是必不可少的报告。我们知道为了报告的目的而在我们的主数据库中构建和插入这样的应用程序是多么危险。除了与其他应用程序的数据并发性外,我们知道这种类型的应用程序可能通过进行复杂查询和操作大量数据来过载我们的数据库。

这就是为什么我们必须将处理大量数据并需要数据库更重的处理的操作定位到专用的 MongoDB 部署。我们将通过读取偏好使应用程序定位到正确的 MongoDB 部署,就像您在第五章中看到的那样,优化查询

默认情况下,应用程序将始终从我们的副本集中读取第一个节点。这种行为确保应用程序始终读取最新的数据,从而确保数据的一致性。但是,如果意图是减少第一个节点的吞吐量,并且我们可以接受最终一致性,可以通过启用secondarysecondaryPreferred模式将读操作重定向到副本集中的辅助节点。

  • 除了在主节点上减少吞吐量的功能之外,在次要节点中优先考虑读操作对于分布在多个数据中心的应用程序至关重要,因此我们在地理上分布了副本集。这是因为我们可以通过设置最近模式选择最近的节点或延迟最低的节点来执行读操作。

  • 最后,通过使用primaryPreferred模式,我们可以大大提高数据库的可用性,允许读操作在任何副本集节点中执行。

但是,除了读取偏好规范,主要或次要,如果我们还可以指定将操作定位到哪个实例呢?例如,考虑一个分布在两个不同位置的副本集,每个实例都有不同类型的物理存储。除此之外,我们希望确保写操作将在至少一个具有ssd磁盘的每个数据中心的实例中执行。这是可能的吗?答案是

这是由于标签集。标签集是一个配置属性,可以控制副本集的写关注和读偏好。它们由一个包含零个或多个标签的文档组成。我们将把这个配置存储在副本集配置文档的members[n].tags字段中。

在读取偏好的情况下,标签集允许您为副本集的特定成员定位读取操作。当选择读取过程的副本集成员时,标签集值将被应用。

标签集只会影响读取偏好模式之一,即primaryPreferredsecondarysecondaryPreferrednearest。标签集不会影响primary模式,这意味着它只会影响副本集次要成员的选择,除非与nearest模式结合使用,在这种情况下,最接近的节点或延迟最小的节点可以成为主节点。

在看如何进行此配置之前,您需要了解副本集成员是如何选择的。将执行操作的客户端驱动程序进行选择,或者在分片集群的情况下,选择是由mongos实例完成的。

因此,选择过程是这样进行的:

  1. 创建主要和次要成员的列表。

  2. 如果指定了标签集,则不符合规范的成员将被跳过。

  3. 确定最接近应用程序的客户端。

  4. 创建其他副本集成员的列表,考虑其他成员之间的延迟。此延迟可以在通过secondaryAcceptableLatencyMS属性执行写操作时定义。在分片集群的情况下,可以通过--localThresholdlocalPingThresholdMs选项进行设置。如果没有设置这些配置中的任何一个,那么默认值将为 15 毫秒。

提示

您可以在 MongoDB 手册参考中找到有关此配置的更多信息docs.mongodb.org/manual/reference/configuration-options/#replication.localPingThresholdMs

  1. 将随机选择要执行操作的主机,并执行读操作。

标签集配置与任何其他 MongoDB 配置一样简单。与往常一样,我们使用文档来创建配置,并且如前所述,标签集是副本集配置文档的一个字段。可以通过在副本集成员上运行conf()方法来检索此配置文档。

提示

您可以在 MongoDB 文档中找到有关conf()方法的更多信息docs.mongodb.org/manual/reference/method/rs.conf/#rs.conf

以下文件显示了在rs1的 mongod shell 上执行rs.conf()命令后,读操作的标签集示例,这是我们副本集的主节点。

rs1:PRIMARY> rs.conf()
{ // This is the replica set configuration document

 "_id" : "rs1",
 "version" : 4,
 "members" : [
 {
 "_id" : 0,
 "host" : "172.17.0.2:27017"
 },
 {
 "_id" : 1,
 "host" : "172.17.0.3:27017"
 },
 {
 "_id" : 2,
 "host" : "172.17.0.4:27017"
 }
 ]
}

要为副本集的每个节点创建标签集配置,我们必须在主要的 mongod shell 中执行以下命令序列:

首先,我们将获取副本集配置文档并将其存储在cfg变量中:

rs1:PRIMARY> cfg = rs.conf()
{
 "_id" : "rs1",
 "version" : 4,
 "members" : [
 {
 "_id" : 0,
 "host" : "172.17.0.7:27017"
 },
 {
 "_id" : 1,
 "host" : "172.17.0.5:27017"
 },
 {
 "_id" : 2,
 "host" : "172.17.0.6:27017"
 }
 ]
}

然后,通过使用cfg变量,我们将为我们的三个副本集成员中的每一个设置一个文档作为members[n].tags字段的新值:

rs1:PRIMARY> cfg.members[0].tags = {"media": "ssd", "application": "main"}
rs1:PRIMARY> cfg.members[1].tags = {"media": "ssd", "application": "main"}
rs1:PRIMARY> cfg.members[2].tags = {"media": "ssd", "application": "report"}

最后,我们调用reconfig()方法,传入存储在cfg变量中的新配置文档以重新配置我们的副本集:

rs1:PRIMARY> rs.reconfig(cfg)

如果一切正确,我们必须在 mongod shell 中看到这个输出:

{ "ok" : 1 }

要检查配置,我们可以重新执行命令rs.conf()。这将返回以下内容:

rs1:PRIMARY> cfg = rs.conf()
{
 "_id" : "rs1",
 "version" : 5,
 "members" : [
 {
 "_id" : 0,
 "host" : "172.17.0.7:27017",
 "tags" : {
 "application" : "main",
 "media" : "ssd"
 }
 },
 {
 "_id" : 1,
 "host" : "172.17.0.5:27017",
 "tags" : {
 "application" : "main",
 "media" : "ssd"
 }
 },
 {
 "_id" : 2,
 "host" : "172.17.0.6:27017",
 "tags" : {
 "application" : "report",
 "media" : "ssd"
 }
 }
 ]
}

现在,考虑以下customer集合:

{
 "_id": ObjectId("54bf0d719a5bc523007bb78f"),
 "username": "customer1",
 "email": "customer1@customer.com",
 "password": "1185031ff57bfdaae7812dd705383c74",
 "followedSellers": [
 "seller3",
 "seller1"
 ]
}
{
 "_id": ObjectId("54bf0d719a5bc523007bb790"),
 "username": "customer2",
 "email": "customer2@customer.com",
 "password": "6362e1832398e7d8e83d3582a3b0c1ef",
 "followedSellers": [
 "seller2",
 "seller4"
 ]
}
{
 "_id": ObjectId("54bf0d719a5bc523007bb791"),
 "username": "customer3",
 "email": "customer3@customer.com",
 "password": "f2394e387b49e2fdda1b4c8a6c58ae4b",
 "followedSellers": [
 "seller2",
 "seller4"
 ]
}
{
 "_id": ObjectId("54bf0d719a5bc523007bb792"),
 "username": "customer4",
 "email": "customer4@customer.com",
 "password": "10619c6751a0169653355bb92119822a",
 "followedSellers": [
 "seller1",
 "seller2"
 ]
}
{
 "_id": ObjectId("54bf0d719a5bc523007bb793"),
 "username": "customer5",
 "email": "customer5@customer.com",
 "password": "30c25cf1d31cbccbd2d7f2100ffbc6b5",
 "followedSellers": [
 "seller2",
 "seller4"
 ]
}

接下来的读操作将使用我们副本集实例中创建的标签:

db.customers.find(
 {username: "customer5"}
).readPref(
 {
 tags: [{application: "report", media: "ssd"}]
 }
)
db.customers.find(
 {username: "customer5"}
).readPref(
 {
 tags: [{application: "main", media: "ssd"}]
 }
)

前面的配置是按应用操作分离的一个例子。我们创建了标签集,标记了应用的性质以及将要读取的媒体类型。

正如我们之前所看到的,当我们需要在地理上分离我们的应用时,标签集非常有用。假设我们在两个不同的数据中心中有 MongoDB 应用程序和副本集的实例。让我们通过在副本集主节点 mongod shell 上运行以下序列来创建标签,这些标签将指示我们的实例位于哪个数据中心。首先,我们将获取副本集配置文档并将其存储在cfg变量中:

rs1:PRIMARY> cfg = rs.conf()

然后,通过使用cfg变量,我们将为我们的三个副本集成员中的每一个设置一个文档作为members[n].tags字段的新值:

rs1:PRIMARY> cfg.members[0].tags = {"media": "ssd", "application": "main", "datacenter": "A"}
rs1:PRIMARY> cfg.members[1].tags = {"media": "ssd", "application": "main", "datacenter": "B"}
rs1:PRIMARY> cfg.members[2].tags = {"media": "ssd", "application": "report", "datacenter": "A"}

最后,我们调用reconfig()方法,传入存储在cfg变量中的新配置文档以重新配置我们的副本集:

rs1:PRIMARY> rs.reconfig(cfg)

如果一切正确,我们将在 mongod shell 中看到这个输出:

{ "ok" : 1 }

我们的配置结果可以通过执行命令rs.conf()来检查:

rs1:PRIMARY> rs.conf()
{
 "_id" : "rs1",
 "version" : 6,
 "members" : [
 {
 "_id" : 0,
 "host" : "172.17.0.7:27017",
 "tags" : {
 "application" : "main",
 "datacenter" : "A",
 "media" : "ssd"
 }
 },
 {
 "_id" : 1,
 "host" : "172.17.0.5:27017",
 "tags" : {
 "application" : "main",
 "datacenter" : "B",
 "media" : "ssd"
 }
 },
 {
 "_id" : 2,
 "host" : "172.17.0.6:27017",
 "tags" : {
 "application" : "report",
 "datacenter" : "A",
 "media" : "ssd"
 }
 }
 ]
}

为了将读操作定位到特定的数据中心,我们必须在查询中指定一个新的标签。以下查询将使用标签,并且每个查询将在自己的数据中心中执行:

db.customers.find(
 {username: "customer5"}
).readPref(
 {tags: [{application: "main", media: "ssd", datacenter: "A"}]}
) // It will be executed in the replica set' instance 0 
db.customers.find(
 {username: "customer5"}
).readPref(
 {tags: [{application: "report", media: "ssd", datacenter: "A"}]}
) //It will be executed in the replica set's instance 2 
db.customers.find(
 {username: "customer5"}
).readPref(
 {tags: [{application: "main", media: "ssd", datacenter: "B"}]}
) //It will be executed in the replica set's instance 1

在写操作中,标签集不用于选择可用于写入的副本集成员。尽管可以通过创建自定义写关注来在写操作中使用标签集。

让我们回到本节开头提出的要求。我们如何确保写操作将分布在地理区域的至少两个实例上?通过在副本集主节点 mongod shell 上运行以下命令序列,我们将配置一个具有五个实例的副本集:

rs1:PRIMARY> cfg = rs.conf()
rs1:PRIMARY> cfg.members[0].tags = {"riodc": "rack1"}
rs1:PRIMARY> cfg.members[1].tags = {"riodc": "rack2"}
rs1:PRIMARY> cfg.members[2].tags = {"riodc": "rack3"}
rs1:PRIMARY> cfg.members[3].tags = {"spdc": "rack1"}
rs1:PRIMARY> cfg.members[4].tags = {"spdc": "rack2"}
rs1:PRIMARY> rs.reconfig(cfg)

标签riodcspdc表示我们的实例所在的地理位置。

现在,让我们创建一个自定义的writeConcern MultipleDC,使用getLastErrorModes属性。这将确保写操作将分布到至少一个位置成员。

为此,我们将执行前面的序列,其中我们在副本集配置文档的settings字段上设置了一个代表我们自定义写关注的文档:

rs1:PRIMARY> cfg = rs.conf()
rs1:PRIMARY> cfg.settings = {getLastErrorModes: {MultipleDC: {"riodc": 1, "spdc":1}}}

mongod shell 中的输出应该是这样的:

{
 "getLastErrorModes" : {
 "MultipleDC" : {
 "riodc" : 1,
 "spdc" : 1
 }
 }
}

然后我们调用reconfig()方法,传入新的配置:

rs1:PRIMARY> rs.reconfig(cfg)

如果执行成功,在 mongod shell 中的输出将是这样的文档:

{ "ok" : 1 }

从这一刻起,我们可以使用writeConcern MultipleDC 来确保写操作将在每个显示的数据中心的至少一个节点中执行,如下所示:

db.customers.insert(
 {
 username: "customer6", 
 email: "customer6@customer.com",
 password: "1185031ff57bfdaae7812dd705383c74", 
 followedSellers: ["seller1", "seller3"]
 }, 
 {
 writeConcern: {w: "MultipleDC"} 
 }
)

回到我们的要求,如果我们希望写操作至少在每个数据中心的两个实例中执行,我们必须按以下方式配置:

rs1:PRIMARY> cfg = rs.conf()
rs1:PRIMARY> cfg.settings = {getLastErrorModes: {MultipleDC: {"riodc": 2, "spdc":2}}}
rs1:PRIMARY> rs.reconfig(cfg)

并且,满足我们的要求,我们可以创建一个名为ssdwriteConcern MultipleDC。这将确保写操作将发生在至少一个具有这种类型磁盘的实例中:

rs1:PRIMARY> cfg = rs.conf()
rs1:PRIMARY> cfg.members[0].tags = {"riodc": "rack1", "ssd": "ok"}
rs1:PRIMARY> cfg.members[3].tags = {"spdc": "rack1", "ssd": "ok"}
rs1:PRIMARY> rs.reconfig(cfg)
rs1:PRIMARY> cfg.settings = {getLastErrorModes: {MultipleDC: {"riodc": 2, "spdc":2}, ssd: {"ssd": 1}}}
rs1:PRIMARY> rs.reconfig(cfg)

在下面的查询中,我们看到使用writeConcern MultipleDC 需要写操作至少出现在具有ssd的一个实例中:

db.customers.insert(
 {
 username: "customer6", 
 email: "customer6@customer.com", 
 password: "1185031ff57bfdaae7812dd705383c74", 
 followedSellers: ["seller1", "seller3"]
 }, 
 {
 writeConcern: {w: "ssd"} 
 }
)

在我们的数据库中进行操作分离并不是一项简单的任务。但是,对于数据库的管理和维护非常有用。这种任务的早期实施需要对我们的数据模型有很好的了解,因为数据库所在的存储的细节非常重要。

在下一节中,我们将看到如何为需要高吞吐量和快速响应时间的应用程序规划集合。

提示

如果您想了解如何配置副本集标签集,可以访问 MongoDB 参考手册docs.mongodb.org/manual/tutorial/configure-replica-set-tag-sets/#replica-set-configuration-tag-sets

固定大小集合

非功能性需求通常与应用程序的响应时间有关。特别是在当今时代,我们一直连接到新闻源,希望最新信息能在最短的响应时间内可用。

MongoDB 有一种特殊类型的集合,满足非功能性需求,即固定大小的集合。固定大小的集合支持高读写吞吐量。这是因为文档按其自然顺序插入,无需索引执行写操作。

MongoDB 保证了自然插入顺序,将数据写入磁盘。因此,在文档的生命周期中不允许增加文档大小的更新。一旦集合达到最大大小,MongoDB 会自动清理旧文档,以便插入新文档。

一个非常常见的用例是应用程序日志的持久性。MongoDB 本身使用副本集操作日志oplog.rs作为固定大小集合。在第八章使用 MongoDB 进行日志记录和实时分析中,您将看到另一个实际示例。

MongoDB 的另一个非常常见的用途是作为发布者/订阅者系统,特别是如果我们使用可追溯的游标。可追溯的游标是即使客户端读取了所有返回的记录,仍然保持打开状态的游标。因此,当新文档插入集合时,游标将其返回给客户端。

以下命令创建ordersQueue集合:

db.createCollection("ordersQueue",{capped: true, size: 10000})

我们使用util命令createCollection创建了我们的固定大小集合,传递给它名称ordersQueue和一个带有capped属性值为truesize值为10000的集合。如果size属性小于 4,096,MongoDB 会调整为 4,096 字节。另一方面,如果大于 4,096,MongoDB 会提高大小并调整为 256 的倍数。

可选地,我们可以使用max属性设置集合可以拥有的最大文档数量:

db.createCollection(
 "ordersQueue",
 {capped: true, size: 10000, max: 5000}
)

注意

如果我们需要将集合转换为固定大小集合,应该使用convertToCapped方法如下:

db.runCommand(
 {"convertToCapped": " ordersQueue ", size: 100000}
)

正如我们已经看到的,MongoDB 按自然顺序保留文档,换句话说,按照它们插入 MongoDB 的顺序。考虑以下文档,如ordersQueue集合中所示插入:

{
 "_id" : ObjectId("54d97db16840a9a7c089fa30"), 
 "orderId" : "order_1", 
 "time" : 1423539633910 
}
{
 "_id" : ObjectId("54d97db66840a9a7c089fa31"), 
 "orderId" : "order_2", 
 "time" : 1423539638006 
}
{
 "_id" : ObjectId("54d97dba6840a9a7c089fa32"), 
 "orderId" : "order_3", 
 "time" : 1423539642022 
}
{
 "_id" : ObjectId("54d97dbe6840a9a7c089fa33"), 
 "orderId" : "order_4", 
 "time" : 1423539646015 
}
{
 "_id" : ObjectId("54d97dcf6840a9a7c089fa34"), 
 "orderId" : "order_5", 
 "time" : 1423539663559 
}

查询db.ordersQueue.find()产生以下结果:

{ 
 "_id" : ObjectId("54d97db16840a9a7c089fa30"), 
 "orderId" : "order_1", 
 "time" : 1423539633910 
}
{ 
 "_id" : ObjectId("54d97db66840a9a7c089fa31"), 
 "orderId" : "order_2", 
 "time" : 1423539638006 
}
{ 
 "_id" : ObjectId("54d97dba6840a9a7c089fa32"), 
 "orderId" : "order_3", 
 "time" : 1423539642022 
}
{ 
 "_id" : ObjectId("54d97dbe6840a9a7c089fa33"), 
 "orderId" : "order_4", 
 "time" : 1423539646015 
}
{ 
 "_id" : ObjectId("54d97dcf6840a9a7c089fa34"), 
 "orderId" : "order_5", 
 "time" : 1423539663559 
}

如果我们像以下查询中所示使用$natural操作符,将得到与前面输出中相同的结果:

db.ordersQueue.find().sort({$natural: 1})

但是,如果我们需要最后插入的文档先返回,我们必须在$natural操作符上执行带有-1值的命令:

db.ordersQueue.find().sort({$natural: -1})

在创建固定大小集合时,我们必须小心:

  • 我们不能对固定大小集合进行分片。

  • 我们不能在固定大小集合中更新文档;否则,文档会增大。如果需要在固定大小集合中更新文档,则必须确保大小保持不变。为了更好的性能,在更新时应创建索引以避免集合扫描。

  • 我们无法在封顶集合中删除文档。

当我们具有高读/写吞吐量作为非功能性要求,或者需要按字节大小或文档数量限制集合大小时,封顶集合是一个很好的工具。

尽管如此,如果我们需要根据时间范围自动使数据过期,我们应该使用生存时间(TTL)函数。

数据自动过期

正如您在第四章中已经看到的,MongoDB 为我们提供了一种索引类型,可以帮助我们在一定时间后或特定日期之后从集合中删除数据。

实际上,TTL 是在 mongod 实例上执行的后台线程,它会查找索引上具有日期类型字段的文档,并将其删除。

考虑一个名为customers的集合,其中包含以下文档:

{ 
 "_id" : ObjectId("5498da405d0ffdd8a07a87ba"), 
 "username" : "customer1", 
 "email" : "customer1@customer.com", 
 "password" : "b1c5098d0c6074db325b0b9dddb068e1", "accountConfirmationExpireAt" : ISODate("2015-01-11T20:27:02.138Z") 
}

为了在 360 秒后使该集合中的文档过期,我们应该创建以下索引:

db.customers.createIndex(
 {accountConfirmationExpireAt: 1}, 
 {expireAfterSeconds: 3600}
)

为了在 2015-01-11 20:27:02 准确地使文档过期,我们应该创建以下索引:

db.customers.createIndex(
 {accountConfirmationExpireAt: 1}, 
 {expireAfterSeconds: 0}
)

在使用 TTL 函数时,我们必须格外小心,并牢记以下几点:

  • 我们无法在封顶集合上创建 TTL 索引,因为 MongoDB 无法从集合中删除文档。

  • TTL 索引不能具有作为另一个索引一部分的字段。

  • 索引字段应为日期或日期类型的数组。

  • 尽管在每个副本集节点中都有后台线程,可以在具有 TTL 索引时删除文档,但它只会从主节点中删除它们。复制过程将从副本集的辅助节点中删除文档。

总结

在本章中,您看到了除了根据我们的查询来思考架构设计之外,还要考虑规划操作和维护来创建我们的集合。

您学会了如何使用标签集来处理数据中心感知操作,以及为什么通过创建封顶集合来限制我们集合中存储的文档数量。同样,您还了解了 TTL 索引在实际用例中的用处。

在下一章中,您将看到如何通过创建分片来扩展我们的 MongoDB 实例。

第七章:扩展

多年来,可扩展性一直是一个备受讨论的话题。尽管关于它已经有很多言论,但这个话题非常重要,在这本书中,它肯定也会找到自己的位置。

我们不感兴趣涉及涉及数据库可扩展性的所有概念,特别是在 NoSQL 数据库中,而是展示 MongoDB 在处理我们的集合时提供的可能性以及 MongoDB 数据模型的灵活性如何影响我们的选择。

可以基于简单的基础架构和低成本的分片请求来水平扩展 MongoDB。分片是通过多个名为“分片”的物理分区分发数据的技术。尽管数据库在物理上被分区,但对于我们的客户来说,数据库本身是一个单一实例。分片技术对数据库的客户完全透明。

亲爱的读者,准备好了吗!在本章中,您将看到一些关于数据库维护的关键主题,例如:

  • 使用分片进行横向扩展

  • 选择分片键

  • 扩展社交收件箱架构设计

使用分片来扩展 MongoDB

当我们谈论数据库的可扩展性时,有两种参考方法:

  • 纵向扩展或垂直扩展:在这种方法中,我们向一台机器添加更多资源。例如,CPU、磁盘和内存,以增加系统的容量。

  • 横向扩展或水平扩展:在这种方法中,我们向系统添加更多节点,并在可用节点之间分配工作。

选择其中一种并不取决于我们的意愿,而是取决于我们想要扩展的系统。有必要了解是否可能以我们想要的方式扩展该系统。我们还必须记住这两种技术之间存在差异和权衡。

增加存储容量、CPU 或内存可能非常昂贵,有时甚至由于服务提供商的限制而不可能。另一方面,增加系统中的节点数量也可能会增加概念上和操作上的复杂性。

然而,考虑到虚拟化技术的进步和云服务提供商提供的便利,对于某些应用程序来说,横向扩展正在成为更实际的解决方案。

MongoDB 准备好了进行水平扩展。这是通过分片技术来实现的。这种技术包括对数据集进行分区,并将数据分布在许多服务器之间。分片的主要目的是支持能够通过在每个分片之间分配操作负载来处理高吞吐量操作的更大型数据库。

例如,如果我们有一个 1TB 的数据库和四个配置好的分片,每个分片应该有 256GB 的数据。但是,这并不意味着每个分片将管理 25%的吞吐量操作。这将完全取决于我们决定构建分片的方式。这是一个巨大的挑战,也是本章的主要目标。

以下图表展示了 MongoDB 中分片的工作原理:

使用分片扩展 MongoDB

在撰写本书时,MongoDB 在其 3.0 版本中提供了多种分片策略:基于范围、基于哈希和基于位置的分片。

  • 在基于范围的策略中,MongoDB 将根据分片键的值对数据进行分区。接近彼此的分片键值的文档将分配到同一个分片中。

  • 在基于哈希的策略中,文档是根据分片键的 MD5 值进行分布的。

  • 在基于位置的策略中,文档将根据将分片范围值与特定分片相关联的配置分布在分片中。这种配置使用标签来实现,这与我们在第六章中看到的“管理数据”中讨论的操作隔离非常相似。

在 MongoDB 中,分片工作在集合级别,这意味着我们可以在同一个数据库中启用分片和不启用分片的集合。要在集合中设置分片,我们必须配置一个分片集群。分片集群的元素包括分片、查询路由器和配置服务器:

  • 分片是我们的数据集的一部分将被分配的地方。一个分片可以是一个 MongoDB 实例或一个副本集

  • 查询路由器是为数据库客户端提供的接口,负责将操作定向到正确的分片

  • 配置服务器是一个负责保持分片集群配置或者说是集群元数据的 MongoDB 实例

以下图显示了一个共享集群及其组件:

使用分片扩展 MongoDB

我们不会深入讨论分片集群的创建和维护,因为这不是本章的目标。然而,重要的是要知道,分片集群的设置取决于场景。

在生产环境中,最低建议的设置是至少三个配置服务器,两个或更多副本集,这将是我们的分片,以及一个或多个查询路由器。通过这样做,我们可以确保环境的最低冗余和高可用性。

选择分片键

一旦我们决定我们需要一个分片集群,下一步就是选择分片键。分片键负责确定文档在集群的分片之间的分布。这些也将是决定我们的数据库成功或失败的关键因素。

对于每个写操作,MongoDB 将根据分片键的范围值分配一个新文档。分片键的范围也被称为。一个块的默认长度为 64MB,但如果您希望将此值定制到您的需求,它是可以配置的。在下图中,您可以看到如何在给定一个从负无穷到正无穷的数字分片键上分布文档:

选择分片键

在开始讨论可能影响我们分片键构建的事情之前,必须尊重 MongoDB 中的一些限制。这些限制是重要的,在某些方面,它们帮助我们消除我们选择中的一些错误的可能性。

分片键的长度不能超过 512 字节。分片键是文档中的索引字段。这个索引可以是一个简单的字段或一个组合的字段,但它永远不会是一个多键字段。自 MongoDB 2.4 版本以来,也可以使用简单哈希字段的索引。

以下信息必须安静地阅读,就像一个咒语,这样你就不会从一开始就犯任何错误。

注意

你必须记住一件事:分片键是不可更改的。

重申一遍,分片键是不可更改的。这意味着,亲爱的读者,一旦创建了分片键,你就永远无法更改它。永远!

您可以在 MongoDB 手册参考docs.mongodb.org/manual/reference/limits/#sharded-clusters中找到有关 MongoDB 分片集群限制的详细信息。

但如果我创建了一个分片键,我想要改变它怎么办?我应该怎么做?与其试图改变它,我们应该做以下事情:

  1. 在磁盘文件中执行数据库的转储。

  2. 删除集合。

  3. 使用新的分片键配置一个新的集合。

  4. 执行预分割的块。

  5. 恢复转储文件。

正如你所看到的,我们不改变分片键。我们几乎是从头开始重新创建的。因此,在执行分片键创建的命令时要小心,否则如果需要更改它,你会头疼的。

注意

你需要记住的下一个信息是,你不能更新分片键的一个或多个字段的值。换句话说,分片键的值也是不可更改的。

尝试在分片键的字段中执行update()方法是没有用的。它不起作用。

在我们继续之前,让我们实际看一下我们到目前为止讨论的内容。让我们为测试创建一个分片集群。以下的分片配置对于测试和开发非常有用。在生产环境中永远不要使用这个配置。给出的命令将创建:

  • 两个分片

  • 一个配置服务器

  • 一个查询路由器

作为第一步,让我们启动一个配置服务器实例。配置服务器只是一个带有初始化参数--configsvrmongod实例。如果我们不为参数--port <port number>设置一个值,它将默认在端口 27019 上启动:

mongod --fork --configsvr --dbpath /data/configdb --logpath /log/configdb.log

下一步是启动查询路由器。查询路由器是一个mongos MongoDB 实例,它使用参数--configdb <configdb hostname or ip:port>来将查询和写操作路由到分片,该参数指示配置服务器。默认情况下,MongoDB 在端口 27017 上启动它:

mongos --fork --configdb localhost --logpath /log/router.log

最后,让我们启动分片。在这个例子中,分片将是两个简单的mongod实例。与mongos类似,mongod实例默认在端口 27017 上启动。由于我们已经在这个端口上启动了mongos实例,让我们为mongod实例设置一个不同的端口:

mongod --fork --dbpath /data/mongod1 --port 27001 --logpath /log/mongod1.log
mongod --fork --dbpath /data/mongod2 --port 27002 --logpath /log/mongod2.log

完成!现在我们为测试分片集群建立了基本的基础设施。但是,等等!我们还没有一个分片集群。下一步是向集群添加分片。为此,我们必须将已经启动的mongos实例连接到查询路由器:

mongo localhost:27017

一旦在mongos shell 中,我们必须以以下方式执行addShard方法:

mongos> sh.addShard("localhost:27001")
mongos> sh.addShard("localhost:27002")

如果我们想要检查前面操作的结果,我们可以执行status()命令,并查看关于创建的分片的一些信息:

mongos> sh.status()
--- Sharding Status --- 
 sharding version: {
 "_id" : 1,
 "minCompatibleVersion" : 5,
 "currentVersion" : 6,
 "clusterId" : ObjectId("54d9dc74fadbfe60ef7b394e")
}
 shards:
 {  "_id" : "shard0000",  "host" : "localhost:27001" }
 {  "_id" : "shard0001",  "host" : "localhost:27002" }
 databases:
 {  "_id" : "admin",  "partitioned" : false,  "primary" : "config" }

在返回的文档中,我们只能看到基本信息,比如我们的分片集群的主机是谁,我们有哪些数据库。目前,我们没有任何使用分片启用的集合。因此,信息被大大简化了。

现在我们有了分片、配置服务器和查询路由器,让我们在数据库中启用分片。在对集合进行相同操作之前,必须先在数据库中启用分片。以下命令在名为ecommerce的数据库中启用分片:

mongos> sh.enableSharding("ecommerce")

通过查询分片集群的状态,我们可以注意到我们有关于我们的ecommerce数据库的信息:

mongos> sh.status()
--- Sharding Status --- 
 sharding version: {
 "_id" : 1,
 "minCompatibleVersion" : 5,
 "currentVersion" : 6,
 "clusterId" : ObjectId("54d9dc74fadbfe60ef7b394e")
}
 shards:
 {  "_id" : "shard0000",  "host" : "172.17.0.23:27017" }
 {  "_id" : "shard0001",  "host" : "172.17.0.24:27017" }
 databases:
 {  "_id" : "admin",  "partitioned" : false,  "primary" : "config" }
 {  "_id" : "ecommerce",  "partitioned" : true,  "primary" : "shard0000" }

考虑一下,在ecommerce数据库中,我们有一个customers集合,其中包含以下文档:

{
 "_id" : ObjectId("54fb7110e7084a229a66eda2"),
 "isActive" : true,
 "age" : 28,
 "name" : "Paige Johnson",
 "gender" : "female",
 "email" : "paigejohnson@combot.com",
 "phone" : "+1 (830) 484-2397",
 "address" : {
 "city" : "Dennard",
 "state" : "Kansas",
 "zip" : 2492,
 "latitude" : -56.564242,
 "longitude" : -160.872178,
 "street" : "998 Boerum Place"
 },
 "registered" : ISODate("2013-10-14T14:44:34.853Z"),
 "friends" : [
 {
 "id" : 0,
 "name" : "Katelyn Barrett"
 },
 {
 "id" : 1,
 "name" : "Weeks Valentine"
 },
 {
 "id" : 2,
 "name" : "Wright Jensen"
 }
 ]
}

我们必须执行shardCollection命令来在这个集合中启用分片,使用集合名称和一个将代表我们的分片键的文档作为参数。

通过在mongos shell 中执行以下命令来启用customers集合中的分片:

mongos> sh.shardCollection("ecommerce.customers", {"address.zip": 1, "registered": 1})
{
 "proposedKey" : {
 "address.zip" : 1,
 "registered" : 1
 },
 "curIndexes" : [
 {
 "v" : 1,
 "key" : {
 "_id" : 1
 },
 "name" : "_id_",
 "ns" : "ecommerce.customers"
 }
 ],
 "ok" : 0,
 "errmsg" : "please create an index that starts with the shard key before sharding."
}

正如你所看到的,命令执行过程中出现了一些问题。MongoDB 警告我们必须有一个索引,并且分片键必须是一个前缀。因此,我们必须在mongos shell 上执行以下序列:

mongos> db.customers.createIndex({"address.zip": 1, "registered": 1})
mongos> sh.shardCollection("ecommerce.customers", {"address.zip": 1, "registered": 1})
{ "collectionsharded" : "ecommerce.customers", "ok" : 1 }

干得好!现在我们有了启用了分片的ecommerce数据库的customers集合。

注意

如果你正在对一个空集合进行分片,shardCollection命令将创建分片键的索引。

但是是什么因素决定了选择address.zipregistered作为分片键?在这种情况下,正如我之前所说的,我选择了一个随机字段来进行说明。从现在开始,让我们考虑什么因素可以确定一个好的分片键的创建。

选择分片键时的基本注意事项

选择分片键并不是一项容易的任务,也没有固定的配方。大多数情况下,提前了解我们的领域及其用途是至关重要的。在进行此操作时要非常小心。一个不太合适的分片键可能会给我们的数据库带来一系列问题,从而影响其性能。

首先是可分性。我们必须考虑一个分片键,使我们能够在分片之间可视化文档的分割。具有有限数量值的分片键可能导致“不可分割”的块。

我们可以说,这个领域必须具有高基数,例如具有高多样性值和唯一字段的字段。识别字段,如电子邮件地址、用户名、电话号码、社会安全号码和邮政编码,是高基数字段的一个很好的例子。

实际上,如果考虑到某种情况,它们每一个都可以是独特的。在电子商务系统中,如果我们有一个与装运相关的文档,我们将有多个具有相同邮政编码的文档。但是,考虑另一个例子,一个城市中美容沙龙的目录系统。那么,如果一个文档代表一个美容沙龙,那么邮政编码将比在前一个例子中更独特。

第三点可能是迄今为止最有争议的,因为它在某种程度上与上一个点相矛盾。我们已经看到,具有高随机性的分片键是尝试增加写操作性能的良好实践。现在,我们将考虑创建一个分片键以针对单个分片。当我们考虑读操作的性能时,从单个分片读取是一个好主意。正如您已经知道的,在分片集群中,数据库复杂性被抽象为查询路由器。换句话说,发现应该在哪些分片上搜索查询中请求的信息是mongos的责任。如果我们的分片键分布在多个分片上,那么mongos将在分片上搜索信息,收集并合并它们,然后交付。但是,如果分片键旨在针对单个分片,那么 mongos 任务将在这个唯一的分片中搜索信息,然后交付。

第四个也是最后一个点是关于当文档中没有任何字段适合作为我们的分片键的选择时。在这种情况下,我们必须考虑一个组合的分片键。在前面的例子中,我们使用了一个由字段address.zipregistered组成的分片键。组合的分片键也将帮助我们拥有一个更可分的键,因为如果分片键的第一个值没有高基数,添加第二个值将增加基数。

因此,这些基本问题告诉我们,根据我们想要搜索的内容,我们应该选择不同的分片键文档方法。如果我们需要查询隔离,那么可以专注于一个分片的分片键是一个不错的选择。但是,当我们需要扩展写操作时,我们的分片键越随机,对性能的影响就越好。

扩展社交收件箱模式设计

2014 年 10 月 31 日,MongoDB 公司在其社区博客上介绍了解决一个非常常见的问题,社交收件箱的三种不同方法。

注意

如果您想查看博客文章,请参阅blog.mongodb.org/post/65612078649/schema-design-for-social-inboxes-in-mongodb

从所呈现的三种模式设计中,可以看到我们迄今为止以一种简单有效的方式应用了所有扩展概念。在所有情况下,都应用了扇出的概念,即工作负载在分片之间并行分布。每种方法都根据数据库客户端的需求有其自己的应用。

三种模式设计是:

  • 在读取时进行扇出操作

  • 在写入时进行扇出操作

  • 在写入时进行扇出操作

在读取时进行扇出操作

由于查询路由器在客户端读取收件箱时的行为,扇出读设计被称为这个名字。与其他设计相比,它被认为是具有最简单机制的设计。它也是最容易实现的。

在扇出读设计中,我们将有一个“收件箱”集合,我们将在其中插入每条新消息。将驻留在此集合中的文档有四个字段:

  • from:表示消息发送者的字符串

  • to:包含所有消息接收者的数组

  • sent:表示消息发送给接收者的日期字段

  • message:表示消息本身的字符串字段

在下面的文件中,我们可以看到一个从约翰发送给迈克和比莉的消息的示例:

{
from: "John", 
to: ["Mike", "Billie"], 
sent: new Date(), 
message: "Hey Mike, Billie"
}

这个集合上的操作将是所有操作中最直接的。发送消息就是在“收件箱”集合中进行插入操作,而读取消息就是查找具有特定接收者的所有消息。

要在数据库上启用分片,我们的“收件箱”集合位于一个名为social的数据库中。为了做到这一点,以及我们在本章中将要做的所有其他事情,我们将使用mongos shell。所以,让我们开始吧:

mongos> sh.enableSharding("social")

现在,我们将不得不创建集合的分片键。为了实现这个设计,我们将使用“收件箱”集合的from字段创建一个分片键:

mongos> sh.shardCollection("social.inbox", {from: 1})

注意

如果我们的集合已经有文档,我们应该为分片键字段创建索引。

最后一步是在tosent字段上创建一个复合索引,以寻求更好的读操作性能:

mongos> db.inbox.createIndex({to: 1, sent: 1})

我们现在准备好在我们的“收件箱”集合中发送和读取消息了。在mongos shell 上,让我们创建一条消息并将其发送给接收者:

mongos> var msg = {
from: "John", 
to: ["Mike", "Billie"], 
sent: new Date(), 
message: "Hey Mike, Billie"
}; // this command creates a msg variable and stores a message json as a value
mongos> db.inbox.insert(msg); // this command inserts the message on the inbox collection

如果我们想读取迈克的收件箱,我们应该使用以下命令:

mongos> db.inbox.find({to: "Mike"}).sort({sent: -1})

在这种设计中,写操作可能被认为是有效的。根据活跃用户的数量,我们将在分片之间有均匀的数据分布。

另一方面,查看收件箱并不那么有效。每次收件箱读取都会使用to字段进行find操作,并按sent字段排序。因为我们的集合将from字段作为分片键,这意味着消息在分片上是按发送者分组的,所以任何不使用分片键的查询都将被路由到所有分片。

如果我们的应用程序旨在发送消息,这种设计就适用。由于我们需要一个社交应用程序,其中您可以发送和阅读消息,让我们来看看下一个设计方法,即扇出写。

扇出写

使用扇出写设计,我们可以说与之前相比,我们将产生相反的效果。在扇出读中,我们到达了集群上的每个分片来查看收件箱,而在扇出写中,我们将在所有分片之间分发写操作。

为了实现扇出写而不是在发送者上进行分片,我们将在消息的接收者上进行分片。以下命令在“收件箱”集合中创建了分片键:

mongos> sh.shardCollection("social.inbox", {recipient: 1, sent: 1})

我们将使用在扇出读设计中使用的相同文档。因此,要将一条消息从约翰发送给迈克和比莉,我们将在mongos shell 中执行以下命令:

mongos> var msg = {
 "from": "John",
 "to": ["Mike", "Billie"], // recipients
 "sent": new Date(),
 "message": "Hey Mike, Billie"
}

mongos> for(recipient in msg.to){ // iterate though recipients
msg.recipient = msg.to[recipient]; // creates a recipient field on the message and stores the recipient of the message
db.inbox.insert(msg); // inserts the msg document for every recipient
}

为了更好地理解发生了什么,让我们做一个小的代码分解:

  • 我们应该做的第一件事是创建一个msg变量,并在那里存储一个 JSON 消息:
var msg = {
 "from": "John",
 "to": ["Mike", "Billie"], // recipients
 "sent": new Date(),
 "message": "Hey Mike, Billie"
}

  • 要向每个接收者发送消息,我们必须迭代to字段中的值,在消息 JSON 中创建一个新字段msg.recipient,并存储消息的接收者:
for(recipient in msg.to){
msg.recipient = msg.to[recipient];

  • 最后,我们将消息插入“收件箱”集合中:
db.inbox.insert(msg); 
}

对于消息的每个接收者,我们将在“收件箱”集合中插入一个新文档。在mongos shell 上执行的以下命令显示了迈克的收件箱:

mongos> db.inbox.find ({recipient: "Mike"}).sort({ sent:-1})
{
 "_id": ObjectId("54fe6319b40b90bd157eb0b8"),
 "from": "John",
 "to": [
 "Mike",
 "Billie"
 ],
 "sent": ISODate("2015-03-10T03:20:03.597Z"),
 "message": "Hey Mike, Billie",
 "recipient": "Mike"
}

由于消息同时有迈克和比莉作为接收者,我们也可以阅读比莉的收件箱:

mongos> db.inbox.find ({recipient: "Billie"}).sort({ sent:-1})
{
 "_id": ObjectId("54fe6319b40b90bd157eb0b9"),
 "from": "John",
 "to": [
 "Mike",
 "Billie"
 ],
 "sent": ISODate("2015-03-10T03:20:03.597Z"),
 "message": "Hey Mike, Billie",
 "recipient": "Billie"
}

通过这样做,当我们读取用户的收件箱时,我们将针对单个分片,因为我们使用分片键作为查找查询的条件。

但是,即使我们只能到达一个分片来查看收件箱,当用户数量增长时,我们将有许多随机读取。为了解决这个问题,我们将介绍分桶的概念。

写入时的扇出与桶

写入时的扇出设计是解决社交收件箱问题的一个非常有趣的方法。每当需要时,我们可以向集群中添加更多的分片,并且收件箱数据将在它们之间均匀分布。然而,正如我们之前所述,随着数据库的增长,我们所做的随机读取是我们必须处理的瓶颈。尽管我们通过使用分片键作为查找查询的条件来针对读操作目标单个分片,但在查看收件箱时我们将始终进行随机读取。假设每个用户平均有 50 条消息,那么每次查看收件箱都会产生 50 次随机读取。因此,当我们将这些随机读取与同时访问其收件箱的用户相乘时,我们可以想象我们将如何快速饱和我们的数据库。

为了减少这种瓶颈,出现了写入时的扇出与桶方法。扇出与桶是对写入时的扇出的改进,通过将消息分桶在按时间排序的消息文档中。

这种设计的实现与以前的设计相比有很大不同。在写入时的扇出与桶中,我们将有两个集合:

  • 一个users集合

  • 一个inbox集合

users集合将具有包含用户数据的文档。在此文档中,除了基本用户信息外,我们还有一个字段,用于存储用户拥有的收件箱消息总数。

inbox集合将存储具有一组用户消息的文档。我们将在此集合中有一个owner字段,用于标识用户,以及一个sequence字段,用于标识桶。这些是我们将使用的字段来对inbox集合进行分片。

在我们的示例中,每个桶将有 50 条消息。以下命令将在社交数据库上启用分片,并在inbox集合中创建分片键:

mongos> sh.enableSharding("social")
mongos> sh.shardCollection("social.inbox", {owner: 1, sequence: 1})

正如之前提到的,我们还有一个users集合。以下命令在user集合中创建一个分片键:

mongos> sh.shardCollection("social.users", {user_name: 1})

现在我们已经创建了分片键,让我们从 John 发送一条消息给 Mike 和 Billie。消息文档将与之前的非常相似。它们之间的区别在于ownersequence字段。在mongos shell 上执行以下代码将从 John 发送一条消息给 Mike 和 Billie:

mongos> var msg = { 
 "from": "John",
 "to": ["Mike", "Billie"], //recipients
 "sent": new Date(),
 "message": "Hey Mike, Billie"
}

mongos> for(recipient in msg.to) {

var count = db.users.findAndModify({
 query: {user_name: msg.to[recipient]},
 update:{"$inc":{"msg_count":1}},
 upsert: true,
 new: true}).msg_count;

 var sequence = Math.floor(count/50);

 db.inbox.update({
 owner: msg.to[recipient], sequence: sequence},
 {$push:{"messages":msg}},
 {upsert: true});
}

与之前一样,为了理解发送消息,让我们对代码进行分解:

  • 首先,我们创建一个msg变量,并将消息 JSON 存储在其中

  • 我们遍历to字段中的收件人,并执行findAndModify方法,在其中我们查找users集合中的文档以确定消息接收者的所有者。由于我们使用了upsert选项,并将其值设为true,如果我们没有找到用户,那么我们将创建一个新用户。update字段使用了$inc运算符,这意味着我们将msg_count字段增加一。该方法还使用了new选项,并且我们将执行保存的文档作为此命令的结果。

  • 从返回的文档中,我们获取msg_count字段的值,该字段表示用户的总消息数,并将该值存储在count变量中。

  • 为了发现消息将被保存的存储桶,我们将使用mongos shell 上可用的Math类的floor函数。正如我们之前所说,我们将在每个存储桶中有 50 条消息,因此我们将通过 50 除以count变量的值,并得到结果的floor函数。例如,如果我们发送第三条用户消息,那么保存此消息的存储桶的结果是Math.floor(3/50),即 0。当我们达到第 50 条消息时,存储桶的值变为 1,这意味着下一条消息将在一个新的存储桶中。

  • 我们将更新收件箱集合中具有我们计算的所有者序列值的文档。由于我们在update命令上使用了upsert选项,并且将值设置为true,如果文档不存在,它将创建该文档。

通过这种方式,我们将确保用户的收件箱完全位于单个分片上。与扇入写相反,在查看收件箱时我们有许多随机读取,而在扇出写与存储桶中,我们对于每 50 条用户消息只进行一次文档读取。

在写入时使用存储桶进行扇出无疑是社交收件箱模式设计的最佳选择,当我们的要求是高效地发送和阅读消息时。然而,收件箱集合的文档大小可能会成为一个问题。根据消息的大小,我们将不得不小心管理我们的存储空间。

总结

模式设计是更好的可扩展性策略。无论我们手头有多少技术和工具,了解我们的数据将如何使用并花时间设计是更便宜和持久的方法。

在下一章中,您将运用到目前为止学到的一切,为一个真实的例子从零开始创建一个模式设计。

第八章:使用 MongoDB 进行日志记录和实时分析

在本书中,向您介绍了许多您已经了解的概念。您已经学会了如何将它们与 MongoDB 提供给我们的技术和工具结合使用。本章的目标是在一个真实的例子中应用这些技术。

我们将在本章中开发的真实例子解释如何使用 MongoDB 作为网络服务器日志数据的持久存储,更具体地说,是来自 Nginx 网络服务器的数据。通过这样做,我们将能够分析 Web 应用程序的流量数据。

我们将从分析 Nginx 日志格式开始,以定义对我们实验有用的信息。之后,我们将定义我们想要在 MongoDB 中执行的分析类型。最后,我们将设计我们的数据库模式,并通过使用代码在 MongoDB 中读写数据来实现。

在本章中,我们将考虑到每个生成此事件的主机都会消耗这些信息并将其发送到 MongoDB。我们的重点不在应用程序的架构或我们在示例中将生成的代码上。因此,亲爱的读者,如果您不同意这里显示的代码片段,请随意修改它们或自己创建一个新的。

也就是说,本章将涵盖:

  • 日志数据分析

  • 我们正在寻找的内容

  • 设计模式

日志数据分析

访问日志经常被开发人员、系统管理员或任何在网络上保持服务的人忽视。但是,当我们需要及时了解每个请求在我们的网络服务器上发生了什么时,它是一个强大的工具。

访问日志保存有关服务器活动和性能的信息,还告诉我们有关可能问题。如今最常见的网络服务器是 Apache HTTPD 和 Nginx。这些网络服务器默认情况下有两种日志类型:错误日志和访问日志。

错误日志

正如其名称所示,错误日志是网络服务器在处理接收到的请求时发现的错误的存储位置。一般来说,这种类型的日志是可配置的,并且会根据预定义的严重级别写入消息。

访问日志

访问日志是存储所有接收和处理请求的地方。这将是我们研究的主要对象。

文件中记录的事件以预定义的布局记录,可以根据服务器管理者的愿望进行格式化。默认情况下,Apache HTTPD 和 Nginx 都有一个称为combined的格式。以这种格式生成的日志示例如下所示:

191.32.254.162 - - [29/Mar/2015:16:04:08 -0400] "GET /admin HTTP/1.1" 200 2529 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.104 Safari/537.36"

乍一看,一行日志中包含太多信息可能有点吓人。然而,如果我们看一下生成此日志的模式并尝试对其进行检查,我们会发现它并不难理解。

生成此行的 Nginx 网络服务器的模式如下所示:

$remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent"

我们将描述此模式的每个部分,以便您更好地理解:

  • $remote_addr:这是在网络服务器上执行请求的客户端的 IP 地址。在我们的示例中,该值对应于 191.32.254.162。

  • $remote_user:这是经过身份验证的用户,如果存在的话。当未识别经过身份验证的用户时,此字段将填写连字符。在我们的示例中,该值为-

  • [$time_local]:这是请求在网络服务器上接收的时间,格式为[day/month/year:hour:minute:second zone]

  • "$request":这是客户端请求本身。也称为请求行。为了更好地理解,我们将分析示例中的请求行:"GET /admin HTTP/1.1"。首先,我们有客户端使用的 HTTP 动词。在这种情况下,它是一个GET HTTP 动词。接着,我们有客户端访问的资源。在这种情况下,访问的资源是/admin。最后,我们有客户端使用的协议。在这种情况下,是HTTP/1.1

  • $status:这是 Web 服务器向客户端返回的 HTTP 状态码。该字段的可能值在 RFC 2616 中定义。在我们的示例中,Web 服务器向客户端返回状态码200

注意

要了解有关 RFC 2616 的更多信息,您可以访问 www.w3.org/Protocols/rfc2616/rfc2616.txt

  • $body_bytes_sent:这是发送给客户端的响应正文的字节长度。当没有正文时,该值将是一个连字符。我们必须注意,该值不包括请求头。在我们的示例中,该值为 2,529 字节。

  • "$http_referer":这是客户端请求头中“Referer”中包含的值。该值表示所请求资源的引用位置。当直接执行资源访问时,该字段填充一个连字符。在我们的示例中,该值为-

  • "$http_user_agent":这是客户端在 User-Agent 头中发送的信息。通常,我们可以在该头中识别请求中使用的网络浏览器。在我们的示例中,该字段的值为"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.104 Safari/537.36"

除此之外,我们还有更多可用于创建新日志格式的变量。其中,我们要强调:

  • $request_time:这表示请求处理的总时间

  • $request_length:这表示客户端响应的总长度,包括头部。

现在我们熟悉了 Web 服务器访问日志,我们将定义我们想要分析的内容,以了解需要记录哪些信息。

我们正在寻找的内容

从 Web 服务器访问日志中提取的信息非常丰富,为我们提供了无限的研究可能性。简单直接,我们可以通过计算访问日志的行数来统计 Web 服务器接收的请求数。但我们可以扩展我们的分析,尝试测量数据流量的平均值,例如。

最近,最广泛使用的服务之一是应用性能管理系统,也称为 APM。如今,这些服务通常作为软件即服务提供,并且主要目标是为我们提供应用程序性能和健康状况的视图。

APM 是基于从访问日志中提取的信息进行分析的一个很好的例子,因为 APM 生成的大部分信息都是基于访问日志的。

注意!我并不是说 APM 仅基于访问日志工作,但 APM 生成的大部分信息可以从访问日志中提取。好吗?

注意

要了解更多信息,请访问 en.wikipedia.org/wiki/Application_performance_management

正如本章开头所说,我们并没有打算编写或创建整个系统,但我们将在实践中展示如何使用 MongoDB 保留访问日志信息以供可能的分析。

基于 APM,我们将结构化我们的示例,分析 Web 服务器资源吞吐量。只需使用 Web 服务器访问日志中包含的信息就可以进行此分析。为此,我们的访问日志需要哪些数据?我们应该使用组合格式吗?

测量 Web 服务器上的流量

我们的 Web 服务器吞吐量将根据一定时间段内的请求数进行估算,即一天内的请求数,一小时内的请求数,一分钟内的请求数或一秒内的请求数。每分钟的请求数是实时监控的一个非常合理的度量。

吞吐量是通过计算在我们的 Web 服务器中处理的请求来计算的。因此,不需要使用访问日志中的特定数据。然而,为了使我们的数据能够进行更丰富的进一步分析,我们将创建一个特定的日志格式,其中将收集请求信息,如 HTTP 状态码、请求时间和长度。

Apache HTTP 和 Nginx 都允许我们自定义访问日志或创建一个具有自定义格式的新文件。第二个选项似乎是完美的。在开始配置 Web 服务器之前,我们将使用先前解释的变量创建我们的日志格式。只需记住我们正在使用 Nginx Web 服务器。

$remote_addr [$time_local] "$request" $status $request_time $request_length

当我们定义了我们的日志格式后,我们可以配置我们的 Nginx Web 服务器。为此,让我们执行以下步骤:

  1. 首先,在 Nginx 中定义这种新格式,我们需要编辑nginx.conf文件,在 HTTP 元素中添加一个新的条目,其中包含新的日志格式:
log_format custom_format '$remote_addr [$time_local] "$request" $status $request_time $request_length';
  1. 现在我们需要在nginx.conf文件中添加另一个条目,定义新的自定义日志将写入哪个文件:
access_log /var/log/nginx/custom_access.log custom_format;
  1. 要应用我们的更改,请在终端中执行以下命令重新加载 Nginx Web 服务器:
/usr/sbin/nginx reload

  1. 重新加载 Nginx 服务器后,我们可以查看我们的新日志文件/var/log/nginx/custom_access.log,并检查行是否像以下行一样:
191.32.254.162 [29/Mar/2015:18:35:26 -0400] "GET / HTTP/1.1" 200 0.755 802

配置了日志格式,Web 服务器设置好了;现在是设计我们的模式的时候了。

设计模式

在本书中已经多次重复的一件事是了解我们的数据及其用途的重要性。现在,比以往任何时候,在这个实际练习中,我们将逐步设计我们的模式,考虑其中涉及的每个细节。

在前一节中,我们定义了我们想要从 Web 服务器访问日志中提取的数据集。下一步是列出与数据库客户端相关的要求。如前所述,一个进程将负责从日志文件中捕获信息并将其写入 MongoDB,另一个进程将读取已在数据库中持久化的信息。

一个值得关注的问题是在 MongoDB 中写入文档时的性能,因为重要的是要确保信息几乎实时生成。由于我们没有对每秒数据量的先前估计,我们将持乐观态度。让我们假设我们将一直从 Web 服务器到我们的 MongoDB 实例中获得大量数据。

考虑到这一要求,我们将担心随着时间的推移数据维度将增加。更多的事件意味着将插入更多的文档。这些是我们的系统良好运行的主要要求。

乍一看,我们可以想象这一切都是关于定义文档格式并将其持久化到集合中。但这样想忽视了众所周知的 MongoDB 模式灵活性。因此,我们将分析吞吐量问题,以便定义在哪里以及如何持久化信息。

捕获事件请求

Web 服务器吞吐量分析可能是最简单的任务。简单地说,事件数量的测量将给我们一个代表 Web 服务器吞吐量的数字。

因此,如果对于每个生成的事件都执行了写文档操作,并声明了此操作的时间,这是否意味着我们可以轻松获得吞吐量?是的!因此,表示我们可以分析吞吐量的最简单的 MongoDB 文档的方式如下:

{

 "_id" : ObjectId("5518ce5c322fa17f4243f85f"),
 "request_line" : "191.32.254.162 [29/Mar/2015:18:35:26 -0400] \"GET /media/catalog/product/image/2.jpg HTTP/1.1\" 200 0.000 867"

}

在文档集合中执行count方法时,我们将获得 Web 服务器的吞吐量值。假设我们有一个名为events的集合,为了找出吞吐量,我们必须在 mongod shell 中执行以下命令:

db.events.find({}).count()

这个命令返回到目前为止在我们的 Web 服务器上生成的事件总数。但是,这是我们想要的数字吗?不是。在没有将其放在一定时间段内的情况下,拥有事件的总数是没有意义的。如果我们不知道我们何时开始记录这些事件,甚至最后一个事件生成的时间,那么到目前为止由 Web 服务器处理的 10,000 个事件有什么用呢?

如果我们想在一定时间段内计算事件的数量,最简单的方法是包括一个代表事件创建日期的字段。这个文档的一个例子如下所示:

{

 "_id" : ObjectId("5518ce5c322fa17f4243f85f"),
 "request_line" : "191.32.254.162 [29/Mar/2015:18:35:26 -0400] \"GET /media/catalog/product/image/2.jpg HTTP/1.1\" 0.000 867",
 "date_created" : ISODate("2015-03-30T04:17:32.246Z")

}

因此,我们可以通过执行查询来检查在给定时间段内 Web 服务器上的总请求数。执行这个查询的最简单方法是使用聚合框架。在 mongod shell 上执行以下命令将返回每分钟的总请求数:

db.events.aggregate(
{
 $group: {
 _id: {
 request_time: {
 month: {
 $month: "$date_created"
 },
 day: {
 $dayOfMonth: "$date_created"
 },
 year: {
 $year: "$date_created"
 },
 hour: {
 $hour: "$date_created"
 },
 min: {
 $minute: "$date_created"
 }
 }
 },
 count: {
 $sum: 1
 }
 }
})

注意

聚合管道有其限制。如果命令结果返回一个超过 BSON 文档大小的单个文档,就会产生错误。自 MongoDB 的 2.6 版本以来,aggregate命令返回一个游标,因此可以返回任意大小的结果集。

您可以在 MongoDB 参考手册的docs.mongodb.org/manual/core/aggregation-pipeline-limits/找到更多关于聚合管道限制的信息。

在命令管道中,我们定义了$group阶段来按天、月、年、小时和分钟对文档进行分组。并且使用$sum运算符来计算所有内容。通过这个aggregate命令的执行,我们将得到如下的文档:

{
 "_id": {
 "request_time": {
 "month": 3,
 "day": 30,
 "year": 2015,
 "hour": 4,
 "min": 48
 }
 },
 "count": 50
}
{
 "_id": {
 "request_time": {
 "month": 3,
 "day": 30,
 "year": 2015,
 "hour": 4,
 "min": 38
 }
 },
 "count": 13
}
{
 "_id": {
 "request_time": {
 "month": 3,
 "day": 30,
 "year": 2015,
 "hour": 4,
 "min": 17
 }
 },
 "count": 26
}

在这个输出中,可以知道 Web 服务器在一定时间段内收到了多少请求。这是由于$group运算符的行为,它根据一个或多个字段收集与查询匹配的文档,并收集文档组。我们将聚合管道的组阶段的每个部分,如月、日、年、小时和分钟,都带入了我们的$date_created字段。

如果您想知道在您的 Web 服务器上哪个资源的吞吐量最高,但没有一个选项符合这个要求。然而,这个问题的快速解决方案很容易实现。乍一看,最快的方法是拆解事件并创建一个更复杂的文档,就像下面的例子所示:

{
 "_id" : ObjectId("5519baca82d8285709606ce9"),
 "remote_address" : "191.32.254.162",
 "date_created" : ISODate("2015-03-29T18:35:25Z"),
 "http_method" : "GET",
 "resource" : "/media/catalog/product/cache/1/image/200x267/9df78eab33525d08d6e5fb8d27136e95/2/_/2.jpg",
 "http_version" : "HTTP/1.1",
 "status": 200,
 "request_time" : 0,
 "request_length" : 867
}

通过使用这种文档设计,可以通过聚合框架知道每分钟的资源吞吐量:

db.events.aggregate([
 {
 $group: {
 _id: "$resource",
 hits: {
 $sum: 1
 }
 }
 },
 {
 $project: {
 _id: 0,
 resource: "$_id",
 throughput: {
 $divide: [
 "$hits",
 1440
 ]
 }
 }
 },
 {
 $sort: {
 throughput: -1
 }
 }
])

在前面的管道中,第一步是按资源分组,并计算在整个一天内资源请求发生的次数。下一步是使用$project运算符,并且与$divide运算符一起使用给定资源中的点击次数,并通过 1,440 分钟进行平均计算,即一天或 24 小时的总分钟数。最后,我们按降序排序结果,以查看哪些资源具有更高的吞吐量。

为了保持清晰,我们将逐步执行管道并解释每个步骤的结果。在第一阶段的执行中,我们有以下结果:

db.events.aggregate([{$group: {_id: "$resource", hits: {$sum: 1}}}])

这个执行通过字段资源对事件集合文档进行分组,并计算使用$sum和值1时字段点击的发生次数。返回的结果如下所示:

{ "_id" : "/", "hits" : 5201 }
{ "_id" : "/legal/faq", "hits" : 1332 }
{ "_id" : "/legal/terms", "hits" : 3512 }

在管道的第二阶段,我们使用$project运算符,它将给出每分钟的点击次数:

db.documents.aggregate([
 {
 $group: {
 _id: "$resource",
 hits: {
 $sum: 1
 }
 }
 },
 {
 $project: {
 _id: 0,
 resource: "$_id",
 throughput: {
 $divide: [
 "$hits",
 1440
 ]
 }
 }
 }
])

以下是这个阶段的结果:

{ "resource" : "/", "throughput" : 3.6118055555555557 }
{ "resource" : "/legal/faq", "throughput" : 0.925 }
{ "resource" : "/legal/terms", "throughput" : 2.438888888888889 }

管道的最后阶段是按吞吐量降序排序结果:

db.documents.aggregate([
 {
 $group: {
 _id: "$resource",
 hits: {
 $sum: 1
 }
 }
 },
 {
 $project: {
 _id: 0,
 resource: "$_id",
 throughput: {
 $divide: [
 "$hits",
 1440
 ]
 }
 }
 },
 {
 $sort: {
 throughput: -1
 }
 }
])

产生的输出如下:

{ "resource" : "/", "throughput" : 3.6118055555555557 }
{ "resource" : "/legal/terms", "throughput" : 2.438888888888889 }
{ "resource" : "/legal/faq", "throughput" : 0.925 }

看起来我们成功地获得了我们文档的良好设计。现在我们可以提取所需的分析和其他分析,正如我们将在后面看到的那样,因此我们现在可以停下来。错了!我们将审查我们的要求,将它们与我们设计的模型进行比较,并尝试弄清楚是否这是最佳解决方案。

我们的愿望是知道每分钟对所有网络服务器资源的吞吐量。在我们设计的模型中,我们的网络服务器每个事件创建一个文档,通过使用聚合框架,我们可以计算我们分析所需的信息。

这个解决方案有什么问题?嗯,如果你认为是集合中的文档数量,那么你是对的。每个事件一个文档可能会生成巨大的集合,这取决于网络服务器的流量。显然,我们可以采用使用分片的策略,并将集合分布到许多主机上。但首先,我们将看看如何利用 MongoDB 中的模式灵活性来减少集合大小并优化查询。

一个文档解决方案

如果我们考虑到我们将有大量信息来创建分析,那么每个事件一个文档可能是有利的。但在我们试图解决的例子中,为每个 HTTP 请求持久化一个文档是昂贵的。

我们将利用 MongoDB 中的模式灵活性,这将帮助我们随着时间的推移增加文档。以下提案的主要目标是减少持久化文档的数量,同时优化我们集合中的读取和写入操作的查询。

我们正在寻找的文档应该能够为我们提供所有所需的信息,以便了解每分钟请求的资源吞吐量;因此我们可以有一个具有这种结构的文档:

  • 一个带有资源的字段

  • 一个带有事件日期的字段

  • 事件发生的分钟和总点击数

以下文档实现了前面列表中描述的所有要求:

{
 "_id" : ObjectId("552005f5e2202a2f6001d7b0"),
 "resource" : "/",
 "date" : ISODate("2015-05-02T03:00:00Z"),
 "daily" : 215840,
 "minute" : {
 "0" : 90,

 "1" : 150,
 "2" : 143,
 ...
 "1349": 210
 }
}

有了这个文档设计,我们可以检索在某个资源上每分钟发生的事件数量。我们还可以通过每日字段知道一天内的总请求数,并使用这个来计算我们想要的任何内容,比如每分钟的请求或每小时的请求。

为了演示我们可以在这个集合上进行的写入和读取操作,我们将利用在 Node.js 平台上运行的 JavaScript 代码。因此,在继续之前,我们必须确保我们的机器上已经安装了 Node.js。

注意

如果您需要帮助,您可以在nodejs.org找到更多信息。

我们应该做的第一件事是创建一个我们的应用程序将驻留的目录。在终端中,执行以下命令:

mkdir throughput_project

接下来我们转到我们创建的目录并启动项目:

cd throughput_project
npm init

回答向导提出的所有问题,以创建我们新项目的初始结构。目前,我们将根据我们给出的答案拥有一个基于package.json文件。

下一步是为我们的项目设置 MongoDB 驱动程序。我们可以通过编辑package.json文件来实现这一点,包括其依赖项的驱动程序引用,或者通过执行以下命令来实现:

npm install mongodb --save

上述命令将为我们的项目安装 MongoDB 驱动程序并将引用保存在package.json文件中。我们的文件应该是这样的:

{
  "name": "throughput_project",
  "version": "1.0.0",
  "description": "",
  "main": "app.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "Wilson da Rocha França",
  "license": "ISC",
  "dependencies": {
    "mongodb": "².0.25"
  }
}

最后一步是创建带有我们示例代码的app.js文件。以下是一个示例代码,向我们展示了如何在我们的网络服务器上计算事件的数量并将其记录在我们的集合中:

var fs = require('fs');
var util = require('util');
var mongo = require('mongodb').MongoClient;
var assert = require('assert');

// Connection URL
var url = 'mongodb://127.0.0.1:27017/monitoring;
// Create the date object and set hours, minutes,
// seconds and milliseconds to 00:00:00.000
var today = new Date();
today.setHours(0, 0, 0, 0);

var logDailyHit = function(db, resource, callback){
 // Get the events collection
  var collection = db.collection('events');
 // Update daily stats
  collection.update({resource: resource, date: today},
    {$inc : {daily: 1}}, {upsert: true},
    function(error, result){
      assert.equal(error, null);
      assert.equal(1, result.result.n);
      console.log("Daily Hit logged");
      callback(result);
  });
}

var logMinuteHit = function(db, resource, callback) {
 // Get the events collection
  var collection = db.collection('events');
 // Get current minute to update
  var currentDate = new Date();
  var minute = currentDate.getMinutes();
  var hour = currentDate.getHours();
 // We calculate the minute of the day
  var minuteOfDay = minute + (hour * 60);
  var minuteField = util.format('minute.%s', minuteOfDay);
 // Create a update object
  var update = {};
  var inc = {};
  inc[minuteField] = 1;
  update['$inc'] = inc;

 // Update minute stats
  collection.update({resource: resource, date: today},
    update, {upsert: true}, function(error, result){
      assert.equal(error, null);
      assert.equal(1, result.result.n);
      console.log("Minute Hit logged");
      callback(result);
  });
}

// Connect to MongoDB and log
mongo.connect(url, function(err, db) {
  assert.equal(null, err);
  console.log("Connected to server");
  var resource = "/";
  logDailyHit(db, resource, function() {
    logMinuteHit(db, resource, function(){
      db.close();
      console.log("Disconnected from server")
      });
    });
});

上面的示例代码非常简单。其中,我们有logDailyHit函数,负责记录事件并在文档daily字段中递增一个单位。第二个函数是logMinuteHit函数,负责记录事件的发生并递增代表当天当前分钟的文档minute字段。这两个函数都有一个更新查询,如果文档不存在,则具有值为trueupsert选项,那么将创建文档。

当我们执行以下命令时,将在资源"/"上记录一个事件。要运行代码,只需转到项目目录并执行以下命令:

node app.js

如果一切正常,运行命令后我们应该看到以下输出:

Connected to server
Daily Hit logged
Minute Hit logged
Disconnected from server

为了感受一下,我们将在 mongod shell 上执行findOne命令并观察结果:

db.events.findOne()
{
 "_id" : ObjectId("5520ade00175e1fb3361b860"),
 "resource" : "/",
 "date" : ISODate("2015-04-04T03:00:00Z"),
 "daily" : 383,
 "minute" : {
 "0" : 90,
 "1" : 150,
 "2" : 143
 }
}

除了之前的模型可以给我们的一切,这个模型还有一些优点。我们注意到的第一件事是,每当我们在 Web 服务器上注册一个新事件发生时,我们只会操作一个文档。另一个优点也在于,我们可以很容易地找到我们正在寻找的信息,给定一个特定的资源,因为我们在一个文档中有一整天的信息,这将导致我们在每次查询中操作更少的文档。

这个模式设计处理时间的方式将在我们考虑报告时给我们带来很多好处。无论是历史还是实时分析,都可以很容易地从这个集合中提取出文本和图形表示。

然而,与之前的方法一样,我们也将不得不处理一些限制。正如我们所见,我们在事件文档中同时增加了daily字段和minute字段,因为它们在 Web 服务器上发生。当某一天没有资源上报事件时,将创建一个新文档,因为我们在更新查询中使用了upsert选项。如果在给定的分钟内首次发生资源事件,同样的事情也会发生——$inc运算符将创建新的minute字段,并将"1"设置为值。这意味着我们的文档会随着时间的推移而增长,并且将超过 MongoDB 最初为其分配的大小。MongoDB 在文档分配的空间满时会自动执行重新分配操作。整天发生的这种重新分配操作直接影响了数据库的性能。

我们应该怎么办?就这样接受吗?不。我们可以通过添加一个预分配空间的过程来减少重新分配操作的影响。总之,我们将让应用程序负责创建一个包含一天内所有分钟的文档,并将每个字段初始化为值0。通过这样做,我们将避免 MongoDB 在一天内进行过多的重新分配操作。

注意

要了解更多关于记录分配策略的信息,请访问 MongoDB 参考用户手册docs.mongodb.org/manual/core/storage/#record-allocation-strategies

举个例子,我们可以在app.js文件中创建一个新的函数来预分配文档空间:

var fs = require('fs');
var util = require('util');
var mongo = require('mongodb').MongoClient;
var assert = require('assert');

// Connection URL
var url = 'mongodb://127.0.0.1:27017/monitoring';

var preAllocate = function(db, resource, callback){
 // Get the events collection
 var collection = db.collection('events');
 var now = new Date();
 now.setHours(0,0,0,0);
 // Create the minute document
 var minuteDoc = {};
 for(i = 0; i < 1440; i++){
 minuteDoc[i] = 0;
 }
 // Update minute stats
 collection.update(
 {resource: resource,
 date: now,
 daily: 0},
 {$set: {minute: minuteDoc}},
 {upsert: true}, function(error, result){
 assert.equal(error, null);
 assert.equal(1, result.result.n);
 console.log("Pre-allocated successfully!");
 callback(result);
 });
}

// Connect to MongoDB and log
mongo.connect(url, function(err, db) {
 assert.equal(null, err);
 console.log("Connected to server");
 var resource = "/";
 preAllocate(db, resource, function(){
 db.close();
 console.log("Disconnected from server")
 });
});

提示

下载示例代码

您可以从您在www.packtpub.com的帐户中下载示例代码文件,用于您购买的所有 Packt Publishing 图书。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接发送到您的电子邮件。

要在当前日期为"/"资源预分配空间,只需运行以下命令:

node app.js

执行的输出类似于这样:

Connected to server
Pre-allocated successfully!
Disconnected from server

我们可以在 mongod shell 上运行findOne命令来检查新文档。由于创建的文档非常长,所以我们只展示其中的一部分:

db.events.findOne();
{
 "_id" : ObjectId("551fd893eb6efdc4e71260a0"),
 "daily" : 0,
 "date" : ISODate("2015-04-06T03:00:00Z"),
 "resource" : "/",
 "minute" : {
 "0" : 0,
 "1" : 0,
 "2" : 0,
 "3" : 0,
 ...
 "1439" : 0,
 }
}

建议我们在午夜前预先分配文档,以确保应用程序的顺利运行。如果我们安排创建这个文档并留有适当的安全边界,我们就不会有在午夜后发生事件的风险。

好了,现在问题解决了,我们可以回到引发我们文档重新设计的问题:数据的增长。

即使将我们收集的文档数量减少到每天每个事件一个文档,我们仍然可能会遇到存储空间的问题。当我们的网络服务器上有太多资源接收事件时,我们无法预测应用程序生命周期中会有多少新资源。为了解决这个问题,我们将使用两种不同的技术:TTL 索引和分片。

TTL 索引

并不总是需要将所有日志信息永久存储在服务器上。限制存储在磁盘上的文件数量已经成为运维人员的标准做法。

同样的道理,我们可以限制我们需要存储在集合中的文档数量。为了实现这一点,我们可以在date字段上创建一个 TTL 索引,指定一个文档在集合中存在多长时间。只要记住,一旦创建了 TTL 索引,MongoDB 将自动从集合中删除过期的文档。

假设事件命中信息只在一年内有用。我们将在date字段上创建一个索引,属性为expireAfterSeconds,值为31556926,对应一年的秒数。

在 mongod shell 上执行以下命令,为我们的事件集合创建索引:

db.monitoring.createIndex({date: 1}, {expireAfterSeconds: 31556926})

如果索引不存在,输出应该是这样的:

{
 "createdCollectionAutomatically" : false,
 "numIndexesBefore" : 1,
 "numIndexesAfter" : 2,
 "ok" : 1
}

一旦完成这一步,我们的文档将根据日期字段在我们的集合中存活一年,之后 MongoDB 将自动删除它们。

分片

如果你是那些拥有无限资源并希望在磁盘上存储大量信息的人之一,那么缓解存储空间问题的一个解决方案是通过分片来分布数据。

正如我们之前所说,当我们选择分片键时,我们应该增加我们的努力,因为通过分片键,我们将确保我们的读写操作将由分片平均分布,也就是说,一个查询将针对集群上的一个分片或几个分片。

一旦我们完全控制了我们在网络服务器上拥有多少资源(或页面)以及这个数字将如何增长或减少,资源名称就成为一个很好的分片键选择。然而,如果我们有一个资源比其他资源有更多的请求(或事件),那么我们将有一个负载过重的分片。为了避免这种情况,我们将包括日期字段来组成分片键,这也将使我们在包含该字段的查询执行中获得更好的性能。

记住:我们的目标不是解释分片集群的设置。我们将向您介绍分片我们的集合的命令,考虑到您之前已经创建了分片集群。

为了使用我们选择的分片键对事件集合进行分片,我们将在 mongos shell 上执行以下命令:

mongos> sh.shardCollection("monitoring.events", {resource: 1, date: 1})

预期的输出是:

{ "collectionsharded" : "monitoring.events", "ok" : 1 }

提示

如果我们的事件集合中有任何文档,我们需要在分片集合之前创建一个索引,其中分片键是分片的前缀。要创建索引,请执行以下命令:

db.monitoring.createIndex({resource: 1, date: 1})

启用了分片的集合将使我们能够在事件集合中存储更多数据,并在数据增长时提高性能。

既然我们已经设计好了我们的文档并准备好我们的集合来接收大量数据,让我们执行一些查询!

查询报告

到目前为止,我们一直把精力集中在将数据存储在我们的数据库中。这并不意味着我们不关心读取操作。我们所做的一切都是通过概述我们应用程序的概况,并试图满足所有要求,为我们的数据库做好准备,迎接任何可能出现的情况。

因此,我们现在将说明我们有多少种可能性来查询我们的集合,以便基于存储的数据构建报告。

如果我们需要的是关于资源总点击量的实时信息,我们可以使用我们的每日字段来查询数据。有了这个字段,我们可以确定在一天中特定时间的资源总点击量,甚至可以根据一天中的分钟数确定资源的平均每分钟请求次数。

要查询基于当天时间的总点击量,我们将创建一个名为getCurrentDayhits的新函数,并且为了查询一天内每分钟的平均请求次数,我们将在app.js文件中创建getCurrentMinuteStats函数:

var fs = require('fs');
var util = require('util');
var mongo = require('mongodb').MongoClient;
var assert = require('assert');

// Connection URL
var url = 'mongodb://127.0.0.1:27017/monitoring';

var getCurrentDayhitStats = function(db, resource, callback){
 // Get the events collection
  var collection = db.collection('events');
  var now = new Date();
  now.setHours(0,0,0,0);
  collection.findOne({resource: "/", date: now},
    {daily: 1}, function(err, doc) {
    assert.equal(err, null);
    console.log("Document found.");
    console.dir(doc);
    callback(doc);
  });
}

var getCurrentMinuteStats = function(db, resource, callback){
 // Get the events collection
  var collection = db.collection('events');
  var now = new Date();
 // get hours and minutes and hold
  var hour = now.getHours()
  var minute = now.getMinutes();
 // calculate minute of the day to create field name
  var minuteOfDay = minute + (hour * 60);
  var minuteField = util.format('minute.%s', minuteOfDay);
 // set hour to zero to put on criteria
  now.setHours(0, 0, 0, 0);
 // create the project object and set minute of the day value
  var project = {};
  project[minuteField] = 1;
  collection.findOne({resource: "/", date: now},
    project, function(err, doc) {
    assert.equal(err, null);
    console.log("Document found.");
    console.dir(doc);
    callback(doc);
  });
}

// Connect to MongoDB and log
mongo.connect(url, function(err, db) {
  assert.equal(null, err);
  console.log("Connected to server");
  var resource = "/";
  getCurrentDayhitStats(db, resource, function(){
    getCurrentMinuteStats(db, resource, function(){
      db.close();
      console.log("Disconnected from server");
    });
  });
});

要看到魔术发生,我们应该在终端中运行以下命令:

node app.js

如果一切正常,输出应该是这样的:

Connected to server
Document found.
{ _id: 551fdacdeb6efdc4e71260a2, daily: 27450 }
Document found.
{ _id: 551fdacdeb6efdc4e71260a2, minute: { '183': 142 } }
Disconnected from server

另一个可能性是每天检索信息,计算资源每分钟的平均请求次数,或者在两个日期之间获取数据集以构建图表或表格。

以下代码有两个新函数,getAverageRequestPerMinuteStats,用于计算资源每分钟的平均请求次数,以及getBetweenDatesDailyStats,展示了如何在两个日期之间检索数据集。让我们看看app.js文件是什么样子的:

var fs = require('fs');
var util = require('util');
var mongo = require('mongodb').MongoClient;
var assert = require('assert');

// Connection URL
var url = 'mongodb://127.0.0.1:27017/monitoring';

var getAverageRequestPerMinuteStats = function(db, resource, callback){
 // Get the events collection
  var collection = db.collection('events');
  var now = new Date();
 // get hours and minutes and hold
  var hour = now.getHours()
  var minute = now.getMinutes();
 // calculate minute of the day to get the avg
  var minuteOfDay = minute + (hour * 60);
 // set hour to zero to put on criteria
  now.setHours(0, 0, 0, 0);
 // create the project object and set minute of the day value
  collection.findOne({resource: resource, date: now},
    {daily: 1}, function(err, doc) {
    assert.equal(err, null);
    console.log("The avg rpm is: "+doc.daily / minuteOfDay);
    console.dir(doc);
    callback(doc);
  });
}

var getBetweenDatesDailyStats = function(db, resource, dtFrom, dtTo, callback){
 // Get the events collection
  var collection = db.collection('events');
 // set hours for date parameters
  dtFrom.setHours(0,0,0,0);
  dtTo.setHours(0,0,0,0);
  collection.find({date:{$gte: dtFrom, $lte: dtTo}, resource: resource},
  {date: 1, daily: 1},{sort: [['date', 1]]}).toArray(function(err, docs) {
    assert.equal(err, null);
    console.log("Documents founded.");
    console.dir(docs);
    callback(docs);
  });
}

// Connect to MongoDB and log
mongo.connect(url, function(err, db) {
  assert.equal(null, err);
  console.log("Connected to server");
  var resource = "/";
  getAverageRequestPerMinuteStats(db, resource, function(){
    var now = new Date();
    var yesterday = new Date(now.getTime());
    yesterday.setDate(now.getDate() -1);
    getBetweenDatesDailyStats(db, resource, yesterday, now, function(){
      db.close();
      console.log("Disconnected from server");
    });

  });
});

正如你所看到的,有许多方法可以查询events集合中的数据。这些是一些非常简单的提取数据的例子,但它们是功能齐全且可靠的。

总结

本章向您展示了一个从零开始设计模式的过程的示例,以解决一个现实生活中的问题。我们从一个详细的问题及其要求开始,演变出了更好地利用可用资源的模式设计。基于这个问题的示例代码非常简单,但将为您终身学习提供基础。太棒了!

在这最后一章中,我们有机会在短短几页内回顾本书的前几章,并应用沿途介绍的概念。但是,正如你现在可能已经意识到的那样,MongoDB 是一个充满可能性的年轻数据库。它在社区中的采用率——包括你自己在内——随着每一次新发布而增加。因此,如果你发现自己面临一个新的挑战,意识到有不止一个解决方案,进行任何你认为必要或有用的测试。同事们也可以帮助,所以和他们交流。并且永远记住,一个好的设计是符合你需求的设计。

posted @ 2024-05-21 12:36  绝不原创的飞龙  阅读(66)  评论(0)    收藏  举报