应用程序设计
1、模式设计注意事项
模式设计,即在文档中表示数据的方式,对于数据表示来说是非常关键的。最好的方式是按照应用程序希望看到的形式来表示数据。因此,与关系数据库不同,在为模式进行建模之前,首先需要了解查询和数据访问的方式。
1.1、在设计模式时需要考虑的几个关键方面
1.1.1、限制条件
有一些数据库或硬件的限制是你需要了解的。你还需要考虑 MongoDB 的一些特殊之处,比如最大文档大小为 16MB、从磁盘读写完整文档、更新会重写整个文档,以及在文档级别进行原子更新。
1.1.2、查询和写入的访问模式
你需要确定并量化应用程序和更大系统的工作负载。工作负载包括应用程序中的读操作和写操作。一旦知道了查询的运行时间和频率,就可以识别最常见的查询。这些查询是在进行模式设计时需要支持的。一旦确定了这些查询,就应该尽量减少查询的数量,并在设计中确保一起查询的数据存储在同一个文档中。
这些查询中未使用的数据应该存放在不同的集合中。不经常使用的数据也应该移动到不同的集合中。需要考虑是否可以将动态(读/写)数据和静态(主要是读)数据分离开。在进行模式设计时,提高最常见查询的优先级会获得最佳的性能。
1.1.3、关系类型
应该根据应用程序的需要以及文档之间的关系来考虑哪些数据是相关的。然后你就可以确定对于数据或文档,是应该嵌入还是引用。需要弄清楚如何在不执行其他查询的情况下引用文档,以及当关系发生变化时需要更新多少文档。还必须考虑数据结构是否易于查询,比如使用内嵌数组(数组中的数组)对某些关系进行建模。
1.1.4、基数
当确定文档和数据的关联方式后,应考虑这些关系的基数,比如具体是一对一、一对多、多对多、一对百万,还是多对几十亿?确定关系的基数非常重要,可以确保在 MongoDB 的模式中使用最佳格式进行建模。还应该考虑是会对多/百万这一端的对象进行访问还是只访问上层对象的内容,以及相关数据字段的更新与读取的比例。充分考虑这些问题将有助于确定应采用内嵌文档还是引用文档,以及是否应该跨文档对数据进行反范式化处理。
1.2、可以使用的设计模式
模式设计在 MongoDB 中很重要,它能直接影响应用程序的性能。在模式设计中可以使用已知的模式或者采用"搭积木"的方式来解决许多常见的问题。最好一起使用一个或多个模式。
1.2.1、多态模式
这种模式适用于集合中的所有文档具有类似但不完全相同结构的情况。它涉及识别跨文档的公共字段,而这些文档需要支持应用程序的公共查询。跟踪文档或子文档中的特定字段将有助于识别数据与不同代码路径或类/子类之间的差异,我们可以在应用程序中编码以管理二者的差异。这允许在文档不完全相同的单个集合中使用简单查询来提高查询性能。
1.2.2、属性模式
这种模式非常适合于文档中部分字段具有希望对其进行排序或查询的公共特性,或者需要排序的字段仅存在于部分文档中,或者这两个条件都满足。它包括将数据重塑为键--值对数组,并在该数组中的元素上创建索引。限定符可以作为附加字段添加到这些键--值对中。此模式有助于查询那些存在许多相似字段的文档,因此需要的索引更少,查询也更容易编写。
1.2.3、分桶模式
这种模式适用于时间序列数据,其中数据在一段时间内被捕获为数据流。在 MongoDB 中,将这些数据"分桶"存储到一组文档中,每个文档会保存特定时间范围内的数据,这比在每个时间点/数据点创建一个文档更高效。例如,可以使用一小时的存储桶,并将该时间内的所有数据都放到文档的一个数组中。文档本身有开始和结束时间,以表明这个"桶"涵盖的时间段。
1.2.4、异常值模式
这种模式用以解决少数文档的查询超出应用程序正常模式的情况。这是一种高级设计模式,当流行程度作为一个因素时尤其适用。这一点可以在有影响力的社交网络、图书销售、电影评论等地方看到。它使用一个标志来表示文档是异常值,并将额外的溢出存储到一个或多个文档中,这些文档通过 "_id" 引用第一个文档。应用程序代码将使用该标志进行额外查询,以检索溢出的文档。
1.2.5、计算模式
这种模式可以在需要频繁计算数据时使用,也可以在读取密集型的数据访问模式下使用。此模式建议在后台执行计算,并定期更新主文档。这提供了计算字段或文档的有效近似值,而不必为单个查询连续生成这些字段或文档。这样可以通过避免重复相同的计算来显著减少 CPU 的压力,特别是在读操作会触发计算并且读写比较高的情况下。
1.2.6、子集模式
当工作集超过了机器的可用 RAM 时可以使用这种模式。这种情况可能是大文档造成的,这些文档包含大量的应用程序没有使用的信息。此模式建议将经常使用的数据和不经常使用的数据分割为两个单独的集合。一个典型的例子可能是电子商务应用程序中将一个产品的 10条最近的评论保存在"主"(经常访问的)集合中,并将所有旧的评论移动到第二个集合中,只有在应用程序需要多于 10 条评论时才进行查询。
1.2.7、扩展引用模式
这种模式用于有许多不同的逻辑实体或"事物",并且每个逻辑实体或"事物"都有各自的集合,但是你希望将这些实体组织在一起以实现特定的功能。在一个典型的电子商务模式中,订单、客户和库存可能会有单独的集合。当需要从这些单独的集合中收集单个订单的所有信息时,可能会对性能产生负面影响。解决方案是识别出经常访问的字段,并在订单文档中复制这些字段。对于电子商务订单,这样的字段可能是接收商品的客户姓名和地址。这种模式以数据冗余为代价,以减少将信息整合在一起所需的查询数量。
1.2.8、近似值模式
这种模式在需要昂贵资源(时间、内存、CPU 周期)的计算却不需要绝对精确的情况下非常有用。这方面的一个例子是一张图片或一则帖子的"点赞"计数器或一个页面浏览的计数器,其中知道确切的计数(例如,是 999 535 还是 10 000 000)是没必要的。在这种情况下,应用此模式可以极大地减少写入次数,例如,仅在每浏览 100 次或更多次时更新计数器,而不是在每次浏览之后都进行更新。
1.2.9、树形模式
当你有很多查询并且数据主要是层次结构时,可以使用这种模式。它遵循前面提到过的,将经常一起查询的数据存储在一起的概念。在 MongoDB 中,可以很容易地将层次结构存储在同一个文档的数组中。在电子商务网站的例子,特别是其产品目录中,通常会有属于多个类别的产品或者这个产品的类别从属于其他某个类别。一个例子就是"硬盘驱动器",它本身是一个类别,但还属于"存储"类别,"存储"本身又属于"计算机部件"类别,而这还是"电子"类别的一部分。在这种情况下,我们会有一个字段跟踪整个层次结构,另一个字段保存直接类别("硬盘驱动器")。保存在数组中的整个层次结构字段提供了对这些值使用多键索引的能力。这可以确保很容易地找到层次结构中与类别相关的所有项目。直接类别字段允许找到与此类别直接相关的所有项目。
1.10、预分配模式
这主要用于 MMAP 存储引擎,但仍然有一些可以使用此模式的场景。该模式建议创建一个初始的空结构,稍后对该结构进行填充。例如,一个按天管理资源的预订系统可以跟踪该资源是空闲的还是已预订/不可用的。资源(x)和天数(y)的二维结构使得检查是否可用以及执行计算变得非常简单。
1.11、文档版本控制模式
这种模式提供了一种机制来保留文档的旧版本。它需要在每个文档中添加一个额外的字段来跟踪"主"集合中的文档版本,还需要一个额外的集合来保存文档的所有修订版本。此模式具备以下假设:具体来说,每个文档都有有限数量的修订版本,不存在大量需要版本控制的文档,并且查询主要是对每个文档的当前版本进行的。假如这些假设不成立,那么可能需要修改模式或考虑使用不同的设计模式。
MongoDB 提供了一些关于模式和模式设计的有用的在线资源。MongoDB 大学提供了一个免费课程------M320 数据建模,以及"使用模式构建"博客系列。
2、范式化与反范式化
表示数据的方法有很多种,需要考虑的最重要的问题之一是应该在多大程度上对数据进行规范化。范式化(normalization)是指将数据分散到多个集合中,在集合之间进行数据的引用。尽管同一份数据可以被多个文档引用,但这份数据只能存在于一个集合中。因此,要更改数据,只需更新一个文档。MongoDB 聚合框架提供了可以进行连接(join)的 $lookup 阶段,它会向"被连接"集合中的每个匹配文档添加一个新的数组字段,其中包含源集合中文档的详细信息。然后,这些整合后的文档将在后续阶段被进一步处理。
与范式化相反,反范式化(denormalization)会将所有数据嵌入单个文档中。多个文档可能拥有数据的副本,而不是所有文档都引用同一份数据。这意味着在信息发生变化时,需要更新多个文档,但可以通过单个查询获取所有相关的数据。
决定何时采用范式化以及何时采用反范式化是比较困难的:通常,范式化的写入速度更快,而反范式化的读取速度更快。因此,应根据应用程序的实际需要进行权衡。
2.1、数据表示的示例
假设我们需要保存学生和他们所上课程的信息。一种表示方式是建立一个 students 集合(每个学生是一个文档)和一个 classes 集合(每门课程是一个文档),然后用第三个集合(studentClasses)存储对学生及其所学课程的引用:
js
> db.studentClasses.findOne({"studentId" : id})
{
"_id" : ObjectId("512512c1d86041c7dca81915"),
"studentId" : ObjectId("512512a5d86041c7dca81914"),
"classes" : [
ObjectId("512512ced86041c7dca81916"),
ObjectId("512512dcd86041c7dca81917"),
ObjectId("512512e6d86041c7dca81918"),
ObjectId("512512f0d86041c7dca81919")
]
}
如果你熟悉关系数据库,那么可能以前见过这种类型的表连接(尽管通常每个文档有一个学生和一门课程,而不是一个课程 "_id" 列表)。将课程放在数组中更像是MongoDB 的风格,但通常不会以这种方式存储数据,因为需要进行大量查询才能获得实际的信息。
假设要找到一个学生所上的课程。我们需要查询students 集合中的学生信息和 studentClasses 中的课程 "_id",然后再查询 classes 集合中的课程信息。因此,为了找到这些信息,需要向服务器端请求 3 次查询。通常这不是理想中 MongoDB 构造数据的方式,除非课程和学生信息会经常变化,并且不需要对数据进行快速读取。
可以通过在学生文档中嵌入对课程的引用来节省一次查询:
js
{
"_id" : ObjectId("512512a5d86041c7dca81914"),
"name" : "John Doe",
"classes" : [
ObjectId("512512ced86041c7dca81916"),
ObjectId("512512dcd86041c7dca81917"),
ObjectId("512512e6d86041c7dca81918"),
ObjectId("512512f0d86041c7dca81919")
]
}
"classes" 字段保存了 John Doe 需要上的课程 "_id" 数组。当需要找出关于这些课程的信息时,可以使用 "_id"来查询 classes 集合。这个过程只需要执行两次查询。当数据不需要随时访问也不会随时变化时,这种数据组织方式是十分常用的。
如果需要进一步优化读取速度,那么可以将数据完全反范式化,将课程信息作为内嵌文档保存到学生文档的"classes" 字段中,从而在一次查询中获得所有信息:
js
{
"_id" : ObjectId("512512a5d86041c7dca81914"),
"name" : "John Doe",
"classes" : [
{
"class" : "Trigonometry",
"credits" : 3,
"room" : "204"
},
{
"class" : "Physics",
"credits" : 3,
"room" : "159"
},
{
"class" : "Women in Literature",
"credits" : 3,
"room" : "14b"
},
{
"class" : "AP European History",
"credits" : 4,
"room" : "321"
}
]
}
这样做的好处是只需要一次查询就可以获得信息。缺点是会占用更多空间,数据更难保持同步。如果发现物理课应该是 4 个(而不是 3 个)学分,那么选择了物理课的每个学生文档都需要更新(而不是仅仅更新一个"物理课"文档)。
最后,也可以混合使用内嵌数据和引用数据------可以使用常用信息来创建一个子文档数组,在需要查询更详细的信息时通过引用找到实际的文档:
js
{
"_id" : ObjectId("512512a5d86041c7dca81914"),
"name" : "John Doe",
"classes" : [
{
"_id" : ObjectId("512512ced86041c7dca81916"),
"class" : "Trigonometry"
},
{
"_id" : ObjectId("512512dcd86041c7dca81917"),
"class" : "Physics"
},
{
"_id" : ObjectId("512512e6d86041c7dca81918"),
"class" : "Women in Literature"
},
{
"_id" : ObjectId("512512f0d86041c7dca81919"),
"class" : "AP European History"
}
]
}
这种方式也是不错的选择,因为内嵌的信息可以随着需求的变化进行修改:如果希望在页面上包含更多或更少的信息,则可以将更多或更少的信息放到内嵌文档中。
另一个重要的考虑因素是信息更新和信息读取二者哪个更频繁。如果数据需要定期更新,那么范式化是个好选择。然而,如果数据变化不频繁,那么就不值得以牺牲应用程序的每次读取效率为代价来优化更新效率了。
例如,教科书上介绍范式化的一个例子是将用户和用户地址保存在不同的集合中。不过,人们的地址很少改变,因此通常不应该为搬家这种小概率事件而牺牲每一次的查询效率,而是应该将地址内嵌在用户文档中。
如果决定使用内嵌文档并且需要更新它们,那么更新文档时应该设置一个定时(cron)任务,以确保所做的更新都能成功更新到所有文档。假设你试图进行多次更新,但服务器在所有文档都更新之前崩溃了。那么你就需要一种方法来检测出这种情况并重新进行未完成的更新。
在更新运算符中," s e t " 是幂等的, " set" 是幂等的," set"是幂等的,"inc" 则不是。进行一次或多次幂等运算会输出相同的结果。在出现网络故障的情况下,重试该操作就可以完成更新。对于非幂等的运算符,则应将该操作分解为两个幂等且可安全重试的单独操作。这可以通过在第一个操作中添加一个唯一的待处理令牌,并让第二个操作同时使用唯一键和唯一的待处理令牌来实现。这种方法可以使 "$inc" 操作幂等,因为每个单独的 updateOne 操作都是幂等的。
在某种程度上,生成的信息越多,就越不应该将这些信息内嵌到其他文档中。如果内嵌字段的内容或嵌入字段的数量是无限增长的,那么通常应该使用引用而不是内嵌。像评论树或者活动列表这样的信息应该保存在单独的文档中,而不是内嵌到其他文档中。还可以考虑使用子集模式来存储文档中最近的项目或其他子集。
最后,被内嵌入文档的字段应该是文档中数据的组成部分。如果在查询文档时总是需要从结果中排除某个字段,则表明该字段可能属于另一个集合。下表是对这些指导原则的汇总。

假设存在一个 users 集合。以下是可能需要的一些示例字段,以及是否应该将这些字段内嵌到用户文档中。
- 用户首选项:用户首选项只与此用户文档相关,并且可能与文档中的其他用户信息一起被查询。所以用户首选项通常应该内嵌到用户文档中。
- 最近活动:这个字段取决于最近活动增长和变化的频繁程度。如果这是一个固定长度的字段(比如最近的 10 次活动),那么应该内嵌此字段或实现子集模式。
- 好友:通常好友信息不应该内嵌到用户文档中,或者至少不应该完全内嵌到用户文档中。详细内容请参阅 9.2.3节。
- 所有由用户产生的内容:这个字段不应该内嵌到用户文档中。
2.2、基数
可以用基数来表示一个集合对另一个集合的引用数量,常见的关系有一对一、一对多或多对多。假设有一个博客应用程序。每篇文章都有一个标题,因此这是一对一的关系。每个 作者都有很多文章,因此这是一对多的关系。每篇文章可以有很多标签,每个标签又可以在多篇文章中使用,因此这是多对多的关系。
在使用 MongoDB 时,从概念上可以将"多"划分为两个子类别:"很多"和"较少"。例如,作者和文章之间可能存在一对少的关系:每个作者只写了几篇文章。文章和标签之间可能存在多对少关系:文章数量很可能比标签多。然而,在文章和评论之间是一对多的关系:每篇文章都有许多评论。
确定少与多的关系有助于决定数据的内嵌和引用。通常来说,"少"的关系使用内嵌的方式会比较好,"多"的关系使用引用的方式则比较好。
2.3、好友、粉丝以及其他麻烦事项
许多社交应用程序需要链接人、内容、粉丝、好友等事物。弄清楚如何权衡内嵌和引用这些高度关联的信息可能会很棘手,但通常关注、好友或收藏可以简化为一个发布--订阅系统:一个用户订阅另一个用户的通知。因此,有两个需要高效进行的基本操作:保存订阅者和将一个事件通知给所有订阅者。
通常实现订阅的方式有 3 种。第一种是将生产者内嵌到订阅者文档中,如下所示:
js
{
"_id" : ObjectId("51250a5cd86041c7dca8190f"),
"username" : "batman",
"email" : "batman@waynetech.com",
"following" : [
ObjectId("51250a72d86041c7dca81910"),
ObjectId("51250a7ed86041c7dca81936")
]
}
现在,对于一个给定的用户文档,可以发出如下查询来查找他们可能感兴趣的所有已发布活动:
js
db.activities.find({"user" : {"$in" :
user["following"]}})
不过,如果需要找到对新发布活动感兴趣的所有用户,则必须查询所有用户的 "following" 字段。
或者可以使用另一种方式,将订阅者内嵌到生产者文档中,如下所示:
js
{
"_id" : ObjectId("51250a7ed86041c7dca81936"),
"username" : "joker",
"email" : "joker@mailinator.com",
"followers" : [
ObjectId("512510e8d86041c7dca81912"),
ObjectId("51250a5cd86041c7dca8190f"),
ObjectId("512510ffd86041c7dca81910")
]
}
每当这个用户发布新信息时,立即就可以知道需要给哪些用户发送通知。这样做的缺点是,如果要查找一个用户关注的用户列表,则需要查询整个 users 集合(与前一个例子中的限制相反)。
这两种方式都有一个额外的缺点:它们会使用户文档更大、更不稳定。"following" 或 "followers" 字段甚至不需要返回:查询粉丝列表这个操作会有多频繁?因此,最后一个方案通过对数据进一步范式化并将订阅信息保存在单独的集合中来避免这些缺点。进行这种程度的范式化可能有些过了,但对于一个经常发生变化并且不需要与文档其他部分一起返回的字段来说非常有用。对"followers" 字段进行这种范式化是比较明智的。
在本例中,使用一个集合来保存发布者和订阅者的关系,其文档如下所示:
js
{
"_id" : ObjectId("51250a7ed86041c7dca81936"), // 被关注者的"_id"
"followers" : [
ObjectId("512510e8d86041c7dca81912"),
ObjectId("51250a5cd86041c7dca8190f"),
ObjectId("512510ffd86041c7dca81910")
]
}
这样可以使用户文档保持精简,但是需要额外的查询才能得到粉丝列表。
应对Wil Wheaton效应
无论使用哪种策略,内嵌字段都只适用于有限数量的子文档或引用。如果某个用户非常有名,则可能会导致用于保存粉丝列表的文档溢出。应对这种情况的一种典型方法是使用异常值模式,在必要时创建一个"延续"文档。例如:
js
> db.users.find({"username" : "wil"})
{
"_id" : ObjectId("51252871d86041c7dca8191a"),
"username" : "wil",
"email" : "wil@example.com",
"tbc" : [
ObjectId("512528ced86041c7dca8191e"),
ObjectId("5126510dd86041c7dca81924")
],
"followers" : [
ObjectId("512528a0d86041c7dca8191b"),
ObjectId("512528a2d86041c7dca8191c"),
ObjectId("512528a3d86041c7dca8191d"),
...
]
}
{
"_id" : ObjectId("512528ced86041c7dca8191e"),
"followers" : [
ObjectId("512528f1d86041c7dca8191f"),
ObjectId("512528f6d86041c7dca81920"),
ObjectId("512528f8d86041c7dca81921"),
...
]
}
{
"_id" : ObjectId("5126510dd86041c7dca81924"),
"followers" : [
ObjectId("512673e1d86041c7dca81925"),
ObjectId("512650efd86041c7dca81922"),
ObjectId("512650fdd86041c7dca81923"),
...
]
}
然后在应用程序中添加从 "tbc"(to be continued)数组中获取数据的相关逻辑。
3、优化数据操作
要优化应用程序,首先必须通过评估其读写性能来找到瓶颈是什么。优化读操作通常包括拥有正确的索引和在单个文档中返回尽可能多的信息。优化写操作通常包括减少索引数量以及尽可能提高更新的效率。
我们经常需要在写入效率更高的模式与读取效率更高的模式之间权衡,因此必须决定哪种操作对应用程序更重要。影响因素不仅要考虑读操作和写操作的重要性,还要考虑读操作和写操作的频繁程度:如果写操作相对更加重要,但是每执行一次写操作就要进行 1000 次读操作,那么还是应首先优化读取速度。
删除旧数据
有些数据只在短时间内比较重要:几周或几个月后,保存这些数据只是在浪费存储空间。删除旧数据有 3 种常见的方式:使用固定集合、使用 TTL 集合,以及使用多个集合。
最简单的方式是使用固定集合:将集合大小设置为一个较大的值,并让旧数据从固定集合的末尾被"删除"。不过,固定集合会对操作造成一些限制,并且容易受到流量峰值的影响,从而暂时降低它们所能容纳的时间长度。
第二种方式是使用 TTL 集合。TTL 集合可以更精确地控制删除文档的时间,但其在写入量过大的集合中操作速度不够快:与用户请求删除操作的方式相同,它通过遍历 TTL 索引来删除文档。但是,如果 TTL 集合能承受足够的写入量,那么这可能是最容易实现的解决方案。
最后一种方式是使用多个集合:例如,每个月的文档单独使用一个集合。每当月份变更时,应用程序都会开始使用本月份的(空)集合,并在查询时搜索当前和以前月份的集合。一旦集合超过特定时间,比如说 6 个月后,可直接将其删除。这种方式几乎可以满足任何流量,但构建应用程序时更加复杂,因为必须使用动态集合或数据库名称,并可能需要查询多个数据库。
4、数据库和集合的设计
一旦确定文档结构,就必须决定将它们放入哪些集合或数据库中。这个过程通常很简单,但是需要记住一些指导原则。
通常来说,具有类似模式的文档应该保存在同一个集合中。MongoDB 通常不允许合并来自多个集合的数据,因此如果有需要一起查询或聚合的文档,则这些文档很适合放在一个大集合中。例如,你可能有一些"形状"非常不同的文档,但是要将它们聚合,就需要让它们都位于同一个集合中(或者如果文档位于不同的集合或数据库中,则可以使用 $merge 阶段)。
对于集合来说,需要考虑的一个大问题是锁机制(每个文档都有一个读/写锁)和存储。通常,如果写入工作负载很高,则可能需要考虑使用多个物理卷来减少 I/O 瓶颈。当使用 --directoryperdb 选项时,每个数据库都可以保留在自己的目录中,这允许你将不同的数据库挂载到不同的卷中。因此,你可能希望数据库中的所有项目都具有相近的"质量"、相近的访问模式或相近的访问量。
假设有一个具有多个组件的应用程序:一个会创建大量低价值数据的日志组件,一个用户集合以及几个用于保存用户生成数据的集合。用户集合具有很高的价值:用户数据的安全是非常重要的。社交活动数据需要放在一个大流量集合中,它的重要性较低,但比日志数据重要。这个集合主要用于用户通知,因此几乎是一个只有追加操作的集合。
按重要性划分之后,可能会得到 3 个数据库:logs(日志)、activities(活动)和 users(用户)。这种策略的好处是,价值最高的集合其数据量可能最小(例如,用户集合通常没有日志集合的数据多)。将所有数据集合都存储在 SSD 上可能是难以负担的,但也许可以只将用户集合存储在 SSD 上,或者对用户集合使用 RAID10,对日志和活动集合使用 RAID0。
注意,在 MongoDB 4.2 之前使用多个数据库以及在聚合框架中引入 m e r g e 运算符时存在一些限制: merge 运算符时存在一些限制: merge运算符时存在一些限制:merge 运算符可以将来自一个数据库的聚合结果存储到另一个数据库以及该数据库的另一个集合中。另外需要注意的一点是,将现有集合从一个数据库复制到另一个数据库时,renameCollection 命令的速度会比较慢,因为它必须将所有文档都复制到新数据库中。
5、一致性管理
必须要明确知道应用程序的读取对数据一致性的要求。MongoDB 支持多种一致性级别,从总是能够读取自己所写的数据到读取不确定的旧数据。如果要得到最近一年内的活动信息报表,那么可能只要求最近这些天的数据完全准确。相反,如果在做实时交易,则可能需要立即读取最新的数据。
要理解如何实现这些不同级别的一致性,就必须了解MongoDB 的内部机制。服务器端为各个数据库连接维护了一个请求队列。客户端每次发来的新请求都会被添加到队列的末尾。连接中的任何后续请求都将依次得到处理。因此,单个连接具有一致的数据库视图,并且始终可以读取到自己的写操作。
注意,每个列队只对应一个连接:如果打开两个 shell,连接到相同的数据库,则会有两个不同的连接。如果在一个 shell 中执行插入操作,那么另一个 shell 中的后续查询可能不会返回插入的文档。然而,在同一个 shell中,如果在插入一个文档之后查询,则一定能够查询到刚插入的文档。想手动重现这种问题可能很困难,但是在繁忙的服务器上,很可能会出现交错的插入和查询操作。当开发人员使用一个线程插入数据,然后在另一个线程中检查数据是否成功插入时经常会遇到这种情况。片刻之后,数据看起来好像没有插入成功,然后又突然出现了。
在使用 Ruby、Python 和 Java 驱动程序时尤其需要注意这个问题,因为这 3 种语言的驱动程序都使用了连接池。为了提高效率,这些驱动程序会建立多个与服务器端的连接(也就是一个连接池),并在它们之间分发请 求。不过,它们都拥有各自的机制来保证由单个连接处理一系列相关的请求。MongoDB 驱动程序连接监控和连接池规范中有关于各种语言连接池的详细文档。
当向副本集的从节点(参见第 12 章)发送读请求时,这将成为一个更大的问题。从节点数据可能落后于主节点,导致读取到的数据是几秒、几分钟甚至几小时之前的。有几种方法可以解决这个问题,最简单的方法是将所有的读请求都发送到主节点(如果数据是否过时很重要的话)。
MongoDB 提供了 readConcern 选项来控制被读取数据的一致性和隔离性。它可以与 writeConcern 结合使用,以控制为应用程序提供的一致性和可用性保证。它有 5个级别:"local"、"available"、"majority"、"linearizable" 和"snapshot"。根据应用程序的不同,如果想避免读取过时数据,那么可以考虑使用 "majority",它只返回已持久化的数据,这些数据已经被大多数副本集成员确认且不会回滚。"linearizable" 也是一种选择:它返回的数据反映了在读操作开始之前所有已成功的多数成员确认的写操作。MongoDB 可能会等待并发执行的写操作完成,然后用 "linearizable" 的 readConcern 返回结果。
3 位来自 MongoDB 的高级工程师在 2019 年的 PVLDB 会议上发表了一篇名为"Tunable Consistency in MongoDB"的论文。本文简要阐述了 MongoDB 中用于复制的不同的一致性模型,以及应用程序开发人员如何利用这些模型。
作者是负责复制机制的高级软件工程师 William Schultz、复制机制团队的负责人 Tess Avitabile,以及分布式系统的产品经理 Alyson Cabral。
6、模式迁移
随着应用程序的增长和需求的变化,数据库模式也可能需要随之增长和改变。有几种方法可以实现这一点,但是无论选择哪种方法,都应该仔细记录应用程序使用的每个模式。理想情况下,如果可以的话,应该考虑使用文档版本控制模式。
最简单的方式是简单地根据应用程序的需要改进数据库模式,以确保应用程序支持所有的旧版模式(例如,接受某些字段的缺失,或者优雅地处理某个字段的多个可能的类型)。但是这种方式可能会导致混乱,特别是当不同版本的模式存在冲突时。例如,某个版本需要一个"mobile" 字段,另一个版本则不需要这个字段,而是需要另外一个不同的字段,还有一个版本会将 "mobile" 视为可选字段。跟踪这些变化的需求可能会逐渐使代码变得一团糟。
为了以一种更结构化的方式处理不断变化的需求,可以在每个文档中包含一个 "version" 字段(或者仅仅是"v"),并使用它来确定应用程序将接受的文档结构。这将对模式的要求更加严格:文档必须对某个版本有效。不过,这仍然需要支持各种旧版本。
最后一种方式是在模式变更时迁移所有数据。通常来说这不是一个好主意:MongoDB 允许使用动态模式来避免迁移,因为这会给系统带来很大压力。不过,如果决定更改每一个文档,则需要确保所有文档都被成功更新。MongoDB 的事务可以支持这种类型的迁移。如果MongoDB 在事务处理过程中崩溃,那么旧的模式将被保留。
7、模式管理
MongoDB 3.2 引入了模式验证,其可以在更新和插入操作期间对数据进行验证。MongoDB 3.6 又通过$jsonSchema 运算符添加了 JSON 模式验证,现在这是MongoDB 中所有模式验证的推荐方法。在撰写本书时,MongoDB 支持 JSON 模式的第 4 版草案,但还请参阅相关文档以了解有关此特性的最新信息。
只有当文档被更改时,验证功能才会检查这些文档,并且此功能是每个集合都需要单独配置的。要向现有集合添加验证功能,可以在 collMod 命令中使用 validator 选项。在使用 db.createCollection() 时,可以通过指定validator 选项将验证添加到新集合中。MongoDB 还提供了两个额外的选项,validationLevel 和validationAction。validationLevel 决定了在更新过程中验证规则对现有文档检查的严格程度,validationAction 决定了是应该在发生错误时拒绝请求,还是允许请求并发出警告。
8、不适合使用MongoDB的场景
虽然 MongoDB 是一个通用型数据库,并且在大多数应用程序中能很好地工作,但它并不是万能的。以下是一些可能需要避免使用 MongoDB 的场景。
- 关系数据库擅长在许多不同的维度上连接不同类型的数据。MongoDB 不支持这么做,以后也很可能不支持。
- 我们选择关系数据库的一个重要原因是使用的工具不支持 MongoDB(希望是暂时的)。从 SQLAlchemy 到 WordPress,很多工具并不支持 MongoDB。支持 MongoDB 的工具库越来越多,但目前来说它的生态系统仍然不如关系数据库。