如何在MongoDB中设计文档模式

来自菜鸟教程
跳转至:导航、​搜索

作为 Write for DOnations 计划的一部分,作者选择了 Open Internet/Free Speech Fund 来接受捐赠。

介绍

如果您有大量使用关系数据库的经验,则可能很难超越关系模型的原则,例如从表和关系的角度进行思考。 面向文档的数据库,如MongoDB,可以摆脱关系模型的僵化和局限。 然而,能够在数据库中存储自描述文档所带来的灵活性和自由度可能会导致其他陷阱和困难。

这篇概念性文章概述了与面向文档的数据库中的模式设计相关的五个通用准则,并强调了在对数据之间的关系进行建模时应该考虑的各种注意事项。 它还将介绍可以用来对此类关系进行建模的几种策略,包括在数组中嵌入文档和使用子引用和父引用,以及何时最适合使用这些策略。

准则 1 — 一起存储需要一起访问的内容

在典型的 关系数据库 中,数据保存在表中,每个表都由表示构成实体、对象或事件的各种属性的列的固定列表构成。 例如,在代表某所大学的学生的表中,您可能会发现包含每个学生的名字、姓氏、出生日期和唯一标识号的列。

通常,每个表代表一个主题。 如果您想存储有关学生当前学习、奖学金或先前教育的信息,则将这些数据保存在一个单独的表中,而不是保存他们的个人信息的表中可能是有意义的。 然后,您可以连接这些表以表示每个表中的数据之间存在关系,表明它们包含的信息具有有意义的联系。

例如,描述每个学生的奖学金状况的表格可以通过学生的学生证号来引用学生,但它不会直接存储学生的姓名或地址,避免数据重复。 在这种情况下,要检索有关任何学生的信息以及学生社交媒体帐户、先前教育和奖学金的所有信息,查询需要一次访问多个表,然后将不同表中的结果编译成一个.

这种通过引用来描述关系的方法被称为 标准化数据模型 。 以这种方式存储数据——使用多个相互关联的单独、简洁的对象——在面向文档的数据库中也是可能的。 但是,文档模型的灵活性以及它在单个文档中存储嵌入文档和数组的自由度意味着您可以对数据进行建模,而不是在关系数据库中建模。

在面向文档的数据库中建模数据的基本概念是“将一起访问的内容存储在一起”。” 深入研究学生示例,假设这所学校的大多数学生拥有多个电子邮件地址。 因此,该大学希望能够存储多个电子邮件地址以及每个学生的联系信息。

在这种情况下,示例文档可能具有如下结构:

{
    "_id": ObjectId("612d1e835ebee16872a109a4"),
    "first_name": "Sammy",
    "last_name": "Shark",
    "emails": [
        {
            "email": "sammy@digitalocean.com",
            "type": "work"
        },
        {
            "email": "sammy@example.com",
            "type": "home"
        }
    ]
}

请注意,此示例文档包含一个嵌入的电子邮件地址列表。

在单个文档中表示多个主题是 非规范化 数据模型的特征。 它允许应用程序一次性检索和操作给定对象(此处为学生)的所有相关数据,而无需访问多个单独的对象和集合。 这样做还保证了对此类文档的操作的原子性,而不必使用 多文档事务 来保证完整性。

将需要使用嵌入式文档一起访问的内容存储在一起通常是在面向文档的数据库中表示数据的最佳方式。 在以下指南中,您将了解对象之间的不同关系(例如一对一或一对多关系)如何在面向文档的数据库中进行最佳建模。

准则 2 — 使用嵌入式文档建模一对一关系

一对一 关系表示两个不同对象之间的关联,其中一个对象与另一种对象恰好连接。

继续上一节中的学生示例,每个学生在任何给定时间点都只有一张有效的学生证。 一张卡永远不属于多个学生,任何学生都不能拥有多张身份证。 如果您要将所有这些数据存储在关系数据库中,那么通过将学生记录和身份证记录存储在通过引用绑定在一起的单独表中来对学生与其身份证之间的关系进行建模可能是有意义的。

在文档数据库中表示这种关系的一种常用方法是使用嵌入式文档。 例如,以下文档描述了一个名叫 Sammy 的学生和他们的学生证:

{
    "_id": ObjectId("612d1e835ebee16872a109a4"),
    "first_name": "Sammy",
    "last_name": "Shark",
    "id_card": {
        "number": "123-1234-123",
        "issued_on": ISODate("2020-01-23"),
        "expires_on": ISODate("2020-01-23")
    }
}

请注意,此示例文档的 id_card 字段不是单个值,而是包含一个代表学生身份证的嵌入式文档,由 ID 号、卡的发行日期和卡的到期日期描述。 身份证本质上成为描述学生 Sammy 的文件的一部分,即使它在现实生活中是一个单独的对象。 通常,像这样构建文档模式以便您可以通过单个查询检索所有相关信息是一个不错的选择。

如果您遇到将一种对象与另一种类型的许多对象联系起来的关系,例如学生的电子邮件地址、他们参加的课程或他们在学生会留言板上发布的消息,事情就会变得不那么简单。 在接下来的几个指南中,您将使用这些数据示例来学习处理一对多和多对多关系的不同方法。

准则 3 — 使用嵌入式文档建模一对多关系

当一种类型的对象与另一种类型的多个对象相关时,可以描述为一对多关系。 一个学生可以有多个电子邮件地址,一辆汽车可以有很多零件,或者一个购物订单可以包含多个物品。 这些示例中的每一个都表示一对多的关系。

虽然在文档数据库中表示一对一关系的最常见方法是通过嵌入式文档,但有几种方法可以在文档模式中对一对多关系进行建模。 但是,在考虑如何最好地建模这些选项时,您应该考虑给定关系的三个属性:

  • CardinalityCardinality 是给定集合中单个元素数量的度量。 例如,如果一个班级有 30 名学生,您可以说该班级的基数为 30。 在一对多关系中,基数在每种情况下都可能不同。 一个学生可以有一个或多个电子邮件地址。 他们可以只注册几节课,也可以有一个完整的时间表。 在一对多关系中,“多”的大小将影响您对数据建模的方式。
  • 独立访问:一些相关数据将很少(如果有的话)与主对象分开访问。 例如,在没有其他学生详细信息的情况下检索单个学生的电子邮件地址可能并不常见。 另一方面,大学的课程可能需要单独访问和更新,而不管注册参加这些课程的学生是谁。 您是否会单独访问相关文档也会影响您对数据进行建模的方式。
  • 数据之间的关系是否是严格的一对多关系:考虑一个示例学生在大学参加的课程。 从学生的角度来看,他们可以参加多个课程。 从表面上看,这似乎是一对多的关系。 然而,大学课程很少由一个学生参加; 更多的时候,多个学生会参加同一个课程。 在这种情况下,所讨论的关系并不是真正的一对多关系,而是多对多关系,因此您将采用与一对多不同的方法来建模这种关系。很多关系。

想象一下,您正在决定如何存储学生电子邮件地址。 每个学生可以有多个电子邮件地址,例如一个用于工作,一个用于个人使用,一个由大学提供。 代表单个电子邮件地址的文档可能采用如下形式:

{
    "email": "sammy@digitalocean.com",
    "type": "work"
}

就基数而言,每个学生只有几个电子邮件地址,因为一个学生不太可能拥有数十个(更不用说数百个)电子邮件地址了。 因此,这种关系可以被描述为 一对多 关系,这是将电子邮件地址直接嵌入学生文档并将它们存储在一起的一个令人信服的理由。 您不会冒任何电子邮件地址列表将无限增长的风险,这会使文档变得庞大且使用效率低下。

注意:请注意,将数据存储在数组中存在一定的缺陷。 例如,单个 MongoDB 文档的大小不能超过 16MB。 虽然使用数组字段嵌入多个文档是可能且常见的,但如果对象列表无法控制地增长,文档可能会很快达到此大小限制。 此外,在嵌入式数组中存储大量数据对查询性能有很大影响。

在数组字段中嵌入多个文档可能适用于许多情况,但要知道它也可能并不总是最好的解决方案。


关于独立访问,电子邮件地址可能不会与学生分开访问。 因此,没有明确的动机将它们作为单独的文档存储在单独的集合中。 这是将它们嵌入到学生文档中的另一个令人信服的理由。

最后要考虑的是这种关系是否真的是一对多的关系而不是多对多的关系。 因为电子邮件地址属于一个人,所以将这种关系描述为一对多关系(或更准确地说,可能是一对多关系)而不是多对多关系是合理的。

这三个假设表明,将学生的各种电子邮件地址嵌入描述学生自己的同一文档中将是存储此类数据的不错选择。 嵌入了电子邮件地址的示例学生文档可能采用以下形式:

{
    "_id": ObjectId("612d1e835ebee16872a109a4"),
    "first_name": "Sammy",
    "last_name": "Shark",
    "emails": [
        {
            "email": "sammy@digitalocean.com",
            "type": "work"
        },
        {
            "email": "sammy@example.com",
            "type": "home"
        }
    ]
}

使用这种结构,每次您检索学生的文档时,您还将在同一读取操作中检索嵌入的电子邮件地址。

如果您为一对多的关系建模,其中相关文档不需要独立访问,通常需要像这样直接嵌入文档,因为这可以降低模式的复杂性。

但是,如前所述,像这样嵌入文档并不总是最佳解决方案。 下一节将提供更多详细信息,说明为什么在某些情况下会出现这种情况,并概述如何使用子引用作为表示文档数据库中关系的替代方法。

准则 4 — 使用子引用建模一对多和多对多关系

学生与其电子邮件地址之间关系的性质决定了如何最好地在文档数据库中建模这种关系。 这与学生与他们参加的课程之间的关系存在一些差异,因此您对学生与他们的课程之间的关系进行建模的方式也会有所不同。

描述学生参加的单一课程的文档可以遵循如下结构:

{
    "name": "Physics 101",
    "department": "Department of Physics",
    "points": 7
}

假设您从一开始就决定使用嵌入式文档来存储有关每个学生课程的信息,如下例所示:

{
    "_id": ObjectId("612d1e835ebee16872a109a4"),
    "first_name": "Sammy",
    "last_name": "Shark",
    "emails": [
        {
            "email": "sammy@digitalocean.com",
            "type": "work"
        },
        {
            "email": "sammy@example.com",
            "type": "home"
        }
    ],
    "courses": [
        {
            "name": "Physics 101",
            "department": "Department of Physics",
            "points": 7
        },
        {
            "name": "Introduction to Cloud Computing",
            "department": "Department of Computer Science",
            "points": 4
        }
    ]
}

这将是一个完全有效的 MongoDB 文档,并且可以很好地达到目的,但请考虑您在上一个指南中了解的三个关系属性。

第一个是基数。 一个学生可能只保留几个电子邮件地址,但他们可以在学习期间参加多个课程。 经过几年的学习,学生可能参加了数十门课程。 此外,他们将与许多其他学生一起参加这些课程,这些学生在他们多年的学习中同样参加了他们自己的课程。

如果您决定像前面的示例一样嵌入每门课程,学生的文档很快就会变得笨拙。 随着基数的增加,嵌入式文档方法变得不那么引人注目。

第二个考虑是独立访问。 与电子邮件地址不同,假设在某些情况下需要自行检索有关大学课程的信息是合理的。 例如,假设某人需要有关可用课程的信息来准备营销手册。 此外,课程可能需要随着时间的推移而更新:教授课程的教授可能会改变,课程表可能会波动,或者其先决条件可能需要更新。

如果您将课程存储为嵌入在学生文档中的文档,则检索大学提供的所有课程的列表将变得很麻烦。 此外,每次课程需要更新时,您都需要查看所有学生记录并在各处更新课程信息。 两者都是分开存储课程而不是完全嵌入它们的好理由。

第三要考虑的是学生和大学课程之间的关系实际上是一对多还是多对多。 在这种情况下,是后者,因为每门课程可以有多个学生参加。 这种关系的基数和独立访问方面建议不要嵌入每个课程文档,主要是出于易于访问和更新等实际原因。 考虑到课程和学生之间关系的多对多性质,将课程文档存储在具有自己唯一标识符的单独集合中可能是有意义的。

表示此单独集合中的类的文档可能具有如下示例的结构:

{
    "_id": ObjectId("61741c9cbc9ec583c836170a"),
    "name": "Physics 101",
    "department": "Department of Physics",
    "points": 7
},
{
    "_id": ObjectId("61741c9cbc9ec583c836170b"),
    "name": "Introduction to Cloud Computing",
    "department": "Department of Computer Science",
    "points": 4
}

如果您决定像这样存储课程信息,您需要找到一种方法将学生与这些课程联系起来,以便您知道哪些学生参加了哪些课程。 在这种情况下,相关对象的数量不是很大,尤其是在多对多关系的情况下,一种常见的方法是使用 子引用

通过子引用,学生的文档将引用学生在嵌入式数组中参加的课程的对象标识符,如下例所示:

{
    "_id": ObjectId("612d1e835ebee16872a109a4"),
    "first_name": "Sammy",
    "last_name": "Shark",
    "emails": [
        {
            "email": "sammy@digitalocean.com",
            "type": "work"
        },
        {
            "email": "sammy@example.com",
            "type": "home"
        }
    ],
    "courses": [
        ObjectId("61741c9cbc9ec583c836170a"),
        ObjectId("61741c9cbc9ec583c836170b")
    ]
}

请注意,这个示例文档仍然有一个 courses 字段,它也是一个数组,但是不像前面的示例那样嵌入完整的课程文档,只嵌入了引用单独集合中的课程文档的标识符。 现在,在检索学生文档时,课程不会立即可用,需要单独查询。 另一方面,立即知道要检索哪些课程。 此外,如果需要更新任何课程的详细信息,只需更改课程文档本身。 学生和他们的课程之间的所有参考将保持有效。

注意: 当关系的基数太大而不能以这种方式嵌入子引用时,没有明确的规则。 如果它最适合所讨论的应用程序,您可能会以较低或较高的基数选择不同的方法。 毕竟,您总是希望构建数据以适应应用程序查询和更新数据的方式。


如果您为一对多关系建模,其中相关文档的数量在合理范围内并且需要独立访问相关文档,则倾向于单独存储相关文档并嵌入子引用以连接它们。

现在您已经了解了如何使用子引用来表示不同类型数据之间的关系,本指南将概述一个相反的概念:父引用。

准则 5 — 使用父引用建模无限的一对多关系

当有太多相关对象无法将它们直接嵌入到父文档中时,使用子引用效果很好,但数量仍在已知范围内。 但是,在某些情况下,关联文档的数量可能是无限的,并且会随着时间的推移而继续增长。

例如,假设大学的学生会有一个留言板,任何学生都可以在其中发布他们想要的任何信息,包括有关课程的问题、旅行故事、职位发布、学习材料或只是免费聊天。 此示例中的示例消息由主题和消息正文组成:

{
    "_id": ObjectId("61741c9cbc9ec583c836174c"),
    "subject": "Books on kinematics and dynamics",
    "message": "Hello! Could you recommend good introductory books covering the topics of kinematics and dynamics? Thanks!",
    "posted_on": ISODate("2021-07-23T16:03:21Z")
}

您可以使用前面讨论的两种方法(嵌入和子引用)中的任何一种来建模这种关系。 如果您决定嵌入,学生的文档可能采用如下形状:

{
    "_id": ObjectId("612d1e835ebee16872a109a4"),
    "first_name": "Sammy",
    "last_name": "Shark",
    "emails": [
        {
            "email": "sammy@digitalocean.com",
            "type": "work"
        },
        {
            "email": "sammy@example.com",
            "type": "home"
        }
    ],
    "courses": [
        ObjectId("61741c9cbc9ec583c836170a"),
        ObjectId("61741c9cbc9ec583c836170b")
    ],
    "message_board_messages": [
        {
            "subject": "Books on kinematics and dynamics",
            "message": "Hello! Could you recommend good introductory books covering the topics of kinematics and dynamics? Thanks!",
            "posted_on": ISODate("2021-07-23T16:03:21Z")
        },
        . . .
    ]
}

但是,如果一个学生在编写消息方面多产,他们的文档将很快变得非常长,并且很容易超过 16MB 的大小限制,因此这种关系的基数建议不要嵌入。 此外,消息可能需要与学生分开访问,如果留言板页面旨在显示学生发布的最新消息,则可能会出现这种情况。 这也表明嵌入不是这种情况的最佳选择。

注意: 在检索学生的文档时,还应考虑是否经常访问留言板消息。 如果没有,将它们全部嵌入到该文档中会在检索和操作该文档时导致性能损失,即使在不经常使用消息列表的情况下也是如此。 不经常访问相关数据通常是您不应该嵌入文档的另一个线索。


现在考虑使用子引用而不是像前面的示例那样嵌入完整文档。 各个消息将存储在单独的集合中,然后学生的文档可以具有以下结构:

{
    "_id": ObjectId("612d1e835ebee16872a109a4"),
    "first_name": "Sammy",
    "last_name": "Shark",
    "emails": [
        {
            "email": "sammy@digitalocean.com",
            "type": "work"
        },
        {
            "email": "sammy@example.com",
            "type": "home"
        }
    ],
    "courses": [
        ObjectId("61741c9cbc9ec583c836170a"),
        ObjectId("61741c9cbc9ec583c836170b")
    ],
    "message_board_messages": [
        ObjectId("61741c9cbc9ec583c836174c"),
        . . .
    ]
}

在此示例中,message_board_messages 字段现在存储对 Sammy 编写的所有消息的子引用。 然而,改变方法只能解决前面提到的问题之一,因为现在可以独立访问消息。 但是,尽管使用子引用方法学生的文档大小会增长得更慢,但考虑到这种关系的无限基数,对象标识符的集合也可能变得笨拙。 毕竟,一个学生在四年的学习中可以轻松写出数千条信息。

在这种情况下,将一个对象连接到另一个对象的常用方法是通过 父引用 。 与之前描述的子引用不同,现在不是学生文档引用单个消息,而是消息文档中指向编写它的学生的引用。

要使用父引用,您需要修改消息文档模式以包含对编写消息的学生的引用:

{
    "_id": ObjectId("61741c9cbc9ec583c836174c"),
    "subject": "Books on kinematics and dynamics",
    "message": "Hello! Could you recommend a good introductory books covering the topics of kinematics and dynamics? Thanks!",
    "posted_on": ISODate("2021-07-23T16:03:21Z"),
    "posted_by": ObjectId("612d1e835ebee16872a109a4")
}

请注意,新的 posted_by 字段包含学生文档的对象标识符。 现在,学生的文档将不包含有关他们发布的消息的任何信息:

{
    "_id": ObjectId("612d1e835ebee16872a109a4"),
    "first_name": "Sammy",
    "last_name": "Shark",
    "emails": [
        {
            "email": "sammy@digitalocean.com",
            "type": "work"
        },
        {
            "email": "sammy@example.com",
            "type": "home"
        }
    ],
    "courses": [
        ObjectId("61741c9cbc9ec583c836170a"),
        ObjectId("61741c9cbc9ec583c836170b")
    ]
}

要检索学生撰写的消息列表,您可以对消息集合使用查询并针对 posted_by 字段进行过滤。 将它们放在单独的集合中可以安全地让消息列表增长而不会影响任何学生的文档。

注意: 使用父引用时,在引用父文档的字段上创建索引可以显着提高每次过滤父文档标识符时的查询性能。


如果你建模一个相关文档数量不限的一对多关系,不管文档是否需要独立访问,一般建议你单独存储相关文档,并使用父引用将它们连接到父文档.

结论

由于面向文档的数据库的灵活性,确定在文档数据库中建模关系的最佳方法不像在关系数据库中那样是一门严格的科学。 通过阅读本文,您已经熟悉了嵌入文档以及使用子引用和父引用来存储相关数据。 您已经了解了考虑关系基数和避免无界数组,以及考虑文档是单独访问还是频繁访问。

这些只是可以帮助您在 MongoDB 中为典型关系建模的一些指南,但建模数据库模式并不是一刀切。 在设计模式时,请始终考虑您的应用程序以及它如何使用和更新数据。

要了解有关在 MongoDB 中存储不同类型数据的模式设计和常见模式的更多信息,我们建议您查看有关该主题的 官方 MongoDB 文档