做 MongoDB 的人,刚开始都会被“文档数据库”这个名头迷住。觉得既然叫文档数据库,那把数据打包塞进一个 JSON 里岂不是最爽?
我见过太多项目,第一版设计时豪情万丈,把一个订单、它的商品明细、收货地址、用户信息、甚至评论全部塞进一个 orders 集合的大文档里。结果上线半年后,随着数据量上来,查询变慢,更新冲突频发,存储空间指数级爆炸。老板问为什么,开发者一脸无辜:“我只是想查询方便一点。”
其实,MongoDB 的数据模型设计,核心就在于一个权衡:嵌入(Embedding) 还是 引用(Referencing)。这俩选择做对了,系统轻盈飞快;做错了,就是无尽的 cursor timeout 和账单焦虑。
今天咱们不聊虚的,我就用我这些年踩过的坑和救过的火,给你拆解一下这套最佳实践。
别迷信“文档”:理解 BSON 的边界
首先,你得对 MongoDB 的底层存储有个敬畏之心。MongoDB 使用 BSON(Binary JSON)格式存储数据。BSON 文档有一个硬性的 size 限制:16MB。
听起来很大?是的,存几个 G 的文本都没问题。但问题是,当你试图把“所有相关数据都塞进一个文档”时,你很快就会发现两个致命伤:
- 重新分片(Rewriting)成本极高:如果你更新文档的一个字段,而该文档已经接近 16MB,或者该文档在磁盘上被其他碎片占据,MongoDB 可能需要在同一磁盘位置重写整个文档。这就好比你要修改一本 1000 页的书中的一句话,结果因为磁盘碎片化,你不得不把整本书重新抄写一遍。
- 查询效率下降:即使你不更新,当你只想要其中一个小字段时,数据库仍然需要读取整个大文档。如果这个文档经常不被完全访问,那就是 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 查出来计算
}
每次查询订单详情,你都需要:
- 读取
order_001。 - 拿着
prod_100去products集合查一次,拿到名字和当前价格。 - 拿着
prod_200去products集合查一次。 - 计算总价。
这不仅是多次数据库查询,还有个更严重的问题:价格不一致。如果商品 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
}
为什么要冗余?
- 查询性能:一次读取订单文档,所有信息齐备,无需额外 JOIN 或多次查找。
- 数据一致性:订单里的价格是“历史快照”,不受商品后续价格变动影响,财务准确。
- 读写分离的权衡:虽然写入订单时需要多存几个字段,但查询订单的频率远高于写入订单的频率。用轻微的写入成本换取巨大的读取性能提升,是值得的。
冗余的边界
当然,冗余也不是越多越好。
- 不要冗余经常变化的数据:比如用户的“最后登录时间”。如果把这个字段冗余到用户的所有订单里,每次用户登录,你就要更新他历史上一万条订单文档,这显然是愚蠢的。
- 只冗余“静态”或“快照”数据:商品名称、购买时的价格、收货地址等。这些信息在购买那一刻确定后,通常不再变化。
嵌套过深的陷阱:三层以上的噩梦
我见过最离谱的设计,是一个嵌套了 5 层的文档:
{
"company": {
"name": "Acme Corp",
"departments": [
{
"name": "Engineering",
"teams": [
{
"name": "Backend",
"members": [
{
"name": "Alice",
"skills": [
{ "name": "Java", "level": 5 },
{ "name": "Go", "level": 4 }
]
}
]
}
]
}
]
}
}
问题在哪里?
- 查询困难:你想找所有会 Java 的人?你得遍历整个
members.skills数组。如果这个结构再深一层,索引都帮不了你。 - 更新灾难:你想给 Alice 加一个 Python 技能?你需要定位到第五层数组,然后更新。如果多个团队同时更新 Alice 的技能,可能会有并发冲突。
- 可读性差:维护这种文档就像在迷宫里找出口。
- 违反单一职责:公司、部门、团队、成员、技能,这应该是五个不同的概念,五个不同的集合。把它们强行塞进一个文档,是设计懒惰的表现。
最佳实践:嵌套不超过 2-3 层
一般来说,文档内部嵌套层级建议控制在 2-3 层以内。超过这个深度,你应该考虑将其拆分为多个集合,用引用连接。
例如,上面的例子应该拆分为:
companies集合departments集合(通过company_id引用)teams集合(通过department_id引用)members集合(通过team_id引用)skills集合(通过member_id引用,或者嵌入在 member 文档中,因为一个成员的技能列表通常不大)
如何决定:嵌入还是引用?一个决策树
每次设计模型时,我问自己这三个问题:
问题 1:子文档的数量是否有上限?
- 是(如:一个用户只有一个个人资料,一篇文章的最近 10 条评论)-> 考虑嵌入。
- 否(如:一个订单的无限商品,一个社交帖子的无限点赞)-> 必须引用。
问题 2:子文档会被独立查询吗?
- 是(如:我需要按商品 ID 查找所有包含该商品的订单)-> 必须引用。
- 否(如:我只会通过订单 ID 查找订单,然后展示其明细)-> 考虑嵌入。
问题 3:子文档的数据是否会频繁独立更新?
- 是(如:评论会被单独点赞、删除、编辑)-> 必须引用。
- 否(如:订单明细在购买后基本不变)-> 考虑嵌入。
简单记忆口诀:
小、静、同生命周期 -> 嵌入 大、动、需独立查询 -> 引用
实战案例:电商系统的数据模型设计
让我们用一个具体的电商场景来巩固这些概念。假设我们要设计一个简化的电商后端。
集合划分
- Users(用户)
- Products(商品)
- Orders(订单)
- OrderItems(订单明细)- 这里我们选择引用,因为商品可能被多个订单购买,且需要独立统计销量
- 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"
