MongoDB索引最佳实践

MongoDB Index

#0 什么是索引

寻找扑克牌游戏
方片A~10这样10张扑克牌,现在牌全都盖住,我们需要从中找到方片5,怎么做?
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3YHcZSRj-1619336236578)(/tfl/captures/2020-08/tapd_20050801_base64_1596424215_46.png)]

如果我们有魔法,能让这些牌都事先排好序,从中查找方片5将会快很多:
我们会先翻中间那张方片6,发现比5大,预判我们要的牌还在左侧…
再尝试翻开左侧中间那张,发现是方片3比5小,要的牌在它右侧…
方片3右侧中间再取一张,bingo!
在这里插入图片描述

二分查找,要求元素必须有序排列,其时间复杂度为O(log2n),什么概念?就是在10亿个元素中查找平均只需要30次比对。

数据库索引

索引的本质,也是排序。因为有序,所以可以使用预言魔法

A database index is a data structure that improves the speed of data retrieval operations on a database table at the cost of additional writes and storage space to maintain the index data structure. Indexes are used to quickly locate data without having to search every row in a database table every time a database table is accessed. —— wikipedia

数据库索引,为提升数据检索效率而存在。You know,for query

  • 数据库索引是有序的,所以至少可以优化:查询、修改/删除、排序
  • 数据库索引数据结构通常是B树B+树,能有效减少磁盘I/O
  • 数据库索引本身也是一种数据,持久化在(磁盘)中,所以索引的维护有性能开销

#1 MongoDB索引原理

当你往Mongo中某各个集合插入文档后,每个文档在经过底层的存储引擎持久化后,会有一个位置信息(pos),通过这个位置信息,就能快速从存储引擎里读出该文档。

MongoDB 3.2版本后默认使用WiredTiger引擎,我司目前使用的也是WiredTiger引擎,下文基于WiredTiger来讨论MongoDB索引

WiredTiger中,位置信息(pos)是wiredtiger在存储文档时生成的一个key(page offset、block offset),通过这个key能访问到对应的文档。

位置信息文档
pos1{“name” : “jack”, “age” : 19 }
pos2{“name” : “rose”, “age” : 20 }
pos3{“name” : “jack”, “age” : 18 }
pos4{“name” : “tony”, “age” : 21}
pos5{“name” : “adam”, “age” : 18}

我们对age字段建立正序索引——

age位置信息
18pos3
18pos5
19pos1
20pos2
21pos4

WiredTiger 中索引使用B Tree数据结构,在大多数中文资料中,又被称为B+树`(数据都在叶子节点、叶子节点成链表)。

在这里插入图片描述

同时,在WiredTiger引擎中,索引数据也会以Page的形式加载到内存中,供查询时使用。

Update语句修改索引字段值时,将优先更新内存中的Page(脏页),同时记录journal(写日志)到磁盘,随后的CheckPoint机制会将这些数据修改写入磁盘进行持久化。

#2 何时不建议使用索引

  • 文档数较少(2000以内),不建议使用索引

    // 创建两张数据情况相同的表,文档数量都是5K;x取值0~4999,y取值(0~4999)%10
    for(var i=0; i< 5000; i ++) {
        db.test1.save({
            x: NumberInt(i),
            y: NumberInt(i % 10)
        })
        db.test2.save({
            x: NumberInt(i),
            y: NumberInt(i % 10)
        })
    }
    

    这时根据x字段建立查询计划,可以看到,虽然test1中进行了全表扫描,但查询耗时与test2相差不大,往往文档数量到5~10W以上这种差距才会明显拉开

    // 对test2集合的x字段分别建立单值索引
    db.test2.ensureIndex({x:1})
    
    db.test1.find({
        x: 456
    }).explain(true)
    /* test-1 查询计划
    "executionStats.nReturned" : 1.0
    "executionStats.executionTimeMillis" : 1.0
    "executionStats.totalKeysExamined" : 0.0
    "executionStats.totalDocsExamined" : 5000.0
    */
    db.test2.find({
        x: 456
    }).explain(true)
    /* test-2 查询计划
    "executionStats.nReturned" : 1.0
    "executionStats.executionTimeMillis" : 0.0
    "executionStats.totalKeysExamined" : 1.0
    "executionStats.totalDocsExamined" : 1.0
    */
    
  • 索引不能更好过滤数据时,不建议使用索引

    前面说过,索引的维护有一定的性能开销,比如会占用一定的磁盘空间、降低数据写入性能…

    即使只讨论查询情况,这里提出一个疑问:IXSCAN一定比COLLSCAN快吗?

    依然是上面两个集合,根据y字段建立查询计划,发现,当需要返回集合中80%及以上的数据时,使用索引可能会更慢

    // 对test2集合的y字段建立单值索引
    db.test2.ensureIndex({y:1})
    
    db.test1.find({
        y: {$gt: 1}
    }).explain(true)
    /* test1 查询计划
    "executionStats.nReturned" : 4000.0
    "executionStats.executionTimeMillis" : 1.0
    "executionStats.totalKeysExamined" : 0.0
    "executionStats.totalDocsExamined" : 5000.0
    */
    db.test2.find({
        y: {$gt: 1}
    }).explain(true)
    /* test2 查询计划
    "executionStats.nReturned" : 4000.0
    "executionStats.executionTimeMillis" : 6.0
    "executionStats.totalKeysExamined" : 4000.0
    "executionStats.totalDocsExamined" : 4000.0
    */
    

#3 何时应该使用索引

  • 对取值丰富的字段建立索引

    结合#2中的实践,谈一下另一个结论:在访问表/集合中较小一部分时使用B+树索引才有意义,无论最终返回多少行记录。

    MongoDB的查询优化器也会对数据分布情况进行分析,甚至会将查询计划缓存起来,在之后的一段时间内都会选择Mongo认为最合理的索引。

    // 使用x字段上的索引
    db.test2.find({
        x: 456,
        y: {$gt: 1}
    }).hint("x_1").explain(true)
    /* 
    "executionStats.nReturned" : 1.0
    "executionStats.executionTimeMillis" : 0.0
    "executionStats.totalKeysExamined" : 1.0
    "executionStats.totalDocsExamined" : 1.0
    */
    // 使用y字段上的索引
    db.test2.find({
        x: 456,
        y: {$gt: 1}
    }).hint("y_1").explain(true)
    /* 
    "executionStats.nReturned" : 1.0
    "executionStats.executionTimeMillis" : 6.0
    "executionStats.totalKeysExamined" : 4000.0
    "executionStats.totalDocsExamined" : 4000.0
    */
    // 甚至全表扫描都会快一些
    db.test2.find({
        x: 456,
        y: {$gt: 1}
    }).sort({"$natural": 1}).explain(true)
    /* 
    "executionStats.nReturned" : 1.0
    "executionStats.executionTimeMillis" : 4.0
    "executionStats.totalKeysExamined" : 0.0
    "executionStats.totalDocsExamined" : 5000.0
    */
    
  • 尽量使用复合索引,取代单值索引

    思路误区:如果集合中x,y两个字段分别建立了单值索引那么下面这样的查询条件,是无法同时命中这两个索引的。

db.test2.find({x:1, y:2})

Can only use 1 index per query!要想同时让两个查询条件都命中索引,我们必须建立复合索引。一般情况下,还可以取代单值索引,减少索引的磁盘空间占用。

例如,我们对person集合的agename字段建立复合索引

db.person.createIndex( {age: 1, name: 1} ) 

这时可以使用该索引的查询条件有:

db.person.find({age: 15, name: '小明'})
db.person.find({age: 15})

而下面这个查询条件不能使用该索引,因为MongoDB的复合索引与MySQL一样,都符合“最左匹配原则”

db.person.find({name: '小明'})

​ 另外,我们复合索引的顺序也有考究,与#2的结论一样,在复合索引中我们也应该将取值丰富的字段放在前面,这样可以更高效的过滤掉条件以外的数据。

​ 在这个场景中,age相同的文档会比较多,而name的取值则相对更丰富,即拥有相同name字段的文档较少,我们应该先按name查找,在name相同的文档中,再age字段查找更为高效。

  • 为排序建立合适的索引

MongoDB在不指定任何排序条件的情况下,默认会按照$natural进行排序,即按照磁盘上的存储顺序返回数据

db.test3.save({
    _id: 2,
    x: NumberInt(1)
})
db.test3.save({
    _id: 1,
    x: NumberInt(2)
})
db.test3.save({
    _id: 4,
    x: NumberInt(3)
})
// 默认查询
db.test3.find({})

在这里插入图片描述

那么,如果查询中使用到索引,返回结果是有序的吗?

// 由于MongoDB默认会对_id建立索引,我们可以直接使用
db.test3.find({}).hint("_id_")

在未加任何查询条件的情况下,数据不再以$natural组织返回,而是以指定的_id索引顺序返回的,即回表扫描的顺序与索引一致。

在这里插入图片描述

注意!MongoDB中,如果没有使用索引进行排序,文档将会被加载到内存中执行排序逻辑,当内存排序超过32M的数据Mongo将会放弃本次执行,并抛出异常。

在这里插入图片描述

使用复合索引排序字段,需要谨慎,例如下面这个索引

db.person.ensureIndex({x: 1, y: -1})

以下几种排序情况,都可以使用该索引

// 顺序与索引顺序完全一致
db.person.find({}).sort({x: 1, y: -1})
// 复合索引前缀的单个字段排序顺序,可以使用该索引
db.person.find({}).sort({x: 1})
db.person.find({}).sort({x:-1})
// 顺序与索引建立时完全相反,可以命中
db.person.find({}).sort({x:-1, y: 1})

而下面这些排序情况,则不会命中该索引

// 字段的顺序非常重要,先按y排序则不能使用索引
db.person.find({}).sort({y: -1, x: 1})
// 排序顺序要么与索引完全相同,要么完全相反,否则都不能命中
db.person.find({}).sort({x:-1, y: -1})
// 不符合最左匹配原则
db.person.find({}).sort({y: -1})
  • 尽量使用索引覆盖查询

Covered Queries (索引覆盖查询),简单地说,就是只查询索引中包含的字段,使得查询计划不必要再次回表多查一次文档数据。由于少了一次回表过程,因此查询效率必然比之较高。

使用时,因注意需同时满足以下条件:
1)所有的查询条件字段都是索引的一部分
2)查询返回的所有字段都是索引的一部分
3)查询条件中不包含等于null (i.e. {"field" : null} or {"field" : {$eq : null}})的匹配条件

// 建立x字段索引
db.test6.ensureIndex({x: 1})

// 使用索引覆盖查询
db.test6.find({x: 23}, {x: 1}).explain(true)

在这里插入图片描述

应注意:_id是默认返回的,因返回字段必须都是索引的一部分,所以查询无法使用索引覆盖。

// 将_id从查询结果集中排除
db.test6.find({x: 23}, {x: 1, _id: 0}).explain(true)

在这里插入图片描述

#4 子文档字段建立索引的效率问题

有一次电梯里聊到MongoDB一个有趣的话题,对方认为文档型数据库的嵌套结构对索引效率是有影响的。

真的是这样吗?在嵌套很深的子文档中的字段上建立索引,查询就会慢吗?

// 创建test4这样一个集合,
for(var i=0; i< 50000; i ++) {
    db.test4.save({
        x: NumberInt(i),
        y: {
            "y1": {
                "y2": {
                    "y3": {
                        "y4": {
                            "y5": {
                                "y6": NumberInt(i)
                            }
                        }
                    }
                }
            }
        }
    })
}
// 再分别对x和y6字段创建单值索引
db.test4.createIndex({x: 1})
db.test4.createIndex({"y.y1.y2.y3.y4.y5.y6": 1})

因为每条文档中,xy6的值都是相同的,所以我们这样建立查询计划

// 查询x=1234的文档,hint使其比命中x索引
db.test4.find({x: 1234}).hint("x_1").explain(true)
/* 
"executionStats.nReturned" : 1.0
"executionStats.executionTimeMillis" : 1.0
"executionStats.totalKeysExamined" : 1.0
"executionStats.totalDocsExamined" : 1.0
*/
// 查询x>1234的文档,依然使用hint
db.test4.find({x: {$gt: 1234}}).hint("x_1").explain(true)
/* 
"executionStats.nReturned" : 48765.0
"executionStats.executionTimeMillis" : 44.0
"executionStats.totalKeysExamined" : 48765.0
"executionStats.totalDocsExamined" : 48765.0
*/
// 查询y.y1.y2.y3.y4.y5.y6=1234的文档,hint使其比命中x索引
db.test4.find({"y.y1.y2.y3.y4.y5.y6": 1234}).hint("y.y1.y2.y3.y4.y5.y6_1").explain(true)
/* 
"executionStats.nReturned" : 1.0
"executionStats.executionTimeMillis" : 0.0
"executionStats.totalKeysExamined" : 1.0
"executionStats.totalDocsExamined" : 1.0
*/
// 查询y.y1.y2.y3.y4.y5.y6>1234的文档,依然使用hint
db.test4.find({"y.y1.y2.y3.y4.y5.y6": {$gt: 1234}}).hint("y.y1.y2.y3.y4.y5.y6_1").explain(true)
/* 
"executionStats.nReturned" : 48765.0
"executionStats.executionTimeMillis" : 43.0
"executionStats.totalKeysExamined" : 48765.0
"executionStats.totalDocsExamined" : 48765.0
*/

从测试结果来看,索引的检索效率,与文档嵌套深度没有关系。

实际上,在WiredTiger引擎中,每个索引节点(Page)逻辑上是一种Key-Value结构,Page中存储了索引键值Key是文档字段的值,Value就是数据指针,指向数据文件存储时的位置信息。

而索引字段是否建立在嵌套文档字段中,是不会影响索引检索效率的,原因很简单:检索时不会用到字段的名称,数据引擎只关心字段的值,以及对应的数据指针。

#5 显式地指定索引

一般情况下,MongoDB会有统计分析数据的机制,并根据我们建立的索引情况,来建立查询Mongo认为最优的计划。

但即便如此,依然会出现索引效率低下问题,特别是在不断的开发迭代过程中,MongoDB文档的结构和数据分布随时可能发生变化,因此显示指定使用的索引变得非常有必要,因为这样可以最大程度地保障你所设计的查询语句与你建立的索引能够“配合默契”。

像这样

db.col.find({}).hint("index_name").explain(true);

或者,你也可以在聚合查询中指定索引

db.col.explain(true).aggregate([{$match: {}},{$group: {}}…], {hint: "index_name"});

#6 OR条件查询注意事项

前面说过,1 index per query,那什么情况下,一条查询语句可以同时使用多个索引呢?

当使用$or条件时就可以,不过你还得注意下面的这些“坑”…

1)or条件中每一个子句都应该有对应的索引

// 建表
for(var i=0; i < 100; i ++) {
    db.test7.save({
        x: i,
        y: i + i%5,
        z: {
            z1: i,
            z2: i + i%10
        }
    })
}
// 建立x字段索引
db.test7.ensureIndex({x:1})
// 建立or查询,此时其实会全表扫描
db.test7.find({
    $or: [
    {
        x: 1
    },
    {
        "z.z1": 2
    }
    ]
}).explain(true)

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KBsyPZ6q-1619336236585)(/tfl/captures/2020-08/tapd_20050801_base64_1596420881_93.png)]

// 完善索引,对z.z1字段建立单值索引
db.test7.ensureIndex({"z.z1":1})
// 再次查询,
db.test7.find({
    $or: [
    {
        x: 1
    },
    {
        "z.z1": 2
    }
    ]
}).explain(true)

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3vKqhxv7-1619336236586)(/tfl/captures/2020-08/tapd_20050801_base64_1596420970_29.png)]

另外,or查询在不指定排序的情况下,数据最终会按照inputStages中每个索引阶段的结果顺序进行返回。

#7 对子文档建立索引的注意事项

MongoDB支持对子文档建立索引,不过在使用这样的索引时,需要留意一些“规则”。

我们建立一个新的集合,其中y字段的值是一个子文档,其中包含了y1y2两个字段。

然后对y字段建立正序索引。

for(var i =0; i < 100; i ++) {
    db.test6.save({
        x: i,
        y: {
            y1: i+1,
            y2: i+2
        }
    })
}
// create index now
db.test6.ensureIndex({
    y: 1
}, {background: true})

索引建立在整个子文档上,但查询条件没有覆盖子文档中的所有字段,则无法使用该索引。

例如,下面这样的查询是无法使用y字段索引

db.test6.find({
    "y.y1": 2
}).explain(true)

在这里插入图片描述

那么怎样才能使用到该索引?必须这样写

db.test6.find({
    y: {
        y1: 2,
        y2: 3
    }
}).explain(true)

在这里插入图片描述

再尝试一个有趣的玩法:我们使用hint方法“强迫”MongoDB使用该索引,结果又会如何?

db.test6.find({
    "y.y1": 2
}).hint("y_1").explain(true)

可以看到Mongo确实在查询计划中确实使用了y字段索引,但这是一种“假象”,实际还是会全表扫描,而且还多了一个索引扫描的步骤。

在这里插入图片描述

#8 Mongo4.2新特性:Wildcard Index

使用 MongoDB 时,经常会遇到一些场景,某个字段包含很多个属性,很多属性都可能需要用于查询,现在的解决方案时,针对每个属性,必须提前知道它的访问行为,建立必要的索引。

MongoDB 4.2 引入 Wildcard Index,可以轻量化、快速地实现数据个性化搜索,而不需要使用Elastic Search这种海量数据规模全文搜索引擎。

例如,product_catalog 集合中的文档可能包含一个product_attributes字段。该product_attributes字段可以包含任意嵌套的字段,包括嵌入式文档和数组:

{
  "product_name" : "Spy Coat",
  "product_attributes" : {
    "material" : [ "Tweed", "Wool", "Leather" ]
    "size" : {
      "length" : 72,
      "units" : "inches"
    }
  }
}

{
  "product_name" : "Spy Pen",
  "product_attributes" : {
     "colors" : [ "Blue", "Black" ],
     "secret_feature" : {
       "name" : "laser",
       "power" : "1000",
       "units" : "watts",
     }
  }
}

以下操作在product_attributes字段上创建通配符索引 $**

db.products_catalog.createIndex( { "product_attributes.$**" : 1 } )

通配符索引可以支持product_attributes对其或其嵌入式字段进行的任意单字段查询 :

db.products_catalog.find( { "product_attributes.size.length" : { $gt : 60 } } )
db.products_catalog.find( { "product_attributes.material" : "Leather" } )
db.products_catalog.find( { "product_attributes.secret_feature.name" : "laser" } )

#9 Mongo4.2新特性:Full Text Search

MongoDB4.2 版本支持通过Atlas(Build With MongoDB Could)搭建全文建索能力,其中运用了LuceneChange Stream等技术。

You know, For Search, Mongo too

在这里插入图片描述

参考文献:
[1]: https://en.wikipedia.org/wiki/Database_index “维基百科 · 数据库索引”
[2]: http://www.ovaistariq.net/733/understanding-btree-indexes-and-how-they-impact-performance/#.XxaIb-gzaUm “Understanding B+tree Indexes and how they Impact Performance”
[3]: https://dzone.com/articles/effective-mongodb-indexing-part-1 “Effective MongoDB Indexing (Part 1)”
[4]: https://docs.mongodb.com/manual/core/index-wildcard/index.html “index-wildcardindex-wildcard”

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值