图书馆到底该怎么摆书?MongoDB数据模型入门

上期回顾
上一篇我们聊了 MongoDB 的索引。
索引解决的是一个问题:
书太多了,怎么快速找到想要的书?
但当图书馆越来越大,又会遇到另一个问题:
书和书之间的相关资料,到底应该怎么摆?
作者信息放在哪里?
出版社信息放在哪里?
借阅记录又应该放在哪里?
这其实就是 MongoDB 中的数据模型(Data Model)。
1. 什么是数据模型
图书馆新书入库
图书馆新进了一批书,管理员开始为这些书建立档案。
比如《三体》,除了书名之外,还有很多相关信息:
书名:三体
作者:刘慈欣
出版社:重庆出版社
分类:科幻
出版时间:2008年管理员很快发现了一个问题:
这些信息到底应该全部放在同一个档案里,还是分别建立不同的档案?
例如,可以把所有信息都放在一起:
《三体》档案
├── 书名:三体
├── 作者:刘慈欣
├── 出版社:重庆出版社
└── 分类:科幻也可以分别建立档案:
书籍档案
《三体》
作者编号:10001
↓
作者档案
编号:10001
姓名:刘慈欣两种方式都可以,但适合的场景并不一样。
这其实就是数据库设计中一个非常重要的问题:
数据到底应该怎么组织和存储?
决定数据如何组织、哪些数据放在一起、哪些数据分开存储的方式,就叫做数据模型。
2. MongoDB的数据模型
文档、集合与字段
MongoDB 使用的是文档模型。
一条图书信息可以保存成一个文档:
{
name: "三体",
author: "刘慈欣",
publisher: "重庆出版社",
category: "科幻"
}其中:
name、author、publisher等叫做字段(Field)- 一条完整的数据叫做文档(Document)
- 多个文档放在一起组成一个集合(Collection)
可以简单理解为:
集合 books
│
├── 文档:《三体》
│ ├── name
│ ├── author
│ └── category
│
├── 文档:《流浪地球》
│
└── 文档:《球状闪电》如果简单和关系型数据库进行对比:
关系型数据库
数据库
└── 表
└── 行
└── 列
MongoDB
数据库
└── 集合 Collection
└── 文档 Document
└── 字段 Field当然,两者并不是完全等价的,这里只是为了方便理解。
MongoDB 的一个重要特点,就是文档可以使用比较灵活的结构。
例如,我们可以把作者信息直接放进书籍文档:
{
name: "三体",
author: {
name: "刘慈欣",
country: "中国"
},
category: "科幻"
}也可以只保存作者编号:
{
name: "三体",
authorId: 10001,
category: "科幻"
}那么问题来了:
作者的信息,到底应该直接放进书里,还是单独建立一个作者档案?
这就涉及 MongoDB 数据建模中最基础的两种方式:
嵌入(Embedding)和引用(Reference)。
3. 第一种摆法:放在一起
嵌入式数据
回到图书馆。
管理员决定,把《三体》的相关资料全部放进同一个档案袋:
《三体》档案
├── 书名:三体
├── 作者
│ ├── 姓名:刘慈欣
│ └── 国籍:中国
├── 出版社:重庆出版社
└── 分类:科幻以后有人想查询《三体》,管理员只需要找到这一个档案袋,就能看到书籍和作者等相关信息。
不需要再去另一个档案室查找。
这就是 MongoDB 中的嵌入式数据(Embedding)。
MongoDB示例
在 MongoDB 中,可以把相关数据直接嵌入到同一个文档中:
{
name: "三体",
author: {
name: "刘慈欣",
country: "中国"
},
publisher: "重庆出版社",
category: "科幻"
}这里的 author 不再只是一个普通字符串,而是一个嵌套对象。
书籍信息和作者信息被放在了同一个文档中。
嵌入有什么好处?
最明显的好处就是:
需要的数据都放在一起,查询时一次就能拿到。
例如:
db.books.findOne({ name: "三体" })返回的数据中已经包含:
- 书名
- 作者
- 出版社
- 分类
不需要再根据作者编号,继续查询一次作者资料。
对于一些经常一起使用的数据,这种方式非常方便。
例如用户的基本信息和联系方式:
{
name: "张三",
age: 30,
contact: {
phone: "13800138000",
email: "zhangsan@example.com"
}
}查看用户资料时,通常也会同时查看联系方式,那么把它们放在一起就比较合理。
除了查询方便,嵌入还有一个重要特点:
同一个文档中的数据可以通过一次原子操作一起更新。
例如修改用户的手机号:
db.users.updateOne(
{ name: "张三" },
{
$set: {
"contact.phone": "13900139000"
}
}
)这次更新只涉及一个文档,不需要跨多个集合进行操作。
嵌入也不是越多越好
既然把数据放在一起这么方便,是不是所有数据都应该塞进一个文档?
当然不是。
还是回到《三体》。
假设刘慈欣写了很多本书:
刘慈欣
├── 三体
├── 流浪地球
├── 球状闪电
├── 超新星纪元
└── ……如果每一本书的文档里,都嵌入完整的作者资料:
{
name: "三体",
author: {
name: "刘慈欣",
country: "中国"
}
}{
name: "流浪地球",
author: {
name: "刘慈欣",
country: "中国"
}
}那么作者的信息就被重复保存了很多次。
如果作者资料发生变化,也可能需要修改多份数据。
另外,如果嵌入的数据不断增加,例如一本书保存了几十万条评论,整个文档也会越来越大。
所以:
嵌入并不是简单地“能放进去就全部放进去”。
还需要考虑:
- 数据是不是经常一起使用?
- 数据会不会大量重复?
- 数据会不会不断增长?
如果数据需要独立维护,而且可能被很多文档共同使用,那么图书馆可能就需要换一种摆放方式。
4. 第二种摆法:分开存放
引用式数据
上一章中,我们把作者资料直接放进了每一本书的档案中。
这种方式查询很方便,但如果一个作者写了很多本书,就会出现一个问题:
同一份作者资料被重复保存了很多次。
于是,图书馆管理员决定换一种方式。
这一次,不再把作者的完整资料放进每一本书的档案袋,而是专门建立一个“作者档案室”。
书籍档案 作者档案
《三体》 编号:10001
├── 书名:三体 姓名:刘慈欣
└── 作者编号:10001 → 国籍:中国
《流浪地球》
├── 书名:流浪地球
└── 作者编号:10001《三体》和《流浪地球》都只保存作者编号 10001。
当需要查看作者的详细信息时,再根据这个编号,到作者档案中查找。
这就是 MongoDB 中的引用式数据(Reference)。
MongoDB示例
首先,单独建立一个作者集合:
// authors
{
_id: 10001,
name: "刘慈欣",
country: "中国"
}然后,在书籍集合中只保存作者的编号:
// books
{
name: "三体",
authorId: 10001,
publisher: "重庆出版社",
category: "科幻"
}另一本书也可以引用同一个作者:
// books
{
name: "流浪地球",
authorId: 10001,
publisher: "中国青年出版社",
category: "科幻"
}这样,刘慈欣的详细资料只需要保存一份。
多本书通过 authorId 找到同一个作者。
引用有什么好处?
1. 减少重复数据
假设一个作者写了100本书。
如果使用嵌入式数据,作者姓名、国籍等信息可能需要重复保存100次。
而使用引用后:
作者资料 × 1
书籍
├── 三体 → authorId: 10001
├── 流浪地球 → authorId: 10001
├── 球状闪电 → authorId: 10001
└── ……作者资料只保存一份即可。
2. 数据可以独立维护
例如图书馆需要更新作者资料。
只需要修改作者档案:
db.authors.updateOne(
{ _id: 10001 },
{
$set: {
awards: ["雨果奖"]
}
}
)不需要一本一本修改书籍档案。
3. 更适合复杂的数据关系
例如:
- 一个作者可以写多本书
- 一本书可能有多个作者
- 一本书可以属于多个分类
- 一个出版社可以出版很多本书
当数据之间的关系越来越复杂时,把所有信息都塞进一个文档,可能会让文档越来越大,也越来越难维护。
这时可以把不同的数据分别保存,再通过编号建立关联。
例如一本书有多个作者:
{
name: "数据库原理",
authorIds: [10001, 10002]
}通过两个作者编号,就可以找到对应的作者资料。
引用也有代价
引用并不是没有成本。
回到图书馆。
如果读者只想知道《三体》的书名,那么管理员只需要找到书籍档案。
但如果读者还想知道:
“这本书是谁写的?作者还有哪些资料?”
管理员可能需要:
查询书籍档案
↓
获得 authorId
↓
查询作者档案
↓
获得作者详细资料例如先查询书籍:
const book = await db.books.findOne({
name: "三体"
});再根据 authorId 查询作者:
const author = await db.authors.findOne({
_id: book.authorId
});也就是说:
数据分开存放后,获取完整信息可能需要额外的查询。
当然,MongoDB 也提供了 $lookup 等方式,可以帮助我们查询相关集合中的数据。
这里暂时不展开,后面介绍 MongoDB 聚合时再详细讲。
5. 一本书有很多作者怎么办?
前面我们已经知道,MongoDB 中的数据可以选择嵌入,也可以选择引用。
但在实际项目中,数据之间往往不是简单的“属于谁”。
例如:
一本书可能有一个作者,也可能有多个作者。
一个作者也可能写过很多本书。
那么,这些数据之间到底是什么关系?
在数据库设计中,我们经常会遇到三种关系:
- 一对一
- 一对多
- 多对多
5.1 一对一
先看一个简单的例子。
图书馆给每本书建立一个详细档案,每本书只有一个档案:
一本书
↓
一个档案这种就是一对一关系。
在 MongoDB 中,这种数据通常可以直接嵌入。
例如:
{
name: "三体",
isbn: "9787536692930",
libraryInfo: {
shelf: "A-12",
status: "available"
}
}书籍和它的馆藏信息经常一起使用,而且馆藏信息通常不会独立存在,所以放在同一个文档中比较自然。
当然,也可以把馆藏信息单独保存,通过 bookId 进行引用。
所以:
一对一并不意味着一定要嵌入。
最终还是要看实际的使用方式。
5.2 一对多
再来看图书馆最常见的一种情况。
一个作者可以写很多本书:
刘慈欣
├── 三体
├── 流浪地球
├── 球状闪电
└── 超新星纪元这就是一对多关系。
一个作者对应多本书。
这种情况下,我们可能有两种设计方式。
方式一:把书嵌入作者
{
name: "刘慈欣",
books: [
{
name: "三体"
},
{
name: "流浪地球"
}
]
}这样查询作者时,可以直接拿到他的所有书。
但这里有一个问题:
如果一个作者有几百本、几千本书呢?
作者文档会不断变大。
而且查询某一本书时,也没有必要把作者的所有书都拿出来。
因此,对于可能不断增长的数据,需要谨慎使用这种嵌入方式。
方式二:书籍独立保存
另一种方式是让每本书成为独立文档:
{
name: "三体",
authorId: 10001
}{
name: "流浪地球",
authorId: 10001
}作者单独保存:
{
_id: 10001,
name: "刘慈欣"
}这样:
作者
↓
authorId
↓
多本独立的书籍文档即使作者的书越来越多,也不会导致作者文档无限增长。
所以,对于数量可能不断增加的一对多关系,把“多”的一方独立保存,通常更加合适。
5.3 多对多
最后来看一个更加复杂的情况。
一本书可能有多个作者:
《数据库原理》
├── 作者A
└── 作者B而一个作者也可能参与多本书:
作者A
├── 《数据库原理》
├── 《MongoDB入门》
└── 《分布式系统》这就是多对多关系。
在 MongoDB 中,可以在书籍文档中保存多个作者的 ID:
{
name: "数据库原理",
authorIds: [10001, 10002]
}作者仍然单独保存:
{
_id: 10001,
name: "作者A"
}{
_id: 10002,
name: "作者B"
}这样一本书就可以关联多个作者。
5.4 关系越复杂,越要考虑数据访问方式
看到这里,可能有人会产生一个疑问:
MongoDB 不是文档数据库吗?
为什么也要考虑一对一、一对多、多对多?
因为:
数据库类型不同,并不意味着现实中的数据关系消失了。
图书馆中依然存在:
一本书 → 一个出版社
一个作者 → 多本书
一本书 → 多个作者
一个读者 → 多次借阅MongoDB 只是提供了不同的数据组织方式,让我们根据实际业务选择:
经常一起使用
↓
嵌入
需要独立维护
↓
引用
数据数量可能不断增长
↓
谨慎嵌入,考虑独立保存所以,一对一、一对多、多对多只是帮助我们分析数据关系的方法,并不是决定 MongoDB 数据结构的唯一标准。
真正决定数据应该怎么设计的,还是:
数据以后会怎么被查询、修改和使用?
总结
图书馆整理书籍时,需要考虑的不只是:
“书放在哪里?”
还要考虑:
“以后怎么找?”
“哪些资料需要一起查看?”
“哪些资料需要独立维护?”
“数据会不会越来越多?”
MongoDB 的数据模型也是如此。
嵌入,就像把相关资料放进同一个档案袋,查询时可以一次拿到;
引用,就像书和作者分别建立档案,通过编号找到对方。
而面对一对一、一对多、多对多的数据关系时,也不能简单地套用某一种方案。
MongoDB 数据模型设计,不是先想“数据应该怎么存”,而是先想“数据以后怎么用”。
这也是 MongoDB 数据建模最值得记住的一句话。
下一篇,我们继续留在这座图书馆。
不过这一次,图书馆开始变大了……
书越来越多、借阅记录越来越长、热门书甚至出现了几十万条评论。
简单的“放在一起”或者“分开存放”,已经不一定够用了。
那么,MongoDB 又该怎么设计?