MongoDB 4.x——合理使用索引(一)

1、索引检索原理

直到今天,索引仍然是数据库性能优化中颇为关键的一项技术。在前面的章节中,我们也介绍了MongoDB所支持的各种索引分类以及基本操作。然而,仅有这些可能还不够,在日常开发中,或许你也经常会遇到这样的问题:

  • 什么时候应该使用索引呢?
  • 怎么创建索引才是最高效的,有没有可遵循的一些原则呢?
  • 索引是如何提升性能的,索引是不是越多越好呢?

1.1、全表扫描带来的问题

在没有任何索引辅助的情况下,数据库查找数据只能通过遍历所有文档,并逐一过滤,直到数据查找完成。

这整个过程称之为全表扫描,如图所示。
在这里插入图片描述
全表扫描是线性查找的方式,其时间复杂度为O(n)​。比如图中的集合有1亿条数据,那么查询其中的某条数据则可能要进行1亿次扫描。当然,这只是最坏的情况,但全表扫描的效率确实是非常低下的,尤其是在遇到数据量大的时候,一个查询可能要花费几十秒甚至几分钟的时间,这对于实时性要求较高的业务系统来说可能是致命的。

可以想象一下,在坐拥亿级用户的新浪微博上,当所有人打开自己的微博主页时至少要等待1分钟以上,而原因竟然是数据库需要对用户表进行全表扫描以找到对应的用户记录。这会是怎样的一种体验!

全表扫描除了查询效率低下,其在整个扫描的过程中,还会加载大量的磁盘数据到内存中,导致MongoDB用于提供快速查询的“热数据缓存”被大量换出,进而又影响到了整体的性能及稳定性。

1.2、B+树索引

既然全表扫描的问题在于扫描的条目太多,那么索引的优化就在于如何缩短检索数据所需要经过的路径。

从MongoDB 3.2版本开始,其采用WiredTiger作为默认的引擎,在索引和集合的检索上则借鉴了B+树的结构。我们可借由该结构对一些查询做出简单的预测,并进一步评估其效率的优劣。值得一提的是,几乎所有的SQL数据库都支持B+树索引,因此这应该是一个好消息,大部分基于SQL数据库的索引调优技巧在MongoDB上仍然是可行的。B+树的结构如图所示。
在这里插入图片描述
B+树索引是一个m阶平衡树的结构,其查询复杂度=O(Logm n)​,其中,m是节点最大的分支数量,n是总体的节点数。以1亿的表为例,转换成平衡树结构(m阶)之后,假设m=10,那么整个树只有8层,一次检索只需要经过8次节点扫描就可以完成,相比全表扫描的方式,效率能得到指数级别的提升。

除了数据库,还有我们所熟知的文件系统也采用了B+树这种检索结构。

1.3、B+树、B-树、哈希表

谈到B+树,不免又会提到B-树(也称B树)索引结构。在早期版本中,MongoDB默认使用MMapV1存储引擎,其中的索引就是一个B-树的结构。而B+树是基于B-树发展而来的,两者在机制上有些类似,相比之下B+树的优点在于:

  1. B+树只在叶子节点上存放数据(或者指针结构)​,其索引节点占用的空间更小。假设一次磁盘读入的数据量是固定的,那么B+树将明显减少磁盘I/O的次数。
  2. B+树每一次检索的扫描次数是恒定的(m阶树=m次)​,在性能表现上更加稳定。

另一种常见的索引结构是哈希表,这同样是非常高效的数据查询结构,但哈希表的问题在于:

  • 由于哈希算法的局限性,只能支持等值检索,无法支持范围检索。
  • 不支持索引的排序。
  • 无法实现索引的“前缀匹配”查询。

因此,哈希表只有在“有限”的场景中使用,MongoDB也支持哈希索引。

1.4、二级索引

二级索引也叫非聚簇索引,另一个相对的概念叫聚簇索引(clustered index)​,这两者的区别如下。

  • 聚簇索引的叶子节点存储了真正的数据记录。
  • 二级索引(非聚簇索引)的叶子节点仅仅存放了索引值以及指向数据记录的指针(或者主键)​。

因此,二级索引在检索数据的过程中往往需要更多的查找次数,其中第二次查找真正数据记录的过程也常常被称为“回表”​。在MongoDB集合中,除了_id之外的其他索引都是二级索引。基于二级索引的检索过程如图所示。

在这里插入图片描述

2、索引检索范例

对于应用开发来说,几乎有超过一半的数据库性能问题都来自于索引的打开方式不对。

这不是耸人听闻,在笔者所经历过的开发调优、线上问题维护过程中,索引缺失或是索引不当等问题占据了大多数。而这些问题大多归根于对索引内部检索过程的不甚了解。既然我们已经大致了解了B+树索引的结构,本节将提供一些常见的查询范例,并展示不同的查询条件在B+树索引上是如何完成的。

2.1、前提

我们假定在db.test集合中写入了少量记录,同时也为字段a建立了索引,代码如下:

db.test.createIndex({a: 1})

2.2、等值检索

db.test.find({a: 3})

检索过程如图所示。
在这里插入图片描述

2.3、范围查询

查询语句:

db.test.find({
	a: {$gte: 2, $lt: 6}
})

检索过程如图所示。
在这里插入图片描述

2.4、分页查询

查询语句:

db.test.find({
	a: {$gte: 2, $lt: 6}
}).skip(2).limit(1)

检索过程如图所示。
在这里插入图片描述

2.5、排序的分页查询

查询语句:

db.test.find({
	a: {$gte: 2, $lt: 6}
}).sort({ a: -1}).skip(2).limit(1)

检索过程如图所示。
在这里插入图片描述

2.6、$ne查询

查询语句:

db.test.find({
	a: {$ne: 3}
}).limit(5)

检索过程如图所示。
在这里插入图片描述

注意:$ne/$not/$nin这类的查询条件可能导致大范围的扫描。

2.7、复合索引查询

对于复合式的索引,需要注意索引的字段顺序会影响排序。这里假定a、b字段作为复合索引的字段,代码如下:

db.test.createIndex({a: 1, b: 1})

查询语句:

db.test.find({a: 5}).sort({b: -1}).limit(1)

检索过程如图所示。
在这里插入图片描述

3、覆盖索引

覆盖索引并不是一种索引,而是指一种查询优化的行为。

我们知道,在一棵二级索引的B+树上,索引的值存在于树的叶子节点上。因此,如果我们希望查询的字段被包含在索引中,则直接查找二级索引树就可以获得,而不需要再次通过_id索引查找出原始的文档。

相比“非覆盖式”的查找,覆盖索引的这种行为可以减少一次对最终文档数据的检索操作(该操作也被称为回表)​。大部分情况下,二级索引树常驻在内存中,覆盖索引式的查询可以保证一次检索行为仅仅发生在内存中,即避免了对磁盘的I/O操作,这对于性能的提升有显著的效果。

或许,覆盖索引一词的命名可能不是很恰当,如若改为索引覆盖,或者“覆盖式的索引查找”应该更容易理解。接下来看一个例子,代码如下:

> db.names.insertMany([
	{"name": "aaa"},
	{"name": "bbb"},
	{"name": "ccc"}
])

> db.names.find()
{ "_id" : ObjectId("6a61bc129dfde78be2b49e24"), "name" : "aaa" }
{ "_id" : ObjectId("6a61bc129dfde78be2b49e25"), "name" : "bbb" }
{ "_id" : ObjectId("6a61bc129dfde78be2b49e26"), "name" : "ccc" }

db.names集合中只有两个字段,除_id之外,还有一个由随机字符串组成的name字段。首先给集合添加一个索引,代码如下:

> db.names.createIndex({name: 1})

然后,尝试根据某个关键字进行名称检索,代码如下:

> db.names.find(
	{name: /a/},
	{name: 1, _id: 0}
)

{ "name" : "aaa" }

上述查询使用了覆盖索引查询,其中,{name:1,_id:0}起到了关键的作用。其用于告知数据库仅仅返回name字段。_id:0是必须提供的,否则数据库会将_id字段也一并返回,这样会导致使用覆盖索引行为失效。

可能你会关注的另外一个问题是,怎么判断查询使用了覆盖索引呢?对上面的查询语句执行explain命令,结果如下:
在这里插入图片描述
在整个查询计划中可以清楚地看到,覆盖索引查询的计划只有两个阶段:

  • IXSCAN,索引扫描阶段。
  • PROJECTION,投射阶段,即提取对应的name字段。

这里并不存在最终文档的获取阶段,即FETCH操作,因此可以判定查询得到了覆盖索引优化。

当然,IXSCAN阶段还有一个正则匹配的过滤器(filter)操作,也就是说,尽管检索操作在name_1索引上就能完成,但仍然需要遍历扫描整个索引树,以找到包含“db”关键字的条目。

如果我们使用前缀式的匹配规则,则可以得到进一步优化,代码如下:
在这里插入图片描述
改为前缀式匹配之后,过滤器操作被消除了。此时的查询可以充分利用B+树的有序性,仅对有限的条目进行扫描即可返回结果。

最终,在使用覆盖索引时需要记住下面两点:

  • 覆盖索引的前提是二级索引,并且检索条件、返回字段都必须严格被索引覆盖到。
  • 对于嵌套的数组字段,无法使用覆盖索引查询。

4、查询计划

如果有多个索引可以同时匹配当前的查询条件,那么MongoDB就会在它们之中做出选择。又或者,整个查询过程并没有索引的介入而执行了全表扫描。

一个查询具体如何被执行的过程称为查询计划。通过explain命令我们可以清楚地看到查询计划的许多细节。这包括我们所关心的一些问题:

  • 查询是否使用了索引。
  • 索引命中的情况是不是最佳的。
  • 查询是否需要扫描大量的记录。

4.1、查询计划构成

MongoDB采用自底向上的方式来构造查询计划,每一个查询计划(query plan)都会被分解为若干个有层次的阶段(stage)​。有意思的是,整个查询计划最终会呈现出一颗多叉树的形状,如图所示。
在这里插入图片描述
一个查询计划会以根阶段作为入口,往下可逐层分解为多个子阶段。

整个计算过程是从下向上投递的,每一个阶段的计算结果都是其上层阶段的输入,每一个阶段都有自己的逻辑语义,比如,stage=IXSCAN就表示一个索引的扫描阶段,举例如下:
在这里插入图片描述
在这里插入图片描述

这个计划由两个阶段组成,子阶段是IXSCAN表示二级索引查找,父阶段则是一个FETCH操作,其根据索引查找的结果(叶子节点指针)执行最终文档的获取操作,如图所示。
在这里插入图片描述

4.2、explain命令

explain命令除提供查询计划的信息外,还可以模拟计划的执行并提供更多的过程信息。总的来说,explain有3种执行模式。

  • queryPlanner:默认的模式,仅进行查询计划分析,输出计划中的阶段信息。
  • executionStats:执行模式,在查询计划分析后,将按照winningPlan执行查询并统计过程信息。
  • allPlansExecution:全计划执行模式,将执行所有计划(包括winningPlan和rejectPlans)​,并返回全部的过程统计信息。
4.2.1、结果详情

explain命令的输出结果包含丰富的信息,总体如下:
在这里插入图片描述

  • queryPlanner:描述查询计划。
  • queryPlanner.namespace:描述当前的集合命名空间,格式为{db}.{collection}
  • queryPlanner.indexFilterSet:是否设置了indexFilter, indexFilter可以决定查询优化器对于某个查询将如何使用索引。
  • queryPlanner.parsedQuery:解析后的查询条件信息。
  • queryPlanner.queryHash:MongoDB 4.2版本中新增,表示查询模型的哈希值。
  • queryPlanner.planCacheKey:MongoDB4.2版本中新增,查询计划缓存的Key值,由查询模型、可用索引计算得出。
  • queryPlanner.winningPlan:最优计划。
  • queryPlanner.rejectPlans:拒绝的计划列表。
  • executionStats:执行过程统计,捕获计划在执行过程中的相关信息,只有在executionStats或allPlansExecution模式下才会输出。
  • executionStats.executionStages:最优计划(winningPlan)执行阶段的过程信息。
  • executionStats.allPlansExecution:全部计划的执行过程统计信息,只有在allPlansExecution模式下才会输出。

(1)winningPlan示例如下

在这里插入图片描述
在这里插入图片描述

(2)executionStats示例如下

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

4.2.2、结果分析

通过对阶段的识别,可以大致判断出执行计划做了什么样的操作,以及出现这些操作的背后原因。下表收集了一些典型的阶段,可提供一些参考。
在这里插入图片描述
判断一个执行计划的好坏,除了识别阶段,还需要综合其他一些因素考虑,主要如下。

  • 有多少个结果被返回了。
  • 索引以及文档的扫描数量。
  • 是否使用了索引,索引是否能完全匹配,是否存在额外的filter动作。
  • 是否使用了覆盖索引优化。
  • 是否存在内存排序。
  • 整个查询执行了多长时间。

5、查询案例分析

5.1、准备数据

执行下面的代码,向practise集合中写入100000条数据。

var collection = db.getCollection("practise");

var count = 10000;
var base = 10;
var items = [];
for(var i = 1; i <= count; i++) {
	var item = {};
	item.x = Math.round(Math.random() * base);
	item.y = Math.round(Math.random() * base);
	item.z = Math.round(Math.random() * base);
	item.name = "ITEM" + i;
	items.push(item);
	
	if(i % 1000 == 0) {
		collection.insertMany(items);
		print("insert", i);
		items = [];
	}
}

接着为该集合创建索引,代码如下:

//单键索引
db.practise.createIndex({name: 1})

//组合索引
db.practise.createIndex({x: 1, y: 1, z: 1})

现在,我们已经拥有了一个文档数量充足的集合,并且还分别创建了一个单键索引和组合式索引。

5.2、全表扫描

操作语句:

> db.practise.find({
	"otherKey": "ITEM9"
}).explain()
...
	"queryPlanner" : {
		"namespace" : "test.practise",
		"indexFilterSet" : false,
		"parsedQuery" : {
			"otherKey" : {
				"$eq" : "ITEM9"
			}
		},
		"queryHash" : "AAC65BC5",
		"planCacheKey" : "AE1A1B1C",
		"maxIndexedOrSolutionsReached" : false,
		"maxIndexedAndSolutionsReached" : false,
		"maxScansToExplodeReached" : false,
		"winningPlan" : {
			"stage" : "COLLSCAN",
			"filter" : {
				"otherKey" : {
					"$eq" : "ITEM9"
				}
			},
			"direction" : "forward"
		},
		"rejectedPlans" : [ ]
	},
...

执行计划如下所示。
在这里插入图片描述

说明:由于otherKey在集合中并不存在字段或索引,因此该语句会导致全表扫描,是比较低效的。

5.3、单键索引命中

操作语句:

> db.practise.find({
	"name": "ITEM9"
}).explain()
...
	"queryPlanner" : {
		"namespace" : "test.practise",
		"indexFilterSet" : false,
		"parsedQuery" : {
			"name" : {
				"$eq" : "ITEM9"
			}
		},
		"queryHash" : "01AEE5EC",
		"planCacheKey" : "5AF8B629",
		"maxIndexedOrSolutionsReached" : false,
		"maxIndexedAndSolutionsReached" : false,
		"maxScansToExplodeReached" : false,
		"winningPlan" : {
			"stage" : "FETCH",
			"inputStage" : {
				"stage" : "IXSCAN",
				"keyPattern" : {
					"name" : 1
				},
				"indexName" : "name_1",
				"isMultiKey" : false,
				"multiKeyPaths" : {
					"name" : [ ]
				},
				"isUnique" : false,
				"isSparse" : false,
				"isPartial" : false,
				"indexVersion" : 2,
				"direction" : "forward",
				"indexBounds" : {
					"name" : [
						"[\"ITEM9\", \"ITEM9\"]"
					]
				}
			}
		},
		"rejectedPlans" : [ ]
	},
...

执行计划如图所示。
在这里插入图片描述
说明:name字段的匹配使用了name_1这个单键索引,查询使用了IXSCAN(索引扫描)+FETCH操作。其中,totalKeysExamined与totalDocsExamined的值都非常小,可见效率是比较高的。

5.4、覆盖索引

操作语句:

> db.practise.find(
	{"name": "ITEM9"},
	{"name": 1, "_id": 0}
).explain()
...
	"queryPlanner" : {
		"namespace" : "test.practise",
		"indexFilterSet" : false,
		"parsedQuery" : {
			"name" : {
				"$eq" : "ITEM9"
			}
		},
		"queryHash" : "3066FB64",
		"planCacheKey" : "488D4567",
		"maxIndexedOrSolutionsReached" : false,
		"maxIndexedAndSolutionsReached" : false,
		"maxScansToExplodeReached" : false,
		"winningPlan" : {
			"stage" : "PROJECTION_COVERED",
			"transformBy" : {
				"name" : 1,
				"_id" : 0
			},
			"inputStage" : {
				"stage" : "IXSCAN",
				"keyPattern" : {
					"name" : 1
				},
				"indexName" : "name_1",
				"isMultiKey" : false,
				"multiKeyPaths" : {
					"name" : [ ]
				},
				"isUnique" : false,
				"isSparse" : false,
				"isPartial" : false,
				"indexVersion" : 2,
				"direction" : "forward",
				"indexBounds" : {
					"name" : [
						"[\"ITEM9\", \"ITEM9\"]"
					]
				}
			}
		},
		"rejectedPlans" : [ ]
	},
...

执行计划如图所示。
在这里插入图片描述
说明:该查询同样使用了name_1索引,但由于仅返回name字段而实现了覆盖索引优化,PROJECTION_COVERED表示使用了覆盖索引方式的PROJECTION(投射)操作。

5.5、列表查询+skip/limit

操作语句:

> db.practise.find({x :{$gt: 3}}).skip(10).limit(5).explain()
...
	"queryPlanner" : {
		"namespace" : "test.practise",
		"indexFilterSet" : false,
		"parsedQuery" : {
			"x" : {
				"$gt" : 3
			}
		},
		"queryHash" : "39913629",
		"planCacheKey" : "CB9286EB",
		"maxIndexedOrSolutionsReached" : false,
		"maxIndexedAndSolutionsReached" : false,
		"maxScansToExplodeReached" : false,
		"winningPlan" : {
			"stage" : "LIMIT",
			"limitAmount" : 5,
			"inputStage" : {
				"stage" : "FETCH",
				"inputStage" : {
					"stage" : "SKIP",
					"skipAmount" : 10,
					"inputStage" : {
						"stage" : "IXSCAN",
						"keyPattern" : {
							"x" : 1,
							"y" : 1,
							"z" : 1
						},
						"indexName" : "x_1_y_1_z_1",
						"isMultiKey" : false,
						"multiKeyPaths" : {
							"x" : [ ],
							"y" : [ ],
							"z" : [ ]
						},
						"isUnique" : false,
						"isSparse" : false,
						"isPartial" : false,
						"indexVersion" : 2,
						"direction" : "forward",
						"indexBounds" : {
							"x" : [
								"(3.0, inf.0]"
							],
							"y" : [
								"[MinKey, MaxKey]"
							],
							"z" : [
								"[MinKey, MaxKey]"
							]
						}
					}
				}
			}
		},
		"rejectedPlans" : [ ]
	},
...

执行计划如图所示。
在这里插入图片描述
说明:这是典型的列表查询,x1y1z_1索引可以匹配x:{$gt:3}条件的检索,查询过程扫描的文档(totalDocsExamined)​、索引数(totalKeysExamined)取决于skip、limit的取值。

5.6、内存排序

操作语句:

> db.practise.find({x: 1}).sort({x1:1}).explain()
...
	"queryPlanner" : {
		"namespace" : "test.practise",
		"indexFilterSet" : false,
		"parsedQuery" : {
			"x" : {
				"$eq" : 1
			}
		},
		"queryHash" : "CC6096C4",
		"planCacheKey" : "D420C005",
		"maxIndexedOrSolutionsReached" : false,
		"maxIndexedAndSolutionsReached" : false,
		"maxScansToExplodeReached" : false,
		"winningPlan" : {
			"stage" : "SORT",
			"sortPattern" : {
				"x1" : 1
			},
			"memLimit" : 104857600,
			"type" : "simple",
			"inputStage" : {
				"stage" : "FETCH",
				"inputStage" : {
					"stage" : "IXSCAN",
					"keyPattern" : {
						"x" : 1,
						"y" : 1,
						"z" : 1
					},
					"indexName" : "x_1_y_1_z_1",
					"isMultiKey" : false,
					"multiKeyPaths" : {
						"x" : [ ],
						"y" : [ ],
						"z" : [ ]
					},
					"isUnique" : false,
					"isSparse" : false,
					"isPartial" : false,
					"indexVersion" : 2,
					"direction" : "forward",
					"indexBounds" : {
						"x" : [
							"[1.0, 1.0]"
						],
						"y" : [
							"[MinKey, MaxKey]"
						],
						"z" : [
							"[MinKey, MaxKey]"
						]
					}
				}
			}
		},
		"rejectedPlans" : [ ]
	},
...

执行计划如图所示。
在这里插入图片描述
说明:组合索引可以覆盖查询条件{x:1},但对于{x1:1}这样的排序条件却无能为力。

本查询计划显示,IXSCAN阶段可以完成查询条件的匹配,但满足条件的文档会先被获取(FETCH)到内存中,再进行一次排序(SORT)​。排序的数据集大小取决于条件的设定,上述语句会导致10109个文档进行内存排序。

executionStages展示了排序阶段的细节,memUsage表示排序所使用的内存,memLimit是排序内存的最大限制(32MB)​。尤其需要注意的是,当内存排序超过最大值(32MB)时,查询就会出错。

5.7、组合索引无法命中

操作语句:

> db.practise.find({y: 1, z: 3}).explain()
...
	"queryPlanner" : {
		"namespace" : "test.practise",
		"indexFilterSet" : false,
		"parsedQuery" : {
			"$and" : [
				{
					"y" : {
						"$eq" : 1
					}
				},
				{
					"z" : {
						"$eq" : 3
					}
				}
			]
		},
		"queryHash" : "2B5DAA81",
		"planCacheKey" : "189A1787",
		"maxIndexedOrSolutionsReached" : false,
		"maxIndexedAndSolutionsReached" : false,
		"maxScansToExplodeReached" : false,
		"winningPlan" : {
			"stage" : "COLLSCAN",
			"filter" : {
				"$and" : [
					{
						"y" : {
							"$eq" : 1
						}
					},
					{
						"z" : {
							"$eq" : 3
						}
					}
				]
			},
			"direction" : "forward"
		},
		"rejectedPlans" : [ ]
	},
...

执行计划如图所示。

在这里插入图片描述
说明:查询条件{y:1,z:3}无法满足x_1_y_1_z_1的前缀匹配原则,因此该查询只能做全表扫描。

5.8、组合索引排序命中

操作语句:

> db.practise.find({x: 1}).sort({y: -1, z: -1}).limit(5).explain()
...
	"queryPlanner" : {
		"namespace" : "test.practise",
		"indexFilterSet" : false,
		"parsedQuery" : {
			"x" : {
				"$eq" : 1
			}
		},
		"queryHash" : "D1E516FC",
		"planCacheKey" : "36B48F45",
		"maxIndexedOrSolutionsReached" : false,
		"maxIndexedAndSolutionsReached" : false,
		"maxScansToExplodeReached" : false,
		"winningPlan" : {
			"stage" : "LIMIT",
			"limitAmount" : 5,
			"inputStage" : {
				"stage" : "FETCH",
				"inputStage" : {
					"stage" : "IXSCAN",
					"keyPattern" : {
						"x" : 1,
						"y" : 1,
						"z" : 1
					},
					"indexName" : "x_1_y_1_z_1",
					"isMultiKey" : false,
					"multiKeyPaths" : {
						"x" : [ ],
						"y" : [ ],
						"z" : [ ]
					},
					"isUnique" : false,
					"isSparse" : false,
					"isPartial" : false,
					"indexVersion" : 2,
					"direction" : "backward",
					"indexBounds" : {
						"x" : [
							"[1.0, 1.0]"
						],
						"y" : [
							"[MaxKey, MinKey]"
						],
						"z" : [
							"[MaxKey, MinKey]"
						]
					}
				}
			}
		},
		"rejectedPlans" : [ ]
	},
...

执行计划如图所示。
在这里插入图片描述

说明:查询条件、排序都与x_1_y_1_z_1这个组合索引匹配,因此查询只需要扫描很有限的几个文档。由于y、z都使用了降序,因此在当前的组合索引中,采用的扫描方向为direction=backward。

5.9、组合索引命中,内存排序

操作语句:

> db.practise.find({x: 1}).sort({y: 1, z: -1}).limit(5).explain()
...
	"queryPlanner" : {
		"namespace" : "test.practise",
		"indexFilterSet" : false,
		"parsedQuery" : {
			"x" : {
				"$eq" : 1
			}
		},
		"queryHash" : "048FB511",
		"planCacheKey" : "3CE59AC2",
		"maxIndexedOrSolutionsReached" : false,
		"maxIndexedAndSolutionsReached" : false,
		"maxScansToExplodeReached" : false,
		"winningPlan" : {
			"stage" : "FETCH",
			"inputStage" : {
				"stage" : "SORT",
				"sortPattern" : {
					"y" : 1,
					"z" : -1
				},
				"memLimit" : 104857600,
				"limitAmount" : 5,
				"type" : "default",
				"inputStage" : {
					"stage" : "IXSCAN",
					"keyPattern" : {
						"x" : 1,
						"y" : 1,
						"z" : 1
					},
					"indexName" : "x_1_y_1_z_1",
					"isMultiKey" : false,
					"multiKeyPaths" : {
						"x" : [ ],
						"y" : [ ],
						"z" : [ ]
					},
					"isUnique" : false,
					"isSparse" : false,
					"isPartial" : false,
					"indexVersion" : 2,
					"direction" : "forward",
					"indexBounds" : {
						"x" : [
							"[1.0, 1.0]"
						],
						"y" : [
							"[MinKey, MaxKey]"
						],
						"z" : [
							"[MinKey, MaxKey]"
						]
					}
				}
			}
		},
		"rejectedPlans" : [ ]
	},
...

执行计划如图所示。

在这里插入图片描述
说明:由于{y:1,z:-1}排序条件与组合索引的字段顺序不一致,因而产生了内存排序。如图所示,数据库会先基于索引扫描(IXSCAN)的结果进行内存排序,而SORT阶段通过limitAmount约束了返回条目数,最后进行文档的FETCH操作。因此一次查询需要扫描10万个索引条目(totalKeysExamined)​,而FETCH阶段则仅仅需要获取5个文档。

需要注意,这里的内存排序是基于索引而不是文档的,在MongoDB 4.0及以前版本中,对于这种查询的排序仍然必须基于文档进行,也就是先执行FETCH再执行SORT阶段,这样会使全量文档都被加载到内存而导致更差的效率。可见MongoDB 4.2版本对索引查询机制做了一些优化。

5.10、组合索引命中,范围+排序

操作语句:

> db.practise.find({x: {$gt: 3}}).sort({x: 1, y: 1, z: 1}).explain()
...
	"queryPlanner" : {
		"namespace" : "test.practise",
		"indexFilterSet" : false,
		"parsedQuery" : {
			"x" : {
				"$gt" : 3
			}
		},
		"queryHash" : "D7099141",
		"planCacheKey" : "4B559A11",
		"maxIndexedOrSolutionsReached" : false,
		"maxIndexedAndSolutionsReached" : false,
		"maxScansToExplodeReached" : false,
		"winningPlan" : {
			"stage" : "FETCH",
			"inputStage" : {
				"stage" : "IXSCAN",
				"keyPattern" : {
					"x" : 1,
					"y" : 1,
					"z" : 1
				},
				"indexName" : "x_1_y_1_z_1",
				"isMultiKey" : false,
				"multiKeyPaths" : {
					"x" : [ ],
					"y" : [ ],
					"z" : [ ]
				},
				"isUnique" : false,
				"isSparse" : false,
				"isPartial" : false,
				"indexVersion" : 2,
				"direction" : "forward",
				"indexBounds" : {
					"x" : [
						"(3.0, inf.0]"
					],
					"y" : [
						"[MinKey, MaxKey]"
					],
					"z" : [
						"[MinKey, MaxKey]"
					]
				}
			}
		},
		"rejectedPlans" : [ ]
	},
...

执行计划如图所示。
在这里插入图片描述

说明:这里的x:{$gt:3}条件、{x:1,y:1,z:1}排序与组合索引是完全匹配的,因此可以高效完成。

5.11、不合适的组合索引,范围+排序

操作语句:

> db.practise.find({x: {$gt: 3}}).sort({y: 1, z: 1}).explain()
...
	"queryPlanner" : {
		"namespace" : "test.practise",
		"indexFilterSet" : false,
		"parsedQuery" : {
			"x" : {
				"$gt" : 3
			}
		},
		"queryHash" : "916DB19C",
		"planCacheKey" : "B7596367",
		"maxIndexedOrSolutionsReached" : false,
		"maxIndexedAndSolutionsReached" : false,
		"maxScansToExplodeReached" : false,
		"winningPlan" : {
			"stage" : "FETCH",
			"inputStage" : {
				"stage" : "SORT",
				"sortPattern" : {
					"y" : 1,
					"z" : 1
				},
				"memLimit" : 104857600,
				"type" : "default",
				"inputStage" : {
					"stage" : "IXSCAN",
					"keyPattern" : {
						"x" : 1,
						"y" : 1,
						"z" : 1
					},
					"indexName" : "x_1_y_1_z_1",
					"isMultiKey" : false,
					"multiKeyPaths" : {
						"x" : [ ],
						"y" : [ ],
						"z" : [ ]
					},
					"isUnique" : false,
					"isSparse" : false,
					"isPartial" : false,
					"indexVersion" : 2,
					"direction" : "forward",
					"indexBounds" : {
						"x" : [
							"(3.0, inf.0]"
						],
						"y" : [
							"[MinKey, MaxKey]"
						],
						"z" : [
							"[MinKey, MaxKey]"
						]
					}
				}
			}
		},
		"rejectedPlans" : [ ]
	},
...

执行计划如图所示。

在这里插入图片描述

说明:该查询中由于x不是等值匹配,因此{y:1,z:1}的排序无法利用组合索引的顺序,此时产生了内存排序。

5.12、合并排序

操作语句:

> db.practise.find({x: {$in: [1, 2, 3, 4]}}).sort({y: 1}).limit(5).explain()
	"queryPlanner" : {
		"namespace" : "test.practise",
		"indexFilterSet" : false,
		"parsedQuery" : {
			"x" : {
				"$in" : [
					1,
					2,
					3,
					4
				]
			}
		},
		"queryHash" : "C28AC753",
		"planCacheKey" : "AB92D2F0",
		"maxIndexedOrSolutionsReached" : false,
		"maxIndexedAndSolutionsReached" : false,
		"maxScansToExplodeReached" : false,
		"winningPlan" : {
			"stage" : "LIMIT",
			"limitAmount" : 5,
			"inputStage" : {
				"stage" : "FETCH",
				"inputStage" : {
					"stage" : "SORT_MERGE",
					"sortPattern" : {
						"y" : 1
					},
					"inputStages" : [
						{
							"stage" : "IXSCAN",
							"keyPattern" : {
								"x" : 1,
								"y" : 1,
								"z" : 1
							},
							"indexName" : "x_1_y_1_z_1",
							"isMultiKey" : false,
							"multiKeyPaths" : {
								"x" : [ ],
								"y" : [ ],
								"z" : [ ]
							},
							"isUnique" : false,
							"isSparse" : false,
							"isPartial" : false,
							"indexVersion" : 2,
							"direction" : "forward",
							"indexBounds" : {
								"x" : [
									"[1.0, 1.0]"
								],
								"y" : [
									"[MinKey, MaxKey]"
								],
								"z" : [
									"[MinKey, MaxKey]"
								]
							}
						},
						{
							"stage" : "IXSCAN",
							"keyPattern" : {
								"x" : 1,
								"y" : 1,
								"z" : 1
							},
							"indexName" : "x_1_y_1_z_1",
							"isMultiKey" : false,
							"multiKeyPaths" : {
								"x" : [ ],
								"y" : [ ],
								"z" : [ ]
							},
							"isUnique" : false,
							"isSparse" : false,
							"isPartial" : false,
							"indexVersion" : 2,
							"direction" : "forward",
							"indexBounds" : {
								"x" : [
									"[2.0, 2.0]"
								],
								"y" : [
									"[MinKey, MaxKey]"
								],
								"z" : [
									"[MinKey, MaxKey]"
								]
							}
						},
						{
							"stage" : "IXSCAN",
							"keyPattern" : {
								"x" : 1,
								"y" : 1,
								"z" : 1
							},
							"indexName" : "x_1_y_1_z_1",
							"isMultiKey" : false,
							"multiKeyPaths" : {
								"x" : [ ],
								"y" : [ ],
								"z" : [ ]
							},
							"isUnique" : false,
							"isSparse" : false,
							"isPartial" : false,
							"indexVersion" : 2,
							"direction" : "forward",
							"indexBounds" : {
								"x" : [
									"[3.0, 3.0]"
								],
								"y" : [
									"[MinKey, MaxKey]"
								],
								"z" : [
									"[MinKey, MaxKey]"
								]
							}
						},
						{
							"stage" : "IXSCAN",
							"keyPattern" : {
								"x" : 1,
								"y" : 1,
								"z" : 1
							},
							"indexName" : "x_1_y_1_z_1",
							"isMultiKey" : false,
							"multiKeyPaths" : {
								"x" : [ ],
								"y" : [ ],
								"z" : [ ]
							},
							"isUnique" : false,
							"isSparse" : false,
							"isPartial" : false,
							"indexVersion" : 2,
							"direction" : "forward",
							"indexBounds" : {
								"x" : [
									"[4.0, 4.0]"
								],
								"y" : [
									"[MinKey, MaxKey]"
								],
								"z" : [
									"[MinKey, MaxKey]"
								]
							}
						}
					]
				}
			}
		},
		"rejectedPlans" : [ ]
	},

执行计划如图所示。
在这里插入图片描述
说明:与案例10不同,这里的x使用了$gt操作符将目标值锁定在有限的若干个值上,数据库会使用归并排序(SORT_MERGE)的方式来保证结果的有序性,如图12-2所示。最终,查询过程只需要扫描limit对应的条目数。

5.13、跨索引的合并排序

索引操作,代码如下:

db.practise.createIndex({x: 1, z: 1})
db.practise.createIndex({y: 1, z: 1})

操作语句:

> db.practise.find({
	$or: [
		{x: 1},
		{y: 1}
	]
}).sort({z: 1}).explain()
...
	"queryPlanner" : {
		"namespace" : "test.practise",
		"indexFilterSet" : false,
		"parsedQuery" : {
			"$or" : [
				{
					"x" : {
						"$eq" : 1
					}
				},
				{
					"y" : {
						"$eq" : 1
					}
				}
			]
		},
		"queryHash" : "56592F73",
		"planCacheKey" : "CE19B698",
		"maxIndexedOrSolutionsReached" : false,
		"maxIndexedAndSolutionsReached" : false,
		"maxScansToExplodeReached" : false,
		"winningPlan" : {
			"stage" : "SUBPLAN",
			"inputStage" : {
				"stage" : "FETCH",
				"inputStage" : {
					"stage" : "SORT_MERGE",
					"sortPattern" : {
						"z" : 1
					},
					"inputStages" : [
						{
							"stage" : "IXSCAN",
							"keyPattern" : {
								"x" : 1,
								"z" : 1
							},
							"indexName" : "x_1_z_1",
							"isMultiKey" : false,
							"multiKeyPaths" : {
								"x" : [ ],
								"z" : [ ]
							},
							"isUnique" : false,
							"isSparse" : false,
							"isPartial" : false,
							"indexVersion" : 2,
							"direction" : "forward",
							"indexBounds" : {
								"x" : [
									"[1.0, 1.0]"
								],
								"z" : [
									"[MinKey, MaxKey]"
								]
							}
						},
						{
							"stage" : "IXSCAN",
							"keyPattern" : {
								"y" : 1,
								"z" : 1
							},
							"indexName" : "y_1_z_1",
							"isMultiKey" : false,
							"multiKeyPaths" : {
								"y" : [ ],
								"z" : [ ]
							},
							"isUnique" : false,
							"isSparse" : false,
							"isPartial" : false,
							"indexVersion" : 2,
							"direction" : "forward",
							"indexBounds" : {
								"y" : [
									"[1.0, 1.0]"
								],
								"z" : [
									"[MinKey, MaxKey]"
								]
							}
						}
					]
				}
			}
		},
		"rejectedPlans" : [ ]
	},

执行计划如图所示。
在这里插入图片描述

说明:这个查询有些特殊,为了应对 o r 操作符和排序的需求,我们事先添加了 y 1 、 z 1 这两个索引,目的是用于产生合并排序优化。从执行计划上可以看到, or操作符和排序的需求,我们事先添加了y1、z1这两个索引,目的是用于产生合并排序优化。从执行计划上可以看到, or操作符和排序的需求,我们事先添加了y1z1这两个索引,目的是用于产生合并排序优化。从执行计划上可以看到,or操作的根阶段是SUBPLAN,而一开始会在两个索引中进行IXSCAN操作,随即执行归并(SORT_MERGE)​,执行FETCH操作后再向上递交结果。

合并排序是在内存中实现的,而且利用了多个索引树,能提升查询的效率。当然,如果本例中没有预置的索引,就不具备合并排序的条件,此时查询会退化为全表扫描加上内存排序的结果。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值