面向Node.js开发者的MongoDB教程(4):索引

1,214 阅读7分钟

什么是索引?

image.png 我相信大部分人小时候都有使用过商务印书馆出版的《新华字典》,当我们需要在字典里面找一个“首”字的时候会怎么做呢?根据音序查字法大概分为这么几步:

  • 在字典的“汉语拼音音节索引”中找到“首”的音序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.stageCOLLSCAN可以看出,stage有以下几个状态:

状态描述
COLLSCAN全表扫描
IXSCAN索引扫描
FETCH通过索引检索指定文档
SHARD_MERGE将各个分片返回数据进行合并
SORT在内存中进行了排序
LIMIT使用limit限制返回数
SKIP使用skip进行跳过
IDHACK对_id进行查询
SHARDING_FILTER对分片进行查询
COUNTSCANcount不使用index进行count时的stage返回
COUNT_SCANcount使用了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是索引的keydir1是创建索引的方向。

让我们再来执行以下刚才的查询命令:

> 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" : [ ]
}

让我们继续关注totalDocsExaminedexecutionTimeMillis这两个字段,此时你会惊喜的发现,总扫描文档的数量totalDocsExamined的值从25300缩小为96, 近乎266倍的差距,因为我们集合里面有96条相同标题的文档数据,如果标题是唯一的话,这个数据会变成1

我们再来看看总耗时,executionTimeMillis23ms减少到了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_timetitle上建立索引。

> 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,你可以选择在connectSchema中进行设置:

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