前言

MongoDB 作为一款非常流行的 NoSQL 数据库,由于其灵活的数据模型和易用性,被广泛应用于各种业务场景中。然而,随着数据量的增长和查询复杂度的提高,数据库的性能问题逐渐显现出来,其中最常见的就是查询速度的下降,即我们常说的“慢查询”。

慢查询不仅会影响到应用的性能,还可能因此导致整个系统的响应速度下降,严重影响用户体验。因此,对 MongoDB 的慢查询进行分析,并通过优化索引等方法提高查询性能,是每一位使用 MongoDB 的开发者必须掌握的技能。

在接下来的文章中,我将详细介绍 MongoDB 的慢查询分析和索引优化的相关知识。

使用 Profiling 工具进行慢查日志收集

Profiling 工具

Profiling 是 mongodb 的查询分析工具,可以通过其收集 mongo 命令的执行信息。收集的信息将会记录在 system.profile 集合中。

可以通过以下命令来查看是否开启了 Profiling 工具:

1
db.getProfilingStatus()

以下是一个返回的示例:

1
{ was: 0, slowms: 100, sampleRate: 1, ok: 1 }

其中:

was 表示的是 Profiling 的级别:

  • 0: 关闭,不收集任何数据,这是默认级别
  • 1: 收集慢查询数据,默认是 100 ms
  • 2: 收集所有数据

slowms 表示的是慢查询的阈值,单位是 毫秒

sampleRate 表示的是当 was 为 2(收集所有数据) 时记录操作的概率,其取值范围为[0,1],其中 1 表示记录所有操作,0 表示忽略所有操作,其他值为记录操作的概率,比如说 0.5 时会有大约一半的操作会被记录。此选项在你需要进行性能分析,但又不希望记录所有操作(可能因为数量太大或者其他原因)的时候非常有用。这样你可以根据需要调整 sampleRate,在保证性能分析有效性的同时,减少对系统性能的影响。

设置 Profiling 的级别和时间

可以在 mongodb 启动后,通过 mongosh 的形式修改 Profiling 的级别和查询阈值:

1
2
3
4
5
6
// 设置等级为 2
db.setProfilingLevel(2)
// 设置等级为 1,慢查询阈值为 200ms
db.setProfilingLevel(1,200)
// 关闭 Profiling
db.setProfilingLevel(0)

也可以在启动 mongod 时就进行设置:

1
mongod --profile=1 --slowms=200

或者在配置文件中添加

1
2
profile = 1
shlowms = 200

获取和阅读慢查日志

通过以下命令可以查看当前的慢查询日志,使用 pretty() 方法可以使显示的结果更美观

1
2
3
4
5
6
db.system.profile.find()
db.system.profile.find().pretty()
db.system.profile.find().limit(10).sort({ts:-1}).pretty() // 最近十条记录
db.system.profile.find({op:{$ne:"command"}}).pretty()
db.system.profile.find({millis:{$gt:150}}).pretty() // 返回操作时间大于 150ms 的
// 其他的查询条件可以根据下面示例的字段介绍,修改筛选器参数

以下通过一个查询的输出,介绍以下各个字段的含义

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
> db.system.profile.find().pretty();
{
"op" : "query", # 操作类型,有insert、query、update、remove、getmore、command
"ns" : "mytesyt.users", # 操作的集合
"query" : {
"$query" : {
"user_id" : 12554442,
"data_time" : {
"$gte" : 1636198400
}
},
"$orderby" : {
"created_at" : 1
}
},
"ntoskip" : 0, # 指定跳过skip()方法 的文档的数量。
"nscanned" : 2, # 为了执行该操作,MongoDB在 index 中浏览的文档数。 一般来说,如果 nscanned 值高于 nreturned 的值,说明数据库为了找到目标文档扫描了很多文档。这时可以考虑创建索引来提高效率。
"nscannedObjects" : 1, #为了执行该操作,MongoDB在 collection中浏览的文档数。
"keyUpdates" : 0, # 索引更新的数量,改变一个索引键带有一个小的性能开销,因为数据库必须删除旧的key,并插入一个新的key到B-树索引
"numYield" : 1, # 该操作为了使其他操作完成而放弃的次数。通常来说,当他们需要访问还没有完全读入内存中的数据时,操作将放弃。这使得在MongoDB为了放弃操作进行数据读取的同时,还有数据在内存中的其他操作可以完成
"lockStats" : { # 锁信息,R:全局读锁;W:全局写锁;r:特定数据库的读锁;w:特定数据库的写锁
"timeLockedMicros" : { # 该操作获取一个级锁花费的时间。对于请求多个锁的操作,比如对 local 数据库锁来更新 oplog ,该值比该操作的总长要长(即 millis )
"r" : NumberLong(1089485),
"w" : NumberLong(0)
},
"timeAcquiringMicros" : { # 该操作等待获取一个级锁花费的时间。
"r" : NumberLong(102),
"w" : NumberLong(2)
}
},
"nreturned" : 1, # 返回的文档数量
"responseLength" : 1669, # 返回字节长度,如果这个数字很大,考虑值返回所需字段
"millis" : 544, # 消耗的时间(毫秒)
"execStats" : { # 一个文档,其中包含执行 查询 的操作,对于其他操作,这个值是一个空文件, system.profile.execStats 显示了就像树一样的统计结构,每个节点提供了在执行阶段的查询操作情况。
"type" : "LIMIT", # 使用limit限制返回数
"works" : 2,
"yields" : 1,
"unyields" : 1,
"invalidates" : 0,
"advanced" : 1,
"needTime" : 0,
"needFetch" : 0,
"isEOF" : 1, # 是否为文件结束符
"children" : [
{
"type" : "FETCH", # 根据索引去检索指定document
"works" : 1,
"yields" : 1,
"unyields" : 1,
"invalidates" : 0,
"advanced" : 1,
"needTime" : 0,
"needFetch" : 0,
"isEOF" : 0,
"alreadyHasObj" : 0,
"forcedFetches" : 0,
"matchTested" : 0,
"children" : [
{
"type" : "IXSCAN", #扫描索引键
"works" : 1,
"yields" : 1,
"unyields" : 1,
"invalidates" : 0,
"advanced" : 1,
"needTime" : 0,
"needFetch" : 0,
"isEOF" : 0,
"keyPattern" : "{ user_id: 1.0, created_at: -1.0 }",
"boundsVerbose" : "field #0['user_id']: [314436841, 314436841], field #1['created_at']: [1436198400, inf.0]",
"isMultiKey" : 0,
"yieldMovedCursor" : 0,
"dupsTested" : 0,
"dupsDropped" : 0,
"seenInvalidated" : 0,
"matchTested" : 0,
"keysExamined" : 2,
"children" : [ ]
}
]
}
]
},
"ts" : ISODate("2021-10-15T07:41:03.061Z"), # 该命令在何时执行
"client" : "x.x.x.x", # 链接ip或则主机
"allUsers" : [
{
"user" : "admin",
"db" : "mytest"
}
],
"user" : "admin@mytest"
}

若是发现 millis 值比较大,那么就需要作优化。可以根据其他的值来判断,需要往哪个方向进行优化:

  • nscanned 数较大:可能没有利用到索引,甚至使用了全表扫描
  • nscanned > nreturned :扫描数大于返回数,考虑利用索引

查询分析

在上文中,我们介绍了如何开启 mongodb 的慢查询记录以及如何查看慢查询操作。但这算是一种被动的方法,只能作为系统上线后的一种优化手段。通常我们需要在开发阶段就预检到可能产生的慢查询,并着手进行优化。此时,就需要进行查询分析了。

explain

在 mongodb 中,可以通过 explain 方法对一条查询进行分析,explain 方法可以接受一个可选的参数,用来指定返回的信息级别。

  1. queryPlanner:这是默认的参数值。在这个级别,explain() 方法只返回查询计划的信息,包括 winningPlan
  2. executionStats:在这个级别,explain() 方法返回查询计划的信息,并且还包括查询执行的统计信息,例如查询执行的时间、扫描的文档数、返回的文档数等
  3. allPlansExecution:在这个级别,explain() 方法返回所有的信息,包括所有考虑过的查询计划的执行统计信息。这个级别的信息最详细,但是也最复杂

一般使用 .explain("executionStats") 返回的信息就足够进行分析了,该命令返回的结果中,需要注意的字段主要有两个,一是 executionStats,该字段包含了查询计划执行的完整过程;二是 winningPlan,该字段描述了 MongoDB 查询优化器选择的最佳查询计划

executionStats 中包含了以下字段:

  1. nReturn:查询条件匹配到的文档数量
  2. executionTimeMillis:查询计划选择和查询执行的总时间(ms)
  3. totalKeysExamined:扫描的索引的条目数
  4. totalDocsExamined:扫描的文档数
  5. executionStages:树状形式展示获胜计划完整的执行信息

通常情况下,totalKeysExamined = nReturned = totalDocsExamined 时表示已经最大程度地利用了索引,表示该查询已经经过了很好的优化。

也存在 totalDocsExamined = 0 但 totalKeysExamined != 0 的情况,这种情况一般出现在覆盖查询时,表示查询计划可以直接通过索引获取需要的数据,不需要加载文档数据。

winningPlan 通常为一个包含层级 inputStage 的结构,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"winningPlan" : {
"stage" : <STAGE1>,
...
"inputStage" : {
"stage" : <STAGE2>,
...
"inputStage" : {
...
}
}
},
}

其中 stage 为阶段名称, inputStage 为描述子阶段的文档,它为父级提供文档或者索引键。可以将其看成一个树形结构,每个子节点将其结果传递给父节点,而只有叶子结点访问了索引或者集合。

stage 字段可能的值列表:

含义
COLLSCAN 全表扫描,这个阶段会扫描整个集合来查找匹配的文档。如果查询计划中出现了该阶段,表示查询一般是需要优化的,可以通过加索引或者修改查询计划来实现
IXSCAN 索引扫描,这个阶段会扫描索引来查找匹配的条目。表示查询计划利用了索引进行扫描
FETCH 检出文档,这个阶段会获取 IXSCAN 阶段找到的文档,会从磁盘中加载数据到内存中
SHARD_MERGE 合并分片中的结果
SHARDING_FILTER 分片中过滤文档
LIMIT 使用 limit 限制返回数
SORT 使用 sort 进行排序
SKIP 使用 skip 跳过文档
GROUP 使用 group 进行了分组
IDHACK 针对 _id 进行查询
COUNT 无索引 count 运算
COUNT_SCAN 有索引 count运算
TEXT 全文索引查询
PROJECTION 投影阶段

索引优化实践

在查询需要使用多个字段的时候,尽量使用复合索引

复合索引的创建遵循 ESR 原则

E : equal

S : sort

R : range

即:创建复合索引时,通常进行等值筛选的字段放在最前面,进行排序的字段次之,最后是进行范围查找的字段。

在能够使用覆盖查询的场景,尽量使用覆盖查询

覆盖查询指的是查询完全不需要将文档加载到内存中,而是可以直接从索引获取数据。

覆盖查询需要满足一个复杂的条件,即:查询、排序以及投影的文档,都被同一个索引满足。

mongodb 的查询默认都是会返回 _id 字段的,所以在索引中没有该字段的时候,我们需要手动把 _id 排除。

简单举个例子,现在集合中有该索引:id_1_app_1_name_1

执行以下查询:

1
db.col.find({id:"cc143189-7ab5-46dc-9344-e1acc08b29bd",app:"n1"},{_id:0,id:1,app:1}).sort({name:1})

该查询的查询字段、排序字段以及投影字段同时出现在了一个索引中,因此能够覆盖查询。

减小低基数字段索引

对于枚举值较少的字段,尽量不要为其单独增加一个索引,效益不高。但是可以在复合索引中出现。

减少不必要的索引

可以使用以下命令查询指定集合指定索引的访问次数,访问频率很低的索引可以分析一下是否是必要的。减少索引数量可以降低空间占用,提高数据库的写效率。

1
2
3
4
db.col.aggregate([
{ $indexStats: { } },
{ $match: { "name": "index_name"}}
],{ cursor: {} })

使用部分索引功能

在 mongo 3.2 之后的版本就可以使用部分索引功能了,在创建索引的时候可以指定一个条件,指定完成后,该索引就只会在满足条件的文档上建立,可以节省索引的空间占用,提高查询效率和写入效率。

以下为一个创建部分索引的例子

1
2
3
4
db.collection.createIndex(
{ field1: 1, field2: 1 },
{ partialFilterExpression: { field3: { $gt: 1} } }
)

值得一提的是,partialFilterExpression 参数不接受 $not$ne 等选项

结语

在本篇博客中介绍了 MongoDB 的慢查询日志收集以及如何进行慢查询分析优化。我们了解了 MongoDB 的 Profiling 工具,了解了如何利用.explain()方法的 winningPlan 和 executionStats 字段来深入理解查询的执行过程和性能。

但需要知道,性能优化是一个持续的过程,而不是一次性的任务。每个数据库和查询都有其独特的特点和需求,因此最佳实践可能会根据具体情况而变化。在优化查询性能时,我们需要不断地监控,收集日志,分析数据,测试新的优化策略,并根据结果进行调整。