欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

关于mongo一些常见话题的深入

程序员文章站 2022-06-11 21:59:36
...

说明

全篇的测试数据(没有特别说明的情况下)基于新华词典,通过脚本重复生成了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}]

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

关于mongo一些常见话题的深入

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个字段参与。

索引不能被以下的查询使用:

  • 正则表达式及非操作符,如 nin,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也是非常合理的。