前言
我相信很多新手小白都会在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的值增加(分页靠后时),服务器扫描的数据量和代价成倍增长。
使用指定字段排序结合索引的更具有优势
查询流程:
- MongoDB 利用排序字段上的索引,直接从索引开始位置查找符合条件的数据。
- 跳过记录的开销由索引跳转代替,避免扫描无关记录。
- 查询性能与页码无关,即使是第 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的值,就会引起并发问题,其他正在修改此值的客户端会将我们的修改覆盖掉。
两种解决方式:
- 加锁: 可以使用findAndModify来”锁“住文档(设定locked字段,其他写入会手动检查这个字段),或者使用乐观锁机制。慢,但是可以保持一致性。
- 使用补偿机制: 对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著