一对多、多对多关系建模
关系型数据库与NoSQL的差异
关系型数据库通过外键和连接表处理一对多、多对多关系。MongoDB作为文档数据库,使用嵌套文档和引用两种方式建模关系。嵌入式文档将关联数据存储在单一文档中,引用则通过ObjectId
建立文档间关联。选择哪种方式取决于查询模式和更新频率。
一对多关系建模
嵌入式文档方案
当子文档数量有限且频繁与父文档一起查询时,嵌入式是最佳选择。例如博客系统中的文章与评论:
// 文章文档
{
_id: ObjectId("5f8d8a7b2f4d4e1d2c3b4a5e"),
title: "MongoDB关系建模",
content: "...",
comments: [
{
author: "用户A",
text: "好文章",
createdAt: ISODate("2023-05-01T10:00:00Z")
},
{
author: "用户B",
text: "受益匪浅",
createdAt: ISODate("2023-05-02T14:30:00Z")
}
]
}
这种结构的优势在于单次查询即可获取完整数据,但缺点是文档大小会不断增长,可能超过16MB限制。
引用方案
当子文档数量庞大或需要独立访问时,使用引用更合适:
// 文章文档
{
_id: ObjectId("5f8d8a7b2f4d4e1d2c3b4a5e"),
title: "MongoDB关系建模",
content: "..."
}
// 评论文档
{
_id: ObjectId("6a7b8c9d0e1f2a3b4c5d6e7f"),
articleId: ObjectId("5f8d8a7b2f4d4e1d2c3b4a5e"),
author: "用户A",
text: "好文章",
createdAt: ISODate("2023-05-01T10:00:00Z")
}
查询时需要额外操作:
// 先查询文章
const article = db.articles.findOne({_id: articleId});
// 再查询相关评论
const comments = db.comments.find({articleId: article._id}).toArray();
多对多关系建模
双向引用方案
多对多关系通常需要中间集合。以学生和课程为例:
// 学生文档
{
_id: ObjectId("1a2b3c4d5e6f7a8b9c0d1e2f"),
name: "张三",
enrolledCourses: [
ObjectId("a1b2c3d4e5f6a7b8c9d0e1f2"),
ObjectId("b2c3d4e5f6a7b8c9d0e1f2a3")
]
}
// 课程文档
{
_id: ObjectId("a1b2c3d4e5f6a7b8c9d0e1f2"),
name: "数据库原理",
students: [
ObjectId("1a2b3c4d5e6f7a8b9c0d1e2f"),
ObjectId("2b3c4d5e6f7a8b9c0d1e2f3a")
]
}
这种方案需要维护双向引用的一致性,可以通过事务保证:
const session = db.getMongo().startSession();
session.startTransaction();
try {
// 学生选课
db.students.updateOne(
{ _id: studentId },
{ $addToSet: { enrolledCourses: courseId } },
{ session }
);
// 课程添加学生
db.courses.updateOne(
{ _id: courseId },
{ $addToSet: { students: studentId } },
{ session }
);
await session.commitTransaction();
} catch (error) {
await session.abortTransaction();
throw error;
} finally {
session.endSession();
}
中间集合方案
更复杂的多对多关系可以使用专门的连接集合:
// 注册记录
{
_id: ObjectId("c3d4e5f6a7b8c9d0e1f2a3b4"),
studentId: ObjectId("1a2b3c4d5e6f7a8b9c0d1e2f"),
courseId: ObjectId("a1b2c3d4e5f6a7b8c9d0e1f2"),
enrolledAt: ISODate("2023-09-01T09:00:00Z"),
grade: null
}
查询学生选修的所有课程:
const enrollments = db.enrollments.find({ studentId: studentId });
const courseIds = enrollments.map(e => e.courseId);
const courses = db.courses.find({ _id: { $in: courseIds } }).toArray();
高级关系模式
树形结构建模
使用父引用模式存储树形结构:
// 分类文档
{
_id: ObjectId("d4e5f6a7b8c9d0e1f2a3b4c5"),
name: "电子产品",
parentId: null
}
{
_id: ObjectId("e5f6a7b8c9d0e1f2a3b4c5d6"),
name: "手机",
parentId: ObjectId("d4e5f6a7b8c9d0e1f2a3b4c5")
}
查询子分类:
const children = db.categories.find({ parentId: parentId }).toArray();
图数据建模
社交网络等图数据可以使用混合方案:
// 用户文档
{
_id: ObjectId("f6a7b8c9d0e1f2a3b4c5d6e7"),
name: "李四",
friends: [
ObjectId("a7b8c9d0e1f2a3b4c5d6e7f8"),
ObjectId("b8c9d0e1f2a3b4c5d6e7f8a9")
]
}
查询朋友的朋友(二度人脉):
const user = db.users.findOne({_id: userId});
const friends = db.users.find({_id: {$in: user.friends}}).toArray();
const friendIds = friends.flatMap(f => f.friends);
const friendsOfFriends = db.users.find({
_id: {$in: friendIds, $ne: userId, $nin: user.friends}
}).toArray();
查询优化技巧
使用$lookup进行联表查询
MongoDB 3.2+支持$lookup
操作符实现类似SQL的左连接:
db.orders.aggregate([
{
$lookup: {
from: "products",
localField: "productId",
foreignField: "_id",
as: "productDetails"
}
},
{
$unwind: "$productDetails"
}
]);
应用层联查
对于频繁访问的关系,可以在应用层缓存关联数据:
async function getArticleWithComments(articleId) {
const [article, comments] = await Promise.all([
db.articles.findOne({_id: articleId}),
db.comments.find({articleId}).toArray()
]);
return {
...article,
comments
};
}
反规范化设计
对读取性能要求高的场景,可以适当冗余数据:
// 订单文档
{
_id: ObjectId("a8b9c0d1e2f3a4b5c6d7e8f9"),
userId: ObjectId("1a2b3c4d5e6f7a8b9c0d1e2f"),
userName: "王五", // 冗余用户名避免联查
items: [
{
productId: ObjectId("b2c3d4e5f6a7b8c9d0e1f2a3"),
productName: "无线耳机",
price: 299
}
]
}
模式设计考量因素
读写比例分析
高频读取的数据适合嵌入式,高频更新的数据适合引用式。例如:
- 博客评论:读多写少 → 嵌入式
- 股票交易记录:写多读少 → 引用式
数据一致性要求
需要强一致性的场景使用引用+事务:
// 转账操作
const session = db.getMongo().startSession();
session.startTransaction();
try {
// 扣减转出账户
db.accounts.updateOne(
{ _id: fromAccount, balance: { $gte: amount } },
{ $inc: { balance: -amount } },
{ session }
);
// 增加转入账户
db.accounts.updateOne(
{ _id: toAccount },
{ $inc: { balance: amount } },
{ session }
);
// 记录交易
db.transactions.insertOne({
from: fromAccount,
to: toAccount,
amount,
timestamp: new Date()
}, { session });
await session.commitTransaction();
} catch (error) {
await session.abortTransaction();
throw error;
} finally {
session.endSession();
}
分片集群考虑
在分片环境中,相关数据应尽量放在同一分片。例如按用户ID分片时,用户的订单数据也应包含用户ID:
{
_id: ObjectId("c9d0e1f2a3b4c5d6e7f8a9b0"),
userId: ObjectId("1a2b3c4d5e6f7a8b9c0d1e2f"), // 分片键
orderDate: ISODate("2023-10-01"),
items: [...]
}
本站部分内容来自互联网,一切版权均归源网站或源作者所有。
如果侵犯了你的权益请来信告知我们删除。邮箱:cc@cccx.cn
上一篇:引用式关联