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 | 位置信息 |
---|---|
18 | pos3 |
18 | pos5 |
19 | pos1 |
20 | pos2 |
21 | pos4 |
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集合的age
和name
字段建立复合索引
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})
因为每条文档中,x
与y6
的值都是相同的,所以我们这样建立查询计划
// 查询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
字段的值是一个子文档,其中包含了y1
和y2
两个字段。
然后对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)搭建全文建索能力,其中运用了Lucene
和Change 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”