在Java中使用MongoDB实现一对多关系建模
目录
- 您将学到什么
- 先决条件
- 项目设置
- 配置支持 POJO 的 MongoClient
- Java 中的一对多关系是什么?
- MongoDB 存储文档的方式与关系型数据库有何不同?
- 模式 1:嵌入式文档
- 模式 2:引用
- MongoDB 模式设计的最佳实践
- 为您的 Java 应用选择正确的关系模型
- 常见问题解答
在关系型数据库中,建模一对多关系非常简单:创建两张表并通过外键关联。当需要同时获取数据时,编写一个 JOIN 语句即可。而在 MongoDB 中,您可以进行选择,这种选择直接影响应用程序的性能、可扩展性和可维护性。
考虑一个常见的场景:一个 BlogPost(博客文章)拥有多个 Comment(评论)对象。在 Java 中,这自然表现为文章类中的一个 List<Comment> 字段。但当需要在 MongoDB 中持久化这种关系时,您需要决定如何存储它。评论应该存放在博客文章文档内部吗?还是应该存放在它们自己的集合中,通过引用进行连接?
本教程将使用纯 Java POJO 和 MongoDB Java 同步驱动程序,带您了解这两种方法——嵌入式文档和引用。您将构建一个小型的博客应用程序,查看生成的文档结构,并了解每种模式在何时表现出色(以及在何时不适用)。在此过程中,我们还将介绍一种被称为子集模式 (Subset Pattern) 的混合策略,它结合了两种方法的优点。
您将学到什么
- 什么是一对多关系,以及它如何从 Java 对象映射到 MongoDB 文档。
- 何时嵌入文档以及何时使用引用,以及每种方式的权衡。
- 如何使用 MongoDB Java 同步驱动程序和 POJO 在 Java 中对这两种模式进行建模。
- 如何有效地查询和更新每种模式。
- 避免常见模式设计陷阱的最佳实践。
先决条件
要跟随本教程,您需要:
- 安装 Java 11+。
- 用于依赖管理的 Maven。
- 一个 MongoDB Atlas 集群(免费层级 即可)或本地 MongoDB 实例。
- 对 Java 和面向对象编程的基本了解。
本教程的完整源代码可在 GitHub 上获取。此仓库的 appName 为 devrel-tutorial-java-driver-foojay。
项目设置
创建一个 Maven 项目,并在 pom.xml 中添加以下依赖项:
在项目根目录下创建一个 .env 文件,填入您的 MongoDB 连接字符串:
配置支持 POJO 的 MongoClient
在深入研究关系模式之前,我们需要配置一个启用了 PojoCodecProvider 的 MongoClient。这告诉驱动程序如何自动将 Java 对象映射为 BSON 文档,反之亦然——无需手动序列化。
这里的关键代码行是 PojoCodecProvider.builder().automatic(true).build()。设置 automatic(true) 会告诉驱动程序处理它遇到的任何 POJO,而不仅仅是您显式注册的那些。这就是使整个 POJO 到 BSON 的映射在后续示例中无缝工作的关键。
Java 中的一对多关系是什么?
在面向对象术语中,一对多关系意味着一个对象包含或关联了一个对象集合。BlogPost 拥有多个 Comment 对象。在 Java 中,这通常表示为一个 List:
这直观且熟悉。但它如何转化为文档数据库呢?在 MongoDB 中,文档是一种丰富的、层级化的数据结构——类似于 JSON 对象。与关系表不同,单个 MongoDB 文档可以容纳嵌套对象和数组。这种灵活性为您提供了关系世界中所没有的选择。
核心问题变成了:这些 Comment 对象应该存放在 BlogPost 文档内部,还是应该存放在一个带有指向文章指针的独立集合中?
MongoDB 存储文档的方式与关系型数据库有何不同?
在关系型数据库中,数据被规范化为表。blog_posts 表和 comments 表通过 post_id 外键连接。要读取一篇文章及其评论,您需要编写 JOIN 查询。数据库强制执行参照完整性,且模式是固定的。
MongoDB 采取了不同的方法。数据以灵活的 BSON 文档(二进制 JSON)形式存储,可以包含嵌套对象、数组和混合类型。这里没有传统意义上的 JOIN——尽管 MongoDB 的 $lookup 聚合阶段在需要时可以执行类似的操作。
这种灵活性意味着 MongoDB 让您可以针对每个用例选择您的关系策略。两种主要策略是:
- 嵌入式文档 —— 将相关数据直接存储在父文档内部。
- 引用 —— 存储一个指向另一个集合中文档的指针(通常是
ObjectId)。
没有哪种方式是普遍“更好”的。正确的选择取决于您的数据访问模式、更新频率和增长预期。让我们来探讨这两种方式。## 模式 1:嵌入式文档 (Embedded Documents)
何时应该使用嵌入?
嵌入意味着将相关数据直接存储在父文档内部。当您读取父文档时,可以一次性获取所有相关数据,无需进行第二次查询。
在以下情况下使用嵌入:
- 子数据总是与父数据一起读取。
- 子数组的大小有限(例如,每篇文章有少量评论,而不是数百万条日志)。
- 您不需要独立于父文档来查询或更新子文档。
在 Java 中建模嵌入式文档
让我们用嵌入的方式来模拟博客场景。Comment(评论)和 User(文章作者)直接嵌入在 BlogPost 文档中。
这是嵌入的 Comment 类——请注意它没有 _id 字段,因为它不是作为一个独立的文档存在的:
以及代表文章作者的嵌入式 User 类:
现在是 BlogPost 类本身。它将作者作为嵌入式 User 持有,并将评论作为嵌入的 List<Comment> 持有:
@BsonProperty 注解将每个 Java 字段映射到对应的 BSON 字段名。@BsonId 注解将 id 字段标记为文档的 _id。每个 POJO 都需要一个无参构造函数,以便 PojoCodecProvider 将文档反序列化为 Java 对象。
插入和查询嵌入式文档
定义好 POJO 后,让我们看看如何插入带有嵌入式评论的博客文章并将其读取出来:
生成的 MongoDB 文档如下所示:
所有内容——文章正文、作者资料和所有评论——都存在于一个文档中。一次 find() 调用即可全部返回。添加新评论是对父文档的原子 $push 操作,无需触及第二个集合。
您还可以使用点号标记法(dot notation)查询嵌入的数据。Filters.eq("comments.author", "Bob") 可以找到所有包含至少一条由 Bob 撰写的评论的文章,而 Filters.eq("author.username", "alice") 则可以按嵌入式作者的用户名进行筛选。## 模式 2:引用 (References)
何时应该使用引用?
引用意味着存储一个指针(通常是 ObjectId),指向位于另一个集合中的文档。要组装完整的对象,你需要执行多次查询。
在以下情况下应使用引用:
- 子文档数量巨大或不确定(例如,一篇热门文章可能有成千上万条评论)。
- 子文档需要独立于父文档进行查询或更新。
- 多个父文档可能引用同一个子文档(例如,一位用户撰写了许多文章和评论)。
在 Java 中对引用进行建模
在引用式方法中,用户、博客文章和评论各自存放在自己的集合中。BlogPost 存储指向 users 集合中作者的 ObjectId,以及指向 comments 集合中评论的 ObjectId 列表。
以下是 User 类——现在是一个拥有自己 _id 的独立文档:
Comment 也成为了一个独立文档,通过 ObjectId 同时引用文章和作者:
而 BlogPost 持有的是引用,而不是嵌入的对象:
注意区别:我们不再使用 private User author 和 private List<Comment> comments,而是使用了 private ObjectId authorId 和 private List<ObjectId> commentIds。数据本身存储在其他地方。
插入与查询被引用的文档
使用引用需要更多的步骤。你需要将文档插入到不同的集合中,维护引用列表,并通过额外的查询来解析这些引用:
最终生成的 MongoDB 文档分布在三个集合中:
代码中可以明显看出这种权衡。组装完整的对象图需要先获取文章,然后获取作者,接着是评论,最后是评论作者。这涉及多次网络往返。不过,Filters.in() 操作符允许我们高效地批量加载相关文档——注意我们是如何收集所有唯一的 commentAuthorIds 并通过单次查询解析它们的,而不是为每条评论执行一次查询。
关键优势体现在第 6 步:你可以直接查询评论集合。查找特定用户的所有评论,或跨所有文章查找最新评论,只需一个简单的查询——无需扫描每篇博客文章文档中的嵌入数组。
注意: 对于希望在服务器端解析引用的场景,MongoDB 的 $lookup 聚合阶段可以在集合之间执行类似左外连接的操作。这对于分析查询或仪表板非常有用,但对于大多数应用程序读取操作,此处展示的多步方法能让你更精确地控制加载内容和加载时机。## MongoDB 模式设计的最佳实践
在了解了这两种模式的实际应用后,以下原则应指导您的模式设计决策。
根据查询模式而非数据结构进行设计
这是 MongoDB 模式设计中最重要的准则。不要从绘制实体关系图并进行规范化开始。相反,请问自己:我的应用程序最常提出什么问题? 如果您的应用程序总是显示一篇博客文章及其评论,那么嵌入式设计会让读取速度变快。如果您的应用程序有一个单独的“用户所有评论”页面,引用(Reference)则能为您提供直接访问途径。
避免无限制的数组
当数组具有可预测的上限时,嵌入式设计效果很好。一篇博客文章有 5 到 50 条评论?嵌入式完全没问题。但如果是可能积累数十万条反应的社交媒体帖子呢?该数组将无限增长,最终触及 MongoDB 16 MB 的文档大小限制。当列表可能无限增长时,请使用引用。
考虑原子性
MongoDB 保证单文档级别的原子更新。当您将评论嵌入到博客文章中时,更新文章并添加评论是一个单一的原子操作。使用引用时,跨多个集合更新文档默认不是原子的。如果您需要父子文档之间的原子更新,嵌入式设计开箱即用地提供了这种保证。对于跨集合的原子性,您需要使用多文档事务。
考虑子集模式 (Subset Pattern)
如果您需要嵌入式的读取性能,但数据集太大而无法完全嵌入,该怎么办?子集模式提供了一个折中方案:嵌入相关数据的一个子集以实现快速访问,同时将完整数据集保留在单独的集合中。
以我们的博客示例为例,您可以仅在文章内嵌入最近的三条评论以实现快速渲染,同时将所有评论存储在单独的评论集合中,供“查看所有评论”页面使用。
以下是子集模式在 Java 中的简化视图。首先是快照类(Snapshot classes)——为显示而优化的轻量级数据副本:
以及结合了这两者的 BlogPost:
关键的维护操作发生在添加新评论时。您将完整的评论插入评论集合,然后使用带有 $slice 的 $push 原子地更新文章,以仅保留最近的条目:
最终的文档为您提供了两全其美的方案——最常见的视图只需一次读取,而在需要时,完整的数据集可在单独的集合中获取:
AuthorSnapshot 在显示字段旁携带了用户的 _id,因此它既充当引用,也充当读取优化的缓存。当读者导航到完整的作者个人资料时,您可以使用该 _id 在用户集合中进行解析。comment_count 字段允许 UI 显示“查看全部 5 条评论”而无需执行计数查询。
权衡显而易见:如果用户更改了他们的显示名称,您需要更新他们出现的所有帖子中的嵌入式快照。对于个人资料更改频率远低于帖子读取频率的博客平台而言,这通常是一个极好的权衡。
保持文档在 16 MB 限制内
这是 MongoDB 对文档大小的硬性约束。如果您的嵌入式数组可能使文档超过此限制,请使用引用。子集模式在这里特别有用:您可以在最常见的视图中获得嵌入的读取性能,而完整数据集则安全地保存在其自己的集合中。
为您的 Java 应用选择合适的关联模型
选择嵌入式文档还是引用,取决于您的应用程序访问模式:
- 选择嵌入:当相关数据总是与父文档一起读取、数组大小有限,且您重视读取性能和原子更新时。
- 选择引用:当相关数据非常多或大小不定、需要独立查询或更新,或者在多个父文档之间共享时。引用使文档保持较小且可预测,但代价是需要额外的查询。
- 选择子集模式:当您需要嵌入的读取性能,但数据集太大或变动太频繁而无法完全嵌入时。嵌入精选的子集以实现快速访问;引用完整数据集以确保完整性。
Java POJO 模型可以清晰地映射到这三种模式。无论您的字段是嵌入式对象、ObjectId 引用还是两者的混合,PojoCodecProvider 都会自动处理序列化和反序列化。MongoDB 的模式设计应始终由您的应用程序查询模式驱动,而 Java 的类型系统使表达所需的文档结构变得非常容易。
所有三种模式的完整工作代码可在 GitHub 上获取。要使用您自己的数据进行试验,请注册一个免费的 MongoDB Atlas 集群,克隆存储库,在 .env 文件中设置您的连接字符串,然后运行:
常见问题解答
我可以在同一个 MongoDB 模式中混合使用嵌入式和引用文档吗?
可以,而且通常建议这样做。子集模式就是一个完美的例子:您嵌入最近的评论以供快速显示,同时将完整的评论历史记录作为引用存储在单独的集合中。MongoDB 的模式设计本质上是灵活的,针对每种关系混合使用策略是一种常见且推荐的做法。
如何在 Spring Data MongoDB 中处理一对多关系?
Spring Data MongoDB 开箱即用地提供了 @DBRef 和嵌入式文档支持。无论您使用什么框架,这里介绍的模式设计原则(嵌入式文档、引用和子集模式)都适用。本教程使用核心 Java 同步驱动程序来解释底层机制,但这些概念可以直接转换到 Spring Data、Quarkus 和 Micronaut 中。
这适用于 MongoDB Java 响应式流 (Reactive Streams) 驱动程序吗?
本教程中介绍的模式设计原则无论您使用哪种驱动程序或框架都是通用的。MongoDB 官方的 Java 响应式流驱动程序通过异步、非阻塞 API 提供相同的操作。社区集成(如 Spring Data MongoDB、Quarkus MongoDB 和 Micronaut MongoDB)也建立在这些相同的底层概念之上,同时增加了框架特定的便利性。
如果我的嵌入式数组变得太大怎么办?
MongoDB 文档有 16 MB 的大小限制。如果您的数组可能无限制地增长(如事件日志、聊天记录、物联网传感器读数),您应该使用引用而不是嵌入。如果您仍然希望为最近的一小部分数据获得快速读取性能,子集模式提供了一个折中方案。
嵌入式文档和引用文档之间有性能差异吗?
是的。嵌入式文档在一次读取操作中获取,这使得它们对于始终需要将子数据与父数据一起使用的读取密集型用例更快。引用至少需要两次读取,这会增加延迟,但它们使文档更小,且更易于单独更新。
我需要使用 MongoDB 引用手动管理参照完整性吗?
是的。与 SQL 外键不同,MongoDB 不会强制执行 ObjectId 引用的参照完整性。您的应用程序代码(通常是 Java 服务层)负责保持引用的一致性。这意味着处理级联删除、孤立引用以及确保 ID 指向现有文档等工作都由您自己负责。