MongoDB查询优化

301 阅读5分钟

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 })

重要
设置slowmssampleRate也会影响系统诊断日志。

如果需要查看当前的分析级别,可以在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值的范围。对于每个具体的lastLoginAttotalSpent都是升序排序的,但在跨范围时,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作为范围查询的条件以防止重复值。