MongoDB 是一个强大的分布式存储引擎,天然支持高可用、分布式和灵活设计。MongoDB 的一个很重要的设计理念是:服务端只关注底层核心能力的输出,至于怎么用,就尽可能的将工作交个客户端去决策。这也就是 MongoDB 灵活性的保证,但是灵活性带来的代价就是使用成本的提升。与 MySql 相比,想要用好 MongoDB,减少在项目中出问题,用户需要掌握的东西更多。本文致力于全方位的介绍 MongoDB 的理论和应用知识,目标是让大家可以通过阅读这篇文章之后能够掌握 MongoDB 的常用知识,具备在实际项目中高效应用 MongoDB 的能力。
本文既有 MongoDB 基础知识也有相对深入的进阶知识,同时适用于对 MonogDB 感兴趣的初学者或者希望对 MongoDB 有更深入了解的业务开发者。
以下是笔者在学习和使用 MongoDB 过程中总结的 MongoDB 知识图谱。本文将按照一下图谱中依次介绍 MongoDB 的一些核心内容。由于能力和篇幅有限,本文并不会对图谱中全部内容都做深入分析,后续将会针对特定条目做专门的分析。同时,如果图谱和内容中有错误或疏漏的地方,也请大家随意指正,笔者这边会积极修正和完善。
本文按照图谱从以下 3 个方面来介绍 MongoDB 相关知识:
(ps:如果大家对某部分知识感兴趣,这里又没有讲解的话,可以自行在网上搜索相关知识,或者连续我,大家共同讨论学习进步)
MongoDB 是基于文档的 NoSql 存储引擎。MongoDB 的数据库管理由数据库、Collection(集合,类似 MySql 的表)、Document(文档,类似 MySQL 的行)组成,每个 Document 都是一个类 JSON 结构 BSON 结构数据。
MongoDB 的核心特性是:No Schema、高可用、分布式(可平行扩展),另外 MongoDB 自带数据压缩功能,使得同样的数据存储所需的资源更少。本节将会依次介绍这些特性的基本知识,以及 MongoDB 是如何实现这些能力的。
MongoDB 是文档型数据库,其文档组织结构是 BSON(Binary Serialized Document Format) 是类 JSON 的二进制存储格式,数据组织和访问方式完全和 JSON 一样。支持动态的添加字段、支持内嵌对象和数组对象,同时它也对 JSON 做了一些扩充,如支持 Date 和 BinData 数据类型。正是 BSON 这种字段灵活管理能力赋予了 Mongo 的 No Schema 或者 Schema Free 的特性。
No Schema 特性带来的好处包括:
方式一:
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 高可用的基础是复制集群,复制集群本质来说就是一份数据存多份,保证一台机器挂掉了数据不会丢失。一个副本集至少有 3 个节点组成:
从上面 3 点我们可以得出 MongoDB 高可用的如下结论:
从上一小节发现,MongoDB 的高可用机制在不同的场景表现是不一样的。实际上,MongoDB 提供了一整套的机制让用户根据自己业务场景选择不同的策略。这里要说的就是 MongoDB 的读写策略,根据用户选取不同的读写策略,你会得到不同程度的数据可靠性和一致性保障。这些对业务开放者非常重要,因为你只有彻底掌握了这些知识,才能根据自己的业务场景选取合适的策略,同时兼顾读写性能和可靠性。
Write Concern —— 写策略
控制服务端一次写操作在什么情况下才返回客户端成功,由两个参数控制:
Read Concern —— 读策略
控制客户端从什么节点读取数据,默认为 primary,具体参数及含义:
更多信息可参考MongoDB 官方文档
Read Concern Level —— 读级别
这是一个非常有意思的参数,也是最不容易理解的异常参数。它主要控制的是读到的数据是不是最新的、是不是持久的,最新的和持久的是一对矛盾,最新的数据可能会被回滚,持久的数据可能不是最新的,这需要业务根据自己场景的容忍度做决策,前提是你的先知道有哪些,他们代表什么意义:
为了便于理解 local 和 majority,这里引用一下 MongoDB 官网上的一张 WriteConcern=majority 时写操作的过程图:
通过这张图可以看出,不同节点在不同阶段看待同一条数据满足的 level 是不同的:
节点 | t0~t1 | t1~t2 | t2~t3 | t3~t4 | t4~t5 | t5~t6 |
---|
水平扩展是 MongoDB 的另一个核心特性,它是 MongoDB 支持海量数据存储的基础。MongoDB 天然的分布式特性使得它几乎可无限的横向扩展,你再也不用为 MySQL 分库分表的各种繁琐问题操碎心了。当然,我们这里不讨论 MongoDB 和其它存储引擎的对比,这个以后专门写下,这里只关注分片集群相关信息。
MongoDB 的分片集群由如下三个部分组成:
其实分片集群的架构看起来和很多支持海量存储的设计很像,本质上都是将存储分片,然后在前面挂一个 proxy 做请求路由。但是,MongoDB 的分片集群有个非常重要的特性是其它数据库没有的,这个特性就是数据均衡。数据分片一个绕不开的话题就是数据分布不均匀导致不同分片负载差异巨大,不能最大化利用集群资源。
MongoDB 的数据均衡的实现方式是:
关于 chunk 更加深入的知识会在后面进阶知识里面讲解,这里就不展开了。
MongoDB 支持两种分片算法来满足不同的查询需求:
区间分片示例:
hash 分片示例:
从上面两张图可以看出:
MongoDB 的另外一个比较重要的特性是数据压缩,MongoDB 会自动把客户数据压缩之后再落盘,这样就可以节省存储空间。MongoDB 的数据压缩算法有多种:
现在推荐的 MongoDB 版本是 4.0,在这个版本下推荐使用 snappy 算法,虽然 zlib 有更高的压缩比,但是读写会有一定的性能波动,不适合核心业务,但是比较适合流水、日志等场景。
在掌握第一部分的基础上,基本上对 MongoDB 有一个比较直观的认识了,知道它是什么,有什么优势,适合什么场景。在此基础上,我们基本上已经可以判定 MongoDB 是否适合自己的业务了。如果适合,那么接下来就需要考虑怎么将其应用到业务中。在此之前,我们还得先对 MonoDB 的性能有个大致的了解,这样才能根据业务情况选取合适的配置。
在使用 MongoDB 之前,需要对其功能和性能有一定的了解,才能判定是否符合自己的业务场景,以及需要注意些什么才能更好的使用。笔者这边对其做了一些测试,本测试是基于自己业务的一些数据特性,而且这边使用的是分片集群。因此有些测试项不同数据会有差异,如压缩比、读写性能具体值等。但是也有一些是共性的结论,如写性能随数据量递减并最终区域平稳。
压缩比
对比了同样数据在 Mongo 和 MySQL 下压缩比对比,可以看出 snapy 算法大概是 MySQL 的 3 倍,zlib 大概是 6 倍。
写性能
分片集群写性能在测试之后得到如下结论,这里分片是 4 核 8G 的配置:
读性能
分片集群的读分为三年种情况:按 shardkey 查询、按索引查询、其他查询。下面这些测试数据都是在单分片 2 亿以上的数据,这个时候 cache 已经不能完全换成业务数据了,如果数据量很小,数据全在 cache 这个性能应该会很好。
在了解了 MongoDB 的基本性能数据之后,就可以根据自己的业务需求选取合适的配置了。如果是分片集群,其中最重要的就是分片选取,包括:
关于前面两点,其实在知道各种性能参数之后就很简单了,前人已经总结出了相关的公式,我这里就简单把图再贴一下。
MonogDB 官方提供了各种语言的 Client,这些 Client 是对 mongo 原始命令的封装。笔者这边是使用的 java,因此并未直接使用 MongoDB 官方的客户端,而是经过二次封装之后的 spring-data-mongo。好处是可以不用他关心底层的设计如连接管理、POJO 转换等。
spring-data-mongo 的使用方式非常简单。
第一步:引入 jar 包
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
第二步:ymal 配置
spring:
data:
mongodb:
host: {{.MONGO_HOST}}
port: {{.MONGO_PORT}}
database: {{.MONGO_DB}}
username: {{.MONGO_USER}}
password: {{.MONGO_PASS}}
这里有个两个要注意:
关于配置,跟多的可以在 IDEA 里面搜索 MongoAutoConfiguration 查看源码,具体就是这个类 org.springframework.boot.autoconfigure.mongo.MongoProperties
关于自己初始化 MongoTemplate 的方式是:
@Configuration
public class MyMongoConfig {
@Primary
@Bean
public MongoTemplate mongoTemplate(MongoDbFactory mongoDbFactory, MongoConverter mongoConverter){
MongoTemplate mongoTemplate = new MongoTemplate(mongoDbFactory,mongoConverter);
mongoTemplate.setWriteConcern(WriteConcern.MAJORITY);
return mongoTemplate;
}
}
第三步:使用 MongoTemplate
在完成上面这些之后,就可以在代码里面注入 MongoTemplate,然后使用各种增删改查接口了。
MongoDB Client 的批量操作有两种方式:
public class MyMongoTemplate extends MongoTemplate {
@Override
protected List<Object> insertDocumentList(String collectionName, List<Document> documents) {
.........
InsertManyOptions options = new InsertManyOptions();
options = options.ordered(false); // 要自己初始化一个这对象,然后设置为false
long begin = System.currentTimeMillis();
if (writeConcernToUse == null) {
collection.insertMany(documents, options); // options这里默认是null
} else {
collection.withWriteConcern(writeConcernToUse).insertMany(documents,options);
}
return null;
});
return MappedDocument.toIds(documents);
}
因为 MongoDB 真的将太多自主性交给的客户端来决策,因此如果对其了解不够,真的会很容易踩坑。这里例举一些常见的坑,避免大家遇到。
预分片
这个问题的常见表现就是:为啥我的数据分布很随机了,但是分片集群的 MongoDB 插入性能还是这么低?
首先我们说下预分片是什么,预分片就是提前把 shard key 的空间划分成若干段,然后把这些段对应的 chunk 创建出来。那么,这个和插入性能的关系是什么呢?
我们回顾下前面说到的 chunk 知识,其中有两点需要注意:
那么,很明显,问题就是出在这了,chunk 分裂和 chunk 迁移都是比较耗资源的,必然就会影响插入性能。
因此,如果提前将个分片上的 chunk 创建好,就能避免频繁的分裂和迁移 chunk,进而提升插入性能。预分片的设置方式为:
sh.shardCollection("saky_db.saky_table", {"_id": "hashed"}, false,{numInitialChunks:8192*分片数})
numInitialChunks 的最大值为 8192 * 分片数
内存排序
这个是一个不容易被注意到的问题,但是使用 MongoDB 时一定要注意的就是避免任何查询的内存操作,因为用 MongoDB 的很多场景都是海量数据,这个情况下任何内存操作的成本都可能是非常高昂甚至会搞垮数据库的,当然 MongoDB 为了避免内存操作搞垮它,是有个阈值,如果需要内存处理的数据超过阈值它就不会处理并报错。
继续说内存排序问题,它的本质是索引问题。MongoDB 的索引都是有序的,正序或者逆序。如果我们有一个 Collection 里面记录了学生信息,包括年龄和性别两个字段。然后我们创建了这样一个复合索引:
{gender: 1, age: 1} // 这个索引先按性别升序排序,相同的再按年龄升序排序
当这个时候,如果你排序顺序是下面这样的话,就会导致内存排序,如果数据两小到没事,如果非常大的话就会影响性能。避免内存排序就是要查询的排序方式要和索引的相同。
{gender: 1, age: -1} // 这个索引先按性别升序排序,相同的再按年龄降序排序
链式复制
链式复制是指副本集的各个副本在复制数据时,并不是都是从 Primary 节点拉 oplog,而是各个节点排成一条链,依次复制过去。
优点:避免大量 Secondary 从 Primary 拉 oplog ,影响 Primary 的性能。
缺点: 如果 WriteConcern=majority,那么链式复制会导致写操作耗时更长。
因此,是否开启链式复制就是一个成本与性能的平衡,默认是开启链式复制的:
接下来终于到了最重要的部分了,这部分将讲解一些 MongoDB 的一些高级功能和底层设计。虽然不了解这些也能使用,但是如果想用好 MongoDB,这部分知识是必须掌握的。
说到 MongoDB 最重要的知识,其存储引擎 Wired Tiger 肯定是要第一个说的。因为 MongoDB 的所有功能都是依赖底层存储引擎实现的,掌握了存储引擎的核心知识,有利于我们理解 MongoDB 的各种功能。存储引擎的核心工作是管理数据如何在磁盘和内存上读写,从 MongoDB 3.2 开始支持多种存储引擎:Wired Tiger,MMAPv1 和 In-Memory,其中默认为 Wired Tiger。
B+ Tree
(ps:这里感谢 biliwang 指出这里的问题,Wired Tiger 具体使用的是 B 树的进阶版 B+ 树,直接说 B 树不严谨,且容易产生误导,因此这里做了更正)
存储引擎最核心的功能就是完成数据在客户端 - 内存 - 磁盘之间的交互。客户端是不可控的,因此如何设计一个高效的数据结构和算法,实现数据快速在内存和磁盘间交互就是存储引擎需要考虑的核心问题。目前大多少流行的存储引擎都是基于 B/B+ Tree 和 LSM(Log Structured Merge) Tree 来实现,至于他们的优势和劣势,以及各种适用的场景,暂时超出了笔者的能力,后面到是有兴趣去研究一下。
Oracle、SQL Server、DB2、MySQL (InnoDB) 这些传统的关系数据库依赖的底层存储引擎是基于 B+ Tree 开发的;而像 Cassandra、Elasticsearch (Lucene)、Google Bigtable、Apache HBase、LevelDB 和 RocksDB 这些当前比较流行的 NoSQL 数据库存储引擎是基于 LSM 开发的。MongoDB 虽然是 NoSQL 的,但是其存储引擎 Wired Tiger 却是用的 B+ Tree,因此有种说法是 MongoDB 是最接近 SQL 的 NoSQL 存储引擎。好了,我们这里知道 Wired Tiger 的存储结构是 B+ Tree 就行了,至于什么是 B+ Tree,它有些啥优势网都有很多文章,这里就不在赘述了。
Page
Wired Tiger 在内存和磁盘上的数据结构都 B+ Tree,B+ 的特点是中间节点只有索引,数据都是存在叶节点。Wired Tiger 管理数据结构的基本单元 Page。
上图是 Page 在内存中的数据结构,是一个典型的 B+ Tree,Page 上有 3 个重要的 list WT_ROW、WT_UPDATE、WT_INSERT。这个 Page 的组织结构和 Page 的 3 个 list 对后面理解 cache、checkpoint 等操作很重要:
上面说了 Page 的基本结构,接下来再看下 Page 的生命周期和状态扭转,这个生命周期和 Wired Tiger 的缓存息息相关。
Page 在磁盘和内存中的整个生命周期状态机如上图:
其中两个比较重要的过程是 reconcile 和 evict。
其中 reconcile 发生在 checkpoint 的时候,将内存中 Page 的修改转换成磁盘需要的 B+ Tree 结构。前面说了 Page 的 WT_UPDATE 和 WT_UPDATE 列表存储了数据被加载到内存之后的修改,类似一个内存级的 oplog,而数据在磁盘中时显然不可能是这样的结构。因此 reconcile 会新建一个 Page 来将修改了的数据做整合,然后原 Page 就会被 discarded,新 page 会被刷新到磁盘,同时加入 LRU 队列。
evict 是内存不够用了或者脏数据过多的时候触发的,根据 LRU 规则淘汰内存 Page 到磁盘。
MongoDB 不是内存数据库,但是为了提供高效的读写操作存储引擎会最大化的利用内存缓存。MongoDB 的读写性能都会随着数据量增加到了某个点出现近乎断崖式跌落最终趋于稳定。这其中的根本原因就是内存是否能 cover 住全部的数据,数据量小的时候是纯内存读写,性能肯定非常好,当数据量过大时就会触发内存和磁盘间数据的来回交换,导致性能降低。所以,如果在使用 MongoDB 时,如果发现自己某些操作明显高于常规,那么很大可能是它触发了磁盘操作。
接下来说下 MongoDB 的存储引擎 Wired Tiger 是怎样利用内存 cache 的。首先,Wired Tiger 会将整个内存划分为 3 块:
内存分配大小一般是不建议改的,除非你确实想把自己全部数据放到内存,并且主够的引擎知识。
引擎 cache 和文件系统 cache 在数据结构上是不一样的,文件系统 cache 是直接加载的内存文件,是经过压缩的数据,可以占用更少的内存空间,相对的就是数据不能直接用,需要解压;而引擎中的数据就是前面提到的 B+ Tree,是解压后的,可以直接使用的数据,占有的内存会大一些。
Evict
就算内存再大它与磁盘间的差距也是数据量级的差异,随着数据增长也会出现内存不够用的时候。因此内存管理一个很重要的操作就是内存淘汰 evict。内存淘汰时机由 eviction_target(内存使用量)和 eviction_dirty_target(内存脏数据量)来控制,而内存淘汰默认是有后台的 evict 线程控制的。但是如果超过一定阈值就会把用户线程也用来淘汰,会严重影响性能,应该避免这种情况。用户线程参与 evict 的原因,一般是大量的写入导致磁盘 IO 抗不住了,需要控制写入或者更换磁盘。
参数名称 | 默认配置值 | 含义 |
---|
前面说过,MongoDB 的读写都是操作的内存,因此必须要有一定的机制将内存数据持久化到磁盘,这个功能就是 Wired Tiger 的 checkpoint 来实现的。checkpoint 实现将内存中修改的数据持久化到磁盘,保证系统在因意外重启之后能快速恢复数据。checkpoint 本身数据也是会在每次 checkpoint 执行时落盘持久化的。
一个 checkpoint 就是一个内存 B+ Tree,其结构就是前面提到的 Page 组成的树,它有几个重要的字段:
checkpoint 的大致流程入上图所述:
Chunk 为啥要单独出来说一下呢,因为它是 MongoDB 分片集群的一个核心概念,是使用和理解分片集群读写实现的最基础的概念。
首先,说下 chunk 是什么,chunk 本质上就是由一组 Document 组成的逻辑数据单元。它是分片集群用来管理数据存储和路由的基本单元。具体来说就是,分片集群不会记录每条数据在哪个分片上,这不现实,它只会记录哪一批(一个 chunk)数据存储在哪个分片上,以及这个 chunk 包含哪些范围的数据。而数据与 chunk 之间的关联是有数据的 shard key 的分片算法 f(x) 的值是否在 chunk 的起始范围来确定的。
前面说过,分片集群的 chunk 信息是存在 Config 里面的,而 Config 本质上是一个复制集群。如果你创建一个分片集群,那么你默认会得到两个库,admin 和 config,其中 config 库对应的就是分片集群架构里面的 Config。其中的包含一个 Collection chunks 里面记录的就是分片集群的全部 chunk 信息,具体结构如下图:
chunk 的几个关键属性:
chunk 是分片集群管理数据的基本单元,本身有一个大小,那么随着 chunk 内的数据不断新增,最终大小会超过限制,这个时候就需要把 chunk 拆分成 2 个,这个就 chunk 的分裂。
chunk 的大小不能太大也不能太小。太大了会导致迁移成本高,太小了有会触发频繁分裂。因此它需要一个合理的范围,默认大小是 64M,可配置的取值范围是 1M ~ 1024M。这个大小一般来说是不用专门配置的,但是也有特例:
MongoDB 一个区别于其他分布式数据库的特性就是自动数据均衡。
chunk 分裂是 MongoDB 保证数据均衡的基础:数据的不断增加,chunk 不断分裂,如果数据不均匀就会导致不同分片上的 chunk 数目出现差异,这就解决了分片集群的数据不均匀问题发现。然后就可以通过将 chunk 从数据多的分片迁移到数据少的分片来实现数据均衡,这个过程就是 rebalance。
如下图所示,随着数据插入,导致 chunk 分裂,让 AB 两个分片有 3 个 chunk,C 分片只有一个,这个时候就会把 B 分配的迁移一个到 C 分分片实现集群数据均衡。
执行 rebalance 是有几个前置条件的:
rebalance 为了尽快完成数据迁移,其设计是尽最大努力迁移,因此是非常消耗系统资源的,在系统配置不高的时候会影响系统正常业务。因此,为了减少其影响需要:
分布式系统必须要面对的一个问题就是数据的一致性和高可用,针对这个问题有一个非常著名的理论就是 CAP 理论。CAP 理论的核心结论是:一个分布式系统最多只能同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance)这三项中的两项。关于 CAP 理论在网上有非常多的论述,这里也不赘述。
CAP 理论提出了分布式系统必须面临的问题,但是我们也不可能因为这个问题就不用分布式系统。因此,BASE(Basically Available 基本可用、Soft state 软状态、Eventually consistent 最终一致性)理论被提出来了。BASE 理论是在一致性和可用性上的平衡,现在大部分分布式系统都是基于 BASE 理论设计的,当然 MongoDB 也是遵循此理论的。
MongoDB 为了保证可用性和分区容错性,采用的是副本集的方式,这种模式就必须要解决的一个问题就是怎样快速在系统启动和 Primary 发生异常时选取一个合适的主节点。这里潜在着多个问题:
Raft 协议
MongoDB 的选举算法是基于 Raft 协议的改进,Raft 协议将分布式集群里面的节点有 3 种状态:
节点的状态变化是:正常情况下只有一个 leader 和多个 flower,当 leader 挂掉了,那么 flower 里面就会有部分节点成为 candidate 参与竞选。当某个 candidate 竞选成功之后就成为新的 leader,而其他 candidate 回到 flower 状态。具体状态机如下:
Raft 协议中有两个核心 RPC 协议分别应用在选举阶段和正常阶段:
本文系作者在时代Java发表,未经许可,不得转载。
如有侵权,请联系nowjava@qq.com删除。