浮生逆旅

MongoDB 特性及使用

字数统计: 4.7k阅读时长: 16 min
2020/11/28 Share

引言

首先说明一点这个博客的内容大多是我在公司个人技术分享,更多的作用是做一个总结,也许会出现上下文不通顺,叙述不明的情况,请见谅。

这篇博客是我在翻阅了《MongoDB实战 第二版》之后诞生的,书本身的内容不错,但是就翻译这一点不得不吐槽,随便找一处,能够将 “cursor” 翻译成光标,可想而知译者对这本书有多么”负责任”,在这里我不得不说一声垃圾。

诞生

在 2007 年,美国纽约有一个名为 10gen(后改名为 MongoDB,Inc) 的创业团队,建立了自己的 PaaS 平台,该平台与 Google App Engine 类似,设计目标就是自动处理伸缩和管理硬件与软件的基础架构,解放开发者,使他们可以更专注于应用开发。

然而 10gen 最终发现开发者并不喜欢放弃对于技术栈的控制,万幸的是用户喜欢上了 10gen 的数据库技术。这就导致了 10gen 团队专注于开发这个数据库产品,最终形成了 MongoDB 项目。

MongoDB 为开源项目,代码完全公开下载,并且可以免费修改、使用,只要遵守代码开源协议即可。鼓励社区提交 Bug 和补丁,MongoDB 的路线图专注于满足社区用户的需求以及创建兼具关系型数据库和分布式键值存储最佳特性的数据库。

关键特性

文档数据模型

类比传统SQL数据库

MySQL(SQL) MongoDB(NoSQL)
数据载体 Table Collection
单个实例 Row Document
实例属性 Column Key

社交新闻网站数据模型(关系型数据库)

社交新闻网站数据模型(MongoDB)

MongoDB 以二进制 JSON 的格式存储文档数据,或者叫做 BSON。文档不需要遵守严格的数据定义Schema。理论上每个集合中的文档都可以拥有不同的数据结构;实际上结合中的文档是相对一致的。我把这句话理解为:利用数据不需要遵从严格的 Schema这一特性,为相同类型数据创建一个集和。

Ad Hoc Queries

主动查询模式:不需要事先定义系统接收何种查询。

关系型数据库会忠实的执行格式正确的包含各种条件的 SQL 查询。但不是所有数据库都支持动态查询,例如健值存储的查询只支持一个领域的查询:主键。键值存储数据库牺牲丰富的查询功能来换取更简单的伸缩模型。MongoDB 的设计目标之一是保留大部分关系型数据库的功能。

辅助索引

大部分数据库会给每条数据一个主键,一个唯一的数据标识,每个主键都会自动索引,这样就可以使用唯一的键来高效的访问每条数据,我们称之为主键索引(primary key indexes)

许多 NoSQL 数据库,比如 HBase,被当作 keyvalue 数据库,这是因为他们那不允许创建辅助索引。因为 MongoDB 引入了 Ad Hoc Queries 模式,拥有丰富的查询功能,所以 MongoDB 必然能够为主键之外的数据属性创建索引,这些索引我们叫做辅助索引(secondary indexes)。在 MongoDB 中,我们可以为每个集合创建 64 个索引。

复制

MongoDB 提供了数据库复制特性,叫做可复制集合(replica set)。可复制集合在多个及其上分布式存储数据,在服务器或者网络出错时,实现数据冗余存储和自动灾备。此外,复制还用于伸缩数据库读操作。

加速与持久化

在数据库系统领域,写入速度和持久性之间存在矛盾的关系。

写入速度可以理解为数据库在给定的时间内插入、更新和删除的容量。持久性指的是这些写操作被永久保存的保证级别。

在 MongoDB 中设置日志的关闭开启及写入时间间隔、使用主从复制来让写入速度与持久性平衡。

伸缩

伸缩数据库的最简单方式是添加更快的硬盘、更多的内存以及更强的 CPU 来突破数据库性能瓶颈。提升单节点参数的做法通常也称垂直扩展。垂直扩展非常简单、可靠,但是最终我们会达到一个无法低成本垂直扩展的临界点。

MongoDB 的设计目标之一是水平伸缩。通过基于范围的分片机制来实现水平扩展,可以自动化管理每个分布式节点存储的数据。另外还有基于哈希和基于 tag 的分片机制,这也是另外一种基于范围的分片机制。

分片系统处理额外的分片节点,而且它还会自动化灾备。每个独立的节点是一个可复制集合,至少由 2 台机器组成,确保节点失败的时候可以自动恢复。这就意味着不需要应用节点去处理这些逻辑;我们的应用与 MongoDB 集群通讯就好像与单个节点通讯一样。

使用

Schema 设计原则

关系型数据库设计范式

对于关系型数据库来讲,我们只需要尽可能遵守数据库设计范式即可,设计范式可以帮助我们确保通用查询以及数据一致性。

目前关系数据库有六种范式:第一范式(1NF)、第二范式(2NF)、第三范式(3NF)、巴斯-科德范式(BCNF)、第四范式(4NF)和第五范式(5NF,又称完美范式)。

规范化目的是使结构更合理,消除存储异常,使数据冗余尽量小。便于插入、删除和更新。

实际应用过程中大部分仅使用前三大范式:属性不可拆分或无重复的列、完全依赖、消除传递依赖。有时故意保留部分冗余可能更方便数据查询。尤其对于那些更新频度不高,查询频度极高的数据库系统更是如此。

简单理解范式的方式是:信息不会存储两次以上。

MongoDB Schema 设计方案

在 MongoDB 中,我们并没有像关系型数据库中比较强制的规范性的设计范式,因为 MongoDB 的数据模型是基于文档的,具有丰富的、多层次的数据表现。相较于关系型数据库,MongoDB 灵活的数据结构更偏向于开发者,SQL 对于数据科学家和分析师来说更为适合。

MongoDB 这种灵活的文档特性,在存储一对多、多对多的关系上更加得心应手,仅仅需要创建一个为数组类型的 key,但缺少类似于关系型数据库 join 查询的能力,我们在数据建模时主要结合应用业务场景考虑两点:

  1. 做数据查询操作时以最少查询次数得到想要的结果
  2. 做数据更新操作时影响最少文档数

简单来说就是尽可能多的冗余那些不轻易更新的其他集合中的数据属性,当然这些冗余数据也应当在常用查询中得以使用。也许这样看起来特别反范式,但对于缺少关联查询手段的 MongoDB 来说,这是最优方案。

有时候我们也需要去创建一些属于本集合内属性的看起来比较多余的缓存属性,例如用户声音模块中的投票,voter_ids 以及 helpful_votes 两个 Schema 属性,其中 voter_ids 存储了所有投票用户的 ID,这样可以阻止用户多次投票,而且这有助于查询所有投票的用户,helpful_votes 存储的是所有投票人数,因为 MongoDB 不允许我们查询文档里数组的大小,基于此进行排序,这是非常有帮助的。

文档的限制

  • 在 MongoDB 2.0 及以后的版本,文档的大小被限制为 16 MB
  • 文档的嵌套深度最大值限制为 100

固定集合(capped collection)

固定集合最初是为高性能日志场景设计的,和标准集合不同,拥有固定的大小存储空间,一旦集合中存储的文档达到上限,后续的插入将会覆盖最先插入的文档。

除了可以进行存储空间大小限制外,也允许指定集合的最大文档数量,可以实现更细粒度的控制。

1
db.createCollection("user.actions", { capped: true, size: 16384, max: 100 });

固定集合不允许进行对文档的更新和删除操作。

聚合(aggregate)

聚合管道 (aggregation pipeline)里的每一步输出都作为下一步都输入

  • $project 指定输出文档里的字段
  • $match 选择要处理的文档
  • $limit 限制传递给下一步的文档数量
  • $skip 跳过一定数量的文档
  • $unwind 打平数组,为每个数组元素生成一个输出文档
  • $group 根据 _id 来分组文档
  • $sort 排序文档
  • $geoNear 选择某个地理位置附近的文档
  • $out 把聚合结果写入某个集合
  • $redact 控制特定数据的访问

Map-Reduce

Map-reduces 是 MongoDB 提供灵活聚合功能的首次尝试。使用 Map-Reduce,就可以使用 JavaScript 定义整个处理流程。这提供了很大的灵活性,但是比 Aggregate Framework 性能要低得多:

  1. Map-Reduce 引擎需要将每条文档从 BSON 转换为 JSON。2.4 以前版本存在单个全局 JavaScript 锁,该锁仅允许一次单个 JavaScript 线程运行。
  2. JavaScript 引擎需要被解释运行,而 Aggregate Framework 运行已经编译的 C++ 代码

性能调优及使用注意

注入攻击

除了意识到随之而来的性能损失,还必须注意 JavaScript 注入攻击的可能性。每当允许用户直接输入代码到 JavaScript 查询,就可能产生注入攻击。例如,当用户直接在排序查询中提交了一个用户表单和值时。如果用户设置属性值或者数值,则这种查询是不安全的:

1
@users.find({"$where": => "this.#{attribute} == ${value}"});

这种情况下,属性值和数值被插入成字符串,然后认定为 JavaScript。这种方式是危险的,因为用户发送的值中可能会包句 JavaScript 代码,让他们获取其他数据集合。这将导致严重的安全漏洞,恶意用户可能会看到其他用户的数据。一般情况下,我们总是要假设用户可能会发送恶意数据和相应计划。

$ne

$ne 并不等同于运算符,作用并不如我们所预期。在操作中,最佳使用方式是结合一个其他的运算符,否则会因为不能使用索引而造成查询效率低下。

1
db.products.find({"details.manufacturer": "Acme", tags: {$ne: "gardening"}});

$where

和 $ne 一样,也是不能够去使用索引的。因为 $where 的值是一段 JavaScript 代码,以为着 mongodb 需要去解析执行它。除非万不得已的情况下,我们轻易不要使用 $where,如果非要使用,最好在其前加一个过滤条件,进一步减少命中的文档数量。

$size

1
db.users.find({"addresses": { $size: 3 }});

$size 运算符不能够使用索引和限制精准匹配(不能指定 size 的范围)。如果我们需要基于数组的大小执行查询,我们可以在文档自身缓存一个属性(数组大小),并且在操作数组的同事手动更新这一属性。可以在缓存属性上面建立数组,可以指定范围并精准匹配查询。

$rename $unset

多用于刷库脚本,进行修改文档属性 key

sort 复合排序

1
db.reviews.find({}).sort({"helpful_votes": -1, "rating": -1});

类似于这样的查询,结果并不一定是我们想象中先根据 “helpful_votes” 再根据 “rating” 进行排序。因为 Ruby 哈希是无序的,所以我们需要使用数组注明字段的排序顺序:

1
db.reviews.find({}).sort([["helpful_votes": -1], ["rating": -1]]);

skip

skip 和 limit 是我们在分页查询中使用非常频繁的查询选项,数据库也一如既往的返回给我们想要的结果。但我们应该小心在有大量命中文档时使用大数值 skip,这种查询服务要求扫描的文档等于 skip 值。

以往经验是我们需要根据业务在集合文档中去缓存一些帮助分页查询的 index,我们可以在应用服务中进行计算,得出最开始的 index 甚至本页最后一个 index,这样甚至能够不使用 skip 和 limit 来达到目的。

事物

可以利用 findAndModify 在过程中锁定文档的特性,在 find 条件中详尽的写出应该命中文档的字段值,比如要通过或拒绝一个审批,那么该审批的状态必定为 pending 状态,我们的查询选择条件应该为

1
{ _id: ObjectId("5fc1ed2b03453c26a681db6c"), status: APPROVAL_STATUS.PENDING }

这么做可以避免段时间内的并发请求去修改已经修改过的文档,尽可能达到与预期一致。

在 MongoDB 2.2 之前,锁策略非常简单,单个读/写锁驻留在整个 MongoDB 实例中。这意味着在任何时刻 MongoDB 只允许一个写或者多个读操作。在 MongoDB 2.2 版本中改为了数据库级别的锁,意味着在数据库级别而不是整个实例级别使用锁,在单个数据库中可以有一个写或者多个读操作。在 MongoDB 3.0 版本中,WiredTiger 存储引擎工作在集合级别,提供了更加强大的文档级别的锁。

更新

在 MMAPv1 存储引擎中,修改文档的大小和数据结构时可能会重写整个文档,如果文档扩大,现存储数据的磁盘空间位置不能继续满足,还需要移动到新的空间中。以 MMAPv1 作为存储引擎的 MongoDB 会通过动态调整集合分配预留空间的填充因子(padding factor)来优化这个问题。填充因子会乘以每个插入文档的大小来获取额外空间,MongoDB 3.0 使用了 2 的幂来作为 MMAPv1 存储引擎默认记录空间分配大小。

索引效率

对于密集读取的应用,索引的成本是可以理解的,要确保所有的索引都会被使用,不会有冗余。假设某个集合包含 10 个索引,当我们做数据插入、删除文档、因空间不足挪动文档或更新文档的索引键,都可能需要对这 10 个索引数据进行维护修改。

我们要确保索引都被加入到 RAM 里,这也是为什么不创建冗余索引的原因,设置的索引越多,就需要越多的 RAM 来维护这些索引。

PROFILER 分析器

对于慢速查询的分析,我们可以使用内置的分析器。默认情况下 MongoDB 禁用了这个工具

1
2
use database_name;
db.setProfilingLevel(2);

分析结果会保存到一个特殊集合:system.profile。该集合是一个固定集合

1
db.system.profile.find({}).sort({millis: -1}).limit(1);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
{
"op" : "command",
"ns" : "lesschat.agile_work_items",
"command" : {
"aggregate" : "agile_work_items",
"pipeline" : [],
"allowDiskUse" : true,
"cursor" : {
"batchSize" : 100
},
"maxTimeMS" : 58000,
"lsid" : {
"id" : UUID("4f0b65fa-dc75-42ea-a5c0-f0cc6f54050c")
},
"$db" : "lesschat",
"$readPreference" : {
"mode" : "secondaryPreferred"
}
},
"cursorid" : 5628156822228229486,
"keysExamined" : 31315,
"docsExamined" : 31218,
"hasSortStage" : true,
"numYield" : 245,
"nreturned" : 100,
"locks" : {
"Global" : {
"acquireCount" : {
"r" : 250
}
},
"Database" : {
"acquireCount" : {
"r" : 248
}
},
"Collection" : {
"acquireCount" : {
"r" : 248
}
}
},
"storage" : {

},
"responseLength" : 5604,
"protocol" : "op_msg",
"millis" : 172,
"planSummary" : "IXSCAN { team: 1, project_id: 1, type: 1, is_archived: 1 }",
"ts" : ISODate("2020-07-27T20:40:04.988+08:00"),
"client" : "10.0.0.45",
"allUsers" : [
{
"user" : "worktile",
"db" : "lesschat"
}
],
"user" : "worktile@lesschat"
}

查询分析器非常有用,但要获取最大收益,需要我们有条不紊的操作。最好的做法是开发阶段就找出一些慢速的查询,如果在生产阶段发现问题,解决问题的成本更高。

explain

通过 PROFILER 分析器,我们可以拿到慢速查询,之后通过 explain 诊断工具来找出查询慢的原因,突破瓶颈。

查询优化器

查询优化器会选择扫描索引入口数量最少的索引作为查询计划。

  1. 根据查询模式(Query pattern)Query Sharp 判断是否存在 CachePlan,如果存在直接选择该 CachePlan 作为最优查询计划
  2. 每个索引的查询计划都有可能满足查询需求,查询优化器会根据这些索引创建新的查询计划并标记计划类型,如果类型为 Optimal Plan 则直接将该 Plan 的 Query Sharp 及查询计划写入CachePlan,否则标记为 Helpful Plan 类型并继续创建查询计划
  3. 并发执行全部 Helpful Plan,选择 nReturned 最少的查询计划,将该 Plan 的 Query Sharp 及查询计划写入CachePlan

查询模式

仅在值上不同的查询选择条件

Query Shape

由查询、排序和投影规范的组合组成

Optimal Plan

Optimal Plan的索引首先应该包含所有查询的过滤字段和排序字段,其次,字段在索引中的顺序是范围过滤和排序字段位于相等字段之后。有多个Optimal Plan时,会执行第一个。

Helpful Plan

不存在Optimal Plan时,查询优化器会并发执行所有Helpful Plan。最早得到101个结果的查询计划会被选中继续执行,并将该查询计划暂存。其他查询计划会被中止。

CachePlan

缓存的查询计划在以下条件下会清空

  • 集合收到1000次写操作
  • 执行reindex
  • 添加或删除索引
  • mongod进程重启
  • 查询时指定explain()

hint

查询优化器并不能保证每次查询都是最优的,无论是 OptimalPlan 还是 WinningPlan 都是相对的,配合 hint 指定索引,会使查询优化器更加准确

拜~


CATALOG
  1. 1. 引言
  2. 2. 诞生
  3. 3. 关键特性
    1. 3.1. 文档数据模型
    2. 3.2. Ad Hoc Queries
    3. 3.3. 辅助索引
    4. 3.4. 复制
    5. 3.5. 加速与持久化
    6. 3.6. 伸缩
  4. 4. 使用
    1. 4.1. Schema 设计原则
      1. 4.1.1. 关系型数据库设计范式
      2. 4.1.2. MongoDB Schema 设计方案
    2. 4.2. 文档的限制
    3. 4.3. 固定集合(capped collection)
    4. 4.4. 聚合(aggregate)
    5. 4.5. Map-Reduce
    6. 4.6. 性能调优及使用注意
      1. 4.6.1. 注入攻击
      2. 4.6.2. $ne
      3. 4.6.3. $where
      4. 4.6.4. $size
      5. 4.6.5. $rename $unset
      6. 4.6.6. sort 复合排序
      7. 4.6.7. skip
      8. 4.6.8. 事物
      9. 4.6.9.
      10. 4.6.10. 更新
      11. 4.6.11. 索引效率
      12. 4.6.12. PROFILER 分析器
      13. 4.6.13. explain
      14. 4.6.14. 查询优化器
      15. 4.6.15. 查询模式
      16. 4.6.16. Query Shape
      17. 4.6.17. Optimal Plan
      18. 4.6.18. Helpful Plan
      19. 4.6.19. CachePlan
      20. 4.6.20. hint