• 技术干货| 如何运用 MongoDB 部分索引优化性能问题?


    背 景

    最近研发提交业务需求,大概逻辑就是先统计总数,然后分页进行导出。SQL 查询条件很简单。根据时间范围以及 productTags 字段必须存在作为条件。目前每天大约 5000 万数据量,数据保留 6 个月满足条件数据不多。但在没有索引的情况下,前端导出是卡死的。本次只讨论count性能问题,分页导数同样需要优化。具体SQL如下:

    db.xiaoxu20220704.count({ "productTags" : { "$exists" : true } ,"deliveryTime" : { "$gte" : { "$numberLong" : "1656864000000" } }, "$lt" : { "$numberLong" : "1657814400000" } } )

    目前是基于 4.4 版本的分片集群。下班后创建索引语句跑起来,第二天上班创建成功,一共执行了 8 小时。通知研发可以进行验证。悲催的事情,执行 count 同样卡死。创建索引语句( 4.2 开始不区分前后台创建引),以下是分析过程。

    db.xiaoxu20220704.createIndex({deliveryTime:1,productTags:1})

    分析过程

    分析执行计划

    explain() 查看执行计划发现 "productTags" : { "$exists" : true } 没有用上索引,而是回表后进行过滤。

    IXSCAN+FETCH 执行计划,而不是 COUNT_SCAN 执行计划。

    explain(“executionStats”) 执行一个小时都没有出来,初步猜测在于 5000 万  fetch+filter 导致的慢。需要找研发了解数据情况。

    db.xiaoxu20220704.explain().count({ "productTags" :{ "$exists" : true } ,  "deliveryTime" : { "$gte" : NumberLong("1656864000000") ,  "$lt" : NumberLong("1657814400000")  }} )"winningPlan" : {"stage" : "COUNT","inputStage" : {"stage" : "FETCH","filter" : {"productTags" : {"$exists" : true},"inputStage" : {"stage" : "IXSCAN","keyPattern" : {"deliveryTime" : 1,"productTags" : 1},"indexName" : "deliveryTime_1_productTags_1","isMultiKey" : true,"multiKeyPaths" : {"deliveryTime" : [ ],"productTags" : ["productTags"]},"isUnique" : false,"isSparse" : false,"isPartial" : false,"indexVersion" : 2,"direction" : "forward","indexBounds" : {"deliveryTime" : ["[1656864000000, 1657814400000)"],"productTags" : ["[MinKey, MaxKey]"]

    沟通业务逻辑

    经了解,导数据通常是按天的,但也会存在按周、按月的需求,为什么会存在按周、按月,业务不想自己去合并表格,每天满足条件数据在 10万 左右,同时这个只有10万记录存在这个 productTags 字段,其他将近 5000 万都不存在这个字段。

    有没有办法只把满足 "productTags" :{ "$exists" : true } 这个条件的记录索引?

    如果能实现,这样查询每天的数据大约在 10 万次,此时如果 FETCH+FILTER 只有 10 万,相比之前 5000 万次,减少了 99.8% 次数。如果能实现查询覆盖,count 效率会更高。

    MongoDB 中确实有这样功能,稀疏索引与部分索引都可以实现这个功能。部分索引功能是稀疏索引的超集同时提供更多的表达式,所以推荐使用部分索引。

    优化索引--创建部分索引

    db.xiaoxu20220704.createIndex({deliveryTime:1,productTags:1},{partialFilterExpression:{ "productTags" : { "$exists" : true }}})

    partialFilterExpression: 支持如下表达式,$exists: true等价稀疏索引 (sparse:1)

    • equality expressions (i.e. field: value or using the $eq operator),

    • $exists: true expression,

    • $gt, $gte, $lt, $lte expressions,

    • $type expressions,

    • $and operator at the top-level only

    查看最新执行计划

    这个分片表执行计划只显示一个 shard,其他 shard 都类似,一共 8 个 shard 。总共加起来10万。执行计划本身没有改变,只是总的 totalKeysExamined 以及 totalDocsExamined 减少 99% ,所以速度很快。

    为什么不能使用覆盖查询?

    正常说只要统计出 deliveryTime 个数就知道知道总 count ,因为 productTags 都是满足 "$exists" : true 。

    注意:

    分片集合与非分片集合的查询覆盖有区别:分片集合想要使用覆盖查询必须包括分片键( readConcern 不是 avaiable 即可),使用非分片集合时,同样无法使用覆盖索引。在目前版本以及包括 5.0 版本使用 $exists:true 时都无法覆盖索引,部分索引能否使用覆盖查询,答案是肯定。

    目前在不改代码逻辑的情况下,索引已经是最优了。

    db.xiaoxu20220704.explain("executionStats").count({"productTags" : { "$exists" : true },  "deliveryTime" : { "$gte" : NumberLong("1656864000000") , "$lt" : NumberLong("1656950400000")  }} )"executionStats" : {                "nReturned" : 0,                "executionTimeMillis" : 3155,                "totalKeysExamined" : 116013,                "totalDocsExamined" : 107597,                "executionStages" : {                        "stage" : "SHARD_MERGE",                        "nReturned" : 0,                        "executionTimeMillis" : 3155,                        "totalKeysExamined" : 116013,                        "totalDocsExamined" : 107597,                        "totalChildMillis" : NumberLong(4836),                        "shards" : [                                {                                        "shardName" : "shard7",                                        "executionSuccess" : true,                                        "nReturned" : 0,                                        "executionTimeMillis" : 113,                                        "totalKeysExamined" : 7716,                                        "totalDocsExamined" : 7193,                                        "executionStages" : {                                                "stage" : "COUNT",                                                "nReturned" : 0,                                                "executionTimeMillisEstimate" : 113,                                                "works" : 7717,                                                "advanced" : 0,                                                "needTime" : 7716,                                                "needYield" : 0,                                                "saveState" : 10,                                                "restoreState" : 10,                                                "isEOF" : 1,                                                "nCounted" : 7193,                                                "nSkipped" : 0,                                                "inputStage" : {                                                        "stage" : "SHARDING_FILTER",                                                        "nReturned" : 7193,                                                        "executionTimeMillisEstimate" : 113,                                                        "works" : 7717,               "advanced" : 7193,               "needTime" : 523,                "needYield" : 0,                  "saveState" : 10,                   "restoreState" : 10,                    "isEOF" : 1,                  "chunkSkips" : 0,                 "inputStage" : {                           "stage" : "FETCH",                           "filter" : {                                   "productTags" : {                                           "$exists" : true                                   }                           },                           "nReturned" : 7193,                           "executionTimeMillisEstimate" : 86,                           "works" : 7717,                           "advanced" : 7193,                           "needTime" : 523,                           "needYield" : 0,                           "saveState" : 10,                           "restoreState" : 10,                           "isEOF" : 1,                           "docsExamined" : 7193,                           "alreadyHasObj" : 0,                           "inputStage" : {                           "stage" : "IXSCAN",                           "nReturned" : 7193,                           "executionTimeMillisEstimate" : 15,                           "works" : 7717,                           "advanced" : 7193,                           "needTime" : 523,                           "needYield" : 0,                           "saveState" : 10,                           "restoreState" : 10,                           "isEOF" : 1,                           "keyPattern" : {                                   "deliveryTime" : 1,                                   "productTags" : 1                           },                           "indexName" : "deliveryTime_1_productTags_1",                           "isMultiKey" : true,                           "multiKeyPaths" : {                                   "deliveryTime" : [ ],                                   "productTags" : [                                           "productTags"                                   ]                           },                           "isUnique" : false,                           "isSparse" : false,                           "isPartial" : true,                           "indexVersion" : 2,                           "direction" : "forward",                           "indexBounds" : {                                   "deliveryTime" : [                                           "[1656864000000, 1656950400000)"                                   ],                                   "productTags" : [                                           "[MinKey, MaxKey]"                                   ]                           },                           "keysExamined" : 7716,                           "seeks" : 1,                           "dupsTested" : 7716,                           "dupsDropped" : 523                                                                }                                                        }                                                }                                        }                                }

    部分索引知识

    部分索引特点与优势

    部分索引只是对满足过滤表达式的记录进行索引,而不是所有记录,所以才称为部分索引。

    部分索引可以减少索引大小,加快查询效率以减少磁盘空间,同时部分索引不是针对所有查询都生效。查询条件必须包括过滤表达式。优化器会自动判断是否使用部分索引,对于排序或者查询可能会导致数据不全的情况,优化器会拒绝使用。

    partialFilterExpression 支持类型

    • equality expressions (i.e. field: value or using the $eq operator),

    • $exists: true expression,

    • $gt, $gte, $lt, $lte expressions,

    • $type expressions,

    • $and operator at the top-level only

       

    部分索引与稀疏索引

    • 部分索引能够更好控制那些记录被索引,稀疏根据索引字段是否存在来索引,而部分索引支持很多种表达式。

    • 部分索引相当于稀疏索引的超集功能。例部分索引的 $exists:true 等价稀疏索引,但也存在区别,部分索引的过滤表达式可以是索引定义也可以不是索引定义(只是用来过滤记录),稀疏索引则都属于索引的定义。这个部分索引如何定义会影响查询覆盖。这个问题与我遇到问题的很接近,接下来我们围绕这个来分析下。

    部分索引与查询覆盖

    在文章开头提到遇到的案例中查询条件是 $exists:true 作为查询条件,经过优化后创建过滤条件为 $exists:true 的部分索引,解决 count 性能问题,但如果过滤的记录增加 N 个数量级,还是会存在性能问题。导致性能问题是完全满足查询覆盖,但优化器却没有使用。而是回表进行过滤,相比在索引是过滤效率高(查询覆盖),如果是需要回表返回完整记录,那么不存在效率问题。因为索引中记录都是满足条件的直接回表过滤也都是满足条件的。

    经过验证目前存在 $exists:true 查询时,不管是部分索引还是普通索引,都无法使用查询覆盖(截止目前最新 5.0 版本都还没有解决,期待未来版本能够优化这个问题),对于部分索引中过滤为 $exists:true 时,满足覆盖查询时,使用具体值而不是 $exists:true 时可以使用查询覆盖。

    分片集合支持查询覆盖,相比非分片集合,索引中需要带分片键。如果开启读写分离时,读备库 readConcern 默认是 avaiable ,此时与非分片集合一样,不需要包括分片键就可以查询覆盖。此时导致读取孤儿文档。

    需要注意此时可以调整 readConcern 为 local 。

    案 例

    构造数据

    mongos> db.xiaoxu20220718.find();

    { "_id" : ObjectId("62d4fbf69dadbc915955a94b"), "name" : "xiaoxu", "age" : 18, "addr" : "shanghai" }{ "_id" : ObjectId("62d4fbf69dadbc915955a94c"), "name" : "xiaojing", "age" : 20, "addr" : "beijing" }{ "_id" : ObjectId("62d4fbf69dadbc915955a94d"), "name" : "xiaobao", "age" : 1 }{ "_id" : ObjectId("62d4fbf69dadbc915955a94e"), "name" : "xiaoxing", "age" : 18 }

    创建部分索引:索引定义不包括过滤字段

    db.xiaoxu20220718.createIndex({name:1},{partialFilterExpression:{addr:{$exists:true}}})

    查询 count 总数

    条件中带 $exists:true

    执行计划:IXSCAN+FETCH+COUNT ,而不是我们期望 COUNT_SCAN

    db.xiaoxu20220718.explain("executionStats").count({"name" : "xiaoxu","addr":{$exists:true}})"winningPlan" : {"stage" : "COUNT","inputStage" : {"stage" : "FETCH","filter" : {"addr" : {"$exists" : true}},"inputStage" : {"stage" : "IXSCAN","keyPattern" : {"name" : 1},"indexName" : "name_1","isMultiKey" : false,"multiKeyPaths" : {"name" : [ ]},"isUnique" : false,"isSparse" : false,"isPartial" : true,"indexVersion" : 2,"direction" : "forward","indexBounds" : {"name" : ["[\"xiaoxu\", \"xiaoxu\"]"

    条件中字段使用实际值而非 $exists:true

    执行计划:IXSCAN+FETCH+COUNT,而不是我们期望 COUNT_SCAN

    db.xiaoxu20220718.explain("executionStats").count({"name" : "xiaoxu","addr":"shanghai"})"winningPlan" : {"stage" : "COUNT","inputStage" : {"stage" : "FETCH","filter" : {"addr" : {"$eq" : "shanghai"}},"inputStage" : {"stage" : "IXSCAN","keyPattern" : {"name" : 1},"indexName" : "name_1","isMultiKey" : false,"multiKeyPaths" : {"name" : [ ]},"isUnique" : false,"isSparse" : false,"isPartial" : true,"indexVersion" : 2,"direction" : "forward","indexBounds" : {"name" : ["[\"xiaoxu\", \"xiaoxu\"]"]}}}},"rejectedPlans" : [ ]}]

    创建部分索引定义中包括过滤字段

    db.xiaoxu20220718.createIndex({name:1,addr:1},{partialFilterExpression:{addr:{$exists:true}}})

    查询 count 总数

    条件中带 $exists:true

    执行计划:IXSCAN+FETCH+COUNT,而不是我们期望COUNT_SCAN,还是选择单列索引

    db.xiaoxu20220718.explain("executionStats").count({"name" : "xiaoxu","addr":{$exists:true}})"winningPlan" : {"stage" : "FETCH","filter" : {"addr" : {"$exists" : true}},"inputStage" : {"stage" : "IXSCAN","keyPattern" : {"name" : 1},"indexName" : "name_1","isMultiKey" : false,"multiKeyPaths" : {"name" : [ ]},"isUnique" : false,"isSparse" : false,"isPartial" : true,"indexVersion" : 2,"direction" : "forward","indexBounds" : {"name" : ["[\"xiaoxu\", \"xiaoxu\"]"]}}},"rejectedPlans" : [{"stage" : "FETCH","filter" : {"addr" : {"$exists" : true}},"inputStage" : {"stage" : "IXSCAN","keyPattern" : {"name" : 1,"addr" : 1},"indexName" : "name_1_addr_1","isMultiKey" : false,"multiKeyPaths" : {"name" : [ ],"addr" : [ ]},"isUnique" : false,"isSparse" : false,"isPartial" : true,"indexVersion" : 2,"direction" : "forward","indexBounds" : {"name" : ["[\"xiaoxu\", \"xiaoxu\"]"],"addr" : ["[MinKey, MaxKey]"]

    条件中字段使用实际值而非$exists:true

    执行计划:符合我们期望 COUNT_SCAN

    mongos> db.xiaoxu20220718.explain("executionStats").count({"name" : "xiaoxu","addr":"shanghai"})
    "winningPlan" : {"stage" : "COUNT","inputStage" : {"stage" : "COUNT_SCAN","keyPattern" : {"name" : 1,"addr" : 1},"indexName" : "name_1_addr_1","isMultiKey" : false,"multiKeyPaths" : {"name" : [ ],"addr" : [ ]},"isUnique" : false,"isSparse" : false,"isPartial" : true,"indexVersion" : 2,"indexBounds" : {"startKey" : {"name" : "xiaoxu","addr" : "shanghai"},"startKeyInclusive" : true,"endKey" : {"name" : "xiaoxu","addr" : "shanghai"},"endKeyInclusive" : true}}},"rejectedPlans" : [ ]}]

    总 结

    • 本次通过部分索引来进行性能优化,同时对部分索引知识简单介绍,需要注意点是查询覆盖在 $exists:true 条件无法生效,期待后续版本改进这个点。

    • 注意部分索引只适合特定场景以及查询覆盖注意事项。

    关于作者:徐靖

    MongoDB 中文社区成员,数据库工程师,具有丰富的数据库运维经验,精通数据库性能优化及故障诊断,目前专注于 MongoDB 数据库运维与技术支持,同时也是公众号《 DB 说》维护者,喜欢研究与分享数据库相关技术。希望能够为社区贡献一份力量。

  • 相关阅读:
    初探元宇宙存储,数据存储市场下一个爆点?
    从TCP到Socket,彻底理解网络编程是怎么回事
    Flink Watermark 机制
    数组与链表
    解决flume往hdfs中写大量小文件问题
    10min快速回顾C++语法(八)STL专题
    A_B001_01 stc-isp 单片机烧录软件安装与使用
    机器学习的实用程序
    正则表达式基础
    UE4 C++设计模式:状态模式(State Pattern)
  • 原文地址:https://blog.csdn.net/u014361223/article/details/126525572