Skip to content

图书馆越来越大,书该怎么摆?MongoDB 数据建模进阶(一)

上一篇我们聊了 MongoDB 数据模型最基础的两种设计方式:

相关的数据,是放在一起,还是分开存?

也就是:

  • 嵌入式数据(Embedding)
  • 引用式数据(Reference)

当图书馆规模还比较小时,这两种方式基本可以满足大多数需求。

但图书馆慢慢变大以后,新的问题也会出现。

比如,一本书被借阅了一次,就会产生一条借阅记录;日积月累以后,一本热门图书可能拥有几十万条甚至更多历史记录。

这时候我们就会发现:

数据不是简单地“存进去”就结束了。

随着数据越来越多,还要考虑:

  • 哪些数据需要经常查询?
  • 哪些数据只是历史记录?
  • 某些数据会不会无限增长?
  • 如果少数数据特别大,该怎么办?

这一篇,我们先解决两个非常典型的问题:

数据很多,但不是所有数据都需要放在一起。

以及:

如果只有少数数据特别大,该怎么办?

一、一本书,为什么会有这么多借阅记录?

先来看一个简单的场景。

图书馆里有一本书:

《三体》

每当有人借阅这本书,系统就产生一条借阅记录。

例如:

json
{
    bookId: 10001,
    userId: 20001,
    borrowDate: "2026-09-01",
    returnDate: "2026-09-10"
}

刚开始的时候,这本书可能只借出去过几次。

text
《三体》

借阅记录:5 条

过了一段时间:

text
借阅记录:500 条

几年以后:

text
借阅记录:100,000 条

如果我们一开始采用嵌入式设计,把这些借阅记录全部放进图书文档里,就可能变成这样:

js
{
    _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 万条借阅记录吗?

显然不需要。

二、读者真正需要看到的是什么?

假设一个普通读者打开《三体》的详情页。

他通常关心的是:

text
《三体》

作者:刘慈欣

当前在馆库存:2 本

可能还会看到:

text
最近借阅情况

但他通常并不需要看到:

text
历史借阅记录:10 万

更不需要把这些历史数据全部加载到前端。

换句话说:

完整借阅记录很多,但当前页面真正需要的数据很少。

这时候,我们就没有必要把所有借阅记录都放在图书主文档里。

三、只把常用的数据放在主文档里

一种更合理的设计方式是:

图书文档只保存图书本身以及页面经常需要的数据。

例如:

json
{
    _id: 10001,
    name: "三体",
    author: "刘慈欣",
    stock: 2
}

完整借阅记录单独存储:

json
{
    bookId: 10001,
    userId: 20001,
    borrowDate: "2026-09-01",
    returnDate: "2026-09-10"
}

于是数据就变成了:

text
books
└── 《三体》

borrowRecords
├── 第 1 条借阅记录
├── 第 2 条借阅记录
├── 第 3 条借阅记录
└── ...

这样,查询图书详情时,只需要读取:

text
《三体》
作者:刘慈欣
在馆库存:2

如果确实需要查询历史借阅记录,再去 borrowRecords 集合中查询。

这其实就是把数据按照“使用频率”分开

图书详情:

经常查询

历史借阅记录:

只有部分场景需要查询

既然使用方式不同,就没有必要全部塞进同一个文档。

四、为什么这样设计更合理?

1. 查询的数据更少

普通用户查看图书详情时,不需要把大量历史记录全部读取出来。

例如:

text
原来的方式:

查询图书

读取 10 万条借阅记录

实际上只显示图书名称和库存

就显得非常浪费。

拆开以后:

text
查询图书

读取图书基本信息

直接返回结果

简单很多。

2. 主文档不会不断膨胀

借阅记录有一个非常明显的特点:

它会不断增加。

一本书今年可能有 1 万条记录,明年可能变成 2 万条。

如果全部嵌入图书文档,那么:

text
图书文档

越来越大

越来越难维护

而单独保存以后:

text
图书文档

保持相对稳定

借阅记录

独立增长

两者的生命周期就被分开了。

MongoDB 单个 BSON 文档还有 16 MB 的大小限制,所以对于这种可能持续增长的数据,尤其需要谨慎设计。

3. 不同业务可以查询不同的数据

普通读者打开图书详情:

text
只需要:
图书信息
库存

图书馆后台查看借阅历史:

text
需要:
借阅记录
借阅时间
归还时间
读者信息

这两个业务的查询需求本来就不同。

把它们拆开之后,反而更加清晰。

4. 也更容易控制数据访问范围

还有一个经常被忽视的问题:

不是所有数据都应该返回给所有人。

例如图书详情页没有必要返回完整的借阅记录,更不应该因为查询一本书,就把历史读者相关信息全部返回给前端。

因此,把数据按使用场景拆开,不仅是性能问题,也有助于权限和隐私控制。

当然,真正的权限控制仍然应该由后端接口和访问控制机制负责,不能单靠数据结构解决。

五、是不是看到“大数组”就应该拆?

也不是。

例如一本书可能有几个标签:

js
{
    name: "三体",
    tags: [
        "科幻",
        "小说",
        "中国"
    ]
}

这个数组非常小,而且查看图书时通常也需要这些信息。

完全没必要把它单独拆出去。

所以:

并不是数组就一定要拆。

真正要考虑的是两个问题:

第一,它是否会持续增长?

例如:

tags ➡️ 通常只有几个

而:

borrowRecords ➡️ 可能不断增加

两者显然不同。

第二,查询时是否经常一起使用?

例如:

text
图书名称
作者
标签
库存

这些通常会一起展示。

而:十万条历史借阅记录

显然没有必要跟着图书详情一起查询。

因此,是否拆分数据,最终还是要回到:

数据以后怎么被使用?

六、这就是“只保留常用的数据”

我们重新看《三体》。

完整的数据可能非常多:

text
借阅记录:100,000 条

但是图书详情真正需要的可能只有:

text
书名
作者
库存

于是我们让主文档只负责这些高频使用的数据:

json
{
    _id: 10001,
    name: "三体",
    author: "刘慈欣",
    stock: 2
}

而完整借阅记录放到另外的集合里。

这其实就是一个非常朴素的思想:

完整数据很多,但主文档只保留真正常用的那一部分。

对应的专业名称

在 MongoDB 数据建模中,这种设计方式通常称为:

Subset Pattern(子集模式)

这里的 Subset,就是“子集”的意思。

你可以简单理解成:

完整数据是一大份,主文档只放其中最常用的一小部分。

所以以后看到 Subset Pattern,直接想到:

只保留常用部分。

七、如果只有少数几本书特别大呢?

解决了“数据很多,但主文档不需要全部保存”之后,又出现了另一个问题。

假设图书馆有:

10 万本图书。

其中大多数都很正常。

比如:

text
《历史是一群喵》
借阅记录:200 条

《薛定谔的喵》
借阅记录:500 条

《活了100万次的猫》
借阅记录:800 条

但突然有一本书特别热门:

《三体》

它可能已经积累了:

text
借阅记录:300 万条

这时候就出现了一个非常明显的差异:

text
绝大多数图书

几百条记录

少数热门图书

几百万条记录

这种情况应该怎么办?

八、不能为了特殊情况,把所有数据都搞复杂

假设我们给所有图书都设计成一种复杂结构:

text
普通图书

特殊数据处理

热门图书

特殊数据处理

虽然可以解决热门图书的问题,但也会让所有普通图书变复杂。

实际上:

真正需要特殊处理的,只是少数几本书。

那么一个更合理的思路就是:

普通情况正常处理,特殊情况单独处理。

这和现实中的图书馆其实很像。

九、图书馆也会给热门图书特殊安排

普通图书:

text
普通书架

热门图书:

text
热门图书专区

因为热门图书的借阅需求明显不同,所以可以采用不同的管理方式。

MongoDB 数据建模也可以这么做。

普通图书:

json
{
    _id: 10001,
    name: "普通图书",
    stock: 3
}

热门图书:

json
{
    _id: 10002,
    name: "三体",
    stock: 2,
    isOutlier: true,
    recordStorage: "external"
}

但是《三体》的海量历史借阅记录,不需要全部塞进图书文档。

继续放在独立的借阅记录集合里:

js
{
    bookId: 10002,
    userId: 20001,
    borrowDate: "2026-09-15",
    returnDate: "2026-09-20"
}

这样:

text
普通图书 ➡️ 普通结构

特别热门的图书 ➡️ 单独关注它的大量关联数据

不会因为一本书特别热门,就让所有图书的数据模型都变复杂。

十、什么样的数据算“特殊”?

这里要特别注意。

所谓“特殊数据”,并不是简单地说:

“只要数据多,就必须特殊处理。”

真正需要关注的是:

它是否明显偏离了系统中绝大多数数据的规模或访问方式。

例如有 100 万个用户:

text
99.9 万用户

几十条操作记录

1000 个用户

几百万条操作记录

那么最后这少数用户就是很明显的异常数据。

再比如:

  • 绝大多数商品销量只有几百
  • 少数爆款销量达到几百万
  • 绝大多数文章访问量很低
  • 少数热门文章访问量极高
  • 绝大多数设备每天产生少量数据
  • 少数设备产生海量数据

这些都属于类似的问题。

重点不是:

数据绝对有多少。

而是:

它是不是明显偏离了正常情况。

判断特殊数据,不只是看数据量,也要看访问频率。有些文档数据量不大,但被访问的次数远远高于其他文档,也属于 Outlier。

十一、为什么要特殊处理这些数据?

因为我们不希望:

极少数异常数据,影响整个系统大多数正常数据的设计。

例如:

text
100 万本图书

99.99 万本

普通规模

100 本

极端热门

如果为了这 100 本书,把所有图书的模型都设计得非常复杂,就有些得不偿失。

更合理的思路是:

text
普通数据

保持简单

特殊数据

特殊处理

这样既能解决异常情况,又不会让整个系统变得过于复杂。

十二、这就是“特殊数据特殊处理”

所以,当你发现:

绝大多数数据都很正常,只有极少数数据特别大。

就可以考虑:

不要让这些异常数据拖累整个数据模型。

普通数据继续按照普通方式设计。

特殊数据,则针对它的特点进行单独处理。

对应的专业名称

在 MongoDB 数据建模中,这种:

绝大多数数据正常,少数特殊数据单独处理

的设计方式,通常称为:

Outlier Pattern(离群模式)

Outlier 可以简单理解为:

从普通数据中“突出出来”的特殊数据。

所以它很好记:

Outlier:特殊数据特殊处理。

十三、Subset 和 Outlier 有什么区别?

这两个 Pattern 很容易混在一起。

其实它们解决的是两个不同的问题。

Subset Pattern

关注的是:

一条数据关联了很多信息,但平时只需要其中一部分。

例如:

text
一本书

10 万条借阅记录

图书详情

只需要书名、作者、库存

核心思想:

只把常用的数据保留在主文档里。

Outlier Pattern

关注的是:

大多数数据都正常,但少数数据特别特殊。

例如:

text
大多数图书

几百条借阅记录

少数热门图书

几百万条借阅记录

核心思想:

不要让特殊数据拖累普通数据的设计。

把它们放在一起就更容易理解:

text
Subset

“数据很多,但我平时只需要一部分。”

Outlier

“绝大多数数据正常,只有少数特别大。”

而且在真实项目中,两种模式完全可以同时出现。

例如《三体》是一本文档规模特别大的热门书:

text
《三体》

借阅记录特别多

Outlier

与此同时:

text
完整借阅记录

数量巨大

图书详情

只需要少量信息

又可以使用:

text
Subset

所以:

不同 Pattern 并不是互斥的。

在实际项目中,它们可能组合起来解决一个更复杂的问题。

十四、回到最开始的问题

这篇文章我们一直在讨论:

图书馆的数据越来越多,怎么办?

现在可以得到两个答案。

如果是:

“数据很多,但当前业务根本不需要全部数据。”

可以考虑:

Subset Pattern

如果是:

“大多数数据都正常,只有极少数数据特别大。”

可以考虑:

Outlier Pattern

它们背后其实还是上一篇文章里那句话:

数据模型不是为了让数据“存得进去”,而是为了让数据“以后好用”。

十五、最后记住这两句话

Subset Pattern

数据很多,但主文档只保留常用的那一部分。

Outlier Pattern

绝大多数数据正常,少数特殊数据单独处理。

以后看到这两个英文名词,不需要死记定义。

只要记住它们解决的问题:

text
数据太多,只需要一部分

Subset

少数数据特别异常

Outlier

这就够了。

MongoDB 数据建模真正重要的,从来不是记住多少个 Pattern,而是看到一个具体业务问题时,能够判断:

这个数据未来会怎么增长?

哪些数据会被经常查询?

哪些数据其实没必要和主数据放在一起?

想清楚这些问题,很多数据模型其实就已经有答案了。

上次更新于: