深入理解MongoDB的复合索引
为什么需要索引?
当你抱怨MongoDB集合查询效率低的时候,可能你就需要考虑使用索引了,为了方便后续介绍,先科普下MongoDB里的索引机制(同样适用于其他的数据库比如mysql)。
mongo-9552:PRIMARY>db.person.find() {"_id":ObjectId("571b5da31b0d530a03b3ce82"),"name":"jack","age":19} {"_id":ObjectId("571b5dae1b0d530a03b3ce83"),"name":"rose","age":20} {"_id":ObjectId("571b5db81b0d530a03b3ce84"),"name":"jack","age":18} {"_id":ObjectId("571b5dc21b0d530a03b3ce85"),"name":"tony","age":21} {"_id":ObjectId("571b5dc21b0d530a03b3ce86"),"name":"adam","age":18}
当你往某各个集合插入多个文档后,每个文档在经过底层的存储引擎持久化后,会有一个位置信息,通过这个位置信息,就能从存储引擎里读出该文档。比如mmapv1引擎里,位置信息是『文件id+文件内offset』,在wiredtiger存储引擎(一个KV存储引擎)里,位置信息是wiredtiger在存储文档时生成的一个key,通过这个key能访问到对应的文档;为方便介绍,统一用pos(position的缩写)来代表位置信息。
什么是复合索引?
复合索引,即CompoundIndex,指的是将多个键组合到一起创建索引,这样可以加速匹配多个键的查询。不妨通过一个简单的示例理解复合索引。
students集合如下:
db.students.find().pretty() { "_id":ObjectId("5aa7390ca5be7272a99b042a"), "name":"zhang", "age":"15" } { "_id":ObjectId("5aa7393ba5be7272a99b042b"), "name":"wang", "age":"15" } { "_id":ObjectId("5aa7393ba5be7272a99b042c"), "name":"zhang", "age":"14" }
在name和age两个键分别创建了索引(_id自带索引):
db.students.getIndexes() [ { "v":1, "key":{ "name":1 }, "name":"name_1", "ns":"test.students" }, { "v":1, "key":{ "age":1 }, "name":"age_1", "ns":"test.students" } ]
当进行多键查询时,可以通过explian()分析执行情况(结果仅保留winningPlan):
db.students.find({name:"zhang",age:"14"}).explain() "winningPlan": { "stage":"FETCH", "filter": { "name": { "$eq":"zhang" } }, "inputStage": { "stage":"IXSCAN", "keyPattern": { "age":1 }, "indexName":"age_1", "isMultiKey":false, "isUnique":false, "isSparse":false, "isPartial":false, "indexVersion":1, "direction":"forward", "indexBounds": { "age":[ "[\"14\",\"14\"]" ] } } }
由winningPlan可知,这个查询依次分为IXSCAN和FETCH两个阶段。IXSCAN即索引扫描,使用的是age索引;FETCH即根据索引去查询文档,查询的时候需要使用name进行过滤。
为name和age创建复合索引:
db.students.createIndex({name:1,age:1}) db.students.getIndexes() [ { "v":1, "key":{ "name":1, "age":1 }, "name":"name_1_age_1", "ns":"test.students" } ]
有了复合索引之后,同一个查询的执行方式就不同了:
db.students.find({name:"zhang",age:"14"}).explain() "winningPlan": { "stage":"FETCH", "inputStage": { "stage":"IXSCAN", "keyPattern": { "name":1, "age":1 }, "indexName":"name_1_age_1", "isMultiKey":false, "isUnique":false, "isSparse":false, "isPartial":false, "indexVersion":1, "direction":"forward", "indexBounds": { "name":[ "[\"zhang\",\"zhang\"]" ], "age":[ "[\"14\",\"14\"]" ] } } }
由winningPlan可知,这个查询的顺序没有变化,依次分为IXSCAN和FETCH两个阶段。但是,IXSCAN使用的是name与age的复合索引;FETCH即根据索引去查询文档,不需要过滤。
这个示例的数据量太小,并不能看出什么问题。但是实际上,当数据量很大,IXSCAN返回的索引比较多时,FETCH时进行过滤将非常耗时。接下来将介绍一个真实的案例。
定位MongoDB性能问题
随着接收的错误数据不断增加,我们Fundebug已经累计处理3.5亿错误事件,这给我们的服务不断带来性能方面的挑战,尤其对于MongoDB集群来说。
对于生产数据库,配置profile,可以记录MongoDB的性能数据。执行以下命令,则所有超过1s的数据库读写操作都会被记录下来。
db.setProfilingLevel(1,1000)
查询profile所记录的数据,会发现events集合的某个查询非常慢:
db.system.profile.find().pretty() { "op":"command", "ns":"fundebug.events", "command":{ "count":"events", "query":{ "createAt":{ "$lt":ISODate("2018-02-05T20:30:00.073Z") }, "projectId":ObjectId("58211791ea2640000c7a3fe6") } }, "keyUpdates":0, "writeConflicts":0, "numYield":1414, "locks":{ "Global":{ "acquireCount":{ "r":NumberLong(2830) } }, "Database":{ "acquireCount":{ "r":NumberLong(1415) } }, "Collection":{ "acquireCount":{ "r":NumberLong(1415) } } }, "responseLength":62, "protocol":"op_query", "millis":28521, "execStats":{ }, "ts":ISODate("2018-03-07T20:30:59.440Z"), "client":"192.168.59.226", "allUsers":[], "user":"" }
events集合中有数亿个文档,因此count操作比较慢也不算太意外。根据profile数据,这个查询耗时28.5s,时间长得有点离谱。另外,numYield高达1414,这应该就是操作如此之慢的直接原因。根据MongoDB文档,numYield的含义是这样的:
Thenumberoftimestheoperationyieldedtoallowotheroperationstocomplete.Typically,operationsyieldwhentheyneedaccesstodatathatMongoDBhasnotyetfullyreadintomemory.ThisallowsotheroperationsthathavedatainmemorytocompletewhileMongoDBreadsindatafortheyieldingoperation.
这就意味着大量时间消耗在读取硬盘上,且读了非常多次。可以推测,应该是索引的问题导致的。
不妨使用explian()来分析一下这个查询(仅保留executionStats):
db.events.explain("executionStats").count({"projectId":ObjectId("58211791ea2640000c7a3fe6"),createAt:{"$lt":ISODate("2018-02-05T20:30:00.073Z")}}) "executionStats": { "executionSuccess":true, "nReturned":20853, "executionTimeMillis":28055, "totalKeysExamined":28338, "totalDocsExamined":28338, "executionStages": { "stage":"FETCH", "filter": { "createAt": { "$lt":ISODate("2018-02-05T20:30:00.073Z") } }, "nReturned":20853, "executionTimeMillisEstimate":27815, "works":28339, "advanced":20853, "needTime":7485, "needYield":0, "saveState":1387, "restoreState":1387, "isEOF":1, "invalidates":0, "docsExamined":28338, "alreadyHasObj":0, "inputStage": { "stage":"IXSCAN", "nReturned":28338, "executionTimeMillisEstimate":30, "works":28339, "advanced":28338, "needTime":0, "needYield":0, "saveState":1387, "restoreState":1387, "isEOF":1, "invalidates":0, "keyPattern": { "projectId":1 }, "indexName":"projectId_1", "isMultiKey":false, "isUnique":false, "isSparse":false, "isPartial":false, "indexVersion":1, "direction":"forward", "indexBounds": { "projectId":[ "[ObjectId('58211791ea2640000c7a3fe6'),ObjectId('58211791ea2640000c7a3fe6')]" ] }, "keysExamined":28338, "dupsTested":0, "dupsDropped":0, "seenInvalidated":0 } } }
可知,events集合并没有为projectId与createAt建立复合索引,因此IXSCAN阶段采用的是projectId索引,其nReturned为28338;FETCH阶段需要根据createAt进行过滤,其nReturned为20853,过滤掉了7485个文档;另外,IXSCAN与FETCH阶段的executionTimeMillisEstimate分别为30ms和27815ms,因此基本上所有时间都消耗在了FETCH阶段,这应该是读取硬盘导致的。
创建复合索引
没有为projectId和createAt创建复合索引是个尴尬的错误,赶紧补救一下:
db.events.createIndex({projectId:1,createTime:-1},{background:true})
在生产环境构建索引这种事最好是晚上做,这个命令一共花了大概7个小时吧!background设为true,指的是不要阻塞数据库的其他操作,保证数据库的可用性。但是,这个命令会一直占用着终端,这时不能使用CTRL+C,否则会终止索引构建过程。
复合索引创建成果之后,前文的查询就快了很多(仅保留executionStats):
db.javascriptevents.explain("executionStats").count({"projectId":ObjectId("58211791ea2640000c7a3fe6"),createAt:{"$lt":ISODate("2018-02-05T20:30:00.073Z")}}) "executionStats": { "executionSuccess":true, "nReturned":0, "executionTimeMillis":47, "totalKeysExamined":20854, "totalDocsExamined":0, "executionStages": { "stage":"COUNT", "nReturned":0, "executionTimeMillisEstimate":50, "works":20854, "advanced":0, "needTime":20853, "needYield":0, "saveState":162, "restoreState":162, "isEOF":1, "invalidates":0, "nCounted":20853, "nSkipped":0, "inputStage": { "stage":"COUNT_SCAN", "nReturned":20853, "executionTimeMillisEstimate":50, "works":20854, "advanced":20853, "needTime":0, "needYield":0, "saveState":162, "restoreState":162, "isEOF":1, "invalidates":0, "keysExamined":20854, "keyPattern": { "projectId":1, "createAt":-1 }, "indexName":"projectId_1_createTime_-1", "isMultiKey":false, "isUnique":false, "isSparse":false, "isPartial":false, "indexVersion":1 } } }
可知,count操作使用了projectId和createAt的复合索引,因此非常快,只花了46ms,性能提升了将近600倍!!!对比使用复合索引前后的结果,发现totalDocsExamined从28338降到了0,表示使用复合索引之后不再需要去查询文档,只需要扫描索引就好了,这样就不需要去访问磁盘了,自然快了很多。
参考
- MongoDB复合索引
- MongoDB文档:CompoundIndexes
总结
以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作具有一定的参考学习价值,如果有疑问大家可以留言交流,谢谢大家对毛票票的支持。