
图书馆越来越大,书该怎么摆?MongoDB 数据建模进阶(一)
上一篇我们聊了 MongoDB 数据模型最基础的两种设计方式:
相关的数据,是放在一起,还是分开存?
也就是:
- 嵌入式数据(Embedding)
- 引用式数据(Reference)
当图书馆规模还比较小时,这两种方式基本可以满足大多数需求。
但图书馆慢慢变大以后,新的问题也会出现。
比如,一本书被借阅了一次,就会产生一条借阅记录;日积月累以后,一本热门图书可能拥有几十万条甚至更多历史记录。
这时候我们就会发现:
数据不是简单地“存进去”就结束了。
随着数据越来越多,还要考虑:
- 哪些数据需要经常查询?
- 哪些数据只是历史记录?
- 某些数据会不会无限增长?
- 如果少数数据特别大,该怎么办?
这一篇,我们先解决两个非常典型的问题:
数据很多,但不是所有数据都需要放在一起。
以及:
如果只有少数数据特别大,该怎么办?
一、一本书,为什么会有这么多借阅记录?
先来看一个简单的场景。
图书馆里有一本书:
《三体》
每当有人借阅这本书,系统就产生一条借阅记录。
例如:
{
bookId: 10001,
userId: 20001,
borrowDate: "2026-09-01",
returnDate: "2026-09-10"
}刚开始的时候,这本书可能只借出去过几次。
《三体》
借阅记录:5 条过了一段时间:
借阅记录:500 条几年以后:
借阅记录:100,000 条如果我们一开始采用嵌入式设计,把这些借阅记录全部放进图书文档里,就可能变成这样:
{
_id: 10001,
name: "三体",
author: "刘慈欣",
borrowRecords: [
{
userId: 20001,
borrowDate: "2026-09-01",
returnDate: "2026-09-10"
},
{
userId: 20002,
borrowDate: "2026-08-20",
returnDate: "2026-08-30"
},
{
userId: 20003,
borrowDate: "2026-08-01",
returnDate: "2026-08-10"
}
// 后面还有很多……
]
}刚开始可能没什么感觉。
但随着借阅记录不断增加,这个文档也会越来越大。
于是问题来了:
用户打开一本书的详情页时,真的需要这 10 万条借阅记录吗?
显然不需要。
二、读者真正需要看到的是什么?
假设一个普通读者打开《三体》的详情页。
他通常关心的是:
《三体》
作者:刘慈欣
当前在馆库存:2 本可能还会看到:
最近借阅情况但他通常并不需要看到:
历史借阅记录:10 万更不需要把这些历史数据全部加载到前端。
换句话说:
完整借阅记录很多,但当前页面真正需要的数据很少。
这时候,我们就没有必要把所有借阅记录都放在图书主文档里。
三、只把常用的数据放在主文档里
一种更合理的设计方式是:
图书文档只保存图书本身以及页面经常需要的数据。
例如:
{
_id: 10001,
name: "三体",
author: "刘慈欣",
stock: 2
}完整借阅记录单独存储:
{
bookId: 10001,
userId: 20001,
borrowDate: "2026-09-01",
returnDate: "2026-09-10"
}于是数据就变成了:
books
└── 《三体》
borrowRecords
├── 第 1 条借阅记录
├── 第 2 条借阅记录
├── 第 3 条借阅记录
└── ...这样,查询图书详情时,只需要读取:
《三体》
作者:刘慈欣
在馆库存:2如果确实需要查询历史借阅记录,再去 borrowRecords 集合中查询。
这其实就是把数据按照“使用频率”分开
图书详情:
经常查询
历史借阅记录:
只有部分场景需要查询
既然使用方式不同,就没有必要全部塞进同一个文档。
四、为什么这样设计更合理?
1. 查询的数据更少
普通用户查看图书详情时,不需要把大量历史记录全部读取出来。
例如:
原来的方式:
查询图书
↓
读取 10 万条借阅记录
↓
实际上只显示图书名称和库存就显得非常浪费。
拆开以后:
查询图书
↓
读取图书基本信息
↓
直接返回结果简单很多。
2. 主文档不会不断膨胀
借阅记录有一个非常明显的特点:
它会不断增加。
一本书今年可能有 1 万条记录,明年可能变成 2 万条。
如果全部嵌入图书文档,那么:
图书文档
↓
越来越大
↓
越来越难维护而单独保存以后:
图书文档
↓
保持相对稳定
借阅记录
↓
独立增长两者的生命周期就被分开了。
MongoDB 单个 BSON 文档还有 16 MB 的大小限制,所以对于这种可能持续增长的数据,尤其需要谨慎设计。
3. 不同业务可以查询不同的数据
普通读者打开图书详情:
只需要:
图书信息
库存图书馆后台查看借阅历史:
需要:
借阅记录
借阅时间
归还时间
读者信息这两个业务的查询需求本来就不同。
把它们拆开之后,反而更加清晰。
4. 也更容易控制数据访问范围
还有一个经常被忽视的问题:
不是所有数据都应该返回给所有人。
例如图书详情页没有必要返回完整的借阅记录,更不应该因为查询一本书,就把历史读者相关信息全部返回给前端。
因此,把数据按使用场景拆开,不仅是性能问题,也有助于权限和隐私控制。
当然,真正的权限控制仍然应该由后端接口和访问控制机制负责,不能单靠数据结构解决。
五、是不是看到“大数组”就应该拆?
也不是。
例如一本书可能有几个标签:
{
name: "三体",
tags: [
"科幻",
"小说",
"中国"
]
}这个数组非常小,而且查看图书时通常也需要这些信息。
完全没必要把它单独拆出去。
所以:
并不是数组就一定要拆。
真正要考虑的是两个问题:
第一,它是否会持续增长?
例如:
tags ➡️ 通常只有几个
而:
borrowRecords ➡️ 可能不断增加
两者显然不同。
第二,查询时是否经常一起使用?
例如:
图书名称
作者
标签
库存这些通常会一起展示。
而:十万条历史借阅记录
显然没有必要跟着图书详情一起查询。
因此,是否拆分数据,最终还是要回到:
数据以后怎么被使用?
六、这就是“只保留常用的数据”
我们重新看《三体》。
完整的数据可能非常多:
借阅记录:100,000 条但是图书详情真正需要的可能只有:
书名
作者
库存于是我们让主文档只负责这些高频使用的数据:
{
_id: 10001,
name: "三体",
author: "刘慈欣",
stock: 2
}而完整借阅记录放到另外的集合里。
这其实就是一个非常朴素的思想:
完整数据很多,但主文档只保留真正常用的那一部分。
对应的专业名称
在 MongoDB 数据建模中,这种设计方式通常称为:
Subset Pattern(子集模式)
这里的 Subset,就是“子集”的意思。
你可以简单理解成:
完整数据是一大份,主文档只放其中最常用的一小部分。
所以以后看到 Subset Pattern,直接想到:
只保留常用部分。
七、如果只有少数几本书特别大呢?
解决了“数据很多,但主文档不需要全部保存”之后,又出现了另一个问题。
假设图书馆有:
10 万本图书。
其中大多数都很正常。
比如:
《历史是一群喵》
借阅记录:200 条
《薛定谔的喵》
借阅记录:500 条
《活了100万次的猫》
借阅记录:800 条但突然有一本书特别热门:
《三体》
它可能已经积累了:
借阅记录:300 万条这时候就出现了一个非常明显的差异:
绝大多数图书
↓
几百条记录
少数热门图书
↓
几百万条记录这种情况应该怎么办?
八、不能为了特殊情况,把所有数据都搞复杂
假设我们给所有图书都设计成一种复杂结构:
普通图书
↓
特殊数据处理
热门图书
↓
特殊数据处理虽然可以解决热门图书的问题,但也会让所有普通图书变复杂。
实际上:
真正需要特殊处理的,只是少数几本书。
那么一个更合理的思路就是:
普通情况正常处理,特殊情况单独处理。
这和现实中的图书馆其实很像。
九、图书馆也会给热门图书特殊安排
普通图书:
普通书架热门图书:
热门图书专区因为热门图书的借阅需求明显不同,所以可以采用不同的管理方式。
MongoDB 数据建模也可以这么做。
普通图书:
{
_id: 10001,
name: "普通图书",
stock: 3
}热门图书:
{
_id: 10002,
name: "三体",
stock: 2,
isOutlier: true,
recordStorage: "external"
}但是《三体》的海量历史借阅记录,不需要全部塞进图书文档。
继续放在独立的借阅记录集合里:
{
bookId: 10002,
userId: 20001,
borrowDate: "2026-09-15",
returnDate: "2026-09-20"
}这样:
普通图书 ➡️ 普通结构
特别热门的图书 ➡️ 单独关注它的大量关联数据不会因为一本书特别热门,就让所有图书的数据模型都变复杂。
十、什么样的数据算“特殊”?
这里要特别注意。
所谓“特殊数据”,并不是简单地说:
“只要数据多,就必须特殊处理。”
真正需要关注的是:
它是否明显偏离了系统中绝大多数数据的规模或访问方式。
例如有 100 万个用户:
99.9 万用户
↓
几十条操作记录
1000 个用户
↓
几百万条操作记录那么最后这少数用户就是很明显的异常数据。
再比如:
- 绝大多数商品销量只有几百
- 少数爆款销量达到几百万
- 绝大多数文章访问量很低
- 少数热门文章访问量极高
- 绝大多数设备每天产生少量数据
- 少数设备产生海量数据
这些都属于类似的问题。
重点不是:
数据绝对有多少。
而是:
它是不是明显偏离了正常情况。
判断特殊数据,不只是看数据量,也要看访问频率。有些文档数据量不大,但被访问的次数远远高于其他文档,也属于 Outlier。
十一、为什么要特殊处理这些数据?
因为我们不希望:
极少数异常数据,影响整个系统大多数正常数据的设计。
例如:
100 万本图书
99.99 万本
↓
普通规模
100 本
↓
极端热门如果为了这 100 本书,把所有图书的模型都设计得非常复杂,就有些得不偿失。
更合理的思路是:
普通数据
↓
保持简单
特殊数据
↓
特殊处理这样既能解决异常情况,又不会让整个系统变得过于复杂。
十二、这就是“特殊数据特殊处理”
所以,当你发现:
绝大多数数据都很正常,只有极少数数据特别大。
就可以考虑:
不要让这些异常数据拖累整个数据模型。
普通数据继续按照普通方式设计。
特殊数据,则针对它的特点进行单独处理。
对应的专业名称
在 MongoDB 数据建模中,这种:
绝大多数数据正常,少数特殊数据单独处理
的设计方式,通常称为:
Outlier Pattern(离群模式)
Outlier 可以简单理解为:
从普通数据中“突出出来”的特殊数据。
所以它很好记:
Outlier:特殊数据特殊处理。
十三、Subset 和 Outlier 有什么区别?
这两个 Pattern 很容易混在一起。
其实它们解决的是两个不同的问题。
Subset Pattern
关注的是:
一条数据关联了很多信息,但平时只需要其中一部分。
例如:
一本书
↓
10 万条借阅记录
图书详情
↓
只需要书名、作者、库存核心思想:
只把常用的数据保留在主文档里。
Outlier Pattern
关注的是:
大多数数据都正常,但少数数据特别特殊。
例如:
大多数图书
↓
几百条借阅记录
少数热门图书
↓
几百万条借阅记录核心思想:
不要让特殊数据拖累普通数据的设计。
把它们放在一起就更容易理解:
Subset
↓
“数据很多,但我平时只需要一部分。”
Outlier
↓
“绝大多数数据正常,只有少数特别大。”而且在真实项目中,两种模式完全可以同时出现。
例如《三体》是一本文档规模特别大的热门书:
《三体》
↓
借阅记录特别多
↓
Outlier与此同时:
完整借阅记录
↓
数量巨大
图书详情
↓
只需要少量信息又可以使用:
Subset所以:
不同 Pattern 并不是互斥的。
在实际项目中,它们可能组合起来解决一个更复杂的问题。
十四、回到最开始的问题
这篇文章我们一直在讨论:
图书馆的数据越来越多,怎么办?
现在可以得到两个答案。
如果是:
“数据很多,但当前业务根本不需要全部数据。”
可以考虑:
Subset Pattern
如果是:
“大多数数据都正常,只有极少数数据特别大。”
可以考虑:
Outlier Pattern
它们背后其实还是上一篇文章里那句话:
数据模型不是为了让数据“存得进去”,而是为了让数据“以后好用”。
十五、最后记住这两句话
Subset Pattern
数据很多,但主文档只保留常用的那一部分。
Outlier Pattern
绝大多数数据正常,少数特殊数据单独处理。
以后看到这两个英文名词,不需要死记定义。
只要记住它们解决的问题:
数据太多,只需要一部分
↓
Subset
少数数据特别异常
↓
Outlier这就够了。
MongoDB 数据建模真正重要的,从来不是记住多少个 Pattern,而是看到一个具体业务问题时,能够判断:
这个数据未来会怎么增长?
哪些数据会被经常查询?
哪些数据其实没必要和主数据放在一起?
想清楚这些问题,很多数据模型其实就已经有答案了。