索引(二)
2、explain输出
如你所见,explain 可以为查询提供大量的信息。对于慢查询来说,它是最重要的诊断工具之一。通过查看一个查询的 explain 输出,可以了解查询都使用了哪些索引以及是如何使用的。对于任何查询,都可以在末尾添加一个 explain 调用(就像添加 sort 或 limit 一样,但是explain 必须是最后一个调用)。
最常见的 explain 输出有两种类型:使用索引的查询和未使用索引的查询。特殊类型的索引可能会创建略有不同的查询计划,但是大多数字段应该是相似的。此外,分片返回的是多个 explain 的集合,因为查询会在多个服务器端上执行。
最基本的 explain 类型是不使用索引的查询。如果一个查询不使用索引,则是因为它使用了 "COLLSCAN"。
对于使用索引的查询,explain 的输出会有所不同,但在最简单的情况下,如果在 test.users 上添加一个索引,那么它看起来会像下面这样:
js
> test.users.find({"age" : 42}).explain('executionStats')
{
"queryPlanner" : {
"plannerVersion" : 1,
"namespace" : "test.users",
"indexFilterSet" : false,
"parsedQuery" : {
"age" : {
"$eq" : 42
}
},
"winningPlan" : {
"stage" : "FETCH",
"inputStage" : {
"stage" : "IXSCAN",
"keyPattern" : {
"age" : 1,
"username" : 1
},
"indexName" : "age_1_username_1",
"isMultiKey" : false,
"multiKeyPaths" : {
"age" : [ ],
"username" : [ ]
},
"isUnique" : false,
"isSparse" : false,
"isPartial" : false,
"indexVersion" : 2,
"direction" : "forward",
"indexBounds" : {
"age" : [
"[42.0, 42.0]"
],
"username" : [
"[MinKey, MaxKey]"
]
}
}
},
"rejectedPlans" : [ ]
},
"executionStats" : {
"executionSuccess" : true,
"nReturned" : 8449,
"executionTimeMillis" : 15,
"totalKeysExamined" : 8449,
"totalDocsExamined" : 8449,
"executionStages" : {
"stage" : "FETCH",
"nReturned" : 8449,
"executionTimeMillisEstimate" : 10,
"works" : 8450,
"advanced" : 8449,
"needTime" : 0,
"needYield" : 0,
"saveState" : 66,
"restoreState" : 66,
"isEOF" : 1,
"invalidates" : 0,
"docsExamined" : 8449,
"alreadyHasObj" : 0,
"inputStage" : {
"stage" : "IXSCAN",
"nReturned" : 8449,
"executionTimeMillisEstimate" : 0,
"works" : 8450,
"advanced" : 8449,
"needTime" : 0,
"needYield" : 0,
"saveState" : 66,
"restoreState" : 66,
"isEOF" : 1,
"invalidates" : 0,
"keyPattern" : {
"age" : 1,
"username" : 1
},
"indexName" : "age_1_username_1",
"isMultiKey" : false,
"multiKeyPaths" : {
"age" : [ ],
"username" : [ ]
},
"isUnique" : false,
"isSparse" : false,
"isPartial" : false,
"indexVersion" : 2,
"direction" : "forward",
"indexBounds" : {
"age" : [
"[42.0, 42.0]"
],
"username" : [
"[MinKey, MaxKey]"
]
},
"keysExamined" : 8449,
"seeks" : 1,
"dupsTested" : 0,
"dupsDropped" : 0,
"seenInvalidated" : 0
}
}
},
"serverInfo" : {
"host" : "eoinbrazil-laptop-osx",
"port" : 27017,
"version" : "4.0.12",
"gitVersion" : "5776e3cbf9e7afe86e6b29e22520ffb6766e95d4"
},
"ok" : 1
}
这个输出会首先告诉你使用了哪个索引:test.users。接下来是实际返回了多少文档:"nReturned"。注意,这并不一定能反映出 MongoDB 在执行查询时做了多少工作(例如,它需要搜索多少索引和文档)。"totalKeysExamined" 描述了所扫描的索引项数量,"totalDocsExamined" 表示扫描了多少个文档。
此输出还显示了没有 rejectedPlans,并且对值为 42.0的索引使用了有界搜索。
"executionTimeMillis" 报告了查询的执行速度,即从服务器接收请求到发出响应的时间。然而,这可能并不总是你希望看到的值。如果 MongoDB 尝试了多个查询计划,那么 "executionTimeMillis" 反映的是所有查询计划花费的总运行时间,而不是所选的最优查询计划所花费的时间。
现在你已经了解了这些基础知识,下面是对一些重要字段的详细介绍。
"isMultiKey" : false
本次查询是否使用了多键索引"nReturned" : 8449
本次查询返回的文档数量"totalDocsExamined" : 8449
MongoDB 按照索引指针在磁盘上查找实际文档的次数。如果查询中包含的查询条件不是索引的一部分,或者请求的字段没有包含在索引中,MongoDB 就必须查找每个索引项所指向的文档。"totalKeysExamined" : 8449
如果使用了索引,那么这个数字就是查找过的索引条目数量。如果本次查询是一次全表扫描,那么这个数字就表示检查过的文档数量。"stage" : "IXSCAN"
MongoDB 是否可以使用索引完成本次查询。如果不可以,那么会使用 "COLLSCAN" 表示必须执行集合扫描来完成查询。
在本例中,可以看出 MongoDB 使用索引找到了所有匹配的文档,因为 "totalKeysExamined" 与"totalDocsExamined" 是一样的。不过,此查询需要返回匹配文档中的每个字段,而索引中只包含了 "age" 字段和 "username" 字段。"needYield" : 0
为了让写请求顺利进行,本次查询所让步(暂停)的次数。如果有写操作在等待执行,那么查询将定期释放它们的锁以允许写操作执行。在本次查询中,由于并没有写操作在等待,因此查询永远不会进行让步。"executionTimeMillis" : 15
数据库执行本次查询所花费的毫秒数。这个数字越小越好。"indexBounds" : {...}
这描述了索引是如何被使用的,并给出了索引的遍历范围。在本例中,由于查询中的第一个子句是精确匹配,因此索引只需要查找 42 这个值就可以了。第二个索引键是一个自由变量,因为查询没有对它进行任何限制。因此,数据库会在符合 "age" : 42 的结果中查找用户名在负无穷("$minElement" : 1)和正无穷("$maxElement" : 1)之间的数据。
再来看一个稍微复杂点儿的例子。假设有一个{"username" : 1, "age" : 1} 上的索引和一个 {"age" : 1,"username" : 1} 上的索引。那么如果对 "username" 和"age" 进行查询,会发生什么呢?这取决于具体的查询:
js
> db.users.find({"age" : {$gt : 10}, "username" : "user2134"}).explain()
{
"queryPlanner" : {
"plannerVersion" : 1,
"namespace" : "test.users",
"indexFilterSet" : false,
"parsedQuery" : {
"$and" : [
{
"username" : {
"$eq" : "user2134"
}
},
{
"age" : {
"$gt" : 10
}
}
]
},
"winningPlan" : {
"stage" : "FETCH",
"inputStage" : {
"stage" : "IXSCAN",
"keyPattern" : {
"username" : 1,
"age" : 1
},
"indexName" : "username_1_age_1",
"isMultiKey" : false,
"multiKeyPaths" : {
"username" : [ ],
"age" : []
},
"isUnique" : false,
"isSparse" : false,
"isPartial" : false,
"indexVersion" : 2,
"direction" : "forward",
"indexBounds" : {
"username" : [
"[\"user2134\", \"user2134\"]"
],
"age" : [
"(10.0, inf.o)"
]
}
}
},
"rejectedPlans" : [
{
"stage" : "FETCH",
"inputStage" : {
"stage" : "IXSCAN",
"keyPattern" : {
"age" : 1,
"username" : 1
},
"indexName" : "age_1_username_1",
"isMultiKey" : false,
"multiKeyPaths" : {
"age" : [ ],
"username" : [ ]
},
"isUnique" : false,
"isSparse" : false,
"isPartial" : false,
"indexVersion" : 2,
"direction" : "forward",
"indexBounds" : {
"age" : [
"(10.0, inf.0]"
],
"username" : [
"[\"user2134\", \"user2134\"]"
]
}
}
}
]
},
"serverInfo" : {
"host" : "eoinbrazil-laptop-osx",
"port" : 27017,
"version" : "4.0.12",
"gitVersion" : "5776e3cbf9e7afe86e6b29e22520ffb6766e95d4"
},
"ok" : 1
}
由于要在 "username" 上进行精确匹配并在 "age" 上进行范围匹配,因此数据库选择使用了 {"username" : 1,"age" : 1} 索引,这与查询语句的顺序相反。另外,如果查询的是一个精确的年龄和用户名范围,那么 MongoDB就会使用另外一个索引:
js
> db.users.find({"age" : 14, "username" : /.*/}).explain()
{
"queryPlanner" : {
"plannerVersion" : 1,
"namespace" : "test.users",
"indexFilterSet" : false,
"parsedQuery" : {
"$and" : [
{
"age" : {
"$eq" : 14
}
},
{
"username" : {
"$regex" : ".*"
}
}
]
},
"winningPlan" : {
"stage" : "FETCH",
"inputStage" : {
"stage" : "IXSCAN",
"filter" : {
"username" : {
"$regex" : ".*"
}
},
"keyPattern" : {
"age" : 1,
"username" : 1
},
"indexName" : "age_1_username_1",
"isMultiKey" : false,
"multiKeyPaths" : {
"age" : [ ],
"username" : [ ]
},
"isUnique" : false,
"isSparse" : false,
"isPartial" : false,
"indexVersion" : 2,
"direction" : "forward",
"indexBounds" : {
"age" : [
"[14.0, 14.0]"
],
"username" : [
"[\"\", {})",
"[/.*/, /.*/]"
]
}
}
},
"rejectedPlans" : [
{
"stage" : "FETCH",
"filter" : {
"age" : {
"$eq" : 14
}
},
"inputStage" : {
"stage" : "IXSCAN",
"filter" : {
"username" : {
"$regex" : ".*"
}
},
"keyPattern" : {
"username" : 1
},
"indexName" : "username_1",
"isMultiKey" : false,
"multiKeyPaths" : {
"username" : [ ]
},
"isUnique" : false,
"isSparse" : false,
"isPartial" : false,
"indexVersion" : 2,
"direction" : "forward",
"indexBounds" : {
"username" : [
"[\"\", {})",
"[/.*/, /.*/]"
]
}
}
}
]
},
"serverInfo" : {
"host" : "eoinbrazil-laptop-osx",
"port" : 27017,
"version" : "4.0.12",
"gitVersion" : "5776e3cbf9e7afe86e6b29e22520ffb6766e95d4"
},
"ok" : 1
}
如果发现 MongoDB 正在使用的索引与自己希望的不一致,则可以用 hint 强制其使用特定的索引。如果希望MongoDB 在上面例子的查询中使用 {"username" : 1,"age" : 1} 索引,则可以像下面这样做。
js
> db.users.find({"age" : 14, "username" : /.*/}).hint({"username" : 1, "age" : 1})
如果查询没有使用你希望其使用的索引,而你使用了 hint 强制进行更改,那么应该在部署之前对这个查询执行 explain。如果强制 MongoDB 在它不知道如何使用索引的查询上使用索引,则可能会导致查询效率比不使用索引时还要低。
3、何时不使用索引
索引在提取较小的子数据集时是最高效的,而有些查询在不使用索引时会更快。结果集在原集合中所占的百分比越大,索引就会越低效,因为使用索引需要进行两次查找:一次是查找索引项,一次是根据索引的指针去查找其指向的文档。而全表扫描只需进行一次查找:查找文档。在最坏的情况下(返回集合内的所有文档),使用索引进行查找的次数会是全表扫描的两倍,通常会明显比全表扫描慢。
不幸的是,关于索引什么时候有用以及什么时候有害,并没有一个固定的规则,因为这实际上取决于数据、索引、文档和平均结果集的大小。根据经验,如果查询返回集合中 30% 或更少的文档,则索引通常可以加快速度。然而,这个数字会在 2% ~ 60% 变动。下表对索引和全表扫描通常适用的情况进行了总结。

假如现在有一个收集统计信息的分析系统。应用程序要根据给定的账户去系统中查询所有的文档,以根据从一小时之前到最开始时间的所有数据来生成一个图表:
js
> db.entries.find({"created_at" : {"$lt" : hourAgo}})
在 "created_at" 上创建索引以加快查询速度。
最初运行时,结果集很小而且可以立即返回。但是几个星期之后,数据开始多起来,而一个月之后,这个查询运行起来就会花费很长时间了。
对于大多数应用程序来说,这很可能就是那个"错误"的查询:你真的需要在查询中返回数据集中的大部分内容吗?大部分应用程序不需要,尤其是那些拥有庞大数据集的应用程序。然而,也有一些合理的情况可能需要获取大部分或者全部的数据。例如,可能需要将这些数据导出到报表系统或在一个批处理任务中使用。在这些情况下,应该尽可能快地返回数据集中的这些内容。
4、索引类型
4.1、唯一索引
唯一索引确保每个值最多只会在索引中出现一次。如果想保证不同文档的 "firstname" 键拥有不同的值,则可以使用 partialFilterExpression 仅为那些有 firstname 字段的文档创建唯一索引:
js
> db.users.createIndex({"firstname" : 1},
... {"unique" : true, "partialFilterExpression":{
"firstname": {$exists: true } } } )
{
"createdCollectionAutomatically" : false,
"numIndexesBefore" : 3,
"numIndexesAfter" : 4,
"ok" : 1
}
假如试图向 users 集合中插入以下文档:
js
> db.users.insert({firstname: "bob"})
WriteResult({ "nInserted" : 1 })
> db.users.insert({firstname: "bob"})
WriteResult({
"nInserted" : 0,
"writeError" : {
"code" : 11000,
"errmsg" : "E11000 duplicate key error collection: test.users index:
firstname_1 dup key: { : \"bob\" }"
}
})
如果检查这个集合,会发现只有第一个 "bob" 被存储了。由于抛出重复键异常比较低效,因此可以对偶尔出现的重复使用唯一约束,而不要对大量重复的键进行过滤。
你可能已经比较熟悉的唯一索引就是 "_id",它会在创建集合时自动创建。这是一个普通的唯一索引(除了不能被删除,它与其他的唯一索引没什么两样)。
如果一个键不存在,那么索引会将其作为 null 存储。这意味着如果对某个键创建了唯一索引并试图插入多个缺少该索引键的文档,那么会因为集合中已经存在了一个该索引键值为 null 的文档而导致插入失败。
在某些情况下,一个值可能不会被索引。索引桶(indexbucket)的大小是有限制的,如果某个索引项超过了它 的限制,这个索引项就不会被包含在索引中。这可能会造成一些困惑,因为这会使一个文档对使用此索引的查询"不可见"。在 MongoDB 4.2 之前,索引中包含的字段必须小于 1024 字节。在 MongoDB 4.2 及以后的版本中,这个限制被去掉了。如果一个文档的字段由于大小限制不能被索引,那么 MongoDB 就不会返回任何类型的错误或警告。这意味着大小超过 8KB 的键不会受到唯一索引的约束:比如,你可以插入多个相同的 8KB 字符串。
4.1.1、复合唯一索引
还可以创建复合唯一索引。在复合唯一索引中,单个键可以具有相同的值,但是索引项中所有键值的组合最多只能在索引中出现一次。
如果在 {"username" : 1, "age" : 1} 上有一个唯一索引,则下面这些插入操作都是合法的:
js
> db.users.insert({"username" : "bob"})
> db.users.insert({"username" : "bob", "age" : 23})
> db.users.insert({"username" : "fred", "age" : 23})
然而,如果试图再次插入这些文档的第二个副本,就会导致重复键异常。
GridFS 是在 MongoDB 中存储大文件的标准方式,它就用到了复合唯一索引。保存文件内容的集合在 {"files_id" : 1, "n" : 1} 上有一个唯一索引,这让文档(其中某一部分)看起来像下面这样:
js
{"files_id" : ObjectId("4b23c3ca7525f35f94b60a2d"), "n" : 1}
{"files_id" : ObjectId("4b23c3ca7525f35f94b60a2d"), "n" : 2}
{"files_id" : ObjectId("4b23c3ca7525f35f94b60a2d"), "n" : 3}
{"files_id" : ObjectId("4b23c3ca7525f35f94b60a2d"), "n" : 4}
注意,这里所有 "files_id" 的值都相同,但是 "n" 的值不同。
4.1.2、去除重复值
当尝试在现有集合中创建唯一索引时,如果存在任何重复值,则会导致创建失败:
js
> db.users.createIndex({"age" : 1}, {"unique" : true})
WriteResult({
"nInserted" : 0,
"writeError" : {
"code" : 11000,
"errmsg" : "E11000 duplicate key error collection:
test.users index: age_1 dup key: { : 12 }"
}
})
通常,需要对数据进行处理(可以使用聚合框架),并找出重复的数据,然后想办法解决。
4.2、部分索引
正如上一节提到的,唯一索引会将 null 作为值,因此无法在多个文档缺少键的情况下使用唯一索引。然而,在很多情况下,你可能希望仅在键存在时才强制执行唯一索引。如果一个字段可能存在也可能不存在,但当其存在时必须是唯一的,那么可以将 "unique" 选项与"partial" 选项组合在一起使用。
MongoDB 中的部分索引只会在数据的一个子集上创建。这与关系数据库上的稀疏索引不同,关系数据库创建的指向一个数据块的索引项会更少,不过所有数据块都有一个关联的稀疏索引项。
要创建部分索引,需要包含 "partialFilterExpression" 选项。部分索引提供了稀疏索引功能的超集,使用一个文档来表示希望在其上创建索引的过滤器表达式。如果有一个电子邮件地址字段是可选的,但是如果提供了这个字段,那么它的值就必须是唯一的。我们可以这样做:
js
> db.users.createIndex(
{"email" : 1},
{
"unique" : true,
"partialFilterExpression" : { email: { $exists: true }}
}
)
部分索引不必是唯一的。要创建非唯一的部分索引,只需去掉 "unique" 选项即可。
需要注意的一点是,根据是否使用部分索引,相同的查询可能返回不同的结果。假设有一个集合,其中大多数文档有 "x" 字段,但有一个文档没有:
js
> db.foo.find()
{ "_id" : 0 }
{ "_id" : 1, "x" : 1 }
{ "_id" : 2, "x" : 2 }
{ "_id" : 3, "x" : 3 }
当在 "x" 上执行查询时,它会返回所有匹配的文档:
js
> db.foo.find({"x" : {"$ne" : 2}})
{ "_id" : 0 }
{ "_id" : 1, "x" : 1 }
{ "_id" : 3, "x" : 3 }
如果在 "x" 上创建一个部分索引,那么 "_id" : 0 的文档将不会被包含在索引中。因此,如果现在查询 "x",那么MongoDB 将使用此索引并且不会返回 {"_id" : 0} 这个文档:
js
> db.foo.find({"x" : {"$ne" : 2}})
{ "_id" : 1, "x" : 1 }
{ "_id" : 3, "x" : 3 }
如果需要返回那些缺少字段的文档,那么可以使用 hint 强制执行全表扫描。
5、索引管理
如前文所述,可以使用 createIndex 函数创建新的索引。每个集合只需要创建一次索引。如果再次尝试创建相同的索引,则不会执行任何操作。
关于数据库索引的所有信息都存储在 system.indexes 集合中。这是一个保留集合,因此不能修改其中的文档或从中删除文档。只能通过 createIndex、createIndexes和 dropIndexes 数据库命令来对它进行操作。
创建一个索引后,可以在 system.indexes 中看到它的元信息。也可以执行 db.collectionName.getIndexes() 来查看给定集合中所有索引的信息:
js
> db.students.getIndexes()
[
{
"v" : 2,
"key" : {
"_id" : 1
},
"name" : "_id_",
"ns" : "school.students"
},
{
"v" : 2,
"key" : {
"class_id" : 1
},
"name" : "class_id_1",
"ns" : "school.students"
},
{
"v" : 2,
"key" : {
"student_id" : 1,
"class_id" : 1
},
"name" : "student_id_1_class_id_1",
"ns" : "school.students"
}
]
其中重要的字段是 "key" 和 "name"。此处的键可用于hint 及其他必须指定索引的地方。这里字段的顺序很重要:{"class_id" : 1, "student_id" : 1} 上的索引与{"student_id" : 1, "class_id" : 1} 上的索引并不相同。索引名称被用作许多管理索引操作的标识符,比如dropIndexes。而索引是否为多键没有在此规范中指定。
"v" 字段在内部用于索引的版本控制。如果有任何索引不包含 "v" : 1 这样的字段,那么说明这个索引是以一种效率较低的旧方式存储的。确保至少运行 MongoDB 2.0,并删除和重建索引,就可以对其进行升级了。
5.1、标识索引
集合中的每个索引都有一个可用于标识该索引的名称,服务器端用这个名称来对其进行删除或者操作。索引名称的默认形式是keyname1dir1_keyname2_dir2..._keynameN_dirN,其中 keynameX 是索引的键,dirX 是索引的方向(1 或-1)。如果索引包含两个以上的键,那么这种方式就会很麻烦,因此可以将自己的名称指定为 createIndex 的选项之一:
js
> db.soup.createIndex(
{"a" : 1, "b" : 1, "c" : 1, ..., "z" : 1},
{"name" : "alphabet"}
)
索引名称是有字符数限制的,因此在创建复杂的索引时可能需要自定义名称。调用 getLastError 就可以知道索引是否创建成功,或者为什么创建失败。
5.2、修改索引
随着应用程序不断变化,你可能会发现数据或者查询已经发生了改变,原先的索引也不那么好用了。可以使用dropIndex 命令删除不再需要的索引:
js
> db.people.dropIndex("x_1_y_1")
{ "nIndexesWas" : 3, "ok" : 1 }
使用索引描述中的 "name" 字段来指定要删除的索引。
创建新的索引既费时又耗费资源。在 4.2 版本之前,MongoDB 会尽可能快地创建索引,阻塞数据库上的所有读写操作,直到索引创建完成。如果希望数据库对读写保持一定的响应,那么可以在创建索引时使用"background" 选项。这会迫使索引创建不时地让步于其他操作,但仍可能对应用程序的性能造成严重影响。后台创建索引也会比前台创建索引慢得多。MongoDB 4.2 引入了一种新的方式,即混合索引创建。它只在索引创建的开始和结束时持有排他锁。创建过程的其余部分会交错地让步于读写操作。
在 MongoDB 4.2 中,这种方式同时替换了前台和后台类型的索引创建。
如果可以选择,在现有文档中创建索引要比先创建索引然后插入所有文档中稍微快一些。