mongodb3.6 query plan机制变更导致慢查询问题排查

我们在升级mongodb3.6之后,线上数据库存在大量慢查询,经过分析explain结果发现是query plan阶段耗时过长,于是我先研究了下mongodb3.6的query plan。

query plan机制

现有索引:

{
    "key" : {
        "c1" : 1.0
    },
    "name" "c1_1",
    "ns" "test.test"
},
{
    "key" : {
        "c2" : 1.0
    },
    "name" "c2_1",
    "ns" "test.test"
}

首次执行查询语句:

db.test.find({c1: 22, c2: 33});

mongodb会检测到所有可能使用的索引,很明显我们可以使用c1_1和c2_1这两个索引,但是只能使用其中一个,这就涉及到选择使用哪个索引的问题。

mongodb有一个算法进行选择(query plan):

在c1_1和c2_1这两个索引B-tree(假设叫treeA、treeB)上进行扫描,对于treeA,只扫描c1=22的节点,对于treeB,只扫描c2=33的节点,两棵树的扫描过程同时进行,互不干扰。

对于每个索引,每次扫描得到一个document,判断这个document是否同时满足c1==22&&c2==33,如果满足就在当前索引的计数+1(对应db.datas.getPlanCache().getPlansByQuery的advanced值)。

扫描停止条件:

1、其中任意一棵树扫描完毕

2、其中任意一棵树advaned值达到101

3、扫描次数达到 max(10000, collectionSize * 0.3)

正常情况下,query plan会在101次扫描内结束,但是可能出现扫描collectionSize * 0.3次的情况,这种情况下一般是索引设计的有问题,比如有下面这样的数据:

idc1c2
1 1 1
2 1 1
3 1 1
4 1 1
............
499999 1 1

500000

2 2
500001 2 2
.............
1000000 2 2

前一半数据是{c1:1, c2:1},另一半数据是{ c1:2, c2:2 },执行queryPlan时,会扫描 100 0000 * 0.3 = 30 0000次,在实际查询阶段也要扫描50 0000次。plan生成好之后会缓存plan结果,下次查询不需要再执行query plan。

 综上来看,query plan算法的设计还是非常合理的。

这个问题暂时搁置,只好又退回到mongodb3.4版本,有一次升级我拿到了具体的慢查询语句,最终找到了原因,先整理如下:

问题描述

mongodb线上环境从3.4升级到3.6之后,发现部分聚合语句的query plan耗时过长。

对比分析3.6和3.4的query plan源码,分析了query plan的机制,并没有发现什么问题。

Diagram of query planner logic

源码分析

第二次升级拿到了具体的具体的聚合语句,该语句$and包含12个$or子条件。通过分析mongodb3.6的源代码,发现该语句在“Evaluate Candiate Plans”阶段,每一个候选plan都扫描了61937个索引树节点,而一共有13个候选,所以一共扫描了61937*13次索引树节点,总共大约花费12s。每一个候选plan扫描61937次其实是比较正常的,而有13个候选是不正常的。又对比了3.4版本,发现3.4版本只有一个候选plan。到这里原因就比较明确了,问题出在"Generate Candidate Plans"阶段而不是之前一直怀疑的"Evaluate Candidate Plans"阶段。

Generate Candidate Plans:根据查询语句预测可能用到哪些索引,并且预测不同的 index bounds。

Evaluate Candidate Plans:对候选plans进行正式评估,会扫描索引树,产生IO。

于是开始分析“Generate Candidate Plans”阶段的代码,看看到底哪里和3.4版本有大的差别:

这一阶段会首先执行findRelevantIndices函数,该函数执行过程如下:

    1、静态扫描查询语句,得到相关列名,比如 fields[] = ["appId", "entryId", "state", "_widget_123456789"],日志中显示的是"predicate over field"

    2、执行QueryPlannerIXSelect::findRelevantIndices函数,遍历当前collection的所有索引,{ appId:1, entryId:1, state:1 } 等等,对于每一个索引,查找fields是否包含这个索引的第一列。比如对于索引 { appId:1, entryId:1, state:1 },在fields数组中查找"appId",能够找到就把这个索引加入到RelevantIndices数组;又比如对于 { expireTime: 1 },在fields数组中查找"expireTime",找不到,就舍弃;再比如对于(假设存在这个索引)  { entryId: 1, appId: 1  },在fields数组中查找"entryId",能够找到就把这个索引加入到RelevantIndices数组

然后确认3.4和3.6版本findRelevantIndices的执行结果是一样的。

接下来RelevantIndices会进入class PlanEnumerator类进行处理,发现这一步的执行结果在3.6和3.4中是不同的,进一步分析代码,果不其然在index_tag.cpp中改动了大量代码,加入了很多新的函数。

3.6新特性

下面根据具体语句来说明,先看下面的查询语句:

 

find({a: 1, $or: [{b: 2, c: 2}, {b: 3, c: 3}]}) 

假设有索引 {a: 1, c: 1} 和 {b: 1, c: 1},在3.6版本之前,会生成两个候选plan:

  1. 扫描索引 { a: 1, c: 1 },且扫描边界为{a: [[1, 1]], c: [["MinKey", "MaxKey"]]}
  2. 扫描索引{ b: 1, c: 1 }(针对OR条件)

因为有$or条件的pushdown机制,条件 a: 1 会被pushdown到 $or 的所有子分支,即等价于  $or: [ { a: 1, b: 2, c: 2 }, { a: 1, b: 3, c: 3 } ]。

3.6版本的变化是,它会认为在扫描{ a: 1, c: 1 }索引的时候,可以有两种不同的边界,一种是{a: [[1, 1]], c: [[2, 2]]},另一种是{a: [[1, 1]], c: [[3, 3]]},而不仅仅是{a: [[1, 1]], c: [["MinKey", "MaxKey"]]}。这两个边界组合生成了一个候选plan。

3.6版本的这种机制的改变在某些时候确实起到了优化作用,扫描的索引树总节点数量变少了,减少了IO次数。

线上问题分析总结

下面我们来分析线上更新遇到的问题,查询语句形如:

    a: 1,
    $and: [
        { $or: [{b: 2, c: 2}, {b: 3, c: 3}] },
        { $or: [{b: 2, c: 2}, {b: 3, c: 3}] },
        { $or: [{b: 2, c: 2}, {b: 3, c: 3}] }
        ...
    ]
});

在3.4版本中,会认为每个$and子条件都只需要走 { b: 1, c: 1 } 这一个索引就可以了,因为index bounds都相同,即所有的$or仅需要生成一个候选plan即可。但是在3.6版本中,根据上面的分析,每个$or条件会生成不同的边界组合,所以不同的$or就不能共用一个plan了,每个$or都需要生成一个plan,这些plans都拥有不同的index bounds,而如果查询语句里有非索引字段的过滤条件,就会导致每个plans都进行全表扫描

posted @ 2019-03-14 22:16  ZhMZ  阅读(484)  评论(0编辑  收藏  举报