作者选择了开放互联网/言论自由基金作为写作捐赠计划的一部分接受捐赠。
引言
如果你在关系数据库方面有丰富的经验,可能会难以超越关系模型的原则,比如习惯于以表格和关系来思考。面向文档的数据库如MongoDB使我们能够摆脱关系模型的刚性和限制。然而,能够在数据库中存储自描述文档所带来的灵活性和自由也可能导致其他问题和困难。
本文概述了面向文档数据库中模式设计的五个常见准则,并强调了在数据间建模关系时应考虑的各种因素。同时,本文还将介绍几种可用于建模这些关系的策略,包括在数组中嵌入文档和使用子父引用,以及何时使用这些策略最为合适。
指南1 — 将需要一起访问的数据存放在一起
在典型的关系型数据库中,数据保存在表中,每个表由一组固定的列构成,这些列表示构成实体、对象或事件的各种属性。例如,在一个代表大学学生的表中,您可能会找到包含每个学生名字、姓氏、出生日期和唯一识别号的列。
通常,每个表代表一个单一主题。如果您想存储有关学生当前学习、奖学金或之前教育的信息,将这些数据保存在与个人信息的表不同的另一个表中可能是有意义的。然后,您可以通过连接这些表来表示它们之间存在关系,表明它们包含的信息具有有意义的联系。
例如,一张描述每位学生奖学金状态的表格可以通过学生ID号码来指代学生,但不会直接存储学生的姓名或地址,从而避免数据重复。在这种情况下,要获取关于任何学生的完整信息,包括学生的社交媒体账户、过往教育和奖学金情况,查询需要同时访问多个表格,然后将不同表格的结果汇总成一个。
这种通过引用描述关系的方法被称为规范化数据模型。以这种方式存储数据——使用多个相互关联的简洁对象——在面向文档的数据库中也是可能的。然而,文档模型的灵活性和它允许在单个文档中存储嵌入文档和数组的自由意味着,你可以采用不同于关系数据库的方式来建模数据。
面向文档数据库中建模数据的基本概念是“存储在一起的数据应一起被访问”。”深入探讨学生示例,假设该校大多数学生拥有多个电子邮件地址。因此,学校希望能够在每位学生的联系信息中存储多个电子邮件地址。
在这种情况下,示例文档可能具有如下结构:
请注意,这个示例文档包含了一个嵌入式的电子邮件地址列表。
在一个文档中表示多个主题的特性,体现了非规范化数据模型。它使得应用程序能够一次性检索和操作给定对象(此处为学生)的所有相关数据,无需访问多个独立的对象和集合。这样做还能确保对这种文档的操作的原子性,无需使用多文档事务来保证完整性。
利用嵌入式文档,将需要一起访问的数据存储在一起,通常是文档型数据库中表示数据的理想方式。在接下来的指南中,你将了解如何针对文档型数据库,最佳地建模对象之间的不同关系,如一对一或一对多关系。
指南2——使用嵌入式文档建模一对一关系
一个一对一关系表示两个不同对象之间的关联,其中一个对象与另一个类型的对象恰好连接一个。
继续上一节的学生示例,每个学生在任何特定时刻只有一张有效的学生证。一张卡不会属于多个学生,也没有学生可以拥有多张身份卡。如果你要将这些数据存储在关系数据库中,很可能会通过在不同的表中存储学生记录和学生证记录,并通过引用将它们关联起来,来模拟学生和他们的学生证之间的关系。
在文档数据库中表示这种关系的一种常见方法是使用嵌入式文档。例如,以下文档描述了一个名叫Sammy的学生及其学生证:
注意,这个示例文档的id_card
字段保存的是一个嵌入式文档,该文档通过ID号、发卡日期和有效期来描述学生的身份卡。身份卡实际上成为了描述学生Sammy的文档的一部分,尽管在现实生活中它是独立的对象。通常,像这样构建文档模式,以便通过单个查询检索所有相关信息是一个明智的选择。
如果你遇到连接一种对象与另一种类型的多个对象的关系,比如学生的电子邮件地址、他们参加的课程或他们在学生会留言板上的帖子,事情就不那么直接了。在接下来的几条指南中,你将使用这些数据示例来学习处理一对多和多对多关系的不同方法。
指南3 — 使用嵌入式文档建模一对少数关系
当一种类型的对象与另一种类型的多个对象相关联时,可以描述为一对多关系。一个学生可以有多个电子邮件地址,一辆车可以有许多零件,或者一个购物订单可以包含多个商品。这些例子都代表了一对多关系。
虽然在文档数据库中最常见的一对一关系表示方式是通过嵌入式文档,但在文档模式中有多种方式来建模一对多关系。在考虑如何最佳建模这些关系时,有三个给定关系的属性你应该考虑:
- 基数:基数是给定集合中独立元素数量的度量。例如,如果一个班级有30名学生,你可以说该班级有30的基数。在一对多关系中,基数在每种情况下都可能不同。一个学生可能有一个电子邮件地址或多个。他们可能只注册了几门课程,或者他们的日程可能完全排满。在一对多关系中,“多”的大小将影响你如何建模数据。
- 独立访问:某些相关数据很少会单独访问,如果真的有这种情况,那也是非常罕见的。例如,单独获取一个学生的电子邮件地址而无需其他学生详细信息的情况可能不多见。另一方面,大学的课程可能需要独立访问和更新,无论是否有学生注册参加。是否需要单独访问相关文档也会影响数据模型的设计方式。
- 数据之间的关系是否严格为一对多关系:以学生就读的大学课程为例。从学生的角度看,他们可以参加多个课程。表面上看,这似乎是一对多的关系。然而,大学课程很少由单个学生参加;通常,多个学生会参加同一课程。在这种情况下,所讨论的关系实际上不是一对多关系,而是多对多关系,因此,在模型化这种关系时,你会采取与一对多关系不同的方法。
想象一下,你在决定如何存储学生电子邮件地址。每个学生可以有多个电子邮件地址,例如一个用于工作,一个用于个人使用,还有一个由大学提供。一个代表单个电子邮件地址的文档可能采用以下形式:
在基数方面,每个学生拥有的电子邮件地址数量有限,因为一个学生不太可能拥有数十个——更不用说数百个——电子邮件地址。因此,这种关系可以被描述为一对少数关系,这是一个有力的理由,将电子邮件地址直接嵌入到学生文档中,并与学生信息一起存储。你不必担心电子邮件地址列表会无限增长,导致文档变得庞大且使用效率低下。
注意:需要注意的是,将数据存储在数组中存在一些潜在的陷阱。例如,单个MongoDB文档的大小不能超过16MB。虽然使用数组字段嵌入多个文档是可能且常见的做法,但如果对象列表无限制地增长,文档可能会迅速达到这一大小限制。此外,在嵌入数组中存储大量数据会对查询性能产生重大影响。
在数组字段中嵌入多个文档在很多情况下可能是合适的,但也要知道这并非总是最佳解决方案。
关于独立访问,电子邮件地址很可能不会单独从学生信息中被访问。因此,没有明确的动机将它们存储为单独集合中的独立文档。这也是另一个有力的理由,将它们嵌入到学生的文档中。
最后需要考虑的是,这种关系是否真的是一对多关系,而不是多对多关系。因为一个电子邮件地址属于一个人,所以将这种关系描述为一对多关系(或者更准确地说,是一对少数关系)比多对多关系更为合理。
这三个假设表明,将学生的各种电子邮件地址嵌入到描述学生本身的同一文档中,是存储此类数据的一个好选择。一个嵌入了电子邮件地址的学生文档示例可能如下所示:
使用这种结构,每次检索学生文档时,也会在同一读取操作中检索嵌入的电子邮件地址。
如果你建模的是一对少数类型的关系,其中相关文档不需要独立访问,直接嵌入文档通常是可取的,因为这样可以减少模式的复杂性。
但如前所述,像这样嵌入文档并不总是最佳解决方案。下一节将详细说明在某些情况下为什么可能不是最佳选择,并概述如何使用子引用作为在文档数据库中表示关系的另一种方式。
指南4 — 使用子引用建模一对多和多对多关系
学生与其电子邮件地址之间的关系性质决定了如何在文档数据库中最佳地建模这种关系。这种关系与学生和他们参加的课程之间的关系有所不同,因此您建模学生与课程之间关系的方式也将有所不同。
描述学生参加的单一课程的文档可能遵循如下结构:
假设您从一开始就决定使用嵌入式文档来存储每个学生的课程信息,如本例所示:
这将是一个完全有效的MongoDB文档,并且很可能满足需求,但请考虑您在前一指南中了解到的三种关系属性。
首先是基数性。一个学生可能只维护几个电子邮件地址,但他们可以在学习期间参加多个课程。经过几年的学习,学生可能参与了数十门课程。此外,他们将与许多其他学生一起参加这些课程,这些学生同样在多年的学习中参加他们自己的一系列课程。
如果决定像前面的例子那样嵌入每个课程,学生的文档很快就会变得难以管理。随着基数增加,嵌入文档的方法变得不那么吸引人。
第二个考虑因素是独立访问。与电子邮件地址不同,可以合理假设会有一些情况需要单独检索有关大学课程的信息。例如,假设有人需要有关可用课程的信息来准备营销手册。此外,课程可能需要随着时间的推移进行更新:授课教授可能会更换,课程安排可能会变动,或者先修条件可能需要更新。
如果将课程作为文档嵌入在学生文档中,那么检索大学提供的所有课程列表将变得麻烦。而且,每次课程需要更新时,都需要遍历所有学生记录并在各处更新课程信息。这两个原因都表明应该将课程单独存储,而不是完全嵌入。
第三个要考虑的是学生与大学课程之间的关系实际上是一对多还是多对多。在这种情况下,是后者,因为每个课程可以有多名学生参加。这种关系的基数和独立访问方面表明不应嵌入每个课程文档,主要是出于实际原因,如访问和更新的便利性。考虑到课程与学生之间多对多的关系,将课程文档存储在一个具有唯一标识符的单独集合中可能是有意义的。
这个独立集合中表示类的文档可能具有如下结构:
如果你决定以这种方式存储课程信息,你需要找到一种方法将学生与这些课程关联起来,以便了解哪些学生参加了哪些课程。在这种情况下,如果相关对象的数量不是特别庞大,尤其是多对多关系时,一种常见的做法是使用子引用。
通过子引用,学生文档会在嵌入的数组中引用学生参加课程的对象标识符,如下例所示:
注意,这个示例文档仍然有一个courses
字段,它也是一个数组,但与之前嵌入完整课程文档的例子不同,这里只嵌入了指向独立集合中课程文档的标识符。现在,当检索一个学生文档时,课程信息不会立即可用,需要单独查询。另一方面,可以立即知道需要检索哪些课程。此外,如果需要更新任何课程的详情,只需修改课程文档本身。学生与其课程之间的所有引用都将保持有效。
注意:对于何时关系的基数过大以至于不能以这种方式嵌入子引用,并没有严格的规定。如果另一种方法更适合当前应用,你可能会在较低或较高的基数下选择不同的方法。毕竟,你总是希望构建数据结构以适应应用程序查询和更新数据的方式。
如果你要模拟一对多关系,其中相关文档的数量在合理范围内,并且需要独立访问这些相关文档,建议将相关文档分开存储,并通过子引用嵌入来连接它们。
现在你已经学会了如何使用子引用来表示不同类型数据之间的关系,本指南将概述一个相反的概念:父引用。
指南5 —— 使用父引用模拟无界的一对多关系
当相关对象太多以至于无法直接嵌入到父文档中,但数量仍在已知范围内时,使用子引用效果良好。然而,有时关联文档的数量可能是无界的,并且会随时间持续增长。
举个例子,想象一下大学的学生会有一个留言板,任何学生都可以发布他们想要的任何信息,包括课程问题、旅行故事、招聘信息、学习资料,或者只是自由聊天。在这个例子中,一条留言包含主题和正文:
你可以采用之前讨论的两种方法之一——嵌入或子引用——来模拟这种关系。如果选择嵌入方式,学生的文档可能呈现如下形式:
然而,若学生频繁撰写消息,其文档长度将迅速增加,极易超出16MB的尺寸限制,因此此关系的高基数表明嵌入并非良策。此外,消息可能需要独立于学生进行访问,例如,若留言板页面旨在展示学生最新发布的消息,此情况亦暗示嵌入非最佳选择。
注意:在获取学生文档时,亦应考量留言板消息的访问频率。若非频繁访问,将所有消息嵌入该文档会导致获取和操作此文档时性能受损,即便这些消息列表不常被使用。相关数据访问不频繁通常是另一线索,表明不应嵌入文档。
现考虑采用子引用而非嵌入完整文档,如前例所示。各消息将存储于独立集合中,而学生文档则可具备如下结构:
在这个例子中,`message_board_messages`字段现在存储了Sammy所写的所有消息的子引用。然而,这种方法仅解决了之前提到的问题之一,即现在可以独立访问这些消息。尽管使用子引用方法会使学生文档的大小增长得更慢,但由于这种关系的无界基数,对象标识符的集合也可能变得笨重。毕竟,一个学生在四年的学习期间很容易写下数千条消息。
在这样的场景中,连接一个对象到另一个对象的常见方式是通过父引用。与前面描述的子引用不同,现在不是学生文档指向单个消息,而是消息文档中有一个引用指向写这条消息的学生。
要使用父引用,你需要修改消息文档的架构,使其包含一个指向消息作者学生的引用:
注意新的`posted_by`字段包含了学生文档的对象标识符。现在,学生文档将不包含他们所发布消息的任何信息:
要检索一个学生所写的所有消息,你需要在消息集合上进行查询,并对`posted_by`字段进行过滤。将它们放在一个单独的集合中,可以安全地让消息列表增长,而不会影响任何学生文档。
注意:在使用父文档引用时,为引用父文档的字段创建索引可以显著提高每次根据父文档标识符进行过滤的查询性能。
如果你构建一个一对多关系,其中相关文档的数量无限制,无论这些文档是否需要独立访问,通常建议将相关文档分开存储,并使用父文档引用将它们与父文档连接起来。
结论
得益于面向文档数据库的灵活性,在文档数据库中确定最佳的关系模型方式相较于关系型数据库而言,不那么严格科学。通过阅读本文,你已经熟悉了嵌入文档以及使用子文档和父文档引用来存储相关数据。你了解了考虑关系基数和避免无限制数组的重要性,以及是否需要单独或频繁访问文档。
这些只是帮助你在MongoDB中建模典型关系的一些指导原则,但数据库模式设计并非一成不变。在设计模式时,始终要考虑你的应用程序及其如何使用和更新数据。
要深入了解模式设计以及在MongoDB中存储不同类型数据的常见模式,我们建议您查阅该主题的官方MongoDB文档。
Source:
https://www.digitalocean.com/community/tutorials/how-to-design-a-document-schema-in-mongodb