Mongo 性能调优

您所在的位置:网站首页 mongodb性能优化方案有哪些 Mongo 性能调优

Mongo 性能调优

2023-08-26 14:47| 来源: 网络整理| 查看: 265

前提

本篇适用于对 Mongo 有一定的使用经验,并希望更加深入了解的人群,并非入门系列,期待读者除基本的增删改查能力外,还应对索引有初步的认知。

另外,在没有特别说明的情况下,语句一般会执行在一个叫“dictionary”的集合中,集合数据来源于 GitHub 上开源的新华字典库,重复生成条数超过 100 万,文档模型大致如下:

字段名类型说明wordString简体字oldwordString繁体字strokesNumber笔画数radicalsString偏旁explanationString简要解释moreString详细解释

性能调优关注点组合索引的命中

数据库索引最容易理解的是单键索引,它用单独的一个字段建立索引,只要在查询语句中以有效的方式包含了该字段即可命中。但组合索引却并非随意命中其中某几个字段就可行的,组合索引的命中方式实际上遵循的是最左前缀匹配原则,即以最左边为起点任何连续的索引都能匹配上,且每匹配多一级就可能会减小一部分筛选范围。

例如存在一个索引为 a_1_b_1_c1,当我们查询 a、a+b 和 a+b+c 时都可以命中它,但b、b+c 和 c 则无法命中。

当然,命中索引并不一定会带来显著的性能提升,关键在于命中索引之后能否显著降低文档扫描数。

为了更好地理解组合索引的原理,可以结合一下生活中常见的地址场景。假设有一个集合存储了全国所有的地址信息,结果大致如下:

{ province: "广东省", city: "深圳市", district: "南山区", subDistrict: "粤海街道", community: "后海", ... } { province: "广东省", city: "深圳市", district: "南山区", subDistrict: "粤海街道", community: "南油", ... } { province: "广东省", city: "深圳市", district: "南山区", subDistrict: "西丽街道", community: "留仙", ... }

该集合创建了一个组合索引 province_1_city_1_district,我们希望查询南山区粤海街道的信息。

1. 直接查询粤海街道db.collection.find({ subDistrict: "粤海街道" })

由于没有命中索引,数据库的扫描范围是全国所有街道,效率低下。

2. 查询深圳市,粤海街道db.collection.find({ city: "深圳市", subDistrict: "粤海街道" })

由于最左前缀匹配原则的限制,该查询依然没有命中索引,还是只能扫描全国所有街道,查询效率相较于直接查询粤海街道反而稍慢,因为多出了一个匹配条件。

3. 查询广东省,粤海街道db.collection.find({ province: "广东省", subDistrict: "粤海街道" })

命中索引,扫描范围变成了广东省内的所有街道,效率有所提高。

4. 查询广东省,南山区,粤海街道db.collection.find({ province: "广东省", district: "南山区", subDistrict: "粤海街道" })

命中索引,但根据最左前缀匹配原则,扫描范围还是广东省,因为城市没有匹配,也即不连续,无法生效。相比于前面一条,效率上并没有得到提高。

5. 查询广东省,深圳市,南山区,粤海街道db.collection.find({ province: "广东省", city: "深圳市", district: "南山区", subDistrict: "粤海街道" })

命中索引,扫描范围缩小到南山区,除索引字段外的其它查询条件将只会建立在这个小范围内进行匹配,效率得到非常大的提升。

很显然,命中索引之后的结果集越小,性能就会越高,所以应该让组合索引尽量命中更多字段。

Mongo 执行计划

与大多数关系型数据库一样,Mongo 也为我们提供了 explain 方法用于分析一个语句的执行计划,通过执行计划我们可以了解到关于查询性能方面的很多信息,包括是否命中索引、如何命中索引、执行耗时、文档扫描数等,是性能优化最基本的分析手段。

explain 支持 queryPlanner(仅给出执行计划)、executionStats(给出执行计划并执行)和 allPlansExecution(前两种的结合)三种分析模式,默认使用的是 queryPlanner:

db.getCollection("word").find({ strokes: 5 }).explain() "queryPlanner" : { "parsedQuery" : { "strokes" : { "$eq" : 5.0 } }, "winningPlan" : { "stage" : "FETCH", "inputStage" : { "stage" : "IXSCAN", "keyPattern" : { "strokes" : 1.0 }, "indexName" : "strokes_1", "isMultiKey" : false, "indexBounds" : { "strokes" : [ "[5.0, 5.0]" ] } } }, "rejectedPlans" : [ { "stage" : "FETCH", "inputStage" : { "stage" : "IXSCAN", "keyPattern" : { "strokes" : 1.0, "pinyin" : 1.0 }, "indexName" : "strokes_1_pinyin_1" } } ] }

这个查询语句同时命中了两个索引:

strokes_1strokes_1_pinyin_1

Mongo 会通过优化分析选择其中一种更好的方案放置到 winningPlan,最终的执行计划是 winningPlan 所描述的方式。其它稍次的方案则会被放置到 rejectedPlans 中,仅供参考。

所以 queryPlanner 的关注点是 winningPlan,如果希望排除其它杂项的干扰,可以直接只返回 winningPlan 即可:

db.getCollection("word").find({ strokes: 5 }).explain().queryPlanner.winningPlan

winningPlan 中,总执行流程分为若干个 stage(阶段),一个 stage 的分析基础可以是其它 stage 的输出结果。从这个案例来说,首先是通过 IXSCAN(索引扫描)的方式获取到初步结果(索引得到的结果是所有符合查询条件的文档在磁盘中的位置信息),再通过 FETCH 的方式提取到各个位置所对应的完整文档。这是一种很常见的索引查询计划(explain 返回的是一个树形结构,实际先执行的是子 stage,再往上逐级执行父 stage)。

如果没有命中索引的话,winningPlan 就有明显的不同了:

db.getCollection("word").find({ word: "中" }).explain().queryPlanner.winningPlan { "stage" : "COLLSCAN", "filter" : { "word" : { "$eq" : "中" } }, "direction" : "forward" }

COLLSCAN 即集合扫描(Collection Scan)。

除了 queryPlanner 之外,还有一种非常有用的 executionStats 模式:

db.getCollection("word").find({ word: "中" }).explain('executionStats').executionStats { "executionSuccess" : true, "nReturned" : 62.0, "executionTimeMillis" : 1061.0, "totalKeysExamined" : 0.0, "totalDocsExamined" : 1000804.0, "executionStages" : { "stage" : "COLLSCAN", "filter" : { "word" : { "$eq" : "中" } }, "nReturned" : 62.0, "executionTimeMillisEstimate" : 990.0, "works" : 1000806.0, "advanced" : 62.0, "needTime" : 1000743.0, "needYield" : 0.0, "saveState" : 7853.0, "restoreState" : 7853.0, "isEOF" : 1.0, "invalidates" : 0.0, "direction" : "forward", "docsExamined" : 1000804.0 } }

总体上大同小异,一些关键字段可以了解一下:

nReturned:执行返回的文档数executionTimeMillis: 执行时间(ms)totalKeysExamined:索引扫描条数totalDocsExamined:文档扫描条数executionStages:执行步骤

在 executionStats 中,我们可以更好地了解执行的情况,但由于该模式会附带执行,如果对一个语句不够了解的话,建议先通过 queryPlanner 初步评估,再决定是先优化还是接着观察 executionStats 的状况。

Mongo 的 explain 也不仅可以应用于查询语句,通过 help 方法来获取帮助的同时,也能了解 explain 所支持的范围:

db.getCollection("word").explain().help() Explainable operations .aggregate(...) - explain an aggregation operation .count(...) - explain a count operation .distinct(...) - explain a distinct operation .find(...) - get an explainable query .findAndModify(...) - explain a findAndModify operation .group(...) - explain a group operation .remove(...) - explain a remove operation .update(...) - explain an update operation Explainable collection methods .getCollection() .getVerbosity() .setVerbosity(verbosity)

例如一个 count 查询可以这么执行分析:

(db.getCollection("word").explain('executionStats').count({ word: "中" })).executionStats线上数据库查询慢的问题定位

如果对数据库的性能没有一个好的概念,很容易生产出一些健美型项目,看起来五大三粗、威风凛凛,直到数据量达到一定的规模,它们就开始三步一喘了,健美选手的肌肉始终不如专业运动员厚实,撑不住真正的压力。

对于这些已经上线的性能隐患,Mongo 提供了很多好用的特性帮助我们快速进行定位,例如使用 Mongostat 查看实时的运行状况:

mongostat [--host {ip}:{port}] [-u {user} -p {password} --authenticationDatabase {dbName}]

大致可以获得如下的结果:

insert、delete、update 和 query 指示了该时段每秒执行的次数,可以粗略评估数据库压力。其它字段也可以作为参考,比如 vsize(占用多少兆的虚拟内存)、res(占用多少兆的物理内存)、net_in(入网流量)、net_out(出网流量)、conn(当前连接数)。

对于 conn,需要捎带一提。Mongo 为每个连接建立一条线程,线程创建、释放以及上下文切换的开销同样会体现在每一条连接上。通常客户端会维护一个连接池,所以 conn 的量应该是得到控制的,如果发现有异常则需要尽快排查优化。

通过下面的方式可以查看当前数据库的可用连接数:

db.serverStatus().connections { "current" : 6.0, "available" : 3885.0, "totalCreated" : 9.0 }

当然,除了可用连接数,db.serverStatus() 也能获取数据库的一些统计信息,有需要可以作为参考项。

如果觉得这些不够直白的话,也可以开启慢日志,在发现运行缓慢的时候通过分析慢日志很容易定位到问题。只要设置 Mongo Profiling 的级别即可开启:

db.setProfilingLevel(level, slowms)

关于 level,Mongo 支持三个级别:

0:默认,不开启命令记录1:记录慢日志,默认记录执行时间大于 100ms 的命令2:记录所有命令

slowms 指明了超过多少 ms 被认为是慢命令。

开启命令日志之后(仅作用于接受执行命令的 db),每一条命令的运行情况都会被当成一条文档插入该 db 的 system.profile 集合中,下面是剔除部分字段后的一条记录:

{ "op" : "query", "ns" : "dictionary.word", "query" : { "find" : "word", "filter" : { "strokes" : 5.0 }, "limit" : NumberInt(10), "batchSize" : NumberInt(4850) }, "keysExamined" : NumberInt(10), "docsExamined" : NumberInt(10), "fromMultiPlanner" : true, "cursorExhausted" : true, // 命令返回的文档数 "nreturned" : NumberInt(10), // 命令返回的字节数 "responseLength" : NumberInt(44810), "protocol" : "op_query", // 命令执行时间 "millis" : NumberInt(1), // 命令执行计划的简要说明 "planSummary" : "IXSCAN { strokes: 1.0 }", // 命令的执行时间点 "ts" : ISODate("2018-08-16T06:33:44.084+0000") }

字段见名思义,很是清晰。需要注意的是,该集合没有设置任何索引字段,也不允许自行建立索引,为了后期的查询效率,建议只开启慢日志记录,而非全命令。

除了慢日志分析之外,还可以直接获取当前数据库正在执行中的命令:

db.currentOp()

下面是剔除了无关命令以及部分字段的一条记录:

{ "inprog" : [ { // 该操作的id "opid" : 99080.0, // 已运行的描述 "secs_running" : 1.0, // 已运行的微秒数 "microsecs_running" : NumberLong(1088762), "op" : "query", "ns" : "dictionary.word", "query" : { "find" : "word", "filter" : { "pinyin" : { "$regex" : "z" } }, "batchSize" : 4850.0 }, "planSummary" : "IXSCAN { pinyin: 1, strokes: 1 }" } ] }

通过 currentOp 可以方便地查看当前数据库有哪些命令执行有异常,从而针对性做出优化。当然,它还有一个用途,比如某个天气晴朗的好日子,一个新来的临时工在生产上执行了一条不可描述的语句,将整个数据库给阻塞住了,线上相关项目停摆,大量用户热火朝天开始拨出投诉电话,就在大家火急火燎地接待解释时,优雅的你,只是随手执行了一下这个语句:

db.killOp(99080)

很好,一切恢复正常,继续喝茶聊天。

如何创建索引?

创建索引的语句如下:

db.collection.createIndex(keys[, options])

例如为 word 集合的 strokes 字段建立一个升序索引:

db.getCollection('word').createIndex({ strokes: 1 })

需要注意的是,这种索引创建方式会阻塞数据库的所有读写操作,直到索引建立完成。为线上已有大量数据的集合建立索引时,更合理的是使用速度相对较慢的后台创建方式:

db.getCollection('word').createIndex({ strokes: 1 }, { background: true })

以这种方式创建索引时,在创建期间数据库可以正常接受读写,如果需要了解创建进度,可以通过 currentOp 来查看:

db.currentOp() { "inprog" : [ { "opid" : 406436.0, "secs_running" : 2.0, "microsecs_running" : NumberLong(2619306), "query" : { "createIndexes" : "word", "indexes" : [ { "key" : { "strokes" : 1.0 }, "name" : "strokes_1", "background" : true } ] }, "msg" : "Index Build (background) Index Build (background): 439475/1000804 43%", "progress" : { "done" : 439476.0, "total" : 1000804.0 } } ], "ok" : 1.0 }索引的限制

索引的创建有一些正常场景下很难触发所以存在感很低的限制,了解一下是有必要的:

被索引的字段值最大不能超过 1024 个字节,否则会得到一个 KeyTooLong 的错误(尽管可以通过配置参数解除限制,但尽量不要这么做)。一个集合最多可以有 64 个索引。索引名称长度:包括数据库与集合名称总共不超过 125 字符。组合索引最多可以有 31 个字段参与。

索引采用 B 树结构存储,相较于时间复杂度为 O(1) 的哈希结构,B 树虽然查询效率为 O(logN),但它允许范围查询,这是哈希结构所无法做到的。理解了 B 树的本质也就可以理解为什么一些非操作无法命中索引的原因,明确来说,下面这些查询都是无法命中索引的,应当引起注意:

正则表达式及非操作符,如 $nin、$not 等;算术运算符,如 $mod 等;$where 子句。覆盖索引的查询

Mongo 的查询结果默认返回 _id 字段,正常使用的时候可能不会太去关注它,但有些情况下这个行为可能会让我们付出一些性能成本。例如接下来的案例,word 集合存在 strokes_1_pinyin_1 这个组合索引,如果我们希望查询笔画数为 6 的所有字的拼音,通常的查询语句是:

db.getCollection("word").find({ strokes: 6 }, { pinyin: 1 })

按照惯例分析一下执行状态:

{ "executionSuccess" : true, "nReturned" : 61628.0, "executionTimeMillis" : 3476.0, "totalKeysExamined" : 61628.0, "totalDocsExamined" : 61628.0, "executionStages" : { "stage" : "PROJECTION", "nReturned" : 61628.0, "executionTimeMillisEstimate" : 3461.0, "inputStage" : { "stage" : "FETCH", "nReturned" : 61628.0, "executionTimeMillisEstimate" : 3461.0, "inputStage" : { "stage" : "IXSCAN", "nReturned" : 61628.0, "executionTimeMillisEstimate" : 42.0, "keyPattern" : { "strokes" : 1.0, "pinyin" : 1.0 }, "indexName" : "strokes_1_pinyin_1" } } } }

该语句在 Mongo 中的执行分为三个步骤,总耗时是 3476ms:

在内存中扫描索引,得到所有笔画数为 6 的记录,耗时 42ms;从磁盘中拉取上一个步骤中已经确定位置的文档,耗时 3461ms;执行字段映射,得到 pinyin 和 _id 两个字段(_id 默认会返回)。

由于 FETCH 的文档有 61628 条,考虑到读取磁盘文件本身的速度,这个耗时似乎是无可厚非的?不急,我们把查询语句稍微变下:

db.getCollection("word").find({ strokes: 6 }, { _id: 0, pinyin: 1 }).explain("executionStats").executionStats

相比于之前,这个查询只是简单地把 id 字段去掉了,会发生什么神奇的事情呢?

{ "executionSuccess" : true, "nReturned" : 61628.0, "executionTimeMillis" : 56.0, "totalKeysExamined" : 61628.0, "totalDocsExamined" : 0.0, "executionStages" : { "stage" : "PROJECTION", "nReturned" : 61628.0, "executionTimeMillisEstimate" : 46.0, "inputStage" : { "stage" : "IXSCAN", "nReturned" : 61628.0, "executionTimeMillisEstimate" : 23.0, "keyPattern" : { "strokes" : 1.0, "pinyin" : 1.0 }, "indexName" : "strokes_1_pinyin_1" } } }

结果产生了质的飞跃,FETCH 步骤不见了,总耗时直接降到 56ms。

这实际上是一种覆盖索引的行为,当查询的字段与返回的字段都在一个组合索引中的时候,数据库不需要去磁盘抓取完整的文档即可返回。

_id 字段很少会被建立在组合索引中,所以它的默认返回可能会干扰到数据库对于覆盖索引的优化,这一点是值得注意的。

当然,不推荐只是为了去除 _id 就使用字段映射,除非这么做可以带来较大的效益,因为这个步骤同样是存在耗时的。

文档排序

很多人误认为 Mongo 用 _id 字段创建了一个默认索引,所以当我们进行查询时,返回来的结果是以 _id 排好序的,实际上不是。

Mongo 返回的文档顺序与查询时的扫描顺序一致,扫描顺序则分为集合扫描和索引扫描。集合扫描的顺序与该集合的文档在磁盘上的存储顺序相同,所以有时会巧合地发现集合扫描返回的文档顺序跟 ObjectId 有关,这纯属误解。

什么时候会采用集合扫描?

在我们不加任何查询条件或者使用非索引查询时,Mongo 会直接扫描集合的全部文档,也即集合扫描。

什么时候会采用索引扫描?

当我们以一个索引字段去查询时,Mongo 不会直接加载磁盘中的文档,而是先以索引值进行匹配,得到所有能初步匹配的文档位置之后,再根据情况决定是否去磁盘拉取对应的文档或者继续下一个阶段。因为索引本身是排好序的,所以扫描的规律自然也是跟着索引的顺序走的。

例如我们有下面的一个集合(test):

{ "first" : 1.0, "second" : 3.0 } { "first" : 2.0, "second" : 2.0 } { "first" : 3.0, "second" : 1.0 } { "first" : 2.0, "second" : 1.0 }

我们为它创建一个 first_1 索引,再通过 first 字段进行查询:

db.getCollection('word').find({ first: { $gt: 0 } })

返回的结果是 first 有序的:

{ "first" : 1.0, "second" : 3.0 } { "first" : 2.0, "second" : 2.0 } { "first" : 2.0, "second" : 1.0 } { "first" : 3.0, "second" : 1.0 }

如果我们需要使用 first 查询,但却能以 second 排序呢?正常来说是使用下面的查询语句:

db.getCollection('test').find({ first: { $gt: 0 } }).sort({ second: 1 })

很不幸,这需要使用到内存排序,所以会经过这些阶段。

IXSCAN:通过 first_1 索引扫描出相关文档的位置FETCH:根据前面扫描到的位置抓取完整文档SORT_KEY_GENERATOR:获取每一个文档排序所用的键值SORT:进行内存排序,最终返回有序结果

在内存中即时排序的效率是极低的,且当被排序的结果集大于 32MB 时,Mongo 还会返回错误。

这该怎么优化呢?尝试建立一个组合索引 first_1_second_1 可行吗?将 second 也加入排序返回来的结果不就正好有序了?并非如此,组合索引的排序与字段的前后有关,first_1_second_1 这个索引会优先确保 first 有序,在first相同时,这些相同的文档间则以 second 为排序依据,所以该组合索引优先确保的是 first 有序,如果需要结果集中是 second 有序的,则要求该结果集的 first 值都相同。

既然这样,我们可否创建一个 second_1_first_1 索引呢?命中这个索引的话,返回的结果集就已经是以 second 排好序了,所以后面的 sort 也可以干脆不需要了?想法是对的,但执行还是有点问题:

db.getCollection('test').find({ first: { $gt: 0 } }).explain('executionStats').executionStats

通过分析计划可以发现,它并不会命中 second_1_first_1 索引:

{ "executionSuccess" : true, "nReturned" : 4.0, "executionTimeMillis" : 4.0, "totalKeysExamined" : 0.0, "totalDocsExamined" : 4.0, "executionStages" : { "stage" : "COLLSCAN", "filter" : { "first" : { "$gt" : 0.0 } }, "nReturned" : 4.0, } }

这在前面的组合索引命中规则中有提及,没有命中第一个索引字段 second 的话,就整个索引都不会命中。办法还是有的,我们先尝试把 sort 字段加回去,为了更好地发现下一个问题,将条件稍作改变再分析一下:

db.getCollection('test').find({ first: { $gte: 2 } }).sort({ second: 1 }).explain('executionStats').executionStats { "executionSuccess" : true, "nReturned" : 3.0, "executionTimeMillis" : 0.0, "totalKeysExamined" : 4.0, "totalDocsExamined" : 4.0, "executionStages" : { "stage" : "FETCH", "filter" : { "first" : { "$gte" : 2.0 } }, "nReturned" : 3.0, "inputStage" : { "stage" : "IXSCAN", "nReturned" : 4.0, "keyPattern" : { "second" : 1.0, "first" : 1.0 }, "indexName" : "second_1_first_1", } } }

加了 sort 之后又可以命中索引了,怎么回事呢?这实际上是 Mongo 的一个优化行为,sort 字段也可以参与索引的匹配。

只是存在一个问题,这条命令最终返回三个文档,但它不像我们命中 first_1 索引一样,只扫描命中的部分,而是先扫描 second,由于这里 second 只是作为排序来使用,没有明确的值,所以 second 字段有多少记录就会扫描多少个键,基本等同于集合扫描,唯一的优化点在于不需要内存排序(相较于集合扫描之后还要进行排序略有优势)。

如果也将 second 加入查询条件(范围搜索),结果就会好多了。

看起来 Mongo 的索引确实不如预期的灵活,但如果把场景变换一下,就会发现好用多了。例如,我们需要查 first 为一个确定值的记录,并希望按照 second 排序,那么 first_1_second_1 就完全满足需要了,first 用于索引,second 用于排序(强调一下,前提是 first 为一个定值)。

用一个更具体的例子描述一下,比如有一个存储用户行为的集合:

{ userId: 'blurooo', action: 'sleep', timestamp: ISODate('2018-08-16T23:00:00.0000') } { userId: 'blurooo', action: 'wake', timestamp: ISODate('2018-08-16T07:30:00.0000') }

需要查询某个用户一天的所有行为,按照行为发生的时间排序。建立一个 userId_1_timestamp_1 索引,查询时只要以 userId 为条件即可:

db.getCollection('user_action').find({ userId: 'blurooo' })

不需要二次排序,返回的结果自然是时间有序的。

从数据库高效查询的角度来说,如果查询语句有排序参与,那么期待执行计划至少不要出现 SORT 这个阶段。但并非 SORT 没有出现就算高效的,基于索引的排序也有性能之分:

db.getCollection("word").find({ strokes: 5 }).sort({ _id: 1 }).explain('executionStats').executionStats

由于返回的文档顺序与索引字段 _id\ 没有任何关系,通过 _id 去排序尽管会用到索引,但实际上需要扫描全文档,效率较低(当然,如果不用索引字段来排,受限于内存,可能根本就没法执行成功):

{ "nReturned" : 33356.0, "executionTimeMillis" : 1261.0, "totalKeysExamined" : 1000804.0, "totalDocsExamined" : 1000804.0 }

如果排序跟返回的文档顺序有关联效率就完全不同了:

db.getCollection("word").find({ strokes: 5 }).sort({ strokes: -1 }).explain('executionStats').executionStats { "nReturned" : 33356.0, "executionTimeMillis" : 83.0, "totalKeysExamined" : 33356.0, "totalDocsExamined" : 33356.0 }

所以说,在有排序需求的场景中,索引的创建最好是结合排序字段,以达到最优的执行效率。

索引的占用空间对性能影响

在这种磁盘白菜价的年代讨论空间大小似乎有点不合时宜,索引文件不也是存在于磁盘的吗?占用空间稍微大点不碍事吧?并非如此,索引虽然也是持久化在磁盘中的,但为了确保索引的速度,实际上需要将其加载到内存中使用,讨论索引的占用空间其实也是在讨论内存的占用空间。当然了,数据库服务器内存动辄百 G 计的土豪朋友请忽略这个话题。

索引依赖于内存,当内存不足以承载所有索引的大小时,就会出现内存 - 磁盘交换的情况,从而大大地降低索引的性能。所以在创建索引时,也应该评估好内存状态。可通过下面的方式获取某一个集合总的索引大小(bytes):

db.collection.totalIndexSize()29642752

由于索引是直接以被索引的字段值为键的,当出现一个需求场景允许选择多种索引方案的其中一种时,在内存的层面上看,选择字段值相对较小的方案是更划算的。

说说 _id 字段

Mongodb 默认为每一个文档创建了 _id 字段,并作为索引存在,其值为一个 12 字节的 ObjectId 类型:

ObjectId = 4 个字节的 unix 时间戳 + 3 个字节的机器信息 + 2 个字节的进程 id + 3 个字节的自增随机数ObjectId 的生成方式可以借鉴在很多有分布式 id 生成需要的场景中。

清楚了 ObjectId 的生成方式,我们可以很方便地将它与时间戳互转,例如使用 Javascript:

// 日期转ObjectId function timeToObjectId(date) { let seconds = Math.floor(date.getTime() / 1000); // 将时间戳(s)转化为十六进制,再补充16个0 return seconds.toString(16) + '0000000000000000'; } // ObjectId转时间,Mongo本身可以直接获取 ObjectId("5b72c9169db571c8ab7ee374").getTimestamp();

ObjectId 的构成特性给我们带来了一些额外的思考,关于文档维护一个 createTime 字段是否有必要?这实际上需要视场景而定。一个时间戳是 8 个字节,一个 ObjectId 是 12 字节,在不缺磁盘空间的今天,增加一个 createTime 不会带来多少负担,但可以更直观地观察到文档的创建时间,如果创建时间需要被展示到业务场景中,每次通过 ObjectId 去转换也是相当吃力不讨好。更进一步,如果有按照创建时间建立组合索引的用途,时间戳也要比 ObjectId 节省近 30% 的内存使用量。当然,如果业务场景不关注创建时间,仅仅需要获取某个时间段创建的记录,那么只使用 ObjectId 也是非常合理的(ObjectId 仅代表文档在数据库中的创建时间,并不一定是文档真实的创建时间,例如文档迁移导致 ObjectId 变化等场景,就非得创建一个 createTime 不可)。

Mongo 常见的执行步骤

常见的有:

COLLSCAN 文档扫描IXSCAN 索引扫描FETCH 检索文档SORT 内存排序COUNT 位于根节点,计算子节点所返回的条目COUNT_SCAN 基于索引的统计PROJECTION 字段映射OR $or 查询子句全部命中索引时出现LIMIT 结果集条数限制SKIP 从结果集中跳过部分条目IDHACK _id 查询$or 查询的优化

当我们使用 $or 查询时,Mongo 的优化是有限的,常见的有下面两种:

1. 查询子句分离db.getCollection("word").find({$or: [{ strokes: 13 }, { pinyin: 'á' }]}, { _id: 0, pinyin: 1 }).explain("executionStats").executionStats { "executionSuccess" : true, "nReturned" : 71052.0, "executionTimeMillis" : 100.0, "totalKeysExamined" : 71114.0, "totalDocsExamined" : 0.0, "executionStages" : { "stage" : "SUBPLAN", "nReturned" : 71052.0, "executionTimeMillisEstimate" : 68.0, "inputStage" : { "stage" : "PROJECTION", "nReturned" : 71052.0, "executionTimeMillisEstimate" : 68.0, "inputStage" : { "stage" : "OR", "nReturned" : 71052.0, "executionTimeMillisEstimate" : 32.0, "dupsTested" : 71114.0, "dupsDropped" : 62.0, "inputStages" : [ { "stage" : "IXSCAN", "nReturned" : 62.0, "executionTimeMillisEstimate" : 0.0, "keyPattern" : { "pinyin" : 1.0, "strokes" : 1.0 }, "indexName" : "pinyin_1_strokes_1", }, { "stage" : "IXSCAN", "nReturned" : 71052.0, "executionTimeMillisEstimate" : 0.0, "keyPattern" : { "strokes" : 1.0, "pinyin" : 1.0 }, "indexName" : "strokes_1_pinyin_1" } ] } } } }

这条 OR 查询被 Mongo 分离为两条独立的查询子句,并分别命中 pinyin_1_strokes_1 和 strokes_1_pinyin_1 两个索引,两个查询的结果由 STAGE_OR 进行合并去重(结果集较大时也是一笔不小的消耗),dupsDropped 表示的是结果集的重合条数。

但要注意的是,除非所有 OR 查询子句都可以命中索引,否则 Mongo 只会执行全文档扫描。

2. 查询子句合并db.getCollection("word").find({$or: [{ strokes: 13 }, { strokes: 12 }]}, { _id: 0, pinyin: 1 }).explain("executionStats").executionStats { "executionSuccess" : true, "nReturned" : 178560.0, "executionTimeMillis" : 233.0, "totalKeysExamined" : 178561.0, "totalDocsExamined" : 0.0, "executionStages" : { "stage" : "SUBPLAN", "nReturned" : 178560.0, "executionTimeMillisEstimate" : 205.0, "inputStage" : { "stage" : "PROJECTION", "nReturned" : 178560.0, "executionTimeMillisEstimate" : 205.0, "inputStage" : { "stage" : "IXSCAN", "nReturned" : 178560.0, "executionTimeMillisEstimate" : 135.0, "keyPattern" : { "strokes" : 1.0, "pinyin" : 1.0 }, "indexName" : "strokes_1_pinyin_1", "indexBounds" : { "strokes" : [ "[12.0, 12.0]", "[13.0, 13.0]" ], "pinyin" : [ "[MinKey, MaxKey]" ] } } } } }

这种情况基本等同于使用 $in 来查询,仅限于查询子句本身可以命中索引的情况下才会出现该优化。

除了这两种简单的优化之外,$or 在组合索引的使用上有一些需要注意的地方。

依然采用前面提到的地址索引这个场景来举例:

1. $or 子句全部命中索引db.collection.find({ $or: [{ province: "广东省" }, { province: "福建省" }], city: "深圳市", district: "南山区", subDistrict: "粤海街道" })

理想来说,这应该是可以全匹配命中 province_1_city_1_district 这个索引的,但由于引入了 $or,会导致只能命中 province(查询子句分离优化),也即范围还是在省,剩下的三个条件则作为 fetch 阶段的匹配依据使用。

正确的方式应该是改为 $in:

db.collection.find({ province: { $in: ["广东省", "福建省"] }, city: "深圳市", district: "南山区", subDistrict: "粤海街道" })2. $or 子句没有全部命中索引db.collection.find({ province: "广东省", $or: [{ city: "深圳市" }, { city: "广州市" }], district: "南山区", subDistrict: "粤海街道" })

是否又直觉认为这应该能全匹配命中 province_1_city_1_district 索引?抱歉,也是不行,由于存在 $or 操作,Mongo 首先会判断 $or 的查询子句是否全都能命中索引,此处明显不能,于是接着寻找另一个执行计划,发现存在 province 字段可以命中 province_1_city_1_district 索引,而 city 由于藏身在 $or 中,没法被利用,所以最终的扫描范围依然是省这个级别。

数据分批处理

Mongo 的 skip 操作本身是通过查找并一行行跳过来实现的,在文档数足够的情况下,最小的扫描数是 skip+limit(没有查询条件时最少),可想而知,在数据量大的时候这个过程会相当耗时,例如获取下面的 100 条数据实际上需要扫描 100 万 + 100 次:

db.getCollection("word").find().sort({ _id: 1 }).skip(1000000).limit(100)

实际上利用 id 可以得到有效的改善,大概流程是,第一次获取到的结果集中,提取最后一条记录的 _id 字段,作为下一次查询的条件:

// 首次正常查询,但使_id有序 db.getCollection("word").find().sort({ _id: 1 }).limit(100) // 下一页用 _id 过滤掉前面的结果,命中 _id 索引,结果已经有序,所以 sort 实际上也可以不需要 db.getCollection("word").find({ _id: { $gt: lastId } }).sort({ _id: 1 }).limit(100)

在没有任何查询条件时,性能达到最高,文档扫描数等于 limit,当有查询条件时,文档扫描数则取决于什么时候凑够 limit 条数据。

尽管理论上可行,但这仅限于连续的分页请求,如果需要随机获取某一页的数据就不太可行了,因为获取不到该页之前的最后一个 _id。

落实到实际场景中,客户端读取数据大致存在两种分页方式:

第一种方式是允许任意页数跳跃的,可以通过缓存每一页的最后一个 _id 来实现,但从另一个角度出发,通常情况下,并不会有人关注若干页之后的数据,所以采用 skip 也很难遇到什么性能瓶颈。

第二种方式则是天然连续的,非常契合 _id 的优化方案。

除了客户端的场景之外,也存在一些批处理任务需要主动去获取数据,例如每天为满足条件的所有用户增加积分,则完全可以采用 _id 扫描的方式来实现,而不是依靠 skip。

使用聚合

聚合(aggregate)是 Mongo 相对高级的应用,利用它可以在数据库层面上实现数据统计、转换等操作。

例如一个最简单的例子,统计笔画数为 10 的字有多少个:

db.getCollection("word").aggregate([{ $match: { strokes: 10 } }, { $group: { _id: 1, count: { $sum: 1 } } }])

下面举个比较有代表性的案例来感受下聚合功能的强大。

假如有一个集合,其存储的文档类似于下面:

{ from: "user_1", to: "user_2", text: "毕竟西湖六月中", time: ISODate("2018-10-01T00:02:12.000") } { from: "user_2", to: "user_1", text: "风光不与四时同", time: ISODate("2018-10-01T10:12:33.000") } { from: "user_2", to: "user_3", text: "车遥遥,马憧憧,君游东山东复东", time: ISODate("2018-10-01T19:44:21.000") }

希望提供一个接口可以获取某两个用户之间最近一个月的聊天时间分布情况,以早上 5 点 30 分到晚上 7 点为白天,其余时间为夜晚,按照这两个区间分别统计白天和夜晚的聊天数,期待的结果类似于下面:

[{ date: "2018-10-01", daytime: 20, night: 150 }, { date: "2018-10-02", daytime: 13, night: 240 }, ...]

如果不用聚合,通常只能先查找出两个用户最近一个月的所有聊天记录,再用代码逻辑进行分析统计,不仅消耗数据库资源,处理出来也是非常累赘,改用聚合就可以像下面这样:

db.getCollection("chat").aggregate([{ // 第一个管道匹配出最近一个月两个人的所有聊天记录,可以覆盖索引 $match: { from: { $in: ["user_1", "user_2"] }, to: { $in: ["user_1", "user_2"] }, time: { $gte: ISODate("2018-10-01T00:00:00.000") } } }, { $project: { date: { // 将 time 字段转换为类似于 2018-10-01 的形式 $dateToString: { format: "%Y-%m-%d", date: "$time" } }, hourAndMinute: { // 提取时间的时分两个值,转换为类似于 14:20 的形式 $dateToString: { format: "%H:%M", date: "$time" } } } }, { $group: { _id: "$date", daytime: { $sum: { $cond: { if: { $and: [{ $gte: ["$hourAndMinute", "05:30"] }, { $lte: ["$hourAndMinute", "19:00"] }] }, then: 1, else: 0 } } }, night: { $sum: { $cond: { if: { $and: [{ $gte: ["$hourAndMinute", "05:30"] }, { $lte: ["$hourAndMinute", "19:00"] }] }, then: 0, else: 1 } } } } }])

值得注意的是,在使用聚合时,每个管道默认允许占用的内存不超过 100M,否则会抛出错误。所以绝大多数情况下我们都应该从一个 match 管道开始,将操作的文档首先收缩到一个合理的范围内(允许命中索引),以确保 Mongo 可以高效地完成后续的统计变换等步骤。有必要的话还可以指定使用磁盘来缓存结果,避免内存耗尽无法完成(这只是一个容错的方案,并非一个好的方案)。

db.collection.aggregate(pipelines, { allowDiskUse: true })



【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3