【MongoDB应用设计技巧】适合于小白的MongoDB设计第一课

271 阅读9分钟

前言

我相信很多新手小白都会在MongoDb的使用过程中,常常纠结如何进行结构定义,比如是选择范式还是反范式,选择子文档还是数组,哪些数据应该嵌入,哪些数据不该嵌入。

最近看了一本书——《深入学习MongoDB》里面就举出了一些常用的技巧,本文主要是参考此书结合一些知识点以及自己的理解总结而成,比较适用于学习Mongo的新手小白了解一些常用的开发技巧。

技巧1:速度和完整性的折中

在多个文档中使用的数据既可以采用内嵌(反范式化)的方式,也可以采用引用(范式化)的方式,两种策略没有优劣之分,各自都有优缺点,关键是要选择适合自己的引用场景。

举个反范式化的例子:

fruit同时存在food和meals集合中

food
{
  _id: β
  fruit:}
​
meals
{
  _id:γ
  fruit:
},
{
  _id:α
  fruit:
}

反范式化会导致不一样的数据,比如这个例子,如果要将此例子中的苹果改为其他水果,但是仅仅只更新了一个值,应用就崩溃了,还没来得及修改其它文档,这时数据库就有了两个不同的值,导致不一致(当然,4.0以后的事务可以解决此问题)。

尤其是对于写比较频繁的场景,为了保证数据一致性,需要修改多个集合中的文档的值。

举一个范式化的例子:

fruit字段存储在food集合中,被meals集合中的文档引用

food
{
  _id: β
  fruit:}
​
meals
{
  _id:γ
  food:β
},
{
  _id:α
  food:β
}

但是范式化导致的问题,就是需要额外进行一次查找,对于读多写少的场景,就不太友好了。

所以,在速度和完整性上需要折中处理,因为极高的性能和瞬间一致性不可兼得(类似于CAP中的 AP和CP之分)。

考虑因素:

在做权衡之中,几个因素可以考虑。

是否总是要额外读取一次集合不怎么改变的数据?

在范式化的例子中,假设food在meals中被读取一万次,food中的fruit才会进行一次修改,那么为了一次的写入快一点,导致这一万次都需要额外多一次查询值得吗?所以说权衡时,可以考虑一下应用的是否具有较高的读写比,按照读写比,来权衡是否范式或者反范式化。

一致性很重要吗?

当应用中的文档不一致性难以容忍时,就必须优先考虑一致性,比如交易系统,订单系统。但是如果是一致性不重要的场景,就可以选择反范式化。

比如现在有一个商品信息和订单信息,由于订单是基本不会变的,但是订单信息中的商品信息可能是变化的。比如商品降价,订单中的商品没有降价,或者商品描述的修改,并不会影响到订单本身,即使从订单中看到的商品信息是历史信息也是可以的(生成订单时的商品信息)。所以就可以考虑反范式化,而且商品信息的修改场景,就是一个写少读多的场景。

要不要快速地读取?

若想读取的尽可能快,那么就要尽可能的反范式化。

如何去理解现实世界中的系统如何处理一致性?

可以去网上查看一篇文章《星巴克不使用两阶段提交》:arthurchiao.art/blog/starbu…

技巧2:适应未来的数据要范式化

“适应未来”是指满足未来10年内的所有查询。

比如说,在数据设计过程中,数据本身的结构可能会不断地演化,且被不同的应用用到。

所以说,"适应未来" 应该进行范式化存储

  • 有新的查询需求或分析场景,系统也可以更高效地适配
  • 减少重复存储,从而减少在业务变化时的维护成本
  • 避免文档过度嵌套或变得过于庞大。

技巧3:尽量单个查询获取数据

我们在实际应用中,应该尽量采用单个查询来获取我们所需要的数据。

比如博客网站的文章列表分页查询。

我们不需要查出首页文章的所有信息,而是查询这些文章的部分信息。比如标题,作者名称,编辑时间,我们可以使用一个分页查询就查出首页的内容:

> db.article.find({},{"title":1, "author":1, "slug":1, "_id":0}).sort({"date":-1}).limit(10)

补充一个知识点:

当要查询第二页,或者第n页时,可以这么写查询语句

> db.article.find({},db.article.find(
  {
    "title": 1,
    "author": 1,
    "slug": 1,
    "_id": 0,
    "date": { $gt: latestDateSeen } // 筛选 date 字段大于指定日期
  }
)
.sort({ "date": -1 }) // 按 date 字段降序排序
.limit(10); // 限制返回文档数量为 10

这里的latestDateSeen就是上一页的最大(或最小,按照排序方式)的时间。

date字段加上索引,这样比skip的方式高效的多。

原理:

skip原理

skip 的原理是 MongoDB 在服务器端生成一个结果集,然后跳过前 N 条记录,返回从第 N+1 条开始的结果。问题在于:

  • 即使跳过的记录不会返回给客户端,MongoDB 仍需要扫描这些记录。
  • 随着 skip 的值增加(分页靠后时),服务器扫描的数据量和代价成倍增长。

使用指定字段排序结合索引的更具有优势

查询流程:

  1. MongoDB 利用排序字段上的索引,直接从索引开始位置查找符合条件的数据。
  1. 跳过记录的开销由索引跳转代替,避免扫描无关记录。
  1. 查询性能与页码无关,即使是第 1000 页,查询代价仍接近于获取第一页。

技巧4:嵌入关联数据

当在嵌人和引用文档之间犹豫不决时,不妨想想查询的目的究竟是为了获得字段本身的信息,还是为了进一步获取更广泛的信息

例如按照某个标签查询应该返回有该标签的文章,而不仅仅是标签本身。

类似地,对于查询评论,可能已存在一个最新评论列表,但人们更希望知道是哪篇文章引发了评论,除非你的应用就是以评论为核心的。

若是从关系型数据库迁移到 MongoDB,联结表就是重点要考虑嵌入的对象。那些仅有一个键和一个值的表(如标签、权限、地址)在 MongoDB 中几乎都应该做嵌人处理。

还有,若是某些信息只在一个文档中使用,则应该嵌入这个文档

技巧5:嵌入时间点数据

数据是时间点的数据时,应该嵌入到主文档中。

技巧1中,订单的例子已经提到,当一个商品打折或者换了图片,并不需要更改原来订单中的信息。类似这种特定于某一时刻的时间点数据,都应做嵌入处理。

订单文档中还有一处也是这样,地址也是这种时间点数据。若某人更新了个人信息,那么并不需要更改其以往的订单内容。

技巧6:不要嵌入不断增加的数据

MongoDB 存储数据的机制决定了对数组不断追加数据是很低效的。在正常使用中数组和对象大小应该相对固定。

所以,嵌人 20 个子文档,或者 100 个,或者1000 000 个都不是问题,关键是提前这么做,之后基本保持不变。放任文档增长会使系统慢得让你受不了的。

评论是个比较特殊的地方,因应用不同而差别较大。对大多数应用而言,评论应该内嵌到所依附的父文档中。但是,有些系统中评论本身自成条目,或者动辄成百上千,这种情况就应该将其放到独立的文档中了。

还有个例子,假设正在做一个以评论为核心的应用,主要的内容就是评论,这时将其作为单独的文档处理比较适合。

技巧7:预填充数据

如果已经知道了未来需要使用哪些字段,第一次就插入这些字段会比用到时再创建的的效率更高。

举一个例子:

如果有一个网站分析的应用,要查看一个文章六个小时内的用户的访问量信息,而且要按照分钟和小时两种频率来存储信息,可以设计如下结构:

{
  "_id": pageid,
  "start":time,
  "visits": {
    "minutes":{
      [num0,num1,num2, ……, num59],
      [num0,num1,num2, ……, num59],
      [num0,num1,num2, ……, num59],
      [num0,num1,num2, ……, num59].
      [num0,num1,num2, ……, num59],
    [num0,num1,num2, ……, num59]
},
"hours":[num0, num1, num2, num3, num4, num5]
}
}

这样有一个好处——可以始终知道文档的形式,类似于模版的作用,需要统计某一个时间点的数据时,只需要修改对应分钟和小时的计数器即可。当增加或者设置计数器时,MongoDB就不用再为其分配空间。

技巧8:尽可能预先分配空间。

这个技巧和技巧6和7的关系紧密。如果我们知道文档比较小(前提),后面会变成固定大小,那么在插入文档的时候,就可以填充此字段一个和最终数据大小一样的数据。

例如

> collection.insert({"_id":123, /* other fields */ , "garbage": someLongString})
> collection.update({"_id":123, {"$unset": {"garbage" : 1 }}})

这里的 someLongString 代表的就是预填充的数据,可以是任何的long类型数据,但是注意,不要在填充数据之前使用此字段。

技巧9:用数组存放要匿名访问的内嵌数据

比如现在有一个场景,要去水果摊买水果。

假设数据结构定义如下:

{
  "_id": "fruit",
  "items":{
    "apple":{
      "price":"20",
      "color":"red"
    },
    "banana":{
      "price":"30",
      "color":"yellow"
    },
    "pear":{
      "price":"45",
      "color":"green"
    }
  }
}

如果我要去找到价格小于25的水果怎么办?子文档是不支持直接查询比较的,只能将每一种水果的price进行比较,如“items.apple.price”是否小于25,或者“itmes.banana.price"是否小于25,但是水果的品种items可能是变化的。

所以可以采用数组的方式解决这个问题:

{
  "_id": "fruit",
  "items":[{
     "name":"apple",
     "price":"20",
     "color":"red"
    },
    {
      "name":"banana",
      "price":"30",
      "color":"yellow"
    },
    {
      "name":"pear",
      "price":"45",
      "color":"green"
    }
  ]
}

这样仅需要一条查询{"items.price":{"$gt":25}}就可以了。如果需要多条件查询,可以使用$elemMatch。

那么什么时候该用子文档呢?字段名总是已知时就比较合适。

比如现在超市有很多的东西,包括水果,蔬菜,肉类, 大米。

我们总是很明确我们需要查询那个类型,且区分度比较大,就可以使用子文档。

{
  "_id":"superMarket",
  "items":{
    "fruit":{},
    "meat":{},
    "vegetable":{}
  }
}

我们此时的查询绝对是items.fruit.XX 或者items.meat.xx,而不是比较那个的价格小于25,因为不具有可比性。

技巧10:文档要自给自足

MongoDB是一种无脑的大型数据存储,也就是说,MongoDB几乎不做任何数据处理,仅仅存取数据。要尽量遵循这点,避免让MongoDb做哪些可以在客户端完成的计算,即便是一些“小”任务,像是求平均值或者求和,也要放到客户端去做。

如果要找的信息必须经过计算,且无法从文档中直接获得,有两种方式

  • 付出高昂的性能代价(强制MongoDb使用JavaScript计算,参见技巧11)
  • 优化文档结构,使得这些信息能够从文档中获得

通常,我们需要通过文档直接给出这些信息。

例子1

比如,现在假设要找到苹果和橘子总数为30的文档,文档结构如下:

{
  "_Id":124,
  "apple":10,
  "orange":5
}

如果是这样设计的话,查询总数就得仰仗JavaScript了,结果会非常低效。

例子2

可以在文档中加入一个总数(total)字段:

{
  "_Id":124,
  "apple":10,
  "organge":5,
  "total":15
}

这样在苹果和橘子的数量更新时,同时更新总数的数量。


但是如果不太确定更新会改变什么内容,情况会变得复杂。

比如水果的种类数量,因为不知道更新是否会添加新的品种。

{
  "_Id":124,
  "apple":10,
  "organge":5,
  "total":2 // 此时代表品种数量
}

如果一个更新可能创建新的字段,也可能不创建,品种总数该不该增加呢?如果创建了新的字段,总数也是要变化的:

> db.fruit.update({"_id":123, {"$inc": {"banana":3, "total" : 1}}})

相反,如果banana的字段已经有了,则不应该增加总数,但是客户端根本无从知晓其存在是否。如果先读到内存中,比较后再更改total的值,就会引起并发问题,其他正在修改此值的客户端会将我们的修改覆盖掉。

两种解决方式:

  1. 加锁: 可以使用findAndModify来”锁“住文档(设定locked字段,其他写入会手动检查这个字段),或者使用乐观锁机制。慢,但是可以保持一致性。
  2. 使用补偿机制: 对total不做处理,后台使用定时任务等方式进行批处理来纠正不一致的地方。快,但是不保证一致性。

技巧11:优先使用 $ 操作符

$操作符不适用于某些操作。但是对于绝大多数应用,让文档本身提供足够的内容可以极大地简化查询的复杂度。然而,还是有些情况下的查询不能用操作符表达。这时候就得借助Javaseript 了。

可以在査询中使用 $where 字句来执行javaScript代码。 查询中的$where对应一个JavaScript的函数,该函数的返回值为true或者false(表示匹配与否)。要是想査找所有 member[0].age 和 member [1].age 相等的文档,可以这样操作:

> db.members.find(("$where" : funetion(){return this.member[0].age == this.member[1].age;... }})

正如预期,$where 为查询带来了极大的灵活性。但是也带来很大的效率问题。

原因

$where 效率不高是因为 MongoDB 为此要做很多事情。对于普通的查询(没有$where 的查询),客户端将査询转换成 BSON,然后发送给数据库。MongoDB 中的数据也是 BSON格式,所以直接比较就可以了。这种查询非常高效。 若是非得用$where 不可,MongoDB就得将集合中的每一个文档都转换成JavaScript 对象,解析文档的 BSON 并添加它们的所有字段到 JavaScript 对象中。然后执行 JavaScript,之后就销毁。所以极其消耗时间和资源。

提高性能

$where 在必要时很有用,但能避免应尽量避免。实际上,如果查询中有很多where,说明应该重新考虑数据库设计是否合理了。

若是$where 确有必要,可以减少 $where 要匹配文档的数量来缓解性能损失。设置一些不用 $where 就能执行的査询条件,并将其放在最前面。$where 遍历的文档越少,需要的时间就越少。

比如上面的例子中,可以加上其他的条件,缩小查询范围

db.members.find({
  'type': { $in: ['joint', 'family'] },
  $where: function () {
    return this.member[0].age === this.member[1].age;
  }
});

技巧12:随时聚合

要尽可能用$inc 随时计算聚合。

例如技巧7中,分析程序要有按分钟和按小时的统计数据。

增加按分钟统计的数据时,同时可以增加按小时统计的数据。 如果聚合需要进一步的计算(比如,计算每小时的平均査询次数),就要将数据存放到分钟字段中,然后由后台的批处理按分钟字段中的最新数据计算平均值,并放到文档中。由于所有需要聚合的信息都在一个文档中,对较新的(未聚合的)文档,甚至可以把计算放到客户端。至于旧文档,应该都通过批处理计算完了。

技巧13:编写代码处理数据完整性

由于 MongoDB 无模式的特点和反范式化的特点,需要注意应用数据的一致性问题。 很多 `ODM'有多种方法来确保不同级别的一致性。然而,还是会有一致性问题,如由于系统崩溃导致的数据不一致(技巧1),由于 MongoDB 更新的限制导致的不致(技巧 10)。要处理这些不一致,要有个脚本做数据验证。 可能要有几个cron 任务,这取决于具体的应用,如下所示

一致性修复器

核对计算,检查重复数据,确保数据一致性

预分配器

创建今后要用到的文档。

聚合器

更新文档内部的聚合数据,使之为最新数据。


其他有用的脚本如下。

结构校验器

确保当前所有的文档都有指定的字段,否则就自动校正或者发送通知。

备份任务

定期对数据库做 fsync、加锁和导出操作。 在后台执行一些检查和保护数据的脚本,能解除很多后顾之忧。

参考:

  • 《深入学习MongoDB》——人民出版社——Kristina Cbodorow著