【MongoDB实现与优化技巧】如何使用正确的“姿势”开发MongoDB应用

271 阅读21分钟

前言

本文列举了一些在MongoDB中实现与优化的一些技巧,非常适合学习MongoDB的新手进行学习。此文紧接着上篇【MongoDB应用设计技巧】写的,也是参考了《深入学习MongoDB》并结合一些网络资料与示例。与其说是“技巧”,不如说是一些建议与规范,非常适合于MongoDB的入门学习者阅读,可以学习到如何使用正确的“姿势”去开发MongoDB。

技巧1: 使用正确的类型

用正确的类型存储数据非常重要,这时因为数据类型会影响数据的查询方式,MongoDB数据存放顺序,以及占用多少空间

数字

作为数字使用的字段都应该使用数字类型存储,也就是要甲酸或者按照大小排序的字段。

具体使用那种数值类型,需要按照场景。

  • 有些场景任意类型都可以:如果是按照大小排序,可以使用32位整数,64位整数,双精度浮点数都可以进行排序。
  • 有些场景只能是指定类型:位操作(AND 和 OR)只适用于整数

日期

和数字类似,精确的时间使用毫秒级别来存储。无需精确到毫秒的时间,比如生日,这可以直接使用ISO格式,也就是yyyy-mm-dd形式存储字符串即可。

字符串

MongoDB中的字符串都是UTF-8编码的,所以其他编码形式的字符串必须要么转为UTF-8,要么以二进制存储。

ObjectId

ObjectId就要作为ObjectId存储,不能存储为字符串,原因如下:

  • 方便查询(字符串和ObjectId不能互相匹配)。
  • ObjectId含有有用的信息,绝大多数驱动都有方法从ObjectId中获知文档的创建日期。
  • 字符串表示的ObjectIdId要多占用两倍的磁盘空间。

技巧2:使用简单的唯一id替换_id

要是数据本身没有一个唯一的字段(通常就是这样),那么就用默认的ObjectId作为 _id。但是,如果数据本身就有唯一的字段,并且不需要ObjectId的功能,那么就可以使用自己唯一的值覆盖掉默认的 _id 。这样会节省一些空间,尤其是要对该字段创建唯一索引时,就会省下一个索引的空间和资源(非常显著的节约)。

使用自定义的 _id 还有些弊端。

  1. 要确保唯一性,并且要处理键值重复的异常。
  2. 要注意自定义的值是顺序插入,还是随机插入。对于MongoDB而言,作为索引的树形结构需要读取到内存中,如果是随机插入,那么就会非常影响性能。

技巧3:不要用文档做 _id

除了不可避免的情况(如MapReduce的输出),通常都不应该将文档作为_id。更改_id必须得覆盖整个文档,所以如果是子文档作为_id,那么这个子文档一旦更新,则整个文档都会被覆盖。

技巧4:不要用数据库引用

在 MongoDB 中,不推荐使用数据库引用(DBRefs$id$ref 等)作为一种管理关联关系的方式。这种设计虽然在某些场景下可以解决问题,但在实践中存在以下局限性和缺点:

1.增加额外的查询负担

DBRef 是一种嵌套文档形式,用于引用其他集合中的文档,例如:

{ 
  "author": {
    "$ref": "authors",
    "$id": ObjectId("60c72b2f8b5b9b2a4d6f05f4")
  }
}

使用可以参考:MongoDB DBRef

问题

  • 在查询时,应用程序需要额外的查询步骤来获取引用的文档。
  • 这种操作等价于传统关系型数据库的 "join",但 MongoDB 不支持直接的跨集合 join,需要手动执行多次查询。

影响

  • 性能降低:每次查询都需要多次访问数据库,增加网络和 I/O 开销。
  • 代码复杂度提升:开发者需要手动编写代码来处理多个查询和结果组合。

2.弱化 MongoDB 的优势

MongoDB 的设计理念是提供灵活、快速的文档存储,支持嵌套和反范式的结构。而使用 DBRef

  • 使数据设计变得接近传统关系型数据库,丧失了 MongoDB 的文档嵌套优势。
  • 在查询和存储效率上,嵌套文档通常优于分散的 DBRef 引用。

3. 实践中更推荐嵌套文档

在 MongoDB 的最佳实践中,推荐使用嵌套文档替代 DBRef。例如,替代上述示例:

{ 
  "author": {
    "name": "John Doe",
    "email": "john@example.com"
  }
}

优点

  • 单次查询即可获取所有所需信息。
  • 数据结构直观,减少额外查询和依赖。
  • 适合不需要频繁更新嵌套数据的场景。

适用场景

  • 当嵌套数据较小或更新不频繁时,直接嵌套更高效。
  • 如果数据非常庞大,且需要频繁访问不同部分,则可以拆分为独立集合并通过其他方式关联(如手动管理 ObjectId)。

技巧5:不要用GridFs处理小的二进制

在 MongoDB 中,GridFS 是为存储和检索大文件(超过 16MB 限制)而设计的。然而,使用 GridFS 存储小的二进制文件(通常指几 KB 或更小)并不是最佳实践。

GridFs需要查询两次,一次获取文件的元信息,另一次获取其内容。所以如果用GridFs存储小文件,会使得应用查询次数翻倍。从根本上说,GridFs是用来将大的二进制对象切成小片存放到数据库中的。

GridFS是用来存放大数据的——至少是一个文档放不下的数据。根据经验来讲,因为过大而使得客户端一次加载不了的东西,也不需要服务端一次加载。所以那些可以作为数据流传递给客户端的数据非常适合应用GridFs,需要在客户端一次全部加载的东西,如图片,声音,甚至小的视频,通常都应该嵌入到主文档中。

技巧6:处理“无缝”故障切换

MongoDB 通过复制集(Replica Set) 实现了高度可用性,并具备自动故障切换(failover)的能力。在正确配置和管理的情况下,可以实现接近无缝的故障切换。

但是有些特定错误是它无法自动处理的,比如网络错误。

举两个例子:

  1. 如果是修改操作,因为网络波动,不知道服务器是否响应怎么办。
  2. 如果是查询操作,如果有一个需要执行很久的查询,但是从节点挂掉了怎么办,总不好重新发到主节点吧。

所以说,针对于这种情况,问题就抛给了使用者。使用者必须处理驱动的网络异常,然后判断是否放弃还是重试。

技巧7:处理复制组失效及故障恢复

应用要能处理所有复制组会遇到的极端情况。

假设应用抛出“no master”异常,可能的原因如下:

  1. 复制组正在做故障恢复,应用要必须平滑的应对活跃节点选举的这段时间。
  2. 选举时间不同,一般只需要几秒钟,但有时会超过30s,如果一部分在网络不好的服务器上,可能会导致数小时连不上主节点。

如果遇到这种情况,我们必须将应用降级为只读模式,包括短时只读(活跃节点选举),长时间只读(大部分机器宕机或者网络割裂),无论有没有活跃节点,都应该能继续从能连接的节点读取数据。

技巧8:尽量减少磁盘访问

众所周知,内存的访问速度远远大于磁盘的访问速度,但是内存本身是很昂贵的,我们无法将所有的数据放到内存中。所以可以通过以下操作来减少磁盘的访问:

1. 使用内存中的工作集:MongoDB 会将经常访问的数据保存在内存中(通过操作系统的文件缓存或内存映射)。这样,数据访问通常会从内存中读取,而不是每次都从磁盘读取。确保 工作集(数据库中经常访问的数据)能够完全容纳在内存中是减少磁盘访问的关键。

2. 选择合适的索引:

  • 覆盖索引:索引不仅能帮助快速查找文档,还能减少磁盘 I/O。如果查询的字段都被索引覆盖,MongoDB 可以仅通过索引返回结果,而无需访问原始文档,从而减少磁盘访问。

  • 避免过多的索引:虽然索引可以提高查询效率,但每增加一个索引,MongoDB 在插入、更新和删除数据时需要额外的磁盘 I/O,因此索引的设计要合理平衡。

3. 选择合适的查询和分页:

避免查询返回大量数据,特别是当工作集超出内存时。分页查询时,使用索引的字段进行分页可以大幅减少磁盘的访问频率。

  • 使用范围查询:对于分页查询,尽量使用基于范围的查询(如 _id 或时间戳字段)而不是使用 skip,因为 skip 会导致 MongoDB 跳过大量数据,从而产生较多的磁盘访问。

技巧9:使用索引减少内存占用

使用索引的目的就是为了减少全表扫描,加速查询性能,减少数据加载量以及提高数据访问效率,间接降低内存消耗。

全表扫描:如果没有索引,MongoDB 会遍历整个集合的所有文档来找到符合查询条件的结果。每扫描一个文档,MongoDB 就需要将整个文档加载到内存中。这会导致内存占用大幅增加,特别是在处理大数据集时。

索引优化:有了索引后,MongoDB 可以通过索引直接定位到相关数据,从而避免全表扫描,减少内存的消耗。例如,如果查询只涉及某个特定字段(如 age),MongoDB 会直接通过索引来查找符合条件的文档,而不需要加载所有文档到内存中。

技巧10 不要到处使用索引

使用索引可以加快检索速度,但是盲目使用索引会导致很多问题:

1.索引会增加写操作的开销

  • 更新成本:每当文档中的索引字段被更新时,相关的索引也需要进行更新。对于每个索引,MongoDB 都需要调整索引中的记录,确保它们与文档保持同步。对于写操作(如 insertupdatedelete)来说,索引的维护会增加额外的开销,尤其是在有多个索引的情况下。频繁的写操作可能导致系统性能下降。
  • 删除操作:删除文档时,MongoDB 需要从所有相关的索引中删除该文档的索引条目,这也会增加开销。

2. 占用磁盘空间

  • 索引会消耗额外的磁盘空间:每个索引都会占用额外的磁盘空间。在大型数据集的情况下,多个索引可能导致显著的磁盘空间消耗。这不仅影响存储成本,还可能导致磁盘 I/O 负担加重,特别是在磁盘容量有限的情况下。
  • 不必要的索引:在某些情况下,索引可能并不常用或者查询并未使用到这些索引。过多的索引不仅消耗磁盘空间,也增加了系统管理的复杂性。

3.索引维护和管理复杂性

  • 索引的选择和维护:不同的查询模式需要不同类型的索引。为了优化性能,你需要根据应用的查询模式来决定哪些字段需要索引。如果在不需要的地方创建了索引,会使得系统更加复杂,且难以管理。过多的索引也可能导致性能问题,尤其是当有不必要的索引时。
  • 不合适的索引类型:不同的查询类型(例如范围查询、精确查询)会受益于不同的索引类型(如单字段索引、复合索引、哈希索引等)。如果为不适合的查询类型创建索引,反而可能降低查询性能。

4.索引的选择性和性能问题

  • 低选择性字段的索引不高效:在某些情况下,如果你为低选择性的字段(如布尔型字段或重复值多的字段)创建索引,反而不会提升查询性能,甚至可能导致性能下降。因为 MongoDB 需要扫描所有索引项并将其与数据集合中的文档进行比对,这对系统来说是一种额外的负担。
  • 索引选择性低时性能下降:例如,假设你为一个字段(比如 status,它的值只有 activeinactive)创建了索引。由于这个字段的选择性很低,MongoDB 并不能有效利用这个索引进行查询,反而会变得和全表扫描一样低效。

5. 影响查询优化器的决策

  • 查询优化器选择索引时的开销:MongoDB 查询优化器会在多个可用的索引中选择一个最合适的索引来执行查询。但是,如果你创建了过多的索引,查询优化器的选择变得复杂且消耗更多资源,可能导致选择一个次优的索引,反而影响查询性能。
  • 多余的索引会让优化器更难做出决策,而且在某些情况下可能让查询变得更慢。

6.索引不适合所有查询

  • 全表扫描有时更高效:某些查询场景(如非常少的文档)可能并不需要索引,直接进行全表扫描反而更高效。使用索引的时间开销可能会比直接扫描所有文档的成本更高,尤其是在数据量不大的时候。
  • 对于某些聚合操作:例如,复杂的聚合操作中,使用索引可能不能有效加速查询,反而需要更多的计算和资源。MongoDB 有时可能会选择全表扫描来执行聚合操作,而非使用索引,这取决于聚合的复杂性。

7.写入时的锁竞争

  • 多个索引引起锁竞争:如果你有多个索引,并且这些索引需要频繁更新,尤其在写操作量大的情况下,可能会导致 MongoDB 在更新索引时发生锁竞争,影响系统的写入性能。
  • 写性能下降:因为每次插入或更新数据时,MongoDB 都需要对索引进行修改,导致写操作的性能受到影响。如果索引设计不当,会进一步影响性能。

技巧11: 使用索引覆盖查询

在 MongoDB 中,索引覆盖查询(Index Covered Query) 是一种通过索引本身返回所有所需数据的查询优化策略。换句话说,当查询的所有字段(包括查询条件、排序字段以及返回字段)都可以从索引中直接获取时,就可以实现索引覆盖查询。

索引覆盖查询的原理

MongoDB 的查询优化器会选择最适合的索引来加速查询。当一个查询可以完全由一个索引提供所需的数据时,它被称为“索引覆盖查询”。这样,MongoDB 就不需要再回到文档存储中去读取数据,所有所需的信息都可以直接从索引中获得。

例如,假设有一个集合 users,其中有以下字段:nameageemail,并且在 nameage 字段上创建了一个复合索引。如果你执行以下查询:

db.users.find({ age: { $gt: 25 } }, { name: 1, age: 1 })

这个查询就可以使用 agename 的复合索引来满足,因为返回的字段(nameage)正是索引中包含的字段,并且查询条件(age: { $gt: 25 })也与索引字段相匹配。因此,MongoDB 可以通过索引直接返回结果,而不需要访问实际的文档。

为什么索引覆盖查询很重要?

提高查询性能

  • 通过避免从磁盘读取数据,索引覆盖查询可以显著提高查询的响应速度。MongoDB 只需要从内存中的索引中读取数据,而不需要访问磁盘上的实际文档。
  • 如果查询能够完全通过索引完成,它就避免了所谓的“回表”操作,即使用索引来定位文档后,再去读取完整文档的过程。

减少磁盘 I/O 和 CPU 消耗

-   由于查询无需访问文档存储,避免了磁盘 I/O 和 CPU 资源的浪费,尤其对于大型集合或者在磁盘性能较差的情况下,索引覆盖查询可以显著提高效率。

提升整体吞吐量

-   减少了对存储的访问,优化了内存的使用,从而提升了数据库的整体吞吐量,特别是在高并发的查询场景下,能够显著减少负载。

如何判断是否能使用索引覆盖查询?

MongoDB 使用 查询计划 来决定是否能利用索引覆盖查询。查询计划的选择基于以下几个因素:

查询字段与索引字段的匹配

查询中涉及的所有字段(查询条件、排序字段、返回字段)必须都能够通过一个单独的索引来提供。如果查询需要的字段不在索引中,MongoDB 就不能实现索引覆盖查询。

复合索引的使用

如果查询涉及多个字段,MongoDB 可能需要一个复合索引来满足索引覆盖查询。如果查询涉及的字段和索引顺序匹配,MongoDB 可以高效地利用这个索引来获取结果。

返回字段的选择

如果查询只需要返回文档中的某些字段(而不是整个文档),且这些字段都在索引中,MongoDB 就能通过索引覆盖查询来返回所需数据。

排序要求的匹配

查询中包含的排序字段(sort())也必须与索引的顺序匹配。如果排序字段可以通过索引提供,MongoDB 就能使用索引来优化查询。

索引覆盖查询的例子

假设有一个 orders 集合,并且在 statuscreated_at 字段上创建了复合索引:

js
​
​
复制代码
db.orders.createIndex({ status: 1, created_at: -1 })

然后执行以下查询:

js
​
​
复制代码
db.orders.find({ status: 'shipped' }, { status: 1, created_at: 1 })

这时,MongoDB 可以使用 statuscreated_at 上的复合索引直接返回结果,完全从索引中获取数据,而不需要访问实际的文档,因此这个查询是一个索引覆盖查询。

不支持索引覆盖查询的情况

查询返回的字段不在索引中: 如果查询要求返回的字段不在索引中,MongoDB 就无法完全从索引中获得数据,必须回到文档存储中读取数据,从而不能使用索引覆盖查询。

需要进行计算或聚合:如果查询需要进行字段计算(例如 $sum$avg 等)或涉及聚合操作(如 groupBy),则无法通过索引覆盖查询来满足。

查询包含不支持的操作: 某些查询操作(如 $regex$text)可能不适合通过索引覆盖查询来加速,尤其是在复杂的模式匹配或全文搜索的情况下。

技巧12:使用复合索引查询加快多个查询:

假设你有一个包含多个查询的场景,这些查询都涉及到同一组字段。比如,查询涉及 agestatuscreatedAt 字段:

  1. find({ age: 25, status: 'active' })
  2. find({ age: 30, status: 'inactive' })
  3. find({ status: 'active', createdAt: { $gt: ISODate("2024-01-01") } })
  4. find({ age: 25, createdAt: { $lt: ISODate("2024-01-01") } })

建立复合索引的方式

对于这些查询,我们可以创建一个复合索引来覆盖 agestatuscreatedAt 字段。你可以根据字段的使用顺序和查询的特点来确定索引的字段顺序。

1. agestatus 是最常用的筛选字段

首先,考虑查询中最常用的字段。例如,在这四个查询中,agestatus 经常一起出现在查询条件中,尤其是前两个查询。为这两个字段创建复合索引能加速这类查询。

db.collection.createIndex({ age: 1, status: 1 })

这个索引将加速包含 agestatus 条件的查询。

2. 添加 createdAt 字段的索引

如果查询中有排序或范围条件(如基于 createdAt 字段),你可以进一步优化查询。对于第三个和第四个查询,它们涉及 createdAt 字段,因此我们可以考虑在复合索引中加入 createdAt 字段。

db.collection.createIndex({ age: 1, status: 1, createdAt: 1 })

这个索引将帮助加速所有涉及 agestatuscreatedAt 条件的查询。它覆盖了大部分查询条件。

3. 考虑查询的顺序

在创建复合索引时,字段的顺序非常关键。字段顺序应该按照查询的使用频率、索引优化的优先级来设计。一般来说,最常用的筛选字段应该排在索引的前面。

  • 如果大多数查询都涉及 agestatus,则 agestatus 应该出现在索引的前面。
  • 对于范围查询(如 createdAt),它应该放在复合索引的末尾,以便更好地支持范围查询。

技巧13:通过分级文档加速扫描

将数据组织的有层次,不仅可以使其看着更有条理,还可以让MongoDb在没有索引时也能快速查询。

例如,假设有个查询并不使用索引。如前文所述,MongoDb需要遍历集合中的所有文档来确定是否有什么能匹配查询条件。这个过程可能相当耗时,而这取决于文档结构。 比方说,用户文档使用了扁平结构

// 扁平结构
{
"_id" : "id",
"name" : username,
"email" : email,
"twitter" : username,
"screenname" : username,
"facebook" : username,
"linkedin" : username,
"phone" : number,
"street" : street,
"city" : city,
"state" : state,
"zip" : zip,
"fax" : number
}

假设我们要执行下面的查询:

> db.users.find({"zip" : "10003"})

MongoDb会做哪些工作呢?它必须遍历每个文档的每个字段,来查找zip字段。

使用内嵌文档我们可以建立自己的树,能让MongoDb执行比次查询更快,现在结构也变了。

// 嵌套结构
{
  "_id": id,
  "name": username,
  "online": {
    "email": email,
    "twitter": username,
    "screenname": username
    "facebook":username
  },
  "address": {
    "street": "street",
    "city": city,
    "state": state
    "zip":zip
  },
  "tele":{
   "phone":number,
   "fax":number
  }
}

相应的查询也变了

db.users.find({"address.zip" : "10003"})

这样MongoDb在找到匹配的address之前,仅查看_id、name和online,而后在address中匹配zip。合理使用层次可以减少MongoDb对字段的访问。

技巧14:AND查询时,满足最少文档的条件放到最前面

MongoDB 执行查询时通常会选择能最早过滤掉不符合条件的数据,从而减少后续操作的文档数量。如果先使用选择性较低的条件,MongoDB 将会扫描更多的文档,之后才应用更具选择性的条件,这样做会浪费更多的计算资源和内存。

技巧15:or查询时,满足最多文档的条件放到最前面

在 MongoDB 中,对于 OR 查询,建议将满足最多文档的条件放在前面,原因是查询优化的原则与 AND 查询略有不同。

在执行 $or 查询时,MongoDB 会并行评估所有的查询条件。为了提高查询效率,选择先评估匹配最多文档的条件,可以更快地减少查询范围,因为它能够提前返回结果并停止不必要的扫描。

简而言之,MongoDB 会根据条件的选择性来决定评估顺序,选择性越高(即返回的文档越少),越应放在查询的后面,反之,匹配文档越多的条件应放在前面,这样可以最大限度地减少查询时的无效操作。

参考: