Spring Data MongoDB - 关系建模

工程 | Christoph Strobl | 2021 年 11 月 29 日 | ...

MongoDB 灵活的模式允许在对实体之间的关系进行建模时采用多种模式。此外,对于许多用例来说,非规范化数据模型(将相关数据直接存储在单个文档中)可能是最佳选择,因为所有信息都保存在一个地方,这样应用程序只需更少的查询即可获取所有数据。然而,这种方法也有其缺点,例如潜在的数据重复、文档更大以及最大文档大小限制。

一般来说,当嵌入的优势被数据重复的影响所抵消时,MongoDB 建议使用规范化数据模型。在这篇博客文章中,我们将探讨当需要处理关系时,使用手动引用DBRefs链接文档的不同可能性。

DBRef 是 MongoDB 的原生元素,用于以显式格式 { $db : …, $ref : …, $id : … } 表示对其他文档的引用,该格式包含有关目标数据库集合以及引用元素的 id 值的信息,最适合链接分散在不同集合中的文档。

另一方面,手动引用的结构更简单(仅存储被引用文档的 id),因此在涉及混合集合引用时不够灵活。

在确定了术语之后,我们来介绍一些众所周知的领域类型,例如 BookPublisher,以及它们之间显而易见的关系

class Book {
    private String isbn13;
    private String title;
    private int pages;
}

class Publisher {
    private String name;
    private String arconym;
    private int foundationYear;
}

将每个 Book 中都嵌入 Publisher 并不是一个吸引人的选项,因为它会导致数据重复,并给存储和可维护性带来不必要的负担

class Book {
    // ...
    private Publisher publisher;
}

虽然这种存储格式允许原子更新,并在查询特定属性时提供最大的灵活性,但正如以下代码片段所示,重复 Publisher 信息可能不值得付出这种代价

{
    "_id" : "617cfb",
    "isbn13" : "978-0345503800",
    "title" : "The Warded Man",
    "pages" : 432,
    "publisher" : {
        "name" : "Del Rey Books",
        "arconym" : "DRB",
        "foundationYear" : 1977
    }
}

Publisher 中嵌入 Books 集合也是如此,这会导致文档不必要地增大。规范化模型并使用链接文档可以缓解此问题。

第一步是确定关系的方向,以弄清楚关系中的哪一部分(如果不是双方)需要持有引用。这个决定将影响我们后续可用的查找、存储和查询选项。

使用 DBRef 进行链接

在这种情况下,Publisher 持有对其关联 Books 的引用。思路是将这些引用作为数组存储在 Publisher 文档中

class Publisher {
    // ...
    @DBRef
    List<Book> books;
}

在上面的代码片段中,books 属性使用 @DBRef 进行了注解。这告诉 Spring Data 映射层将此属性的元素存储为 MongoDB 原生的 $dbref 元素,其格式如下所示

{
    "_id" : "833f7d",
    "name" : "Del Rey Books",
    "arconym" : "DRB",
    "foundationYear" : 1977,
    "books" : [
        {
            "$ref" : "book",
            "$id" : "617cfb"
        },
        {
            "$ref" : "book",
            "$id" : "23e78f"
        }
    ]
}

使用 @DBRef 注解可以避免在 Book 文档中重复所有 Publisher 信息,从而减小存储大小,这是件好事。然而,这种方法也有其缺点。Book 不再包含有关 publisher 的信息,这可能会影响按 Publisher 的属性查询 Books 的操作。从 Book 到 publisher 缺乏反向引用也会影响查找给定 Book 对应的 Publisher 的性能,因为我们现在必须对 Publisher 集合发出一个查询,将 Book.id 与 publisher 的 books 字段进行匹配,而不是直接查找其 id。此外,Publisher 中的 books 数组使用了一个存储了比必要信息更多的复杂对象,而仅使用 id手动引用就足够了,因为所有引用的对象都保存在同一个目标集合中。

幸运的是,有改进的方法,首先是添加一个指向 Publisher 的反向引用(例如,通过其 id

class Book {
    // …
    private String publisherId;
}

使用手动引用进行链接

接下来,让我们从 DBRef 切换到 手动引用 来存储 Book 引用的集合。显而易见的一步是移除 @DBRef 注解,并将 List<Book> 替换为 List<String>,如下面的代码片段所示

class Publisher {
    // …
    List<String> bookIds;
}

{
    …
    "bookIds" : ["617cfb", "23e78f", … ]
}

要向 PublisherbookIds 字段添加新的 Book,我们可以使用以下语句。

template.update(Publisher.class)
    .matching(where("id").is(publisher.id))
    .apply(new Update().push("bookIds", book.id))
    .first();

采用这种方法可以优化存储格式,并非常明确地说明了在领域模型和数据库中使用的字段类型。然而,仅仅 bookIds 并不能提供在哪个集合中查找 bookIds 字段中包含的值的上下文信息。

使用声明式手动引用进行链接

Spring Data MongoDB 3.3.0 开始,可以使用 @DocumentReference 注解以声明方式表达 手动引用

class Publisher {
    // …
    @DocumentReference
    List<Book> books;
}

默认情况下,这告诉映射层在存储时提取被引用实体的 id 值,并在读取时加载被引用的文档本身。

{
    …
    "books" : ["617cfb", … ]
}

由于映射层知道文档之间的链接,因此更新语句(例如前面显示的语句)会检测到关联并提取 id 进行存储

template.update(Publisher.class)
    .matching(where("id").is(publisher.id))
    .apply(new Update().push("books", book))
    .first();

此外,从 BookPublisher 的反向引用也可以通过这种方式建模。在这种情况下,最好延迟 publisher 的检索直到首次访问该属性,以避免提前加载造成的延迟

class Book {
    // …
    @DocumentReference(lazy=true)
    private Publisher publisher;
}

通过使用声明式链接,我们现在可以保留映射功能,同时优化存储。但是,在添加新的 Book 实例时仍需小心,因为这些实例也需要添加到 Publisherbooks 字段中,以建立链接

template.save(newBook);

template.update(Publisher.class)
    .matching(where("id").is(newBook.publisher.id))
    .apply(new Update().push("books", newBook))
    .first();

上面的代码片段很好地概述了处理文档之间链接的非原子性,这可能需要在 事务 中运行操作。

一对多风格的引用

根据您的应用程序需求,可以反转 BookPublisher 之间的关系,以便链接元素仅存储在 Book 文档中。这样,您就可以存储 Books,而无需考虑更新 Publisher 文档,就像我们在上一个代码片段中看到的那样。为此,我们需要做两件事。首先,我们需要告诉映射层省略存储从 PublisherBook 的链接;其次,在检索链接的 Books 时更新 lookup query

第一部分相当容易,只需在 books 属性上应用额外的 @ReadOnlyPorperty 注解。另一部分需要我们使用自定义查询更新 @DocumentReference 注解的 lookup 属性

class Publisher {
    // …
    @ReadOnlyProperty
    @DocumentReference(lookup="{'publisher':?#{#self._id} }")
    List<Book> books;
}

在上面的代码片段中,我们利用了 Spring Data 查询解析器中的表达式支持。这样做,我们可以通过使用 #self 属性访问原始的 Publisher 文档,并提取其标识符,然后在查询 Book 集合以查找匹配元素时使用它。

结语

拥有带有嵌入数据的单一聚合根具有许多优势。然而,一旦这些优势被其他问题(例如存储大小或可操作性)所取代,理解如何建模关系就很重要。我们已经看到,通过从嵌入式方法转向 DBRefs,再到手动引用,我们可以减小存储大小。但是,我们必须处理其他问题,例如影响多个文档的更改以及有限的查询选项。@DocumentReference 是一个强大的工具,它允许您表达和自定义文档之间的链接。您可以在我们的参考文档中了解更多信息。

尽管如此,在您离开之前,请始终记住文档之间的链接需要额外的服务器往返。因此,请确保有支持您查找的索引。链接文档的集合是批量加载的,并在应用程序内存中尽力恢复排序。

此外,总是问自己哪个最适合您的应用程序?默认的嵌入式方法是更好的解决方案吗?您真的需要循环反向引用吗?链接应该是延迟加载的吗?非原子更新将如何影响您的应用程序?最后,您需要运行哪些查询?

获取 Spring 新闻通讯

订阅 Spring 新闻通讯,保持联系

订阅

更进一步

VMware 提供培训和认证,为您的进步提供强大动力。

了解更多

获取支持

Tanzu Spring 通过一项简单的订阅提供对 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件。

了解更多

近期活动

查看 Spring 社区所有近期活动。

查看全部