关于mongo一些常见话题的深入
说明
全篇的测试数据(没有特别说明的情况下)基于新华词典,通过脚本重复生成了100万条记录,集合名为”word”,集合文档模型大致如下:
{
"_id" : ObjectId("5b72c9169db571c8ab7ee375"),
"word" : "吖",
"oldword" : "吖",
"strokes" : NumberInt(6),
"pinyin" : "ā",
"radicals" : "口",
"explanation" : "喊叫天~地。\n 形容喊叫的声音高声叫~~。\n\n 吖ā[吖啶黄](-dìnghuáng)〈名〉一种注射剂。\n ────────────────—\n \n 吖yā 1.呼;喊。",
"more" : "吖 a 部首 口 部首笔画 03 总笔画 06 吖2\nyā\n喊,呼喊 [cry]\n不索你没来由这般叫天吖地。--高文秀《黑旋风》\n吖\nyā\n喊声\n则听得巡院家高声的叫吖吖。--张国宾《合汗衫》\n另见ā\n吖1\nā\n--外国语的音译,主要用于有机化学。如吖嗪\n吖啶\nādìng\n[acridine] 一种无色晶状微碱性三环化合物c13h9n,存在于煤焦油的粗蒽馏分中,是制造染料和药物(如吖啶黄素和奎吖因)的重要母体化合物\n吖1\nyā ㄧㄚˉ\n(1)\n喊叫天~地。\n(2)\n形容喊叫的声音高声叫~~。\n郑码jui,u5416,gbkdfb9\n笔画数6,部首口,笔顺编号251432\n吖2\nā ㄚˉ\n叹词,相当于呵”。\n郑码jui,u5416,gbkdfb9\n笔画数6,部首口,笔顺编号251432"
}
常见话题
1. 执行分析
与大多数关系型数据库一样,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_1
+ strokes_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的方式提取到各个位置所对应的文档。这是一种很常见的索引查询计划。
如果没有命中索引的话,winningPlan就有明显的不同了:
db.getCollection("word").find({ word: '陈' }).explain().queryPlanner.winningPlan
{
"stage" : "COLLSCAN",
"filter" : {
"word" : {
"$eq" : "陈"
}
},
"direction" : "forward"
}
COLLSCAN即全文档扫描。
除了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
除以上提到的stage,还有一些其它的stage也会经常遇见,例如SORT(内存排序),COUNT_SCAN(基于索引的统计),COUNT(全文档统计)。
从数据库高效查询的角度来说,如果查询语句有排序参与,那么期待执行计划至少不要出现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
}
所以说,在有排序需求的场景中,索引的创建最好是结合排序字段,以达到最优的执行效率。
2. 线上数据库查询慢的问题定位
如果对数据库的性能没有一个好的概念,很容易生产出一些健美型项目,看起来三大五粗,好不威风,直到数据量达到一定的规模它们就开始三步一喘了,如果到这个时候还没有及时排除隐患,最终的结果自然就是项目运行崩溃,健美选手的肌肉始终不如专业运动员厚实,撑不住真正的压力。
基于这些问题,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)
很好,一切恢复正常,继续喝茶聊天。
3. 查询返回的文档顺序
很多人误认为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: { $ne: null } })
返回的结果是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: { $ne: null } }).sort({ second: 1 })
但很不幸,这需要使用到内存排序,所以会经过这些阶段:
1. IXSCAN:通过first_1索引扫描出相关文档的位置
2. FETCH:根据前面扫描到的位置抓取完整文档
3. SORT_KEY_GENERATOR:获取每一个文档排序所用的键值
4. SORT:进行内存排序,最终返回结果
效率自然极低,该怎么优化呢?尝试建立一个复合索引first_1_second_1可行否?将second也加入排序返回来的结果不就正好有序了吗?真不是,复合索引的排序与字段的前后有关,first_1_second_1这个索引会优先确保first有序,在first相同时,这些相同的文档间则以second为排序依据,所以复合索引的第一个字段才是真正决定文档间如何排序的依据。
既然这样,我们可否创建一个second_1_first_1索引呢?命中这个索引的话,返回的结果就已经是以second排好序了吧?所以后面的sort也可以干脆不需要了?想法是对的,但执行还是有点问题:
db.getCollection('test').find({ first: { $ne: null } }).explain('executionStats').executionStats
通过分析计划可以看到,根本不会命中second_1_first_1索引:
{
"parsedQuery" : {
"first" : {
"$lte" : 2.0
}
},
"winningPlan" : {
"stage" : "COLLSCAN",
"filter" : {
"first" : {
"$lte" : 2.0
}
},
"direction" : "forward"
},
"rejectedPlans" : [
]
}
这又是怎么回事呢?又回到复合索引字段前后的问题了,mongo的索引有点局限,要求使用复合索引时必须命中第一个字段,这一点可能会是很多人使用mongo索引的误区。
当然,办法也是有的,我们先尝试把sort字段加回去,为了更好地发现下一个问题,将条件稍作改变再分析一下:
db.getCollection('test').find({ first: { $lte: 2 } }).explain('executionStats').executionStats
{
"executionSuccess" : true,
"nReturned" : 3.0,
"executionTimeMillis" : 0.0,
"totalKeysExamined" : 4.0,
"totalDocsExamined" : 4.0,
"executionStages" : {
"stage" : "FETCH",
"filter" : {
"first" : {
"$lte" : 2.0
}
},
"nReturned" : 3.0,
"executionTimeMillisEstimate" : 0.0,
"docsExamined" : 4.0,
"inputStage" : {
"stage" : "IXSCAN",
"nReturned" : 4.0,
"executionTimeMillisEstimate" : 0.0,
"works" : 5.0,
"advanced" : 4.0,
"needTime" : 0.0,
"needYield" : 0.0,
"saveState" : 0.0,
"restoreState" : 0.0,
"isEOF" : 1.0,
"invalidates" : 0.0,
"keyPattern" : {
"second" : 1.0,
"first" : 1.0
},
"indexName" : "second_1_first_1",
"isMultiKey" : false,
"multiKeyPaths" : {
"second" : [
],
"first" : [
]
},
"isUnique" : false,
"isSparse" : false,
"isPartial" : false,
"indexVersion" : 2.0,
"direction" : "forward",
"indexBounds" : {
"second" : [
"[MinKey, MaxKey]"
],
"first" : [
"[MinKey, MaxKey]"
]
},
"keysExamined" : 4.0,
"seeks" : 1.0,
"dupsTested" : 0.0,
"dupsDropped" : 0.0,
"seenInvalidated" : 0.0
}
}
}
加了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' })
不需要二次排序即已满足需要。
4. 如何创建索引?
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
}
5. 索引的限制
索引的创建有一些正常场景下很难触发所以存在感很低的限制,了解一下是有必要的:
+ 被索引的字段值最大不能超过1024个字节,否则会得到一个KeyTooLong的错误(尽管可以通过配置参数解除限制,但尽量不要这么做)。
+ 一个集合最多可以有64个索引。
+ 索引名称长度:包括数据库与集合名称总共不超过125字符。
+ 联合索引最多可以有31个字段参与。
索引不能被以下的查询使用:
- 正则表达式及非操作符,如 not, 等。
- 算术运算符,如 $mod, 等。
- $where 子句
6. 索引的占用空间对性能影响
在这种磁盘白菜价的年代讨论空间大小似乎有点不合时宜,索引文件不也是存在磁盘的吗?占用空间稍微大点不碍事吧?nono,索引虽然也是持久化在磁盘中的,但为了确保索引的速度,实际上需要将其加载到内存中使用,讨论索引的占用空间其实也是在讨论内存的占用空间。当然了,数据库服务器内存动辄T计的土豪朋友请忽略这个话题。
索引依赖于内存,当内存不足以承载所有索引的大小时,就会出现内存 - 磁盘交换的情况,从而大大地降低索引的性能。所以在创建索引时,也应该评估好内存状态。可通过下面的方式获取某一个文档总的索引大小(bytes):
db.collection.totalIndexSize()
29642752
由于索引是直接以被索引的字段值为键的,当出现一个需求场景允许选择多种索引方案的其中一种时,在内存的层面上看,选择字段值相对较小的方案是更划算的。
7. 说说_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也是非常合理的。