进阶介绍
本节将介绍在选择器部分使用条件操作符和正则表达式的高级查询。这些操作符和正则表达式中的每一个都会提供对于你所编写的查询的更多控制权,并且相应的你也就拥有了对于能从MongoDB数据库中抓取到的信息的更多控制权。
使用条件操作符
条件操作符使你可以对尝试从数据库中提取的数据拥有更多的控制权。在本节中,将专注于以下操作符:$lt、$lte、$gt、$gte、$in、$nin以及$not。
以下示例假设了一个名称为Students的集合,它包含以下文档类型:
_{
id:ObjectID(),
Name:”Fu11 Name”,
Age:30, Gender:”M”,
Class:”C1”,
Score:95
}
首先要创建该集合并且插入一些样本文档。
1 | db.students.insert({Name:"S1",Age:25,Gender:"M",Class:"C1",Score:95}) |
$It 和$te
我们从Slt和Slte操作符开始讲解。它们分别表示“小于”和“小于等于”。
如果希望找出小于25岁(Age<25)的所有学生,就可以执行以下具有一个选择器的find命令:
1 | db.students.find({"Age":{"$lt":25}}) |
如果希望找出小于或等于25岁(Age≤25)的所有学生,则可以执行以下命令:
1 | db.students.find({"Age":{"$lte":25}}) |
$gt和$gte
$gt和$gte 操作符分别表示“大于”和“大于等于”。
我们来找出Age>25的所有学生。这可以通过执行以下命令来完成:
1 | db.students.find({"Age":{"$gt":25}}) |
如果修改上面的示例以返回Age≥25的学生,那么其命令就如下所示:
1 | db.students.find({"Age":{"$gte":25}}) |
$in和$nin
我们找出属于C1或C2班的所有学生。其命令如下所示:
1 | db.students.find({"Class":{"$in":["C1","C3"]}}) |
与此相反的信息可以通过使用$nin来返回。
我们接下来找出那些不属于C1或C2班的学生。其命令如下所示:
1 | db.students.find({"Class":{"$nin":["C1","C3"]}}) |
接下来看看如何才能组合上述所有操作符并且编写一个查询。假设你希望找出性别是“M”或者属于“C1”或“C2”班并且年龄大于或等于25岁的所有学生。这可以通过执行以下命令来完成:
1 | db.students.find({$or:[{"Gender":"M","Class":{"$in":["C1","C2"]}}],"Age":{"$gte":25}}) |
正则表达式
在本节中,将了解如何使用正则表达式。正则表达式在你希望找出姓名以“A”开头的学生这样的场景中是很有用的。
为了理解这一点,我们再添加3个或4个不同姓名的学生。
1 | db.students.insert({Name:"Student1",Age:25,Gender:"M",Class:"Biology",Score:95}) |
假设你希望找出姓名以“St”或“Te”开头并且其班级以“Che”开头的所有学生。
使用正则表达式就能筛选出这些结果,如以下代码所示:
1 | db.students.find({"Name":/(St | Te)*/i,"Class":/(Che)/i}) |
为了理解正则表达式如何工作,我们使用查询”Name”:/(St | Te)*/i,作为例子。
//i表明该正则表达式是不区分大小写的。
(St | Te)*意味着Name字符串必须以“St”或“Te”开头。
结尾处的*意味着它将匹配其后的所有内容。
当将所有这一切组合使用时,你就是在对以“St”或“Te”开头的名称进行不区分大小写的匹配。在用于Class的正则表达式中,也运行了相同正则表达式。
接下来,我们让这个查询更复杂一些。我们加入上面介绍的操作符。
抓取姓名如student1、student2并且其性别为女性以及年龄大于等于15岁的学生。
其命令如下:
1 | db.students.find({"Name":/(student*)/i,"Gender":"M","Age":{$gte:15}}) |
MapReduce
MapReduce框架使得任务分配成为可能,在这个示例中,就是数据跨计算机群集的聚合以便缩短用来聚合数据集的时间。它由两部分构成:映射(Map)和缩小(Reduce)。
这里是一个更具体的描述:MapReduce是一个框架,它被用于处理跨大量数据集的高可分配问题以及使用多个节点来运行的问题。如果所有这些节点都使用相同的硬件,那么这些节点就被统称为一个群集;否则,它就会被称为一个网格。这一处理过程可以作用于结构化数据(数据库中存储的数据)以及非结构化数据(文件系统中存储的数据)。
- ·“Map”:在这个步骤中,该节点会充当接收输入参数的主节点并且将大问题划分成多个小的子问题。然后这些子问题会被跨工作节点进行分发。工作节点可能会进一步将问题划分成子问题。这就产生了多层树结构。然后工作节点将处理其中的子问题并且将答案返回给主节点。
- ·“Reduce”:在这个步骤中,所有的子问题答案都被提供给了主节点,然后主节点会合并所有这些答案并且生成最终的输出结果,这就是你尝试解决的大问题的答案。
为了理解它如何工作,我们思考一个小例子,其中你要找出集合中男学生和女学生的人数。
这涉及以下步骤var :首先你要创建map和reduce函数,然后你要调用mapReduce函数并且传递必要的参数。
我们首先定义map函数:
1 | var map = function(){emit(this.Gender,1);}; |
这一步用作输入文档并且基于Gender字段来发送类型为{“F”,1}或(”M,1}的文档。
接着,你要创建reduce函数:
1 | var reduce = function(key, value) {return Array.sum(value);}; |
var reduce=function(key,value){return Array.sum(value);};这将基于key字段来分组map函数所发送的文档,在本示例中该key字段就是Gender,并且将返回值的合计,在本示例中这个值是作为“1”来发送的。上面定义的reduce函数的输出结果就是关于性别的统计计数。
最后,要使用mapReduce函数将它们放在一起,如下所示:
1 | db.students.mapReduce(map, reduce, {out: "mapreducecount1"}) |
这实际上是在应用该map、reduce函数,它们是在students集合上定义的。最终的结果会被存储到一个名称为mapreducecountl的新集合中。
为了验证它,可以在mapreducecountl集合上运行find()命令,如下所示:
这里还有一个用来说明MapReduce工作原理的示例。我们使用MapReduce来找出按班级统计的平均分。正如你在上面的示例中所看到的,首先需要创建map函数,然后创建reduce函数,最后你要将它们合并起来以将输出结果存储到数据库的一个集合中。其代码片段如下所示:
1 | var map_1 = function(){emit(this.Class,this.Score);}; |
第一步是定义map函数,它会循环遍历集合文档并且将输出结果返回为{“Class”:Score},例如(”C1”:95}。第二步会对班级进行分组并且计算该班级的平均分。第三步会合并结果;它会定义map、reduce函数需要被应用到的集合并且最终它会定义在何处存储该输出结果,在这个示例中,输出结果会被存储到一个名称为MR_ClassAvg_1的新集合中。
在最后一步中,使用了find以便检查产生的输出结果。
aggregate()
上一节介绍了MapReduce函数。在本节中,将粗浅介绍MongoDB的聚合框架。
聚合框架让你可以在不使用MapReduce函数的情况下算出聚合值。就性能方面来说,聚合框架比MapReduce函数要快。你总是需要牢记的是,MapReduce是为了批量方法使用的,而非用于实时分析。
接下来要使用aggregate函数来描述上述两个探讨过的输出结果。首先,该输出结果在于计算出男学生和女学生的人数。这可以通过执行以下命令来实现:
1 | db.students.aggregate({$group:{_id:"$Gender", totalStudent: {$sum: 1}}}) |
同样,为了计算出按班级统计的平均分,可以执行以下命令:
1 | db.students.aggregate({$group:{_id:"$Class", AvgScore:{$avg:"$Score"}}}) |
设计应用程序的数据模型
在本节中,将介绍如何为一个应用程序设计数据模型。MongoDB数据库提供了两个选项用于设计一个数据模型:用户可以将相关对象彼此内嵌,或者可以使用ID来彼此引用。在本节中,将探究这些选项。
为了理解这些选项,要设计一个博客应用并且揭示这两个选项的使用。
一个典型的博客应用由以下应用场景构成:
人们会就不同的主题发表博文。除了主题分类之外,也可以使用不同的标签。举个例子,如果类别是政治,且博文谈论的内容与一名政治家有关,那么该政治家的姓名就可以添加到该博文作为一个标签。这有助于用户快速找到与其兴趣相关的博文,还可以让他们将相关的博文链接到一起。
浏览博文的人可以对博文进行评论。
关系型数据模型与标准化
在开始探究MongoDB的方法之前,我们先看看在像SQL这样的关系型数据库中你会如何对此进行建模。
在关系型数据库中,数据建模通常是通过定义表并且逐步消除数据冗余以实现标准形式来发展而成的。
什么是标准形式
在关系型数据库中,标准形式通常由依据应用程序需求创建表开始,然后逐步消除冗余以实现最高的标准形式,该标准形式也被称为第三范式或3NF。为了更好地理解这一点,我们将博客应用程序的数据放入一张表单中。该初始数据如下图所示。
这些数据实际上是第一范式。将有大量的冗余,因为可能会有针对博文的多条评论并且会有多个标签被关联到博文。当然,冗余带来的问题在于,它伴随着不一致的可能性,其中相同数据的各种副本可能具有不同的值。为了消除这一冗余,你需要通过将它划分成多个表以进一步标准化数据。作为此步骤的一部分,必须指定一个唯一标识表中每一行的键列,以便可以在表之间创建链接。在使用3NF对上述场景进行建模时,其标准形式看起来就像下图图中所示的RDBMS图表一样。
在这个例子中,你有了一个没有冗余的数据模型,允许你在更新它时无须担心会更新多个行。尤其是,你不再需要担心数据模型中的不一致性了。
标准形式的问题
正如所提及的,标准化的好处在于,它允许在没有任何冗余的情况下轻易地进行更新(比如,它有助于保持数据一致性)。更新一个用户名称意味着更新Users表中的名称。
然而,当尝试取回数据时,会出现一个问题。例如,要找出与由特定用户发表的博文有关的所有标签和评论,关系型数据库编程人员就要使用JOIN。通过使用JOIN,数据库就会按照应用程序界面设计来返回所有的数据,但真正的问题在于数据库执行什么操作来得到该结果集。
通常,所有的RDBMS都会读取一个硬盘并且进行搜寻,这会将99%的时间花费在读取一行上。在面临硬盘访问时,随机搜寻就是最大的敌人。在此背景下这一点如此重要的原因在于,JOIN通常需要随机搜寻。JOIN操作是关系型数据库中开销最大的操作之一。此外,如果最终需要将你的数据库扩展为多台服务器,那么你就会面临生成一个分布式联结的问题,这是一个复杂且通常很慢的操作。
MongoDB文档数据模型方法
在MongoDB中,数据是存储在文档中的。对于我们应用程序设计人员来说,幸运的是,这在模式设计方面带来了一些新的可能性。而对于我们来说,它也会让我们的模式设计过程变得复杂。如今在面对模式设计问题时,不再像使用关系型数据库时那样有一种标准化数据库设计的固定路径了。在MongoDB中,模式设计取决于你试图解决的问题。
如果必须使用MongoDB文档模型对上述表进行建模,那么可以像下面这样将博文数据存储在一个文档中:
1 | { |
如你所见,将评论和标签仅仅嵌入到了单个文档中。或者,可以根据_id字段通过引用评论和标签来稍微“标准化”一下这个模型:
1 | //Authors document: |
本章剩下的内容专注于找出哪个解决方案将适用于你的上下文(比如是否使用引用或者是否嵌入)。
嵌入
在本节中,将看到嵌入是否会对性能产生积极影响。当希望抓取一些数据集并且将它显示在界面上时,比如显示与博文有关的评论的页面,嵌入就会很有用;在这种情况下,评论可以被嵌入到Blogs文档中。
这种方法的好处在于,由于MongoDB会在硬盘上连续存储文档,因此可以在单次搜寻中抓取到所有相关的数据。
除此之外,由于不支持JOIN并且你在这个例子中使用引用,因此应用程序可以进行像下面这样的一些操作来抓取与博文相关的评论数据。
(1)从blogs文档中抓取相关的评论_id。
(2)根据第一步中找到的评论_id抓取comments文档。
如果使用这种方法,也就是引用,那么不仅数据库必须进行多次搜寻来找到你的数据,而且会为查找带来额外的延迟,因为它现在会进行两次数据库访问以检索你的数据。
如果应用程序频繁访问与博文有关的评论数据,那么几乎可以肯定,在blog文档中嵌入评论将对性能产生积极影响。
衡量是否选用嵌入的另一个关注点是,在写入数据时对于原子性和隔离性的期望程度。MongoDB的设计没有多文档事务。在MongoDB中,仅在单个文档级别提供了操作的原子性,因此需要以原子方式一起更新的数据需要被共同放置在单个文档中。
当更新数据库中的数据时,必须确保你的更新要么完全成功、要么完全失败,绝不能出现“部分成功”的情况,因而也就绝不会有其他数据库读取者看到未完成的写操作。
引用
你已经看到了,嵌入是能够在许多情况下提供最佳性能的方法;它还会提供数据一致性的保障。然而,在有些情况下,一个更为标准的模型在MongoDB中会运行得更好。
使用多个集合并且添加引用的一个原因在于,在查询数据时它能提供增强的灵活性。我们用上面提到过的博客示例理解这一点。
你看到了如何使用嵌入模式,它对于在单个页面上同时显示所有数据来说将会很好地运行(比如显示带有所有相关评论的博文的页面)。
选择假定你需要搜索特定用户发表的评论。该查询(使用此嵌入模式)可能会如以下代码所示:
1 | db.posts.find({'comments.author':'author2'},{'comments':1}) |
然后,这个查询的结果就会是下面这种形式的文档:
1 | { |
此方法主要的缺点是,你得到的数据会远远多于你实际需要的数据。实际上,你无法要求只取出author2的评论;你必须取得author2评论过的博文,其中也包括那些博文上的所有其他评论。这些数据需要在应用程序代码中进一步过滤。
另一方面,假定你决定使用一个标准化模式。在这个例子中将有3个文档:“Authors”、
“Posts”和“Comments”。
“Authors”文档将具有特定于作者的内容,比如Name、Age、Gender等,而“Posts”
文档将具有特定于博文的详细信息,比如博文创建信息、博文作者、实际内容以及博文的标题。
“Comments”文档将具有博文的评论,比如CommentedOn日期时间、创建的作者以及评论的内容。这被描述如下:
1 | //Authors document: |
在此场景中,找出由“author2”发表的评论这个查询可以通过comments集合上的一个简单find()来实现:
1 | db.comments.find({"author":"author2"}) |
一般来说,如果应用程序的查询模式为众所周知,且数据往往只能通过一种方式来访问,那么嵌入的方法将很好地发挥作用。相反,如果应用程序可以用许多不同的方式查询数据,或者你无法预料到数据可能被查询的模式,那么一种更为“标准化”的方法可能会更好。
例如,在上述模式中,将能够对评论排序或者使用limit、skip操作符返回一个更受限制的评论集。在嵌入的例子中,无法摆脱按照其在博文中存储的顺序来检索所有评论的环节。
另一个权衡是否使用文档引用的因素就是,具有一对多关系的情况。
例如,一个有大量读者参与的受欢迎的博客中,可能某篇博文就有数百甚或数千评论。在这种情况下,嵌入就会带来显著的负面影响:
- 读取性能上的影响:随着文档大小的增长,它将消耗更多的内存。内存方面的问题在于,MongoDB数据库会在内存中缓存频繁访问的文档,而这些文档变得越大,它们适合放入内存中的可能性就越小。这将导致在检索文档时出现更多的页面错误,也将导致随机磁盘I/0,进而降低性能。
- 更新性能上的影响:随着文档大小的增长,以及在这样的文档上执行更新操作以附加数据,最终MongoDB会需要将该文档移动到具有更多可用空间的区域。
当这种情况出现时,这种移动将显著降低更新性能。
除此之外,MongoDB文档具有16MB这一固定的大小限制。尽管这是要意识到的一个情况,但将经常遇到由于内存压力以及在到达16MB大小限制之前文档的复制很顺畅而产生的问题。
权衡是否使用文档引用的一个最终因素是多对多或M:N关系的情况。
例如,在上面的示例中,存在标签。每篇博文都可以具有多个标签并且每个标签都可以关联到多个博文条目。
实现博文-标签M:N关系的一种方法是使用以下三个集合:
- Tags集合,它将存储标签详细信息
- Blogs集合,它将存储博文详细信息
- 第三个集合,被称为Tag-To-Blog Mapping,它将在标签和博文之间进行映射
此方法类似于关系型数据库中的方法,但这不会影响应用程序的性能,因为查询最终将进行大量应用程序级别的“联结”。
或者,可以在博文文档中使用嵌入了标签的嵌入模型,但这将会导致数据重复。尽管这对读取操作有一些简化,但它将增加更新操作的复杂性,因为在更新一个标签详细信息时,用户需要确保在标签被嵌入到其他博文文档的每一个地方都对该标签进行更新。
因此,对于多对多联结来说,一个折中的办法通常是最好的,嵌入一组_id值而非完整的文档:
1 | //Tags document: |
尽管查询会有一点复杂,但你不再需要担心在每个地方更新标签。
总的来说,MongoDB中的模式设计是你需要很早就做出的决定之一,并且它取决于应用程序的需求和查询。
如你所见,当需要将数据放在一起进行访问或者你需要进行原子更新时,嵌入将产生积极影响。不过,如果在查询时需要更多的灵活性或者如果拥有一种多对多关系,那么使用引用将是一个好的选择。
最终,决策取决于你的应用程序的访问模式,并且MongoDB中没有固定不变的规则。在下一节中,将学习与各种数据建模考虑事项有关的内容。
数据建模的决策
这涉及决定如何构造文档,以便有效建模数据。要决策的重要一点在于,你是否需要嵌入数据或者使用对数据的引用(例如,是否使用嵌入或引用)。
有个例子可以很好地揭示这一点。假定你有一个书籍评论站点,其中有作者和书籍以及具有嵌套评论的评价系统。
现在问题在于,如何构造这些集合。该决策取决于对每本书籍所预期的评论数量以及将要执行的读取以及写入操作的频率是多少。
操作上的注意事项
除了元素彼此交互的方式之外(比如,是否以嵌入方式存储文档或者使用引用),在为应用程序设计一个数据模型时,还有若干其他操作上的因素也很重要。后面几节中将介绍这些因素。
数据生命周期管理
如果应用程序具有只需要在有限的一段时间内在数据库中持久化的数据集,那么就需要此功能。
假设你需要将与评价和评论有关的数据保留一个月,那么就可以考虑使用此功能。
这是通过使用集合的生存周期(Time to Live,TTL)功能来实现的。集合的TTL功能会确保文档在一段时间后过期。
此外,如果应用程序需求是仅处理最近插入的文档,那么使用固定集合将有助于优化性能。
索引
可以创建索引来支持经常用到的查询,以便提高性能。默认情况下,MongoDB会在id字段上创建一个索引。
下面是在创建索引时需要考虑的一些要点:
- 每个索引至少需要8KB的数据空间。
- 对于写操作,索引的添加将带来一些不良的性能影响。因此,对于具有大量写入的集合来说,使用索引的代价可能会很大,因为对于每一个插入,都必须将键添加到所有的索引。
- 索引适用于具有大量读取操作的集合,比如读与写的操作比例很高的那些集合。
未被索引的读操作不会受到索引的影响。
分片
在设计应用程序模型时,一个重要的因素是,是否要对数据进行分区。这是通过在MongoDB中使用分片来实现的。
分片也被称为数据分区。在MongoDB中,一个集合是通过其跨机器群集分布的文档来分区的,这些文档就被称为碎片。这样会对性能产生显著的影响。我们将在第7章中探讨更多与分片有关的内容。
大量的集合
对比在单个集合中存储数据,下面是具有多个集合时的设计考虑事项:
- 选择多个集合用于数据存储是没有性能损失的。
- 在高吞吐量的批处理应用程序中,为不同的数据类型使用单独的集合会提高性能。
当在设计具有大量集合的模型时,你需要考虑以下行为:
- 每个集合都会关联数千字节的特定最小开销。
- 每个索引至少需要8KB的数据空间,其中包括_id索引。
现在你知道了每个数据库的元数据都被存储在
文档的增长
有一些更新,比如将一个元素推送到一个数组中、添加新字段等,会造成文档大小的增长,这将导致文档从一个位置移动到另一个位置,以便适应该文档。文档迁移的这一过程会同时带来资源和时间方面的消耗。尽管MongoDB提供了填充来最小化迁移的发生,但你可能还是需要手动处理文档增长。