首页 » MongoDB实战 » MongoDB实战全文在线阅读

《MongoDB实战》7.3 查询优化

关灯直达底部

查询优化是识别慢查询、找出它们为什么慢、逐步让它们变快的过程。本节里,我们会依次看到查询优化过程中的每一步,当你读完本节之后,基本就能找出MongoDB里所有的问题查询了。

在深入之前,我必须提醒一下,本节出现的技术不能解决所有查询的性能问题。慢查询的原因千奇百怪,糟糕的应用程序设计、不恰当的数据模型、硬件配置不够都是常见的原因,处理这些问题要耗费大量时间。此处我们会看到通过重新组织查询以及构建有效的索引来进行优化的方法。我还将介绍其他途径,以便你在上述手段不奏效时尝试一下。

7.3.1 识别慢查询

如果感觉基于MongoDB的应用程序变慢了,那么就该着手剖析查询语句了。任何严谨的应用程序设计方法中都应该包含对查询语句的审核;考虑到MongoDB中这一切都是如此简单,没有理由不这么做。虽然每个应用程序对查询语句的要求各有不同,但可以保守地进行假设:对于大多数应用而言,查询都不该超过100 ms。这个假设被固化在了MongoDB的日志里,无论什么操作(包括查询在内),只要超过100 ms就会输出一条警告。因此,要识别慢查询,第一时间就该看日志。

到目前为止,我们的数据集都很小,无法生成执行时间超过100 ms的查询。所以随后的例子里,我们将使用一组由NASDAQ日汇总数据组成的数据集。如果你也希望能执行这些查询,需要将它们放到本地数据库里。要导入它,首先从http://mng.bz/ii49下载压缩包,然后将其解压到一个临时文件夹里。你将看到如下输出:

$ unzip stocks.zipArchive: stocks.zip   creating: dump/stocks/  inflating: dump/stocks/system.indexes.bson  inflating: dump/stocks/values.bson  

最后,用以下命令将数据还原到数据库里:

$ mongorestore -d stocks -c values dump/stocks  

股票数据集很大,而且方便使用。针对某个NASDAQ上市股票的子集,有从1983年开始25年的数据,每天一个文档,记录每日的最高价、最低价、收盘价和成交量。有了如此数量的集合文档,很容易就能生成一条日志警告。试着查询第一条谷歌股价:

db.values.find({"stock_symbol": "GOOG"}).sort({date: -1}).limit(1)  

你会注意到这条查询执行了一段时间。如果查看MongoDB的日志,会看到预期中的慢查询警告。下面就是一段示例输出:

Thu Nov 16 09:40:26 [conn1] query stocks.values          ntoreturn:1 scanAndOrder reslen:210 nscanned:4308303          { query: { stock_symbol: "GOOG" }, orderby: { date: -1.0 } }          nreturned:1 4011ms  

其中包含大量信息,在讨论explain时,我们会研究其中所有内容的含义。现在,如果仔细阅读这段消息,应该能抽取出最重要的部分:这是针对stocks.values的查询;执行的查询选择器包含匹配stock_symbol以及排序;最关键的可能是这个查询花了4 s(4011 ms)之久。

一定要设法处理这样的警告。它们太关键了,值得你时不时地筛查MongoDB的日志。可以通过grep轻松实现筛查:

grep -E '([0-9])+ms' mongod.log  

如果100 ms的阈值太高了,可以通过--slowms服务器选项降低这个值。要是把慢查询定义为执行时间超过50 ms,那么用--slowms 50来启动mongod

当然,筛查日志还不够彻底。你可以通过日志检查慢查询,但这个过程太粗糙了,应该将其作为预发布或生产环境中的一种“健康检查”。要在那些慢查询成为问题之前识别它们,你需要一个更精确的工具,MongoDB内置的查询剖析器正是你所需要的。

使用剖析器

要识别慢查询,离不开MongoDB内置的剖析器。剖析功能默认是关闭的,让我们先把它打开。在MongoDB Shell中,输入以下命令:

use stocksdb.setProfilingLevel(2)  

先选择要剖析的数据库,因为剖析总是针对某个特定数据库的。随后将剖析级别设置为2,这是最详细的级别;它告诉剖析器将每次的读和写都记录到日志里。还有一些其他选项。若只要记录慢(100 ms)操作,可以将剖析级别设置为1。要彻底禁用剖析器,将级别设置为0。如果只想在日志里记录耗时超过一定毫秒阈值的操作,可以像下面这样将毫秒数作为第二个参数:

use stocksdb.setProfilingLevel(1, 50)  

一旦开启了剖析器,就可以执行查询了。让我们再运行一条对股票数据库的查询,找出数据集中最高的收盘价:

db.values.find({}).sort({close: -1}).limit(1)  

剖析结果会保存在一个特殊的名为system.profile的固定集合里。你是否还记得,固定集合的大小是确定的,数据会像环一样写入其中,一旦集合达到最大尺寸,新文档会覆盖最早的文档。system.profile被分配了128 KB,因此确保剖析数据不会消耗太多资源。

你可以像查询任何固定集合那样查询system.profile。举例来说,查询所有耗时超过150 ms的语句:

db.system.profile.find({millis: {$gt: 150}})  

因为固定集合保持了自然插入顺序,可以用$natural操作符进行排序,以便先显示最近的结果:

db.system.profile.find.sort({$natural: -1}).limit(5)  

回到刚才的查询语句,结果集里应该会有大致这样一条内容:

{ "ts" : ISODate("2011-09-22T22:42:38.332Z"),"op" : "query", "ns" : "stocks.values","query" : { "query ":{}, "orderby " : { "close" : -1 } },"ntoreturn" : 1, "nscanned" : 4308303, "scanAndOrder" : true,"nreturned" : 1, "responseLength" : 194, "millis" : 14576,"client" : "127.0.0.1", "user" : "" }  

又是一条慢查询:耗时将近15 s!除了执行时间,其中还包含所有在MongoDB慢查询警告中出现查询的信息,足够进行更深一步的排查了,而下一节里就会讲到这个话题。

但在继续之前,还得再说一些与剖析策略有关的内容。先使用较粗的设置,然后不断细化,用这种方式来使用剖析器就挺不错的。首先保证没有查询超过100 ms,然后将阈值降低到75 ms,以此类推。开启剖析器之后,你会想把应用程序测一遍,最起码把每个读写操作都执行一遍。如果考虑的周到一些,就必须在真实条件下执行那些操作,数据大小、查询负载和硬件都应该能代表应用程序的生产环境。

查询剖析器十分有用,但要将它发挥到极致,你还得有条理。比起生产环境,最好能在开发过程中找到慢查询,不然补救的成本会大很多。

7.3.2 分析慢查询

有了MongoDB的剖析器,可以很方便地找到慢查询。要知道这些查询为什么慢会更麻烦一点,因为这个过程中可能还要求有点“侦察工作”。正如前文所述,慢查询的原因是多种多样的。走运的话,加个索引就能解决慢查询。在更复杂的情况里,可能不得不重新安排索引、重建数据模型,或者升级硬件。但是,总是应该先看看最简单的情况,本节就与此相关。

最简单的情况里,问题的根本原因是缺少索引、索引不当或者查询不理想。可以在慢查询上运行explain来确认原因。现在,让我们来了解下具体的做法。

1. 使用并了解EXPLAIN

MongoDB的explain命令提供了关于指定查询路径的详细信息。1 让我们仔细地看一看,对上一节里运行的最后一条查询执行explain能收集到什么信息。要在Shell中运行explain,只需在查询后附上explain方法调用:

1. 可以回忆一下我在第2章中介绍的explain,当时只是简单地介绍。本节我将提供完整的命令说明及其输出。

db.values.find({}).sort({close: -1}).limit(1).explain{  "cursor" : "BasicCursor",  "nscanned" : 4308303,  "nscannedObjects" : 4308303,  "n" : 1,  "scanAndOrder" : true,  "millis" : 14576,  "nYields" : 0,  "nChunkSkips" : 0,  "indexBounds":{}}  

millis字段指出该查询耗时超过14 s,其原因很明显。请看nscanned的值,它表明查询引擎必须扫描4 308 303个文档才能完成查询。现在,在values集合上运行count

db.values.count4308303  

扫描的文档数与集合中的文档总数一致,也就是说执行了一次全集合扫描。如果你希望查询返回集合里的全部文档,这倒不是一件坏事。但是如果仅需返回一个文档,正如explain中的n所示,那这就成问题了。一般来说,希望n的值与nscanned的值尽可能接近。在进行集合扫描时,情况往往不是这样的。cursor字段指明你在使用BasicCursor,这只能说明在扫描集合本身而非索引。

scanAndOrder字段进一步解释了查询缓慢的原因,当查询优化器无法使用索引来返回排序结果集时,它就会出现。因此,本例中不仅查询引擎需要扫描集合,还要求手动对结果集进行排序。

如此之差的性能是无法接受的,好在应对之道比较简单。你只需要在close字段上构建一个索引。现在就动手,然后重新发起查询:2

2. 注意,索引的构建可能需要几分钟。

db.values.ensureIndex({close: 1})db.values.find({}).sort({close: -1}).limit(1).explain{  "cursor" : "BtreeCursor close_1 reverse",  "nscanned" : 1,  "nscannedObjects" : 1,  "n" : 1,  "millis" : 0,  "nYields" : 0,  "nChunkSkips" : 0,  "indexBounds" : {    "close" : [      [        {          "$maxElement" : 1        },        {          "$minElement" : 1        }      ]    ]  }}  

差距太大了!这次的查询处理只用了不到1 ms。通过cursor字段可以看到,正在使用名为close_1的索引上的BtreeCursor,而且在倒序迭代索引。在indexBounds字段里,可以看到特殊值$maxElement$minElement,它们说明查询横跨了整个索引。此时查询优化器经过B树的最右边才找到最大键,然后再沿路返回。因为限制了返回集为1,在找到了最大元素后查询就完成了。当然,由于索引是有序保存索引项的,就没有必要再进行scanAndOrder所指定的手工排序了。

如果在查询选择器中使用了经过索引的键,就会看到输出中有些许不同之处。来看看查询收盘价大于500的查询语句的explain输出:

> db.values.find({close: {$gt: 500}}).explain{  "cursor" : "BtreeCursor close_1",  "nscanned" : 309,  "nscannedObjects" : 309,  "n" : 309,  "millis" : 5,  "nYields" : 0,  "nChunkSkips" : 0,  "indexBounds" : {    "close" : [      [        500,        1.7976931348623157e+308      ]    ]  }}  

扫描的文档数仍然与返回的文档数相同(nnscanned是一致的),这是理想状态。请注意,在索引边界的指定方式上,此处与前者有所不同。这里没有使用$maxElement$minElement键,边界是实际值。下限是500,上限实际是无限大。这些值必须和正在查询的值使用相同的数据类型;在查询的是数字,所以这里的索引边界是数字。如果要查询一系列字符串,那么边界就是字符串。3

3. 如果觉得这无法理解,请回忆一下,某个指定索引能包含多种数据类型的键。因此,查询结果总是会被限制在查询所使用的数据类型中。

在继续之前,请自己在查询上运行explain,要注意nnscanned之间的不同。

2. MongoDB的查询优化器与hint

查询优化器是MongoDB中的一部分,如果存在可用的索引,它会为给定查询选择一个最高效的索引。在为查询选择理想的索引时,查询优化器使用了一套相当简单的规则:

  1. 避免scanAndOrder。如果查询中包含排序,尝试使用索引进行排序;

  2. 通过有效的索引约束来满足所有字段——尝试对查询选择器里的字段使用索引;

  3. 如果查询包含范围查找或者排序,那么对于选择的索引,其中最后用到的键需能满足该范围查找或排序。

如果某个索引能满足以上所有这些条件,那么它就会被视为最佳索引并予以使用。要是有多个最佳索引,则任意选择其一。可以遵循这条经验:如果能为查询构建最优索引,查询优化器的工作能更轻松些。为此,请尽力而为。

让我们来看一个查询,它完全满足索引(和查询优化器)。回顾股票数据集,假设要执行如下查询,获取所有大于200的谷歌收盘价:

db.values.find({stock_symbol: "GOOG", close: {$gt: 200}})  

该查询的最优索引同时包含这两个键,但其中把close键放在最后以便执行范围查询:

db.values.ensureIndex({stock_symbol: 1, close: 1})  

如果执行查询,会看到这两个键都被用到了,索引边界也和预想的一样:

db.values.find({stock_symbol: "GOOG", close: {$gt: 200}}).explain{  "cursor" : "BtreeCursor stock_symbol_1_close_1",  "nscanned" : 730,  "nscannedObjects" : 730,  "n" : 730,  "millis" : 1,  "nYields" : 0,  "nChunkSkips" : 0,  "isMultiKey" : false,  "indexOnly" : false,  "indexBounds" : {    "stock_symbol" : [      [        "GOOG",        "GOOG"      ]    ],    "close" : [      [        200,        1.7976931348623157e+308      ]    ]  }}>  

这是本条查询的最优explain输出:nnscanned的值相同。现在再来考虑一下没有索引能完美运用于查询之上的情况。例如,没有{stock_symbol: 1,close: 1}索引,但是在那两个字段上分别建有索引。通过getIndexKeys列出索引,会看到:

db.values.getIndexKeys[ { "_id ":1},{"close ":1},{"stock_symbol ":1}]  

因为查询中同时包含stock_symbolclose两个键,没有很明显的索引可用。这时就该查询优化器出马了,它所用的试探方式比想象的要简单得多,完全基于nscanned的值。换言之,优化器会选择扫描索引项最少的索引。查询首次运行时,优化器会为每个可能有效适用于该查询的索引创建查询计划,随后并行运行各个计划4,nscanned值最低的计划胜出。优化器会停止那些长时间运行的计划,将胜出的计划保存在来,以便后续使用。

4. 严格地说,这些计划是交错在一起的。

你可以发起查询并运行explain来查看实际的过程。首先,删除复合索引{stock_symbol: 1,close: 1},在这些键上构建单独的索引:

db.values.dropIndex("stock_symbol_1_close_1")db.values.ensureIndex({stock_symbol: 1})db.values.ensureIndex({close: 1})  

true作为参数传递给explain方法,这能将查询优化器尝试的计划列表包含在输出里。输出见代码清单7-1。

代码清单7-1 用explain(true)查看查询计划

db.values.find({stock_symbol: "GOOG", close: {$gt: 200}}).explain(true){   "cursor" : "BtreeCursor stock_symbol_1",   "nscanned" : 894,   "nscannedObjects" : 894,   "n" : 730,   "millis" : 8,   "nYields" : 0,   "nChunkSkips" : 0,   "isMultiKey" : false,   "indexOnly" : false,   "indexBounds" : {     "stock_symbol" : [       [         "GOOG",         "GOOG"       ]     ]   },   "allPlans" : [     {       "cursor" : "BtreeCursor close_1",       "indexBounds" : {         "close" : [           [             100,             1.7976931348623157e+308           ]         ]       }     },   {     "cursor" : "BtreeCursor stock_symbol_1",     "indexBounds" : {       "stock_symbol" : [         [           "GOOG",           "GOOG"         ]       ]     }   },   {     "cursor" : "BasicCursor",     "indexBounds" : {     }   } ]}  

你马上能发现查询计划选择了{stock_symbol: 1}索引来实现查询。输出的下方,allPlans键指向一个列表,其中还包含了两个额外的查询计划:一个使用{close: 1}索引,另一个用BasicCursor扫描集合。

优化器拒绝集合扫描的原因显而易见,但不选择{close :1}索引的原因却不明显。可以通过hint找到答案,hint能强迫查询优化器使用某个特定索引:

query = {stock_symbol: "GOOG", close: {$gt: 100}}db.values.find(query).hint({close: 1}).explain{  "cursor" : "BtreeCursor close_1",  "nscanned" : 5299,  "n" : 730,  "millis" : 36,  "indexBounds" : {    "close" : [      [        200,        1.7976931348623157e+308      ]    ]  }}  

nscanned的值是5299,这比之前扫描的894项要多得多,完成查询的时间也证实了这一点。

剩下的就是要理解查询优化器是如何缓存它所选择的查询计划,并让其过期的。毕竟,你不会希望优化器对每条查询都并行运行所有计划。

在发现了一个成功的计划之后,会记录下查询模式(query pattern)、nscanned的值以及索引说明。针对刚才的查询,所记录的结构是这样的:

{ pattern:{stock_symbol:'equality',close: 'bound'},   index:{stock_symbol:1},   nscanned:894}  

查询模式记录下了每个键的匹配类型,你正请求对stock_symbol的精确匹配(相等),对close的范围匹配(边界)5。只要新的查询匹配此模式,就会使用该索引。

5. 也许你会对此感兴趣,共有三种范围匹配类型:上界(upper)、下界(lower)以及上下界(upper-and-lower)。查询模式还包含各种排序。

但这一信息不应该是永久的,实际情况也是如此。在发生以下事件之后优化器会自动让计划过期。

  • 对集合执行了100次写操作。

  • 在集合上增加或删除了索引。

  • 虽然使用了缓存的查询计划,但工作量大于预期。此处,“工作量大”的标准是nscanned超过缓存的nscanned值的10倍。

发生最后一种事件时,优化器会立即开始交错执行其他查询计划,也许另一个索引会更高效。

7.3.3 查询模式

此处列举了几种常见的查询模式,以及它们所使用的索引。

1. 单键索引

要讨论单键索引,请回忆一下为股票集合的收盘价创建的索引{close: 1},该索引能用于以下场景。

  • 精确匹配

举例来说,要精确匹配所有收盘价是100的条目:

db.values.find({close: 100})  
  • 排序

可以对被索引字段排序。例如:

db.values.find({}).sort({close: 1}) 

本例中的排序没有查询选择器,除非真的打算迭代整个集合,否则你可能会希望再增加一个限制。

  • 范围查询

针对某个字段进行范围查询,在同一字段上带不带排序都可以。例如,查询所有大于或等于100的收盘价:

db.values.find({close: {$gte: 100}})  

如果对同一个键增加排序子句,优化器仍能使用相同的索引:

db.values.find({close: {$gte: 100}}).sort({close: 1})  

2. 复合键索引

复合键索引稍微复杂一点,但它们的用法与单键索引类似。有一点要牢记,针对每个查询,复合键索引只能高效适用于单个范围或排序。仍然是股价的例子,想象一个三复合键索引{close: 1,open: 1,date: 1},可能会有以下几种场景。

  • 精确匹配

精确匹配第一个键、第一和第二个键,或者第一、第二和第三个键,按照这个顺序:

db.values.find({close: 1})db.values.find({close: 1, open: 1})db.values.find({close: 1, open: 1, date: "1985-01-08"})  
  • 范围匹配

精确匹配任意一组最左键(包含空),随后对其右边紧邻的键进行范围查询或者排序。于是,以下所有的查询对于该三键索引而言都是十分理想的:

db.values.find({}).sort({close: 1})db.values.find({close: {$gt: 1}})db.values.find({close: 100}).sort({open: 1})db.values.find({close: 100, open: {$gt: 1}})db.values.find({close: 1, open: 1.01, date: {$gt: "2005-01-01"}})db.values.find({close: 1, open: 1.01}).sort({date: 1})  

3. 覆盖索引

如果你从未听说过覆盖索引(covering index,也称索引覆盖),那么从一开始就要意识到这个术语并不恰当。覆盖索引不是一种索引,而是对索引的一种特殊用法。如果查询所需的所有数据都在索引自身之中,那就可以说索引能覆盖该查询。覆盖索引查询也称仅使用索引的查询(index-only query),因为不用引用被索引文档本身就能实现这些查询,这能带来性能的提升。

MongoDB中能很方便地使用覆盖索引,简单地选择存在于单个索引里的字段集合,排除掉_id字段(因为这个字段几乎不会出现在正使用的索引中)。下面这个例子里用到了上一节创建的三复合键索引:

db.values.find({open: 1}, {open: 1, close: 1, date: 1, _id: 0})  

如果对它执行explain,你会看到其中标识为indexOnly的字段被设为了true。这说明查询结果是由索引而非实际集合数据提供的。

查询优化总是针对特定应用程序的,但是我希望本节的理念和技术能帮助你更好地调整查询。通过观察和实验进行调整总是行之有效的方法。要养成习惯剖析并解释你的查询,在此过程中,你会了解查询优化器鲜为人知的一面,并能保证应用程序的查询性能。