MongoDB概述
MongoDB是一款NoSQL类型的文档型数据库。
NoSQL
NoSQL是一种非关系型DMS,不需要固定的架构,可以避免joins链接,并且易于扩展。NoSQL数据库用于具有庞大数据存储需求的分布式数据存储。NoSQL用于大数据和实时Web应用程序。
MongoDB特点
- 面向文档
由于MongoDB是NoSQL类型的数据库,它不是以关系类型的格式存储数据,而是将数据存储在文档中。这使得MongoDB非常灵活,可以适应实际的业务环境和需求。 - 临时查询
MongoDB支持按字段,范围查询和正则表达式搜索。可以查询返回文档中的特定字段。 - 索引
可以创建索引以提高MongoDB中的搜索性能。MongoDB文档中的任何字段都可以建立索引。 - 复制
MongoDB可以提供副本集的高可用性。副本集由两个或多个mongo数据库实例组成。每个副本集成员可以随时充当主副本或辅助副本的角色。主副本是与客户端交互并执行所有读/写操作的主服务器。辅助副本使用内置复制维护主数据的副本。当主副本发生故障时,副本集将自动切换到辅助副本,然后它将成为主服务器。 - 负载平衡
MongoDB使用分片的概念,通过在多个MongoDB实例之间拆分数据来水平扩展。MongoDB可以在多台服务器上运行,以平衡负载或复制数据,以便在硬件出现故障时保持系统正常运行。
MongoDB存储方式
MongoDB是文档型数据库,文件以BSON格式存储在硬盘中。
BSON是JSON的一种二进制形式的存储格式。
注:MongoDB内部执行引擎为JS解释器,把文档存储成bson结构,在查询时,转换为JS对象,并可以通过熟悉的JS语法来操作。
MongoDB常用术语
-
_id – 这是每个MongoDB文档中必填的字段。_id字段表示MongoDB文档中的唯一值。_id字段类似于文档的主键。如果创建的新文档中没有_id字段,MongoDB将自动创建该字段。
-
集合 – 这是MongoDB文档的分组。集合等效于在任何其他RDMS(例如Oracle或MS SQL)中创建的表。集合存在于单个数据库中。从介绍中可以看出,集合不强制执行任何结构。
-
游标 – 这是指向查询结果集的指针。客户可以遍历游标以检索结果。
-
数据库 – 这是像RDMS中那样的集合容器,其中是表的容器。每个数据库在文件系统上都有其自己的文件集。MongoDB服务器可以存储多个数据库。
-
文档 - MongoDB集合中的记录基本上称为文档。文档包含字段名称和值。
-
字段 - 文档中的名称/值对。一个文档具有零个或多个字段。字段类似于关系数据库中的列。
MongoDB和RDBMS区别
SQL术语/概念 | MongoDB术语/概念 | 解释/说明 |
---|---|---|
database | database | 数据库 |
table | collection | 数据库表/集合 |
row | document | 数据记录行/文档 |
column | field | 数据字段/域 |
index | index | 索引 |
table joins | index | 表连接,MongoDB不支持 |
primary key | primary key | 主键,MongoDB自动将_id字段设置为主键 |
MongoDB聚合操作
Pipline操作
MongoDB的聚合管道(Pipeline)将MongoDB文档在一个阶段(Stage)处理完毕后将结果传递给下一个阶段(Stage)处理。阶段(Stage)操作是可以重复的。
表达式:处理输入文档并输出。表达式是无状态的,只能用于计算当前聚合管道的文档,不能处理其他的文档。
聚合框架中常用的Stages:
-
$project - 修改输入文档的结构。可用来重命名,增加或删除域,也可以用于创建计算结果及嵌套文档。
-
$match - 用于过滤数据,只输出符合条件的文档。使用MongoDB的标准查询操作。
-
$limit - 用来限制MongoDB聚合管道返回的文档数。
-
$skip - 在聚合管道中跳过指定数量的文档,并返回余下的文档。
-
$unwind - 将文档中的某一个数组类型字段拆分成多条,每条包含数组中的一个值。
-
$group - 将集合中的文档分组,可用于统计结果。
-
$sort - 将输入文档排序后输出。
-
$genNear - 输出接近某一地理位置的有序文档。
-
$bucket - 分组(分桶)计算。
-
$facet - 多次分组计算。
-
$out - 将结果集输出,必须是Pipeline最后一个stage。
MongoDB数据逻辑结构
MongoDB数据逻辑结构分为数据库(database)、集合(collection)、文档(document)三层:
- 一个mongod实例允许创建多个数据库
- 一个数据库中允许创建多个集合(集合相当于关系型数据库的表)
- 一个集合则是由若干个文档构成(文档相当于关系型数据库的行,是MongoDB中数据的基本单元)
数据库
一个数据库中可以创建多个集合,原则上我们通常把逻辑相近的集合都放在一个数据库中,当然出于性能或者数据量的关系,也可能进行拆分。
在MongoDB中有几个内建的数据库:
-
admin - admin库主要存放有数据库账号相关信息
-
local - local数据库永远不会被复制到从节点,可以用来存储限于本地单台服务器的任意集合副本集的配置信息、oplog就存储在local库中
(重要的数据不要存储在local库,因为没有冗余副本,如果这个节点故障,存储在local库的数据就无法正常使用了) -
config - config数据库用于分片集群环境,存放了分片相关的元数据信息
-
test - MongoDB默认创建的一个测试库,连接mongod服务时,如果不指定连接的具体数据库,默认就会连接到test库
集合
集合由若干条文档记录构成
-
集合是schema-less的(无模式或动态模式),这意味着集合不需要在读写数据前创建模式就可以使用,集合中的文档也可以拥有不同的字段,随时可以任意增减某个文档的字段。
-
在集合上可用对文档进行增删改查以及进行聚合操作
-
在集合上还可以对文档中的字段创建索引
-
除了一般的集合外,还可以创建一种叫做"定容集合"类型的集合,这种集合与一般集合主要的区别是,它可以限制集合的容量大小,在数据写满的时候,又可以从头开始覆盖最开始的文档进行循环写入。
-
副本集就是利用这种类型的集合作为oplog,记录primary节点上的写操作,并且同步到从节点重放,以实现主副节点数据复制的功能
文档
文档是MongoDB中数据的基本存储单元,它以一种叫做BSON文档的结构表示。BSON,即Binary JSON,多个键及其关联的值有序地存放在其中,类似映射,散列或字典。
-
文档中的键/值对是有序的,不同序则是不同文档。并且键是区分大小写的,否则也为不同文档
-
文档的键是字符串,而值除了字符串,还可以是int,long,double,boolean,子文档,数组等多种类型
-
文档中不能有重复的键
-
每个文档都有一个默认的_id值,它相当于关系型数据库中的主键,这个键的值在同一个集合中必须是唯一的,_id键值默认是ObjectId类型
在插入文档的时候,如果用户不设置文档的_id值的话,MongoDB会自动生成一个唯一的ObjectId值进行填充
MongoDB引擎-WiredTiger
WiredTiger(以下简称WT)是一个优秀的单机数据库存储引擎,它拥有诸多的特性,既支持BTree索引,也支持LSM Tree索引,支持行存储和列存储,实现ACID级别事务、支持大到4G的记录等。
WT的产生不是因为这些特性,而是和计算机发展的现状息息相关。
现代计算机近20年来CPU的计算能力和内存容量飞速发展,但磁盘的访问速度并没有得到相应的提高,WT就是在这样的一个情况下研发出来。
它设计了充分利用CPU并行计算的内存模型的无锁并行框架,使得WT引擎在多核CPU上的表现优于其他存储引擎。
针对磁盘存储特性,WT实现了一套基于BLOCK/Extent的友好的磁盘访问算法,使得WT在数据压缩和磁盘I/O访问上优势明显。
实现了基于snapshot技术的ACID事务,snapshot技术大大简化了WT的事务模型,摒弃了传统的事务锁隔离又同时能保证事务的ACID。
WT根据现代内存容量特性实现了一种基于Hazard Pointer 的LRU cache模型,充分利用了内存容量的同时又能拥有很高的事务读写并发。
存储引擎及常用数据结构
存储引擎要做的事情无外乎是将磁盘上的数据读到内存并返回给应用,或者将应用修改的数据由内存写到磁盘上。
目前大多数流行的存储引擎是基于B-Tree或LSM(Log Strctured Merge)Tree这两种数据结构来设计的。
B-Tree
像Oracle、SQL Server、DB2、MySQL(InnoDB)和PostgreSQL这些传统的关系数据库依赖的底层存储引擎是基于B-Tree开发的。
B-Tree可以在查找数据的过程中减少磁盘I/O的次数
LSM Tree
像Cassandra、Elasticsearch (Lucene)、Google Bigtable、Apache HBase、LevelDB和RocksDB这些当前比较流行的NoSQL数据库存储引擎是基于LSM开发的。
插件式兼容上述两种
当然有些数据库采用了插件式的存储引擎架构,实现了Server层和存储引擎层的解耦,可以支持多种存储引擎,如MySQL既可以支持B-Tree结构的InnoDB存储引擎,还可以支持LSM结构的RocksDB存储引擎。
对于MongoDB来说,也采用了插件式存储引擎架构,底层的WiredTiger存储引擎还可以支持B-Tree和LSM两种结构组织数据,但MongoDB在使用WiredTiger作为存储引擎时,目前默认配置是使用了B-Tree结构。
B-Tree数据结构
在整个B-Tree中,从上往下依次为Root结点、内部结点和叶子结点,
每个结点就是一个Page,数据以Page为单位在内存和磁盘间进行调度,每个Page的大小决定了相应结点的分支数量,
每条索引记录会包含一个数据指针,指向一条数据记录所在文件的偏移量
WiredTiger在磁盘上的基础数据结构
对于WiredTiger存储引擎来说,集合所在的数据文件和相应的索引文件都是按B-Tree结构来组织的,
不同之处在于数据文件对应的B-Tree叶子结点上除了存储键名外(keys),还会存储真正的集合数据(values),所以数据文件的存储结构也可以认为是一种b+ Tree
从上图可以看到,B+ Tree中的leaf page包含一个页头(page header)、块头(block header)和真正的数据(key/value)
其中页头定义了页的类型、页中实际载荷数据的大小、页中记录条数等信息;块头定义了此页的checksum、块在磁盘上的寻址位置等信息
WiredTiger有一个块设备管理的模块,用来为page分配block。如果要定位某一行数据(key/value)的位置,可以先通过block的位置找到此page(相对于文件起始位置的偏移量),再通过page找到行数据的相对位置,最后可以得到行数据相对于文件起始位置的偏移量offsets。由于offsets是一个8字节大小的变量,所以WiredTiger磁盘文件的大小,其最大值可以非常大(264bit)
WiredTiger在内存上的基础数据结构
WiredTiger会按需将磁盘的数据以Page为单位加载到内存,同时在内存会构造相应的B-Tree来存储这些数据。
为了高效的支撑CRUD等操作以及将内存里面发生变化的数据持久化到磁盘上,WiredTiger也会在内存里面维护其它几种数据结构:
-
内存里面B-Tree包含三种类型的page,即rootpage、internal page和leaf page,前两者包含指向其子页的page index指针,不包含集合中的真正数据,leaf page包含集合中的真正数据即keys/values和指向父页的home指针
-
内存上的leaf page会维护一个WT_ROW结构的数组变量,将保存从磁盘leaf page读取的keys/values值,每一条记录还有一个cell_offset变量,表示这条记录在page上的偏移量
-
内存上的leaf page会维护一个WT_UPDATE结构的数组变量,每条被修改的记录都会有一个数组元素与之对应,如果某条记录被多次修改,则会将所有修改值以链表形式保存
-
内存上的leaf page会维护一个WT_INSERT_HEAD结构的数组变量,具体插入的data会保存在WT_INSERT_HEAD结构中的WT_UPDATE属性上,且通过key属性的offset和size可以计算出此条记录待插入的位置;同时,为了提高寻找待插入位置的效率,每个WT_INSERT_HEAD变量以跳转链表的形式构成。(通过跳转链表的数据结构能够提升插入操作的效率)
page的其它数据结构
对于一个面向行存储的leaf page来说,包含的数据结构除了上面提到的WT_ROW(keys/values)、WT_UPDATE(修改数据)、WT_INSERT_HEAD(插入数据)外,还有如下几种重要的数据结构:
-
WT_PAGE_MODIFY - 保存page上事务、脏数据字节大小等与page修改相关的信息
-
read_gen - page的read generation值作为evict page时使用,具体来说对应page在LRU队列中的位置,决定page被evict server选中淘汰出去的先后顺序
-
WT_PAGE_LOOKASIDE - page关联的lookasidetable数据。当对一个page进行reconcile时,如果系统中还有之前的读操作正在访问此page上修改的数据,则会将这些数据保存到lookasidetable;当page再被读时,可以利用lookasidetable中的数据重新构建内存page
-
WT_ADDR - page被成功reconciled后,对应的磁盘上块的地址,将按这个地址将page写到磁盘,块是最小磁盘上文件的最小分配单元,一个page可能有多个块
-
checksum - page的校验和,如果page从磁盘读到内存后没有任何修改,比较checksum可以得到相等结果,那么后续reconcile这个page时,不会将这个page的再重新写入磁盘
Page生命周期
数据以page为单位加载到cache、cache里面又会生成各种不同类型的page及为不同类型的page分配不同大小的内存、eviction触发机制和reconcile动作都发生在page上、page大小持续增加时会被分割成多个小page,所有这些操作都是围绕一个page来完成的。
Page的典型生命周期如下:
第一步:pages从磁盘读到内存
第二步:pages在内存中被修改
第三步:被修改的脏pages在内存被reconcile(page被淘汰到底发生了什么),完成后将discard这些pages
第四步:pages被选中,加入淘汰队列,等待被evict(Page被淘汰的时机)线程淘汰出内存
第五步:evict线程会将“干净“的pages直接从内存丢弃(因为相对于磁盘page来说没做任何修改),将经过reconcile处理后的磁盘映像写到磁盘再丢弃“脏的”page
pages的状态是在不断变化的,因此,对于读操作来说,它首先会检查pages的状态是否为WT_REF_MEM,然后设置一个hazard指针指向要读的pages,如果刷新后,pages的状态仍为WT_REF_MEM,读操作才能继续处理。
与此同时,evict线程想要淘汰pages时,它会先锁住pages,即将pages的状态设为WT_REF_LOCKED,然后检查pages上是否有读操作设置的hazard指针,如有,说明还有线程正在读这个page则停止evict,重新将page的状态设置为WT_REF_MEM;如果没有,则pages被淘汰出去。
Page的各种状态
针对一页page的每一种状态,详细描述如下:
-
WT_REF_DISK - 初始状态,page在磁盘上的状态,必须被读到内存后才能使用,当page被evict后,状态也会被设置为这个
-
WT_REF_DELETED - page在磁盘上,但是已经从内存B-Tree上删除,当我们不在需要读某个leaf page时,可以将其删除
-
WT_REF_LIMBO - page的映像已经被加载到内存,但page上还有额外的修改数据在lookasidetable上没有被加载到内存
-
WT_REF_LOOKASIDE - page在磁盘上,但是在lookasidetable也有与此page相关的修改内容,在page可读之前,也需要加载这部分内容
当对一个page进行reconcile时,如果系统中还有之前的读操作正在访问此page上修改的数据,则会将这些数据保存到lookasidetable;当page再被读时,可以利用lookasidetable中的数据重新构建内存page。 -
WT_REF_LOCKED - 当page被evict时,会将page锁住,其它线程不可访问
-
WT_REF_MEM - page已经从磁盘读到内存,并且能正常访问
-
WT_REF_READING - page正在被某个线程从磁盘读到内存,其它的读线程等待它被读完,不需要重复去读
-
WT_REF_SPLIT - 当page变得过大时,会被split,状态设为WT_REF_SPLIT,原来指向的page不再被使用
Page的大小参数
无论将数据从磁盘读到内存,还是从内存写到磁盘,都是以page为单位调度的,但是在磁盘上一个page到底多大?是否是最小分割单元?以及内存里面各种page的大小对存储引擎的性能是否有影响?
参考以上问题,page大小涉及的相关参数如下:
参数名称 | 默认配置值 | 含义 |
---|---|---|
allocation_size | 4kb | 磁盘上最小分配单元 |
memory_page_max | 5mb | 内存中允许的最大page值 |
internal_page_max | 4kb | 磁盘上允许的最大internal page 值 |
leaf_page_max | 32kb | 磁盘上允许的最大leaf page 值 |
internal_key_max | 1/10*internal_page | internal page 上允许的最大key值 |
leaf_key_max | 1/10*leaf_page | leaf page上允许的最大key值 |
leaf_key_value | 1/2*leaf_page | leaf page上允许的最大value值 |
split_pct | 75% | reconciled的page的分割百分比 |
详细说明如下:
-
allocation_size
MongoDB磁盘文件的最小分配单元(由WiredTiger自带的块管理模块来分配),一个page的可以由一个或多个这样的单元组成;默认值是4KB,与主机操作系统虚拟内存页的大小相当,大多数场景下不需要修改这个值。 -
memory_page_max
WiredTigerCache里面一个内存page随着不断插入修改等操作,允许增长达到的最大值,默认值为5MB。当一个内存page达到这个最大值时,将会被split成较小的内存pages且通过reconcile将这些pages写到磁盘pages,一旦完成写到磁盘,这些内存pages将从内存移除。
需要注意的是:split和reconcile这两个动作都需要获得page的排它锁,导致应用程序在此page上的其它写操作会等待,因此设置一个合理的最大值,对系统的性能也很关键。
如果值太大,虽然spilt和reconcile发生的机率减少,但一旦发生这样的动作,持有排它锁的时间会较长,导致应用程序的插入或修改操作延迟增大; 如果值太小,虽然单次持有排它锁的时间会较短,但是会导致spilt和reconcile发生的机率增加。 -
internal_page_max
磁盘上internalpage的最大值,默认为4KB。随着reconcile进行,internalpage超过这个值时,会被split成多个pages。
这个值的大小会影响磁盘上B-Tree的深度和internalpage上key的数量,如果太大,则internalpage上的key的数量会很多,通过遍历定位到正确leaf page的时间会增加;如果太小,则B-Tree的深度会增加,也会影响定位到正确leaf page的时间。 -
leaf_page_max
磁盘上leaf page的最大值,默认为32KB。随着reconcile进行,leaf page超过这个值时,会被split成多个pages。
这个值的大小会影响磁盘的I/O性能,因为我们在从磁盘读取数据时,总是期望一次I/O能多读取一点数据,所以希望把这个参数调大;但是太大,又会造成读写放大,因为读出来的很多数据可能后续都用不上。 -
internal_key_max
internalpage上允许的最大key值,默认大小为internalpage初始值的1/10,如果超过这个值,将会额外存储。导致读取key时需要额外的磁盘I/O。 -
leaf_key_max
leaf page上允许的最大key值,默认大小为leaf page初始值的1/10,如果超过这个值,将会额外存储。导致读取key时需要额外的磁盘I/O。 -
leaf_value_max
leaf page上允许的最大value值(保存真正的集合数据),默认大小为leaf page初始值的1/2,如果超过这个值,将会额外存储。导致读取value时需要额外的磁盘I/O。 -
split_pct
内存里面将要被reconciled的 page大小与internal_page_max或leaf_page_max值的百分比,默认值为75%,如果内存里面被reconciled的page能够装进一个单独的磁盘page上,则不会发生spilt,否则按照该百分比值*最大允许的page值分割新page的大小。
WiredTired Cache分配规则(Page淘汰前置原因)
WiredTired启动的时候会向操作系统申请一部分内存给自己使用,这部分内容我们称为Internal Cache,如果主机上只运行MongoDB相关的服务进程,则剩下的内存可以作为文件系统的缓存(File System Cache)并由操作系统负责管理
MongoDB启动时,首先从整个主机内存中切一大块出来分给WiredTiger的Internal Cache,用于构建B-Tree中的各种page以及基于这些page的增删改查等操作。
从MongoDB3.4版本开始,默认的Internal Cache大小由下面的规则决定:比较50% of (RAM – 1 GB)和256MB的大小,取其中较大者。例如,假设主机内存为10GB,则Internal Cache取值为50% of (10GB – 1 GB),等于4.5GB;
如果主机内存为1.2GB,则Internal Cache取值为256MB。
然后,会从主机内存再额外划一小块给MongoDB创建索引专用,默认最大值为500MB,这个规则适用于所有索引的构建,包括多个索引同时构建时。
最后,会将主机剩余的内存(排除其它进程的使用)作为文件系统缓存,供MongoDB使用,这样MongoDB可将压缩的磁盘文件也缓存到内存中,从而减少磁盘I/O。
为了节省磁盘空间,集合和索引在磁盘上的数据是被压缩的,默认情况下集合采取的是块压缩算法,索引采取的是前缀压缩算法。因此,同一份数据在磁盘、文件系统缓存和Internal Cache三个位置的格式是不一样的,如下描述:
-
所有数据在File System Cache中的格式和在磁盘上的格式是一致的,将数据先加载到文件系统缓存,不但可以减少磁盘I/O次数,还能减少内存的占用;
-
索引数据加载到WiredTiger的Internal Cache后,格式与磁盘上的格式不一样,但仍能利用其前缀压缩的特性(即去掉索引字段上重复的前缀)减少对内存的占用;
-
集合数据加载到WiredTiger的Internal Cache后,其数据必须解压后才能被后续各种操作使用,因此格式与磁盘上和File System Cache都不一样。
Page被淘汰的时机-Page淘汰机制(Page eviction)
当cache里面的"脏页"达到一定比例或cache使用量达到一定比例时就会触发相应的evict page线程来将pages(包含干净的pages和脏pages)按一定的算法(LRU队列)淘汰出去,以便腾挪出内存空间,保障后面新的插入或修改等操作。
参数名称 | 默认配置值 | 含义 |
---|---|---|
eviction_target | 80% | 当cache的使用量达到80%时触发work thread 淘汰page |
eviction_trigger | 90% | 当cache的使用量达到90%时触发application thread 和 work thread 淘汰page |
eviction_dirty_target | 5% | 当脏数据所占cache比例达到5%时触发work thread 淘汰 page |
eviction_dirty_trigger | 20% | 当脏数据所占cache比例达到20%时触发applicationthread和 work thread 淘汰page |
第一种情况:当cache的使用量占比达到参数eviction_ target设定值时(默认为80%),会触发后台线程执行page eviction,此时应用线程未阻塞,读写操作仍在正常进行;
如果使用量继续增长达到eviction_trigger参数设定值时(默认为90%),应用线程支撑的读写操作等请求将被阻塞,应用线程也参与到页面的淘汰中,加速淘汰内存中pages。
第二种情况:当cache里面的“脏数据”达到参数eviction_dirty_target设定值时(默认为5%),会触发后台线程执行page eviction,此时应用线程未阻塞,读写操作仍在正常进行;
如果“脏数据”继续增长达到参数eviction_dirty_trigger设定值(默认为20%),同时会触发应用线程来执行page eviction,应用线程支撑的读写操作等请求将被阻塞。
还有一种特性情况:当在page上不断进行插入或更新时,如果页上内容占用内存空间的大小大于系统设定的最大值(memory_page_max),则会强制触发page eviction动作。
先通过将此大的page拆分为多个小的page,再通过reconcile将这些小的pages保存到磁盘上,一旦reconcile写入磁盘完成,这些pages就能从cache中淘汰出去,从而为后面更多的写入操作腾出空间。
默认情况下WiredTiger只使用一个后台线程来完成page eviction,为了提升eviction的性能,我们可以通过参数threads_min和threads_max来设定evict server启动的后台线程数。
通过设定合理值,加速页面淘汰,避免淘汰不及时导致应用线程也被迫加入到淘汰任务中来,造成应用线程对其它正常请求操作的阻塞。
淘汰一个page时,会先锁住这个page,再检查这个page上是否有其它线程还在使用(判断是否有hazard point指针指向它),如有则不会evict这个page。
page被淘汰到底发生了什么(Page reconcile)
数据从磁盘page加载到内存后被查询和修改,被修改的数据和新插入的数据也需要从内存写到磁盘进行保存
WiredTiger实现了一个叫reconcile模块来完成将内存里面的修改的数据生成相应磁盘映像(与磁盘上的page格式匹配),然后再将这些磁盘映像写到磁盘上。
checkpoint原理
Checkpoint主要有两个目的
一是将内存里面发生修改的数据写到数据文件进行持久化保存,确保数据一致性;
二是实现数据库在某个时刻意外发生故障,再次启动时,缩短数据库的恢复时间。
WiredTiger存储引擎中的Checkpoint模块就是来实现这个功能的。
本质上来说,checkpoint相当于一个日志,记录了上次Checkpoint后相关数据文件的变化
一个CheckPoint包含关键信息如下图所示:
每个checkpoint包含一个root page、三个指向磁盘具体位置上pages的列表以及磁盘上文件的大小。
详细字段信息描述如下:
root page
包含rootpage的大小(size),在文件中的位置(offset),校验和(checksum),创建一个checkpoint时,会生成一个新root page。
allocated list pages
用于记录最后一次checkpoint之后,在这次checkpoint执行时,由WiredTiger块管理器新分配的pages,会记录每个新分配page的size,offset和checksum。
discarded list pages
用于记录最后一次checkpoint之后,在这次checkpoint执行时,丢弃的不在使用的pages,会记录每个丢弃page的size,offset和checksum。
available list pages
在这次checkpoint执行时,所有由WiredTiger块管理器分配但还没有被使用的pages;当删除一个之前创建的checkpoint时,它所附带的可用pages将合并到最新的这个checkpoint的可用列表上,也会记录每个可用page的size,offset和checksum。
file size
在这次checkpoint执行后,磁盘上数据文件的大小。
Checkpoint执行的完整流程
流程描述如下:
-
查询集合数据时,会打开集合对应的数据文件并读取其最新checkpoint数据
-
集合文件会按checkponit信息指定的大小(file size)被truncate掉,所以系统发生意外故障,恢复时可能会丢失checkponit之后的数据(如果没有开启Journal)
-
在内存构造一棵包含root page的live tree,表示这是当前可以修改的checkpoint结构,用来跟踪后面写操作引起的文件变化;其它历史的checkpoint信息只能读,可以被删除
-
内存里面的page随着增删改查被修改后,写入并需分配新的磁盘page时,将会从livetree中的available列表中选取可用的page供其使用。随后,这个新的page被加入到checkpoint的allocated列表中
-
如果一个checkpoint被删除时,它所包含的allocated和discarded两个列表信息将被合并到最新checkpoint的对应列表上;任何不再需要的磁盘pages,也会将其引用添加到live tree的available列表中
-
当新的checkpoint生成时,会重新刷新其allocated、available、discard三个列表中的信息,并计算此时集合文件的大小以及rootpage的位置、大小、checksum等信息,将这些信息作checkpoint元信息写入文件
-
生成的checkpoint默认名称为WiredTigerCheckpoint,如果不明确指定其它名称,则新check point将自动取代上一次生成的checkpoint
Checkpoint执行的触发时机
触发checkpoint执行,通常有如下几种情况:
-
按一定时间周期:默认60s,执行一次checkpoint
-
按一定日志文件大小:当Journal日志文件大小达到2GB(如果已开启),执行一次checkpoint
-
任何打开的数据文件被修改,关闭时将自动执行一次checkpoint。 注意:checkpoint是一个相当重量级的操作,当对集合文件执行checkpoint时,会在文件上获得一个排它锁,其它需要等待此锁的操作,可能会出现EBUSY的错误
WiredTired事务实现
WT在实现事务的时使用主要是使用了三个技术:snapshot(事务快照)、MVCC (多版本并发控制)和redo log(重做日志),为了实现这三个技术,它还定义了一个基于这三个技术的事务对象和全局事务管理器。
事务对象描述如下
wt_transaction{
transaction_id: 本次事务的**全局唯一的ID**,用于标示事务修改数据的版本号
snapshot_object: 当前事务开始或者操作时刻其他正在执行且并未提交的事务集合,用于事务隔离
operation_array: 本次事务中已执行的操作列表,用于事务回滚。
redo_log_buf: 操作日志缓冲区。用于事务提交后的持久化
state: 事务当前状态
}
WT的事务快照
事务开始或者进行操作之前对整个WT引擎内部正在执行或者将要执行的事务进行一次截屏,保存当时整个引擎所有事务的状态,确定哪些事务是对自己见的,哪些事务都自己是不可见。
WT引擎中的snapshot_oject是有一个最小执行事务snap_min、一个最大事务snap max和一个处于[snap_min, snap_max]区间之中所有正在执行的写事务序列组成。
WT的多版本并发控制
WT中的MVCC是基于key/value中value值的链表,这个链表单元中存储有当先版本操作的事务ID和操作修改后的值。
描述如下
wt_mvcc{
transaction_id: 本次修改事务的ID
value: 本次修改后的值
}
WT中的数据修改都是在这个链表中进行append操作,每次对值做修改都是append到链表头上,每次读取值的时候读是从链表头根据值对应的修改事务transaction_id和本次读事务的snapshot来判断是否可读,如果不可读,向链表尾方向移动,直到找到读事务能都的数据版本。
全局事务管理器
要创建整个系统事务的快照截屏,就需要一个全局的事务管理来进行事务截屏时的参考。
wt_txn_global{
current_id: 全局写事务ID产生种子,一直递增
oldest_id: 系统中最早产生且还在执行的写事务ID
transaction_array: 系统事务对象数组,保存系统中所有的事务对象
scan_count: 正在扫描transaction_array数组的线程事务数,用于建立snapshot过程的无锁并发
}
transaction_array保存的是正在执行事务的区间的事务对象序列。在建立snapshot时,会对整个transaction_array做扫描,确定snap_min/snap_max/snap_array这三个参数和更新oldest_id,在扫描的过程中,凡是transaction_id不等于WT_TNX_NONE都认为是在执行中且有修改操作的事务,直接加入到snap_array当中。整个过程是一个无锁操作过程,这个过程如下:
创建snapshot截屏的过程在WT引擎内部是非常频繁,尤其是在大量自动提交型的短事务执行的情况下,由创建snapshot动作引起的CPU竞争是非常大的开销,所以这里WT并没有使用spin lock ,而是采用了上图的一个无锁并发设计,这种设计遵循了我们开始说的并发设计原则。
从WT引擎创建事务snapshot的过程中现在可以确定,snapshot的对象是有写操作的事务,纯读事务是不会被snapshot的,因为snapshot的目的是隔离mvcc list中的记录,通过MVCC中value的事务ID与读事务的snapshot进行版本读取,与读事务本身的ID是没有关系。在WT引擎中,开启事务时,引擎会将一个WT_TNX_NONE( = 0)的事务ID设置给开启的事务,当它第一次对事务进行写时,会在数据修改前通过全局事务管理器中的current_id来分配一个全局唯一的事务ID。这个过程也是通过CPU的CAS_ADD原子操作完成的无锁过程。
MongoDB副本集及分片集群
MongoDB副本集
为什么要引入复制集
保证数据在生产部署时的冗余和可靠性,通过在不同机器上保存副本来保证数据不会因为单点损失而丢失。能够随时应对数据丢失,机器损坏带来的风险。
在MongoDB中就是复制集(replica set): 一组复制集就是一组mongodb实例掌管同一个数据集,实例可以在不同的机器上面。实例中包含一个主导,接受客户端所有的写入操作,其他都是副本实例,从主服务器上获得数据并保持同步。
副本集是一组维护相同数据集合的mongod实例。副本集包含多个数据承载节点和一个可选的仲裁节点。在数据承载节点中,有且仅有一个成员为主节点,其他节点为副本节点。
主节点接收所有的写操作。一个副本集仅有一个主节点能够用写关注点级别来确认写操作,虽然在某些情况下,另一个mongod的实例也可以暂时认为自己是主节点。
复制集成员:
主节点(Primary)
包含了所有的写操作的日志。但是副本服务器集群包含所有的主服务器数据,因此当主服务器挂掉了,就会在副本服务器上重新选取一个成为主服务器。
从节点(Seconary)
正常情况下,复制集的Seconary会参与Primary选举(自身也可能会被选为Primary),并从Primary同步最新写入的数据,以保证与Primary存储相同的数据。
Secondary可以提供读服务,增加Secondary节点可以提供复制集的读服务能力,同时提升复制集的可用性。另外MongoDB支持对复制集的Secondary节点进行灵活的配置,以适应多种场景的需求。
仲裁节点(Arbiter)
Arbiter节点只参与投票,不能被选为Primary,并且不从Primary同步数据。
仲裁节点永远只能是仲裁节点,但在选举过程中主节点也许会降级成为副本节点,副本节点也可能会升级成主节点。
复制集是如何保证数据高可用的
- 选举机制
- 故障转移期间的回滚
选举机制
复制集通过选举机制来选择主节点
如何选出Primary主节点的?
假设复制集内能够投票的成员数量为N,则大多数为N/2 + 1,当复制集内存活成员数量不足大多数时,整个复制集将无法选举出Primary,复制集将无法提供写服务,处于只读状态。
在什么情况下会触发选举机制?
在以下的情况下会触发选举机制:
-
往复制集中新加入节点
-
初始化复制集
-
对复制集进行维护时,比如rs.stepDown()或者 rs.reconfig()操作时
-
从节点失联时,比如超时(默认是10s)
异步复制及慢操作
副本节点复制主节点的oplog并异步地应用操作到它们的数据集。通过让副本节点的数据集反映主服务器的数据集,副本集可以在一个或多个成员失败的情况下继续运行。
从4.2版本开始(从4.0.6开始也是可行的),副本集的副本成员会记录oplog中应用时间超过慢操作阈值的慢操作条目。这些慢oplog信息被记录在从节点的诊断日志 中,其路径位于REPL 组件的文本applied op: took ms中。这些慢日志条目仅仅依赖于慢操作阈值。它们不依赖于日志级别(无论是系统还是组件级别)、过滤级别,或者慢操作采样比例。过滤器不会捕获慢日志条目。
自动故障转移
当主节点无法和集群中的其他节点通信的时间超过electionTimeoutMillis配置的期限时(默认10s),一个候选的副本节点会发起选举来推荐自己成为新主节点。集群会尝试完成一次新主节点的选举并恢复正常的操作。
副本集在选举成功前是无法处理写操作的。如果读请求被配置运行在副本节点上,则当主节点下线时,副本集可以继续处理这些请求。
假设采用默认的副本配置选项,集群选择新主节点的中间过渡时间通常不应超过12秒。这包括了将主节点标记为unavailable、发起以及完成一次选举的时间。可以通过修改settings.electionTimeoutMillis 复制配置选项来调整这个时间期限。网络延迟等因素可能会延长完成副本集选举所需的时间,从而影响集群在没有主节点的情况下运行的时间。这些因素取决于实际的集群架构情况。
将electionTimeoutMillis复制配置选项从默认的10000(10秒)降低可以更快地检测主节点故障。然而,由于诸如临时性的网络延迟等因素,集群可能会更频繁地发起选举,即使主节点在其他方面是健康的。这也许会增加w : 1 级别写操作发生回滚的可能性。
应用程序连接逻辑应该包括对自动故障转移和后续选举的容错处理能力。从MongoDB 3.6开始,MongoDB驱动程序可以探测到主节点的丢失,并自动重试某些写操作一次,提供额外的自动故障转移和选举的内置处理:
MongoDB 4.2兼容的驱动程序默认启用可重试写
MongoDB 4.0和3.6兼容的驱动程序必须通过在 连接字符串中包含retryWrites=true来显式地启用可重试写。
复制集中的OpLog
oplog(操作日志)是一个特殊的有上限的集合(老的日志会被overwrite),它保存所有修改数据库中存储的数据的操作的滚动记录。
什么是Oplog
MongoDB在主节点上应用数据库操作,然后将这些操作记录到optlog中。然后从节点通过异步进程复制和应用(数据同步)这些操作。在local.oplog.rs集合中,所有复制集成员都包含oplog的一个副本用来维护数据库的当前状态。
MongoDB 4.4支持以小时为单位指定最小操作日志保留期,其中MongoDB仅在以下情况下删除操作日志条目:
-
oplog已达到配置的最大大小
-
oplog条目早于配置的小时数
复制集中的数据同步(重点)
复制集中的数据同步是为了维护共享数据集的最新副本,包括复制集的辅助成员同步或复制其他成员的数据。 MongoDB使用两种形式的数据同步:
-
初始同步(Initial Sync) - 以使用完整的数据集填充新成员, 即全量同步
-
复制(Replication) - 以将正在进行的更改应用于整个数据集, 即增量同步
初始同步(Initial Sync)
从节点当出现如下状况时,需要先进行全量同步
-
oplog为空
-
local.replset.minvalid集合里_initialSyncFlag字段设置为true
-
内存标记initialSyncRequested设置为true
这3个场景分别对应:
-
新节点加入,无任何oplog,此时需先进行initial sync
-
initial sync开始时,会主动将_initialSyncFlag字段设置为true,正常结束后再设置为false;如果节点重启时,发现_initialSyncFlag为true,说明上次全量同步中途失败了,此时应该重新进行initial sync
-
当用户发送resync命令时,initialSyncRequested会设置为true,此时会重新开始一次initial sync
Initial Sync流程
-
全量同步开始,设置minvalid集合的_initialSyncFlag
-
获取同步源上最新oplog时间戳为t1
-
全量同步集合数据 (耗时)
-
获取同步源上最新oplog时间戳为t2
-
重放[t1, t2]范围内的所有oplog
-
获取同步源上最新oplog时间戳为t3
-
重放[t2, t3]范围内所有的oplog
-
建立集合所有索引 (耗时)
-
获取同步源上最新oplog时间戳为t4
-
重放[t3, t4]范围内所有的oplog
-
全量同步结束,清除minvalid集合的_initialSyncFlag
注:
- 重试流程:如果同步期间碰到一些标记为RetriableError的错误,则会进行重试,直到重试超过initialSyncTransientErrorRetryPeriodSeconds才会标记为永久性错误。这个时候,会进行切换sync source并重试整个initial sync的流程
- Oplog拉取(Oplog Fetching)
-
OplogFetcher从全量开始就启动拉取oplog
-
全量期间将获取到oplog缓存local.temp_oplog_buffer表
-
稳态复制期间将oplog缓存到OplogBuffer
-
OplogFetcher获取失败,重新选取同步源
复制(Replication)
initial sync结束后,接下来Secondary就会『不断拉取主上新产生的optlog并重放』
- producer thread,这个线程不断的从同步源上拉取oplog,并加入到一个BlockQueue的队列里保存着。
- replBatcher thread,这个线程负责逐个从producer thread的队列里取出oplog,并放到自己维护的队列里。
- sync线程将replBatcher thread的队列分发到默认16个replWriter线程,由replWriter thread来最终重放每条oplog。
问题来了,为什么一个简单的『拉取oplog并重放』的动作要搞得这么复杂?
性能考虑,拉取oplog是单线程进行,如果把重放也放到拉取的线程里,同步势必会很慢;所以设计上producer thread只干一件事。
为什么不将拉取的oplog直接分发给replWriter thread,而要多一个replBatcher线程来中转?
oplog重放时,要保持顺序性,而且遇到createCollection、dropCollection等DDL命令时,这些命令与其他的增删改查命令是不能并行执行的,而这些控制就是由replBatcher来完成的。
优化方案(重点):
-
initial sync单线程复制数据,效率比较低,生产环境应该尽量避免initial sync出现,需合理配置oplog,按默认『5%的可用磁盘空间』来配置oplog在绝大部分场景下都能满足需求,特殊的case(case1, case2)可根据实际情况设置更大的oplog。
-
新加入节点时,可以通过物理复制的方式来避免initial sync,将Primary上的dbpath拷贝到新的节点,直接启动,这样效率更高。
-
当Secondary上需要的oplog在同步源上已经滚掉时,Secondary的同步将无法正常进行,会进入RECOVERING的状态,需向Secondary主动发送resyc命令重新同步。
-
生产环境,最好通过db.printSlaveReplicationInfo()来监控主备同步滞后的情况,当Secondary落后太多时,要及时调查清楚原因。
-
当Secondary同步滞后是因为主上并发写入太高导致,(db.serverStatus().metrics.repl.buffer.sizeBytes持续接近db.serverStatus().metrics.repl.buffer.maxSizeBytes),可通过调整Secondary上replWriter并发线程数来提升。
副本集帮助我们解决读请求扩展、高可用等问题。那么业务场景进一步增长,会出现以下问题:
-
存储容量超过单机磁盘容量
-
活跃数据集超出单机内存容量:很多读请求需要从磁盘读取
-
写入量超出单机IOPS上限
此时我们需要对MongoDB做分片集群
MongoDB分片集群
分片为应对高吞吐量与大数据量提供了方法。使用分片减少了每个分片需要处理的请求数,因此,通过水平扩展,集群可以提高自己的存储容量和吞吐量。举例来说,当插入一条数据时,应用只需要访问存储这条数据的分片.
MongoDB分片集群是对数据进行水平扩展的一种方式。
MongoDB使用分片集群支持对大数据集和高吞吐量的业务场景
MongoDB分片集群的基本架构
MongoDB分片集群包括以下组件:
- ShardServer - 每个shard(分片)包含被分片的数据集中的一个子集。每个分片可以被部署为副本集架构。(部署为副本集来保证高可用)
- mongos - mongos充当查询路由器,作为分片集群的入口,在客户端应用程序和分片集群之间提供接口,对请求进行路由、分发、合并(部署多个mongos来保证高可用)。
- configServer - config servers存储了分片集群的元数据和配置信息(部署为副本集来保证高可用)。
MongoDB分片集群(Sharded Cluster)通过将数据分散存储到多个分片(Shard)上,来实现高可扩展性。实现分片集群时,MongoDB引入Config Server来存储集群的元数据,引入mongos作为应用访问的入口,mongos从Config Server读取路由信息,并将请求路由到后端对应的Shard上。
注:
-
用户访问mongos跟访问单个mongod类似
-
所有mongos是对等关系,用户访问分片集群可通过任意一个或多个mongos
-
mongos本身是无状态的,可任意扩展,集群的服务能力为[Shard服务能力之和]与[mongos服务能力之和]的最小值
-
访问分片集群时,最好将应用负载均衡的分散到多个mongos上
MongoDB分片集群连接Connection String
Connection String URI: mongodb://[username:password@][host1:port1],[host2:port2],...,[hostN:portN]/[database][?options]
-
mongodb --- // 前缀,代表这是一个Connection String
-
username:password@ --- 如果启用了鉴权,需要指定用户密码
-
hostX:portX --- 多个 mongos 的地址列表
-
/database --- 鉴权时,用户帐号所属的数据库
-
?options --- 指定额外的连接选项
通过Java连接的demo:
MongoClientURI connectionString = new MongoClientURI("mongodb://:****@s-m5e80a9241323604.mongodb.rds.aliyuncs.com:3717,s-m5e053215007f404.mongodb.rds.aliyuncs.com:3717/admin"); // ****替换为root密码
MongoClient client = new MongoClient(connectionString);
MongoDatabase database = client.getDatabase("mydb");
MongoCollection collection = database.getCollection("mycoll");
在访问分片集群时,请务必确保 MongoDB URI 里包含2个及以上的mongos地址,来实现负载均衡及高可用
常用连接参数(options):
-
readPreference=secondaryPreferred - 实现读写分离,读请求优先到Secondary节点,从而实现读写分离的功能。
-
maxPoolSize=xx - 限制连接数,将客户端连接池限制在xx以内。
-
w=majority - 保证数据写入到大多数节点后才返回。
集合的数据分布
Primary Shard
默认情况下,每个DataBase的集合都是未分片的,存储在一个固定的Shard上,称为Primary Shard
当创建一个新的DataBase时,系统会根据各个Shard目前存储的数据量,选择一个数据量最小的Shard作为新Database的Primary Shard
MongoDB将数据进行分片支持集合级别,已经被分片的集合被切分成多份保存到Shard上。
集合分片键与Chunk管理
chunk
在一个shard server内部,MongoDB还是会把数据分为chunks,每个chunk代表这个shard server内部一部分数据。
chunk的产生,会有以下两个用途:
- Splitting - 当一个chunk的大小超过配置中的chunk size时,MongoDB的后台进程会把这个chunk切分成更小的chunk,从而避免chunk过大的情况
- Balancing - 在MongoDB中,balancer是一个后台进程,负责chunk的迁移,从而均衡各个shard server的负载,系统初始1个chunk,chunk size默认值64M,生产库上选择适合业务的chunk size是最好的。MongoDB会自动拆分和迁移chunks。
MongoDB基于ShardKey将Collection拆分为多个子集,每个子集称为一个chunk
ShardedCollection的数据按照ShardKey划分为minKey~maxKey的区间
每个chunk有自己负责的一个区间(前闭后开)
存储ShardedCollection的Shard上有该Collection的一个或多个chunk
chunk分裂
chunk的分裂和迁移非常消耗IO资源;在插入和更新,读数据不会分裂。
-
伴随着数据的写入,当chunk增长到指定大小(默认64MB)时,MongoDB会对chunk进行分裂,称为chunk split
-
自动触发:插入&更新时会自动触发Chunk Split。ChunkSize被调小时不会立即发生CunkSplit
-
手动触发:sh.splitAt(xxx)/sh.splitFind(xxx)
-
JumboChunk:一个最小的Chunk可以只包含一个唯一的ShardKey,这样的Chunk不可以再进行分裂,称为JomboChunk
调整chunkSize
use config;
db.settings.save({_id:"chunksize",value:});
chunksize的选择:
小的chunksize:数据均衡是迁移速度快,数据分布更均匀。数据分裂频繁,路由节点消耗更多资源。
大的chunksize:数据分裂少。数据块移动集中消耗IO资源。通常100-200M
chunkSize 对分裂及迁移的影响
-
MongoDB 默认的 chunkSize 为64MB,如无特殊需求,建议保持默认值;
-
chunkSize 会直接影响到 chunk 分裂、迁移的行为。 chunkSize 越小,chunk 分裂及迁移越多,数据分布越均衡;反之,chunkSize 越大,chunk 分裂及迁移会更少,但可能导致数据分布不均。
-
chunkSize 太小,容易出现 jumbo chunk(即shardKey 的某个取值出现频率很高,这些文档只能放到一个 chunk 里,无法再分裂)而无法迁移;chunkSize 越大,则可能出现 chunk 内文档数太多(chunk 内文档数不能超过 250000 )而无法迁移。
-
chunk 自动分裂只会在数据写入时触发,所以如果将 chunkSize 改小,系统需要一定的时间来将 chunk 分裂到指定的大小。
-
chunk 只会分裂,不会合并,所以即使将 chunkSize 改大,现有的 chunk 数量不会减少,但 chunk 大小会随着写入不断增长,直到达到目标大小。
chunk迁移
-
为了保证数据负载均衡,MongoDB支持Chunk在Shard间迁移,称为chunk migration
-
自动触发 - 当chunk在shard之间分布不均时,Balancer线程会自动触发Chunk迁移(Chunk数量最多的Shard->chunk数量最少的Shard上迁移)
-
手动触发 - sh.moveChunk(namespace,query,destination)
-
每个Shard同一时间只能有一个chunk在进行迁移
Targeted Operations & Broadcast Operations
特定目标的操作(Targeted Operations) - 根据分片键计算出目标Shard(s),发起请求并返回结果(实现包含分片键的查询操作,基于分片键的更新、删除、插入操作)
广播的操作(Broadcast Operations) - 将请求发送给所有Shard,合并查询结果并返回给客户端(实现不包含分片键的查询操作,基于_id字段的更新、删除操作)
MongoDB的分片分为范围分片及哈希分片
范围分片 - 根据ShardKey的值进行数据分片,将单个Collection的数据分散存储在多个shard上,用户可以指定根据集合内文档的某个字段即shard key来进行范围分片(range sharding)。
哈希分片 - 根据ShardKey计算哈希值,基于哈希值进行数据分片,基于哈希片键最大的好处就是保证数据在各个节点分布基本均匀。
参考资料
MongoDB教程 - Mongo知识体系详解 --- @pdai
WiredTiger存储引擎系列 --- 郭远威
干货分享| MongoDB 中文社区2021长沙大会