MongoDB作为非关系型文档数据库,摒弃了MySQL的外键关联机制,通过内嵌文档 和引用关联两种方式实现实体间关系建模。本文将从零讲解MongoDB核心的一对一、一对多、多对多文档关系,结合真实业务场景,提供完整的新增、查询实操代码,同时总结三种关系的建模选型规则,帮助开发者彻底吃透MongoDB关联业务开发。
目录
[2.1 内嵌文档(嵌入式)](#2.1 内嵌文档(嵌入式))
[2.2 引用关联(引用式)](#2.2 引用关联(引用式))
[3.1 业务场景说明](#3.1 业务场景说明)
[3.2 一对一建模规则](#3.2 一对一建模规则)
[3.3 实战:数据新增](#3.3 实战:数据新增)
[3.4 实战:关联查询](#3.4 实战:关联查询)
[3.5 一对一场景选型总结](#3.5 一对一场景选型总结)
[4.1 业务场景说明](#4.1 业务场景说明)
[4.2 一对多建模规则](#4.2 一对多建模规则)
[4.3 实战:数据新增](#4.3 实战:数据新增)
[4.4 实战:一对多关联查询(核心)](#4.4 实战:一对多关联查询(核心))
[4.5 特殊场景:小数量一对多内嵌](#4.5 特殊场景:小数量一对多内嵌)
[5.1 业务场景说明](#5.1 业务场景说明)
[5.2 MongoDB多对多建模方案](#5.2 MongoDB多对多建模方案)
[5.3 方案一:数组引用(轻量多对多)](#5.3 方案一:数组引用(轻量多对多))
[5.3.1 数据新增](#5.3.1 数据新增)
[5.3.2 多对多关联查询](#5.3.2 多对多关联查询)
[5.4 方案二:中间集合(企业级标准多对多)](#5.4 方案二:中间集合(企业级标准多对多))
[5.4.1 结构设计](#5.4.1 结构设计)
[5.4.2 数据新增](#5.4.2 数据新增)
[5.4.3 多层联表查询(完整多对多结果)](#5.4.3 多层联表查询(完整多对多结果))
[5.5 多对多方案选型](#5.5 多对多方案选型)
[7.1 什么时候不用内嵌?](#7.1 什么时候不用内嵌?)
[7.2 lookup查询为空怎么办?](#7.2 lookup查询为空怎么办?)
[7.3 MongoDB需要手动维护关联关系吗?](#7.3 MongoDB需要手动维护关联关系吗?)
[7.4 多对多开发避坑](#7.4 多对多开发避坑)
一、前言
在关系型数据库(MySQL)中,实体关联依靠主键、外键 实现,规则严格且固定。而MongoDB是文档型数据库,没有外键约束,所有数据以BSON文档形式存储,实体之间的关联关系完全由开发者通过内嵌(嵌入文档) 和**引用(存储ObjectId)**手动建模。
日常业务中99%的关联场景都可归纳为 一对一 、一对多 、多对多 三种关系。本文聚焦这三种核心关系,通过真实业务案例+可运行代码,手把手实现数据新增、关联查询,解决新手建模混乱、关联查询不会写的问题。
二、MongoDB两种核心建模方式
在正式讲解关系前,先明确MongoDB唯一的两种关联建模方式,所有一对一、一对多、多对多场景均基于这两种方式实现:
2.1 内嵌文档(嵌入式)
将子文档直接嵌套在父文档内部,数据存储在同一个集合、同一个文档中,无需跨集合查询。
优点:查询效率极高,一次查询即可获取所有关联数据,支持原子操作。
缺点:数据冗余,文档体积会变大,不适合子数据频繁新增、数量不固定的场景。
2.2 引用关联(引用式)
父子数据分属不同集合,父文档中存储子文档的 ObjectId(MongoDB文档唯一主键),通过ID关联两个集合的数据。
优点:数据解耦、无冗余,支持子数据无限扩展,适合动态新增的场景。
缺点:需要跨集合关联查询,相比内嵌方式多一次查询开销。
三、一对一关系建模与实战
3.1 业务场景说明
场景 :用户 - 用户详情
一个用户对应唯一的个人详情(身份证、性别、年龄、住址),一个详情仅归属一个用户,数据一一对应,且详情数据固定、极少单独查询,完美适配一对一内嵌建模。
3.2 一对一建模规则
优先使用内嵌文档,将附属、唯一的子文档直接嵌入主文档,无需拆分集合。
3.3 实战:数据新增
创建 user 集合,将用户详情内嵌到用户文档中,实现一对一数据新增:
bash
// 切换数据库
use test_db;
// 一对一新增:用户+唯一用户详情(内嵌文档)
db.user.insertOne({
username: "张三",
phone: "13800138000",
// 内嵌一对一详情文档
user_info: {
gender: "男",
age: 25,
id_card: "110101199901011234",
address: "北京市朝阳区"
}
});
// 批量新增一对一数据
db.user.insertMany([
{
username: "李四",
phone: "13900139000",
user_info: {
gender: "女",
age: 23,
id_card: "310101200102023456",
address: "上海市浦东新区"
}
},
{
username: "王五",
phone: "13700137000",
user_info: {
gender: "男",
age: 28,
id_card: "440101199605056789",
address: "广州市天河区"
}
}
]);
3.4 实战:关联查询
一对一内嵌文档查询分为:整体查询、精准匹配内嵌字段查询、指定字段返回查询。
bash
// 1. 查询所有用户及内嵌的详情信息
db.user.find();
// 2. 精准查询:查询年龄25岁的用户(匹配内嵌文档字段)
db.user.find({ "user_info.age": 25 });
// 3. 筛选返回字段:只展示用户名、年龄、地址,隐藏_id
db.user.find(
{},
{ _id: 0, username: 1, "user_info.age": 1, "user_info.address": 1 }
);
// 4. 单条精准查询
db.user.findOne({ username: "张三" });
3.5 一对一场景选型总结
✅ 适用内嵌:数据唯一、固定、极少修改、跟随主数据一起查询(用户-详情、商品-参数)
❌ 不适用内嵌:子数据需单独查询、频繁修改(此类场景可改用引用式一对一)
四、一对多关系建模与实战
4.1 业务场景说明
场景 :用户 - 订单
一个用户可以创建多个订单,一个订单仅归属一个用户,是最经典的一对多 业务场景。订单数据动态新增、数量不固定,适合引用式建模(拆分双集合,通过用户ID关联)。
4.2 一对多建模规则
主集合(用户)、子集合(订单)拆分存储,子文档中存储主文档的 ObjectId 实现关联,通过 $lookup 实现联表查询。
4.3 实战:数据新增
分两步新增:先新增主数据(用户),再新增子数据(订单)并绑定用户ID:
bash
// 1. 新增主数据:用户(主集合 user)
// 保存返回结果,获取用户ObjectId,用于绑定订单
let userRes = db.user.insertOne({
username: "赵六",
phone: "13600136000"
});
// 获取新增用户的ID
let userId = userRes.insertedId;
// 2. 新增子数据:订单(子集合 order),通过user_id关联用户
db.order.insertMany([
{
order_no: "OD20260708001",
price: 99.9,
goods_name: "无线鼠标",
create_time: new Date(),
user_id: userId // 关联用户ID,实现一对多绑定
},
{
order_no: "OD20260708002",
price: 299,
goods_name: "机械键盘",
create_time: new Date(),
user_id: userId
}
]);
// 批量新增其他用户订单数据
db.order.insertMany([
{
order_no: "OD20260708003",
price: 199,
goods_name: "蓝牙耳机",
create_time: new Date(),
// 绑定上文李四用户的ID(可自行替换对应ObjectId)
user_id: ObjectId("xxx")
}
]);
4.4 实战:一对多关联查询(核心)
MongoDB 通过 $lookup 聚合管道实现跨集合一对多关联查询,等价于MySQL的JOIN联表查询,语法固定:
$lookup参数说明:
- from:需要关联的子集合名称
- localField:主集合的关联字段
- foreignField:子集合的关联字段
- as:关联查询结果的别名(数组格式,存储多条子数据)
bash
// 关联查询:查询所有用户及其对应的所有订单(一对多完整关联)
db.user.aggregate([
{
$lookup: {
from: "order", // 关联订单集合
localField: "_id", // 用户集合的关联字段(主键ID)
foreignField: "user_id", // 订单集合的关联字段(外键user_id)
as: "order_list" // 订单数据结果存入order_list数组
}
}
]);
// 精准查询:查询【赵六】的所有订单信息
db.user.aggregate([
// 第一步:筛选指定用户
{ $match: { username: "赵六" } },
// 第二步:关联查询订单
{
$lookup: {
from: "order",
localField: "_id",
foreignField: "user_id",
as: "order_list"
}
}
]);
// 筛选过滤:只查询订单金额大于100元的用户订单
db.user.aggregate([
{ $match: { username: "赵六" } },
{
$lookup: {
from: "order",
localField: "_id",
foreignField: "user_id",
as: "order_list"
}
},
// 过滤订单数组数据
{
$addFields: {
order_list: {
$filter: {
input: "$order_list",
as: "item",
cond: { $gte: ["$$item.price", 100] }
}
}
}
}
]);
4.5 特殊场景:小数量一对多内嵌
若子数据数量极少、固定不变(如用户常用地址、商品标签),也可使用数组内嵌实现一对多,无需拆分集合,查询效率更高:
bash
// 数组内嵌一对多:用户多个常用地址
db.user.insertOne({
username: "孙七",
phone: "13500135000",
// 内嵌多个地址,一对多数组存储
address_list: [
{ type: "家庭地址", addr: "成都市锦江区" },
{ type: "公司地址", addr: "成都市高新区" }
]
});
// 查询内嵌数组地址
db.user.find({ "address_list.type": "家庭地址" });
五、多对多关系建模与实战(重点补充)
5.1 业务场景说明
场景 :学生 <=> 课程
一名学生可以选修多门课程,一门课程可以被多名学生选择。双向一对多,构成多对多关系。
其他常见多对多场景:
1)用户 <=> 角色(一个用户多个角色、一个角色多个用户)
2)商品 <=> 标签(一个商品多标签、一个标签多商品)
3)文章 <=> 分类
5.2 MongoDB多对多建模方案
MongoDB 没有原生多对多,行业标准两种方案:
方案A:数组引用(简单、推荐中小数据量)
一方文档中用数组存储另一方的 ObjectId,结构简单、查询方便。缺点:不适合频繁新增、超大数量关联。
方案B:中间关联集合(标准、企业级)
单独建立一张「中间关联集合」,只存储双方ID,完全对标MySQL多对多中间表。适合大数据量、频繁增删关联关系、需要附带关联字段(选课时间、分数)。
5.3 方案一:数组引用(轻量多对多)
5.3.1 数据新增
bash
use test_db;
// 1. 创建课程集合
db.course.insertMany([
{ name: "Java开发", teacher: "张老师" },
{ name: "MongoDB数据库", teacher: "李老师" },
{ name: "Python爬虫", teacher: "王老师" }
]);
// 2. 获取课程ID(实际开发可直接写死ID)
let javaId = db.course.findOne({name:"Java开发"})._id;
let mongoId = db.course.findOne({name:"MongoDB数据库"})._id;
// 3. 学生文档用数组引用多门课程(多对多核心)
db.student.insertMany([
{
stu_name: "小明",
age: 20,
// 选修多门课程
course_ids: [javaId, mongoId]
},
{
stu_name: "小红",
age: 19,
course_ids: [mongoId]
}
]);
5.3.2 多对多关联查询
bash
// 查询学生对应的所有选修课程
db.student.aggregate([
{
$lookup:{
from:"course",
localField:"course_ids",
foreignField:"_id",
as:"course_list"
}
}
]);
原理:localField 传入数组时,$lookup 会自动匹配数组中所有ID,天然实现多对多匹配。
5.4 方案二:中间集合(企业级标准多对多)
适用:关联关系频繁变动、数据量大、需要记录关联附加信息(选课时间、成绩、状态)。
5.4.1 结构设计
- student:学生主集合
- course:课程主集合
- stu_course_rel:中间关联集合(存储 stu_id、course_id、score、create_time)
5.4.2 数据新增
bash
use test_db;
// 1. 新增学生
let stu = db.student.insertOne({stu_name:"小李",age:21});
let stuId = stu.insertedId;
// 2. 新增课程
let cs = db.course.insertOne({name:"Go语言开发",teacher:"陈老师"});
let csId = cs.insertedId;
// 3. 新增多对多关联关系(中间表)
db.stu_course_rel.insertOne({
stu_id: stuId,
course_id: csId,
score: 0,
select_time: new Date()
});
5.4.3 多层联表查询(完整多对多结果)
bash
// 查询所有学生 + 所选课程 + 选课信息
db.student.aggregate([
// 第一步:关联中间表
{
$lookup: {
from: "stu_course_rel",
localField: "_id",
foreignField: "stu_id",
as: "rel_list"
}
},
// 第二步:拆分数组,扁平化数据
{ $unwind: "$rel_list" },
// 第三步:关联真实课程数据
{
$lookup: {
from: "course",
localField: "rel_list.course_id",
foreignField: "_id",
as: "course_info"
}
},
{ $unwind: "$course_info" }
]);
5.5 多对多方案选型
✅ 数组引用 :简单、查询快、适合标签、权限角色、固定关联场景
✅ 中间集合:解耦、可扩展、可加字段、适合选课、订单商品、复杂权限系统
六、三种关系建模选型对照表(全新更新)
|-------------|--------|---------------------------|-----------------|-----------------------|
| 关系类型 | 建模方式 | 适用场景 | 优点 | 缺点 |
| 一对一 | 内嵌文档 | 数据唯一、固定、跟随主数据查询,极少单独操作子数据 | 查询快、无跨集合操作、原子性强 | 轻微数据冗余,子数据无法独立查询 |
| 一对多(少量子数据) | 数组内嵌 | 子数据数量固定、不持续新增,如标签、地址 | 无需联表,一次查询获取全部数据 | 子数据过多会导致文档臃肿 |
| 一对多(大量子数据) | ID引用关联 | 子数据动态新增、数量不固定,如订单、评论 | 数据解耦、无冗余、扩展性强 | 需要$lookup联表查询,有轻微性能开销 |
| 多对多(简单) | ID数组引用 | 关联关系稳定、不频繁增减,如角色权限、商品标签 | 结构简单、查询高效、无需中间表 | 无法存储关联附加字段,频繁变更性能差 |
| 多对多(复杂/企业级) | 中间集合关联 | 关联关系频繁变动、需要附加字段(时间、分数、状态) | 高度解耦、可扩展、适配大数据量 | 查询层级多,需要多次lookup |
七、常见问题总结
7.1 什么时候不用内嵌?
子数据需要单独增删改查、子数据数量无限增长、主文档频繁更新时,禁止内嵌,优先使用引用关联。
7.2 $lookup查询为空怎么办?
- 检查关联字段类型是否一致(常见错误:主键是ObjectId,外键存的是字符串);2. 检查字段名是否拼写错误;3. 确认子数据已绑定对应主ID。
7.3 MongoDB需要手动维护关联关系吗?
需要!MongoDB无外键约束,删除主数据后,子数据不会自动删除,业务代码中需手动处理关联数据的同步删除、更新。
7.4 多对多开发避坑
1)数组引用不适合上万条关联数据,会造成单文档过大;
2)需要排序、分页、筛选关联关系时,优先使用中间集合;
3)多对多聚合查询记得搭配 $unwind 扁平化数组,否则数据层级嵌套过深。
八、结尾
MongoDB的文档关系建模核心是场景优先 :一对一、静态少量一对多优先内嵌;动态海量一对多优先ID引用;简单多对多用数组引用,复杂多对多用中间集合。掌握 内嵌文档 、$lookup关联查询 、中间集合建模,即可覆盖99%的日常业务关联场景。