越过大山和mongoDB查询操作的坑

本周在2亿数据集上跑mapreduce(以下简称MR)和aggreation framework(AF)计算,但处理的速度非常慢。本文从索引入手寻找解决之道,针对这类问题进行简单的分析、实验。


1. 索引

1.1 索引基础知识

首先我们看下官方文档上的一个例子来了解索引的原理。索引存放着集合指定的字段(一个或者多个)并依据升序/降序方式排序,如下图users集合中的score字段就作为集合的一个索引,按照升序排列着。这样当查询score值时就能在索引中找到文档条目直接跳转到目标文档的位置,这能使得查找速度提高几个数量级。

不使用索引的查询称为全表查询,这个处理过程和我们在一本没有索引的书中查找信息很像。现在我们来测试一下有无索引查询字段的过程和速度。

1.2 单索引实例

我们以文档评分集合为例来分析索引在查询中的作用。用例数据结构大致如下,我们会建立索引的字段有score(用户的打分)以及mail.date(提交时间)。

{
    "_id" : ObjectId("562ba634ef2109c32a3e3ca5"),
    "mail" : {
        "date" : ISODate("2015-10-15T17:11:58.000+08:00"),
        "receivedDate" : ISODate("2015-10-24T23:39:32.069+08:00"),
        "subject" : "ab"
    },
    "score" : 0
}

我们查询score分数大于0的值。首先我们不建立索引查询一次。这里我们需要引入explain()函数来查看执行查询过程中所做的事情。在mongo中输入 'db.mails.find({ 'score': { $gt: 0 }}).explain()' 。其结果如下图:

从上图中我们可以得到几个比较重要的字段:

  • 'cursor':表示查询的游标类型,分为BasicCursor(全表搜索的原始游标)、BtreeCursor(使用索引搜索的btree游标)、GeoSearchCursor (使用地理空间索引)、Complex Plan(多个索引之间的结合使用)
  • 'isMultikey':表示是否使用多值索引(数组字段)的布尔值
  • 'n':表示查询返回的文档数,或者说是匹配查询条件的文档数
  • 'nscannedObjects': 实际查询的文档数
  • 'nscanned':表明查询扫描了集合中多少个文档(无使用索引)/索引条目数(使用索引)
  • 'nscannedObjectsAllPlans':所有查询计划的查询文档数,mongoDB的查询计划可以看文档的详细解释
  • 'nscannedObjectsAllPlans':所有查询计划的查询扫描数
  • 'scanAndOrder':表明是否对返回的结果是否排序的布尔值,当直接使用索引的顺序返回结果时其值为false
  • 'indexOnly':表示是否只有索引即可完成查询的布尔值,当查询的字段都存在一个索引中并且返回的字段也在同一索引中即为true
  • 'nYields': 表示查询暂停的次数。这是由于mongoDB的其他操作使得查询暂停,使得这次查询放弃了读锁以等待写操作的执行。
  • 'nChunkSkips':表示的略过的文档数量,当在分片系统中的正在进行的块移动时会发生。
  • 'millis': 执行的毫秒数
  • 'indexBounds': 索引的使用情况,即文档中key的上下界。
  • 'server':表示服务器名
  • 'filterSet':表示是否应用了索引过滤的布尔值
  • 'stats':
  • 'allPlans':存放所有查询计划的数组,当设置参数为true/1时才打印出来
  • 'oldPlan':存放前次查询计划的数据,当设置参数为true/1时才打印出来

    上面3个重要的文档数量指标的关系为:nscanned >= nscannedObjects >= n,也就是扫描数(也可以说是索引条目) >= 查询数(通过索引到硬盘上查询的文档数) >= 返回数(匹配查询条件的文档数)。这里我们看到mongoDB总共扫描了27249条文档并返回了1297条文档数据。

    而运行这条查询(db.mails.find({ 'score': { $gt: 0 }}))大概花了0.366秒,那么接下来我们看看使用索引的结果。

    使用 'db.mails.ensureIndex({ 'score': 1 })' 将score字段依据升序来创建索引。使用索引之后的查询过程是什么样子呢?我们再通过.explain()看一下。

    其nscannedObjects和nscanned都缩小至1297,也就是实际返回的文档数。这也表明查询通过索引减少了大量的迭代过程。而这条查询(db.mails.find({ 'score': { $gt: 0 }}))的时间也减少至 0.242 秒。可见通过索引提高了我们这种多值查询的效率。我们再回到1.1,索引的本质是树(B树),最小的值在最左边的叶子上,最大的值在最右边的叶子上。这种数据结构能够让查找数据、循序存取、插入数据及删除的动作,都在对数时间内完成。

    1.3 复合索引(compound index)

    索引的值是按照一定顺序排列的。因此在使用索引对文档排序是非常快。然而当我们需要对两个字段排序时,单索引就无法满足了。这样就引入复合索引,复合索引是建立在多个字段上的索引。既然扩展到多个字段,那么不同的方向对生成的索引就有影响了。比如{ 'score': 1, 'mail.date': -1 }与{ 'score': 1, 'mail.date': 1 }就是不一样的索引。当然,这只对需要多字段条件排序是,其方向才显得比较重要。复合索引还具有双重功能,对不同的查询可以表现为不同的索引。比如{ 'score': 1, 'mail.date': -1 }即可以对两个字段排序,也可以对{ 'score': 1 }进行排序。如果只是基于单一键进行排序,MongoDB可以简单地从相反方向读取索引。比如这个索引就可以对{ 'score': -1 }有效。

    在接下来的场景中我们需要查询score大于0的文档并且还获取最近10天的文档。那么查询语句也就应该这么写 'db.mails.find({ 'score': { $gt: 0}, 'mail.date': { $gt: ISODate("2015-12-18T0000:00.000+08:00") } }).sort({ 'mail.date': -1})'。那么在现有的索引下的查询过程如下图。

    我们可以看到,由于只对score建了索引,查询依据第一个条件('score': { $gt: 0})扫描到1297个文档,然而通过第二个条件('mail.date': { $gt: ISODate("2015-12-18T0000:00.000+08:00") })得到81个匹配条件的文档。其运行的时间有0.276秒。为了使实验能有意思,我创建了单独的索引 { 'mail.date': 1 },和复合索引 { 'score': 1, 'mail.date': -1 }、{ 'score': 1, 'mail.date': 1 }、{ 'mail.date': 1, 'score': 1 }、{ 'mail.date': 1, 'score': -1 }。其.explain()的返回结果如下。

    其'cursor'为'BtreeCursor mail.date1score1 reverse', reverse的意思是查询结果是以'mail.date'倒序返回,所以将索引反向。再观察我们发现其nscanned的值并不理想,扫描了索引的5723个条目,当然通过复合索引还是减少了文档的扫描数。那么我们通过传递.explain()参数true来查看其他query plan的过程。在'allPlans'数组中总共有6个plan,第一个就是mail.date1score1 reverse。第二个为'mail.date1score-1'其与上一次差不多也是查询了索引的5723个条目。而第三个为'mail.date1 reverse'也就是{mail.date: 1 }索引的反向,结果如下图。

    其'nscanned'、'nscannedObjects'、'n'数目比较有意思,我们试着解读一下:mongoDB通过索引找到了83个条目,然后依据索引在硬盘上找到了82条文档记录,但是最后与查询条件匹配发现没有一条满足查询条件的(这里很可能是score字段没有满足)。为什么会这样呢?这与mongoDB的查询优化器的工作有关。摘自《MongoDB权威指南(第2版)》:

    基本来说,如果一个索引嫩够精确匹配一个查询,那么查询优化器就会使用这个索引。不然的话,可能会有几个索引都适合你的查询。MongoDB会从这些坑内的索引子集中卫每次查询计划选择一个,这些查询计划是并行执行的。最早返回100个结果的就是胜者,其他的查询计划就会被终止。explain()输出的信息里的“allPlans”字段显示了本次查询尝试的每个查询计划。

    也就是说,基于索引'mail.date_1 reverse'的查询在没有完成其查询计划时,就被终止了。其记录保留终止前所处理的数据量。让我们把这个索引查询跑完,通过.hint()加上索引来指定使用某个索引。我们运行 'db.mails.find({ 'score': { $gt: 0}, 'mail.date': { $gt: ISODate("2015-12-18T0000:00.000+08:00") }}).sort({ 'mail.date': -1}).hint({ 'mail.date': -1 }).explain(true)'的结果为下图:

    这样就发现了,通过这个索引扫描的文档比原来的多得多,特别是在消耗硬盘读写的地方也增加到了6198。当然,在这次数据量的测试集上,使用不同索引的速度比较还是不太明显。两者都接近于0.3秒左右。其他四个索引的处理过程也大致如此,成为落选者。

    1.4 索引交集(index intersection)

    索引交集是2.6版本新添加进来的功能。通过这个功能,mongoDB可以使用多个索引之间的交集来处理查询。像我们前面所说的,mongoDB中的索引 { 'score': 1, 'mail.date': -1 } 只能查询score的顺序或者对两个字段进行查询,但是不能使用索引对 'mail.date'字段进行查询和排序。但是如果我们建立两个索引 { 'score': 1 }、{ 'mail.date': -1 },这两个索引即可以单独使用,也可以在一个查询中使用。值得注意的是索引在查询排序上的限制,当排序需要完全与查询条件的索引时是无法使用索引交集的。

    我们还是以前面的样例上进行测试。首先,将复合索引都进行删除,只保留{ 'mail.date': 1}、{ score: 1 }。并进行原始查询语句('db.mails.find({ 'score': { $gt: 0}, 'mail.date': { $gt: ISODate("2015-12-18T0000:00.000+08:00") }}).sort({ 'mail.date': -1})')的运行。其采用的索引还是score_1,但是在allPlans中还是有Complex Plan的cursor,只不过其查询索引的数目才到1379条,比不上单索引罢了。其实我们需要记住一点就是:Sometimes, one index is more selective than another. But it doesn't mean that it returns more quickly the result.(有时,一个认真挑选的索引并不意味着就能更快地返回结果)。Lelouchzqy讲的一个例子就说:虽然对'name'字段进行所能能过滤掉很多文档,但是最终还是需要针对整个查询语句进行数据比较。而针对'date'可能会导致索引的条目太多,但是其后的'name'匹配会相对更加简单。

2. 项目中遇到的坑

为什么我前面这样介绍索引呢?因为索引是一切后续操作的前导,就是前两篇文章讲到的MR和AF操作就需要先进行查询操作。我在进行AF计算测试时,尝试将一个月的数据进行统计(大约1387w条数据),程序无法跑通,总是报getmore error:

Error: command failed: {
        "errmsg" : "exception: getMore: cursor didn't exist on server, possible restart or timeout?",  
        "code" : 13127,
        "ok" : 0
}

这个错误发生的原因是游标(cursor,返回查询结果的指针)的“超时销毁”机制。游标是通过迭代来遍历结果的。当游标完成结果的迭代时,它会清除自身。而“超时销毁”就是当游标没有完成迭代,但是超过10分钟内没有使用的情形下(进入stale),游标被强制销毁。这种机制当然是我们希望的:

极少有应用程序希望用户花费数分钟坐在那里等待结果。

但是也会遇到问题。就像我们这里情况的一样:当数据量太大时,主机无法在10分钟内完成当次的计算任务,使得游标被回收。mongoDB返回这个错误,使得程序无法执行。而出现这个原因也是因为提取较大的子数据集时,查询不使用会更快。因为索引需要进行两次查找:一次是查找索引条目,一次是根据索引指针去查找相应的文档。

这时我们就只能通过实验来判断是否需要使用索引了。我们采用前两篇的执行逻辑来寻聚合操作。实验结果如下表:

我们可以得出:在当前环境下,没有索引的查询速度还是比有索引的情况要快。如果若要给一个参考值的话,可以应用权威指南里的内容:这个数据可能会在2%~60%之间变动。

最后说明一下为什么文章名称有个mongoDB的坑。这是因为在寻找解决方法的过程中,发现mongoDB的官方文档语焉不详。而很多问题最后一般也会导向mongoDB的bug提交讨论站,问题也就分为mongoDB不支持和mongoDB在新版本中添加(修复)了这个功能。比如cursor timeout的自定义在新版本上才能使用。在抱怨过后还是继续提高自己的知识水平。


references:

  1. explain()结果分析

  2. 复合索引的测试调优追踪

  3. mongoDB cursor timeout相关问题讨论