MongoDB提供了两个用于优化查询性的工具:explain和数据库分析器。数据库分析器负责收集数据库命令的详细信息,explain用于返回有关查询计划和查询计划执行信息。
数据库分析器
分析器负责收集数据库命令的详细信息(包括CRUD操作以及配置和管理命令)。对于收集到的所有数据都会写入被分析数据库中的“固定大小的集合system.profile”。
默认情况下,分析器处于未启用状态。我们可以根据不同的分析级别对数据库或实例启用分析器。
启用分析功能后,会影响数据库性能和磁盘使用情况。
分析器级别
可设置的分析级别:
- 0
关闭分析器,不收集任何数据。默认的分析级别。 - 1
分析器会收集超过slowms或指定过滤匹配的操作的数据。 - 2
分析器会收集所有操作的数据。
启用分析器
在为数据库启用分析后,MongoDB会在该数据库中创建system.profile集合,集合大小默认为1MB。
例如,要对当前连接的数据库的所有数据库操作启用分析,在mongo中运行操作:
db.setProfilingLevel(2)
命令会在was字段中会返回上一个分析级别并设置新级别:
{ "was" : 0, "slowms" : 100, "sampleRate" : 1, "ok" : 1 }
默认情况下,慢操作阈值为100毫秒。
注意
使用db.setProfilingLevel设置的分析级别,在mongod实例重启后恢复为默认值0。
将当前连接的数据库的分析级别设置为1并将mongod实例的慢操作阈值设置为10毫秒:
db.setProfilingLevel(1, { slowms: 10 })
重要
设置slowms和sampleRate也会影响系统诊断日志。
如果需要查看当前的分析级别,可以在mongo中运行下面的操作:
db.getProfilingLevel()
关于分析器更详细的设置(比如设置过滤器),在这里不展开叙述。有需要可以参考MongoDB官方文档
explain
返回有关查询计划和查询计划执行统计信息,可用来对单个查询操作的性能进行调优。
输出结构
按照官档所述,explain输出结构可能会因操作使用的查询引擎而异。在mongo中的输出结构:(只列举下文中需要用的字段):
{
"explainVersion" : "1",
"queryPlanner" : {
"indexFilterSet" : false,
"winningPlan" : {
"stage" : "SORT",
"inputStage": {
"stage": "COLLSCAN"
}
}
},
"executionStats" : {
"nReturned" : 0,
"executionTimeMillis" : 58,
"totalKeysExamined" : 0,
"totalDocsExamined" : 100000,
}
}
其中
queryPlanner.indexFilterSet
是否应用索引过滤器。queryPlanner.winningPlan
所有查询计划中获胜者。queryPlanner.winningPlan.stage
执行阶段名称。比如,IXSCAN表示使用索引,COLLSCAN表示会进行全表扫描等。queryPlanner.winningPlan.inputStage(s)
描述子阶段信息。executionStats
详细说明获胜计划的执行情况。executionStats.nReturned
返回的文档数。executionStats.totalKeysExamined
扫描的索引条目数。executionStats.totalDocsExamined
查询执行过程中检查的文档数。executionStats.executionTimeMillis
选择查询计划和执行查询所需的总时间(不包括将数据传输回客户端的网络时间)。
更详细的资料,可以参考MongoDB官方文档
查询优化
需求
在users集合中搜索出近六个月登录过系统且为超级会员的用户,结果按照累计消费金额降序排序。
其中users中存储的文档结构如下:
{
fullName: "Kristie Donnelly", // 用户名
totalSpent: 26, // 累计消费金额
memberLevel: 1, // 会员等级。其中,0代表普通用户,1代表会员,2代表超级会员
lastLoginAt: 1711382533, // 最后一次登录系统时间
registeredAt: 1672506664, // 注册时间
}
步骤拆解
STEP1
搜索近六个月登录过系统的用户:
db.users.find({ lastLoginAt: { $gte: 1717171200 } })
查看查询计划的统计信息:
db.users.find({ lastLoginAt: { $gte: 1717171200 } }).explain(true)
{
"queryPlanner" : {
"winningPlan" : {
"stage" : "COLLSCAN"
}
},
"executionStats" : {
"nReturned" : 54255,
"executionTimeMillis" : 74,
"totalKeysExamined" : 0,
"totalDocsExamined" : 100000
}
}
MongoDB需要进行全表扫描以查找匹配的文档。返回文档和扫描的文档之间的数量差异可能表明,使用索引可能有助于提高查询效率。
对lastLoginAt字段添加索引:
db.users.createIndex({ lastLoginAt: 1 })
查看查询计划的统计信息:
db.users.find({ lastLoginAt: { $gte: 1717171200 } }).explain(true)
{
"queryPlanner" : {
"winningPlan" : {
"stage" : "FETCH",
"inputStage" : {
"stage" : "IXSCAN",
"indexName" : "lastLoginAt_1"
}
}
},
"executionStats" : {
"nReturned" : 54255,
"executionTimeMillis" : 59,
"totalKeysExamined" : 54255,
"totalDocsExamined" : 54255
}
}
MongoDB使用了索引运行时,查询扫描文档数和索引条目数以及返回的文档数相等,从而提升查询效率。
在添加索引前后,查询执行时间并没有大幅度下降。这是因为,即使在使用索引的情况下,MongoDB仍然需要扫描大量的文档以查找匹配的文档。
重要
如果MongoDB需要扫描大量文档才能返回结果,那么某些查询在没有索引的情况下可能会执行得更快。
STEP2
搜索近六个月登录过系统且为超级会员的用户:
db.users.find({ lastLoginAt: { $gte: 1717171200 }, memberLevel: 2 })
查看查询计划的统计信息:
db.users.find({ lastLoginAt: { $gte: 1717171200 }, memberLevel: 2 }).explain(true)
{
"queryPlanner" : {
"winningPlan" : {
"stage" : "FETCH",
"inputStage" : {
"stage" : "IXSCAN",
"indexName" : "lastLoginAt_1",
}
}
},
"executionStats" : {
"nReturned" : 545,
"executionTimeMillis" : 106,
"totalKeysExamined" : 54255,
"totalDocsExamined" : 54255
}
}
MongoDB虽然使用了索引运行查询,但是仍需要扫描大量的文档以查询匹配的文档。结合之前的经验,可能需要创建复合索引以支持多个字段的查询。
在lastLoginAt字段和memberLevel字段上添加复合索引:
db.users.createIndex({ lastLoginAt: 1, memberLevel: 1 })
查看查询计划的统计信息:
db.users.find({ lastLoginAt: { $gte: 1717171200 }, memberLevel: 2 }).explain(true)
{
"queryPlanner" : {
"winningPlan" : {
"stage" : "FETCH",
"inputStage" : {
"stage" : "IXSCAN",
"indexName" : "lastLoginAt_1_memberLevel_1"
}
}
},
"executionStats" : {
"nReturned" : 545,
"executionTimeMillis" : 91,
"totalKeysExamined" : 54152,
"totalDocsExamined" : 545
}
}
扫描的文档数和返回的文档数一致表明,添加的复合索引有效提升了查询效率。但此时扫描的索引条目仍然比较大。为了进一步提高查询效率,应尽可能减小扫描的索引条目数totalKeysExamined的值。
或许你也想到了,复合索引上字段的顺序不正确。它应该是{ memberLevel: 1, lastLoginAt: 1 }:
db.users.createIndex({ memberLevel: 1, lastLoginAt: 1 })
查看查询计划的统计信息:
db.users.find({ lastLoginAt: { $gte: 1717171200 }, memberLevel: 2 }).explain(true)
{
"queryPlanner" : {
"winningPlan" : {
"stage" : "FETCH",
"inputStage" : {
"stage" : "IXSCAN",
"indexName" : "memberLevel_1_lastLoginAt_1"
}
}
},
"executionStats" : {
"nReturned" : 545,
"executionTimeMillis" : 7,
"totalKeysExamined" : 545,
"totalDocsExamined" : 545
}
}
扫描的索引条目书与返回的文档数相等,这意味着MongoDB只需检查索引即可返回结果。MongoDB不必扫描所有的文档,只需将匹配的文档放入到内存中。这大幅度提升了查询效率。
重要
先进行等值测试,再进行范围测试。
STEP3
最后,将结果按照消费金额降序排序:
db.users.find({ lastLoginAt: { $gte: 1717171200 }, memberLevel: 2 }).sort({ totalSpent: -1 })
查看查询计划的统计信息:
db.users.find({ lastLoginAt: { $gte: 1717171200 }, memberLevel: 2 }).sort({ totalSpent: -1 }).explain(true)
{
"queryPlanner" : {
"winningPlan" : {
"stage" : "SORT",
"sortPattern" : {
"totalSpent" : -1
},
"memLimit" : 104857600,
"type" : "simple",
"inputStage" : {
"stage" : "FETCH",
"inputStage" : {
"stage" : "IXSCAN",
"indexName" : "memberLevel_1_lastLoginAt_1"
}
}
}
},
"executionStats" : {
"nReturned" : 545,
"executionTimeMillis" : 31,
"totalKeysExamined" : 545,
"totalDocsExamined" : 545
}
}
结果中包含了SORT阶段,表明MongoDB无法使用索引来获取排序结果,必须对数据执行阻塞排序操作。阻塞排序表示MongoDB必须在返回结果之前消耗并处理排序的所有输入文档。
注意
MongoDB在执行阻塞排序操作时,内存限制为100MB。一旦超过该限制,MongoDB会自动将临时文件写入磁盘,除非该查询指定了{ allowDiskUse: false },此时会直接返回错误。
在memberLevel 字段和totalSpent字段上添加索引来获取排序顺序:
db.users.createIndex({ memberLevel: 1, totalSpent: 1 })
查看查询计划的统计信息:
db.users.find({ lastLoginAt: { $gte: 1717171200 }, memberLevel: 2 }).sort({ totalSpent: -1 }).explain(true)
{
"queryPlanner" : {
"winningPlan" : {
"stage" : "FETCH",
"inputStage" : {
"stage" : "IXSCAN",
"indexName" : "memberLevel_1_totalSpent_1"
}
}
},
"executionStats" : {
"nReturned" : 545,
"executionTimeMillis" : 10,
"totalKeysExamined" : 1009,
"totalDocsExamined" : 1009
}
}
MongoDB从包含排序字段的索引中获取排序结果,不需要在内存执行阻塞排序操作。但是,查询效率会随着扫描的文档数增加而降低。所以该索引可能还不是最优解。
为什么这里不选择构建索引{ memberLevel: 1, lastLoginAt: 1, totalSpent: 1 }呢?因为范围查询破坏了索引顺序的完整性,使得MongoDB无法直接利用索引进行排序。例如,在查询{ lastLoginAt: { $gte: 1717171200 }, memberLevel: 2 }时,索引会匹配多个lastLoginAt值的范围。对于每个具体的lastLoginAt,totalSpent都是升序排序的,但在跨范围时,totalSpent无法保证全局有序。
重要
MongoDB无法对范围过滤器(如$gt(e)、$lt(e)、$in、$nin、$ne等)的结果进行索引排序.
在先前的复合索引上包含lastLoginAt字段:
db.users.createIndex({ memberLevel: 1, totalSpent: 1, lastLoginAt: 1 })
查看查询计划的统计信息:
db.users.find({ lastLoginAt: { $gte: 1717171200 }, memberLevel: 2 }).sort({ totalSpent: -1 }).explain(true)
{
"queryPlanner" : {
"winningPlan" : {
"stage" : "FETCH",
"inputStage" : {
"stage" : "IXSCAN",
"indexName" : "memberLevel_1_totalSpent_1_lastLoginAt_1"
}
}
},
"executionStats" : {
"nReturned" : 545,
"executionTimeMillis" : 8,
"totalKeysExamined" : 903,
"totalDocsExamined" : 545
}
}
扫描的文档数减少,表明MongoBD根据索引条目就可以过滤不符合时间范围的文档。
总结
在创建索引时,需要根据查询需求、查询过滤条件、排序字段、索引的访问模式进行考虑。同样的索引,在不同的查询场景下,查询性能是不一样的。使用索引的排序也不一定就比阻塞排序的性能更好,甚至有时候也可以考虑在客户端进行排序。
分页优化
如果要检索所有会员等级为普通用户的数据,我们通常会采用分页查询方式,避免单次返回大量的数据给客户端,避免造成性能问题。
例如,查询第一页的数据:
db.users.find({ memberLevel: 0 }).sort({ lastLoginAt: -1 }).limit(20)
这似乎看起来并没有什么问题,查询使用了上索引,执行时间也很快。
在查询第2001页的数据时,发现执行时间变长了:
db.users.find({ memberLevel: 0 }).sort({ lastLoginAt: -1 }).skip(40000).limit(20)
查看查询计划的统计信息:
db.users.find({ memberLevel: 0 }).sort({ lastLoginAt: -1 }).skip(40000).limit(20).explain(true)
{
"queryPlanner" : {
"winningPlan" : {
"stage" : "LIMIT",
"inputStage" : {
"stage" : "FETCH",
"inputStage" : {
"stage" : "SKIP",
"inputStage" : {
"stage" : "IXSCAN",
"indexName" : "memberLevel_1_lastLoginAt_1"
}
}
}
}
},
"executionStats" : {
"nReturned" : 20,
"executionTimeMillis" : 80,
"totalKeysExamined" : 40020,
"totalDocsExamined" : 20
}
}
随着偏移量的增加,扫描的索引条目数线性增长,skip()的速度会变慢。
那么,有没有什么方法可以来解决大偏移量造成的性能问题?使用范围查询来避免扫描不需要的文档。
例如,查询第一页的数据:
db.users.find({ memberLevel: 0 }).sort({ lastLoginAt: -1 }).limit(20)
{ "_id" : ObjectId("6748e42dbe50ad83235117d5"), "fullName" : "Carla Powlowski", "totalSpent" : 302, "memberLevel" : 0, "lastLoginAt" : 1732829951, "registeredAt" : 1694049724 }
{ "_id" : ObjectId("6748e42dbe50ad8323512138"), "fullName" : "Janis Carter", "totalSpent" : 36, "memberLevel" : 0, "lastLoginAt" : 1732829497, "registeredAt" : 1695455849 }
{ "_id" : ObjectId("6748e42dbe50ad832351508b"), "fullName" : "Jared Howell", "totalSpent" : 909, "memberLevel" : 0, "lastLoginAt" : 1732829446, "registeredAt" : 1702823645 }
...
{ "_id" : ObjectId("6748e42dbe50ad832350e888"), "fullName" : "Catherine Fisher", "totalSpent" : 105, "memberLevel" : 0, "lastLoginAt" : 1732822123, "registeredAt" : 1686627303 }
{ "_id" : ObjectId("6748e42dbe50ad8323512fbc"), "fullName" : "Cora Ziemann", "totalSpent" : 301, "memberLevel" : 0, "lastLoginAt" : 1732821995, "registeredAt" : 1697690139 }
查询下一页的数据时,需要将上一次返回的结果中的最后一条数据的lastLoginAt的值,添加到查询条件中。例如,查询第二页的数据:
db.users.find({ memberLevel: 0, lastLoginAt: { $lte: 1732821995 } }).sort({ lastLoginAt: -1 }).limit(20)
需要注意的是,因为lastLoginAt的值不唯一,会导致相同值的数据在前后两页中重复出现。可以选择_id作为范围查询的条件以防止重复值。