MongoDB 中的范式与反范式

本文摘录自《MongoDB权威指南》第八章,可以彻底回答以下两个问题:

数据表示的方式有很多种,其中最重要的问题之一就是在多大程度上对数据进行范式化。范式化(normalization)是将数据分散到多个不同的集合,不同集合之间可以相互引用数据。虽然很多文档可以引用某一块数据,但是这块数据只存储在一个集合中。所以,如果要修改这块数据,只需修改保存这块数据的那一个文档就行了。但是,MongoDB没有提供连接(join)工具,所以在不同集合之间执行连接查询需要进行多次查询。

反范式化(denormalization)与范式化相反:将每个文档所需的数据都嵌入在文档内部。每个文档都拥有自己的数据副本,而不是所有文档共同引用同一个数据副本。这意味着,如果信息发生了变化,那么所有相关文档都需要进行更新,但是在执行查询时,只需要一次查询,就可以得到所有数据。

决定何时采用范式化何时采用反范式化时比较困难的。范式化能够提高数据写入速度,反范式化能够提高数据读取速度。需要根据自己应用程序的十几需要仔细权衡。

数据表示的例子

假设要保存学生和课程信息。一种表示方式是使用一个students集合(每个学生是一个文档)和一个classes集合(每门课程是一个文档)。然后用第三个集合studentsClasses保存学生和课程之间的联系。

> db.studentsClasses.findOne({"studentsId": id});
{
  "_id": ObjectId("..."),
  "studentId": ObjectId("...");
  "classes": [
    ObjectId("..."),
    ObjectId("..."),
    ObjectId("..."),
    ObjectId("...")
  ]
}

如果比较熟悉关系型数据库,可能你之前建国这种类型的表连接,虽然你的每个记过文档中可能只有一个学生和一门课程(而不是一个课程“_id”列表)。将课程放在数组中,这有点儿MongoDB的风格,不过实际上通常不会这么保存数据,因为要经历很多次查询才能得到真实信息。

假设要找到一个学生所选的课程。需要先查找students集合找到学生信息,然后查询studentClasses找到课程“_id”,最后再查询classes集合才能得到想要的信息。为了找出课程信息,需要向服务器请求三次查询。很可能你并不想再MongoDB中用这种数据组织方式,除非学生信息和课程信息经常发生变化,而且对数据读取速度也没有要求。

如果将课程引用嵌入在学生文档中,就可以节省一次查询:

{
  "_id": ObjectId("..."),
  "name": "John Doe",
  "classes": [
    ObjectId("..."),
    ObjectId("..."),
    ObjectId("..."),
    ObjectId("...")
  ]
}

"classes"字段是一个数组,其中保存了John Doe需要上的课程“_id”。需要找出这些课程的信息时,就可以使用这些“_id”查询classes集合。这个过程只需要两次查询。如果数据不需要随时访问也不会随时发生变化(“随时”比“经常”要求更高),那么这种数据组织方式是非常好的。

如果需要进一步优化读取速度,可以将数据完全反范式化,将课程信息作为内嵌文档保存到学生文档的“classes”字段中,这样只需要一次查询就可以得到学生的课程信息了:

{
  "_id": ObjectId("..."),
  "name": "John Doe"
  "classes": [
    {
      "class": "Trigonometry",
      "credites": 3,
      "room": "204"
    },
    {
      "class": "Physics",
      "credites": 3,
      "room": "159"
    },
    {
      "class": "Women in Literature",
      "credites": 3,
      "room": "14b"
    },
    {
      "class": "AP European History",
      "credites": 4,
      "room": "321"
    }
  ]
}

上面这种方式的优点是只需要一次查询就可以得到学生的课程信息,缺点是会占用更多的存储空间,而且数据同步更困难。例如,如果物理学的学分变成了4分(不再是3分),那么选修了物理学课程的每个学生文档都需要更新,而且不只是更新“Physics”文档。

最后,也可以混合使用内嵌数据和引用数据:创建一个子文档数组用于保存常用信息,需要查询更详细信息时通过引用找到实际的文档:

{
  "_id": ObjectId("..."),
  "name": "John Doe",
  "classes": [
    {
      "_id": ObjectId("..."),
      "class": "Trigonometry"    
    },
    {
      "_id": ObjectId("..."),
      "class": "Physics"
    }, {
      "_id": ObjectId("..."),
      "class": "Women in Literature"
    }, {
      "_id": ObjectId("..."),
      "class": "AP European History"
    }
  ]
}

这种方式也是不错的选择,因为内嵌的信息可以随着需求的变化进行修改,如果希望在一个页面中包含更多(或者更少)的信息,就可以将更多(或者更少)的信息放在内嵌文档中。

需要考虑的另一个重要问题是,信息更新更频繁还是信息读取更频繁?如果这些数据会定期更新,那么范式化是比较好的选择。如果数据变化不频繁,为了优化更新效率儿牺牲读写速度就不值得了。

例如,教科书上介绍范式化的一个例子可能是将用户和用户地址保存在不同的集合中。但是,人们几乎不会改变住址,所以不应该为了这种概率极小的情况(某人改变了住址)而牺牲每一次查询的效率。在这种情况下,应该将地址内嵌在用户文档中。

如果决定使用内嵌文档,更新文档时,需要设置一个定时任务(cron job),以确保所做的每次更新都成功更新了所有文档。例如,我们试图将更新扩散到多个文档,在更新完成所有文档之前,服务器崩溃了。需要能够检测到这种问题,并且重新进行未完的更新。

一般来说,数据生成越频繁,就越不应该将这些内嵌到其他文档中。如果内嵌字段或者内嵌字段数量时无限增长的,那么应该将这些内容保存在单独的集合中,使用引用的方式进行访问,而不是内嵌到其他文档中,评论列表或者活动列表等信息应该保存在单独的集合中,不应该内嵌到其他文档中。

最后,如果某些字段是文档数据的一部分,那么需要将这些字段内嵌到文档中。如果在查询文档时经常需要将某个字段排除,那么这个字段应该放在另外的集合中,而不是内嵌在当前的文档中。

更适合内嵌 更适合引用
子文档较小 子文档较大
数据不会定期改变 数据经常改变
最终数据一致即可 中间阶段的数据必须一致
文档数据小幅增加 文档数据大幅增加
数据通常需要执行二次查询才能获得 数据通常不包含在结果中
快速读取 快速写入

假如我们有一个用户集合。下面是一些可能需要的字段,以及它们是否应该内嵌到用户文档中。

用户首选项(account preferences)

用户首选项只与特定用户相关,而且很可能需要与用户文档内的其他用户信息一起查询。所以用户首选项应该内嵌到用户文档中。

最近活动(recent activity)

这个字段取决于最近活动增长和变化的频繁程度。如果这是个固定长度的字段(比如最近的10次活动),那么应该将这个字段内嵌到用户文档中。

好友(friends)

通常不应该将好友信息内嵌到用户文档中,至少不应该将好友信息完全内嵌到用户文档中。下节会介绍社交网络应用的相关内容。

所有由用户产生的内容

不应该内嵌在用户文档中。

基数

一个集合中包含的对其他集合的引用数量叫做基数(cardinality)。常见的关系有一对一、一对多、多对多。假如有一个博客应用程序。每篇博客文章(post)都有一个标题(title),这是一个对一个的关系。每个作者(author)可以有多篇文章,这是一个对多的关系。每篇文章可以有多个标签(tag),每个标签可以在多篇文章中使用,所以这是一个多对多的关系。

在MongoDB中,many(多)可以被分拆为两个子分类:many(多)和few(少)。假如,作者和文章之间可能是一对少的关系:每个作者只发表了为数不多的几篇文章。博客文章和标签可能是多对少的关系:文章数量实际上很可能比标签数量多。博客文章和评论之间是一对多的关系:每篇文章可以拥有很多条评论。

只要确定了少与多的关系,就可以比较容易地在内嵌数据和引用数据之间进行权衡。通常来说,“少”的关系使用内嵌的方式会比较好,“多”的关系使用引用的方式比较好。

好友、粉丝、以及其他的麻烦事情

亲近朋友,远离敌人

很多社交类的应用程序都需要链接人、内容、粉丝、好友,以及其他一些事物。对于这些高度关联的数据使用内嵌的形式还是引用的形式不容易权衡。这一节会介绍社交图谱数据相关的注意事项。通常,关注、好友或者收藏可以简化为一个发布、订阅系统:一个用户可以订阅另一个用户相关的通知。这样,有两个基本操作需要比较高效:如何保存订阅者,如何将一个事件通知给所有订阅者。

比较常见的订阅实现方式有三种。第一种方式是将内容生产者内嵌在订阅者文档中:

{
    "_id": ObjectId("..."),
    "username": "batman",
    "email": "batman@waynetech.com",
    "following": [
        ObjectId("..."),
        ObjectId("...")
    ]
}

现在,对于一个给定的用户文档,可以使用形如db.activities.find({"user": {"$in": user["following"]}})的方式查询该用户感兴趣的所有活动信息。但是,对于一条刚刚发布的活动信息,如果要找出对这条信息感兴趣的所有用户,就不得不查询所有用户的“following”字段了。

另一种方式是将订阅者内嵌到生产者文档中:

{
    "_id": ObjectId("..."),
    "username": "joker",
    "email": "joker@mailinator.com",
    "followers": [
        ObjectId("..."),
        ObjectId("..."),
        ObjectId("...")
    ]
}

当这个生产者新发布一条信息时,我们立即就可以知道需要给哪些用户发布通知。这样做的缺点时,如果需要找到一个用户关注的用户列表,就必须查询整个用户集合。这样方式的优缺点与第一种方式的优缺点恰好相反。

同时,这两种方式都存在另一个问题:它们会使用户文档变得越来越大,改变也越来越频繁。通常,“following”和“followers”字段甚至不需要返回:查询粉丝列表有多频繁?如果用户比较频繁地关注某些人或者对一些人取消关注,也会导致大量的碎片。因此,最后的方案对数据进一步范式化,将订阅信息保存在单独的集合中,以避免这些缺点。进行这种成都的范式化可能有点儿过了,但是对于经常发生变化而且不需要与文档其他字段一起返回的字段,这非常有用。对“followers”字段做这种范式化使有意义的。

用一个集合来保存发布者和订阅者的关系,其中的文档结构可能如下所示:

{
    "_id": ObjectId("..."),   //被关注者的"_id"
    "followers": [
        ObjectId("..."),
        ObjectId("..."),
        ObjectId("...")
    ]
}

这样可以使用户文档比较精简,但是需要额外的查询才能得到粉丝列表。由于“followers”数组的大小经常会发生变化,所以可以在这个集合上启用“usePowerOf2Sizes”,以保证users集合尽可能小。如果将followers集合保存在另一个数据库中,也可以在不过多影响users集合的前提下对其进行压缩。

应对威尔惠顿效应

不管使用什么样的策略,内嵌字段只能在子文档或者引用数量不是特别大的情况下有效发挥作用。对于比较有名的用户,可能会导致用于保存粉丝列表的文档溢出。对于这种情况的一种解决方案使在必要时使用“连续的”文档。例如:

> db.users.find({"username": "wil"})
{
    "_id": ObjectId("..."),
    "username": "wil",
    "email": "wil@example.com",
    "tbc": [
        ObjectId("123"),    // just for example
        ObjectId("456")     // same as above
    ],
    "followers": [
        ObjectId("..."),
        ObjectId("..."),
        ObjectId("..."),
        ...
    ]
}
{
    "_id": ObjectId("123"),
    "followers": [
        ObjectId("..."),
        ObjectId("..."),
        ObjectId("..."),
        ...
    ]
}
{
    "_id": ObjectId("456"),
    "followers": [
        ObjectId("..."),
        ObjectId("..."),
        ObjectId("..."),
        ...
    ]
}

对于这种情况,需要在应用程序中添加从“tbc”(to be continued)数组中取数据的相关逻辑。

说点什么

No silver bullet.

@2014-06-25 15:42