Spring Data MongoDB - 关系建模

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

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

一般来说,当嵌入式模型的复制带来的负面影响超过其优势时,MongoDB 建议使用规范化的数据模型。在这篇博文中,我们将探讨在需要处理关系时,使用手动引用DBRefs链接文档的各种可能性。

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

另一方面,手动引用在结构上更简单(仅存储被引用文档的id),但在混合集合引用方面灵活性不如 DBRefs。

在确定了术语之后,让我们介绍一些众所周知的领域类型,例如 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 中也是如此,这会导致文档不必要地变大。规范化模型并使用链接文档可以缓解此问题。

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

使用 DBRefs 进行链接

在这种情况下,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 的信息,这可能会影响查询 BooksPublisher 属性进行查找的性能。从 Book 到 publisher 的反向引用缺失也会影响查找给定 BookPublisher 的性能,因为我们现在必须发出一个针对 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", … ]
}

要将新的 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 的反向引用也可以通过这种方式建模。在这种情况下,在首次访问属性之前延迟检索 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 文档中。这允许您在不更新 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 社区所有即将举行的活动。

查看所有