MongoDB全方位知识图谱!
导语 | MongoDB是一个强大的分布式存储引擎,天然支持高可用、分布式和灵活设计。MongoDB的一个很重要的设计理念是:服务端只关注底层核心能力的输出,至于怎么用,就尽可能的将工作交个客户端去决策。这也就是MongoDB灵活性的保证,但是灵活性带来的代价就是使用成本的提升。与MySql相比,想要用好MongoDB,减少在项目中出问题,用户需要掌握的东西更多。本文致力于全方位的介绍MongoDB的理论和应用知识,目标是让大家可以通过阅读这篇文章之后能够掌握MongoDB的常用知识,具备在实际项目中高效应用MongoDB的能力。本文既有MongoDB基础知识也有相对深入的进阶知识,同时适用于对MonogDB感兴趣的初学者或者希望对MongoDB有更深入了解的业务开发者。
一、前言
以下是笔者在学习和使用MongoDB过程中总结的MongoDB知识图谱。本文将按照一下图谱中依次介绍MongoDB的一些核心内容。由于能力和篇幅有限,本文并不会对图谱中全部内容都做深入分析,后续将会针对特定条目做专门的分析。同时,如果图谱和内容中有错误或疏漏的地方,也请大家随意指正,笔者这边会积极修正和完善。
本文按照图谱从以下3个方面来介绍MongoDB相关知识:
基础知识:主要介绍MongoDB的重要特性,No Schema、高可用、分布式扩展等特性,以及支撑这些特性的相关设计
应用接入:主要介绍MongoDB的一些测试数据、接入方式、spring-data-mongo应用以及使用Mongo的一些注意事项。
进阶知识:主要介绍MongoDB的一些核心功能的设计实现,包括WiredTiger存储引擎介绍、Page/Chunk等数据结构、一致性/高可用保证、索引等相关知识。
二、基础知识
MongoDB是基于文档的NoSql存储引擎。MongoDB的数据库管理由数据库、Collection(集合,类似MySql的表)、Document(文档,类似MySQL的行)组成,每个Document都是一个类JSON结构BSON结构数据。
MongoDB的核心特性是:No Schema、高可用、分布式(可平行扩展),另外MongoDB自带数据压缩功能,使得同样的数据存储所需的资源更少。本节将会依次介绍这些特性的基本知识,以及MongoDB是如何实现这些能力的。
(一)No Schema
MongoDB是文档型数据库,其文档组织结构是BSON(Binary Serialized Document Format) 是类JSON的二进制存储格式,数据组织和访问方式完全和JSON一样。支持动态的添加字段、支持内嵌对象和数组对象,同时它也对JSON做了一些扩充,如支持Date和BinData数据类型。正是BSON这种字段灵活管理能力赋予了Mongo的No Schema或者Schema Free的特性。
No Schema特性带来的好处包括:
强大的表现能力:对象嵌套和数组结构可以让数据库中的对象具备更高的表现能力,能够用更少的数据对象表现复杂的领域模型对象。
便于开发和快速迭代:灵活的字段管理,使得项目迭代新增字段非常容易。
降低运维成本:数据对象结构变更不需要执行DDL语句,降低Online环境的数据库操作风险,特别是在海量数据分库分表场景。
MongoDB在提供No Schema特性基础上,提供了部分可选的Schema特性:Validation。其主要功能有包括:
规定某个Document对象必须包含某些字段。
规定Document某个字段的数据类型$type(MongoDB中$开头都是关键字)
规定Document某个字段的取值范围:可以是枚举$in,
或 者 正 则$ ,或者正则是$regex。
上面的字段包含内嵌文档的,也就是说,你可以指定Document内任意一层JSON文件的字段属性。validator的值有两种,一种是简单的JSON Object,另一种是通过关键字$jsonSchema指定。以下是简单示例,想了解更多请参考官方文档:MongoDB JSON Schema详解
(https://www.docs4dev.com/docs/zh/mongodb/v3.6/reference/reference-operator-query-jsonSchema.html)
方式一:
db.createCollection("saky_test_validation",{validator:
{
$and:[
{name:{$type: "string"}},
{status:{$in:["INIT","DEL"]}}]
}
})
方式二:
db.createCollection("saky_test_validation", {
validator: {
$jsonSchema: {
bsonType: "object",
required: [ "name", "status", ],
properties: {
name: {
bsonType: "string",
description: "must be a string and is required"
},
status: {
enum: [ "INIT", "DEL"],
description: "can only be one of the enum values and is required"
}
} }})
(二)MongoDB的高可用
高可用是MongoDB最核心的功能之一,相信很多同学也是因为这一特性才想深入了解它的。那么本节就来说下MongoDB通过哪些方式来实现它的高可用,然后给予这些特性我们可以实现什么程度的高可用。
相信一旦提到高可用,浮现在大家脑海里会有如下几个问题:
是什么:MongoDB高可用包括些什么功能?它能保证多大程度的高可用?
为什么:MongoDB是怎样做到这些高可用的?
怎么用:我们需要做些怎样的配置或者使用才能享受到MongoDB的高可用特性?
那么,带着这些问题,我们继续看下去,看完大家应该会对这些问题有所了解了。
MongoDB高可用的基础是复制集群,复制集群本质来说就是一份数据存多份,保证一台机器挂掉了数据不会丢失。一个副本集至少有3个节点组成:
有且仅有一个主节点(Primary):负责整个集群的写操作入口,主节点挂掉之后会自动选出新的主节点。
一个或多个从节点(Secondary):一般是2个或以上,从主节点同步数据,在主节点挂掉之后可被选举成新的主节点。
零个或1个仲裁节点(Arbiter):这个是为了节约资源或者多机房容灾用,只负责主节点选举时投票不存数据,保证能有节点获得多数赞成票。
从上面的节点类型可以看出,一个三节点的复制集群可能是PSS或者PSA结构。PSA结构优点是节约成本,但是缺点是Primary挂掉之后,一些依赖 majority(多数)特性的写功能出问题,因此一般不建议使用。
复制集群确保数据一致性的核心设计是:
Journal日志:Journal日志是MongoDB的预写日志WAL,类似MySQL 的redo log,然后100ms一次将Journal日志刷盘。当然触发机制还有其它场景,这里仅仅是讨论异常场景下可能丢失多长时间的数据。更多详细的解释可以参考MongoDB的两种日志journal与oplog。
Oplog:Oplog是用来做主从复制的,类似MySql里的binlog。MongoDB 的写操作都由Primary节点负责,Primary节点会在写数据时会将操作记录在Oplog中,Secondary节点通过拉取oplog信息,回放操作实现数据同步。
Checkpoint:上面提到了MongoDB的写只写了内存和Journal日志(Journal日志是WAL日志),并没有做数据持久化到数据文件中,Checkpoint就是将内存变更刷新到磁盘持久化的过程。MongoDB会每60s一次将内存中的变更刷盘,并记录当前持久化点(checkpoint),以便数据库在重启后能快速恢复数据。
节点选举:MongoDB的节点选举规则能够保证在Primary挂掉之后选取的新节点一定是集群中数据最全的一个,在3.3.1节点选举有说明具体实现
从上面4点我们可以得出 MongoDB 高可用的如下结论:
MongoDB宕机重启之后可以通过checkpoint快速恢复上一个60s之前的数据。
MongoDB最后一个checkpoint到宕机期间的数据可以通过Journal日志回放恢复。
Journal日志因为是100ms刷盘一次,因此至多会丢失100ms的数据(这个可以通过WriteConcern的参数控制不丢失,只是性能会受影响,适合可靠性要求非常严格的场景)
如果在写数据开启了多数写,那么就算Primary宕机了也是至多丢失100ms数据(可避免,同上)
从上一小节发现,MongoDB的高可用机制在不同的场景表现是不一样的。实际上,MongoDB提供了一整套的机制让用户根据自己业务场景选择不同的策略。这里要说的就是MongoDB的读写策略,根据用户选取不同的读写策略,你会得到不同程度的数据可靠性和一致性保障。这些对业务开放者非常重要,因为你只有彻底掌握了这些知识,才能根据自己的业务场景选取合适的策略,同时兼顾读写性能和可靠性。
Write Concern——写策略
控制服务端一次写操作在什么情况下才返回客户端成功,由两个参数控制:
w参数:控制数据同步到多少个节点才算成功,取值范围0~节点个数/majority。0表示服务端收到请求就返回成功,majority表示同步到大多数(大于等于N/2)节点才返回成功。其它值表示具体的同步节点个数。默认为1,表示 Primary写成功就返回成功。
j参数:控制单个节点是否完成Journal持久化到磁盘才返回成功,取值范围 true/false。默认false,因此可能最多丢100ms数据。
Read Preference——读策略
控制客户端从什么节点读取数据,默认为primary,具体参数及含义:
primary:读主节点。
primaryPreferred:优先读主节点,不存在时读从节点。
secondary:读从节点。
secondaryPreferred:优先读从节点,不存在时读主节点。
nearest:就近读,不区分主节点还是从节点,只考虑节点延时。
更多信息可参考MongoDB 官方文档
(https://www.mongodb.com/docs/v4.0/reference/read-preference/index.html?_ga=2.71414227.1531435120.1648536327-1778944104.1630835426)
Read Concern Level——读级别
这是一个非常有意思的参数,也是最不容易理解的异常参数。它主要控制的是读到的数据是不是最新的、是不是持久的,最新的和持久的是一对矛盾,最新的数据可能会被回滚,持久的数据可能不是最新的,这需要业务根据自己场景的容忍度做决策,前提是你的先知道有哪些,他们代表什么意义:
local:直接从查询节点返回,不关心这些数据被同步到了多少个节点。存在被回滚的风险。
available:适用于分片集群,和local差不多,也存在被回滚的风险。
majority:返回被大多数节点确认过的数据,不会被回滚,前提是WriteConcern=majority。
linearizable:适用于事务,读操作会等待在它开始前已经在执行的事务提交了才返回。
snapshot:适用于事务,快照隔离,直接从快照去。
为了便于理解local和majority,这里引用一下MongoDB官网上的一张 WriteConcern=majority时写操作的过程图:
通过这张图可以看出,不同节点在不同阶段看待同一条数据满足的level是不同的:
(三)MongoDB的可扩展性——分片集群
水平扩展是MongoDB的另一个核心特性,它是MongoDB支持海量数据存储的基础。MongoDB天然的分布式特性使得它几乎可无限的横向扩展,你再也不用为MySQL分库分表的各种繁琐问题操碎心了。当然,我们这里不讨论MongoDB和其它存储引擎的对比,这个以后专门写下,这里只关注分片集群相关信息。
MongoDB的分片集群由如下三个部分组成:

Config:配置,本质上是一个MongoDB的副本集,负责存储集群的各种元数据和配置,如分片地址、chunks等。
Mongos:路由服务,不存具体数据,从Config获取集群配置讲请求转发到特定的分片,并且整合分片结果返回给客户端。
Mongod:一般将具体的单个分片叫mongod,实质上每个分片都是一个单独的复制集群,具备负责集群的高可用特性。
其实分片集群的架构看起来和很多支持海量存储的设计很像,本质上都是将存储分片,然后在前面挂一个proxy做请求路由。但是,MongoDB的分片集群有个非常重要的特性是其它数据库没有的,这个特性就是数据均衡。数据分片一个绕不开的话题就是数据分布不均匀导致不同分片负载差异巨大,不能最大化利用集群资源。
MongoDB的数据均衡的实现方式是:
分片集群上数据管理单元叫chunk,一个chunk默认64M,可选范围1~ 1024M。
集群有多少个chunk,每个chunk的范围,每个chunk是存在哪个分片上的,这些数据都是存储在Config的。
chunk会在其内部包含的数据超过阈值时分裂成两个。
MongoDB在运行时会自定检测不同分片上的chunk数,当发现最多和最少的差异超过阈值就会启动chunk迁移,使得每个分片上的chunk数差不多。
chunk 迁移过程叫rebalance,会比较耗资源,因此一般要把它的执行时间设置到业务低峰期。
关于chunk更加深入的知识会在后面进阶知识里面讲解,这里就不展开了。
MongoDB支持两种分片算法来满足不同的查询需求:
区间分片:可以按shardkey做区间查询的分片算法,直接按照shardkey的值来分片。
hash分片:用的最多的分片算法,按shardkey的hash值来分片。hash分片可以看作一种特殊的区间分片。
区间分片示例:

hash分片示例:

从上面两张图可以看出:
分片的本质是将shardkey按一定的函数变换f(x)之后的空间划分为一个个连续的段,每一段就是一个chunk。
区间分片f(x)=x;hash分片f(x)=hash(x)。
每个chunk在空间中起始值是存在Config里面的。
当请求到Mongos的时候,根据shardkey的值算出f(x)的具体值为 f(shardkey),找到包含该值的chunk,然后就能定位到数据的实际位置了。
(四)数据压缩
MongoDB的另外一个比较重要的特性是数据压缩,MongoDB会自动把客户数据压缩之后再落盘,这样就可以节省存储空间。MongoDB的数据压缩算法有多种:
Snappy:默认的压缩算法,压缩比3~5倍。
Zlib:高度压缩算法,压缩比5~7倍。
前缀压缩:索引用的压缩算法,简单理解就是丢掉重复的前缀。
zstd:MongoDB 4.2之后新增的压缩算法,拥有更好的压缩率。
现在推荐的MongoDB版本是4.0,在这个版本下推荐使用snappy算法,虽然zlib有更高的压缩比,但是读写会有一定的性能波动,不适合核心业务,但是比较适合流水、日志等场景。
三、应用接入
在掌握第一部分的基础上,基本上对MongoDB有一个比较直观的认识了,知道它是什么,有什么优势,适合什么场景。在此基础上,我们基本上已经可以判定MongoDB是否适合自己的业务了。如果适合,那么接下来就需要考虑怎么将其应用到业务中。在此之前,我们还得先对MongoDB的性能有个大致的了解,这样才能根据业务情况选取合适的配置。
(一)基本性能测试
在使用MongoDB之前,需要对其功能和性能有一定的了解,才能判定是否符合自己的业务场景,以及需要注意些什么才能更好的使用。笔者这边对其做了一些测试,本测试是基于自己业务的一些数据特性,而且这边使用的是分片集群。因此有些测试项不同数据会有差异,如压缩比、读写性能具体值等。但是也有一些是共性的结论,如写性能随数据量递减并最终区域平稳。
压缩比
对比了同样数据在Mongo和MySQL下压缩比对比,可以看出snapy算法大概是MySQL的3倍,zlib大概是6倍。

写性能
分片集群写性能在测试之后得到如下结论,这里分片是4核8G的配置:
写性能的瓶颈在单个分片上。
当数据量小时是存内存读写,写性能很好,之后随着数量增加急剧下降,并最终趋于平稳,在3000QPS。
少量简单的索引对写性能影响不大。
分片集群批量写和逐条写性能无差异,而如果是复制集群批量写性能是逐条写性能的数倍。这点有点违背常识,具体原因这边还未找到。

读性能
分片集群的读分为三年种情况:按shardkey查询、按索引查询、其他查询。下面这些测试数据都是在单分片2亿以上的数据,这个时候cache已经不能完全换成业务数据了,如果数据量很小,数据全在cache这个性能应该会很好。
按shardkey查下,在Mongos处能算出具体的分片和chunk,所以查询速度非常稳定,不会随着数据量变化。平均耗时2ms以内,4核8G单分片3万QPS。这种查询方式的瓶颈一般在 分片Mongod上,但也要注意Mongos配置不能太低。
按索引查询的时候,由于Mongos需要将数据全部转发到所有的分片,然后聚合全部结果返回客户端,因此性能瓶颈在Mongos上。测试Mongos 8核16G+10分片情况下,单个Mongos的性能在1400QPS,平均时延10ms。业务场景索引是唯一的,因此如果索引数据不唯一,后端分片数更多,这个性能还会更低。
如果不按shardkey和索引查询因为涉及全表扫描,因此在数据量上千万之后基本不可用。
Mongos有点特殊情况要注意的,就是客户端请求会到哪个Mongos是通过客户端ip的hash值决定的,因此同一个客户端所有请求一定会到同一个 Mongos,如果客户端过少的时候还会出现Mongos负载不均问题。
(二)分片选择
在了解了MongoDB的基本性能数据之后,就可以根据自己的业务需求选取合适的配置了。如果是分片集群,其中最重要的就是分片选取,包括:
需要多少个Mongos。
需要分为多少个分片。
分片键和分片算法用什么。
关于前面两点,其实在知道各种性能参数之后就很简单了,前人已经总结出了相关的公式,我这里就简单把图再贴一下。

(三)spring-data-mongo
MonogDB官方提供了各种语言的Client,这些Client是对mongo原始命令的封装。笔者这边是使用的java,因此并未直接使用MongoDB官方的客户端,而是经过二次封装之后的spring-data-mongo。好处是可以不用他关心底层的设计如连接管理、POJO转换等。
spring-data-mongo的使用方式非常简单。
第一步:引入jar包
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-mongodbartifactId>
dependency>
第二步:ymal配置
spring:
data:
mongodb:
host: {{.MONGO_HOST}}
port: {{.MONGO_PORT}}
database: {{.MONGO_DB}}
username: {{.MONGO_USER}}
password: {{.MONGO_PASS}}
这里有个两个要注意:
权限,MongoDB的权限是到数据级别的,所有配置的username必须有 database那个库的权限,要不然会连不上。
这种方式配置没有指定读写concern,如果需要在连接上指定的话,需要用 uri的方式来配置,两种配置方式是不兼容的,或者自己初始化 MongoTemplate。
关于配置,跟多的可以在IDEA里面搜索MongoAutoConfiguration查看源码,具体就是这个类:
org.springframework.boot.autoconfigure.mongo.MongoProperties
关于自己初始化MongoTemplate的方式是:
public class MyMongoConfig {
public MongoTemplate mongoTemplate(MongoDbFactory mongoDbFactory, MongoConverter mongoConverter){
MongoTemplate mongoTemplate = new MongoTemplate(mongoDbFactory,mongoConverter);
mongoTemplate.setWriteConcern(WriteConcern.MAJORITY);
return mongoTemplate;
}
}
第三步:使用MongoTemplate
在完成上面这些之后,就可以在代码里面注入MongoTemplate,然后使用各种增删改查接口了。
MongoDB Client的批量操作有两种方式:
一条命令操作批量数据:insertAll,updateMany等。
批量提交一批命令:bulkOps,这种方式节省的就是客户端与服务端的交互次数。
bulkOps的方式会比另外一种方式在性能上低一些。
这两种方式到引擎层面具体执行时都是一条条语句单独执行,它们有一个很重要的参数:ordered,这个参数的作用是控制批量操作在引擎内最终执行时是并行的还是穿行的。其默认值是true。
true:批量命令窜行执行,遇到某个命令错误时就退出并报错,这个和事物不一样,它不会回滚已经执行成功的命令,如批量插入如果某条数据主键冲突了,那么它前面的数据都会插入成功,后面的会不执行。
false:批量命令并行执行,单个命令错误不影响其它,在执行结构里会返回错误的部分。还是以批量插入为例,这种模式下只会是主键冲突那条插入失败,其他都会成功。
显然,false模式下插入耗时会低一些,但是MongoTemplate的insertAll 函数是在内部写死的true。因此,如果想用false模式,需要自己继承MongoTemplate然后重写里面的insertDocumentList方法。
public class MyMongoTemplate extends MongoTemplate {
protected List