MongoDB schema 设计:什么时候 embed、什么时候 reference

起因

第一次用 MongoDB 是从关系数据库转过来:把 PG 里的 users / posts /
comments 三张表照搬成三个 collection,每篇 post 存 user_id 引用。
查询时 application 端做 3 次 query 拼起来。
最后发现这就是把 MongoDB 当 NoSQL "假 PG" 用,丧失了文档数据库的优势
(也没拿到关系数据库的事务)。

MongoDB 的正确姿势是设计 schema 时考虑访问模式(access pattern):
经常一起读的数据 embed 在一起。

决策框架

按几个维度选 embed vs reference:

1. 一对一 / 一对多 / 多对多

  • 1:1 或 1:few(< 几十)→ embed
  • 1:many(百级 +)→ reference(避免文档太大)
  • many:many → reference

2. 是否一起读 / 一起写

  • 经常一起读 → embed
  • 独立访问、生命周期不同 → reference

3. 是否会增长

  • 有界(如 user.address 通常 1-3 条)→ embed
  • 无界(comments 可能千万级)→ reference

4. 写入频率

  • 子数据频繁更新(counter / status) → reference 避免文档反复重写
  • 子数据基本不变 → embed

实战例子:博客系统

模式 A:扁平 reference(关系数据库 mindset)

// users
{ _id: ObjectId("..."), name: "Alice", email: "..." }

// posts
{ _id: ObjectId("..."), title: "...", body: "...", author_id: ObjectId("...") }

// comments
{ _id: ObjectId("..."), post_id: ObjectId("..."), author_id: ObjectId("..."), body: "..." }

读一篇 post + 它的评论 + 作者:3 次 query + application 拼接。
不是 MongoDB 的优势用法。

模式 B:embed comments 进 post

{
  _id: ObjectId("..."),
  title: "...",
  body: "...",
  author: { _id: ObjectId("..."), name: "Alice" },   // embed 作者基本信息
  comments: [
    {
      _id: ObjectId("..."),
      author: { _id: ObjectId("..."), name: "Bob" },
      body: "...",
      created_at: ISODate(),
    },
    // ...
  ],
  created_at: ISODate(),
}

一次 db.posts.findOne({_id}) 拿全部数据。

适合:

  • 每篇 post 评论数有限(< 几百)
  • 评论展示几乎总是跟 post 一起读
  • 评论不会被独立查询(如"列出某用户所有评论"少见)

不适合:

  • 评论可能上千(文档 > 16MB 限制)
  • 评论独立修改频繁(每次更新整个 post 文档)
  • 需要按评论独立查询

模式 C:分桶(bucket pattern)

如果评论会增长但仍想 embed-like 局部性:

// post 主体
{ _id, title, body, comment_count: 42 }

// comments 分桶
{
  _id: ObjectId(),
  post_id: ObjectId("..."),
  bucket_index: 0,             // 第 0 桶
  comments: [
    { author, body, created_at }, // 桶内最多 100 条
    // ...
  ]
}

按时间 / id 范围把评论分批。读最近评论 = 读最后一个 bucket(小文档)。
全量评论 = 多次 query 但每次都是几 KB 而非几 MB。

适合 IoT 时序数据、聊天历史、活动 feed 等。

模式 D:用 sub-collection + 反规范化字段

// posts (主信息 + 反规范化字段)
{
  _id, title, body, author_id, author_name,   // 作者名嵌进来避免 join
  comment_count: 42, last_comment_at: ISODate(),
}

// comments(独立 collection)
{ _id, post_id, author_id, author_name, body, created_at }

author_name 在 post 里冗余存——读 post 列表不需要 join users。
用户改名时要更新所有引用(cost 低频)。

last_comment_at / comment_count 在 post 上维护,列表页排序方便。

这是大多数博客 / 社交 app 的 sweet spot

实战例子:电商订单

// orders(embed product 当时的 snapshot)
{
  _id,
  user_id: ObjectId(),
  items: [
    {
      product_id: ObjectId(),
      // 下面是下单时 snapshot,product 涨价也不变
      sku: "ABC-123",
      name: "Widget",
      price: 99.50,
      quantity: 2,
    }
  ],
  total: 199.00,
  status: "shipped",
  shipping_address: { ... },     // embed
  payment: { method, txn_id },   // embed
  created_at, updated_at,
}

为什么 product 信息要 embed snapshot?产品涨价 / 改名 / 下架后,
历史订单还能显示下单时的价格 / 描述。这是文档数据库优势:把"事实"
冻结在事件发生时。

索引

// 复合索引:常按 user 查最新订单
db.orders.createIndex({ user_id: 1, created_at: -1 })

// 部分索引:只索引未完成订单
db.orders.createIndex(
  { user_id: 1, created_at: -1 },
  { partialFilterExpression: { status: { $in: ["pending", "processing"] } } }
)

// 全文搜索
db.posts.createIndex({ title: "text", body: "text" })
db.posts.find({ $text: { $search: "mongodb schema" } })

explain('executionStats') 看 query 走没走索引:

db.orders.find({user_id: ObjectId("...")}).sort({created_at: -1}).limit(20).explain('executionStats')
// IXSCAN + totalKeysExamined ~= nReturned = 健康
// COLLSCAN = 缺索引

事务

MongoDB 4.0+ 支持多文档事务:

const session = client.startSession()
try {
  session.startTransaction()
  await db.collection('orders').insertOne(order, { session })
  await db.collection('inventory').updateOne(
    { _id: productId },
    { $inc: { stock: -quantity }},
    { session }
  )
  await session.commitTransaction()
} catch (e) {
  await session.abortTransaction()
  throw e
} finally {
  session.endSession()
}

但好的 schema 设计应该让大多数操作不需要跨文档事务
embed 把"原子操作"装进一个文档里,单文档写自然原子。

Aggregation pipeline

复杂查询用 aggregation:

db.orders.aggregate([
  { $match: { status: "shipped" } },
  { $unwind: "$items" },
  { $group: {
      _id: "$items.sku",
      revenue: { $sum: { $multiply: ["$items.price", "$items.quantity"] }},
      count: { $sum: "$items.quantity" }
  }},
  { $sort: { revenue: -1 } },
  { $limit: 10 },
])

像 SQL GROUP BY 但 stages 串成 pipeline。比 application 端聚合快 10-100x
(DB 内执行,结果集小)。

何时不用 MongoDB

  • 强事务 + 多表 join + 复杂报表 → PostgreSQL
  • 关系紧密 + schema 严格 → 关系库
  • 极大写吞吐(百万 ops/s)+ key-value → DynamoDB / Cassandra

MongoDB 适合:用户 profile / 内容 / 半结构化 / 易变 schema /
fast-iterating 产品。

效果

我们的内容系统迁移到合理设计:

  • 首页列表 query:3 次 SQL JOIN(PG)→ 1 次 find(Mongo),延迟从
    80ms → 12ms
  • 文档存 markdown 源 + render 后 HTML + metadata 一起,全 cache 友好
  • schema 增加新字段无需 migration(直接写新 doc)

踩过的坑

  1. 文档 > 16MB:MongoDB 单文档硬上限。无限增长的 array 早晚踩。

  2. 数组上的 $push + $slice:要限大小:
    js db.posts.updateOne( { _id: postId }, { $push: { comments: { $each: [newComment], $slice: -100 } } } )
    保留最近 100 条评论,老的滚出。

  3. null 和缺字段语义混淆
    js { foo: null } // 字段存在,值为 null { /* no foo */ } // 字段缺失
    find({foo: null}) 同时匹配两种。要区分用 {foo: {$exists: true}}

  4. ObjectId vs string:把 string _id 传给 find({_id: id})
    匹配不到 ObjectId。永远 new ObjectId(idStr) 转换。

  5. schema 后期变更:MongoDB 不强制 schema,但 application 假设
    字段存在。改 schema 需要 migrate 旧文档(updateMany 加默认值)。
    建议生产用 schema validation:
    js db.createCollection("orders", { validator: { $jsonSchema: { ... } } })

精确评价 共 0 人评价
可复现性
可复现 · 0 不可复现 · 0
文风
文风流畅 · 0 文风晦涩 · 0
立场
支持 · 0 反对 · 0

登录后即可对本帖作出评价。

评论区 0 条 · 所有人可在此交流

登录后参与评论。

还没有评论,来说两句。