MongoDB查询优化器

索引概述

介绍查询优化器首先要从索引开始。索引在计算机系统中应用非常广泛,是提高查询效率的常用手段。如果没有索引,MongoDB必须遍历集合中所有文档才能找到匹配的结果;如果存在一个适当的索引可以限制MongoDB必须检查的文档数量。

在MongoDB中,索引是一种特殊的数据结构,以一种便于遍历的方式存储集合数据的部分信息。

常见的索引有几种组织模型,其中,B-Tree索引可以看做将键值映射到有序数组中的位置;Hash索引将键值映射到无序数组中的位置。MongoDB默认采用B-Tree组织索引文件,在2.4版本后也允许建立Hash索引。

索引的创建

MongoDB索引是在集合上创建的,通过ensureIndex方法可以方便的设置索引。MongoDB集合可以创建多个索引。恰当的索引可以显著提高查询效率,但是当数据发生变动时,不仅需要修改更新文档,还要更新集合上的索引,这无疑会导致写操作要花费更多的时间。因此,MongoDB限制每个集合最多可以有64个索引,当然,只要设计合理,这个上限也是足够的。
我们用《MongoDB 权威指南》中的例子做一下修改,假设用MongoDB存储百万级用户数据,包括姓名(name), 性别(sex), 年龄(age),电话(tel)信息。为了便于说明问题,这里只写四条数据:

> db.users.find()
{ "_id" : ObjectId("5a2f8e7bd24b2183ae3b9fab"), "name" : "Andy", "sex" : "male", "age" : 16, "tel" : "1300000001" }
{ "_id" : ObjectId("5a2f97c7eb221206e13c7c3f"), "name" : "Bale", "sex" : "male", "age" : 18, "tel" : "1300000002" }
{ "_id" : ObjectId("5a2fa15beb221206e13c7c40"), "name" : "Cindy", "sex" : "female", "age" : 17, "tel" : "1300000003" }
{ "_id" : ObjectId("5a2fa1cbeb221206e13c7c41"), "name" : "Dany", "sex" : "male", "age" : 20, "tel" : "1300000004" }

我们创建三个索引:

> db.users.ensureIndex({"age": 1, "sex": 1});
{
    "createdCollectionAutomatically" : false,
    "numIndexesBefore" : 1,
    "numIndexesAfter" : 2,
    "ok" : 1
}
> db.users.ensureIndex({"age": 1});
{
    "createdCollectionAutomatically" : false,
    "numIndexesBefore" : 2,
    "numIndexesAfter" : 3,
    "ok" : 1
}
> db.users.ensureIndex({"sex": 1, "age": 1});
{
    "createdCollectionAutomatically" : false,
    "numIndexesBefore" : 3,
    "numIndexesAfter" : 4,
    "ok" : 1
}

在创建集合时,MongoDB会默认在_id字段上创建一个唯一索引,并且不能删除。所以当我们创建第一个索引时numIndexesBefore值是1。需要注意的是,B-Tree索引是有序的,上面三个索引大致结构是:

# {"age": 1, "sex": 1}
[16, male]      ->  ObjectId("5a2f8e7bd24b2183ae3b9fab")
[17, female]    ->  ObjectId("5a2fa15beb221206e13c7c40")
[18, male]      ->  ObjectId("5a2f97c7eb221206e13c7c3f")
[20, male]      ->  ObjectId("5a2fa1cbeb221206e13c7c41")

# {"age": 1}
[16]      ->  ObjectId("5a2f8e7bd24b2183ae3b9fab")
[17]      ->  ObjectId("5a2fa15beb221206e13c7c40")
[18]      ->  ObjectId("5a2f97c7eb221206e13c7c3f")
[20]      ->  ObjectId("5a2fa1cbeb221206e13c7c41")

# {"sex": 1, "age": 1}
[female, 17]    ->  ObjectId("5a2fa15beb221206e13c7c40")
[male, 16]      ->  ObjectId("5a2f8e7bd24b2183ae3b9fab")
[male, 18]      ->  ObjectId("5a2f97c7eb221206e13c7c3f")
[male, 20]      ->  ObjectId("5a2fa1cbeb221206e13c7c41")

索引的选择

MongoDB一个集合可以创建多个索引,查询优化器为索引创建查询计划,将选择索引转换为选择查询计划或者说选择查询计划对应的游标。

由于最近版本中几乎看不到优化器的概念,我们从较早的版本中开始介绍,以MongoDB-2.4.14为例,具体的选择过程可以分为三个阶段:首先根据查询模式(Query pattern)判断是否存在CachedPlan,如果存在直接选择,这里查询模式是除了数值外其他查询条件完全相同;其次,如果没有缓存记录,查询优化器创建新查询计划并标记类型,如果类型为Optimal Plan则直接执行该Plan;最后,如果不存在Optimal Plan,MongoDB会并发尝试可能的Helpful Plan以及不使用索引的基础查询。查询优化器会对比选择表现最好的查询计划继续执行,并将查询模式与最终查询计划的映射写入CachedPlan。

Optimal Plan

Optimal Plan的索引首先应该包含所有查询的过滤字段和排序字段,其次,字段在索引中的顺序是范围过滤和排序字段位于相等字段之后。有多个Optimal Plan时,会执行第一个。

Helpful Plan

不存在Optimal Plan时,查询优化器会并发执行所有Helpful查询计划。最早得到101个结果的查询计划会被选中继续执行,并将该查询计划暂存。其他查询计划会被中止。

下面列举几个例子,为了便于说明,我们将集合文档数扩大到1000000:

> var names = ["Andy", "Bale", "Cindy", "Dany", "Emmy", "Ford", "Gill"]
> var sexs = ["male", "female"]
> for (i=0; i<1000000; i++) {
    db.users.insert(
        {"name": names[Math.floor(Math.random()*7)],
         "sex": sexs[Math.floor(Math.random()*2)],
         "age": Math.floor(Math.random()*100),
         "tel": 13000000001+i
        } 
    ); 
}

首先查询age为18的所有记录,这里只截取部分输出信息:

> db.users.find({"age": 18}).explain({"verbose": true})
{
    "cursor" : "BtreeCursor age_1_sex_1",
    "isMultiKey" : false,
    "n" : 9777,
    "nscannedObjects" : 9777,
    "nscanned" : 9777,
    "millis" : 2168,
    "allPlans" : [
        {
            "cursor" : "BtreeCursor age_1_sex_1",
            "n" : 9777,
            "nscannedObjects" : 9777,
            "nscanned" : 9777,
        }
    ],
}

根据定义“age_1_sex_1”和“age_1”对应的查询计划都是Optimal Plan,这里选取了第一个便不再查找其他Plan,所以allPlans也只有“age_1_sex_1”一个。
下面第二次查询age为18的记录,同样截取部分输出:

> db.users.find({"age": 18}).explain({"verbose": true})
{
    "cursor" : "BtreeCursor age_1_sex_1",
    "isMultiKey" : false,
    "n" : 9777,
    "nscannedObjects" : 9777,
    "nscanned" : 9777,
    "millis" : 1686,
    "allPlans" : [
        {
            "cursor" : "BtreeCursor age_1_sex_1",
            "n" : 9777,
            "nscannedObjects" : 9777,
            "nscanned" : 9777,
        }
    ],
    "oldPlan" : {
        "cursor" : "BtreeCursor age_1_sex_1",
    },
}

与第一次不同,这里多了oldPlan。注意:

  • 根据查询模式查找缓存查询计划,此时如果查找age为20只有值不同,同样会命中缓存。
  • 由于指定了explain(),本次查询并没有直接使用缓存计划而是需要重新评估。

缓存的查询计划在以下条件下会清空并重新评估:

  • 集合收到1000次写操作
  • 执行reindex
  • 添加或删除索引
  • mongod进程重启
  • 查询时指定explain()

第三个例子,我们查询name为Andy,age为18的记录:

> db.users.find({"age": 18, "name": "Andy"}).explain({"verbose": true})
{
    "cursor" : "BtreeCursor age_1",
    "isMultiKey" : false,
    "n" : 1392,
    "nscannedObjects" : 9777,
    "nscanned" : 9777,
    "millis" : 889,
    "allPlans" : [
        {
            "cursor" : "BtreeCursor age_1",
            "n" : 1348,
            "nscannedObjects" : 9777,
            "nscanned" : 9777,
        },
        {
            "cursor" : "BtreeCursor age_1_sex_1",
            "n" : 91,
            "nscannedObjects" : 700,
            "nscanned" : 700,
        },
        {
            "cursor" : "BasicCursor",
            "n" : 0,
            "nscannedObjects" : 700,
            "nscanned" : 700,

        }
    ],
}

此次查询没有最优索引,两个索引“age_1_sex_1”和“age_1”对查询有帮助,连同基础查询总共创建三个查询计划。
评估候选查询计划会在以下情况之一发生时结束:

  • 获取到所有查询结果;
  • 最先得到101个查询结果(enoughCumulativeMatchesToChooseAPlan)
    上面例子中,“age_1”最先返回101个查询结果,对应的查询计划执行到最后,其他查询计划被中止。

在MongoDB较新的版本中(以3.2.10为例),查询优化器做了很大的改变。对于每个查询,系统首先在查询计划缓存中匹配相同查询信息(Query Shape)选择查询计划;如果没有命中缓存,查询计划生成器会评估所有候选查询计划,选择一个最优计划(Winning Plan),并将最有计划写入缓存。
当缓存命中时,系统会重新评估该缓存计划,作出是否合格的判定,并根据判定结果保留或清除缓存计划。
下面是3.2.10版本的一次查询:

replset:PRIMARY> db.users.find({"age": 18, "sex": "male"}).explain("queryPlanner")
{
    "queryPlanner" : {
        "namespace" : "date.users",
        "indexFilterSet" : false,
        "winningPlan" : {
            "stage" : "FETCH",
            "inputStage" : {
                "indexName" : "sex_1_age_1",
            }
        },
        "rejectedPlans" : [
            {
                "inputStage" : {
                    "indexName" : "age_1",
                }
            },
            {
                "inputStage" : {
                    "indexName" : "age_1_sex_1",
                }
            }
        ]
    },
}

两个版本最主要的区别就是新版本取消了直接通过规则选择Optimal Plan的逻辑,按照以前的匹配规则,有“sex_1_age_1”这样的Optimal Plan不会再执行其他Optimal Plan和Helpfun Plan。取消Optimal Pan是因为往往Optimal Plan并不是最优的。下面是MongoDB-2.4.14的两个例子,同样只截取部分信息说明:

> db.users.find({"age": 18}, {"age": 1, "sex": 1, "_id": 0}).explain("verbose")
{
    "cursor" : "BtreeCursor age_1",
    "n" : 9777,
    "nscannedObjects" : 9777,
    "nscanned" : 9777,
    "indexOnly" : false,
    "millis" : 755,
    "allPlans" : [
        {
            "cursor" : "BtreeCursor age_1",
            "n" : 9777,
            "nscannedObjects" : 9777,
            "nscanned" : 9777,
        }
    ],
}
> db.users.find({"age": 18}, {"age": 1, "sex": 1, "_id": 0}).hint("age_1_sex_1").explain("verbose")
{
    "cursor" : "BtreeCursor age_1_sex_1",
    "n" : 9777,
    "nscannedObjects" : 0,
    "indexOnly" : true,
    "millis" : 27,
    "allPlans" : [
        {
            "cursor" : "BtreeCursor age_1_sex_1",
            "n" : 9777,
            "nscannedObjects" : 0,
            "nscanned" : 9777,
        }
    ],
}

上面的两次查询中,第一次查询“age_1”首先匹配到Optimal Plan并执行到最后,用时755毫秒,“age_1_sex_1”虽然同样是Optimal Plan,但是索引的顺序排在“age_1”之后,所以没有被选中。第二次查询中通过hint显式指定“age_1_sex_1”,通过该索引查询是“indexOnly”,不需要扫描文档就可以完成查询,所以效率更高,用时只有27毫秒。

另外,查询优化器还有一个变化,匹配缓存计划由Query Pattern变为Query Shape。官网上对二者的定义分别是:

  • Query pattern refers to query select conditions that differ only in the values.
  • A query shape consists of a combination of query, sort, and projection specifications.

Query Pattern 关注的是Fileds,Range和Sort,而Query Shape增加了Projection。

总结

查询优化器极大提升了查询性能,随着版本的升级,虽然最新的代码中已经很难找到“optimizer”的字段,但是逻辑仍然保留并且变得更加完善。
目前,查询优化器并不能保证每次查询都是最优的,无论是OptimalPlan 还是WinningPlan都是相对的,配合hint,index filter会使查询优化器更精准。
查询优化器是一次MongoDB查询的一个重要环节,深入了解可以帮助我们更合理的设计索引,更高效的使用MongoDB。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349

推荐阅读更多精彩内容