MongoDB索引属性及索引使用建议及通过explain查看索引执行计划

609 阅读8分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第24天,点击查看活动详情

一、索引属性

1-1、唯一索引(Unique Indexes)

在现实场景中,唯一性是很常见的一种索引约束需求,重复的数据记录会带来许多处理上的麻烦,比如订单的编号、用户的登录名等。通过建立唯一性索引,可以保证集合中文档的指定字段拥有唯一值。

# 创建唯一索引
db.values.createIndex({title:1},{unique:true})
# 复合索引支持唯一性约束
db.values.createIndex({title:1type:1},{unique:true})
#多键索引支持唯一性约束
db.inventory.createIndex( { ratings: 1 },{unique:true} )
  • 唯一性索引对于文档中缺失的字段,会使用null值代替,因此不允许存在多个文档缺失索引字段的情况。
  • 对于分片的集合,唯一性约束必须匹配分片规则。换句话说,为了保证全局的唯一性,分片键必须作为唯一性索引的前缀字段。

1-2、部分索引(Partial Indexes)

部分索引仅对满足指定过滤器表达式的文档进行索引。通过在一个集合中为文档的一个子集建立索引,部分索引具有更低的存储需求和更低的索引创建和维护的性能成本。3.2新版功能。

部分索引提供了稀疏索引功能的超集,应该优先于稀疏索引。

db.restaurants.createIndex(
    { cuisine: 1, name: 1 },
    { partialFilterExpression: { rating: { $gt: 5 } } }
)

partialFilterExpression选项接受指定过滤条件的文档:

  • 等式表达式(例如:field: value或使用$eq操作符)
  • $exists: true
  • $gt$gte$lt$lte
  • $type
  • 顶层的$and
# 符合条件,使用索引
db.restaurants.find( { cuisine: "Italian", rating: { $gte: 8 } } )
# 不符合条件,不能使用索引
db.restaurants.find( { cuisine: "Italian" } )     

1-2-1、案例1

restaurants集合数据

db.restaurants.insert({
    "_id" : ObjectId("5641f6a7522545bc535b5dc9"),
    "address" : {
        "building" : "1007",
        "coord" : [
            -73.856077,
            40.848447
        ],
        "street" : "Morris Park Ave",
        "zipcode" : "10462"
    },
    "borough" : "Bronx",
    "cuisine" : "Bakery",
    "rating" : { "date" : ISODate("2014-03-03T00:00:00Z"),
    "grade" : "A",
    "score" : 2
    },
    "name" : "Morris Park Bake Shop",
    "restaurant_id" : "30075445"
})      

创建索引

db.restaurants.createIndex(
    { borough: 1, cuisine: 1 },
    { partialFilterExpression: { 'rating.grade': { $eq: "A" } } }
) 

测试

# 走索引
db.restaurants.find( { borough: "Bronx", 'rating.grade': "A" } )
#不走索引
db.restaurants.find( { borough: "Bronx", cuisine: "Bakery" } )

唯一约束结合部分索引使用导致唯一约束失效的问题

注意:如果同时指定了partialFilterExpression和唯一约束,那么唯一约束只适用于满足筛选器表达式的文档。如果文档不满足筛选条件,那么带有惟一约束的部分索引不会阻止插入不满足惟一约束的文档。

1-2-2、案例2

users集合数据准备

db.users.insertMany( [
    { username: "david", age: 29 },
    { username: "amanda", age: 35 },
    { username: "rajiv", age: 57 }
] ) 

创建索引,指定username字段和部分过滤器表达式age: {$gte: 21}的唯一约束。

db.users.createIndex(
    { username: 1 },
    { unique: true, partialFilterExpression: { age: { $gte: 21 } } }
)

测试

索引防止了以下文档的插入,因为文档已经存在,且指定的用户名和年龄字段大于21:--唯一索引起作用

db.users.insertMany( [
    { username: "david", age: 27 },
    { username: "amanda", age: 25 },
    { username: "rajiv", age: 32 }
] )  

但是,以下具有重复用户名的文档是允许的,因为唯一约束只适用于年龄大于或等于21岁的文档。--唯一索引失效

db.users.insertMany( [
    { username: "david", age: 20 },
    { username: "amanda" },
    { username: "rajiv", age: null }
] )   

1-3、稀疏索引(Sparse Indexes)

索引的稀疏属性确保索引只包含具有索引字段的文档的条目,索引将跳过没有索引字段的文档。

特性: 只对存在字段的文档进行索引(包括字段值为null的文档)

#不索引不包含xmpp_id字段的文档 
db.addresses.createIndex( { "xmpp_id": 1 }, { sparse: true } )

如果稀疏索引会导致查询和排序操作的结果集不完整,MongoDB将不会使用该索引,除非hint()明确指定索引。

使用了稀疏索引,没有字段的数据就不会被查询出来了

1-3-1、案例

数据准备

db.scores.insertMany([    {"userid" : "newbie"},    {"userid" : "abby", "score" : 82},    {"userid" : "nina", "score" : 90}])  

创建稀疏索引

db.scores.createIndex( { score: 1 } , { sparse: true } )

测试

# 使用稀疏索引
db.scores.find( { score: { $lt: 90 } } )

如下使用了稀疏索引,如果数据中没有该字段,数据就不会被查询出来 image.png

# 即使排序是通过索引字段,MongoDB也不会选择稀疏索引来完成查询,以返回完整的结果
db.scores.find().sort( { score: -1 } )

如下:虽然score创建了稀疏索引,但是因为有sort,因此索引失效 image.png

# 要使用稀疏索引,使用hint()显式指定索引
db.scores.find().sort( { score: -1 } ).hint( { score: 1 } )    

如果要使用排序,并且需要走稀疏索引,那就使用hint进行处理,此时数据中没有该key的数据不会被查询出来 image.png

同时具有稀疏性和唯一性的索引可以防止集合中存在字段值重复的文档,但允许不包含此索引字段的文档插入。

1-3-2、案例

# 创建具有唯一约束的稀疏索引 
db.scores.createIndex( { score: 1 } , { sparse: true, unique: true } )

测试

这个索引将允许插入具有唯一的分数字段值或不包含分数字段的文档。因此,给定scores集合中的现有文档,索引允许以下插入操作:

db.scores.insertMany( [    { "userid": "AAAAAAA", "score": 43 },    { "userid": "BBBBBBB", "score": 34 },    { "userid": "CCCCCCC" },    { "userid": "CCCCCCC" }] )   

image.png

索引不允许添加下列文件,因为已经存在评分为82和90的文件:

db.scores.insertMany( [        { "userid": "AAAAAAA", "score": 82 },        { "userid": "BBBBBBB", "score": 90 }] ) 

image.png

1-4、TTL索引(TTL Indexes)

在一般的应用系统中,并非所有的数据都需要永久存储。例如一些系统事件、用户消息等,这些数据随着时间的推移,其重要程度逐渐降低。更重要的是,存储这些大量的历史数据需要花费较高的成本,因此项目中通常会对过期且不再使用的数据进行老化处理。

通常的做法如下:

方案一:为每个数据记录一个时间戳,应用侧开启一个定时器,按时间戳定期删除过期的数据。

方案二:数据按日期进行分表,同一天的数据归档到同一张表,同样使用定时器删除过期的表。

对于数据老化,MongoDB提供了一种更加便捷的做法:TTL(Time To Live)索引。TTL索引需要声明在一个日期类型的字段中,TTL 索引是特殊的单字段索引,MongoDB 可以使用它在一定时间或特定时钟时间后自动从集合中删除文档。

# 创建 TTL 索引,TTL 值为3600秒 
db.eventlog.createIndex( { "lastModifiedDate": 1 }, { expireAfterSeconds: 3600 } )

对集合创建TTL索引之后,MongoDB会在周期性运行的后台线程中对该集合进行检查及数据清理工作。除了数据老化功能,TTL索引具有普通索引的功能,同样可以用于加速数据的查询。

TTL 索引不保证过期数据会在过期后立即被删除。文档过期和 MongoDB 从数据库中删除文档的时间之间可能存在延迟。删除过期文档的后台任务每 60 秒运行一次。因此,在文档到期和后台任务运行之间的时间段内,文档可能会保留在集合中。

1-4-1、案例

数据准备

db.log_events.insertOne( {
    "createdAt": new Date(),
    "logEvent": 2,
    "logMessage": "Success!"
} ) 

创建TTL索引

db.log_events.createIndex( { "createdAt": 1 }, { expireAfterSeconds: 20 } )

可变的过期时间

TTL索引在创建之后,仍然可以对过期时间进行修改。这需要使用collMod命令对索引的定义进行变更

db.runCommand({collMod:"log_events",index:{keyPattern:{createdAt:1},expireAfterSeconds:600}})

image.png

使用约束

TTL索引的确可以减少开发的工作量,而且通过数据库自动清理的方式会更加高效、可靠,但是在使用TTL索引时需要注意以下的限制:

  • TTL索引只能支持单个字段,并且必须是非_id字段。
  • TTL索引不能用于固定集合。
  • TTL索引无法保证及时的数据老化,MongoDB会通过后台的TTLMonitor定时器来清理老化数据,默认的间隔时间是1分钟。当然如果在数据库负载过高的情况下,TTL的行为则会进一步受到影响。
  • TTL索引对于数据的清理仅仅使用了remove命令,这种方式并不是很高效。因此TTL Monitor在运行期间对系统CPU、磁盘都会造成一定的压力。相比之下,按日期分表的方式操作会更加高效。

日志存储:

  • 日期分表
  • 固定集合
  • TTL索引

插入: writeConcern:{w:0}

1-5、隐藏索引(Hidden Indexes)

隐藏索引对查询规划器不可见,不能用于支持查询。通过对规划器隐藏索引,用户可以在不实际删除索引的情况下评估删除索引的潜在影响。如果影响是负面的,用户可以取消隐藏索引,而不必重新创建已删除的索引。4.4新版功能。

创建隐藏索引
db.restaurants.createIndex({ borough: 1 },{ hidden: true });
# 隐藏现有索引
db.restaurants.hideIndex( { borough: 1} );
db.restaurants.hideIndex( "索引名称" )
# 取消隐藏索引
db.restaurants.unhideIndex( { borough: 1} );
db.restaurants.unhideIndex( "索引名称" );  

1-5-1、案例

db.scores.insertMany([    {"userid" : "newbie"},    {"userid" : "abby", "score" : 82},    {"userid" : "nina", "score" : 90}]) 

创建隐藏索引

db.scores.createIndex(
    { userid: 1 },
    { hidden: true }
) 

查看索引信息

db.scores.getIndexes()

索引属性hidden只在值为true时返回

image.png 测试

# 不使用索引
db.scores.find({userid:"abby"}).explain()

#取消隐藏索引
db.scores.unhideIndex( { userid: 1} )
#使用索引
db.scores.find({userid:"abby"}).explain()   

1-6、索引属性小结

上面内容主要叙述了索引的相关属性,可以配合索引类型进行使用

属性关键字对应的值说明
唯一索引uniquetrue/false
部分索引partialFilterExpression表达式
稀疏索引sparsetrue/false
TTL索引expireAfterSeconds
隐藏索引hiddentrue/falsehideIndex:隐藏现有索引;unhideIndex:取消隐藏索引

二、索引使用建议

2-1、为每一个查询建立合适的索引

这个是针对于数据量较大比如说超过几十上百万(文档数目)数量级的集合。如果没有索引MongoDB需要把所有的Document从盘上读到内存,这会对MongoDB服务器造成较大的压力并影响到其他请求的执行。

2-2、创建合适的复合索引,不要依赖于交叉索引

如果你的查询会使用到多个字段,MongoDB有两个索引技术可以使用:交叉索引和复合索引。交叉索引就是针对每个字段单独建立一个单字段索引,然后在查询执行时候使用相应的单字段索引进行索引交叉而得到查询结果。交叉索引目前触发率较低,所以如果你有一个多字段查询的时候,建议使用复合索引能够保证索引正常的使用。

#查找所有年龄小于30岁的深圳市马拉松运动员
db.athelets.find({sport: "marathon", location: "sz", age: {$lt: 30}}})
#创建复合索引
db.athelets.createIndex({sport:1, location:1, age:1})   

2-3、复合索引字段顺序:匹配条件在前,范围条件在后(Equality First, Range After)

前面的例子,在创建复合索引时如果条件有匹配和范围之分,那么匹配条件(sport: “marathon”) 应该在复合索引的前面。范围条件(age: <30)字段应该放在复合索引的后面。

2-4、尽可能使用覆盖索引(Covered Index)

建议只返回需要的字段,同时,利用覆盖索引来提升性能。

2-5、建索引要在后台运行

在对一个集合创建索引时,该集合所在的数据库将不接受其他读写操作。对大数据量的集合建索引,建议使用后台运行选项 {background: true}

2-6、避免设计过长的数组索引

数组索引是多值的,在存储时需要使用更多的空间。如果索引的数组长度特别长,或者数组的增长不受控制,则可能导致索引空间急剧膨胀

三、explain执行计划详解

通常我们需要关心的问题:

  • 查询是否使用了索引
  • 索引是否减少了扫描的记录数量
  • 是否存在低效的内存排序

MongoDB提供了explain命令,它可以帮助我们评估指定查询模型(querymodel)的执行计划,根据实际情况进行调整,然后提高查询效率。

explain()方法的形式如下:

db.collection.find().explain(<verbose>)
  • verbose 可选参数,表示执行计划的输出模式,默认queryPlanner
模式名字描述
queryPlanner执行计划的详细信息,包括查询计划、集合信息、查询条件、最佳执行计划、查询方式和 MongoDB 服务信息等
exectionStats最佳执行计划的执行情况和被拒绝的计划等信息
allPlansExecution选择并执行最佳执行计划,并返回最佳执行计划和其他执行计划的执行情况

3-1、queryPlanner

# 未创建title的索引 
db.books.find({title:"book-1"}).explain("queryPlanner")

image.png

字段名称描述
plannerVersion执行计划的版本
namespace查询的集合
indexFilterSet是否使用索引
parsedQuery查询条件
winningPlan最佳执行计划
stage查询方式
filter过滤条件
direction查询顺序
rejectedPlans拒绝的执行计划
serverInfomongodb服务器信息

3-2、executionStats

executionStats 模式的返回信息中包含了 queryPlanner 模式的所有字段,并且还包含了最佳执行计划的执行情况

#创建索引 
db.books.createIndex({title:1}) 

db.books.find({title:"book-1"}).explain("executionStats")

image.png

字段名称描述
winningPlan.inputStage用来描述子stage,并且为其父stage提供文档和索引关键字
winningPlan.inputStage.stage子查询方式
winningPlan.inputStage.keyPattern所扫描的index内容
winningPlan.inputStage.indexName索引名
winningPlan.inputStage.isMultiKey是否是Multikey。如果索引建立在array上,将是true
executionStats.executionSuccess是否执行成功
executionStats.nReturned返回的个数
executionStats.executionTimeMillis这条语句执行时间
executionStats.executionStages.executionTimeMillisEstimate检索文档获取数据的时间
executionStats.executionStages.inputStage.executionTimeMillisEstimate扫描获取数据的时间
executionStats.totalKeysExamined索引扫描次数
executionStats.totalDocsExamined文档扫描次数
executionStats.executionStages.isEOF是否到达 steam 结尾,1 或者 true 代表已到达结尾
executionStats.executionStages.works工作单元数,一个查询会分解成小的工作单元
executionStats.executionStages.advanced优先返回的结果数
executionStats.executionStages.docsExamined文档检查数

3-3、allPlansExecution

allPlansExecution返回的信息包含 executionStats 模式的内容,且包含allPlansExecution:[]块

"allPlansExecution" : [
    {
        "nReturned" : <int>,
        "executionTimeMillisEstimate" : <int>,
        "totalKeysExamined" : <int>,
        "totalDocsExamined" :<int>,
        "executionStages" : {
            "stage" : <STAGEA>,
            "nReturned" : <int>,
            "executionTimeMillisEstimate" : <int>,
            ...
        }
    }
    },
    ...
]     

3-4、stage状态

状态描述
COLLSCAN全表扫描
IXSCAN索引扫描
FETCH根据索引检索指定文档
SHARD_MERGE将各个分片返回数据进行合并
SORT在内存中进行了排序
LIMIT使用limit限制返回数
SKIP使用skip进行跳过
IDHACK对_id进行查询
SHARDING_FILTER通过mongos对分片数据进行查询
COUNTSCANcount不使用Index进行count时的stage返回
COUNT_SCANcount使用了Index进行count时的stage返回
SUBPLA未使用到索引的$or查询的stage返回
TEXT使用全文索引进行查询时候的stage返回
PROJECTION限定返回字段时候stage的返回

执行计划的返回结果中尽量不要出现以下stage:

  • COLLSCAN(全表扫描)
  • SORT(使用sort但是无index)
  • 不合理的SKIP
  • SUBPLA(未用到index的$or)
  • COUNTSCAN(不使用index进行count)