什么是索引?
我相信大部分人小时候都有使用过商务印书馆出版的《新华字典》,当我们需要在字典里面找一个“首”字的时候会怎么做呢?根据音序查字法大概分为这么几步:
- 在字典的“汉语拼音音节索引”中找到“首”的音序
S
- 再找到
S
音序下的所有音节shou
(不带声调的)所对应的页码 - 翻到页码
- 从改页码开始查找“首”字
当然你也可以从《新华字典》的第一页开发一页一页的查找,直到找到“首”这个字,但可想而知,可能除了查找“啊”(a)
这类排在字典靠前的字之外,音序查字法肯定是比一页一页查找要快的。
数据库中索引就类似《新华字典》中的“汉语音节索引”或者一本书的目录,有了索引我们就不需要再进行全表扫描了,能够快速定位需要查找的内容。通常来说,应该尽量避免全表扫描,因为全表扫描的效率是非常低的。
截止目前,我们MongoDB教程中的查询基本都是全表扫描,因为我们没有创建索引,你可以使用db.collection.getIndexs()
方法获取集合所拥有的索引:
> db.hotspots.getIndexes()
[ { "v" : 2, "key" : { "_id" : 1 }, "name" : "_id_" } ]
复制代码
可以看到,目前只有一个_id
作为默认索引
explain()
函数
MongoDB提供的explain()
函数常被用来判断查询语句的效率,该函数返回详细的执行计划和执行情况等信息。你可以以三种模式运行:
queryPlanner
模式:执行计划的详细信息,包括查询计划、集合信息、查询条件、最佳执行计划、查询方式和 MongoDB 服务信息等executionStats
模式:最佳执行计划的执行情况和被拒绝的计划等信息allPlansExecution
模式:选择并执行最佳执行计划,并返回最佳执行计划和其他执行计划的执行情况
目前我们只用到executionStats
,我们来查找视频标题为:"中元节潮汕文化短片《番客》:希望每一个亡灵都有人纪念,每一个人终能落叶归根"的文档,看下explain()
会输出什么。为了方便理解,我们只取返回值的executionStats
字段
> db.hotspots.find(
{ "title": "中元节潮汕文化短片《番客》:希望每一个亡灵都有人纪念,每一个人终能落叶归根" }, null
).explain("allPlansExecution").executionStats
复制代码
返回值如下:
{
"executionSuccess" : true,
"nReturned" : 96,
"executionTimeMillis" : 23,
"totalKeysExamined" : 0,
"totalDocsExamined" : 25300,
"executionStages" : {
"stage" : "COLLSCAN",
"filter" : {
"title" : {
"$eq" : "中元节潮汕文化短片《番客》:希望每一个亡灵都有人纪念,每一个人终能落叶归根"
}
},
"nReturned" : 96,
"executionTimeMillisEstimate" : 5,
"works" : 25302,
"advanced" : 96,
"needTime" : 25205,
"needYield" : 0,
"saveState" : 25,
"restoreState" : 25,
"isEOF" : 1,
"direction" : "forward",
"docsExamined" : 25300
},
"allPlansExecution" : [ ]
}
复制代码
目前来说,大部分的字段可以先忽略,我们可以先关注这2个字段:
executionTimeMillis
:执行语句的耗时,这次耗时23ms
。totalDocsExamined
:文档扫描次数,本次扫描了25300
个文档
通过count()
方法,我们可以知道一个集合内的文档数量,可以看到本次查询的文档扫描数量和集合内的文档总数是相等的。也就是执行全表扫描。
> db.hotspots.find().count()
25300
复制代码
其实从executionStages.stage
为COLLSCAN
可以看出,stage
有以下几个状态:
状态 | 描述 |
---|---|
COLLSCAN | 全表扫描 |
IXSCAN | 索引扫描 |
FETCH | 通过索引检索指定文档 |
SHARD_MERGE | 将各个分片返回数据进行合并 |
SORT | 在内存中进行了排序 |
LIMIT | 使用limit限制返回数 |
SKIP | 使用skip进行跳过 |
IDHACK | 对_id进行查询 |
SHARDING_FILTER | 对分片进行查询 |
COUNTSCAN | count不使用index进行count时的stage返回 |
COUNT_SCAN | count使用了Index进行count时的stage返回 |
SUBPLA | 未使用到索引的$or查询的stage返回 |
TEXT | 使用全文索引进行查询时候的stage返回 |
PROJECTION | 限定返回字段时候stage的返回 |
在项目中,我们应该避免stage
出现COLLSCAN
,也就是全表扫描的情况。
接下来,我们看看如何通过在MongoDB中创建索引,来优化我们的查询效率。
MongoDB索引
创建索引
在mongosh中,创建索引需要使用到db.collection.createIndex(keys, options)
,其中keys
为你要创建的索引字段,1
是按照升序来创建索引,-1
是按照降序来创建所以。
例如我们想创建title
作为索引:
> db.hotspots.createIndex({ "title": 1 })
复制代码
此时我们再来查看一下现在hotspots
中的索引:
> db.hotspots.getIndexes()
[
{
"v" : 2,
"key" : {
"_id" : 1
},
"name" : "_id_"
},
{
"v" : 2,
"key" : {
"title" : 1
},
"name" : "title_1"
}
]
复制代码
可以看到每个索引都有一个name
字段用于唯一标识索引,name
的默认形式是keyname1_dir1
,其中keyname1
是索引的key
,dir1
是创建索引的方向。
让我们再来执行以下刚才的查询命令:
> db.hotspots.find(
{ "title": "中元节潮汕文化短片《番客》:希望每一个亡灵都有人纪念,每一个人终能落叶归根" }, null
).explain("allPlansExecution").executionStats
复制代码
得到的返回值如下:
{
"executionSuccess" : true,
"nReturned" : 96,
"executionTimeMillis" : 2,
"totalKeysExamined" : 96,
"totalDocsExamined" : 96,
"executionStages" : {
"stage" : "FETCH",
"nReturned" : 96,
"executionTimeMillisEstimate" : 0,
"works" : 97,
"advanced" : 96,
"needTime" : 0,
"needYield" : 0,
"saveState" : 0,
"restoreState" : 0,
"isEOF" : 1,
"docsExamined" : 96,
"alreadyHasObj" : 0,
"inputStage" : {
"stage" : "IXSCAN",
"nReturned" : 96,
"executionTimeMillisEstimate" : 0,
"works" : 97,
"advanced" : 96,
"needTime" : 0,
"needYield" : 0,
"saveState" : 0,
"restoreState" : 0,
"isEOF" : 1,
"keyPattern" : {
"title" : 1
},
"indexName" : "title_1",
"isMultiKey" : false,
"multiKeyPaths" : {
"title" : [ ]
},
"isUnique" : false,
"isSparse" : false,
"isPartial" : false,
"indexVersion" : 2,
"direction" : "forward",
"indexBounds" : {
"title" : [
"[\"中元节潮汕文化短片《番客》:希望每一个亡灵都有人纪念,每一个人终能落叶归根\", \"中元节潮汕文化短片《番客》:希望每一个亡灵都有人纪念,每一个人终能落叶归根\"]"
]
},
"keysExamined" : 96,
"seeks" : 1,
"dupsTested" : 0,
"dupsDropped" : 0
}
},
"allPlansExecution" : [ ]
}
复制代码
让我们继续关注totalDocsExamined
和executionTimeMillis
这两个字段,此时你会惊喜的发现,总扫描文档的数量totalDocsExamined
的值从25300
缩小为96
, 近乎266倍的差距,因为我们集合里面有96条相同标题的文档数据,如果标题是唯一的话,这个数据会变成1
。
我们再来看看总耗时,executionTimeMillis
从23ms
减少到了2ms
,接近10倍的优化。这只是集合内文档数量仅有2万条的情况,在真实的业务场景中,数据量可能比2万条多出成千上万倍,此时的有索引和没有索引的耗时差别可能也是几个数量级的差异。
最后再关注下executionStages.state
的值为FETCH
,确实是通过索引进行检索的。
复合索引
索引的值是按一定顺序进行排列的,所以对使用了索引的集合进行排序是非常快的。但是需要注意的是,只有在首先使用索引键进行排序时,索引才有用。
例如刚才对title
设置了索引对于下面的查询没有作用,这依然会是一个全表扫描:
> db.hotspots.find({}).sort({ "update_time": -1, "title": 1 }).limit(1)
复制代码
我们同样可以使用explain('allPlansExecution')
查看本次扫描了多少文档:
> db.hotspots.find({})
.sort({ "update_time": -1, "title": 1 })
.limit(1)
.explain("allPlansExecution")
.executionStats
复制代码
从以下返回值可以看到,totalDocsExamined
的值为集合总数据量:
{
"executionSuccess" : true,
"nReturned" : 1,
"executionTimeMillis" : 66,
"totalKeysExamined" : 0,
"totalDocsExamined" : 25300,
...
}
复制代码
此时我们就需要对集合建立复合索引,对于上面的例子,我们需要在update_time
和title
上建立索引。
> db.hotspots.createIndex({ "update_time": -1, "title": 1 })
复制代码
让我们来看看创建了索引后,执行explain("allPlansExecution")
的结果:
{
"executionSuccess" : true,
"nReturned" : 1,
"executionTimeMillis" : 0,
"totalKeysExamined" : 1,
"totalDocsExamined" : 1,
...
}
复制代码
只扫描了一个文档瞬间返回了数据,查询耗时基本上接近0
。
删除索引
在本文开头我们使用了db.collection.getIndexes()
获取集合的索引,让我们来看一下到目前为止的索引列表:
> db.hotspots.getIndexes()
[
{
"v" : 2,
"key" : {
"_id" : 1
},
"name" : "_id_",
"ns" : "bilibili_hot.hotspots"
},
{
"v" : 2,
"key" : {
"title" : 1.0
},
"name" : "title_1",
"ns" : "bilibili_hot.hotspots"
},
{
"v" : 2,
"key" : {
"update_time" : -1.0,
"title" : 1.0
},
"name" : "update_time_-1_title_1",
"ns" : "bilibili_hot.hotspots"
}
]
复制代码
如果想要删除其中的索引,我们可以使用db.collection.dropIndex(name)
删除索引:
> db.hotspots.dropIndex('update_time_-1_title_1')
复制代码
Mongoose索引
创建索引
在Mongoose上有两种创建索引的方式,分别是字段级别索引和Schema级别索引。
const hotSpotSchema = new Schema({
title: {
type: String,
require: true,
index: true, // 字段级别索引
},
});
hotSpotSchema.index({ title: 1 }) // Schema级别索引
复制代码
TTL索引(到期删除)
const hotSpotSchema = new Schema({
update_time: {
type: Date,
require: true,
index: {
expires: 60, // 60s后过期
}
},
});
复制代码
索引创建事件
当Node.js应用启动的时候,如果你设置了索引,那么Mongoose会自动调用createIndex()
方法依次创建索引。并且在createIndex()
调用成功或出现错误时释放index
事件。
const HotSpot = mongoose.model("hotSpot", hotSpotSchema);
HotSpot.on('index', error => {
if (error) {
console.log(error.message)
} else {
console.log("索引创建成功")
}
})
复制代码
禁用自动创建
需要注意的是,Mongoose官方并不建议在生成环境使用这样的方式创建索引。因为可能你的生成环境服务器目前正处于写负载比较高的情况,此时你重启了Node.js应用创建数据库索引,会导致写入性能够健显著降低,影响生产环境的正常服务。更加建议的做法是,是根据服务器监控,在整体负载较低、用户访问量最少的时候创建索引,这样能够最大化降低潜在的性能隐患。
如果你想在Mongoose中禁用自动创建索引的操作,你可以设置autoIndex: false
,你可以选择在connect
或Schema
中进行设置:
mongoose.connect('mongodb://127.0.0.1:27017/bilibili_hot', { autoIndex: false });
// 或
mongoose.createConnection('mongodb://127.0.0.1:27017/bilibili_hot', { autoIndex: false });
// 或
hotSpotSchema.set('autoIndex', false);
// 或
new Schema({..}, { autoIndex: false });
复制代码