Skip to content

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

上期回顾

上一篇我们聊了 MongoDB 的索引。

索引解决的是一个问题:

书太多了,怎么快速找到想要的书?

但当图书馆越来越大,又会遇到另一个问题:

书和书之间的相关资料,到底应该怎么摆?

作者信息放在哪里?

出版社信息放在哪里?

借阅记录又应该放在哪里?

这其实就是 MongoDB 中的数据模型(Data Model)


1. 什么是数据模型

图书馆新书入库

图书馆新进了一批书,管理员开始为这些书建立档案。

比如《三体》,除了书名之外,还有很多相关信息:

text
书名:三体
作者:刘慈欣
出版社:重庆出版社
分类:科幻
出版时间:2008年

管理员很快发现了一个问题:

这些信息到底应该全部放在同一个档案里,还是分别建立不同的档案?

例如,可以把所有信息都放在一起:

text
《三体》档案
├── 书名:三体
├── 作者:刘慈欣
├── 出版社:重庆出版社
└── 分类:科幻

也可以分别建立档案:

text
书籍档案
《三体》
作者编号:10001



作者档案
编号:10001
姓名:刘慈欣

两种方式都可以,但适合的场景并不一样。

这其实就是数据库设计中一个非常重要的问题:

数据到底应该怎么组织和存储?

决定数据如何组织、哪些数据放在一起、哪些数据分开存储的方式,就叫做数据模型


2. MongoDB的数据模型

文档、集合与字段

MongoDB 使用的是文档模型

一条图书信息可以保存成一个文档:

js
{
    name: "三体",
    author: "刘慈欣",
    publisher: "重庆出版社",
    category: "科幻"
}

其中:

  • nameauthorpublisher 等叫做字段(Field)
  • 一条完整的数据叫做文档(Document)
  • 多个文档放在一起组成一个集合(Collection)

可以简单理解为:

text
集合 books

├── 文档:《三体》
│   ├── name
│   ├── author
│   └── category

├── 文档:《流浪地球》

└── 文档:《球状闪电》

如果简单和关系型数据库进行对比:

text
关系型数据库

数据库
└── 表
    └── 行
        └── 列


MongoDB

数据库
└── 集合 Collection
    └── 文档 Document
        └── 字段 Field

当然,两者并不是完全等价的,这里只是为了方便理解。

MongoDB 的一个重要特点,就是文档可以使用比较灵活的结构。

例如,我们可以把作者信息直接放进书籍文档:

js
{
    name: "三体",
    author: {
        name: "刘慈欣",
        country: "中国"
    },
    category: "科幻"
}

也可以只保存作者编号:

js
{
    name: "三体",
    authorId: 10001,
    category: "科幻"
}

那么问题来了:

作者的信息,到底应该直接放进书里,还是单独建立一个作者档案?

这就涉及 MongoDB 数据建模中最基础的两种方式:

嵌入(Embedding)和引用(Reference)。


3. 第一种摆法:放在一起

嵌入式数据

回到图书馆。

管理员决定,把《三体》的相关资料全部放进同一个档案袋:

text
《三体》档案
├── 书名:三体
├── 作者
│   ├── 姓名:刘慈欣
│   └── 国籍:中国
├── 出版社:重庆出版社
└── 分类:科幻

以后有人想查询《三体》,管理员只需要找到这一个档案袋,就能看到书籍和作者等相关信息。

不需要再去另一个档案室查找。

这就是 MongoDB 中的嵌入式数据(Embedding)

MongoDB示例

在 MongoDB 中,可以把相关数据直接嵌入到同一个文档中:

js
{
    name: "三体",
    author: {
        name: "刘慈欣",
        country: "中国"
    },
    publisher: "重庆出版社",
    category: "科幻"
}

这里的 author 不再只是一个普通字符串,而是一个嵌套对象。

书籍信息和作者信息被放在了同一个文档中。

嵌入有什么好处?

最明显的好处就是:

需要的数据都放在一起,查询时一次就能拿到。

例如:

js
db.books.findOne({ name: "三体" })

返回的数据中已经包含:

  • 书名
  • 作者
  • 出版社
  • 分类

不需要再根据作者编号,继续查询一次作者资料。

对于一些经常一起使用的数据,这种方式非常方便。

例如用户的基本信息和联系方式:

js
{
    name: "张三",
    age: 30,
    contact: {
        phone: "13800138000",
        email: "zhangsan@example.com"
    }
}

查看用户资料时,通常也会同时查看联系方式,那么把它们放在一起就比较合理。

除了查询方便,嵌入还有一个重要特点:

同一个文档中的数据可以通过一次原子操作一起更新。

例如修改用户的手机号:

js
db.users.updateOne(
    { name: "张三" },
    {
        $set: {
            "contact.phone": "13900139000"
        }
    }
)

这次更新只涉及一个文档,不需要跨多个集合进行操作。


嵌入也不是越多越好

既然把数据放在一起这么方便,是不是所有数据都应该塞进一个文档?

当然不是。

还是回到《三体》。

假设刘慈欣写了很多本书:

text
刘慈欣
├── 三体
├── 流浪地球
├── 球状闪电
├── 超新星纪元
└── ……

如果每一本书的文档里,都嵌入完整的作者资料:

js
{
    name: "三体",
    author: {
        name: "刘慈欣",
        country: "中国"
    }
}
js
{
    name: "流浪地球",
    author: {
        name: "刘慈欣",
        country: "中国"
    }
}

那么作者的信息就被重复保存了很多次。

如果作者资料发生变化,也可能需要修改多份数据。

另外,如果嵌入的数据不断增加,例如一本书保存了几十万条评论,整个文档也会越来越大。

所以:

嵌入并不是简单地“能放进去就全部放进去”。

还需要考虑:

  • 数据是不是经常一起使用?
  • 数据会不会大量重复?
  • 数据会不会不断增长?

如果数据需要独立维护,而且可能被很多文档共同使用,那么图书馆可能就需要换一种摆放方式。


4. 第二种摆法:分开存放

引用式数据

上一章中,我们把作者资料直接放进了每一本书的档案中。

这种方式查询很方便,但如果一个作者写了很多本书,就会出现一个问题:

同一份作者资料被重复保存了很多次。

于是,图书馆管理员决定换一种方式。

这一次,不再把作者的完整资料放进每一本书的档案袋,而是专门建立一个“作者档案室”。

text
书籍档案                         作者档案

《三体》                         编号:10001
├── 书名:三体                    姓名:刘慈欣
└── 作者编号:10001        →      国籍:中国


《流浪地球》
├── 书名:流浪地球
└── 作者编号:10001

《三体》和《流浪地球》都只保存作者编号 10001

当需要查看作者的详细信息时,再根据这个编号,到作者档案中查找。

这就是 MongoDB 中的引用式数据(Reference)


MongoDB示例

首先,单独建立一个作者集合:

js
// authors
{
    _id: 10001,
    name: "刘慈欣",
    country: "中国"
}

然后,在书籍集合中只保存作者的编号:

js
// books
{
    name: "三体",
    authorId: 10001,
    publisher: "重庆出版社",
    category: "科幻"
}

另一本书也可以引用同一个作者:

js
// books
{
    name: "流浪地球",
    authorId: 10001,
    publisher: "中国青年出版社",
    category: "科幻"
}

这样,刘慈欣的详细资料只需要保存一份。

多本书通过 authorId 找到同一个作者。


引用有什么好处?

1. 减少重复数据

假设一个作者写了100本书。

如果使用嵌入式数据,作者姓名、国籍等信息可能需要重复保存100次。

而使用引用后:

text
作者资料 × 1

书籍
├── 三体 → authorId: 10001
├── 流浪地球 → authorId: 10001
├── 球状闪电 → authorId: 10001
└── ……

作者资料只保存一份即可。


2. 数据可以独立维护

例如图书馆需要更新作者资料。

只需要修改作者档案:

js
db.authors.updateOne(
    { _id: 10001 },
    {
        $set: {
            awards: ["雨果奖"]
        }
    }
)

不需要一本一本修改书籍档案。


3. 更适合复杂的数据关系

例如:

  • 一个作者可以写多本书
  • 一本书可能有多个作者
  • 一本书可以属于多个分类
  • 一个出版社可以出版很多本书

当数据之间的关系越来越复杂时,把所有信息都塞进一个文档,可能会让文档越来越大,也越来越难维护。

这时可以把不同的数据分别保存,再通过编号建立关联。

例如一本书有多个作者:

js
{
    name: "数据库原理",
    authorIds: [10001, 10002]
}

通过两个作者编号,就可以找到对应的作者资料。


引用也有代价

引用并不是没有成本。

回到图书馆。

如果读者只想知道《三体》的书名,那么管理员只需要找到书籍档案。

但如果读者还想知道:

“这本书是谁写的?作者还有哪些资料?”

管理员可能需要:

text
查询书籍档案

获得 authorId

查询作者档案

获得作者详细资料

例如先查询书籍:

js
const book = await db.books.findOne({
    name: "三体"
});

再根据 authorId 查询作者:

js
const author = await db.authors.findOne({
    _id: book.authorId
});

也就是说:

数据分开存放后,获取完整信息可能需要额外的查询。

当然,MongoDB 也提供了 $lookup 等方式,可以帮助我们查询相关集合中的数据。

这里暂时不展开,后面介绍 MongoDB 聚合时再详细讲。


5. 一本书有很多作者怎么办?

前面我们已经知道,MongoDB 中的数据可以选择嵌入,也可以选择引用。

但在实际项目中,数据之间往往不是简单的“属于谁”。

例如:

一本书可能有一个作者,也可能有多个作者。

一个作者也可能写过很多本书。

那么,这些数据之间到底是什么关系?

在数据库设计中,我们经常会遇到三种关系:

  • 一对一
  • 一对多
  • 多对多

5.1 一对一

先看一个简单的例子。

图书馆给每本书建立一个详细档案,每本书只有一个档案:

text
一本书

一个档案

这种就是一对一关系

在 MongoDB 中,这种数据通常可以直接嵌入。

例如:

js
{
    name: "三体",
    isbn: "9787536692930",
    libraryInfo: {
        shelf: "A-12",
        status: "available"
    }
}

书籍和它的馆藏信息经常一起使用,而且馆藏信息通常不会独立存在,所以放在同一个文档中比较自然。

当然,也可以把馆藏信息单独保存,通过 bookId 进行引用。

所以:

一对一并不意味着一定要嵌入。

最终还是要看实际的使用方式。


5.2 一对多

再来看图书馆最常见的一种情况。

一个作者可以写很多本书:

text
刘慈欣
├── 三体
├── 流浪地球
├── 球状闪电
└── 超新星纪元

这就是一对多关系

一个作者对应多本书。

这种情况下,我们可能有两种设计方式。

方式一:把书嵌入作者

js
{
    name: "刘慈欣",
    books: [
        {
            name: "三体"
        },
        {
            name: "流浪地球"
        }
    ]
}

这样查询作者时,可以直接拿到他的所有书。

但这里有一个问题:

如果一个作者有几百本、几千本书呢?

作者文档会不断变大。

而且查询某一本书时,也没有必要把作者的所有书都拿出来。

因此,对于可能不断增长的数据,需要谨慎使用这种嵌入方式。


方式二:书籍独立保存

另一种方式是让每本书成为独立文档:

js
{
    name: "三体",
    authorId: 10001
}
js
{
    name: "流浪地球",
    authorId: 10001
}

作者单独保存:

js
{
    _id: 10001,
    name: "刘慈欣"
}

这样:

text
作者

authorId

多本独立的书籍文档

即使作者的书越来越多,也不会导致作者文档无限增长。

所以,对于数量可能不断增加的一对多关系,把“多”的一方独立保存,通常更加合适。


5.3 多对多

最后来看一个更加复杂的情况。

一本书可能有多个作者:

text
《数据库原理》
├── 作者A
└── 作者B

而一个作者也可能参与多本书:

text
作者A
├── 《数据库原理》
├── 《MongoDB入门》
└── 《分布式系统》

这就是多对多关系

在 MongoDB 中,可以在书籍文档中保存多个作者的 ID:

js
{
    name: "数据库原理",
    authorIds: [10001, 10002]
}

作者仍然单独保存:

js
{
    _id: 10001,
    name: "作者A"
}
js
{
    _id: 10002,
    name: "作者B"
}

这样一本书就可以关联多个作者。


5.4 关系越复杂,越要考虑数据访问方式

看到这里,可能有人会产生一个疑问:

MongoDB 不是文档数据库吗?

为什么也要考虑一对一、一对多、多对多?

因为:

数据库类型不同,并不意味着现实中的数据关系消失了。

图书馆中依然存在:

text
一本书 → 一个出版社
一个作者 → 多本书
一本书 → 多个作者
一个读者 → 多次借阅

MongoDB 只是提供了不同的数据组织方式,让我们根据实际业务选择:

text
经常一起使用

    嵌入

需要独立维护

    引用

数据数量可能不断增长

谨慎嵌入,考虑独立保存

所以,一对一、一对多、多对多只是帮助我们分析数据关系的方法,并不是决定 MongoDB 数据结构的唯一标准。

真正决定数据应该怎么设计的,还是:

数据以后会怎么被查询、修改和使用?


总结

图书馆整理书籍时,需要考虑的不只是:

“书放在哪里?”

还要考虑:

“以后怎么找?”

“哪些资料需要一起查看?”

“哪些资料需要独立维护?”

“数据会不会越来越多?”

MongoDB 的数据模型也是如此。

嵌入,就像把相关资料放进同一个档案袋,查询时可以一次拿到;

引用,就像书和作者分别建立档案,通过编号找到对方。

而面对一对一、一对多、多对多的数据关系时,也不能简单地套用某一种方案。

MongoDB 数据模型设计,不是先想“数据应该怎么存”,而是先想“数据以后怎么用”。

这也是 MongoDB 数据建模最值得记住的一句话。

下一篇,我们继续留在这座图书馆。

不过这一次,图书馆开始变大了……

书越来越多、借阅记录越来越长、热门书甚至出现了几十万条评论。

简单的“放在一起”或者“分开存放”,已经不一定够用了。

那么,MongoDB 又该怎么设计?

上次更新于: