从mysql到mongo,mongo技术原理剖析
最近部门在进行业务上云,正好抓着这个机会,研究一下mongo这样的nosql的数据库,在业务中究竟能发挥怎样的作用,是否可以替代业务中某些存储场景,顺便把调研和比较的过程写下来,供大家参考。
[toc]
一、mongo简介
1.1 术语
_id – 这是每个MongoDB文档中必填的字段。_id字段表示MongoDB文档中的唯一值。_id字段类似于文档的主键。如果创建的新文档中没有_id字段,MongoDB将自动创建该字段。
集合 – collection 这是MongoDB文档的分组,集合等效于在任何其他RDMS(例如Oracle或MS SQL)中创建的表,集合存在于单个数据库中。
游标 – cursor 这是指向查询结果集的指针。客户可以遍历游标以检索结果。
数据库 – database 同mysql database。
文档 - MongoDB集合中的记录基本上称为文档。文档包含字段名称和值。
字段 - 文档中的名称/值对。一个文档具有零个或多个字段,字段类似于关系数据库中的列。
mongo术语与msyql术语对比
| sql术语 | mongo术语 | 说明 |
|---|---|---|
| database | database | 数据库 |
| table | collection | |
| row 行 | document 文档 | |
| column 列 | field 字段 | |
| table joins 联表 | mongo不支持 | |
| index | index | 索引 |
| primary key | primary key | mongo自动将_id设为主键 |
1.2 mongo基本功能
1.2.1 文档
mongo的基本存储单位是【文档】,类似于javascript中的json, 文档结构更符合开发人员如何使用各自的编程语言构造其类和对象,包括字典、数组等数据结构,而不必受限sql中的行和列,具有键值对的清晰结构。 mongo和大多数nosql数据库一样,不需要预先定义文档结构,可以动态创建字段。 MongoDB中可用的数据模型可以更直观的存储业务数据的层次、结构和关系。
1.2.2 查询功能
支持按字段,范围查询、json嵌套查询和正则表达式搜索
1.2.3 索引
1.2.3.1 索引类型
Single Field, 支持普通索引或唯一索引
Compound Index. 多字段组合索引,可对每个字段设置排序方法(升序/降序)
Multikey Index. 多键索引,就是对数组中的每一个元素建立索引,我觉得称为数组索引更合理
- Unique Multikey Index, 唯一性约束针对所有文档中该数组字段的合集,即不同文档的数组中,不能有相同key
- Compound Multikey Index, 最多只能有一个字段是数组类型
Geospatial Index. 经纬度索引,其目的是方便经纬度相关的查找操作,是2D Index的一个特例,而2D Index是如何实现的?实现可以多种方式。比如Compound Index。
Text Indexes.也是需要先分词再建立“倒排索引”,可以看做是一个玩具版的ES。估计引用场景也非常简单。搞全文检索有更加专业的工具,没人会用MongoDB。
Hashed Indexes. 哈希索引,不支持范围查询。
1.2.3.2 索引特性
unique, 唯一索引
TTL,过期时间,这种索引允许为每一个文档设置一个超时时间,一个文档达到预设置的老化程度后就会被删除
sparse 稀疏,仅对字段不为null的文档建立索引,降低索引维护成本
partitial 部分索引,稀疏索引的超集,不仅可以对不为null,还可以设置其他过滤条件,比如对age>18的doc才建立索引
References: 官方文档 https://www.mongodb.com/docs/manual/indexes/
1.2.4 扩展性
mongo支持sharding,通过在多个mongo实例之间拆分数据来水平扩展。mongo可以在多台服务器上运行,以平衡负载或复制数据,水平拓展非常方便。
1.2.5 容灾
mongo支持主从节点,通过oplog向从节点同步数据,极端情况可以通过拉起从节点作为主节点使用;
大部分云厂商,支持灾备实例,可在分钟级情况,无感切换到灾备实例。
二、mongo技术原理
2.1 存储引擎
mongo自3.0版本默认使用WiredTiger引擎(下称WT),WT是一个优秀的单机数据库存储引擎,它拥有诸多的特性,既支持BTree索引(默认,下面介绍也已默认使用BTree),也支持LSM Tree索引,支持行存储和列存储,实现ACID级别事务、支持大到4G的记录等。
WT的设计了充分利用CPU并行计算的内存模型的无锁并行框架,使得WT引擎在多核CPU上的表现优于其他存储引擎。针对磁盘存储特性,WT实现了一套基于BLOCK/Extent的友好的磁盘访问算法,使得WT在数据压缩和磁盘I/O访问上优势明显。实现了基于snapshot技术的ACID事务,snapshot技术大大简化了WT的事务模型,摒弃了传统的事务锁隔离又同时能保证事务的ACID。WT根据现代内存容量特性实现了一种基于Hazard Pointer 的LRU cache模型,充分利用了内存容量的同时又能拥有很高的事务读写并发。
2.2 数据/索引结构及page生命周期
对于MongoDB来说,采用了插件式存储引擎架构,底层的WiredTiger存储引擎还可以支持B+Tree和LSM两种结构组织数据,但MongoDB在使用WiredTiger作为存储引擎时,目前默认配置是使用了B+Tree结构。
references:
WiredTiger maintains a table’s data in memory using a data structure called a B-Tree ( B+ Tree to be specific), referring to the nodes of a B-Tree as pages. Internal pages carry only keys. The leaf pages store both keys and values.
2.2.1 磁盘上的基础数据结构
对于WiredTiger存储引擎来说,集合所在的数据文件和相应的索引文件都是按B-Tree结构来组织的,不同之处在于数据文件对应的B-Tree叶子结点上除了存储键名外(keys),还会存储真正的集合数据(values),所以数据文件的存储结构也可以认为是一种B**+**Tree,其整体结构如下图所示:

(图来自mongoDB中文社区)
从上图可以看到,B**+** Tree中的leaf page包含一个页头(page header)、块头(block header)和真正的数据(key/value),其中页头定义了页的类型、页中实际载荷数据的大小、页中记录条数等信息;块头定义了此页的checksum、块在磁盘上的寻址位置等信息。
WiredTiger有一个块设备管理的模块,用来为page分配block。如果要定位某一行数据(key/value)的位置,可以先通过block的位置找到此page(相对于文件起始位置的偏移量),再通过page找到行数据的相对位置,最后可以得到行数据相对于文件起始位置的偏移量offsets。由于offsets是一个8字节大小的变量,所以WiredTiger磁盘文件的大小,其最大值可以非常大(264bit)。
2.2.2 内存上的数据结构
WiredTiger会按需将磁盘的数据以page为单位加载到内存,同时在内存会构造相应的B-Tree来存储这些数据。为了高效的支撑CRUD等操作以及将内存里面发生变化的数据持久化到磁盘上,WiredTiger也会在内存里面维护其它几种数据结构,如下图所示:

(图来自mongoDB中文社区)
上图是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变量以跳转链表的形式构成。
2.2.3 page的生命周期
2.2.1和2.2.2介绍了数据以page为单位加载到cache、cache里面又会生成各种不同类型的page及为不同类型的page分配不同大小的内存、eviction触发机制和reconcile动作都发生在page上、page大小持续增加时会被分割成多个小page,所有这些操作都是围绕一个page来完成的。
因此,有必要系统的分析一页page的生命周期、状态以及相关参数的配置,这对我们理解mongo的执行和缓存机制有很大的帮助。

pages从磁盘读到内存;
pages在内存中被修改;
被修改的脏pages在内存被reconcile,完成后将discard这些pages。
pages被选中,加入淘汰队列,等待被evict线程淘汰出内存;
evict线程会将“干净“的pages直接从内存丢弃(因为相对于磁盘page来说没做任何修改),将经过reconcile处理后的磁盘映像写到磁盘再丢弃“脏的”pages。
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被淘汰出去。
2.3 事务
mongo(WT引擎)下,一共有3种隔离级别:Read-Uncommitted、Read-Commited、Snapshot-isolation
下面将介绍WT引擎是怎么对事务进行管理来保证数据操作的隔离的
2.3.1 Global Transaction Manager 全局事务管理器
WT引擎通过snapshot进行事务隔离,下面是WT的全局事务器的基本结构:
1 | type wt_txn_global struct{ |
下面是一次事务snapshot的创建过程:

创建snapshot的过程在WT引擎内部是非常频繁,尤其是在大量自动提交型的短事务执行的情况下,由创建snapshot引起的CPU竞争是非常大的开销,所以这里WT并没有使用spin lock ,而是采用了上图的一个无锁并发设计,这样能极大的提高事务执行的效率。
事务ID
从WT引擎创建事务snapshot的过程中现在可以确定,snapshot的对象是有写操作的事务,纯读事务是不会被snapshot的,因为snapshot的目的是隔离mvcc list中的记录,通过MVCC中value的事务ID与读事务的snapshot进行版本读取,与读事务本身的ID是没有关系。在WT引擎中,开启事务时,引擎会将一个WT_TNX_NONE( = 0)的事务ID设置给开启的事务,当它第一次对事务进行写时,会在数据修改前通过全局事务管理器中的current_id来分配一个全局唯一的事务ID。这个过程也是通过CPU的CAS_ADD原子操作完成的无锁过程。
2.3.2 事务过程
事务过程一般分为:事务开启、事务执行、事务提交、事务回滚4种情况,下面分别介绍一下WT引擎如何进行这些操作。
2.3.2.1 事务开启
WT事务开启过程中,首先会为事务创建一个事务对象并把这个对象加入到全局事务管理器当中,然后通过事务配置信息确定事务的隔离级别和redo log的刷盘方式并将事务状态设为执行状态,最后判断如果隔离级别是ISOLATION_SNAPSHOT(snapshot级的隔离),在本次事务执行前创建一个系统并发事务的snapshot截屏。至于为什么要在事务执行前创建一个snapshot,在后面WT事务隔离章节详细介绍。
2.3.2.2 事务执行
事务在执行阶段,如果是读操作,不做任何记录,因为读操作不需要回滚和提交。
如果是写操作,WT会对每个写操作做详细的记录。在上面介绍的事务对象(wt_transaction)中有两个成员,一个是操作operation_array,一个是redo_log_buf。这两个成员是来记录修改操作的详细信息,在operation_array的数组中,包含了一个指向MVCC list对应修改版本值的指针。那么详细的更新操作流程如下:
- 创建一个mvcclist中的值单元对象(update)
- 根据事务对象的transactionid和事务状态判断是否为本次事务创建了写的事务ID,如果没有,为本次事务分配一个事务ID,并将事务状态设成HAS_TXN_ID状态。
- 将本次事务的ID设置到update单元中作为mvcc版本号。
- 创建一个operation对象,并将这个对象的值指针指向update,并将这个operation加入到本次事务对象的operation_array
- 将update单元加入到mvcc list的链表头上。
- 写入一条redo log到本次事务对象的redo_log_buf当中。
2.3.2.3事务提交
WT引擎对事务的提交过程比较简单,先将要提交的事务对象中的redo_log_buf中的数据写入到redo log file(重做日志文件)中,并将redo log file持久化到磁盘上。清除提交事务对象的snapshot object,再将提交的事务对象中的transaction_id设置为WT_TNX_NONE,保证其他事务在创建系统事务snapshot时本次事务的状态是已提交的状态。
2.3.2.4 事务回滚
WT引擎对事务的回滚过程也比较简单,先遍历整个operation_array,对每个数组单元对应update的事务id设置以为一个WT_TXN_ABORTED(= uint64_max),标示mvcc 对应的修改单元值被回滚,在其他读事务进行mvcc读操作的时候,跳过这个放弃的值即可。整个过程是一个无锁操作。
2.3.3 隔离级别
下面将根据下图来分别介绍3种事务隔离级别

2.3.3.1 Read-Uncommited 读未提交
Read-Uncommited(未提交读)隔离方式的事务在读取数据时总是读取到系统中最新的修改,哪怕是这个修改事务还没有提交一样读取,这其实就是一种脏读。WT引擎在实现这个隔方式时,就是将事务对象中的snap_object.snap_array置为空即可,那么在读取MVCC list中的版本值时,总是读取到MVCC list链表头上的第一个版本数据。举例说明,在图5中,如果T0/T3/T5的事务隔离级别设置成Read-uncommited的话,那么T1/T3/T5在T5时刻之后读取系统的值时,读取到的都是14。一般数据库不会设置成这种隔离方式,它违反了事务的ACID特性,生产上几乎没有使用场景。
2.3.3.1 Read-Commited 读已提交
Read-Commited(提交读)隔离方式的事务在读取数据时总是读取到系统中最新提交的数据修改,这个修改事务一定是提交状态。这种隔离级别可能在一个长事务多次读取一个值的时候前后读到的值可能不一样,这就是经常提到的“幻读”。在WT引擎实现read-commited隔离方式就是事务在执行每个操作前都对系统中的事务做一次截屏,然后在这个截屏上做读写。上图,T5事务在T4事务提交之前它进行读取前做事务,
1 | snapshot={ |
在读取MVCC list时,12和14修改对应的事务T2、T4都出现在snap_array中,只能再向前读取11,11是T1的修改,而且T1 没有出现在snap_array,说明T1已经提交,那么就返回11这个值给T5。
之后事务T2提交,T5在它提交之后再次读取这个值,会再做一次snapshot
1 | snapshot={ |
这时在读取MVCC list中的版本时,这时由于T2已经提交,就会读取到最新的提交修改12。
2.3.3.1 SnapShot-Isolation快照隔离
Snapshot-Isolation(快照隔离)隔离方式是读事务开始时看到的最后提交的值版本修改,这个值在整个读事务执行过程只会看到这个版本,不管这个值在这个读事务执行过程被其他事务修改了几次,这种隔离方式不会出现“幻读”。WT在实现这个隔离方式很简单,在事务开始时对系统中正在执行的事务做一个snapshot,这个snapshot一直沿用到事务提交或者回滚。上图T5事务在开始时,对系统中的执行的写事务做snapshot
1 | snapshot={ |
那么在他读取值时读取到的是11。即使是T2完成了提交,但T5的snapshot执行过程不会更新,T5读取到的依然是11。
这种隔离方式的写比较特殊,就是如果有对事务看不见的数据修改,那么本事务尝试修改这个数据时会失败回滚,这样做的目的是防止忽略不可见的数据修改。
总结
通过上面对三种事务隔离方式的分析,WT并没有使用传统的事务独占锁和共享访问锁来保证事务隔离,而是通过对系统中写事务的snapshot截屏来实现,这样做保证了mongo的在多事务执行情况下的高并发能力。
2.3.4 事务日志与宕机事务恢复
(todo)
2.4 checkpoint机制
checkpoint是WT进行数据持久化的机制和手段,checkpoint主要用于:
- 将内存里面发生修改的数据写到数据文件进行持久化保存,确保数据一致性
- 实现数据库在某个时刻意外发生故障,再次启动时,缩短数据库的恢复时间
本质上来说,Checkpoint相当于一个日志,记录了上次Checkpoint后相关数据文件的变化。
2.4.1 checkpoint流程
todo
2.5 sharding-分片
高数据量和吞吐量的数据库应用会对单机的性能造成较大压力, 大的查询量会将单机的CPU耗尽, 大的数据量对单机的存储压力较大, 最终会耗尽系统的内存而将压力转移到磁盘IO上。
为了解决这些问题, 有两个基本的方法: 垂直扩展和水平扩展。
垂直扩展:增加更多的CPU和存储资源来扩展容量。水平扩展:将数据集分布在多个服务器,sharding就是水平拓展的方案
sharding思想
分片为应对高吞吐量与大数据量提供了方法。使用分片减少了每个分片需要处理的请求数,因此,通过水平扩展,集群可以提高自己的存储容量和吞吐量。举例来说,当插入一条数据时,应用只需要访问存储这条数据的分片.
sharding目的
- 读/写能力提升
- 存储容量扩容
- 高可用性
2.5.1 shard key - 分片键
shardkey必须是一个索引,通过sh.shardCollection加会自动创建索引,一个自增的分片键对写入和数据均匀分布就不是很好,因为自增的片键总会在一个分片上写入,后续达到某个阀值可能会写到别的分片。
shardkey属性
- shardkey键是不可变。
- shardkey必须有索引。
- shardkey大小限制512bytes。
- shardkey用于路由查询。
- MongoDB不接受已进行collection级分片的collection上插入无分片键的文档(null值也不行)
2.5.2 分片算法
2.5.2.1 Hash Sharding 哈希分片
计算shardkey的哈希值,并用该哈希值来做分片,hash sharding的好处是,shardkey相近的文档,很可能不在同一个chunk,这样数据的分离性会较好。

2.5.2.2 Range Sharding 范围哈希
由用户指定shardkey的范围,根据shardkey的值落在不同范围来决定数据存储在哪个chunk
在使用片键做范围划分的系统中,拥有相近分片键的文档很可能存储在同一个数据块中,因此也会存储在同一个分片中,如下图

2.5.3 分片集群的query
在分片集群的架构下,很多单机查询都无法按原来的方法实现,如sorting、limit、skip,下面会介绍mongo在分片架构下如何处理这些查询
2.5.3.1 sorting
- 假如查询指定了sorting,则mongo会对所有shard返回的结果集,做一个merge sort再返回
- 假如查询没有指定sorting,则哪怕查询用了带索引的字段做条件,mongo的cursor,会对所有shard的结果集,做
round robin返回,这意味着,最终的结果集,最多是部分有序
2.5.3.2 limit
mongo在各个shard查询时,会把limit参数传递给各个shard,然后等各个shard返回后,组装成一个总的result(此时总结过集有n*limit个文档),然后再返回前limit个结果
2.5.3.3 skip
mongo不会把skip参数传给各个shard,而是等所有shard返回结果后,再跳过${skip}个结果然后返回
2.5.3.4 limit+skip
当limit和skip联合使用时,mongo会limit=${skip}+${limit}作为shard的limit传给各个shard来提高操作效率,假如,limit=10,skip=100,则各个shard会执行limit=110的查询,然后再聚合后再skip聚合后的前100个文档,再返回
三、mongo与mysql性能对比
基于业务数据集的性能比较,由于工作保密的缘故不能公开,大家可以参考profile的benchmark,也是比较有代表性的。
3.1 无事务操作性能对比
性能测试的机器参数和驱动代码、测试代码均可参考原文
3.1.1 插入

3.1.2 查询

3.1.3 更新
3.1.4 删除
`
3.2 有(长)事务操作下的性能对比
todo
四、业务选型建议
如果业务符合下列几种情况,个人是十分建议推荐使用mongo 的
- 数据schema变化较大且频繁,mongo动态schema
- 对json类型有强需求
- 数据操作几乎没有事务,或没有跨table(collection)事务
- 数据量大,且业务没有现成的分库分表等中间件
- 非常重要的一点,团队里有对mongo熟悉的开发和运维,当然后者可以通过使用云mongo来代替
五、修改历史
| 修改时间 | 修改内容 |
|---|---|
| 2020-11-05 | 增加事务原理 |
| 2020-11-27 | 增加分片原理 |
六、参考资料
从mysql到mongo,mongo技术原理剖析

