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的高可用特性?
那么,带着这些问题,我们继续看下去,看完大家应该会对这些问题有所了解了。
-
MongDB复制集群
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的分片集群由如下三个部分组成:
![图片](https://img2022.cnblogs.com/blog/2550804/202206/2550804-20220609105201585-1569736895.png)
- 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分片可以看作一种特殊的区间分片。
区间分片示例:
![图片](https://img2022.cnblogs.com/blog/2550804/202206/2550804-20220609105201585-1569736895.png)
hash分片示例:
![图片](https://img2022.cnblogs.com/blog/2550804/202206/2550804-20220609105201585-1569736895.png)
从上面两张图可以看出:
- 分片的本质是将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倍。
![图片](https://img2022.cnblogs.com/blog/2550804/202206/2550804-20220609105201585-1569736895.png)
-
写性能
分片集群写性能在测试之后得到如下结论,这里分片是4核8G的配置:
- 写性能的瓶颈在单个分片上。
- 当数据量小时是存内存读写,写性能很好,之后随着数量增加急剧下降,并最终趋于平稳,在3000QPS。
- 少量简单的索引对写性能影响不大。
- 分片集群批量写和逐条写性能无差异,而如果是复制集群批量写性能是逐条写性能的数倍。这点有点违背常识,具体原因这边还未找到。
![图片](https://img2022.cnblogs.com/blog/2550804/202206/2550804-20220609105201585-1569736895.png)
-
读性能
分片集群的读分为三年种情况:按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。
-
需要分为多少个分片。
-
分片键和分片算法用什么。
关于前面两点,其实在知道各种性能参数之后就很简单了,前人已经总结出了相关的公式,我这里就简单把图再贴一下。
![图片](https://img2022.cnblogs.com/blog/2550804/202206/2550804-20220609105201585-1569736895.png)
(三)spring-data-mongo
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}}
这里有个两个要注意:
- 权限,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<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数差异过大时就会启动rebalance,迁移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,那么链式复制会导致写操作耗时更长。
因此,是否开启链式复制就是一个成本与性能的平衡,默认是开启链式复制的:
-
是关闭链式复制,用更好的机器配置来支持所有节点从Primary拉oplog。
-
还是开启链式复制,用更长的写耗时来降低对节点配置的需求。
链式复制关闭时,节点数据复制对Primary节点性能影响程度目前没有专业测试过,因此不能评判到底开启还是关闭好,这边数据库同学从他们的经验来建议是关闭,因此我这边是关闭的,如果有用到MongoDB的可以考虑关掉。
四、进阶知识
接下来终于到了最重要的部分了,这部分将讲解一些MongoDB的一些高级功能和底层设计。虽然不了解这些也能使用,但是如果想用好MongoDB,这部分知识是必须掌握的。
(一)存储引擎Wired Tiger
说到MongoDB最重要的知识,其存储引擎Wired Tiger肯定是要第一个说的。因为MongoDB的所有功能都是依赖底层存储引擎实现的,掌握了存储引擎的核心知识,有利于我们理解MongoDB的各种功能。存储引擎的核心工作是管理数据如何在磁盘和内存上读写,从MongoDB 3.2开始支持多种存储引擎:Wired Tiger,MMAPv1和In-Memory,其中默认为Wired Tiger。
-
重要数据结构和Page
B+Tree
存储引擎最核心的功能就是完成数据在客户端-内存-磁盘之间的交互。客户端是不可控的,因此如何设计一个高效的数据结构和算法,实现数据快速在内存和磁盘间交互就是存储引擎需要考虑的核心问题。目前大多少流行的存储引擎都是基于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。
![图片](https://img2022.cnblogs.com/blog/2550804/202206/2550804-20220609105201585-1569736895.png)
上图是Page在内存中的数据结构,是一个典型的B+ Tree,Page上有3个重要的list WT_ROW、WT_UPDATE、WT_INSERT。这个Page的组织结构和Page的3个list对后面理解cache、checkpoint等操作很重要:
- 内存中的Page树是一个checkpoint。
- 叶节点Page的WT_ROW:是从磁盘加载进来的数据数组。
- 叶节点Page的WT_UPDATE:是记录数据加载之后到下个checkpoint之间被修改的数据。
- 叶节点Page的WT_INSERT:是记录数据加载之后到下个checkpoint之间新增的数据。
上面说了Page的基本结构,接下来再看下Page的生命周期和状态扭转,这个生命周期和Wired Tiger的缓存息息相关。
![图片](https://img2022.cnblogs.com/blog/2550804/202206/2550804-20220609105201585-1569736895.png)
Page在磁盘和内存中的整个生命周期状态机如上图:
- DIST:Page在磁盘中。
- DELETE:Page已经在磁盘中从树中删除。
- READING:Page正在被从磁盘加载到内存中。
- MEM:Page在内存中,且能正常读写。
- LOCKED:内存淘汰过程(evict)正在锁住Page。
- LOOKASIDE:在执行reconcile的时候,如果page正在被其他线程读取被修改的部分,这个时候会把数据存储在lookasidetable里面。当页面再次被读时可以通过lookasidetable重构出内存Page。
- LIMBO:在执行完reconcile之后,Page会被刷到磁盘。这个时候如果page有lookasidetable数据,并且还没合并过来之前就又被加载到内存了,就会是这个状态,需要先从lookasidetable重构内存Page才能正常访问。
其中两个比较重要的过程是reconcile和evict。
其中reconcile发生在checkpoint的时候,将内存中Page的修改转换成磁盘需要的B+ Tree结构。前面说了Page的WT_UPDATE和WT_UPDATE列表存储了数据被加载到内存之后的修改,类似一个内存级的oplog,而数据在磁盘中时显然不可能是这样的结构。因此reconcile会新建一个Page来将修改了的数据做整合,然后原Page就会被discarded,新page会被刷新到磁盘,同时加入LRU队列。
evict是内存不够用了或者脏数据过多的时候触发的,根据LRU规则淘汰内存 Page到磁盘。
-
cache
MongoDB不是内存数据库,但是为了提供高效的读写操作存储引擎会最大化的利用内存缓存。MongoDB的读写性能都会随着数据量增加到了某个点出现近乎断崖式跌落最终趋于稳定。这其中的根本原因就是内存是否能cover住全部的数据,数据量小的时候是纯内存读写,性能肯定非常好,当数据量过大时就会触发内存和磁盘间数据的来回交换,导致性能降低。所以,如果在使用MongoDB时,如果发现自己某些操作明显高于常规,那么很大可能是它触发了磁盘操作。
接下来说下MongoDB的存储引擎Wired Tiger是怎样利用内存cache的。首先,Wired Tiger会将整个内存划分为3块:
- 存储引擎内部cache:缓存前面提到的内存数据,默认大小Max((RAM-1G)/2,256M ),服务器16G的话,就是(16-1)/2=7.5G。这个内存配置一定要注意,因为Wired Tiger如果内存不够可能会导致数据库宕掉的。
- 索引cache:换成索引信息,默认500M。
- 文件系统cache:这个实际上不是存储引擎管理,是利用的操作系统的文件系统缓存,目的是减少内存和磁盘交互。剩下的内存都会用来做这个。
内存分配大小一般是不建议改的,除非你确实想把自己全部数据放到内存,并且主够的引擎知识。
引擎cache和文件系统cache在数据结构上是不一样的,文件系统cache是直接加载的内存文件,是经过压缩的数据,可以占用更少的内存空间,相对的就是数据不能直接用,需要解压;而引擎中的数据就是前面提到的B+ Tree,是解压后的,可以直接使用的数据,占有的内存会大一些。
Evict
就算内存再大它与磁盘间的差距也是数据量级的差异,随着数据增长也会出现内存不够用的时候。因此内存管理一个很重要的操作就是内存淘汰evict。内存淘汰时机由eviction_target(内存使用量)和eviction_dirty_target(内存脏数据量)来控制,而内存淘汰默认是有后台的evict线程控制的。但是如果超过一定阈值就会把用户线程也用来淘汰,会严重影响性能,应该避免这种情况。用户线程参与evict的原因,一般是大量的写入导致磁盘IO抗不住了,需要控制写入或者更换磁盘。
![图片](https://img2022.cnblogs.com/blog/2550804/202206/2550804-20220609105201585-1569736895.png)
- checkpoint
前面说过,MongoDB的读写都是操作的内存,因此必须要有一定的机制将内存数据持久化到磁盘,这个功能就是Wired Tiger的checkpoint来实现的。checkpoint实现将内存中修改的数据持久化到磁盘,保证系统在因意外重启之后能快速恢复数据。checkpoint本身数据也是会在每次checkpoint执行时落盘持久化的。
一个checkpoint 就是一个内存B+ Tree,其结构就是前面提到的Page组成的树,它有几个重要的字段:
-
root page:就是指向B+Tree的根节点。
-
allocated list pages:上个checkpoint结束之后到本checkpoint结束前新分配的 page 列表
-
available list pages:Wired Tiger分配了但是没有使用的page,新建 page 时直接从这里取。
-
discarded list pages:上个checkpoint结束之后到本checkpoint结束前被删掉的page列表。
![图片](https://img2022.cnblogs.com/blog/2550804/202206/2550804-20220609105201585-1569736895.png)
checkpoint的大致流程入上图所述:
在系统启动或者集合文件打开时,从磁盘加载最新的checkpoint。
根据checkpoint的file size truncate文件。因为只有checkpoint确认的数据才是真正持久化的数据,它后面的数据可能是最新checkpoint之后到宕机之间的数据,不能直接用,需要通过Journal日志来回放。
根据checkpoint构建内存的B+Tree。
数据库run起来之后,各种修改操作都是操作checkpoint的B+Tree,并且会checkpoint会有专门的list来记录这些修改和新增的page。
在60s一次的checkpoint执行时,会创建新的checkpoint,并且将旧的 checkpoint数据合并过来。然后执行reconcile将修改的数据刷新到磁盘,并删除旧的checkpoint。这时候会清空allocated,discarded里面的 page,并且将空闲的page加到available里面。
(二)Chunk
Chunk为啥要单独出来说一下呢,因为它是MongoDB分片集群的一个核心概念,是使用和理解分片集群读写实现的最基础的概念。
-
基本信息
首先,说下chunk是什么,chunk本质上就是由一组Document组成的逻辑数据单元。它是分片集群用来管理数据存储和路由的基本单元。具体来说就是,分片集群不会记录每条数据在哪个分片上,这不现实,它只会记录哪一批(一个chunk)数据存储在哪个分片上,以及这个chunk包含哪些范围的数据。而数据与chunk之间的关联是有数据的shard key的分片算法f(x)的值是否在chunk的起始范围来确定的。
前面说过,分片集群的chunk信息是存在Config里面的,而Config本质上是一个复制集群。如果你创建一个分片集群,那么你默认会得到两个库,admin和config,其中config库对应的就是分片集群架构里面的Config。其中的包含一个Collection chunks里面记录的就是分片集群的全部chunk信息,具体结构如下图:
![图片](https://img2022.cnblogs.com/blog/2550804/202206/2550804-20220609105201585-1569736895.png)
chunk的几个关键属性:
- _id:chunk的唯一标识。
- ns:命名空间,就是DB.COLLECTION的结构。
- min:chunk包含数据的shard key的f(x)最小值。
- max:chunk包含数据的shard key的f(x)最大值。
- shard:chunk当前所在分片ID。
- history:记录chunk的迁移历史。
-
chunk分裂
chunk是分片集群管理数据的基本单元,本身有一个大小,那么随着chunk内的数据不断新增,最终大小会超过限制,这个时候就需要把chunk拆分成2个,这个就chunk的分裂。
chunk的大小不能太大也不能太小。太大了会导致迁移成本高,太小了有会触发频繁分裂。因此它需要一个合理的范围,默认大小是64M,可配置的取值范围是1M~1024M。这个大小一般来说是不用专门配置的,但是也有特例:
-
如果你的单条数据太小了,25W条也远小于64M,那么可以适当调小,但也不是必要的。
-
如果你的数据单条过大,大于了64M,那么就必须得调大chunk了,否则会产生jumbo chunk,导致chunk不能迁移。
导致chunk分裂有两个条件,达到任何一个都会触发:
-
容量达到阈值:就是chunk中的数据大小加起来超过阈值,默认是上面说的 64M。
-
数据量到达阈值:前面提到了,如果单条数据太小,不加限制的话,一个 chunk内数据量可能几十上百万条,这也会影响读写性能,因此MongoDB内置了一个阈值,chunk内数据量超过25W条也会分裂。
-
rebalance
MongoDB一个区别于其他分布式数据库的特性就是自动数据均衡。
chunk分裂是MongoDB保证数据均衡的基础:数据的不断增加,chunk不断分裂,如果数据不均匀就会导致不同分片上的chunk数目出现差异,这就解决了分片集群的数据不均匀问题发现。然后就可以通过将chunk从数据多的分片迁移到数据少的分片来实现数据均衡,这个过程就是rebalance。
如下图所示,随着数据插入,导致chunk分裂,让AB两个分片有3个 chunk,C分片只有一个,这个时候就会把B分配的迁移一个到C分分片实现集群数据均衡。
![图片](https://img2022.cnblogs.com/blog/2550804/202206/2550804-20220609105201585-1569736895.png)
执行rebalance是有几个前置条件的:
- 数据库和集合开启了rebalance开关,默认是开启的。
- 当前时间在设置的rebalance时间窗,默认没有配置,就是只要检测到了就会执行rebalance。
- 集群中分片chunk数最大和最小之差超过阈值,这个阈值和chunk总数有关,具体如下:
![图片](https://img2022.cnblogs.com/blog/2550804/202206/2550804-20220609105201585-1569736895.png)
rebalance为了尽快完成数据迁移,其设计是尽最大努力迁移,因此是非常消耗系统资源的,在系统配置不高的时候会影响系统正常业务。因此,为了减少其影响需要:
- 预分片:减少大量数据插入时频繁的分裂和迁移chunk。
- 设置rebalance时间窗。
- 对于可能会影响业务的大规模数据迁移,如扩容分片,可以采取手段迁移的方式来控制迁移速度。
(三)一致性/高可用
分布式系统必须要面对的一个问题就是数据的一致性和高可用,针对这个问题有一个非常著名的理论就是CAP理论。CAP理论的核心结论是:一个分布式系统最多只能同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance)这三项中的两项。关于CAP理论在网上有非常多的论述,这里也不赘述。
CAP理论提出了分布式系统必须面临的问题,但是我们也不可能因为这个问题就不用分布式系统。因此,BASE(Basically Available基本可用、Soft state软状态、Eventually consistent最终一致性)理论被提出来了。BASE理论是在一致性和可用性上的平衡,现在大部分分布式系统都是基于 BASE理论设计的,当然MongoDB也是遵循此理论的。
-
选举和Raft协议
MongoDB为了保证可用性和分区容错性,采用的是副本集的方式,这种模式就必须要解决的一个问题就是怎样快速在系统启动和Primary发生异常时选取一个合适的主节点。这里潜在着多个问题:
- 系统怎样发现Primary异常?
- 哪些Secondary节点有资格参加Primary选举?
- 发现Primary异常之后用什么样的算法选出新的Primary节点?
- 怎么样确保选出的Primary是最合适的?
Raft协议
MongoDB的选举算法是基于Raft协议的改进,Raft协议将分布式集群里面的节点有3种状态:
- leader:就是Primary节点,负责整个集群的写操作。
- candidate:候选者,在Primary节点挂掉之后,参与竞选的节点。只有选举期间才会存在,是个临时状态。
- flower:就是Secondary节点,被动的从Primary节点拉取更新数据。
节点的状态变化是:正常情况下只有一个leader和多个flower,当leader挂掉了,那么flower里面就会有部分节点成为candidate参与竞选。当某个candidate竞选成功之后就成为新的leader,而其他candidate回到flower状态。具体状态机如下:
![图片](https://img2022.cnblogs.com/blog/2550804/202206/2550804-20220609105201585-1569736895.png)
Raft协议中有两个核心RPC协议分别应用在选举阶段和正常阶段:
- 请求投票:选举阶段,candidate向其他节点发起请求,请求对方给自己投票。
- 追加条目:正常阶段,leader节点向flower节点发起请求,告诉对方有数据更新,同时作为心跳机制来向所有flower宣示自己的地位。如果flower在一定时间内没有收到该请求就会启动新一轮的选举投票。
投票规则
Raft协议规定了在选举阶段的投票规则:
- 一个节点,在一个选举周期(Term)内只能给一个candidate节点投赞成票,且先到先得。
- 只有在candidate节点的oplog领先或和自己相同时才投赞成票。
选举过程
一轮完整的选举过程包含如下内容:
- 某个/多个flower节点超时未收到leader的心跳,将自己改变成candidate 状态,增加选举周期(Term),然后先给自己投一票,并向其他节点发起投票请求。
- 等待其它节点的投票返回,在此期间如果收到其它candidate发来的请求,根据投票规则给其它节点投票。
- 如果某个candidate在收到过半的赞成票之后,就把自己转换成leader状态,并向其它节点发送心跳宣誓即位。
- 如果节点在没有收到过半赞成票之前,收到了来自leader的心跳,就将自己退回到flower状态。
- 只要本轮有选出leader就完成了选举,否则超时启动新一轮选举。
catchup(追赶)
以上就是目前掌握的MongoDB的选举机制,其中有个问题暂时还未得到解答,就是最后一个,怎样确保选出的Primary是最合适的那一个。因为,从前面的协议来看,存在一个逻辑bug:由于flower转换成candidate是随机并行的,再加上先到先得的投票机制会导致选出一个次优的节点成为Primary。但是这一点应该是笔者自己掌握知识不够,应该是有相关机制保证的,怀疑是通过节点优先级实现的。这点也和相关同学确认过,因此这里暂定此问题不存在,等深入学习这里的细节之后补充其设计和实现。
针对Raft协议的这个问题,下来查询了一些资料,结论是:
- Raft协议确实不保证选举出来的Primary节点是最优的。
- MongoDB通过在选举成功,到新Primary即位之前,新增了一个 catchup(追赶)操作来解决。即在节点获取投票胜利之后,会先检查其它节点是否有比自己更新的oplog,如果没有就直接即位,如果有就先把数据同步过来再即位。
-
主从同步
MongoDB的主从同步机制是确保数据一致性和可靠性的重要机制。其同步的基础是oplog,类似MySQL的binlog,但是也有一些差异,oplog虽然叫log但并不是一个文件,而是一个集合(Collection)。同时由于 oplog 的并行写入,存在尾部乱序和空洞现象,具体来说就是oplog里面的数据顺序可能是和实际数据顺序不一致,并且存在时间的不连续问题。为了解决这个问题,MongoDB采用的是混合逻辑时钟(HLC)来解决的,HLC不止解决乱序和空洞问题,同时也是用来解决分布式系统上事务一致性的方案。
主从同步的本质实际上就是,Primary节点接收客户端请求,将更新操作写到oplog,然后Secondary从同步源拉取oplog并本地回放,实现数据的同步。
同步源选取
同步源是指节点拉取oplog的源节点,这个节点不一定是Primary,链式复制模式下就可能是任何节点。节点的同步源选取是一个非常复杂的过程,大致上来说是:
- 节点维护整个集群的全部节点信息,并每2s发送一次心跳检测,存活的节点都是同步源备选节点。
- 落后自己的节点不能做同步源:就是源节点最新的opTime不能小于自己最新的opTime。
- 落后Primary 30s以上的不能作为同步源。
- 太超前的节点不能作为同步源:就是源节点最老的opTime不能大于自己最新的opTime,否则有oplog空洞。
在同步源选取时有些特殊情况:
- 用户可以为节点指定同步源。
- 如果关闭链式复制,所有Secondary节点的同步源都是Primary节点。
- 如果从同步源拉取出错了,会被短期加入黑名单。
oplog拉取和回放
整个拉取和回放的逻辑非常复杂,这里根据自己的理解简化说明,如果想了解更多知识可以参考《MongoDB复制技术内幕》
节点有一个专门拉取oplog的线程,通过Exhausted cursor从同步源拉取 oplog。拉取下来之后,并不会执行回放执行,而是会将其丢到一个本地的阻塞队列中。
然后有多个具体的执行线程,从阻塞队列中取出oplog并执行。在取出过程中,同一个Collection的oplog一定会被同一个线程取出执行,线程会尽可能的合并连续的插入命令。
整个回放的执行过程,大致为先加锁,然后写本店oplog,然后将oplog刷盘(WAL机制),最后更新自己的最新opTime。
(四)索引
索引对任何数据库而言都是非常重要的一个功能。数据库支持的索引类型,决定的数据库的查询方式和应用场景。而正确的使用索引能够让我们最大化的利用数据库性能,同时避免不合理的操作导致的数据库问题,最常见的问题就是CPU或内存耗尽。
-
基本概念
MongoDB的索引和MySql的索引有点不一样,它的索引在创建时必须指定顺序(1:升序,-1:降序),同时所有的集合都有一个默认索引 _id,这是一个唯一索引,类似MySql的主键。
MongoDB支持的索引类型有:
- 单字段索引:建立在单个字段上的索引,索引创建的排序顺序无所谓,MongoDB可以头/尾开始遍历。
- 复合索引:建立在多个字段上的索引。
- 多key索引:我们知道MongoDB的一个字段可能是数组,在对这种字段创建索引时,就是多key索引。MongoDB会为数组的每个值创建索引。就是说你可以按照数组里面的值做条件来查询,这个时候依然会走索引。
- Hash索引:按数据的哈希值索引,用在hash分片集群上。
- 地理位置索引:基于经纬度的索引,适合2D和3D的位置查询。
- 文本索引:MongoDB虽然支持全文索引,但是性能低下,暂时不建议使用。
-
注意事项
索引功能强大,但是也有很多限制,使用索引时一定要注意一些问题。
复合索引
复合索引有几个问题需要注意:
-
复合索引遵循前缀匹配原则:{userid:1,score:-1}的索引隐含了 {userid:1}的索引
- 避免内存排序:复合索引除第一个字段之外,其他字段的查询排序方式,必须和索引排序方式一致,否则会导致内存排序。如前面的索引,可以支持 {userid:-1,score:-1}的查询,同时也能支持{userid:1,score:1}的查询,只是后一种需要内存排序score字段。
- 索引交集:索引交集时查询优化器的优化方案,很少用到,尽量不要依赖这个功能。索引交集本质上就有创建两个独立的单字段索引,在查询保护两个字段时,优化器自动做索引交集。如{user:1}+{score:-1}两个索引的交集可以支持前面的{userid:1,score:1}的查询
后台创建索引
在对一个已经拥有较大数据集的Collection创建索引时,建议通过创建命令参数指定后台创建,不会阻塞命令和意外中断。但是,在后台创建多个索引时,不能命令执行完就接着下一个。因为是后台创建,命令行虽然推出了,但是索引还没创建完。这个时候如果同事输入多个创建索引命令,会因为大量的写操作和数据复制导致系统cpu耗尽。这个时候需要观察系统监控,确定第一个索引创建完了再执行下一个。
-
explain
explain是MongoDB的查询计划工具,和MySql的explain功能相同,都是用来分析一条语句的索引使用情况、影响行数、执行时间等。
explain有三种参数分别对应结果输出的三部分数据:
- queryPlanner:MongoDB运行查询优化器对当前的查询进行评估并选择一个最佳的查询计划。
- exectionStats:mongoDB运行查询优化器对当前的查询进行评估并选择一个最佳的查询计划进行执行。在执行完毕后返回这个最佳执行计划执行完成时的相关统计信息。
- allPlansExecution:即按照最佳的执行计划执行以及列出统计信息,如果有多个查询计划,还会列出这些非最佳执行计划部分的统计信息。
explain是一个非常有用的工具,建议在一个数据量较大的数据库上开发新功能时,一定要用explain分析一下自己的语句是否合理、索引是否合理,避免在项目上线之后出现问题。
鸣谢
https://mp.weixin.qq.com/s/bhXPnLotUoQYJI61eORCfA
本文来自博客园,作者:{春光牛牛,yak},转载请注明原文链接:https://www.cnblogs.com/yakniu/p/16358494.html
欢迎各位大佬们评论指正
QQ讨论群:610129902