MongoDB开发规范及建模

MongoDB 开发规范

(1)命名原则。数据库、集合命名需要简单易懂,数据库名使用小写字符,集合名称使用统一命名风格,可以统一大小写或使用驼峰式命名。数据库名和集合名称均不能超过 64 个字符。

(2)集合设计。对少量数据的包含关系,使用嵌套模式有利于读性能和保证原子性的写入。对于复杂的关联关系,以及后期可能发生演进变化的情况,建议使用引用模式。

(3)文档设计。避免使用大文档,MongoDB 的文档最大不能超过 16MB。如果使用了内嵌的数组对象或子文档,应该保证内嵌数据不会无限制地增长。在文档结构上,尽可能减少字段名的长度,MongoDB 会保存文档中的字段名,因此字段名称会影响整个集合的大小以及内存的需求。一般建议将字段名称控制在 32 个字符以内。

(4)索引设计。在必要时使用索引加速查询。避免建立过多的索引,单个集合建议不超过10个索引。MongoDB 对集合的写入操作很可能也会触发索引的写入,从而触发更多的 I/O 操作。无效的索引会导致内存空间的浪费,因此有必要对索引进行审视,及时清理不使用或不合理的索引。遵循索引优化原则,如覆盖索引、优先前缀匹配等,使用 explain 命令分析索引性能。

(5)分片设计。对可能出现快速增长或读写压力较大的业务表考虑分片。分片键的设计满足均衡分布的目标,业务上尽量避免广播查询。应尽早确定分片策略,最好在集合达到 256GB 之前就进行分片。如果集合中存在唯一性索引,则应该确保该索引覆盖分片键,避免冲突。为了降低风险,单个分片的数据集合大小建议不超过 2TB。

(6)升级设计。应用上需支持对旧版本数据的兼容性,在添加唯一性约束索引之前,对数据表进行检查并及时清理冗余的数据。新增、修改数据库对象等操作需要经过评审,并保持对数据字典进行更新。

(7)考虑数据老化问题,要及时清理无效、过期的数据,优先考虑为系统日志、历史数据表添加合理的老化策略。

(8)数据一致性方面,非关键业务使用默认的WriteConcern:1(更高性能写入);对于关键业务类,使用WriteConcern:majority保证一致性(性能下降)。如果业务上严格不允许脏读,则使用ReadConcern:majority选项。

(9)使用 update、findAndModify 对数据进行修改时,如果设置了upsert:true,则必须使用唯一性索引避免产生重复数据。

(10)业务上尽量避免短连接,使用官方最新驱动的连接池实现,控制客户端连接池的大小,最大值建议不超过200。

(11)对大量数据写入使用 Bulk Write 批量化 API,建议使用无序批次更新。

(12)优先使用单文档事务保证原子性,如果需要使用多文档事务,则必须保证事务尽可能小,一个事务的执行时间最长不能超过 60s。

(13)在条件允许的情况下,利用读写分离降低主节点压力。对于一些统计分析类的查询操作,可优先从节点上执行。

(14)考虑业务数据的隔离,例如将配置数据、历史数据存放到不同的数据库中。微服务之间使用单独的数据库,尽量避免跨库访问。

(15)维护数据字典文档并保持更新,提前按不同的业务进行数据容量的规划。

MongoDB 数据建模案例分析

朋友圈评论内容管理

需求

社交类的 APP 需求,一般都会引入“朋友圈”功能,这个产品特性有一个非常重要的功能就是评论体系。

先整理下需求:
这个 APP 希望点赞和评论信息都要包含头像信息:
a. 点赞列表,点赞用户的昵称,头像;
b. 评论列表,评论用户的昵称,头像;

数据查询则相对简单:
a. 根据分享 ID,批量的查询出 10 条分享里的所有评论内容;

建模

不好的设计
跟据上面的内容,先来一个非常非常"朴素"的设计:

{
  "_id": 41,
  "username": "小白",
  "uid": "100000",
  "headurl": "http://xxx.yyy.cnd.com/123456ABCDE",
  "praise_list": [
    "100010",
    "100011",
    "100012"
  ],
  "praise_ref_obj": {
      "100010": {
        "username": "小一",
        "headurl": "http://xxx.yyy.cnd.com/8087041AAA",
        "uid": "100010"
      },
      "100011": {
        "username": "mayun",
        "headurl": "http://xxx.yyy.cnd.com/8087041AAB",
        "uid": "100011"
      },
      "100012": {
        "username": "penglei",
        "headurl": "http://xxx.yyy.cnd.com/809999041AAA",
        "uid": "100012"
      }
    },
    "comment_list": [
      "100013",
      "100014"
    ],
    "comment_ref_obj": {
      "100013": {
        "username": "小二",
        "headurl": "http://xxx.yyy.cnd.com/80232041AAA",
        "uid": "100013",
        "msg": "good"
      },
      "100014": {
        "username": "小三",
        "headurl": "http://xxx.yyy.cnd.com/11117041AAB",
        "uid": "100014",
        "msg": "bad"
      }
  	}
}

可以看到,comment_ref_obj 与 praise_ref_obj 两个字段,有非常重的关系型数据库痕迹,猜测,这个系统之前应该是放在了普通的关系型数据库上,或者设计者被关系型数据库的影响较深。而在 MongoDB 这种文档型数据库里,实际上是没有必要这样去设计,这种建模只造成了多于的数据冗余。
另外一个问题是,url 占用了非常多的信息空间,这点在压测的时候会有体现,带宽会过早的成为瓶颈。同样 username 信息也是如此,此类信息相对来说是全局稳定的,基本不会做变化。并且这类信息跟随评论一起在整个 APP 中流转,也无法处理”用户名修改“的需求。
根据这几个问题,重新做了优化的设计建议。
推荐的设计

{
  "_id": 41,
  "uid": "100000",
  "praise_uid_list": [
    "100010",
    "100011",
    "100012"
  ],
  "comment_msg_list": [
    {
    "100013": "good"
    },
    {
    "100014": "bad"
    }
  ]
}

对比可以看到,整个结构要小了几个数量级,并且可以发现 url,usrname 信息都全部不见了。那这样的需求应该如何去实现呢?
从业务抽象上来说,url,username 这类信息实际上是非常稳定的,不会发生特别大的频繁变化。并且这两类信息实际上都应该是跟 uid 绑定的,每个 uid 含有指定的 url,username,是最简单的key,value 模型。所以,这类信息非常适合做一层缓存加速读取查询。
进一步的,每个人的好友基本上是有限的,头像,用户名等信息,甚至可以在 APP 层面进行缓存,也不会消耗移动端过多容量。但是反过来看,每次都到后段去读取,不但浪费了移动端的流量带宽,也加剧了电量消耗。

总结

MongoDB 的文档模型固然强大,但绝对不是等同于关系型数据库的粗暴聚合,而是要考虑需求和业务,合理的设计。有些人在设计时,也会被文档模型误导,三七二十一,一股脑的把信息塞到一个文档中,反而最后会带来各种使用问题。

多列数据结构

需求

需求是基于电影票售卖的不同渠道价格存储。某一个场次的电影,不同的销售渠道对应不同的价格。
整理需求为:

数据字段:
a. 场次信息;
b. 播放影片信息;
c. 渠道信息,与其对应的价格;
d. 渠道数量最多几十个;

业务查询有两种:
a. 根据电影场次,查询某一个渠道的价格;
b. 根据渠道信息,查询对应的所有场次信息;

建模

不好的模型设计
我们先来看其中一种典型的不好建模设计:

{
  "scheduleId": "0001",
  "movie": "你的名字",
  "price": { 
    "gewala": 30,
    "maoyan": 50,
    "taopiao": 20
  }
}

数据表达上基本没有字段冗余,非常紧凑。再来看业务查询能力:
a. 根据电影场次,查询某一个渠道的价格;
建立 createIndex({scheduleId:1, movie:1}) 索引,虽然对 price 来说没有创建索引优化,但通过前面两个维度,已经可以定位到唯一的文档,查询效率上来说尚可;
b. 根据渠道信息,查询对应的所有场次信息;
为了优化这种查询,需要对每个渠道分别建立索引,例如:

createIndex({"price.gewala":1})
createIndex({"price.maoyan":1})
createIndex({"price.taopiao":1})

但渠道会经常变化,并且为了支持此类查询,肯能需要创建几十个索引,对维护来说简直就是噩梦;
此设计行不通,否决。

一般般的设计

{
  "scheduleId": "0001",
  "movie": "你的名字",
  "channel": "gewala",
  "price": 30
}

{
  "scheduleId": "0001",
  "movie": "你的名字",
  "channel": "maoyan",
  "price": 50
}

{
  "scheduleId": "0001",
  "movie": "你的名字",
  "channel": "taopiao",
  "price": 20
}

与上面的方案相比,把整个存储对象结构进行了平铺展开,变成了一种表结构,传统的关系数据库多数采用这种类型的方案。信息表达上,把一个对象按照渠道维度拆成多个,其他的字段进行了冗余存储。如果业务需求再复杂点,造成的信息冗余膨胀非常巨大。膨胀后带来的副作用会有磁盘空间占用上升,内存命中率降低等缺点。对查询的处理呢:
a. 根据电影场次,查询某一个渠道的价格;
建立 createIndex({scheduleId:1, movie:1, channel:1}) 索引;
b. 根据渠道信息,查询对应的所有场次信息;
建立 createIndex({channel:1}) 索引;
更进一步的优化呢?

合理的设计

{
  "scheduleId": "0001",
  "movie": "你的名字",
  "provider": [
    {
      "channel": "gewala",
      "price": 30 },
    {
      "channel": "maoyan",
      "price": 50 },
    {
      "channel": "taopiao",
      "price": 20 
    }
  ]
}

这里使用了在 MongoDB 建模中非常容易忽略的结构——”数组“。查询方面的处理,是可以建立 Multikey Index 索引
a. 根据电影场次,查询某一个渠道的价格;
建立 createIndex({scheduleId:1, movie:1, “provider.channel”:1}) 索引;
b. 根据渠道信息,查询对应的所有场次信息;
建立 createIndex({“provider.channel”:1}) 索引;

总结

这个案例并不复杂,需求也很清晰,但确实非常典型的 MongoDB 建模设计,开发人员在进行建模设计时经常也会受传统数据库的思路影响,沿用之前的思维惯性,而忽略了“文档”的价值。

物联网时序数据建模

本案例非常适合与 IoT 场景的数据采集,结合 MongoDB 的 Sharding 能力,文档数据结构等优点,可以非常好的解决物联网使用场景。

需求

案例背景是来自真实的业务,美国州际公路的流量统计。数据库需要提供的能力:

  • 存储事件数据
  • 提供分析查询能力
  • 理想的平衡点:

内存使用;
写入性能;
读取分析性能;

  • 可以部署在常见的硬件平台上

建模

每个事件用一个独立的文档存储

{
  segId: "I80_mile23",
  speed: 63,
  ts: ISODate("2013-10-16T22:07:38.000-0500")
}

非常“传统”的设计思路,每个事件都会写入一条同样的信息。多少的信息,就有多少条数据,数据量增长非常快;
数据采集操作全部是 Insert 语句;

每分钟的信息用一个独立的文档存储(存储平均值)

{
  segId: "I80_mile23",
  speed_num: 18,
  speed_sum: 1134,
  ts: ISODate("2013-10-16T22:07:00.000-0500")
}

对每分钟的平均速度计算非常友好(speed_sum/speed_num);
数据采集操作基本是Update语句;
数据精度降为一分钟;

每分钟的信息用一个独立的文档存储(秒级记录)

{
  segId: "I80_mile23",
  speed: {0:63, 1:58, ... , 58:66, 59:64},
  ts: ISODate("2013-10-16T22:07:00.000-0500")
}

每秒的数据都存储在一个文档中;
数据采集操作基本是 Update 语句;

每小时的信息用一个独立的文档存储(秒级记录)

{
  segId: "I80_mile23",
  speed: {0:63, 1:58, ... , 3598:54, 3599:55},
  ts: ISODate("2013-10-16T22:00:00.000-0500")
}

相比上面的方案更进一步,从分钟到小时;
每小时的数据都存储在一个文档中;
数据采集操作基本是 Update 语句;
更新最后一个时间点(第 3599 秒),需要 3599 次迭代(虽然是在同一个文档中);

进一步优化下

{
  segId: "I80_mile23",
  speed: {
    0: {0:47, ..., 59:45},
    ...,
    59: {0:65, ... , 59:56}
  }
  ts: ISODate("2013-10-16T22:00:00.000-0500")
}

用了嵌套的手法把秒级别的数据存储在小时数据里;
数据采集操作基本是 Update 语句;
更新最后一个时间点(第 3599 秒),需要 59+59 次迭代;
嵌套结构正是 MongoDB 的魅力所在,稍动脑筋把一维拆成二维,大幅度减少了迭代次数;

每个事件用一个独立的文档存储 VS 每分钟的信息用一个独立的文档存储

  • 从写入上看:后者每次修改的数据量要小很多,并且在 WiredTiger 引擎下,同一个文档的修改一定时间窗口下是可以在内存中合并的;
  • 从读取上看:查询一个小时的数据,前者需要返回 3600 个文档,而后者只需要返回 60 个文档,效率上的差异显而易见;
  • 从索引上看:同样,因为稳定数量的大幅度减少,索引尺寸也是同比例降低的,并且 segId,ts 这样的冗余数据也会减少冗余。容量的降低意味着内存命中率的上升,也就是性能的提高;

每小时的信息用一个独立的文档存储 VS 每分钟的信息用一个独立的文档存储

  • 从写入上看:因为 WiredTiger 是每分钟进行一次刷盘,所以每小时一个文档的方案,在这一个小时内要被反复的 load 到 PageCache 中,再刷盘。所以,综合来看后者相对更合理;
  • 从读取上看:前者的数据信息量较大,正常的业务请求未必需要这么多的数据,有很大一部分是浪费的;
  • 从索引上看:前者的索引更小,内存利用率更高;

总结

那么到底选择哪个方案更合理呢?从理论分析上可以看出,不管是小时存储,还是分钟存储,都是利用了 MongoDB 的信息聚合的能力。
每小时的信息用一个独立的文档存储:设计上较极端,优势劣势都很明显;
每分钟的信息用一个独立的文档存储:设计上较平衡,不会与业务期望偏差较大;
落实到现实的业务上,哪种是最优的?最好的解决方案就是根据自己的业务情况进行性能测试,以上的分析只是“理论”基础,给出“实践”的方向,但千万不可以此论断。

本图文内容来源于网友网络收集整理提供,作为学习参考使用,版权属于原作者。
THE END
分享
二维码
< <上一篇
下一篇>>