做 MongoDB 的人,刚开始都会被“文档数据库”这个名头迷住。觉得既然叫文档数据库,那把数据打包塞进一个 JSON 里岂不是最爽?

我见过太多项目,第一版设计时豪情万丈,把一个订单、它的商品明细、收货地址、用户信息、甚至评论全部塞进一个 orders 集合的大文档里。结果上线半年后,随着数据量上来,查询变慢,更新冲突频发,存储空间指数级爆炸。老板问为什么,开发者一脸无辜:“我只是想查询方便一点。”

其实,MongoDB 的数据模型设计,核心就在于一个权衡:嵌入(Embedding) 还是 引用(Referencing)。这俩选择做对了,系统轻盈飞快;做错了,就是无尽的 cursor timeout 和账单焦虑。

今天咱们不聊虚的,我就用我这些年踩过的坑和救过的火,给你拆解一下这套最佳实践。

别迷信“文档”:理解 BSON 的边界

首先,你得对 MongoDB 的底层存储有个敬畏之心。MongoDB 使用 BSON(Binary JSON)格式存储数据。BSON 文档有一个硬性的 size 限制:16MB

听起来很大?是的,存几个 G 的文本都没问题。但问题是,当你试图把“所有相关数据都塞进一个文档”时,你很快就会发现两个致命伤:

  1. 重新分片(Rewriting)成本极高:如果你更新文档的一个字段,而该文档已经接近 16MB,或者该文档在磁盘上被其他碎片占据,MongoDB 可能需要在同一磁盘位置重写整个文档。这就好比你要修改一本 1000 页的书中的一句话,结果因为磁盘碎片化,你不得不把整本书重新抄写一遍。
  2. 查询效率下降:即使你不更新,当你只想要其中一个小字段时,数据库仍然需要读取整个大文档。如果这个文档经常不被完全访问,那就是 I/O 的浪费。

所以,“关联数据就嵌入”不是真理,“高频共访问数据才嵌入”才是真理。

嵌入策略:什么时候该“打包”?

嵌入(Embedding)是指将子文档直接放在父文档内部。这是 MongoDB 的强项,因为它允许你在一次磁盘读取中获取所有数据,无需昂贵的 JOIN 操作。

1. 一对少,且子文档有上限

这是最经典的嵌入场景。比如 博客文章和评论

想象一下,如果你有一篇文章,你有 10 条评论。你把这 10 条评论嵌入到文章文档里,结构大概长这样:

{
  "_id": "article_001",
  "title": "MongoDB 设计之道",
  "author": "Agnes",
  "content": "这里是一篇关于数据库设计的文章...",
  "comments": [
    { "user": "Alice", "text": "写得好!", "date": "2023-10-01" },
    { "user": "Bob", "text": "有点深奥", "date": "2023-10-02" }
  ],
  "tags": ["MongoDB", "Database", "Design"]
}

为什么要这么干? 因为在博客系统中,展示文章时,你几乎 100% 的情况都会同时展示评论列表。把它们嵌入在一起,一次读取就搞定,速度极快。

但是,注意这个“上限”: 如果这篇文章有 10,000 条评论呢?你不能把所有评论都塞进去。通常的做法是:

  • 嵌入最近 N 条(比如最近 20 条)。
  • 或者,只嵌入评论的摘要,总评论数放在外层。
  • 当评论超过一定数量,就切换到引用模式,或者使用专门的评论集合。

2. 静态且频繁访问的配置数据

另一个好例子是 用户配置。比如一个 App 的用户设置:主题、字体大小、通知偏好。

{
  "_id": "user_123",
  "username": "zhangsan",
  "settings": {
    "theme": "dark",
    "font_size": 14,
    "notifications": {
      "email": true,
      "sms": false,
      "push": true
    }
  }
}

为什么嵌入?因为 settings 这部分数据,每次用户打开 App 都要读,而且很少发生大规模并发修改(比如同时两个人改字体大小)。嵌入它,减少了一次额外的数据库查询。

3. 嵌入的黄金法则

  • 数据量小:子文档总和不要超过几十 KB。
  • 访问模式一致:主文档和子文档通常一起被读取或一起不被读取。
  • 更新频率低:子文档不要频繁独立更新。
  • 有数量上限:如果子文档数量不可预测且可能无限增长(如日志、聊天记录),千万别嵌入

引用策略:什么时候该“分离”?

引用(Referencing)就是关系型数据库的外键思路。你在父文档里存一个 ID,真正的数据在另一个集合里。

1. 数据量不可预测且会无限增长

回到刚才的评论例子,如果这篇文章火了,评论达到 10 万条。如果你把它们全部嵌入一个文档,光这一篇文章的文档就可能有几十 MB。

这时候,你必须用引用:

文章集合:

{
  "_id": "article_001",
  "title": "MongoDB 设计之道",
  "comment_count": 10053,
  "latest_comment_ids": ["cmt_998", "cmt_999", "cmt_10000"]
}

评论集合:

{
  "_id": "cmt_998",
  "article_id": "article_001",
  "user_id": "user_alice",
  "text": "写得好!",
  "date": "2023-10-01"
}

为什么这么做?

  • 避免 16MB 限制:评论可以独立存储,不受文章文档大小影响。
  • 独立扩展:你可以对 comments 集合单独做分片(Sharding),比如按 article_id 分片,这样热点文章的评论压力不会打到文章本身。
  • 更新独立:新增评论只写 comments 集合,不触碰 articles 集合,减少了写入冲突。

2. 一对多,且“多”的一方会被独立查询

比如 订单和订单项。一个订单可能有多个商品。

如果你把订单项嵌入订单,结构像这样:

{
  "_id": "order_001",
  "user_id": "user_123",
  "items": [
    { "product_id": "prod_100", "qty": 2, "price": 19.99 },
    { "product_id": "prod_200", "qty": 1, "price": 49.99 }
  ],
  "total": 89.97
}

这看起来不错,对吧?但是,想象一下你的业务需求:

“找出所有购买过 product_id: prod_100 的用户。”

如果数据是嵌入的,MongoDB 必须扫描每一个订单文档,检查其 items 数组里是否包含 prod_100。对于百万级订单,这简直是灾难。

如果你用引用,把 order_items 单独放在一个集合里:

// order_items 集合
{ "_id": "oi_001", "order_id": "order_001", "product_id": "prod_100", "qty": 2 }
{ "_id": "oi_002", "order_id": "order_001", "product_id": "prod_200", "qty": 1 }
{ "_id": "oi_003", "order_id": "order_002", "product_id": "prod_100", "qty": 5 }

你就可以在 order_items 集合的 product_id 字段上建立索引,瞬间找到所有相关订单,无需扫描整个订单集合。

引用的核心场景总结:

  • 子文档数量庞大或无限增长。
  • 子文档需要被独立查询(不按父文档 ID 查)。
  • 子文档有自己的生命周期,与父文档不同步(比如子文档需要单独更新、删除)。
  • 为了避免数据冗余(见下文)。

数据冗余:是敌人还是朋友?

在关系型数据库里,冗余是万恶之源, normalization(规范化)是神圣不可侵犯的。但在 MongoDB 里,适度的冗余是性能优化的必要手段

反范式设计:用空间换时间

假设你有一个电商系统,有 products(商品)和 orders(订单)两个集合。

错误做法(完全引用,无冗余):

// orders 集合
{
  "_id": "order_001",
  "user_id": "user_123",
  "product_ids": ["prod_100", "prod_200"],
  "total_price": 0 // 需要从 products 查出来计算
}

每次查询订单详情,你都需要:

  1. 读取 order_001
  2. 拿着 prod_100products 集合查一次,拿到名字和当前价格。
  3. 拿着 prod_200products 集合查一次。
  4. 计算总价。

这不仅是多次数据库查询,还有个更严重的问题:价格不一致。如果商品 prod_100 的价格从 10 元涨到 12 元,历史上已经存在的订单 order_001 如果只存了 product_id,那你重新查询时,算出来的总价就是 12 元,但这笔订单当时其实是按 10 元买的!这会导致财务对不上账。

正确做法(嵌入/冗余关键信息):

// orders 集合
{
  "_id": "order_001",
  "user_id": "user_123",
  "items": [
    { 
      "product_id": "prod_100", 
      "product_name": "iPhone 15", // 冗余:快照当时的名称
      "quantity": 1, 
      "price_at_purchase": 10.00, // 冗余:快照当时的价格
      "total": 10.00 
    },
    { 
      "product_id": "prod_200", 
      "product_name": "AirPods", 
      "quantity": 2, 
      "price_at_purchase": 49.99, 
      "total": 99.98 
    }
  ],
  "total_price": 109.98
}

为什么要冗余?

  1. 查询性能:一次读取订单文档,所有信息齐备,无需额外 JOIN 或多次查找。
  2. 数据一致性:订单里的价格是“历史快照”,不受商品后续价格变动影响,财务准确。
  3. 读写分离的权衡:虽然写入订单时需要多存几个字段,但查询订单的频率远高于写入订单的频率。用轻微的写入成本换取巨大的读取性能提升,是值得的。

冗余的边界

当然,冗余也不是越多越好。

  • 不要冗余经常变化的数据:比如用户的“最后登录时间”。如果把这个字段冗余到用户的所有订单里,每次用户登录,你就要更新他历史上一万条订单文档,这显然是愚蠢的。
  • 只冗余“静态”或“快照”数据:商品名称、购买时的价格、收货地址等。这些信息在购买那一刻确定后,通常不再变化。

嵌套过深的陷阱:三层以上的噩梦

我见过最离谱的设计,是一个嵌套了 5 层的文档:

{
  "company": {
    "name": "Acme Corp",
    "departments": [
      {
        "name": "Engineering",
        "teams": [
          {
            "name": "Backend",
            "members": [
              {
                "name": "Alice",
                "skills": [
                  { "name": "Java", "level": 5 },
                  { "name": "Go", "level": 4 }
                ]
              }
            ]
          }
        ]
      }
    ]
  }
}

问题在哪里?

  1. 查询困难:你想找所有会 Java 的人?你得遍历整个 members.skills 数组。如果这个结构再深一层,索引都帮不了你。
  2. 更新灾难:你想给 Alice 加一个 Python 技能?你需要定位到第五层数组,然后更新。如果多个团队同时更新 Alice 的技能,可能会有并发冲突。
  3. 可读性差:维护这种文档就像在迷宫里找出口。
  4. 违反单一职责:公司、部门、团队、成员、技能,这应该是五个不同的概念,五个不同的集合。把它们强行塞进一个文档,是设计懒惰的表现。

最佳实践:嵌套不超过 2-3 层

一般来说,文档内部嵌套层级建议控制在 2-3 层以内。超过这个深度,你应该考虑将其拆分为多个集合,用引用连接。

例如,上面的例子应该拆分为:

  • companies 集合
  • departments 集合(通过 company_id 引用)
  • teams 集合(通过 department_id 引用)
  • members 集合(通过 team_id 引用)
  • skills 集合(通过 member_id 引用,或者嵌入在 member 文档中,因为一个成员的技能列表通常不大)

如何决定:嵌入还是引用?一个决策树

每次设计模型时,我问自己这三个问题:

问题 1:子文档的数量是否有上限?

  • (如:一个用户只有一个个人资料,一篇文章的最近 10 条评论)-> 考虑嵌入
  • (如:一个订单的无限商品,一个社交帖子的无限点赞)-> 必须引用

问题 2:子文档会被独立查询吗?

  • (如:我需要按商品 ID 查找所有包含该商品的订单)-> 必须引用
  • (如:我只会通过订单 ID 查找订单,然后展示其明细)-> 考虑嵌入

问题 3:子文档的数据是否会频繁独立更新?

  • (如:评论会被单独点赞、删除、编辑)-> 必须引用
  • (如:订单明细在购买后基本不变)-> 考虑嵌入

简单记忆口诀:

小、静、同生命周期 -> 嵌入 大、动、需独立查询 -> 引用

实战案例:电商系统的数据模型设计

让我们用一个具体的电商场景来巩固这些概念。假设我们要设计一个简化的电商后端。

集合划分

  1. Users(用户)
  2. Products(商品)
  3. Orders(订单)
  4. OrderItems(订单明细)- 这里我们选择引用,因为商品可能被多个订单购买,且需要独立统计销量
  5. Reviews(评论)- 引用,因为评论数量可能很大,且需要独立查询某商品的所有评论

1. Users 集合

{
  "_id": "user_123",
  "username": "zhangsan",
  "email": "zhangsan@example.com",
  "created_at": "2023-01-01T00:00:00Z",
  "shipping_addresses": [ // 嵌入:地址数量有限,通常<10个,且随用户一起查询
    {
      "_id": "addr_001",
      "street": "123 Main St",
      "city": "Beijing",
      "zip": "100000",
      "is_default": true
    }
  ],
  "preferences": { // 嵌入:小对象,频繁读取,极少变更
    "theme": "dark",
    "newsletter": false
  }
}

2. Products 集合

{
  "_id": "prod_100",
  "name": "iPhone 15",
  "description": "Apple's latest smartphone",
  "price": 999.00,
  "category_id": "cat_electronics",
  "inventory_count": 500,
  "tags": ["phone", "apple", "5g"], // 嵌入:小数组,用于过滤
  "created_at": "2023-06-01T00:00:00Z"
}

3. Orders 集合

这里我们采用混合策略:订单基本信息嵌入,订单明细引用。

”`json { “_id”: “order_001”, “user_id”: “user_123”, “status”: “shipped”, “total_price”: 1009.00, // 冗余:计算好的总价,避免每次查询时重新计算 “shipping_address”: { // 嵌入:快照地址,不再引用 user 的 addresses,因为地址可能已变更

"street": "123 Main St",
"city": "Beijing",
"zip": "100000"

}, “order_items_ref”: [ // 引用:明细单独存储,便于统计和独立查询

"oi_001",
"oi_002"