如何设计 Mongodb 的数据模型

Posted by jiananshi on 2017-08-02

公司的存储服务上线后,我遇到过很多次使用 MySQL 的表设计模式来设计 Mongodb 存储模型的服务,并在服务拓展后遇到 Mongodb 不支持数据库级别的 join 而觉得它不好用的情况(其他还有如事务、外键等等)。

在使用了一段时间 Mongodb 后,我觉得这些评价是有失公允的,Mongodb 有它适应的场景,不应该处处同 MySQL 比较。比如不支持 join 我们可以通过应用级别的 join 解决,而且目前注重读性能的服务是禁止使用 join 的(Mongodb 新出的 Aggregation 指令 => $lookup 可以通过一个逻辑外键来实现 join)。

Mongodb 的文档是基于 BSON 协议,它可以描述丰富的数据类型,比如数组。在 MySQL 的一对多、一对多中(多对多后面会讲),我们可以通过一个关系表实现:

id refId
1 20
1 30
5 60

而 Mongodb 中则要简单得多:

1
2
3
4
{
id: 1,
refId: [20, 30, 60]
}

See?既然我们有如此丰富的数据类型建模,通常可以在设计阶段就将外键的问题消灭,而使用内嵌文档(embeded document)的来设计也可以保证在这一条 document 的读写中的原子性。

那是否这就意味着使用 Mongodb 就要尽量将存储模型设计成嵌套的存储方式呢?这取决于你的应用场景,假设下面这条数据:

1
2
3
4
5
6
7
8
9
{
id: 1,
name: 'mj',
gender: 'male',
followers: [
{ id: 2, name: 'ymy', gender: 'female' },
{ id: 3, name: 'cookie', gender: 'male' }
]
}

每个人都有关注者(followers),我们将关注者的详细信息存储在了 followers 字段中,这样在获取这个人及他的关注者的信息时,一次 IO 即可完成,添加和删除 followers 也十分便捷。这种模型的弊端也很明显,在 followers 的数据更新时我们需要修改多个地方:这个人的信息,这个人关注的所有人的 followers 的信息。而且这个修改是无法保证原子性的,会出现有的地方的这个人的信息依然是老的,因此这种设计模型常常会被修改成:

1
2
3
4
5
{
id: 1,
name: 'mj',
followers: [2, 3]
}

将 followers 的 id 存储下来,在 follower 信息更新时只更新一个地方,避免了上面提到的写入问题。这是否意味着下面的这种方案更好的?答案是取决于你的应用场景,如果数据大量读极少写(Web 服务通常如此),那么第一个方案对于读的性能会更好,同时也要考虑用户是否可以接受自己修改的信息不同步的问题(可以通过设置一个定时脚本批量同步数据)?而第二个方案在面对频繁写操作的场景更胜一筹。

还有一个折中的方案是允许部分冗余,比如用户的 gender 其实是几乎不会变的,而在展示 follower 的时候我们同时也希望展示他们的 gender,如果使用方案二不可避免的需要一次应用层的 JOIN 操作,因此我们可以冗余 gender 字段:

1
2
3
4
5
6
7
8
{
id: 1,
name: 'mj',
followers: [
{ id: 2, gender: 'female' },
{ id: 3, gender: 'male' }
]
}

这种模型对读操作同方案一的性能一致,如果在这里调用方仅需要展示 id 和 gender 字段,那么这种数据模型兼具优化读操作,同时将其他写操作频繁数据分离的优势。唯一的问题在于当我们到了需要更新 gender 字段时,我们不得不查询所有的用户,找出包含目标 follower 的数据并更新其中的 gender 字段,在这个例子中由于 follower 「外键」是同一个 collection 中的数据,所以这一操作可以保证原子性,而如果不是同一个 collection 就无法保证操作的原子性了,结果就是出现脏数据。

所幸这两个问题都有解决办法,查询我们可以通过双向关联来优化,也就是每个 follower 记录一下它 follow 的人的 id,通过这组 id 缩小更新操作的查询范围。而由于没有事务 ,mongodb 中脏数据通常需要我们使用定时脚本去洗或者清理,在实际业务场景中我们总是需要去衡量这两者带来的利弊。

在以往的数据库设计中进行「范式化」是值得提倡的(关于数据库范式这里有一篇很好的介绍),套用书里的话来说:

范式化(normalization)是将数据分散到多个不同的集合,不同集合之间可以相互引用数据。虽然很多文档可以引用某一块数据,但是这块数据只存储在一个集合中。所以,如果要修改这块数据,只需修改保存这块数据的那一个文档就行了。但是,MongoDB没有提供连接(join)工具,所以在不同集合之间执行连接查询需要进行多次查询。反范式化(denormalization)与范式化相反:将每个文档所需的数据都嵌入在文档内部。每个文档都拥有自己的数据副本,而不是所以文档共同引用同一个数据副本。这意味着,如果信息发生了变化,那么所有相关文档都需要进行更新,但是在执行查询时,只需要一次查询,就可以得到所有数据

Mongodb 缺乏事务和 JOIN 并不完全是设计的缺陷,而是它有它想要解决的问题和适合的场景,在实际开发中没有一种数据模型可以满足所有业务需求,最终我们还是要根据实际业务场景和读写比例设计我们的数据结构。