MongoDB查询优化:从 10s 到 10ms

本文涉及的产品
云数据库 Tair(兼容Redis),内存型 2GB
云数据库 MongoDB,独享型 2核8GB
推荐场景:
构建全方位客户视图
云原生多模数据库 Lindorm,多引擎 多规格 0-4节点
简介: 本文是我前同事付秋雷最近遇到到一个关于MongoDB执行计划选择的问题,非常有意思,在探索源码之后,他将整个问题搞明白并整理分享出来。付秋雷(他的博客)曾是Tair(阿里内部用得非常官方的KV存储系统)的核心开发,目前就职于蘑菇街。

本文是我前同事付秋雷最近遇到到一个关于MongoDB执行计划选择的问题,非常有意思,在探索源码之后,他将整个问题搞明白并整理分享出来。付秋雷曾是 Tair(阿里内部用得非常广泛的KV存储系统)的核心开发成员,目前就职于蘑菇街。


背景

苏先生反馈线上某条查询很慢(10+ seconds),语句相当于

db.myColl.find({app:"my_app",requestTime:{$gte:1492502247000,$lt:1492588800000}}).sort({_id:-1}).limit(1)

myColl这个collection中的记录内容类似于:

{ "_id" : ObjectId("58fd895359cb8757d493ce60"), "app" : "my_app", "eventId" : 141761066, "requestTime" : NumberLong("1493010771753"), "scene" : "scene01" }
{ "_id" : ObjectId("58fd895359cb8757d493ce52"), "app" : "my_app", "eventId" : 141761052, "requestTime" : NumberLong("1493010771528"), "scene" : "scene02" }
{ "_id" : ObjectId("58fd895359cb8757d493ce36"), "app" : "my_app", "eventId" : 141761024, "requestTime" : NumberLong("1493010771348"), "scene" : "scene03" }
{ "_id" : ObjectId("58fd895359cb8757d493ce31"), "app" : "my_app", "eventId" : 141761019, "requestTime" : NumberLong("1493010771303"), "scene" : "scene01" }
{ "_id" : ObjectId("58fd895359cb8757d493ce2d"), "app" : "my_app", "eventId" : 141761015, "requestTime" : NumberLong("1493010771257"), "scene" : "scene01" }
{ "_id" : ObjectId("58fd895259cb8757d493ce10"), "app" : "my_app", "eventId" : 141760986, "requestTime" : NumberLong("1493010770866"), "scene" : "scene01" }
{ "_id" : ObjectId("58fd895259cb8757d493ce09"), "app" : "my_app", "eventId" : 141760979, "requestTime" : NumberLong("1493010770757"), "scene" : "scene01" }
{ "_id" : ObjectId("58fd895259cb8757d493ce02"), "app" : "my_app", "eventId" : 141760972, "requestTime" : NumberLong("1493010770614"), "scene" : "scene03" }
{ "_id" : ObjectId("58fd895259cb8757d493cdf1"), "app" : "my_app", "eventId" : 141760957, "requestTime" : NumberLong("1493010770342"), "scene" : "scene02" }
{ "_id" : ObjectId("58fd895259cb8757d493cde6"), "app" : "my_app", "eventId" : 141760946, "requestTime" : NumberLong("1493010770258"), "scene" : "scene01" }

相关的索引有:

[
    {
        "v" : 1,
        "key" : {
            "_id" : 1
        },
        "name" : "_id_",
        "ns" : "myDatabase.myColl"
    },
    {
        "v" : 1,
        "key" : {
            "responseTime" : -1
        },
        "name" : "idx_responseTime_-1",
        "ns" : "myDatabase.myColl"
    },
    {
        "v" : 1,
        "key" : {
            "app" : 1,
            "scene" : 1,
            "eventId" : -1,
            "requestTime" : -1
        },
        "name" : "idx_app_1_scene_1_eventId_-1_requestTime_-1",
        "ns" : "myDatabase.myColl"
    }
]

慢查询就是在myColl中查找符合[1492502247000, 1492588800000)这个时间范围的所有记录,以下描述中称这条查询为bad query

如果去掉$lt:1492588800000这个约束条件,查找[1492502247000, +∞)这个时间范围,就会很快(milliseconds)。

db.myColl.find({app:"my_app",requestTime:{$gte:1492502247000}}).sort({_id:-1}).limit(1)

以下描述中称这条查询为good query

问题来了:

  • [问题A] 这两条查询都是走的什么索引呢?导致执行时间相差如此之大
  • [问题B] 如果两条查询选取的索引不同,为什么会有这个不同呢,这两条查询长得还是挺像的
  • [问题C] 如果bad query选取和good query一样的索引,是否还会有一样的问题呢

问题A

这两条查询都是走的什么索引呢?导致执行时间相差如此之大

Mysql一样,Mongodb也提供了explain语句,可以获取query语句查询计划queryPlanner)、以及执行过程中的统计信息(executionStats)。

违和发散:Cassandra中也是有类似的功能Hbase中目前是没有看到的

mongo shell中的使用方法是在query语句后面加上.explain('executionStats'),对于上面的good query,对应的explain语句为:

db.myColl.find({app:"my_app",requestTime:{$gte:1492502247000}}).sort({_id:-1}).limit(1).explain('executionStats')

good queryexplain语句的执行结果如下,无关细节用...省略:

{
    "queryPlanner" : {
        "plannerVersion" : 1,
        "namespace" : "myDatabase.myColl",
        "indexFilterSet" : false,
        "parsedQuery" : ...
        "winningPlan" : {
            "stage" : "LIMIT",
            "limitAmount" : 1,
            "inputStage" : {
                "stage" : "FETCH",
                "filter" : ...,
                "inputStage" : {
                    "stage" : "IXSCAN",
                    "keyPattern" : {
                        "_id" : 1
                    },
                    "indexName" : "_id_",
                    ...
                    "direction" : "backward",
                    "indexBounds" : {
                        "_id" : [
                            "[MaxKey, MinKey]"
                        ]
                    }
                }
            }
        },
        "rejectedPlans" : ...,
    },
    "executionStats" : {
        "executionSuccess" : true,
        "nReturned" : 1,
        "executionTimeMillis" : 0,
        "totalKeysExamined" : 8,
        "totalDocsExamined" : 8,
        "executionStages" : {
            "stage" : "LIMIT",
            ...
            "inputStage" : {
                "stage" : "FETCH",
                ...
                "inputStage" : {
                    "stage" : "IXSCAN",
                    ...
                    "direction" : "backward",
                    "indexBounds" : {
                        "_id" : [
                            "[MaxKey, MinKey]"
                        ]
                    },
                    "keysExamined" : 8,
                    ...
                }
            }
        }
    },
    "serverInfo" : ...,
    "ok" : 1
}

结果分为四部分:queryPlannerexecutionStatsserverInfook,仅关注queryPlannerexecutionStats这两部分。

executionStats就是执行queryPlanner.winningPlan这个计划时的统计信息,可以从indexBounds看到good query在索引扫描(IXSCAN)阶段,使用的索引是_id主键索引。从IXSCAN这个阶段的keysExamined统计可以解释为什么good query执行的这么快,只扫描了8条数据。

同样使用explain语句看看bad query使用的是什么索引:

{
    "queryPlanner" : {
        ...
        "winningPlan" : {
            "stage" : "SORT",
            ...
            "inputStage" : {
                "stage" : "SORT_KEY_GENERATOR",
                "inputStage" : {
                    "stage" : "FETCH",
                    "inputStage" : {
                        "stage" : "IXSCAN",
                        "keyPattern" : {
                            "app" : 1,
                            "scene" : 1,
                            "eventId" : -1,
                            "requestTime" : -1
                        },
                        "indexName" : "idx_app_1_scene_1_eventId_-1_requestTime_-1",
                        ...
                        "direction" : "forward",
                        "indexBounds" : {
                            "app" : [
                                "[\"my_app\", \"my_app\"]"
                            ],
                            "scene" : [
                                "[MinKey, MaxKey]"
                            ],
                            "eventId" : [
                                "[MaxKey, MinKey]"
                            ],
                            "requestTime" : [
                                "(1492588800000.0, 1492502247000.0]"
                            ]
                        }
                    }
                }
            }
        },
        "rejectedPlans" : ...,
    },
    "executionStats" : {
        "executionSuccess" : true,
        "nReturned" : 1,
        "executionTimeMillis" : 56414,
        "totalKeysExamined" : 3124535,
        "totalDocsExamined" : 275157,
        "executionStages" : {
            "stage" : "SORT",
            ...
            "inputStage" : {
                "stage" : "SORT_KEY_GENERATOR",
                ...
                "inputStage" : {
                    "stage" : "FETCH",
                    ...
                    "inputStage" : {
                        "stage" : "IXSCAN",
                        ...
                        "direction" : "forward",
                        "indexBounds" : {
                            "app" : [
                                "[\"my_app\", \"my_app\"]"
                            ],
                            "scene" : [
                                "[MinKey, MaxKey]"
                            ],
                            "eventId" : [
                                "[MaxKey, MinKey]"
                            ],
                            "requestTime" : [
                                "(1492588800000.0, 1492502247000.0]"
                            ]
                        },
                        "keysExamined" : 3124535,
                        ...
                    }
                }
            }
        }
    },
    "serverInfo" : ...,
    "ok" : 1
}

可以看到bad query使用的索引是一个复合索引(Compound Indexes),确实和good query使用的索引不一样。同样,从IXSCAN这个阶段的keysExamined统计可以看到扫描了3124535条数据,所以执行时间会很长。

问题B

如果两条查询选取的索引不同,为什么会有这个不同呢,这两条查询长得还是挺像的

Mongodb是如何为查询选取认为合适的索引的呢?

粗略来说,会先选几个候选的查询计划,然后会为这些查询计划按照某个规则来打分,分数最高的查询计划就是合适的查询计划,这个查询计划里面使用的索引就是认为合适的索引

好,粗略地说完了,现在细致一点说(还是那句话:没有代码的解释都是耍流氓,以下所有的代码都是基于mongodb-3.2.10)。

先看一个栈:

mongo::PlanRanker::scoreTree
mongo::PlanRanker::pickBestPlan
mongo::MultiPlanStage::pickBestPlan
mongo::PlanExecutor::pickBestPlan
mongo::PlanExecutor::make
mongo::PlanExecutor::make
mongo::getExecutor
mongo::getExecutorFind
mongo::FindCmd::explain

这是使用lldb来调试mongod时,在mongo::PlanRanker::scoreTree(代码位于src/mongo/db/query/plan_ranker.cpp)处设置断点打印出来的栈。

scoreTree里面就是计算每个查询计划的得分的:

    // We start all scores at 1.  Our "no plan selected" score is 0 and we want all plans to
    // be greater than that.
    double baseScore = 1;
    
    // How many "units of work" did the plan perform. Each call to work(...)
    // counts as one unit.
    size_t workUnits = stats->common.works;

    // How much did a plan produce?
    // Range: [0, 1]
    double productivity =
        static_cast<double>(stats->common.advanced) / static_cast<double>(workUnits);
        
    ...
        
    double tieBreakers = noFetchBonus + noSortBonus + noIxisectBonus;
    double score = baseScore + productivity + tieBreakers;

scoreTree并没有执行查询,只是根据已有的PlanStageStats* stats来进行计算。那么,是什么时候执行查询来获取查询计划的PlanStageStats* stats的呢?

mongo::MultiPlanStage::pickBestPlan(代码位于src/mongo/db/exec/multi_plan.cpp)中,会调用workAllPlans来执行所有的查询计划,最多会调用numWorks次:

    size_t numWorks = getTrialPeriodWorks(getOpCtx(), _collection);
    size_t numResults = getTrialPeriodNumToReturn(*_query);

    // Work the plans, stopping when a plan hits EOF or returns some
    // fixed number of results.
    for (size_t ix = 0; ix < numWorks; ++ix) {
        bool moreToDo = workAllPlans(numResults, yieldPolicy);
        if (!moreToDo) {
            break;
        }
    }

问题C

如果 bad query选取和 good query一样的索引,是否还会有一样的问题呢

Mongodb查询时,可以借助于hint命令强制选取某一条索引来进行查询,比如上述的bad query加上.hint({_id:1}),就可以强制使用主键索引:

db.myColl.find({app:"my_app",requestTime:{$gte:1492502247000,$lt:1492588800000}}).sort({_id:-1}).limit(1).hint({_id:1})

然而,即使是这样,查询还是很慢,依然加上.explain('executionStats')看一下执行情况,解答问题A时已经对explain的结果做了些解释,所以这次着重看IXSCAN阶段的keysExamined

{
...
        "executionStages" : {
            "stage" : "LIMIT",
            ...
            "inputStage" : {
                "stage" : "FETCH",
                "filter" : {
                    "$and" : [
                        {
                            "app" : {
                                "$eq" : "my_app"
                            }
                        },
                        {
                            "requestTime" : {
                                "$lt" : 1492588800000
                            }
                        },
                        {
                            "requestTime" : {
                                "$gte" : 1492502247000
                            }
                        }
                    ]
                },
                "nReturned" : 1,
                ...
                "inputStage" : {
                    "stage" : "IXSCAN",
                    ...
                    "nReturned" : 32862524,
                    ...
                    "keysExamined" : 32862524,
                    ...
...
}

扫描了32862524条记录,依然很慢。这个现象比较好解释了,从executionStats.executionStages可以看到,加了hint的查询经历了LIMIT => FETCH => IXSCAN 这几个阶段,IXSCAN这个阶段返回了32862524条记录,被FETCH阶段过滤只剩下一条,所以有32862523条无效扫描,为什么会有这么多无效扫描呢?

这个和业务逻辑是相关的,requestTime时间戳是随时间增长的,主键_id也可以认为随时间增长的,所以按照主键索引倒序来,最开始被扫描的是最新的记录,最新的记录是满足"requestTime" : {"$gte" : 1492502247000}这个条件的,所以good query只需要满足"app" : {"$eq" : "my_app"}就会很快返回;

然而bad query的约束条件"requestTime" : {"$gte" : 1492502247000, "$lt" : 1492588800000}中的"$lt" : 1492588800000是无法被满足的,必须要把所有比1492588800000这个时间戳新的记录都扫描完了之后才会返回

苏先生提出了完美的解决方案:不使用_id来排序,而是使用request_time来进行排序。这样就会使用"requestTime" : -1这条索引,只需要进行"app" : {"$eq" : "my_app"}的过滤,也是milliseconds时间内完成查询。

总结

  • 搭建有效的线下调试环境是重现、解决问题的重要手段,例如之前重现zk问题时使用salt快速搭建本地集群
  • 维护开源产品不了解源码,或者没有找到看的有效入口,是很被动的,缺少定位解决问题的根本手段

参考

感谢林青大神在排查过程中提供的关键帮助。

相关实践学习
MongoDB数据库入门
MongoDB数据库入门实验。
快速掌握 MongoDB 数据库
本课程主要讲解MongoDB数据库的基本知识,包括MongoDB数据库的安装、配置、服务的启动、数据的CRUD操作函数使用、MongoDB索引的使用(唯一索引、地理索引、过期索引、全文索引等)、MapReduce操作实现、用户管理、Java对MongoDB的操作支持(基于2.x驱动与3.x驱动的完全讲解)。 通过学习此课程,读者将具备MongoDB数据库的开发能力,并且能够使用MongoDB进行项目开发。 &nbsp; 相关的阿里云产品:云数据库 MongoDB版 云数据库MongoDB版支持ReplicaSet和Sharding两种部署架构,具备安全审计,时间点备份等多项企业能力。在互联网、物联网、游戏、金融等领域被广泛采用。 云数据库MongoDB版(ApsaraDB for MongoDB)完全兼容MongoDB协议,基于飞天分布式系统和高可靠存储引擎,提供多节点高可用架构、弹性扩容、容灾、备份回滚、性能优化等解决方案。 产品详情: https://www.aliyun.com/product/mongodb
相关文章
|
6月前
|
监控 NoSQL MongoDB
【MongoDB 专栏】MongoDB 查询优化技巧
【5月更文挑战第10天】本文探讨了MongoDB查询优化技巧,包括合理使用索引、优化查询语句、数据模型简化、避免全表扫描、选择合适查询方法及监控性能。通过案例分析展示了优化策略及其效果,强调了持续优化对提升数据库性能和用户体验的重要性。掌握这些技巧能有效挖掘MongoDB的潜力。
251 2
【MongoDB 专栏】MongoDB 查询优化技巧
|
6月前
|
NoSQL 大数据 数据处理
MongoDB聚合框架与复杂查询优化:技术深度解析
【4月更文挑战第30天】本文深入探讨了MongoDB的聚合框架和复杂查询优化技术。聚合框架包含$match、$group、$sort和$project阶段,用于数据处理和分析,提供灵活性和高性能。优化查询涉及创建合适索引、使用聚合框架、简化查询语句、限制返回结果数、避免跨分片查询、只查询所需字段及使用$inc操作符。理解这些技术有助于提升MongoDB在大数据和复杂查询场景下的性能。
|
NoSQL 数据库 索引
MongoDB调优-查询优化-MongoDB Profiler
MongoDB调优-查询优化-MongoDB ProfilerMongoDB Profiler 概述官方文档:https://docs.mongodb.com/manual/tutorial/manage-the-database-profiler/index.html 熟悉 Mysql 的人应该知道,Mysql 是有个慢查询日志的,它可以帮助我们进行优化我们的 sql,并提高我们系统的稳定性和流畅性。
1719 0
|
1月前
|
存储 关系型数据库 MySQL
一个项目用5款数据库?MySQL、PostgreSQL、ClickHouse、MongoDB区别,适用场景
一个项目用5款数据库?MySQL、PostgreSQL、ClickHouse、MongoDB——特点、性能、扩展性、安全性、适用场景比较
|
2月前
|
存储 NoSQL 关系型数据库
非关系型数据库-MongoDB技术(二)
非关系型数据库-MongoDB技术(二)
|
2月前
|
NoSQL 关系型数据库 MongoDB
非关系型数据库-MongoDB技术(一)
非关系型数据库-MongoDB技术(一)
|
19天前
|
NoSQL Cloud Native atlas
探索云原生数据库:MongoDB Atlas 的实践与思考
【10月更文挑战第21天】本文探讨了MongoDB Atlas的核心特性、实践应用及对云原生数据库未来的思考。MongoDB Atlas作为MongoDB的云原生版本,提供全球分布式、完全托管、弹性伸缩和安全合规等优势,支持快速部署、数据全球化、自动化运维和灵活定价。文章还讨论了云原生数据库的未来趋势,如架构灵活性、智能化运维和混合云支持,并分享了实施MongoDB Atlas的最佳实践。
|
20天前
|
NoSQL Cloud Native atlas
探索云原生数据库:MongoDB Atlas 的实践与思考
【10月更文挑战第20天】本文探讨了MongoDB Atlas的核心特性、实践应用及对未来云原生数据库的思考。MongoDB Atlas作为云原生数据库服务,具备全球分布、完全托管、弹性伸缩和安全合规等优势,支持快速部署、数据全球化、自动化运维和灵活定价。文章还讨论了实施MongoDB Atlas的最佳实践和职业心得,展望了云原生数据库的发展趋势。
|
22天前
|
存储 NoSQL MongoDB
MongoDB 数据库引用
10月更文挑战第20天
13 1

相关产品

  • 云数据库 MongoDB 版