MongoDB索引机制


三、MongoDB索引机制

任何数据库都有索引这一核心功能,MongoDB自然不例外,而且MongoDB在索引方面特别完善,毕竟作为数据库领域的后起之秀,它集百家之长,将“借鉴”这一思想发挥到了极致,先来看看MongoDB索引的概念词:

单列索引、组合索引、唯一索引、全文索引、哈希索引、空间(地理位置)索引、稀疏索引、TTL索引……

怎么样?相信看过MySQL索引这篇文章的小伙伴一定眼熟,其中的索引名词和MySQL十分相似,接下来咱们挨个接触一下,当然,这里也先给出官方文档地址:MongoDB索引机制。

PS:早版本的MongoDB中,索引底层默认使用B-Tree结构,而4.x版本后,MongoDB推出了V2版索引,默认使用变种B+Tree来作为索引的数据结构(和MySQL索引的数据结构相同)。

3.1、初识MongoDB索引

在之前提到过,MongoDB会为每个集合生成一个默认的_id字段,该字段在每个文档中必须存在,可以手动赋值,如果不赋值则会默认生成一个ObjectId,该字段则是集合的主键,MongoDB会基于该字段创建一个默认的主键索引,后续基于_id字段查询数据时,会走索引来提升查询效率。

但当咱们基于其他字段查询时,由于未使用_id作为条件,这会导致find语句走全表查询,即从第一条数据开始,遍历完整个集合,从而检索到目标数据。当集合中的数据量,达到百万、千万、甚至更高时,意味着效率会直线下滑,在这种情况下,必须得由我们手动为频繁作为查询条件的字段建立索引。

这里咱们先来说说索引的分类,同MySQL一样,站在不同维度上来说,可以衍生出不同的分类。

从字段数量的维度划分:

• 单列索引:基于一个字段建立的索引;

• 组合索引:也叫复合索引、多列索引、联合索引,是由多个字段组成的索引;

• 多键索引:和上面的不同,如果索引字段为数组类型,MongoDB会专门为每个元素生成索引条目;

• 部分索引:使用一个或多个字段的前N个字节创建出的索引;

从排序维度上划分:

• 升序索引:一或多个字段组成的索引,排序方式完全为升序(从小到大);

• 降序索引:一或多个字段组成的索引,排序方式完全为降序(从大到小);

• 多序索引:多个字段组成的索引,但其中一部分字段为升序,一部分字段为降序;

从功能维度划分:

• 主键索引:MongoDB中默认为_id字段且不能更改,用于维护聚簇索引树;

• 普通索引:一或多个字段组成的索引,没有任何特殊性,只为提升检索效率;

• 唯一索引:一或多个字段组成的索引,值不能重复,且只允许一个文档不插入索引字段;

• 全文索引:一或多个字段组成的索引,集合内只能有一个,可以基于索引字段进行全文检索;

• 空间索引:基于地理空间数据字段创建的索引,支持平面几何的2D索引和球形几何的2D sphere索引;

从索引性质维度划分:

• 稀疏索引:结合唯一索引使用,允许一个唯一索引字段,出现多个null值;

• TTL索引:类似于Redis的过期时间,为一个字段创建TTL索引后,超时会自动删除整个文档;

• 隐藏索引:在不删除索引的情况下,把索引隐藏起来,语句执行时不再走索引,相当于关闭索引;

• 通配符索引:给内嵌文档、且会发生动态变化的字段创建的索引;

从存储方式维度划分:

• 聚簇索引:索引数据和文档数据存储在一起的索引;

• 非聚簇索引:索引数据和文档数据分开存储的索引;

从数据结构维度划分:

• B+树索引:索引底层采用B+Tree结构存储;

• 哈希索引:索引底层采用Hash结构存储;

显而易见,这里又出现了一堆索引相关的名词,不过好在其中大部分概念,和MySQL索引的概念相同,下面展开聊聊。

3.2、MongoDB索引详解

先来看看MongoDB中创建索引的命令,如下:

db.collection.createIndex(<key and index type specification>, <options>);

前面提到的所有索引,都是通过这一个方法创建,不同类型的索引,通过里面的参数和选项来区分,下面说明一下参数和可选项。

第一个参数主要是传字段,以及索引类型,这里可以传一或多个字段,用于表示单列/复合索引。

第二个参数表示可选项,如下:

background:是否以后台形式创建索引,因为创建索引会导致其他操作阻塞;

unique:是否创建成唯一索引;

name:指定索引的名称;

sparse:是否对集合中不存在的索引字段的文档不启用索引;

expireAfterSeconds:指定存活时间,超时后会自动删除文档;

v:指定索引的版本号;

weights:指定索引的权重值,权值范围是1~99999,当一条语句命中多个索引时,会根据该值来选择;

接着还是以之前的xiong_mao集合为例,来阐述索引相关内容,数据如下:

db.xiong_mao.insert([
{_id:1,name:"肥肥",age:3,hobby:"竹子",color:"黑白色"},
{_id:2,name:"花花",color:"黑白色"},
{_id:4,name:"黑熊",age:3,food:{name:"黄金竹",grade:"S"}},
{_id:5,name:"白熊",age:4,food:{name:"翠绿竹",grade:"B"}},
{_id:6,name:"棕熊",age:3,food:{name:"明月竹",grade:"A"}},
{_id:7,name:"红熊",age:2,food:{name:"白玉竹",grade:"S"}},
{_id:8,name:"粉熊",age:6,food:{name:"翡翠竹",grade:"A"}},
{_id:9,name:"紫熊",age:3,food:{name:"烈日竹",grade:"S"}},
{_id:10,name:"金熊",age:6,food:{name:"黄金竹",grade:"S"}}
]);

3.2.1、单列索引

db.xiong_mao.createIndex({name: -1}, {name: "idx_name"});

这代表着基于name字段,创建一个名为idx_name的单列普通索引,-1代表降序,不过对于单字段的索引而言,排序方式并不重要,因为索引底层默认是B+Tree,每个文档之间会有双向指针,为此,MongoDB基于单字段索引查询时,既可以向前、也可以向后查找数据,示意图如下:

图片MongoDB索引

创建完成后,可以通过db.xiong_mao.getIndexes()命令查询索引,如下:

[
  {v: 2, key: { _id: 1 }, name: '_id_'},
  {v: 2, key: { name: -1 }, name: 'idx_name'}
]

这里可以看到,MongoDB默认给_id字段创建的索引,第二个则是咱们手动创建的索引,索引的版本为2(如果不指定索引名,会按“字段名+下划线+排序方式”规则默认生成)。

再来看一种情况,例如现在将集合中的爱好字段,变为一个数组:

{_id:1, name:"肥肥", age:3, hobby:["竹子", "睡觉"], color:"黑白色"}

现在给hobby字段创建一个索引,这时叫啥索引?多键索引!因为这里是基于单个数组类型的字段在建立索引,所以MongoDB会为数组中的每个元素,都生成索引的条目(即索引键),由于一个文档的数组字段,拥有多个元素,因此会创建多个索引键,这也是“多键索引”的名字由来。

3.2.2、复合索引

复合索引是指基于多个字段创建的索引,例如:

db.xiong_mao.createIndex({name:-1, age:1}, {name:"idx_name_age"});

这里基于name、age字段两个字段,创建了一个复合索引,其中指定了按name降序、age升序,这个排序就有意义了,MongoDB生成索引键时,会按照指定的顺序,来将索引键插入到树中。

说明:索引键=索引字段的值,比如现在一个文档的name=zhuzi、age=3,索引键为zhuzi3。 注意:由于这里的顺序是{name:-1, age:1},所以当排序查询时,支持sort({name:-1,age:1})、sort({name:1,age:-1}),因为这两个顺序和树的组成顺序要么完全相同、相反,而当执行sort({name:-1,age:-1})、sort({name:1,age:1})排序查询时,将不会使用索引,因为这时和树的顺序冲突。

这里咱们用explain命令浅浅分析一下:

db.xiong_mao.find({}).sort({name:1,age:1}).explain("executionStats");
db.xiong_mao.find({}).sort({name:-1,age:1}).explain("executionStats");

执行结果信息比较多,这里只贴出关键信息,如下:

图片排序索引对比

从图中能明显看出,第一条与索引顺序冲突的语句,走的是COLLSCAN全集合扫描;而第二条复合索引顺序的语句,走的是IXSCAN索引扫描,使用了name_-1_age_1这个索引检索到了数据,因此这点在使用复合索引时一定要注意!

同时,因为刚刚又给name、age字段建立了一个复合索引,所以最开始建立name单列索引,属于冗余的重复索引,这时可以通过dropIndex命令删除掉,如下:

// 自定义名称创建的索引,需要传入名称删除
db.xiong_mao.dropIndex("name_-1");
// 使用默认名称创建的索引,可以直接传入创建时的字段+顺序
db.xiong_mao.dropIndex({name:-1});
// 这个方法可以删除一个集合的所有索引(除开默认的_id索引外)
db.xiong_mao.dropIndexes();

顺便这里也提一句,如果索引在创建时未指定名称,中途是不可以重命名的,只能先删再建时重新命名。

3.2.3、唯一索引

唯一索引相信大家都熟悉,它必须创建在不会出现重复值的字段上,基于唯一索引查找数据时,找到第一个满足条件的数据,就会立马停止匹配,毕竟该字段的值在集合中是唯一的,创建的方式如下:

db.xiong_mao.createIndex({name:1}, {unique: true});

只需要将unique设置为true即可,现在再尝试向集合中插入一条name重复的文档:

db.xiong_mao.insertOne({name: "肥肥"});
[Error] index 0: 11000 - E11000 duplicate key error collection: 
    zhuzi.xiong_mao index: name_1 dup key: { name: "肥肥" }

这时就会因为name值违反唯一约束而报错。OK,再来看一个特殊情况:

db.xiong_mao.insertOne({_id:66, age: 1});
db.xiong_mao.insertOne({_id:77, age: 2});

这时大家查询一下该集合,会发现_id=66这条数据会插入进去,而77这条数据会报错,这是为什么?

因为MongoDB中,可以向集合中动态插入不同的字段,上面两条语句都没有指定name字段,这时MongoDB默认会将这两条语句的name字段置空,而name=null的情况,也会被当作一个索引键,插入到索引树中。由于name字段建立了唯一索引,因此空值情况也只能出现一次。

那这个问题能不能解决呢?当然可以,这里需要用到稀疏索引,“稀疏”是索引的一种特性,如下:

// 先删除索引
db.xiong_mao.dropIndex("name_1");
// 再重新建立一次,并将sparse选项置为true
db.xiong_mao.createIndex({name:1}, {unique:true, sparse:true});

这时再执行前面的两条语句,就能同时插入了!后续可以通过null值,来查询没有唯一索引字段的文档:

db.xiong_mao.find({name:null});

3.2.4、部分索引

部分索引即使用字段的一部分开创建索引,但必须要结合partialFilterExpression选项来实现,如下:

db.xiong_mao.createIndex(
// 给hobby字段创建索引
{hobby:1},
{partialFilterExpression:{
hobby:{
// 只为存在hobby字段的文档创建索引
$exists:true,
// 通过$substr操作符,截取前1个字节作为索引键
$expr:{$eq:[{$substr:["$hobby",0,1]},"prefix"]}
}
}}
);

其实这就类似于MySQL中的前缀索引,不过MongoDB的中的部分索引功能更强大,还可以只为集合中的一部分文档创建索引,例如:

db.xiong_mao.createIndex(
   {age: -1},
   // 只为集合中年龄大于2岁的文档创建索引
   {partialFilterExpression: {age: {$gt: 2}}}
);

为此,这里纠正一下:在最开始给出的索引分类概念中,将部分索引解释成了前缀索引的概念,实则不然,其实还可以为集合里的一部分数据创建索引!

3.2.5、TTL索引

TTL索引这个类型比较有趣,可以基于它实现过期自动删除的效果,主要依靠expireAfterSeconds选项来创建,不过只能在Date、ISODate类型的字段上,建立TTL索引,如下:

db.test_ttl.insertMany([
// new Date()表示插入当前时间
{_id:1,time:newDate()},
{_id:2,time:newDate()},
{_id:3,time:newDate()},
{_id:4,time:newDate()},
{_id:5,notes:"这条数据用于观察TTL删除特性"}
]);
db.test_ttl.find();
db.test_ttl.createIndex({time:1},{expireAfterSeconds:10});

上面新建了一个ttl_test集合,并向其中插入了5条数据,接着对time字段创建了一个TTL索引,给定的过期时间为10s,这里咱们稍等12s左右再去查询:

db.test_ttl.find();
[{_id: 5, notes: '这条数据用于观察TTL删除特性'}]

此时会发现,只剩下了_id=5这条没有time字段的数据,从这点就可以观察出TTL索引的特性,不过大家在使用时需要注意:TTL索引只能建在单字段上,不支持建立TTL复合索引;同时,TTL索引只能建立在类型为Date、ISODate的字段上,在其他类型的字段上建立TTL索引,文档永远不会过期。

这里说明一下,为什么TTL索引必须基于Date类型的字段创建呢?因为MongoDB会使用该字段的值,作为计算的起始时间,如果在一个Date数组类型的字段上建立TTL索引,MongoDB会使用其中最早的时间来计算过期时间。

3.2.6、全文索引

MySQL中想实现模糊查询,一般会采用like关键字;而在MongoDB中想实现模糊查询,官方并没有提供相关方法与操作符,只能通过自己写正则的形式,实现模糊查找的功能,那有没有更好的方法呢?答案是有,为相应字段创建全文索引即可。

全文索引在之前讲MySQL索引时也聊到过,在数据量不大不小(几百万左右)、查询又不是特别复杂的情况下,直接上ElasticSearch、Solr等中间件,显得有点大材小用,此时全文索引就是这类搜索引擎的平替。不过相较于MySQLMongoDB提供的全文索引,功能方面会更加强大。

创建的语法如下:

db.xiong_mao.createIndex({name: "text"}, {name:"ft_idx_name"});

这里对name字段建立了一个全文索引,和创建普通索引的区别在于:在字段后面加了一个text

不过要注意,MongoDB全文索引停用词、词干和词器的规则,默认为英语,想要更改,这里涉及到创建索引时的两个可选项:

  • default_language:指定全文索引停用词、词干和词器的规则,默认为english
  • language_override:指定全文索引语言覆盖的范围,默认为language

不过注意,不管任何技术栈的全文索引,对中文的支持都不太友好,分词方面总会有点不完善,所以MongoDB很鸡贼,全文索引直接不支持中文,当你试图通过default_language:"chinese"去将语言改为中文时,会直接给你返回报错~

当然,正是由于MongoDB的全文索引不支持中文,因此就算你给一个字符串字段,建立了全文索引后,也无法实现全文搜索,如下:

db.xiong_mao.find({$text: {$search: "熊"}});

这段语句的含义是:通过全文索引搜索含“熊”这个关键字的数据,在咱们前面给出的集合数据中,name包含“熊”的数据有好几条,但这条语句执行之后的结果为null。想要解决这个问题,必须要手动安装第三方的中文分词插件,如mmseg、jieba等。

当然,如果你字段中的值是英文,这自然是支持的,例如:

// 先向集合中插入三个name为英文的文档
db.xiong_mao.insertMany([
{_id:21,name:"jack bear",age:1},
{_id:22,name:"coly bear",age:2},
{_id:23,name:"alan bear",age:3}
]);

// 再使用英文作为关键字进行全文搜索(多个关键字用空格分隔)
db.xiong_mao.find({$text:{$search:"jack coly"}});
// =============结果=============
[
{_id:22,name:'coly bear',age:2},
{_id:21,name:'jack bear',age:1}
]

结果很明显,全文索引对英文完全支持,不过关于全文索引更多的搜索方法,大家可自行查阅官方文档,或寻找相关资料,这里不再过多阐述。

3.2.7、空间索引

空间索引,这玩意儿很多人用不到,除非涉及到地图、出行类的业务,或者其他涉及到坐标的场景,毕竟这时才会存储经纬度,MongoDB的空间索引,必须建立在类型为Point的字段上,总共有三种空间索引:

  • 2D:适用于平面上的二维几何数据;
  • 2Dsphere:适用于球面上的二维几何数据;
  • geoHaystack2D索引的升级版,适用于平面的特定坐标数据(查询性能更高);

这里来演示一下,先创建一个名为“熊猫馆”的集合,并插入几条坐标数据:

db.panda_house.insertMany([
{_id:1,house_name:"熊猫高级会所",location:{type:"Point",coordinates:[-66.66,22.22]}},
{_id:2,house_name:"竹子高级会所",location:{type:"Point",coordinates:[-88.88,55.55]}},
{_id:3,house_name:"竹子爱熊猫会所",location:{type:"Point",coordinates:[-77.88,11.22]}}
]);

接着我们为其location字段,创建一个2dsphere空间索引:

db.panda_house.createIndex({location:"2dsphere"});

接着可以使用坐标来进行查询,例如查询某个坐标附近五公里内的熊猫馆:

db.panda_house.find(
// 基于location字段进行查询
{location:{
// $near用来实现坐标附近的数据检索,返回结果会按距离排序
$near:{
// $geometry操作符用于指定Geo对象(地理空间对象)
$geometry:{
type:"Point",coordinates:[-66.66,22.21]},
// $maxDistance限制了最大搜索距离为5000m(五公里)
$maxDistance:5000
}}
});

上述语句执行后,最终会把“熊猫高级会所”这条数据搜索出来,是不是和你点外卖时的搜索场景很像?

3.2.8、哈希索引

哈希索引是等值查询时最快的索引,在之前MySQL篇章也提及过它,只不过MySQL-InnoDB引擎中,不支持手动创建哈希索引,只能由InnoDB运行时自动生成,即自适应哈希索引技术。反观MongoDB中,默认使用的索引结构为B+Tree,但它也支持手动创建哈希结构的索引,语法如下:

db.xiong_mao.createIndex({name:'hashed'});

我们只需要在创建索引时,在字段后显式指明hashed即可,这样在做等值查询时,就会自动使用哈希索引。

不过注意,哈希索引只支持等值查询的场景,如果一个字段还需要参与范围查询、排序等场景,那么并不建议在该字段上建立哈希索引。同时,创建哈希索引的字段,其值必须具备分散性,如年龄、性别这类字段,显然并不适合,大量重复值会导致哈希冲突十分严重,像手机号、身份证号这类属性的字段,就特别适合建立哈希索引。

3.2.9、通配符索引

在前面提到过“内嵌文档”这个概念,这是指将另一个文档,以字段值的形式嵌入到一个文档中。

结合MongoDB可以动态插入各种字段的特性,每个内嵌文档的字段,也可以灵活变化,例如前面给出的数据:

{_id:4, name:"黑熊", age:3, food:{name:"黄金竹", grade:"S"}},
{_id:5, name:"白熊", age:4, food:{name:"翠绿竹", grade:"B"}},
......

这些数据中都内嵌了一个food文档,虽然现在插入的都是固定的name、grade字段,但我们可以随时插入新的字段,例如:

db.xiong_mao.insertOne({
    _id:99, name:"星熊", age: 1,
    food: {name:"星光竹", grade:"S", quality_inspector: ["竹大","竹二"]}
});

这时新插入的文档,其food字段又多了一个quality_inspector质检员的属性,对于这种动态变化的字段,可不可以建立索引呢?答案是可以,MongoDB4.2中引入了“通配符索引”来支持对未知或任意字段的查询操作,创建的语法如下:

db.xiong_mao.createIndex({"food.$**": 1});

这时再通过food字段来进行查询,看看explain执行计划:

db.xiong_mao.find({"food.grade":"S"}).explain();

图片通配符索引-explain

执行计划中很明显,通过food字段作为查询条件时,用到了刚刚创建的通配符索引。

最后,如果你想观察一个索引到底有没有效果,可以对比有索引、没索引时的查询耗时,但咱们需要把索引删掉吗?这是不需要的,MongoDB支持隐藏索引,可以把一个已有索引“藏”起来,相关命令如下:

// 创建隐藏索引
db.<集合名>.createIndex({<字段名>:<排序方式>}, {hidden: true});

// 隐藏已有索引
db.<集合名>.hideIndex({<字段名>:<排序方式>});
db.<集合名>.hideIndex("索引名称");

// 取消隐藏索引
db.<集合名>.unhideIndex({<字段名>:<排序方式>});
db.<集合名>.unhideIndex("索引名称");

OK,MongoDB基于索引查询时,同样也支持索引覆盖,也就是需要返回的结果字段,在索引字段中有时,会直接返回索引键的值,并不会再次回表查询整个文档。

3.3、explain执行计划

在之前的《SQL优化篇》中,曾详细说到过MySQLexplain工具,通过该命令,能有效帮咱们分析语句的执行情况,而MongoDB同样也提供了这个命令,在上面的索引阶段,也简单使用过,命令格式如下:

db.<collection>.find().explain(<verbose>);

explain方法同样有三个模式可选,这里简单列出来:

  • queryPlanner:返回执行计划的详细信息,包括查询计划、集合信息、查询条件、最佳计划、查询方式、服务信息等(默认模式);
  • exectionStats:列出最佳执行计划的执行情况和被拒绝的计划等信息(即语句最终执行的方案);
  • allPlansExecution:选择并执行最佳执行计划,同时输出其他所有执行计划的信息;

一般排查find()查询缓慢问题时,可以先指定第二个模式,查看最佳执行计划的信息;如果怀疑MongoDB没选择好索引,则可以再指定第三个模式,查看其他执行计划,如果的确是因为走错了索引,这时你可以通过hint强制指定要使用的索引,如下:

db.集合名.find(查询条件).hint(索引名);

OK,接着来了解一下explain命令输出的信息含义,大家可以自行去mongosh里执行一下explain命令,会发现它输出了特别多的信息,咱们主要关注stage这个值,这是最重要的字段,就类似于MySQL-explaintype字段,代表着本次语句的查询类型,该字段可能会出现以下值:

COLLSCAN:扫描整个集合进行查询;

IXSCAN:通过索引进行查询;

COUNT_SCAN:使用索引在进行count操作;

COUNTSCAN:没使用索引在进行count操作;

FETCH:根据索引键去磁盘拿具体的数据;

SORT:执行了sort排序查询;

LIMIT:使用了limit限制返回行数;

SKIP:使用了skip跳过了某些数据;

IDHACK:通过_id主键查询数据;

SHARD_MERGE:从多个分片中查询、合并数据;

SHARDING_FILTER:通过mongos对分片集群执行查询操作;

SUBPLA:未使用索引的$or查询;

TEXT:使用全文索引进行查询;

PROJECTION:本次查询指定了返回的结果集字段(投影查询);

这里咱们只需要带SCAN后缀的,因为其他都属于命令执行的“阶段”,并不属于具体的类型,explain会将一条语句执行的每个阶段,都详细列出来,每个阶段都会有stage字段,我们要做的,就是确保每个阶段都能用上索引即可,如果某一阶段出现COLLSCAN,在数据量较大的情况下,都有可能导致查询缓慢。

其次,咱们需要关心keysExamined、docsExamined两个字段的值(exectionStats模式下才能看到),前者代表扫描的索引键数量,后者代表扫描的文档数量,前者越大,代表索引字段值的离散性太差,后者的值越大,一般代表着没建立索引。

OK,这里暂时了解这么多,关于explain其余的输出信息,很多跟集群有关系,为此,等后续把MongoDB集群过了之后,有机会再详细讲述~