阿里云主机折上折
  • 微信号
您当前的位置:网站首页 > 一对多、多对多关系建模

一对多、多对多关系建模

作者:陈川 阅读数:54899人阅读 分类: MongoDB

关系型数据库与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

前端川

前端川,陈川的代码茶馆🍵,专治各种不服的Bug退散符💻,日常贩卖秃头警告级的开发心得🛠️,附赠一行代码笑十年的摸鱼宝典🐟,偶尔掉落咖啡杯里泡开的像素级浪漫☕。‌