在 MongoDB 的日常开发与运维中,数据建模是决定系统性能、可扩展性和可维护性的基石。一个糟糕的 Schema 设计会从根本上限制应用的扩展能力。与关系型数据库的"范式优先"不同,MongoDB 的设计遵循"查询优先"原则------一起访问的数据应该一起存储。
一、核心设计原则------嵌入 vs 引用
1. 为什么这是一个关键决策
当你开始在 MongoDB 中设计数据模型时,第一个关键决策就是如何建模关系:应该将相关数据嵌入到单个文档中,还是使用引用来链接集合?这个选择直接决定了查询性能的根基。
MongoDB 提供了两种链接相关数据的方式:
| 方式 | 说明 | 适用场景 |
|---|---|---|
| 嵌入(Embedding) | 将相关数据作为子文档直接存放在父文档中 | 数据一起访问、关系为"一对少" |
| 引用(Referencing) | 通过 _id 在不同集合间建立关联 |
数据独立更新、关系为"一对多"或"多对多" |
2. 嵌入(Embedding)详解
嵌入是将相关数据作为子文档或数组存储在父文档内部。这是 MongoDB 原生的关系建模方式,可为经常一起访问的文档提供更好的性能。
适用场景:
- 一对一关系:如用户与其档案信息
- 一对少关系:关联数据量有限且不会无限增长
- 数据总是被一起访问:应用程序在单次读取操作中需要获取所有相关信息
- 需要原子更新:嵌入数据支持在单个文档中进行原子操作
优势:
- 单次查询即可获取所有数据,延迟低
- 避免
$lookup操作的开销 - 支持原子性的读写操作
注意事项:
- 文档最大不能超过 16MB 的 BSON 限制
- 深层嵌套时,小改动可能触发整个父文档重写
- 频繁更新的数据嵌入会导致写放大
3. 引用(Referencing)详解
引用是通过在一个文档中存储另一个文档的 _id 来建立关联。数据分别存储在不同的集合中,查询时需要通过 $lookup 或应用层多次查询来组装数据。
适用场景:
- 一对多关系:关联数据可能大量增长
- 多对多关系:复杂的关系网络
- 数据独立更新:子文档频繁变化且独立于父文档
- 文档可能超过 16MB 限制:需要控制文档大小
优势:
- 避免文档无限增长
- 数据只需在一处维护,更新成本低
- 更灵活的数据模型演进
注意事项:
- 需要多次查询或
$lookup,增加延迟 - 应用层需要处理关联逻辑
4. 决策框架
选择嵌入还是引用,核心依据是应用程序的数据访问模式,而不仅仅是实体关系。

官方决策指南:
| 考虑因素 | 推荐方式 | 理由 |
|---|---|---|
| 相关数据放在一起使模型更简单 | 嵌入 | 简化代码和查询 |
| 实体间存在"has-a"或"contains"关系 | 嵌入 | 自然的文档嵌套 |
| 应用程序同时查询多条信息 | 嵌入 | 单次读取获取全部 |
| 数据经常一起更新 | 嵌入 | 原子操作 |
| 应同时存档的数据 | 嵌入 | 保持数据完整性 |
| 子方具有较高的关联基数 | 引用 | 避免文档过大 |
| 数据重复过于复杂 | 引用 | 保持单一数据源 |
| 数据总大小占用过多内存或带宽 | 引用 | 减少工作集压力 |
| 嵌入式数据会无限增长 | 引用 | 避免超过16MB限制 |
| 写入密集型工作负载 | 引用 | 减少写放大 |
| 子数据可以独立存在 | 引用 | 保持数据独立性 |
实用经验法则:
- 大量 CREATE 和 GET 操作 → 优先考虑嵌入
- 大量 UPDATE 和 DELETE 操作 → 优先考虑引用
二、高级 Schema 设计模式
MongoDB 官方"使用模式构建"(Building with Patterns)博客系列系统性地介绍了多种经过验证的 Schema 设计模式。本文将重点讲解三种最常用且对性能影响最为显著的模式。
1. 属性模式(Attribute Pattern)
问题场景
当一个集合中的文档包含大量具有共同特征的相似字段时,如果需要对这类字段进行排序或查询,就需要为每个字段创建索引。索引是有成本的,过多的索引会降低写入性能。
典型反模式示例------电影的多国上映日期:
javascript
// ❌ 没有使用属性模式
{
"_id": 1,
"title": "Star Wars",
"runtime": 121,
"directors": ["George Lucas"],
"release_US": ISODate("1977-05-20T01:00:00+01:00"),
"release_France": ISODate("1977-10-19T01:00:00+01:00"),
"release_Italy": ISODate("1977-10-20T01:00:00+01:00"),
"release_UK": ISODate("1977-12-27T01:00:00+01:00")
}
如果要按各国上映日期查询,需要创建 4 个独立索引。
解决方案
属性模式将多个相似字段合并到一个键值对数组中:
javascript
// ✅ 使用属性模式
{
"_id": 1,
"title": "Star Wars",
"runtime": 121,
"directors": ["George Lucas"],
"releases": [
{ "location": "USA", "date": ISODate("1977-05-20T01:00:00+01:00") },
{ "location": "France", "date": ISODate("1977-10-19T01:00:00+01:00") },
{ "location": "Italy", "date": ISODate("1977-10-20T01:00:00+01:00") },
{ "location": "UK", "date": ISODate("1977-12-27T01:00:00+01:00") }
]
}
只需在 releases 数组上创建一个索引即可覆盖所有国家/地区的查询:
javascript
db.movies.createIndex({ "releases": 1 })
适用条件
- 文档包含许多具有共同特征的相似字段
- 需要对这些字段的子集进行排序或查询
- 只有一小部分文档包含排序所需的字段
其他典型应用场景
- 多语言国际化:将不同语言版本的内容存为键值对
- 产品属性:不同产品有不同维度的属性(服装的尺码、饮料的容量等)
- 动态字段 :字段名不可预知或会持续增加

2. 子集模式(Subset Pattern)
问题场景
当一个文档包含一个大型数组字段(如评论、历史记录),但应用程序实际上只频繁访问其中一小部分数据时,整个文档会被加载到内存中,导致工作集超过 RAM 容量,频繁触发页面换出。
典型反模式示例------图书评论:
javascript
// ❌ 无界数组 - 所有评论都在一个文档中
{
"title": "Harry Potter",
"author": "J.K. Rowling",
"publisher": "Scholastic",
"reviews": [
{ "user": "Alice", "review": "Great book!", "rating": 5 },
{ "user": "Bob", "review": "Didn't like it!", "rating": 1 },
// ... 可能成千上万条评论
]
}
随着评论数量增长,文档可能超过 16MB 限制,且每次读取都要加载全部评论。
解决方案
子集模式将最常访问的数据子集嵌入主文档,将完整数据放在单独的集合中:
javascript
// ✅ 使用子集模式 - 主文档只保留最近3条评论
// books 集合
{
"title": "Harry Potter",
"author": "J.K. Rowling",
"publisher": "Scholastic",
"reviews": [ // 只保留最近/最热门的3条
{ "reviewer": "Alice", "review": "Great book!", "rating": 5 },
{ "reviewer": "Charlie", "review": "Not bad, but could be better.", "rating": 3 },
{ "reviewer": "Bob", "review": "Didn't like it.", "rating": 1 }
]
}
// reviews 集合 - 存储所有评论
{
"book_id": ObjectId("..."),
"reviewer": "Jason",
"review": "Did not enjoy!",
"rating": 1,
"createdAt": ISODate("...")
}
适用条件
- "一对多"关系中"多"端数据量大
- 查询集中在最近或高频访问的少量数据上
- 数据读取频繁但更新不频繁
注意事项
- 子集模式会产生数据冗余
- 如果子集数据需要频繁更新,更新成本较高
- 适合写入较少、读取频繁的场景

3. 扩展引用模式(Extended Reference Pattern)
问题场景
当应用程序频繁跨集合查询数据时,每次都需要执行 $lookup 操作。虽然完全嵌入会导致数据重复和维护困难,但每次都做 $lookup 又会显著影响读取性能。
解决方案
扩展引用模式是一种反规范化策略------只将引用文档中最常访问的字段复制到父文档中,而不是嵌入全部数据或仅存储 ID:
javascript
// customers 集合 - 数据源(单一事实来源)
{
"_id": ObjectId("64b7f1f77bcf86cd79943901"),
"email": "john.doe@example.com",
"firstName": "John",
"lastName": "Doe",
"phone": "+1-555-0123",
"addresses": [...],
"preferences": {...},
"createdAt": ISODate("2024-01-15T10:30:00Z")
}
// orders 集合 - 使用扩展引用
{
"_id": ObjectId("..."),
"orderDate": ISODate("2025-06-20T14:45:00Z"),
"total": 299.99,
// ✅ 扩展引用:只复制最常访问的客户字段
"customer": {
"_id": ObjectId("64b7f1f77bcf86cd79943901"),
"firstName": "John",
"lastName": "Doe",
"email": "john.doe@example.com"
},
// 不复制:phone, addresses, preferences 等低频字段
"items": [...]
}
决策流程图

适合复制的字段
| ✅ 适合复制 | ❌ 不适合复制 |
|---|---|
| 显示名称、标题、标签 | 频繁变化的数据(库存数量) |
| 稳定的标识符(SKU、slug) | 大文本字段(描述、正文) |
| 计算汇总(计数、总额) | 敏感数据(密码、Token、PII) |
| 时间戳(创建日期) | 嵌套数组等复杂结构 |
适用条件
- 频繁一起读取关联数据
- 只需要关联文档的一小部分字段
- 被复制的字段变化不频繁
- 读性能比写性能更重要
- 希望避免
$lookup的开销
注意事项
- 本质是用写放大换取读性能
- 如果关联字段频繁变化,维护成本会很高
- 需要建立数据同步机制处理字段更新
三、模式组合使用与最佳实践
1. 三种模式的对比总结
| 维度 | 属性模式 | 子集模式 | 扩展引用模式 |
|---|---|---|---|
| 核心目的 | 减少索引数量 | 控制文档大小 | 减少 JOIN 操作 |
| 解决什么问题 | 大量相似字段的索引爆炸 | 无界数组导致文档过大 | 频繁跨集合查询的性能开销 |
| 主要手段 | 将相似字段转为键值对数组 | 只保留高频访问的数据子集 | 复制最常用的关联字段 |
| 代价 | 查询稍复杂 | 数据冗余 | 写放大、数据一致性维护 |
| 典型场景 | 多语言、多地区属性 | 评论、历史记录 | 订单中的客户/商品信息 |
2. 模式组合使用
在实际项目中,这三种模式经常组合使用:
电商订单系统示例:
- 属性模式:商品的多语言名称、多规格属性
- 子集模式:商品详情页只显示最近 5 条评论
- 扩展引用模式 :订单中冗余存储客户姓名和商品名称(避免每次查询都做
$lookup)
3. 核心设计原则
- 查询优先:根据应用程序的数据访问模式设计 Schema,而非照搬关系型数据库的范式设计
- 一起访问的数据一起存储
- 尽早规划并迭代:在开发早期规划和设计 Schema,MongoDB 的灵活模式允许迭代式设计
- 考虑数据演进:设计时要预判未来数据结构和访问模式的变化
四、常见面试题
面试题一:MongoDB 中嵌入(Embedding)和引用(Referencing)的区别是什么?如何选择?
参考答案:
区别:
- 嵌入:将相关数据作为子文档直接存储在父文档内部。数据在单个文档中,一次查询即可获取全部。
- 引用 :通过
_id在不同集合间建立关联,数据分别存储,查询时需要通过$lookup或多次查询组装。
选择原则:
- 优先考虑嵌入,当数据一起访问、关系为"一对少"、需要原子操作时
- 使用引用,当关联数据可能无限增长、频繁独立更新、或为"多对多"关系时
- 核心决策依据是访问模式而非实体关系
面试题二:什么是属性模式(Attribute Pattern)?它解决了什么问题?
参考答案:
属性模式是一种 Schema 设计模式,将文档中多个具有共同特征的相似字段合并到一个键值对数组中。
解决的问题:
- 避免为每个相似字段创建独立索引,减少索引数量
- 处理动态、不可预知的字段名
- 避免稀疏字段导致的索引效率问题
典型场景:多国上映日期、多语言内容、产品多维度属性。
面试题三:子集模式(Subset Pattern)和扩展引用模式(Extended Reference Pattern)有何区别?
参考答案:
| 维度 | 子集模式 | 扩展引用模式 |
|---|---|---|
| 目的 | 控制文档大小,避免无界数组 | 减少跨集合 JOIN 操作 |
| 手段 | 将完整数据放在单独集合,主文档只保留高频子集 | 将关联文档的常用字段复制到父文档 |
| 数据流向 | 主文档 → 子集(主文档保留部分) | 关联文档 → 父文档(父文档获得冗余字段) |
| 典型场景 | 图书评论、操作日志 | 订单中的客户信息、商品快照 |
面试题四:MongoDB 文档有 16MB 大小限制,如何设计 Schema 避免超出?
参考答案:
- 使用引用代替嵌入:将大型子数组(如评论、历史记录)移到独立集合
- 使用子集模式:主文档只保留最常访问的数据子集
- 使用桶模式(Bucket Pattern) :将时间序列数据按时间分段存储
- 拆分文档:将大型文档按业务维度拆分为多个相关文档
- 使用 GridFS:存储大型文件(如图片、视频)