Spring Data MongoDB - 关系建模

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

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

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

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;
}

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

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
    }
}

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

第一步是确定关系的方向,以确定关系的哪一部分需要保存引用(如果不是两者都保存)。此决定将影响我们稍后可用的查找、存储和查询选项。

使用 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属性查找Books的查询。从Book到发布者的缺乏反向引用也会影响查找给定BookPublisher的性能,因为我们现在必须对Publisher集合发出一个查询,该查询将Book.id与发布者的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", … ]
}

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

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的反向引用也可以通过这种方式建模。在这种情况下,可能需要延迟检索发布者,直到第一次访问该属性以避免急切加载延迟。

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文档中。这使您可以在无需考虑更新Publisher文档的情况下存储Books,就像我们在上一段代码段中看到的那样。为此,我们需要做两件事。首先,我们需要告诉映射层省略存储从PublisherBook的链接,其次,在检索链接的Books时更新查找查询

初始部分相当容易,只需对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 社区中所有即将举行的活动。

查看全部