更多优质内容
请关注公众号

深入学习mongodb(四)内嵌数据和引用数据(范式化/反范式化)、优化技巧、数据一致性、模式迁移和不适合使用MongoDB的场景-张柏沛IT博客

正文内容

深入学习mongodb(四)内嵌数据和引用数据(范式化/反范式化)、优化技巧、数据一致性、模式迁移和不适合使用MongoDB的场景

栏目:数据库 系列:深入学习mongdb 发布时间:2022-10-30 15:05 浏览量:995

本章介绍如何设计应用程序,以便更好地使用MongoDB,内容包括:
内嵌数据和引用数据之间的权衡;
优化技巧;
数据一致性;
模式迁移;
不适合使用MongoDB作为数据存储的场景。


一、内嵌数据和引用数据之间的权衡(范式化/反范式化)
数据表示的方式有很多种,其中最重要的问题之一就是在多大程度上对数据进行范式化。

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

举个例子,现在业务上假设mysql有一个文章表和评论表,我要查询和“python”相关的文章及其相关评论,在mysql中我可以执行一个join查询sql就达到目的。但是如果在mongdb中,假如我们的存储方式也像在mysql中的一样,将文章存到一个文章集合,将评论存到一个评论集合,那么我们需要执行2次查询,第一次先根据“python”关键词在文章集合中查到文章的id,再根据文章id到评论集合查到评论的内容。这是因为mongdb并不支持join查询。


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

还是上面的例子,反范式化就是将某个文章下所有评论内嵌到文章这个文档内部。这么一来就能只查询文章集合就得到文章和评论的内容。


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


例子1:
假设要保存学生和课程信息(每个学生要学的课是不同的,是个多对多关系)。一种表示方式是使用一个students集合(每个学生是一个文档)和一个classes课程集合(每门课程是一个文档)。然后用第三个集合studentClasses保存学生和课程之间的联系。

db.studentClasses.findOne({"studentId"  :  id})
{
    "_id"  :  ObjectId("512512c1d86041c7dca81915"),
    "studentId"  :  ObjectId("512512a5d86041c7dca81914"),
    "classes"  :  [
        ObjectId("512512ced86041c7dca81916"),
        ObjectId("512512dcd86041c7dca81917"),
        ObjectId("512512e6d86041c7dca81918"),                
        ObjectId("512512f0d86041c7dca81919")
    ]
}

这就是一种范式化设计。


假设要找到一个学生所选的课程详情和学生详情。需要先查找students集合找到学生信息,然后根据学生id查询studentClasses找到课程"_id",最后再查询classes集合才能得到想要的信息。为了找出课程信息,需要向服务器请求三次查询。

可如果我们这样存储,将课程id嵌入在学生文档中,那么上面的需求就只需要2次查询:

{
        "_id"  :  ObjectId("512512a5d86041c7dca81914"),        
        "name"  :  "John  Doe",
        "classes"  :  [
            ObjectId("512512ced86041c7dca81916"),
            ObjectId("512512dcd86041c7dca81917"),
            ObjectId("512512e6d86041c7dca81918"),
            ObjectId("512512f0d86041c7dca81919")
        ]
}


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

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

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


最后,也可以混合使用内嵌数据和引用数据:将课程的常用信息嵌入到学生表中,需要查询更详细的课程信息时通过引用找到实际的文档:

{
    "_id"  :  ObjectId("512512a5d86041c7dca81914"),
    "name"  :  "John  Doe",
    "classes"  :  [
        {
                "_id"  :  ObjectId("512512ced86041c7dca81916"),
                "class"  :  "Trigonometry"
        },
        {
                "_id"  :  ObjectId("512512dcd86041c7dca81917"),                        
                "class"  :  "Physics"
        },
        {
                "_id"  :  ObjectId("512512e6d86041c7dca81918"),
                "class"  :  "Women  in  Literature"
        },
        {
                "_id"  :  ObjectId("512512f0d86041c7dca81919"),
                "class"  :  "AP  European  History"
        }
    ]
}

也就是说,放入到内嵌文档中的应该是不经常修改,却经常查询的字段。
需要考虑信息更新更频繁还是信息读取更频繁,如果这些数据会定期更新,那么范式化(关联的方式)是比较好的选择。如果数据变化不频繁,反范式化是比较好的选择。

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

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

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

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


使用内嵌数据与引用数据的比较



如何设计一个存储方案以达到范式化和反范式化的平衡很重要。一旦初始规划错误,那么后面对表的维护会很麻烦。

以上原则不仅适用于mongdb,也同样适用于所有非关系型数据库的业务数据存储设计原则。



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

在MongoDB中,many(多)可以被分拆为两个子分类:many(多)和few(少)。

例如,作者和文章之间可能是一对少的关系:每个作者只发表了为数不多的几篇文章。

博客文章和标签可能是多对少的关系:文章总量实际上很可能比标签总量多。

博客文章和评论之间是一对多的关系:每篇文章都可以拥有很多条评论。


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



三、一些优化技巧

优化文档增长
更新数据时,需要明确更新是否会导致文件体积增长,以及增长程度。如果增长程度是可预知的,可以为文档预留足够的增长空间,这样可以避免文档移动,可以提高写入速度。检查一下填充因子:如果它大约是1.2或者更大,可以考虑手动填充。
如果要对文档进行手动填充,可以在创建文档时创建一个占空间比较大的字段,文件创建成功之后再将这个字段移除。这样就提前为文档分配了足够的空间供后续使用。

假设有一个餐馆评论的集合,其中的文档如下所示:

{
    "_id"  :  ObjectId(),
    "restaurant"  :  "Le  Cirque",
    "review"  :  "Hamburgers  were  overpriced."
    "userId"  :  ObjectId(),
    "tags"  :  []
}


"tags"字段会随着用户不断添加标签而增长,应用程序可能经常需要执行这样的更新操作。可以在文档最后添加一个大字段(随便用什么名字)进行手工填充,如下所示:

{
        "_id"  :  ObjectId(),
        "restaurant"  :  "Le  Cirque",
        "review"  :  "Hamburgers  were  overpriced."        "userId"  :  ObjectId(),
        "tags"  :  [],
        "garbage"  :  "........................................................"+
        "................................................................"+
        "................................................................"
}


可以在第一次插入文档时这么做,也可以在upsert时使用"$setOnInsert"创建这个字段。
更新文档时,总是用"$unset"移除"garbage"字段。

db.reviews.update({"_id"  :  id},{"$push"  :  {"tags"  :  {"$each"  :  ["French",  "fine  dining",  "hamburgers"]}}},  "$unset"  :  {"garbage"  :  true}})


如果"garbage"字段存在,"$unset"操作符可以将其移除;如果这个字段不存在,"$unset"操作符什么也不做。
如果文档中有一个字段需要增长,应该尽可能将这个字段放在文档最后的位置("garbage"
之前)。这样可以稍微提高一点点的性能,因为如果"tags"字段发生了增长,MongoDB不需要重写"tags"后面的字段(不需要移动tags之后字段的位置)


删除旧数据
有些数据只在特定时间内有用:几周或者几个月之后,保留这些数据只是在浪费存储空间。有三种常见的方式用于删除旧数据:使用固定集合,使用TTL集合,或者定期删除集合。

使用固定集合:将集合大小设为一个比较大的值,当集合被填满时,将旧数据从固定集合中挤出。但是,固定集合会对操作造成一些限制,而且在密集插入数据时会大大降低数据在固定集合内的存活期。

使用TTL集合:TTL集合可以更精确地控制删除文档的时机。但是,对于写入量非常大的集合来说这种方式可能不够快:它通过遍历TTL索引来删除文档。如果TTL集合能够承受足够的写入量,使用TTL集合删除旧数据可能是最简单的方式了。

使用多个集合(分表):例如,每个月的文档单独使用一个集合。每当月份变更时,应用程序就开始使用新月份的集合(初始是个空集合),查询时要对当前月份和之前月份的集合都进行查询。对于6个月之前创建的集合,可以直接将其删除。这种方式可以应对任意的操作量,但是对于应用程序来说会比较复杂,因为需要使用动态的集合名称(或者数据库名称),也要动态处理对多个数据库的查询。

使用多个数据库时有一些限制:MongoDB通常不允许直接将数据从一个数据库移到另一个数据库。例如,无法将在A数据库上执行MapReduce的结果保存到B数据库中,也无法使用renameCollection命令将集合从一个数据库移动到另一个数据库(比如,可以将foo.bar重命名为foo.baz,但是不能将foo.bar重命名为foo2.baz)。


一致性管理
mongodb服务器为每个数据库连接维护一个请求队列。客户端每次发来的新请求都会添加到队列的末尾。入队之后,这个连接上的请求会依次得到处理。一个连接拥有一个一致的数据库视图,可以总是读取到这个连接最新写入的数据。
每个列队只对应一个连接:如果打开两个shell,连接到相同的数据库,这时就存在两个不同的连接,两个请求队列。如果在其中一个shell中执行插入操作,紧接着在另一个shell中执行查询操作,新插入的数据可能不会出现在查询结果中。
但是,如果是在同一个shell中,插入一个文档然后执行查询,一定能够查询到刚插入的文档。想手动重现这种问题是很困难的,但是在一个频繁执行插入和查询的服务器上很可能会发生。
经常会有一些开发者使用一个线程插入数据,然后使用另一个线程检查数据是否成功插入(两个连接,两个请求队列)。片刻之后,刚刚的数据看上去好像并没有成功插入,但是这些数据忽然就出现了。
使用Ruby、Python和Java驱动程序时尤其要注意这个问题,因为这三种语言的驱动程序都使用了连接池(connection pool)。为了提高效率,这些驱动程序会建立多个与服务器之间的连接(也就是一个连接池),将请求通过不同的连接发送到服务器。但是它们都有各自的机制来保证一系列相关连的请求会被同一个连接处理。


不适合使用MongoDB的场景
MongoDB不支持下面这些应用场景。
1、MongoDB不支持事务(transaction),对事务性有要求的应用程序不建议使用MongoDB(其实在新版的mongodb中已经能够支持事务操作)。
如果非要使用事务将多个db操作打包为一个原子操作,可以在应用程序中使用锁来自己实现事务。

2、在多个不同维度上对不同类型的数据进行连接,这是关系型数据库善长的事情。MongoDB不支持这么做,以后也很可能不支持。




更多内容请关注微信公众号
zbpblog微信公众号

如果您需要转载,可以点击下方按钮可以进行复制粘贴;本站博客文章为原创,请转载时注明以下信息

张柏沛IT技术博客 > 深入学习mongodb(四)内嵌数据和引用数据(范式化/反范式化)、优化技巧、数据一致性、模式迁移和不适合使用MongoDB的场景

热门推荐
推荐新闻