跳至主要内容

MongoDB 索引最佳实践:性能优化技巧

了解如何创建 MongoDB 索引,并通过技巧让其发挥最佳性能。
· 8分钟 阅读

使用 AI 探索

对于任何数据库(包括 MongoDB)来说,索引都是开发过程中的关键组成部分。没有索引时,随着数据库规模增长,性能会随之下降;而有了合理的索引,数据量增加的同时,访问性能仍能保持在最佳状态。

在本文中,我们将探讨 MongoDB 中可用的多种索引类型,分享一些优化使用体验的技巧,并说明如何确保您创建的索引真正被使用。如果您仍在学习 MongoDB,建议查看 Introduction to MongoDB 课程。

MongoDB 索引的重要性与原因

谈到索引,本质上就是在谈性能。索引决定了我们是要遍历集合中的每一条文档来匹配查询,还是只扫描一小批更可能匹配查询的文档。如果集合很小、需要扫描的文档不多,无论如何性能都不差。但如果集合里有 100,000 条文档,而只有 100 条会匹配查询,那么在没有基于这 100 条文档特征建立索引的情况下,扫描全部 100,000 条文档去找那 100 条,很可能会耗时更长。

MongoDB 中有多种索引类型:

  • 单字段(Single field):对文档中的某一个特定字段建立的索引。默认的 _id 字段索引可视为单字段索引。
  • 复合(Compound):基于文档的多个字段建立的索引,查询中字段的使用顺序以及索引中字段的顺序都很重要。
  • 多键(Multikey):用于数组字段的索引。
  • 通配(Wildcard):支持不同文档间字段名略有差异的场景。这类索引的性能通常不如其他索引。
  • 地理空间(Geospatial):用于提升基于经纬度坐标数据的地理空间查询性能的索引。
  • 哈希(Hashed):最适合与分片键(shard key)配合使用的索引。

关于各种索引类型的更多信息,请查看 MongoDB 文档。

多数情况下,您会使用单字段、复合或多键索引,但最终还是要视您的应用类型、查询方式和数据而定。

凡是涉及读取文档的数据库交互,索引都很重要。这不仅限于使用 find 或 findOne 检索文档。在更新和删除文档时,您同样会使用匹配条件,数据库会先根据该条件找到文档再执行更新或删除。

不仅 find 和 findOne 这类检索操作能从索引中受益。当您在聚合管道的首个阶段使用 $match 时,索引也能显著加速。

MongoDB 索引优化的实用技巧

随意创建索引在 MongoDB 中可能跟不使用索引一样低效。相反,考虑以下几点,有助于构建更高效的应用。

复合索引的 ESR 原则:Equality、Sort、Range

创建复合索引的经验法则,是确定查询中每个字段的用法,并尽量遵循 ESR 最佳实践(等值、排序、范围)。

ESR 是什么?我们来拆解一下:

  • Equality(等值):字段按精确匹配(而非模糊匹配)。例如,在查询中精确匹配某人的姓名就是等值阶段。
  • Sort(排序):用于按升序或降序排序的字段。
  • Range(范围):用于非精确匹配的字段。例如,使用 $lt 或 $gt 运算符会在某个范围内匹配文档,而非精确匹配。

为了更好地理解其含义,请看下面的查询:

db.employee.find(
	{
		"department": "Developer Relations",
		"salary": {
			"$lt": 80000
		}
	}
).sort(
	{ 
		"name": 1 
	}
);

上述查询对 department 做了等值匹配,对 salary 做了范围匹配,并按 name 进行排序。

遵循 ESR 原则,索引可以这样设计:

db.employee.createIndex({
	"department": 1,
	"name": 1,
	"salary": 1
});

我们遵循 ESR 的目的是避免内存排序,并尽可能缩小搜索空间,从而实现高性能的查询执行。

覆盖查询(Covered queries)

一般来说,索引能让查询时需要扫描的文档子集更小,从而提升性能。但如果您希望进一步提升应用性能,可以考虑覆盖查询。覆盖查询意味着所有结果都直接来自索引,而无需扫描集合中的任何文档。

要实现覆盖查询,有几点限制,是否可行取决于您的应用:

  • 匹配条件和排序必须与索引的定义完全一致。
  • 结果集中请求返回的字段也必须包含在索引中。
  • 在分片场景下,分片键需要是索引的一部分。

这具体意味着什么?

以如下索引为例:

db.department.createIndex({
	"country": 1,
	"salary": 1
});

在上例中,我们希望基于 country 和 salary 字段建立索引。然后可以运行如下查询:

db.department.find(
	{
		"country": "US"
	},
	{
		"country": 1,
		"salary": 1
	}
);

上述带有投影的查询会使用我们的索引,但并非覆盖查询。因为在 MongoDB 中,_id 字段默认会返回,而在本例中我们并未将 _id 添加到索引中。因此可以更改索引,或修改查询。若选择修改查询,可以这样做:

db.department.find(
	{
		"country": "US"
	},
	{
		"country": 1,
		"salary": 1,
		"_id": 0
	}
);

请记住,若要使用覆盖查询,索引必须有效,且请求的字段需包含在索引中。否则,您只是借助索引来定位文档,而非直接在索引中返回结果。

您可以通过在 explain 操作符中使用 executionStats 参数(本文稍后会提到)来验证是否使用了覆盖查询。totalDocsExamined 的值可用于判断:如果为 0,说明您在使用覆盖查询。

减少或移除不必要的索引

从服务器资源的角度看,索引并非“免费”。每个索引都需要由服务器维护,而维护会消耗 CPU、内存和磁盘。创建更多索引会带来更多开销,在资源不足的服务器上可能导致成本增加或性能降低。这种性能下降不仅会影响读取,也可能会波及写入和删除操作,因为 MongoDB 需要消耗更多资源。

建议仅为高频查询创建索引。每月只执行一次的查询真的需要索引吗?大概不需要。每 10 秒执行一次的查询需要索引吗?当然需要!

在 MongoDB 博客上了解更多索引 最佳实践。

如何判断您的 MongoDB 索引是否在工作?

在 MongoDB 中创建了索引,并不意味着您的查询一定会使用它。我们实际上可以对任一查询进行排查,了解其使用了哪个索引。

以如下索引为例:

db.people.createIndex({
	"name": 1,
	"is_active": 1
});

在上述示例中,我们为 name 和 is_active 字段建立了索引。现在我们运行如下查询:

db.people.find({
	"is_active": true
});

无论是否有有效索引,上述查询都能运行;如果数据集很小,无论如何都会很快。但如果我们想确认它是否使用了索引,可以对查询稍作调整:

db.people.find({
	"is_active": true
}).explain();

这一次,我们使用 explain 方法来查看查询的执行计划。在执行计划中,可以看到如下信息:

winningPlan: {
	isCached: false,
	stage: 'COLLSCAN',
	filter: { is_active: { '$eq': true } },
	direction: 'forward'
}

对于该查询,MongoDB 使用了 "COLLSCAN",这意味着即使查询包含了我们索引中的一个字段,它也没有使用该索引。如果我们将查询改为如下形式:

db.people.find({
	"name": "Nic Raboy",
	"is_active": true
}).explain();

这次 explain 的结果发生了变化:

winningPlan: {
	isCached: false,
	stage: 'FETCH',
	inputStage: {
		stage: 'IXSCAN',
		keyPattern: { name: 1, is_active: 1 },
		indexName: 'name_1_is_active_1',
		isMultiKey: false,
		multiKeyPaths: { name: [], is_active: [] },
		isUnique: false,
		isSparse: false,
		isPartial: false,
		indexVersion: 2,
		direction: 'forward',
		indexBounds: {
			name: [ '["Nic Raboy", "Nic Raboy"]' ],
			is_active: [ '[true, true]' ]
		}
	}
},

这一次,执行计划显示我们使用了 "IXSCAN",并给出了使用的索引名称,在本例中即 name_1_is_active_1。该名称是在创建索引时由系统自动定义的。

对于 update 操作,流程基本类似,不同之处在于 explain 函数的位置。

一次 update 操作可能如下所示:

db.people.explain().update(
    {
        “name”: “Nic Raboy”,
        “Is_active”: true
    },
    {
        “$set”: { “is_active”: false }
    }
);

请注意,上述示例中,explain 出现在 update 函数之前。

如果在更新操作中使用了预期的索引,您会看到 "IXSCAN" 以及索引名称。了解更多 更新操作及其与索引的配合使用。

另一种查看查询执行计划细节的方法是对数据库使用 runCommand。尽管结果不如前者直观,但功能很强大,类似如下:

db.runCommand({
    "explain": {
        "update": "people",
        "updates": [
            {
                "q": { 
                    "name": "Nic Raboy",
                    "is_active": true
                },
                "u": {
                    "$set": { "is_active": false }
                }
            }
        ]
    },
    "verbosity": "allPlansExecution"
});

上述方式与带 explain 的 update 操作作用相同。

如果您对创建索引不太有把握,或发现查询执行比预期更慢,不妨查看执行细节。

结语

本文介绍了 MongoDB 索引的一些最佳实践。虽然我们没有覆盖所有可能的索引情境,但这些内容可以作为起点,帮助您确保用户在使用数据时获得尽可能好的体验。

重申几点要点:

  • 尽可能遵循 ESR(等值、排序、范围)原则。
  • 如有可能,尝试使用覆盖查询。
  • 不要盲目创建索引,而应找出最常用的查询,以及这些查询会提取多少数据。
  • 借助 explain 操作符测试索引及其性能。

以上并非提升应用性能的全部索引最佳实践。还有其他因素,例如 基数(cardinality) 等。

若需更多索引方面的帮助,请查看 MongoDB 文档。如果您在 MongoDB Atlas 中使用付费层,可以考虑使用内置的一些 性能顾问工具来获取索引和查询建议。

MongoDB 索引常见问题解答

我应该在一个 MongoDB 集合中创建多少个索引?

视情况而定。您需要找到适合自身的平衡点,但建议优先为高频执行的查询建立索引,而非偶尔执行的查询。

我创建了几个索引,但查询仍然很慢。为什么?

您的查询确定真的在使用您创建的索引吗?请使用 explain 操作符,或借助 MongoDB Atlas 中的顾问工具进行排查。

对于 ESR 原则,索引字段顺序必须严格是等值、排序、范围吗?

虽然 MongoDB 某些情况下可以“乱序”使用索引,但当索引键顺序与查询中字段使用顺序一致(先等值过滤,再排序,最后范围)时效果最佳。

explain 有哪些不同模式?

默认情况下,explain() 运行在 “queryPlanner” 模式;如果需要其他模式,还有 “executionStats” 和 “allPlansExecution”。

Nic Raboy

作者

Nic Raboy

Nic Raboy 是 MongoDB 的开发者关系主管,带领一支由 Python、Java、C# 与 PHP 开发者组成的团队,创作优质内容,帮助开发者在项目中成功使用 MongoDB。他具备 Golang 与 JavaScript 经验,并常常撰写自己在开发过程中的见闻与实践。

主题