Ohhnews

分类导航

$ cd ..
foojay原文

在Java中使用MongoDB实现一对多关系建模

#java#mongodb#数据库建模#数据存储#后端开发

目录

在关系型数据库中,建模一对多关系非常简单:创建两张表并通过外键关联。当需要同时获取数据时,编写一个 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 中添加以下依赖项:

$ xml
<dependencies>
    <!-- MongoDB Java 同步驱动程序 -->
    <dependency>
        <groupId>org.mongodb</groupId>
        <artifactId>mongodb-driver-sync</artifactId>
        <version>5.3.1</version>
    </dependency>
    <!-- dotenv:从 .env 文件加载 MONGODB_URI -->
    <dependency>
        <groupId>io.github.cdimascio</groupId>
        <artifactId>dotenv-java</artifactId>
        <version>3.0.0</version>
    </dependency>
    <!-- 日志记录 -->
    <dependency>
        <groupId>org.slf4j</groupId>
        <artifactId>slf4j-api</artifactId>
        <version>2.0.13</version>
    </dependency>
    <dependency>
        <groupId>ch.qos.logback</groupId>
        <artifactId>logback-classic</artifactId>
        <version>1.5.6</version>
    </dependency>
</dependencies>

在项目根目录下创建一个 .env 文件,填入您的 MongoDB 连接字符串:

MONGODB_URI=mongodb+srv://<username>:<password>@<cluster>.mongodb.net/?retryWrites=true&w=majority

配置支持 POJO 的 MongoClient

在深入研究关系模式之前,我们需要配置一个启用了 PojoCodecProviderMongoClient。这告诉驱动程序如何自动将 Java 对象映射为 BSON 文档,反之亦然——无需手动序列化。

$ java
package com.example.mongodb.relationships.config;
import com.mongodb.ConnectionString;
import com.mongodb.MongoClientSettings;
import com.mongodb.client.MongoClient;
import com.mongodb.client.MongoClients;
import io.github.cdimascio.dotenv.Dotenv;
import org.bson.codecs.configuration.CodecRegistry;
import org.bson.codecs.pojo.PojoCodecProvider;
import static org.bson.codecs.configuration.CodecRegistries.fromProviders;
import static org.bson.codecs.configuration.CodecRegistries.fromRegistries;

public class MongoConfig {
    /**
     * DevRel 跟踪名称 — 标识来自 foojay.io 本教程的流量。
     * 格式: devrel-{medium}-{primary}-{secondary}-{platform}
     */
    private static final String APP_NAME = "devrel-tutorial-java-driver-foojay";
    private MongoConfig() {
        // 工具类
    }

    public static MongoClient createClient() {
        String mongoUri = loadMongoUri();
        CodecRegistry pojoCodecRegistry = fromRegistries(
                MongoClientSettings.getDefaultCodecRegistry(),
                fromProviders(PojoCodecProvider.builder().automatic(true).build())
        );
        MongoClientSettings settings = MongoClientSettings.builder()
                .applyConnectionString(new ConnectionString(mongoUri))
                .applicationName(APP_NAME)
                .codecRegistry(pojoCodecRegistry)
                .build();
        return MongoClients.create(settings);
    }

    private static String loadMongoUri() {
        // 首先尝试系统环境变量 (例如 CI/CD 流水线)
        String uri = System.getenv("MONGODB_URI");
        if (uri != null && !uri.isBlank()) {
            return uri;
        }

        // 回退到本地开发的 .env 文件
        Dotenv dotenv = Dotenv.configure().ignoreIfMissing().load();
        uri = dotenv.get("MONGODB_URI");
        if (uri == null || uri.isBlank()) {
            throw new IllegalStateException(
                    "MONGODB_URI 未设置。请将其定义为环境变量 " +
                    "或在项目根目录的 .env 文件中定义。 " +
                    "请参阅 .env.example 获取预期格式。"
            );
        }
        return uri;
    }
}

这里的关键代码行是 PojoCodecProvider.builder().automatic(true).build()。设置 automatic(true) 会告诉驱动程序处理它遇到的任何 POJO,而不仅仅是您显式注册的那些。这就是使整个 POJO 到 BSON 的映射在后续示例中无缝工作的关键。

Java 中的一对多关系是什么?

在面向对象术语中,一对多关系意味着一个对象包含或关联了一个对象集合。BlogPost 拥有多个 Comment 对象。在 Java 中,这通常表示为一个 List

$ java
public class BlogPost {
    private String title;
    private List<Comment> comments;
}

这直观且熟悉。但它如何转化为文档数据库呢?在 MongoDB 中,文档是一种丰富的、层级化的数据结构——类似于 JSON 对象。与关系表不同,单个 MongoDB 文档可以容纳嵌套对象和数组。这种灵活性为您提供了关系世界中所没有的选择。

核心问题变成了:这些 Comment 对象应该存放在 BlogPost 文档内部,还是应该存放在一个带有指向文章指针的独立集合中?

MongoDB 存储文档的方式与关系型数据库有何不同?

在关系型数据库中,数据被规范化为表。blog_posts 表和 comments 表通过 post_id 外键连接。要读取一篇文章及其评论,您需要编写 JOIN 查询。数据库强制执行参照完整性,且模式是固定的。

MongoDB 采取了不同的方法。数据以灵活的 BSON 文档(二进制 JSON)形式存储,可以包含嵌套对象、数组和混合类型。这里没有传统意义上的 JOIN——尽管 MongoDB 的 $lookup 聚合阶段在需要时可以执行类似的操作。

这种灵活性意味着 MongoDB 让您可以针对每个用例选择您的关系策略。两种主要策略是:

  • 嵌入式文档 —— 将相关数据直接存储在父文档内部。
  • 引用 —— 存储一个指向另一个集合中文档的指针(通常是 ObjectId)。

没有哪种方式是普遍“更好”的。正确的选择取决于您的数据访问模式、更新频率和增长预期。让我们来探讨这两种方式。## 模式 1:嵌入式文档 (Embedded Documents)

何时应该使用嵌入?

嵌入意味着将相关数据直接存储在父文档内部。当您读取父文档时,可以一次性获取所有相关数据,无需进行第二次查询。

在以下情况下使用嵌入:

  • 子数据总是与父数据一起读取
  • 子数组的大小有限(例如,每篇文章有少量评论,而不是数百万条日志)。
  • 您不需要独立于父文档来查询或更新子文档。
优点缺点
一次读取即可获取所有内容文档可能变得非常大
对父文档和子文档进行原子更新难以独立查询/更新子文档
使用 POJO 进行简单的 Java 映射16 MB 的文档大小限制

在 Java 中建模嵌入式文档

让我们用嵌入的方式来模拟博客场景。Comment(评论)和 User(文章作者)直接嵌入在 BlogPost 文档中。

这是嵌入的 Comment 类——请注意它没有 _id 字段,因为它不是作为一个独立的文档存在的:

$ java
package com.example.mongodb.relationships.embedded.model;
import org.bson.codecs.pojo.annotations.BsonProperty;
import java.time.Instant;

public class Comment {
    @BsonProperty("author")
    private String author;
    @BsonProperty("body")
    private String body;
    @BsonProperty("posted_at")
    private Instant postedAt;
    public Comment() {}
    public Comment(String author, String body) {
        this.author = author;
        this.body = body;
        this.postedAt = Instant.now();
    }
    // 为简洁起见,省略了 Getter 和 Setter 方法
}

以及代表文章作者的嵌入式 User 类:

$ java
package com.example.mongodb.relationships.embedded.model;
import org.bson.codecs.pojo.annotations.BsonProperty;

public class User {
    @BsonProperty("username")
    private String username;
    @BsonProperty("display_name")
    private String displayName;
    @BsonProperty("email")
    private String email;
    @BsonProperty("bio")
    private String bio;
    public User() {}
    public User(String username, String displayName, String email, String bio) {
        this.username = username;
        this.displayName = displayName;
        this.email = email;
        this.bio = bio;
    }
    // 为简洁起见,省略了 Getter 和 Setter 方法
}

现在是 BlogPost 类本身。它将作者作为嵌入式 User 持有,并将评论作为嵌入的 List<Comment> 持有:

$ java
package com.example.mongodb.relationships.embedded.model;
import org.bson.codecs.pojo.annotations.BsonId;
import org.bson.codecs.pojo.annotations.BsonProperty;
import org.bson.types.ObjectId;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;

public class BlogPost {
    @BsonId
    private ObjectId id;
    @BsonProperty("title")
    private String title;
    @BsonProperty("content")
    private String content;
    @BsonProperty("author")
    private User author;
    @BsonProperty("published_at")
    private Instant publishedAt;
    @BsonProperty("comments")
    private List<Comment> comments = new ArrayList<>();
    public BlogPost() {}
    public BlogPost(String title, String content, User author) {
        this.title = title;
        this.content = content;
        this.author = author;
        this.publishedAt = Instant.now();
    }
    // 为简洁起见,省略了 Getter 和 Setter 方法
}

@BsonProperty 注解将每个 Java 字段映射到对应的 BSON 字段名。@BsonId 注解将 id 字段标记为文档的 _id。每个 POJO 都需要一个无参构造函数,以便 PojoCodecProvider 将文档反序列化为 Java 对象。

插入和查询嵌入式文档

定义好 POJO 后,让我们看看如何插入带有嵌入式评论的博客文章并将其读取出来:

$ java
package com.example.mongodb.relationships.embedded;
import com.example.mongodb.relationships.embedded.model.BlogPost;
import com.example.mongodb.relationships.embedded.model.Comment;
import com.example.mongodb.relationships.embedded.model.User;
import com.mongodb.client.MongoClient;
import com.mongodb.client.MongoCollection;
import com.mongodb.client.MongoDatabase;
import com.mongodb.client.model.Filters;
import com.mongodb.client.model.Updates;
import java.util.Arrays;

public class EmbeddedExample {
    private static final String DATABASE_NAME = "relationships_demo";
    private static final String COLLECTION_NAME = "blog_posts_embedded";
    private final MongoCollection<BlogPost> collection;
    public EmbeddedExample(MongoClient client) {
        MongoDatabase database = client.getDatabase(DATABASE_NAME);
        this.collection = database.getCollection(COLLECTION_NAME, BlogPost.class);
    }

    public void run() {
        // 1. 构建作者作为嵌入式 User 对象
        User alice = new User("alice", "Alice Johnson", "alice@example.com",
                "Java developer and MongoDB enthusiast.");

        // 2. 构建包含嵌入式作者和评论的博客文章
        BlogPost post = new BlogPost(
                "Getting Started with MongoDB",
                "MongoDB is a document database that stores data in flexible, JSON-like documents.",
                alice
        );

        post.setComments(Arrays.asList(
                new Comment("Bob", "Great introduction, very clear!"),
                new Comment("Carol", "I never thought of it that way. Thanks!")
        ));

        // 3. 插入 —— 一个包含作者、内容和评论的文档
        collection.insertOne(post);

        // 4. 获取文章 —— 作者和评论在同一次读取中返回
        BlogPost fetched = collection.find(Filters.eq("_id", post.getId())).first();

        if (fetched != null) {
            System.out.println("Title: " + fetched.getTitle());
            System.out.println("Author: " + fetched.getAuthor().getDisplayName());
            fetched.getComments().forEach(c ->
                    System.out.println("  Comment by " + c.getAuthor() + ": " + c.getBody())
            );
        }

        // 5. 使用 $push 添加新评论 —— 对父文档进行原子更新
        collection.updateOne(
                Filters.eq("_id", post.getId()),
                Updates.push("comments", new Comment("Dave", "Looking forward to the next post!"))
        );

        // 6. 查询:查找所有包含来自 "Bob" 的至少一条评论的文章
        long count = collection.countDocuments(Filters.eq("comments.author", "Bob"));
        System.out.println("Posts with a comment from Bob: " + count);

        // 7. 查询:按嵌入式作者的用户名查找文章
        long alicePosts = collection.countDocuments(Filters.eq("author.username", "alice"));
        System.out.println("Posts authored by 'alice': " + alicePosts);
    }
}

生成的 MongoDB 文档如下所示:

$ cat
{
  "_id": ObjectId("..."),
  "title": "Getting Started with MongoDB",
  "content": "MongoDB is a document database...",
  "author": {
    "username": "alice",
    "display_name": "Alice Johnson",
    "email": "alice@example.com",
    "bio": "Java developer and MongoDB enthusiast."
  },
  "published_at": ISODate("2025-01-01T00:00:00Z"),
  "comments": [
    { "author": "Bob",   "body": "Great introduction, very clear!",          "posted_at": ISODate("...") },
    { "author": "Carol", "body": "I never thought of it that way. Thanks!",  "posted_at": ISODate("...") }
  ]
}

所有内容——文章正文、作者资料和所有评论——都存在于一个文档中。一次 find() 调用即可全部返回。添加新评论是对父文档的原子 $push 操作,无需触及第二个集合。

您还可以使用点号标记法(dot notation)查询嵌入的数据。Filters.eq("comments.author", "Bob") 可以找到所有包含至少一条由 Bob 撰写的评论的文章,而 Filters.eq("author.username", "alice") 则可以按嵌入式作者的用户名进行筛选。## 模式 2:引用 (References)

何时应该使用引用?

引用意味着存储一个指针(通常是 ObjectId),指向位于另一个集合中的文档。要组装完整的对象,你需要执行多次查询。

在以下情况下应使用引用:

  • 子文档数量巨大或不确定(例如,一篇热门文章可能有成千上万条评论)。
  • 子文档需要独立于父文档进行查询或更新
  • 多个父文档可能引用同一个子文档(例如,一位用户撰写了许多文章和评论)。
优点缺点
保持文档较小且可预测需要多次读取(默认情况下不支持 JOIN)
子文档可以独立查询组装对象的 Java 代码更复杂
可扩展至大型、不断增长的数据集默认情况下不支持跨文档的原子更新

在 Java 中对引用进行建模

在引用式方法中,用户、博客文章和评论各自存放在自己的集合中。BlogPost 存储指向 users 集合中作者的 ObjectId,以及指向 comments 集合中评论的 ObjectId 列表。

以下是 User 类——现在是一个拥有自己 _id 的独立文档:

$ java
package com.example.mongodb.relationships.referenced.model;
import org.bson.codecs.pojo.annotations.BsonId;
import org.bson.codecs.pojo.annotations.BsonProperty;
import org.bson.types.ObjectId;
import java.time.Instant;

public class User {
    @BsonId
    private ObjectId id;
    @BsonProperty("username")
    private String username;
    @BsonProperty("display_name")
    private String displayName;
    @BsonProperty("email")
    private String email;
    @BsonProperty("bio")
    private String bio;
    @BsonProperty("joined_at")
    private Instant joinedAt;
    public User() {}
    public User(String username, String displayName, String email, String bio) {
        this.username = username;
        this.displayName = displayName;
        this.email = email;
        this.bio = bio;
        this.joinedAt = Instant.now();
    }
    // 为简洁起见,省略了 Getter 和 Setter 方法
}

Comment 也成为了一个独立文档,通过 ObjectId 同时引用文章和作者:

$ java
package com.example.mongodb.relationships.referenced.model;
import org.bson.codecs.pojo.annotations.BsonId;
import org.bson.codecs.pojo.annotations.BsonProperty;
import org.bson.types.ObjectId;
import java.time.Instant;

public class Comment {
    @BsonId
    private ObjectId id;
    @BsonProperty("post_id")
    private ObjectId postId;
    @BsonProperty("author_id")
    private ObjectId authorId;
    @BsonProperty("body")
    private String body;
    @BsonProperty("posted_at")
    private Instant postedAt;
    public Comment() {}
    public Comment(ObjectId postId, ObjectId authorId, String body) {
        this.postId = postId;
        this.authorId = authorId;
        this.body = body;
        this.postedAt = Instant.now();
    }
    // 为简洁起见,省略了 Getter 和 Setter 方法
}

BlogPost 持有的是引用,而不是嵌入的对象:

$ java
package com.example.mongodb.relationships.referenced.model;
import org.bson.codecs.pojo.annotations.BsonId;
import org.bson.codecs.pojo.annotations.BsonProperty;
import org.bson.types.ObjectId;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;

public class BlogPost {
    @BsonId
    private ObjectId id;
    @BsonProperty("title")
    private String title;
    @BsonProperty("content")
    private String content;
    @BsonProperty("author_id")
    private ObjectId authorId;
    @BsonProperty("published_at")
    private Instant publishedAt;
    @BsonProperty("comment_ids")
    private List<ObjectId> commentIds = new ArrayList<>();
    public BlogPost() {}
    public BlogPost(String title, String content, ObjectId authorId) {
        this.title = title;
        this.content = content;
        this.authorId = authorId;
        this.publishedAt = Instant.now();
    }
    // 为简洁起见,省略了 Getter 和 Setter 方法
}

注意区别:我们不再使用 private User authorprivate List<Comment> comments,而是使用了 private ObjectId authorIdprivate List<ObjectId> commentIds。数据本身存储在其他地方。

插入与查询被引用的文档

使用引用需要更多的步骤。你需要将文档插入到不同的集合中,维护引用列表,并通过额外的查询来解析这些引用:

$ java
package com.example.mongodb.relationships.referenced;
import com.example.mongodb.relationships.referenced.model.BlogPost;
import com.example.mongodb.relationships.referenced.model.Comment;
import com.example.mongodb.relationships.referenced.model.User;
import com.mongodb.client.MongoClient;
import com.mongodb.client.MongoCollection;
import com.mongodb.client.MongoDatabase;
import com.mongodb.client.model.Filters;
import com.mongodb.client.model.Updates;
import org.bson.types.ObjectId;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;

public class ReferencedExample {
    private static final String DATABASE_NAME = "relationships_demo";
    private final MongoCollection<User> usersCollection;
    private final MongoCollection<BlogPost> postsCollection;
    private final MongoCollection<Comment> commentsCollection;
    public ReferencedExample(MongoClient client) {
        MongoDatabase database = client.getDatabase(DATABASE_NAME);
        this.usersCollection = database.getCollection("users", User.class);
        this.postsCollection = database.getCollection("blog_posts_referenced", BlogPost.class);
        this.commentsCollection = database.getCollection("comments", Comment.class);
    }

    public void run() {
        // 1. 将用户插入 users 集合
        User alice = new User("alice", "Alice Johnson", "alice@example.com",
                "Java developer and MongoDB enthusiast.");
        User bob = new User("bob", "Bob Smith", "bob@example.com",
                "Backend engineer who loves databases.");
        User carol = new User("carol", "Carol Williams", "carol@example.com",
                "Full-stack developer and tech blogger.");
        usersCollection.insertMany(Arrays.asList(alice, bob, carol));

        // 2. 插入博客文章,并通过 ObjectId 引用 Alice 作为作者
        BlogPost post = new BlogPost(
                "Understanding MongoDB Indexes",
                "Indexes support efficient execution of queries in MongoDB.",
                alice.getId()
        );

        postsCollection.insertOne(post);
        ObjectId postId = post.getId();

        // 3. 插入评论,引用文章及其各自的作者
        List<Comment> comments = Arrays.asList(
                new Comment(postId, bob.getId(), "The index on _id is automatic, right?"),
                new Comment(postId, carol.getId(), "What about compound indexes? Any tips?")
        );

        commentsCollection.insertMany(comments);
        // 收集 MongoDB 在插入期间分配的 ObjectIds
        List<ObjectId> commentIds = new ArrayList<>();
        comments.forEach(c -> commentIds.add(c.getId()));

        // 4. 更新文章以存储引用列表
        postsCollection.updateOne(
                Filters.eq("_id", postId),
                Updates.set("comment_ids", commentIds)
        );

        // 5. 多步获取:加载文章,然后解析作者和评论
        BlogPost fetchedPost = postsCollection.find(Filters.eq("_id", postId)).first();

        if (fetchedPost != null) {
            // 从 users 集合解析文章作者
            User postAuthor = usersCollection
                    .find(Filters.eq("_id", fetchedPost.getAuthorId()))
                    .first();

            // 通过 ObjectId 解析评论
            List<Comment> resolvedComments = commentsCollection
                    .find(Filters.in("_id", fetchedPost.getCommentIds()))
                    .into(new ArrayList<>());

            // 在单次查询中批量加载所有评论作者
            List<ObjectId> commentAuthorIds = resolvedComments.stream()
                    .map(Comment::getAuthorId)
                    .distinct()
                    .collect(Collectors.toList());

            Map<ObjectId, User> commentAuthors = usersCollection
                    .find(Filters.in("_id", commentAuthorIds))
                    .into(new ArrayList<>())
                    .stream()
                    .collect(Collectors.toMap(User::getId, Function.identity()));

            // 打印组装好的对象图
            System.out.println("Title: " + fetchedPost.getTitle());
            if (postAuthor != null) {
                System.out.println("Author: " + postAuthor.getDisplayName());
            }

            resolvedComments.forEach(c -> {
                User commentAuthor = commentAuthors.get(c.getAuthorId());
                String authorName = commentAuthor != null
                        ? commentAuthor.getDisplayName() : "Unknown";
                System.out.println("  Comment by " + authorName + ": " + c.getBody());
            });
        }

        // 6. 独立查询评论 — 这是引用的关键优势
        commentsCollection
                .find(Filters.eq("author_id", bob.getId()))
                .forEach(c -> System.out.println("Bob's comment: " + c.getBody()));

        // 7. 查询特定作者的所有文章
        long alicePosts = postsCollection
                .countDocuments(Filters.eq("author_id", alice.getId()));
        System.out.println("Posts authored by Alice: " + alicePosts);
    }
}

最终生成的 MongoDB 文档分布在三个集合中:

$ node
// users 集合
[{
  "_id": ObjectId("uuu"),
  "username": "alice",
  "display_name": "Alice Johnson",
  "email": "alice@example.com",
  "bio": "Java developer and MongoDB enthusiast.",
  "joined_at": ISODate("...")
},
{
  "_id": ObjectId("uuu2"),
  "username": "bob",
  "display_name": "Bob Smith",
  "email": "bob@example.com",
  "bio": "Java developer and MongoDB enthusiast.",
  "joined_at": ISODate("...")
}]

// blog_posts_referenced 集合
[{
  "_id": ObjectId("aaa"),
  "title": "Understanding MongoDB Indexes",
  "content": "Indexes support efficient execution of queries...",
  "author_id": ObjectId("uuu"),
  "published_at": ISODate("..."),
  "comment_ids": [ObjectId("bbb"), ObjectId("ccc")]
}]

// comments 集合
[{
  "_id": ObjectId("bbb"),
  "post_id": ObjectId("aaa"),
  "author_id": ObjectId("uuu2"),
  "body": "The index on _id is automatic, right?",
  "posted_at": ISODate("...")
}]

代码中可以明显看出这种权衡。组装完整的对象图需要先获取文章,然后获取作者,接着是评论,最后是评论作者。这涉及多次网络往返。不过,Filters.in() 操作符允许我们高效地批量加载相关文档——注意我们是如何收集所有唯一的 commentAuthorIds 并通过单次查询解析它们的,而不是为每条评论执行一次查询。

关键优势体现在第 6 步:你可以直接查询评论集合。查找特定用户的所有评论,或跨所有文章查找最新评论,只需一个简单的查询——无需扫描每篇博客文章文档中的嵌入数组。

注意: 对于希望在服务器端解析引用的场景,MongoDB 的 $lookup 聚合阶段可以在集合之间执行类似左外连接的操作。这对于分析查询或仪表板非常有用,但对于大多数应用程序读取操作,此处展示的多步方法能让你更精确地控制加载内容和加载时机。## MongoDB 模式设计的最佳实践

在了解了这两种模式的实际应用后,以下原则应指导您的模式设计决策。

根据查询模式而非数据结构进行设计

这是 MongoDB 模式设计中最重要的准则。不要从绘制实体关系图并进行规范化开始。相反,请问自己:我的应用程序最常提出什么问题? 如果您的应用程序总是显示一篇博客文章及其评论,那么嵌入式设计会让读取速度变快。如果您的应用程序有一个单独的“用户所有评论”页面,引用(Reference)则能为您提供直接访问途径。

避免无限制的数组

当数组具有可预测的上限时,嵌入式设计效果很好。一篇博客文章有 5 到 50 条评论?嵌入式完全没问题。但如果是可能积累数十万条反应的社交媒体帖子呢?该数组将无限增长,最终触及 MongoDB 16 MB 的文档大小限制。当列表可能无限增长时,请使用引用。

考虑原子性

MongoDB 保证单文档级别的原子更新。当您将评论嵌入到博客文章中时,更新文章并添加评论是一个单一的原子操作。使用引用时,跨多个集合更新文档默认不是原子的。如果您需要父子文档之间的原子更新,嵌入式设计开箱即用地提供了这种保证。对于跨集合的原子性,您需要使用多文档事务

考虑子集模式 (Subset Pattern)

如果您需要嵌入式的读取性能,但数据集太大而无法完全嵌入,该怎么办?子集模式提供了一个折中方案:嵌入相关数据的一个子集以实现快速访问,同时将完整数据集保留在单独的集合中。

以我们的博客示例为例,您可以仅在文章内嵌入最近的三条评论以实现快速渲染,同时将所有评论存储在单独的评论集合中,供“查看所有评论”页面使用。

以下是子集模式在 Java 中的简化视图。首先是快照类(Snapshot classes)——为显示而优化的轻量级数据副本:

$ java
public class AuthorSnapshot {
    @BsonId
    private ObjectId id;
    @BsonProperty("username")
    private String username;
    @BsonProperty("display_name")
    private String displayName;
    @BsonProperty("profile_picture_url")
    private String profilePictureUrl;
    public AuthorSnapshot() {}
    public static AuthorSnapshot fromUser(User user) {
        return new AuthorSnapshot(
                user.getId(),
                user.getUsername(),
                user.getDisplayName(),
                user.getProfilePictureUrl()
        );
    }
    // 为简洁起见,省略了 Getter 和 Setter
}

public class CommentSnapshot {
    @BsonId
    private ObjectId id;
    @BsonProperty("author")
    private String author;
    @BsonProperty("body")
    private String body;
    @BsonProperty("posted_at")
    private Instant postedAt;
    public CommentSnapshot() {}
    public static CommentSnapshot fromComment(Comment comment, String authorDisplayName) {
        return new CommentSnapshot(
                comment.getId(),
                authorDisplayName,
                comment.getBody(),
                comment.getPostedAt()
        );
    }
    // 为简洁起见,省略了 Getter 和 Setter
}

以及结合了这两者的 BlogPost

$ java
public class BlogPost {
    public static final int LATEST_COMMENTS_LIMIT = 3;
    @BsonId
    private ObjectId id;
    @BsonProperty("title")
    private String title;
    @BsonProperty("content")
    private String content;
    @BsonProperty("author")
    private AuthorSnapshot author;
    @BsonProperty("published_at")
    private Instant publishedAt;
    @BsonProperty("latest_comments")
    private List<CommentSnapshot> latestComments = new ArrayList<>();
    @BsonProperty("comment_count")
    private int commentCount;
    // 为简洁起见,省略了构造函数、Getter 和 Setter
}

关键的维护操作发生在添加新评论时。您将完整的评论插入评论集合,然后使用带有 $slice$push 原子地更新文章,以仅保留最近的条目:

$ java
import com.mongodb.client.model.Filters;
import com.mongodb.client.model.PushOptions;
import com.mongodb.client.model.Updates;

private void addComment(ObjectId postId, User author, String body) {
    // 1. 将规范评论插入评论集合
    Comment comment = new Comment(postId, author.getId(), body);
    commentsCollection.insertOne(comment);
    // 2. 构建用于嵌入的轻量级快照
    CommentSnapshot snapshot = CommentSnapshot.fromComment(comment, author.getDisplayName());
    // 3. 在单次往返中更新文章:$push 配合 $slice 限制嵌入数组的大小,
    //    $inc 保持计数器同步——这两个字段的修改在 updateOne 调用中是原子的。
    //    请注意,步骤 1 中的 insertOne 和此处的 updateOne 是两个独立的操作,
    //    整体上并非原子操作。
    postsCollection.updateOne(
            Filters.eq("_id", postId),
            Updates.combine(
                    Updates.pushEach(
                            "latest_comments",
                            Arrays.asList(snapshot),
                            new PushOptions().slice(-BlogPost.LATEST_COMMENTS_LIMIT)
                    ),
                    Updates.inc("comment_count", 1)
            )
    );
}

最终的文档为您提供了两全其美的方案——最常见的视图只需一次读取,而在需要时,完整的数据集可在单独的集合中获取:

$ cat
{
  "_id": ObjectId("ppp"),
  "title": "The Subset Pattern in Practice",
  "content": "The Subset Pattern is a schema design strategy...",
  "author": {
    "_id": ObjectId("uuu"),
    "username": "alice",
    "display_name": "Alice Johnson",
    "profile_picture_url": "https://cdn.example.com/avatars/alice.jpg"
  },
  "published_at": ISODate("..."),
  "latest_comments": [
    { "_id": ObjectId("c3"), "author": "Dave Brown",      "body": "This is exactly what I was looking for.",      "posted_at": ISODate("...") },
    { "_id": ObjectId("c4"), "author": "Eve Davis",       "body": "Could you write a follow-up on the Bucket Pattern?", "posted_at": ISODate("...") },
    { "_id": ObjectId("c5"), "author": "Bob Smith",       "body": "I refactored my schema using this — works great!",   "posted_at": ISODate("...") }
  ],
  "comment_count": 5
}

AuthorSnapshot 在显示字段旁携带了用户的 _id,因此它既充当引用,也充当读取优化的缓存。当读者导航到完整的作者个人资料时,您可以使用该 _id 在用户集合中进行解析。comment_count 字段允许 UI 显示“查看全部 5 条评论”而无需执行计数查询。

权衡显而易见:如果用户更改了他们的显示名称,您需要更新他们出现的所有帖子中的嵌入式快照。对于个人资料更改频率远低于帖子读取频率的博客平台而言,这通常是一个极好的权衡。

保持文档在 16 MB 限制内

这是 MongoDB 对文档大小的硬性约束。如果您的嵌入式数组可能使文档超过此限制,请使用引用。子集模式在这里特别有用:您可以在最常见的视图中获得嵌入的读取性能,而完整数据集则安全地保存在其自己的集合中。

为您的 Java 应用选择合适的关联模型

选择嵌入式文档还是引用,取决于您的应用程序访问模式:

  • 选择嵌入:当相关数据总是与父文档一起读取、数组大小有限,且您重视读取性能和原子更新时。
  • 选择引用:当相关数据非常多或大小不定、需要独立查询或更新,或者在多个父文档之间共享时。引用使文档保持较小且可预测,但代价是需要额外的查询。
  • 选择子集模式:当您需要嵌入的读取性能,但数据集太大或变动太频繁而无法完全嵌入时。嵌入精选的子集以实现快速访问;引用完整数据集以确保完整性。

Java POJO 模型可以清晰地映射到这三种模式。无论您的字段是嵌入式对象、ObjectId 引用还是两者的混合,PojoCodecProvider 都会自动处理序列化和反序列化。MongoDB 的模式设计应始终由您的应用程序查询模式驱动,而 Java 的类型系统使表达所需的文档结构变得非常容易。

所有三种模式的完整工作代码可在 GitHub 上获取。要使用您自己的数据进行试验,请注册一个免费的 MongoDB Atlas 集群,克隆存储库,在 .env 文件中设置您的连接字符串,然后运行:

$ bash
mvn compile exec:java

常见问题解答

我可以在同一个 MongoDB 模式中混合使用嵌入式和引用文档吗?

可以,而且通常建议这样做。子集模式就是一个完美的例子:您嵌入最近的评论以供快速显示,同时将完整的评论历史记录作为引用存储在单独的集合中。MongoDB 的模式设计本质上是灵活的,针对每种关系混合使用策略是一种常见且推荐的做法。

如何在 Spring Data MongoDB 中处理一对多关系?

Spring Data MongoDB 开箱即用地提供了 @DBRef 和嵌入式文档支持。无论您使用什么框架,这里介绍的模式设计原则(嵌入式文档、引用和子集模式)都适用。本教程使用核心 Java 同步驱动程序来解释底层机制,但这些概念可以直接转换到 Spring Data、Quarkus 和 Micronaut 中。

这适用于 MongoDB Java 响应式流 (Reactive Streams) 驱动程序吗?

本教程中介绍的模式设计原则无论您使用哪种驱动程序或框架都是通用的。MongoDB 官方的 Java 响应式流驱动程序通过异步、非阻塞 API 提供相同的操作。社区集成(如 Spring Data MongoDBQuarkus MongoDBMicronaut MongoDB)也建立在这些相同的底层概念之上,同时增加了框架特定的便利性。

如果我的嵌入式数组变得太大怎么办?

MongoDB 文档有 16 MB 的大小限制。如果您的数组可能无限制地增长(如事件日志、聊天记录、物联网传感器读数),您应该使用引用而不是嵌入。如果您仍然希望为最近的一小部分数据获得快速读取性能,子集模式提供了一个折中方案。

嵌入式文档和引用文档之间有性能差异吗?

是的。嵌入式文档在一次读取操作中获取,这使得它们对于始终需要将子数据与父数据一起使用的读取密集型用例更快。引用至少需要两次读取,这会增加延迟,但它们使文档更小,且更易于单独更新。

我需要使用 MongoDB 引用手动管理参照完整性吗?

是的。与 SQL 外键不同,MongoDB 不会强制执行 ObjectId 引用的参照完整性。您的应用程序代码(通常是 Java 服务层)负责保持引用的一致性。这意味着处理级联删除、孤立引用以及确保 ID 指向现有文档等工作都由您自己负责。