上篇我们把mongoDB中常用的聚合查询进行了学习,至此已经学完了mongo的常见操作,本篇我们来站在设计的角度思考,如何在设计mongo的阶段,尽可能结合前面的操作,让mongo程序发挥出更好的性能

范式与反范式化设计的取舍

看到范式,熟悉关系型数据库比如Mysql的人都知道,在关系型数据库中设计规范中有种说法叫三范式(五范式)设计,而在mongo设计中,范式化设计就是指将一个文档的数据进行拆分,分散到多个不同的集合中去,而在这些集合的文档中互相引用对应的唯一属性,例如ObjectId等。但是我们需要知道的是,在mongoDB中是没有join查询操作的,因此我们如果需要将拆分的数据完整查询出来,起码要分开查询多次才可以。而反范式就是传统的文档格式的数据,即将多个集合数据聚合在一个文档数据中,存储在一个文档集合中。这样做的好处是每次查询完整数据的时候只需要一步查询即可完成,但是当我们做文档修改的时候,尤其是对内嵌的文档修改的时候,可能同样的数据我们需要便利,将所有的该文档数据都进行修改才可以。而什么时候使用范式,什么时候使用反范式,不管是关系型数据库还是Nosql,都是比较头疼的事情,我们可以通过几个常见的场景来分析分别选择哪种设计会更好一些。

学生-课程-选课

有个经典的场景,例如现在需要存储学生-选课-课程之间的关系数据到mongoDB中,我们可以做的存储方式一般有三种:

  1. 第一种是经典的范式化设计,学生信息单独一个集合文档存储,课程数据单独存储在一个集合文档,而学生选了哪些课程,再去单独存储在一个新的集合文档中,而这三个集合文档之间的关系仅仅靠学生集合文档中的studentId和选课表中的studentId属性对应,即可查询出来学生 + 该学生选择的所有课程Id信息,这个时候我们还需要将这些课程id再去和课程集合文档进行映射,才能得到全部的数据
  2. 第二种则是基于第一种的半范式设计方案,即将每个学生选择的课程Id也存入学生集合文档中,这样做的好处是节省了一次查询的过程,仅需要将课程id数组拿到,再去查询对应的课程信息即可
  3. 第三种则是基于第二种方式的进一步优化,即不使用关联关系,直接将每个学生选择的课程相关信息都存入学生集合文档中,大概结构如下:
  1. {
  2. "_id" : ObjectId("512512a5d86041c7dca81914"),
  3. "name" : "John Doe",
  4. "classes" : [
  5. {
  6. "class" : "Trigonometry",
  7. "credits" : 3,
  8. "room" : "204"
  9. },
  10. {
  11. "class" : "Physics",
  12. "credits" : 3,
  13. "room" : "159"
  14. },
  15. {
  16. "class" : "Women in Literature",
  17. "credits" : 3,
  18. "room" : "14b"
  19. },
  20. {
  21. "class" : "AP European History",
  22. "credits" : 4,
  23. "room" : "321"
  24. }
  25. ]
  26. }

这样的好处就是一次查询所有的结果都可以拿到,这种方式就是完全的反范式化,但是我们需要明白反范式化带来的问题,就是会导致每个文档层次变多,文档占用内存变大,并且如果我们将某个课程的信息修改了,需要将全部文档中的该课程信息都去修改,极大的增加了修改的难度,但是我们可以将三种方案的结合起来,例如我们可以将一些可能极少变更的课程信息,比如课程名称等存入学生集合文档中,而这种信息也往往是常规查询如列表查询中最常需要的数据,这样我们很多时候只需要查询当前集合文档就能得到结果,如果需要查看选择的课程的详情信息,则可以再去拿课程id/课程名称等信息去课程集合中获取详细的信息,比如课程的分值等。这样我们的绝大多数场景下的实践都会尽可能的优化,例如修改课程信息/查询学生信息和选课列表/查询选择课程的详情信息等,都可以尽可能在查询与修改中找到平衡。

粉丝/好友 社交类型的数据存储问题

很多时候在做用户以及社交圈类型的软件的时候,往往需要存储人、内容以及社交圈的其他人信息,如好友,黑名单等,那么这个时候往往就需要对是否把数据内嵌进文档中进行权衡和取舍。例如用户的关注、好友列表以及收藏,都可以视为发布-订阅模式,那么针对订阅-发布系统的数据,常见的存储方式就有三种

  1. 将感兴趣的内容添加到每个用户文档中存储
  1. {
  2. "_id" : ObjectId("51250a5cd86041c7dca8190f"),
  3. "username" : "batman",
  4. "email" : "batman@waynetech.com"
  5. "following" : [
  6. ObjectId("51250a72d86041c7dca81910"),
  7. ObjectId("51250a7ed86041c7dca81936")
  8. ]
  9. }

这样存储文档数据的好处是,如果我们确定了某个用户,可以很方便的查询出来他的关注(感兴趣)内容,只需要这样查询即可:

  1. db.activities.find({"user" : {"$in" :
  2. user["following"]}})

但是这也带来一个很严重的问题,如果我们需要查询最新的活动列表,那么我们就需要将每个用户都便利一次,找到所有的感兴趣的活动列表,再去找到最新的那条,因此我们可以将存储结构调整如下

  1. 2. 将关注的用户列表存入活动文档中
  1. {
  2. "_id" : ObjectId("51250a7ed86041c7dca81936"),
  3. "active_name":"广场舞",
  4. "followers" : [
  5. ObjectId("512510e8d86041c7dca81912"),
  6. ObjectId("51250a5cd86041c7dca8190f"),
  7. ObjectId("512510ffd86041c7dca81910")
  8. ]
  9. }

这样当当前活动发布了一条新的消息的时候,我们可以很容易找到应该发给哪些用户,但是我们如果需要查询某个用户关注的所有活动,那么也是一样的问题,我们需要便利所有的活动文档信息,才能找到当前用户所有的关注

  1. 3. 将文档结构范式化,关注信息和用户信息分别存储到集合中

可以看到,前两种方式采用反范式的方案,无法保证在频繁关注或者频繁取消关注的用户信息中快速完成查询 和 修改数据的过程,并且可能产生大量碎片化数据,因此我们可以考虑采用范式化存储,这样就可以保证最小变动实现数据的存储和查询

  1. 总结:从上述案例我们大概可以看出来,范式化和反范式化数据,比较重要的指标就是如果数据发生变动,过程中影响文档是否较大,或者说文档数据是否会频繁变更,如果存在频繁变更的可能性,建议使用范式化操作,除此之外,如果某些字段是文档的一部分,那么就需要内嵌到文档中,方便查询,如果在查询过程中,经常需要排除部分字段,说明此部分数据大部分情况下不是我们需要的数据,则可以考虑分开存储,而不是全部内嵌到文档中

内嵌文档 和 引用数据文档比较

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

优化文档数据

如果想要设计出更优的的应用程序,必须要考虑一点就是读写性能的瓶颈,如果是读操作的瓶颈,那么尽可能优化索引和字段,如果是写入瓶颈,就需要考虑设计较小的文档,支持批量处理,以及更小的文档改动等。

文档增长的处理

我们在更新数据的时候如果是直接替换部分数据或者文档中存在部分集合字段,将集合字段的数据进行新增,就会导致文档的数据大小在增长和变动。而MongoDB在文档大小变动的时候,会进行文档的移动,导致性能下降,因此我们为了避免这种情况,可以优化考虑根据数据的预估大小,进行预先数据填充。比如我们假设有一个文档,用于存储用户发布的文章信息,那么随着文章的变多,数据会越来越大,但是我们可以大概预估出每个用户绝大多数情况下发布的文章不超过30篇,每篇文章大部分都在5K字以内,因此我们可以预先设置一个默认字段存储了30条数据,每个数据大概在5k字范围内的集合文档,用于占位,此字段并不参与项目中任何操作,仅仅是为了给文档集合字段预先占位,当我们存储文章到集合的过程中,可以选择将当前字段的占位文档删除对应的数量,在存入对应条数的文章数据,这样可以尽量保持文档整体的大小在原来的范围内(可以比以前文档小,但是尽量不能增长,增长会使得文档扩容,进行移动)

废弃数据(旧数据)处理

如果存储的数据,仅是几周内,或者一段时间内有效,超过时间以后,数据是否准确,以及数据是否存在已经无关紧要的时候,这个时候我们可以考虑将这部分数据进行处理。常见的处理方式有如下几种:TTL集合,定期JOB处理删除,以及固定大小集合

这几种方式中最简单的肯定是使用固定大小的集合进行过期数据的处理,比如我们的系统中存在公告/banner等轮播/列表信息,并且固定的每次都是最新的n条数据,这个时候我们就可以考虑使用固定大小集合,比如固定每次都是最新的十条数据,我们可以设置一个固定大小为10的集合,当我们需要更新文档的时候,只需要存入最新的文档数据即可,当集合存满以后,原来的最旧的数据会自动被删除,存入新的文档数据。

除此之外,TTL集合也是常见的处理方案,不过这种方案也有一定的弊端,比如我们需要知道数据什么时候更新,什么时候删除,时间是提前预知的,无法实现动态更新和变更数据。当我们需要实现动态变更数据,更加灵活调整数据集的时候,建议使用定时JOB的方式处理,除了定时JOB意外,我们还有一种特殊的处理方式,即多集合存储的方案,例如我们可以根据时间范围拆分为一周一个集合,或者一个月一个集合的方式存储数据,如果我们需要删除某个时间段的数据,可以直接将这部分集合删除即可。

一致性处理问题

除了前面设计中常见的问题以外,还有一个很重要的问题在设计和使用mongo的过程中需要注意,那就是mongoDB的数据一致性处理问题。MongoDB支持多种不同的一致性级别,从每次都读到完全正确的最新数据到读取不确定新旧程度的数据。我们需要知道数据的准确实时程度是什么样的,例如我们如果需要查询和处理n个月或者n年的数据,那么可能只要近期的数据一致即可,而不是需要很高的实时性,如果需要实时查询,那么就需要考虑强一致或者一致性处理。

要理解如何获得这些不同级别的一致性,首先要了解MongoDB的内部机制。MongoDB服务器会为每一个数据库连接维护一个请求队列,客户端每次发来的请求都会进入队列的队尾,入队列以后,会按照顺序处理这些请求。因此我们使用同一个会话处理的数据,永远都是能读取到实时的最新数据的。但是我们需要考虑一个问题,在使用过程中,往往会为了减少建立连接带来的消耗,会构建一个请求缓存池,这时就会创建多个会话连接,那么就会存在一个会话连接正在执行插入或者更新操作,另一个正在查询的操作,这个时候就会发生一些很奇怪的现象,比如明明显示插入成功,立刻查询,并没有查询出来,后面突然数据又出现了。因此解决这种问题,我们可以考虑,在程序中使用标记锁,一次只能操作同一个集合一次,必须等上一次操作完成,此次操作才可以继续,或者每一个集合仅使用一个会话连接即可。当然除了通过程序操作来维护以外,我们也可以考虑使用复制集或者集群的方式,将读取的操作都发送到主库中去,保证每次读取的数据都是集群(副本集)中数据最新的即可。