暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

巧用MongoDB部分索引优化性能问题

DB说 2022-07-19
1012

【背景】

        最近研发提交业务需求,大概逻辑就是先统计总数,然后分页进行导出.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})


【分析过程】

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]"
    ]


    2、沟通业务逻辑

          经了解,导数据通常是按天的,但也会存在按周、按月的需求,为什么会存在按周、按月,业务不想自己去合并表格,每天满足条件数据在10万左右,同时这个只有10万记录存在这个productTags字段,其他将近5000万都不存在这个字段.有没有办法只把满足"productTags" :{ "$exists" : true }这个条件的记录索引?如果能实现,这样查询每天的数据大约在10万次,此时如果FETCH+FILTER只有10万,相比之前5000万次,减少了99.8%次数.如果能实现查询覆盖,count效率会更高.MongoDB中确实有这样功能,稀疏索引与部分索引都可以实现这个功能.部分索引功能是稀疏索引的超集同时提供更多的表达式,所以推荐使用部分索引.


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

        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


    4、查看最新执行计划

        这个分片表,执行计划只显示一个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
      }
      }
      }
      }
      }

      【部分索引知识】

      1、部分索引特点与优势

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


      2、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

         

      3、部分索引与稀疏索引

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

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


      4、部分索引与查询覆盖

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

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

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


      [案例]

      1、构造数据

      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 }



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

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


        3、查询count总数

        3.1条件中带$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\"]"


          3.2条件中字段使用实际值而非$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" : [ ]
            }
            ]


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

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


            5、查询count总数

            5.1条件中带$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]"
              ]




              5.2条件中字段使用实际值而非$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" : [ ]
                }
                ]



                总结:

                  1、本次通过部分索引来进行性能优化,同时对部分索引知识简单介绍,需要注意点是查询覆盖在$exists:true条件无法生效,5.0以及还没有正式发布6.0也没有实现这个功能,期待后续版本改进这个点.

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

                文章转载自DB说,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

                评论