前面介绍了 MongoDB 的安装与基础的 CURD 操作、索引与聚合、基本使用、常用管理命令与授权认证等相关的知识点。今天我将详细的为大家介绍 MongoDB 存储引擎 WiredTiger 相关知识,希望大家能够从中收获多多!如有帮助,请点在看、转发支持一波!!!
WiredTiger从被MongoDB收购到成为MongoDB的默认存储引擎的一年半得到了迅猛的发展,也逐步被外部熟知。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模型,充分利用了内存容量的同时又能拥有很高的事务读写并发。更多关于 MongoDB 数据库的学习文章,请参阅:NoSQL 数据库之 MongoDB ,本系列持续更新中。
存储引擎要做的事情无外乎是将磁盘上的数据读到内存并返回给应用,或者将应用修改的数据由内存写到磁盘上。如何设计一种高效的数据结构和算法是所有存储引擎要考虑的根本问题,目前大多数流行的存储引擎是基于B-Tree或LSM(Log Structured Merge) Tree这两种数据结构来设计的。
像Oracle、SQL Server、DB2、MySQL (InnoDB)和PostgreSQL这些传统的关系数据库依赖的底层存储引擎是基于B-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为核心来分析MongoDB是如何将文档数据在磁盘和内存间进行流传以及WiredTiger存储引擎的其它高级特性。
B-Tree是为磁盘或其它辅助存储设备而设计的一种数据结构,目的是为了在查找数据的过程中减少磁盘I/O的次数。
一个典型的B-Tree结构如下图所示:
在整个B-Tree中,从上往下依次为Root结点、内部结点和叶子结点,每个结点就是一个Page,数据以Page为单位在内存和磁盘间进行调度,每个Page的大小决定了相应结点的分支数量,每条索引记录会包含一个数据指针,指向一条数据记录所在文件的偏移量。
如上图,假设每个结点100个分支,那么所有叶子结点合起来可以包含100万个键值(等于100_100_100)。通常情况下Root结点和内部结点的Page会驻留在内存中,所以查找一条数据可能只需2次磁盘I/O。但随着数据不断的插入、删除,会涉及到B-Tree结点的分裂、位置提升及合并等操作,因此维护一个B-Tree的平衡也是比较耗时的。
对于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会按需将磁盘的数据以page为单位加载到内存,同时在内存会构造相应的B-Tree来存储这些数据。为了高效的支撑CRUD等操作以及将内存里面发生变化的数据持久化到磁盘上,WiredTiger也会在内存里面维护其它几种数据结构,如下图所示:
上图是WiredTiger在内存里面的大概布局图,通过它我们可梳理清楚存储引擎是如何将数据加载到内存,然后如何通过相应数据结构来支持查询、插入、修改操作的。
WT_ROW
结构的数组变量,将保存从磁盘leaf page读取的keys/values值,每一条记录还有一个cell_offset变量,表示这条记录在page上的偏移量;WT_UPDATE
结构的数组变量,每条被修改的记录都会有一个数组元素与之对应,如果某条记录被多次修改,则会将所有修改值以链表形式保存。WT_INSERT_HEAD
结构的数组变量,具体插入的data会保存在WT_INSERT_HEAD
结构中的WT_UPDATE
属性上,且通过key属性的offset和size可以计算出此条记录待插入的位置;同时,为了提高寻找待插入位置的效率,每个WT_INSERT_HEAD
变量以跳转链表的形式构成。下图是一个跳转链表的插入示例:
假如现在插入一个16,最终结果如下:
如果是一个普通的链表,寻找合适的插入位置时,需要经过:
开始结点->2->5->8->10->20的比较;
对于跳转链表来说只需经过:开始结点->5->10->20的比较,可以看到比在普通链表上寻找插入位置时需要的比较步骤少,所以,通过跳转链表的数据结构能够提升插入操作的效率。
对于一个面向行存储的leaf page来说,包含的数据结构除了上面提到的WT_ROW
(keys/values)、WT_UPDATE
(修改数据)、WT_INSERT_HEAD
(插入数据)外,还有如下几种重要的数据结构:
保存page上事务、脏数据字节大小等与page修改相关的信息;
page的read generation值作为evict page时使用,具体来说对应page在LRU队列中的位置,决定page被evict server选中淘汰出去的先后顺序。
page关联的lookasidetable数据。当对一个page进行reconcile时,如果系统中还有之前的读操作正在访问此page上修改的数据,则会将这些数据保存到lookasidetable;当page再被读时,可以利用lookasidetable中的数据重新构建内存page.
page被成功reconciled后,对应的磁盘上块的地址,将按这个地址将page写到磁盘,块是最小磁盘上文件的最小分配单元,一个page可能有多个块。
page的校验和,如果page从磁盘读到内存后没有任何修改,比较checksum可以得到相等结果,那么后续reconcile这个page时,不会将这个page的再重新写入磁盘。更多关于 MongoDB 数据库的学习文章,请参阅:NoSQL 数据库之 MongoDB ,本系列持续更新中。
通过前文我们了解到数据以page为单位加载到cache、cache里面又会生成各种不同类型的page及为不同类型的page分配不同大小的内存、eviction触发机制和reconcile动作都发生在page上、page大小持续增加时会被分割成多个小page,所有这些操作都是围绕一个page来完成的。
因此,有必要系统的分析一页page的生命周期、状态以及相关参数的配置,这对后续MongoDB的性能调优和故障问题的定位和解决有帮助。
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进行reconcile时,如果系统中还有之前的读操作正在访问此page上修改的数据,则会将这些数据保存到lookasidetable;当page再被读时,可以利用lookasidetable中的数据重新构建内存page。
无论将数据从磁盘读到内存,还是从内存写到磁盘,都是以page为单位调度的,但是在磁盘上一个page到底多大?是否是最小分割单元?以及内存里面的各种page的大小对存储引擎的性能是否有影响?本节将围绕这些问题,分析与page大小相关的参数是如何影响存储引擎性能的。总的来说,涉及到的关键参数和默认值如下表所示:
MongoDB磁盘文件的最小分配单元(由WiredTiger自带的块管理模块来分配),一个page的可以由一个或多个这样的单元组成;默认值是4KB,与主机操作系统虚拟内存页的大小相当,大多数场景下不需要修改这个值。
WiredTigerCache里面一个内存page随着不断插入修改等操作,允许增长达到的最大值,默认值为5MB。当一个内存page达到这个最大值时,将会被split成较小的内存pages且通过reconcile将这些pages写到磁盘pages,一旦完成写到磁盘,这些内存pages将从内存移除。
需要注意的是:split和reconcile这两个动作都需要获得page的排它锁,导致应用程序在此page上的其它写操作会等待,因此设置一个合理的最大值,对系统的性能也很关键。
如果值太大,虽然spilt和reconcile发生的机率减少,但一旦发生这样的动作,持有排它锁的时间会较长,导致应用程序的插入或修改操作延迟增大;
如果值太小,虽然单次持有排它锁的时间会较短,但是会导致spilt和reconcile发生的机率增加。
磁盘上internalpage的最大值,默认为4KB。随着reconcile进行,internalpage超过这个值时,会被split成多个pages。
这个值的大小会影响磁盘上B-Tree的深度和internalpage上key的数量,如果太大,则internalpage上的key的数量会很多,通过遍历定位到正确leaf page的时间会增加;如果太小,则B-Tree的深度会增加,也会影响定位到正确leaf page的时间。
磁盘上leaf page的最大值,默认为32KB。随着reconcile进行,leaf page超过这个值时,会被split成多个pages。
这个值的大小会影响磁盘的I/O性能,因为我们在从磁盘读取数据时,总是期望一次I/O能多读取一点数据,所以希望把这个参数调大;但是太大,又会造成读写放大,因为读出来的很多数据可能后续都用不上。
internalpage上允许的最大key值,默认大小为internalpage初始值的1/10,如果超过这个值,将会额外存储。导致读取key时需要额外的磁盘I/O。
leaf page上允许的最大key值,默认大小为leaf page初始值的1/10,如果超过这个值,将会额外存储。导致读取key时需要额外的磁盘I/O。
leaf page
上允许的最大value值(保存真正的集合数据),默认大小为leaf page初始值的1/2,如果超过这个值,将会额外存储。导致读取value时需要额外的磁盘I/O。
内存里面将要被reconciled的 page大小与internal_page_max
或leaf_page_max
值的百分比,默认值为75%,如果内存里面被reconciled的page能够装进一个单独的磁盘page上,则不会发生spilt,否则按照该百分比值*最大允许的page值分割新page的大小。更多关于 MongoDB 数据库的学习文章,请参阅:NoSQL 数据库之 MongoDB ,本系列持续更新中。
总的来说,Checkpoint主要有两个目的:
本质上来说,Checkpoint相当于一个日志,记录了上次Checkpoint后相关数据文件的变化。
一个Checkpoint包含关键信息如下图所示:
每个checkpoint包含一个root page、三个指向磁盘具体位置上pages的列表以及磁盘上文件的大小。
我们可以通过WiredTiger提供的wt命令工具(工具需要单独编译,下一篇会讲解如何编译安装wt工具)查看每个checkpoints具体信息。
例如,在dbPath指定的data目录下执行如下命令:
wt list -c
输出集合对应数据文件和索引文件的checkpoints信息:
如数据文件file:collection-7-16963667508695721.wt的checkpoint信息:
WiredTigerCheckpoint.1:Sat Apr 11 08:35:59 2020 (size 8 KB)
file-size: 16 KB, checkpoint-size: 4 KB
offset, size, checksum
root : 8192, 4096, 3824871989 (0xe3faea35)
alloc : 12288, 4096, 4074814944 (0xf2e0bde0)
discard : 0, 0, 0 (0)
avail : 0, 0, 0 (0)
如索引文件file:index-8-16963667508695721.wt的checkpoint信息:
WiredTigerCheckpoint.1:Sat Apr 11 08:35:59 2020 (size 8 KB)
file-size: 16 KB, checkpoint-size: 4 KB
offset, size, checksum
root : 8192, 4096, 997122142 (0x3b6ee05e)
alloc : 12288, 4096, 4074814944 (0xf2e0bde0)
discard : 0, 0, 0 (0)
avail : 0, 0, 0 (0)
详细字段信息描述如下:
包含rootpage的大小(size),在文件中的位置(offset),校验和(checksum),创建一个checkpoint时,会生成一个新root page。
用于记录最后一次checkpoint之后,在这次checkpoint执行时,由WiredTiger块管理器新分配的pages,会记录每个新分配page的size,offset和checksum。
用于记录最后一次checkpoint之后,在这次checkpoint执行时,丢弃的不在使用的pages,会记录每个丢弃page的size,offset和checksum。
在这次checkpoint执行时,所有由WiredTiger块管理器分配但还没有被使用的pages;当删除一个之前创建的checkpoint时,它所附带的可用pages将合并到最新的这个checkpoint的可用列表上,也会记录每个可用page的size,offset和checksum。
Checkpoint是数据库中一个比较耗资源的操作,何时触发执行以及以什么样的流程执行是本节要研究的内容,如下所述:
一个checkpoint典型执行流程如下图所述:
流程描述如下:
触发checkpoint执行,通常有如下几种情况:
注意:checkpoint是一个相当重量级的操作,当对集合文件执行checkpoint时,会在文件上获得一个排它锁,其它需要等待此锁的操作,可能会出现EBUSY的错误。更多关于 MongoDB 数据库的学习文章,请参阅:NoSQL 数据库之 MongoDB ,本系列持续更新中。
知道了基本的事务概念和ACID后,来看看WT引擎是怎么来实现事务和ACID的。要了解实现先要知道它的事务的构造和使用相关的技术,WT在实现事务的时使用主要是使用了三个技术:snapshot(事务快照)
、MVCC (多版本并发控制)
和redo log(重做日志)
,为了实现这三个技术,它还定义了一个基于这三个技术的事务对象和全局事务管理器。事务对象描述如下
wt_transaction{
transaction_id: 本次事务的**全局唯一的ID**,用于标示事务修改数据的版本号
snapshot_object: 当前事务开始或者操作时刻其他正在执行且并未提交的事务集合,用于事务隔离
operation_array: 本次事务中已执行的操作列表,用于事务回滚。
redo_log_buf: 操作日志缓冲区。用于事务提交后的持久化
state: 事务当前状态
}
WT中的MVCC是基于key/value中value值的链表,这个链表单元中存储有当先版本操作的事务ID和操作修改后的值。描述如下:
wt_mvcc{
transaction_id: 本次修改事务的ID
value: 本次修改后的值
}
WT中的数据修改都是在这个链表中进行append操作,每次对值做修改都是append到链表头上,每次读取值的时候读是从链表头根据值对应的修改事务transaction_id和本次读事务的snapshot来判断是否可读,如果不可读,向链表尾方向移动,直到找到读事务能都的数据版本。样例如下:
上图中,事务T0发生的时刻最早,T5发生的时刻最晚。T1/T2/T4是对记录做了修改。那么在mvcc list当中就会增加3个版本的数据,分别是11/12/14。如果事务都是基于snapshot级别的隔离,T0只能看到T0之前提交的值10,读事务T3访问记录时它能看到的值是11,T5读事务在访问记录时,由于T4未提交,它也只能看到11这个版本的值。这就是WT 的MVCC基本原理。
上面多次提及事务的snapshot,那到底什么是事务的snapshot呢?其实就是事务开始或者进行操作之前对整个WT引擎内部正在执行或者将要执行的事务进行一次截屏,保存当时整个引擎所有事务的状态,确定哪些事务是对自己见的,哪些事务都自己是不可见。说白了就是一些列事务ID区间。WT引擎整个事务并发区间示意图如下:
WT引擎中的snapshot_oject
是有一个最小执行事务snap_min
、一个最大事务snap max和一个处于[snap_min, snap_max]
区间之中所有正在执行的写事务序列组成。如果上图在T6时刻对系统中的事务做一次snapshot,那么产生的
snapshot_object = {
snap_min=T1,
snap_max=T5,
snap_array={T1, T4, T5},
};
那么T6能访问的事务修改有两个区间:所有小于T1事务的修改[0, T1)
和[snap_min,snap_max]
区间已经提交的事务T2的修改。换句话说,凡是出现在snap_array中或者事务ID大于snap_max的事务的修改对事务T6是不可见的。如果T1在建立snapshot之后提交了,T6也是不能访问到T1的修改。这个就是snapshot方式隔离的基本原理。
通过上面的snapshot的描述,我们可以知道要创建整个系统事务的快照截屏,就需要一个全局的事务管理来进行事务截屏时的参考,在WT引擎中是如何定义这个全局事务管理器的呢?在CPU多核多线程下,它是如何来管理事务并发的呢?下面先来分析它的定义:
wt_txn_global{
current_id: 全局写事务ID产生种子,一直递增
oldest_id: 系统中最早产生且还在执行的写事务ID
transaction_array: 系统事务对象数组,保存系统中所有的事务对象
scan_count: 正在扫描transaction_array数组的线程事务数,用于建立snapshot过程的无锁并发
}
transaction_array
保存的是图2正在执行事务的区间的事务对象序列。在建立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原子操作完成的无锁过程。
一般事务是两个阶段:事务执行和事务提交。在事务执行前,我们需要先创建事务对象并开启它,然后才开始执行,如果执行遇到冲突和或者执行失败,我们需要回滚事务(rollback)。如果执行都正常完成,最后只需要提交(commit)它即可。从上面的描述可以知道事务过程有:创建开启
、执行
、提交
和回滚
。那么从这几个过程中来分析WT是怎么实现这几个过程的。
WT事务开启过程中,首先会为事务创建一个事务对象并把这个对象加入到全局事务管理器当中,然后通过事务配置信息确定事务的隔离级别和redo log
的刷盘方式并将事务状态设为执行状态,最后判断如果隔离级别是ISOLATION_SNAPSHOT(snapshot级的隔离),在本次事务执行前创建一个系统并发事务的snapshot截屏。至于为什么要在事务执行前创建一个snapshot,在后面WT事务隔离章节详细介绍。
事务在执行阶段,如果是读操作,不做任何记录,因为读操作不需要回滚和提交。如果是写操作,WT会对每个写操作做详细的记录。在上面介绍的事务对象(wt_transaction)中有两个成员,一个是操作operation_array
,一个是redo_log_buf
。这两个成员是来记录修改操作的详细信息,在operation_array
的数组单元中,包含了一个指向MVCC list对应修改版本值的指针。那么详细的更新操作流程如下:
示意图如下:
WT引擎对事务的提交过程比较简单,先将要提交的事务对象中的redo_log_buf中的数据写入到redo log file(重做日志文件)中,并将redo log file持久化到磁盘上。清除提交事务对象的snapshot object,再将提交的事务对象中的transaction_id设置为WT_TNX_NONE,保证其他事务在创建系统事务snapshot时本次事务的状态是已提交的状态。
WT引擎对事务的回滚过程也比较简单,先遍历整个operation_array,对每个数组单元对应update的事务id设置以为一个WT_TXN_ABORTED(= uint64_max)
,标示mvcc 对应的修改单元值被回滚,在其他读事务进行mvcc读操作的时候,跳过这个放弃的值即可。整个过程是一个无锁操作,高效、简洁。
传统的数据库事务隔离分为:Read-Uncommited(未提交读)
、Read-Commited(提交读)
、Repeatable-Read(可重复读)
和Serializable(串行化)
,WT引擎并没有按照传统的事务隔离实现这四个等级,而是基于snapshot的特点实现了自己的Read-Uncommited、Read-Commited和一种叫做snapshot-Isolation(快照隔离)的事务隔离方式。在WT中不管是选用的是那种事务隔离方式,它都是基于系统中执行事务的快照截屏来实现的。那来看看WT是怎么实现上面三种方式的。
Read-Uncommited(未提交读)隔离方式的事务在读取数据时总是读取到系统中最新的修改,哪怕是这个修改事务还没有提交一样读取,这其实就是一种脏读。WT引擎在实现这个隔方式时,就是将事务对象中的snap_object.snap_array
置为空
即可,那么在读取MVCC list中的版本值时,总是读取到MVCC list链表头上的第一个版本数据。举例说明,在图5中,如果T0/T3/T5的事务隔离级别设置成Read-uncommited的话,那么T1/T3/T5在T5时刻之后读取系统的值时,读取到的都是14。一般数据库不会设置成这种隔离方式,它违反了事务的ACID特性。可能在一些注重性能且对脏读不敏感的场景会采用,例如网页cache。
Read-Commited(提交读)隔离方式的事务在读取数据时总是读取到系统中最新提交的数据修改,这个修改事务一定是提交状态。这种隔离级别可能在一个长事务多次读取一个值的时候前后读到的值可能不一样,这就是经常提到的“幻象读”。在WT引擎实现read-commited隔离方式就是事务在执行每个操作前都对系统中的事务做一次截屏,然后在这个截屏上做读写。还是来看图5,T5事务在T4事务提交之前它进行读取前做事务
snapshot={
snap_min=T2,
snap_max=T4,
snap_array={T2,T4},
};
在读取MVCC list时,12和14修个对应的事务T2/T4都出现在snap_array中,只能再向前读取11,11是T1的修改,而且T1 没有出现在snap_array,说明T1已经提交,那么就返回11这个值给T5。
之后事务T2提交,T5在它提交之后再次读取这个值,会再做一次
snapshot={
snap_min=T4,
snap_max=T4,
snap_array={T4},
}
这时在读取MVCC list中的版本时,就会读取到最新的提交修改12。
Snapshot-Isolation(快照隔离)隔离方式是读事务开始时看到的最后提交的值版本修改,这个值在整个读事务执行过程只会看到这个版本,不管这个值在这个读事务执行过程被其他事务修改了几次,这种隔离方式不会出现“幻象读”。WT在实现这个隔离方式很简单,在事务开始时对系统中正在执行的事务做一个snapshot,这个snapshot一直沿用到事务提交或者回滚。还是来看图5,T5事务在开始时,对系统中的执行的写事务做
snapshot={
snap_min=T2,
snap_max=T4,
snap_array={T2,T4}
}
那么在他读取值时读取到的是11。即使是T2完成了提交,但T5的snapshot执行过程不会更新,T5读取到的依然是11。
这种隔离方式的写比较特殊,就是如果有对事务看不见的数据修改,那么本事务尝试修改这个数据时会失败回滚,这样做的目的是防止忽略不可见的数据修改。
通过上面对三种事务隔离方式的分析,WT并没有使用传统的事务独占锁和共享访问锁来保证事务隔离,而是通过对系统中写事务的snapshot截屏来实现。这样做的目的是在保证事务隔离的情况下又能提高系统事务并发的能力。
通过上面的分析可以知道WT在事务的修改都是在内存中完成的,事务提交时也不会将修改的MVCC list当中的数据刷入磁盘,那么WT是怎么保证事务提交的结果永久保存呢?WT引擎在保证事务的持久可靠问题上是通过redo log(重做操作日志)的方式来实现的,在本文的事务执行和事务提交阶段都有提到写操作日志。WT的操作日志是一种基于K/V操作的逻辑日志,它的日志不是基于btree page的物理日志。说的通俗点就是将修改数据的动作记录下来,例如:插入一个key= 10,value= 20的动作记录在成:
{
Operation = insert,(动作)
Key = 10,
Value = 20
};
将动作记录的数据以append追加的方式写入到wt_transaction对象中redo_log_buf中,等到事务提交时将这个redo_log_buf中的数据已同步写入的方式写入到WT的重做日志的磁盘文件中。如果数据库程序发生异常或者崩溃,可以通过上一个checkpoint(检查点)位置重演磁盘上这个磁盘文件来恢复已经提交的事务来保证事务的持久性。根据上面的描述,有几个问题需要搞清楚:
在分析这三个问题前先来看WT是怎么管理重做日志文件的,在WT引擎中定义一个叫做LSN序号结构,操作日志对象是通过LSN来确定存储的位置的,LSN就是LogSequence Number(日志序列号),它在WT的定义是文件序号加文件偏移,
wt_lsn{
file: 文件序号,指定是在哪个日志文件中
offset: 文件内偏移位置,指定日志对象文件内的存储文开始位置
}。
WT就是通过这个LSN来管理重做日志文件的。
WT引擎的操作日志对象(以下简称为logrec)对应的是提交的事务,事务的每个操作被记录成一个logop对象,一个logrec包含多个logop,logrec是一个通过精密序列化事务操作动作和参数得到的一个二进制buffer,这个buffer的数据是通过事务和操作类型来确定其格式的。
WT中的日志分为4类:分别是建立checkpoint的操作日志(LOGREC_CHECKPOINT)、普通事务操作日志(LOGREC_COMMIT)、btree page同步刷盘的操作日志(LOGREC_FILE_SYNC)和提供给引擎外部使用的日志(LOGREC_MESSAGE)。这里介绍和执行事务密切先关的LOGREC_COMMIT,这类日志里面由根据K/V的操作方式分为:LOG_PUT(增加或者修改K/V操作)、LOG_REMOVE(单KEY删除操作)和范围删除日志,这几种操作都会记录操作时的key,根据操作方式填写不同的其他参数,例如:update更新操作,就需要将value填上。除此之外,日志对象还会携带btree的索引文件ID、提交事务的ID等,整个logrec和logop的关系结构图如下:
对于上图中的logrec header中的为什么会出现两个长度字段:logrec磁盘上的空间长度和在内存中的长度,因为logrec在刷入磁盘之前会进行空间压缩,那么磁盘上的长度和内存中的长度就不一样了。压缩是根据系统配置可选的。
WT引擎在采用WAL(Write-Ahead Log)方式写入日志,WAL通俗点说就是说在事务所有修改提交前需要将其对应的操作日志写入磁盘文件。在事务执行的介绍小节中我们介绍是在什么时候写日志的,这里我们来分析事务日志是怎么写入到磁盘上的,整个写入过程大致分为下面几个阶段:
整个过程的示意图如下:
WT为了减少日志刷盘造成写IO,对日志罗刷盘操作做了大量的优化,实现一种类似MySQL组提交的刷盘方式。这种刷盘方式会将同时发生提交的事务日志合并到一个slotbuffer中,先完成合并的事务线程会同步等待一个完成刷盘信号,最后完成日志数据合并的事务线程将slotbuffer中的所有日志数据sync到磁盘上并通知在这个slotbuffer中等待其他事务线程刷盘完成。并发事务的logrec合并到slot buffer中的过程是一个完全无锁的过程,这减少了必要的CPU竞争和操作系统上下文切换。
为了这个无锁设计WT在全局的log管理中定义了一个acitve_ready_slot和一个slot_pool数组结构,大致如下定义:
wt_log{
. . .
active_slot: 准备就绪且可以作为合并logrec的slotbuffer对象
slot_pool: 系统所有slot buffer对象数组,包括:正在合并的、准备合并和闲置的slot buffer。
}
slot buffer对象是一个动态二进制数组,可以根据需要进行扩大。定义如下:
wt_log_slot{
. . .
state: 当前slot的状态,ready/done/written/free这几个状态
buf: 缓存合并logrec的临时缓冲区
group_size: 需要提交的数据长度
slot_start_offset: 合并的logrec存入log file中的偏移位置
. . .
}
通过一个例子来说明这个无锁过程,假如在系统中slot_pool中的slot个数为16,设置的slotbuffer大小为4KB,当前log管理器中的active_slot的slot_start_offset=0,有4个事务(T1、T2、T3、T4)同时发生提交,他们对应的日志对象分别是logrec1、logrec2、logrec3和logrec4。
Logrec1 size = 1KB, logrec2 szie =2KB, logrec3 size =2KB, logrec4 size =5KB。他们合并和写入的过程如下:
logrec1_size + slot_start_offset < slot_size
并且slot处于ready状态,那T1事务的合并位置就是active_slot[0, 1KB]
,slot_group_size = 1KB
active_slot[1KB, 3KB]
, slot_group_size = 3KB
。那这里有几种其他的情况,假如在第2步运行的完成后,T3也进行JION操作,这个时候
slot_size(4KB) < slot_group_size(3KB)+ logrec_size(2KB)
.那么T3不JION当时的active_slot,而是自旋等待active_slot_new顶替active_slot后再JION到active_slot_new。
如果在第2步时,T4也提交,因为logrec4(5KB)> slot_size(4KB),那么T4就不会进行JION操作,而是直接将自己的logrec数据写入log文件,并做sync刷盘返回。在返回前因为发现有logrec4大小的日志数据无法合并,全局log对象会试图将slot buffer的大小放大两倍,这样做的目的是尽量让下面的事务提交日志能进行slot合并写。
WT引擎之所以引入slot日志合并写的原因就是为了减少磁盘的I/O访问,通过无锁的操作,减少全局日志缓冲区的竞争。
从上面关于事务日志和MVCC list相关描述我们知道,事务的redo log主要是防止内存中已经提交的事务修改丢失,但如果所有的修改都存在内存中,随着时间和写入的数据越来越多,内存就会不够用,这个时候就需要将内存中的修改数据写入到磁盘上,一般在WT中是将整个BTREE上的page做一次checkpoint并写入磁盘。WT中的checkpoint是一个append方式管理的,也就是说WT会保存多个checkpoint版本。不管从哪个版本的checkpoint开始度可以通过重演redo log来恢复内存中已提交的事务修改。整个重演过程就是就是简单的对logrec中各个操作的执行。这里值得提一下的是因为WT保存多个版本的checkpoint,那么它会将checkpoint做为一种元数据写入到元数据表中,元数据表也会有自己的checkpoint和redo log,但是保存元数据表的checkpoint是保存在WiredTiger.wt文件中,系统重演普通表的提交事务之前,先会重演元数据事务提交修改。后面单独用一个篇幅来说明btree、checkpoint和元数据表的关系和实现。
WT的redo log是通过配置开启或者关闭的,MongoDB并没有使用WT的redolog来保证事务修改不丢,而是采用了WT的checkpoint和MongoDB复制集的功能结合来保证数据的完成性的。大致的细节是如果某个mongoDB实例宕机了,重启后通过MongoDB的复制协议将自己最新checkpoint后面的修改从其他的MongoDB实例复制过来。
虽然WT实现了多操作事务模型,然而MongoDB并没有提供事务,这或许和MongoDB本身的架构和产品定位有关系。但是MongoDB利用了WT的短事务的隔离性实现了文档级行锁,对MongoDB来说这是大大的进步。
可以说WT在事务的实现上另辟蹊径,整个事务系统的实现没有用繁杂的事务锁,而是使用snapshot和MVCC这两个技术轻松的而实现了事务的ACID,这种实现也大大提高了事务执行的并发性。除此之外,WT在各个事务模块的实现多采用无锁并发,充分利用CPU的多核能力来减少资源竞争和I/O操作,可以说WT在实现上是有很大创新的。通过对WiredTiger的源码分析和测试,也让我获益良多,不仅仅了解了数据库存储引擎的最新技术,也对CPU和内存相关的并发编程有了新的理解,很多的设计模式和并发程序架构可以直接借鉴到现实中的项目和产品中。更多关于 MongoDB 数据库的学习文章,请参阅:NoSQL 数据库之 MongoDB ,本系列持续更新中。
从mongoDB 3.0版本引入WiredTiger存储引擎(以下称为WT)以来,一直有同学反应在高速写入数据时WT引擎会间歇性写挂起,有时候写延迟达到了几十秒,这确实是个严重的问题。引起这类问题的关键在于WT的LRU cache的设计模型,WT在设计LRU cache时采用分段扫描标记和hazardpointer的淘汰机制,在WT内部称这种机制叫eviction cache或者WT cache,其设计目标是充分利用现代计算机超大内存容量来提高事务读写并发。在高速不间断写时内存操作是非常快的,但是内存中的数据最终必须写入到磁盘上,将页数据(page)由内存中写入磁盘上是需要写入时间,必定会和应用程序的高速不间断写产生竞争,这在任何数据库存储引擎都是无法避免的,只是由于WT利用大内存和写无锁的特性,让这种不平衡现象更加显著。下图是一位网名叫chszs同学对mongoDB 3.0和3.2版本测试高速写遇到的hang现象。
从上图可以看出,数据周期性出现了hang现象,笔者在单独对WT进行高并发顺序写时遇到的情况和上图基本一致,有时候挂起长达20秒。针对此问题我结合WT源码和调试测试进行了分析,基本得出的结论如下:
要彻底弄清楚这几个问题,就需要对从WT引擎的eviction cache原理来剖析,通过分析原理找到解决此类问题的办法。先来看eviction cache是怎么实现的,为什么要这么实现。
eviction cache是一个LRU cache,即页面置换算法缓冲区,LRU cache最早出现的地方是操作系统中关于虚拟内存和物理内存数据页的置换实现,后被数据库存储引擎引入解决内存和磁盘不对等的问题。所以LRU cache主要是解决内存与数据大小不对称的问题,让最近将要使用的数据缓存在cache中,把最迟使用的数据淘汰出内存,这是LRU置换算法的基本原则。但程序代码是无法预测未来的行为,只能根据过去数据页的情况来确定,一般我们认为过去经常使用的数据比不常用的数据未来被访问的概率更高,很多LRU cache大部分是基于这个规则来设计。
WT的eviction cache也不例外的遵循了这个LRU原理,不过WT的eviction cache对数据页采用的是分段局部扫描和淘汰,而不是对内存中所有的数据页做全局管理。基本思路是一个线程阶段性的去扫描各个btree,并把btree可以进行淘汰的数据页添加到一个lru queue中,当queue填满了后记录下这个过程当前的btree对象和btree的位置(这个位置是为了作为下次阶段性扫描位置),然后对queue中的数据页按照访问热度排序,最后各个淘汰线程按照淘汰优先级淘汰queue中的数据页,整个过程是周期性重复。WT的这个evict过程涉及到多个eviction thread和hazard pointer技术。
WT的evict过程都是以page为单位做淘汰,而不是以K/V。这一点和memcache、redis等常用的缓存LRU不太一样,因为在磁盘上数据的最小描述单位是page block,而不是记录。
从上面的介绍可以知道WT引擎的对page的evict过程是个多线程协同操作过程,WT在设计的时候采用一种叫做leader-follower的线程模型,模型示意图如下:
Leader thread负责周期性扫描所有内存中的btree索引树,将符合evict条件的page索引信息填充到eviction queue,当填充queue满时,暂停扫描并记录下最后扫描的btree对象和btree上的位置,然后对queue中的page按照事务的操作次数和访问次做一次淘汰评分,再按照评分从小到大做排序。也就是说最评分越小的page越容易淘汰。下个扫描阶段的起始位置就是上个扫描阶段的结束位置,这样能保证在若干个阶段后所有内存中的page都被扫描过一次,这是为了公平性。这里必须要说明的是一次扫描很可能只是扫描内存一部分btree对象,而不是全部,所以我对这个过程称为阶段性扫描(evict pass),它不是对整个内存中的page做评分排序。这个阶段性扫描的间隔时间是100毫秒,而触发这个evict pass的条件就是WT cache管理的内存超出了设置的阈值,这个在后面的eviction cache管理的内存小节中详细介绍。
在evict pass后,如果evction queue中有等待淘汰的page存在就会触发一个操作系统信号来激活follower thread来进行evict page工作。虽然evict pass的间隔时间通常是100毫秒,这里有个问题就是当WT cache的内存触及上限并且有大量写事务发生时,读写事务线程在事务开始时会唤醒leader thread和follower thread,这就会产生大量的操作系统上下文切换,系统性能急剧下降。好在WT-2.8版本修复了这个问题,leader follower通过抢锁来成为leader,通过多线程信号合并和周期性唤醒来follower,而且leader thread也承担evict page的工作,可以避免大部分的线程唤醒和上下文切换。是不是有点像Nginx的网络模型?
hazard pointer是一个无锁并发技术,其应用场景是单个线程写和多个线程读的场景,大致的原理是这样的,每个读的线程设计一个与之对应的无锁数组用于标记这个线程引用的hazard pointer对象。读线程的步骤如下:
写线程的步骤大致是这样的,写线程如果需要对某个hazard pointer对象写时,先判断所有读线程是否标记了这个对象,如果标记了,放弃写。如果未标记,进行写。
Hazardpointer是怎样应用在WT中呢?我们这样来看待这个事情,把内存page的读写看做hazard pointer的读操作,把page从内存淘汰到磁盘上的过程看做hazard pointer的写操作,这样瞬间就能明白为什么WT在页的操作上可以不遵守The FIX Rules规则,而是采用无锁并发的页操作。要达到这种访问方式有个条件就是内存中page本身的结构要支持lock free访问。从上面的描述可以看出evict page的过程中首先要做一次hazard pointer写操作检查,而后才能进行page的reconcile和数据落盘。
hazard pointer并发技术的应用是整个WT存储引擎的关键,它关系到btree结构、internal page的构造、事务线程模型、事务并发等实现。Hazard pointer使得WT不依赖The Fix Rules规则,也让WT的btree结构更加灵活多变。
Hazard pointer是比较新的无锁编程模式,可以应用在很多地方,笔者曾在一个高并发媒体服务器上用到这个技术,以后有机会把里面的技术细节分享出来。更多关于 MongoDB 数据库的学习文章,请参阅:NoSQL 数据库之 MongoDB ,本系列持续更新中。
eviction cache其实就是内存管理和page淘汰系统,目标就是为了使得管辖的内存不超过物理内存的上限,而触发淘汰evict page动作的基础依据就是内存上限。eviction cache管理的内存就是内存中page的内存空间,page的内存分为几部分:
WT在统计page的内存总量是通过一个footprint机制来统计两项数据,一项是总的内存使用量mem_size,一项是增删改造成的脏页数据总量dirty_mem_size。统计方式很简单,就是每次对页进行载入、增删改、分裂和销毁时对上面两项数据做原子增加或者减少计数,这样可以精确计算到当前系统中WT引擎内存占用量。假设引擎外部配置最大内存空间为cache_size,内存上限触发evict的比例为80%,内存脏页上限触发evict的比例为75%.那么系统触发evict pass操作的条件为:
mem_size> cache_size * 80%
或者
dirty_mem_size> cache_size * 75%
满足这个条件leader线程就会进行evict pass阶段性扫描并填充eivction queue,最后驱使follower线程进行evict page操作。
前面介绍过evict pass是一个阶段性扫描的过程,整个过程分为扫描阶段、评分排序阶段和evict调度阶段。扫描阶段是通过扫描内存中btree,检查btree在内存中的page对象是否可以进行淘汰。扫描步骤如下:
PS:在开始evict pass时,evict queue可能存在有上次扫描且未淘汰出内存的page,那么这次evict pass一定会让queue填满(大概400个page)。
评分排序阶段是在evict pass后进行的,当queue中有page时,会根据每个page当前的访问次数、page类型和淘汰失败次数等计算一个淘汰评分,然后按照评分从小打到进行快排,排序完成后,会根据queue中最大分数和最小分数计算一个淘汰边界evict_throld,queue中所有大于evict_throld的page不列为淘汰对象。
WT为了让btree索引页尽量保存在内存中,在评分的时候索引页的分值会加上1000000分,让btree索引页免受淘汰。
evict pass最后会做个判断,如果有follower线程存在,用系统信号唤醒follower进行evict page。如果系统中没有follower,leader线程进行eivct page操作。这个模型在WT-2.81版本已经修改成抢占模式。
evictpage其实就是将evict queue中的page数据先写入到磁盘文件中,然后将内存中的page对象销毁回收。整个evict page也分为三个阶段:从evict queue中获取page对象
、hazard pointer判断
和page的reconcile过程
,整个过程的步骤如下:
evict page的过程大部分是由follower thread来执行,这个在上面的线程模型一节中已经描述过。但在一个读写事务开始之前,会先检查WT cache是否有足够的内存空间进行事务执行,如果WT cache的内存容量触及上限阈值,事务执行线程会尝试去执行evict page工作,如果evict page失败,会进行线程堵塞等待直到 WT cache有执行读写事务的内存空间(是不是读写挂起了?)。这种状况一般出现在正在建立checkpoint的时候,那么checkpoint是怎么引起这个现象的呢?下面来分析缘由。
众所周知,建立checkpoint的过程是将内存中所有的脏页(dirty page)同步刷入磁盘上并将redo log的重演位置设置到最后修改提交事务的redo log位置,相对于WT引擎来说,就是将eviction cache中的所有脏页数据刷入磁盘但并不将内存中的page淘汰出内存。这个过程其实和正常的evict过程是冲突的,而且checkpoint过程中需要更多的内存完成这项工作,这使得在一个高并发写的数据库中有可能出现挂起的状况发生。为了更好的理解整个问题的细节,我们先来看看WT checkpoint的原理和过程。
WT引擎中的btree建立checkpoint过程还是比较复杂的,过程的步骤也比较多,而且很多步骤会涉及到索引、日志、事务和磁盘文件等。我以WT-2.7(mongoDB 3.2)版本为例子,checkpoint大致的步骤如下图:
在上图中,其中绿色的部分是在开始checkpoint事务之前会将所有的btree的脏页写入文件OS cache中,如果在高速写的情况下,写的速度接近也reconcile的速度,那么这个过程将会持续很长时间,也就是说OS cache中会存在大量未落盘的数据。而且在WT中btree采用的copy on write(写时复制)和extent技术,这意味OS cache中的文件数据大部分是在磁盘上是连续存储的,那么在绿色框最后一个步骤会进行同步刷盘,这个时候如果OS cache的数据量很大就会造成这个操作长时间占用磁盘I/O。这个过程是会把所有提交的事务修改都进行reconcile落盘操作。
在上图的紫色是真正开始checkpoint事务的步骤,这里需要解释的是由于前面绿色步骤刷盘时间会比较长,在这个时间范围里会有新的写事务发生,也就意味着会新的脏页,checkpint必须把最新提交的事务修改落盘而且还要防止btree的分裂,这个时候就会获得btree的独占排他式访问,这时 eviction cache不能对这个btree上的页进行evict操作(在这种情况下是不是容易造成WT cache满而挂起读写事务?)。
PS:WT-2.8版本之后对checkpoint改动非常大,主要是针对上面两点做了拆分,防止读写事务挂起发生,但大体过程是差不多的。
通过前面的分析大概知道写挂起的原因了,主要引起挂起的现象主要是因为写内存的速度远远高于写磁盘的速度。先来看一份内存和磁盘读写的速度的数据吧。顺序读写的对比:
从上图可以看出,SATA磁盘的顺序读写1MB数据大概需要8ms, SSD相对快一点,大概只需2ms.但内存的读写远远大于磁盘的速度。SATA的随机读取算一次I/O时间,大概在8ms 到10ms,SSD的随机读写时间比较快,大概0.1ms。
我们来分析checkpoint时挂起读写事务的几种情况,假设系统在高速写某一张表(每秒以100MB/S的速度写入),每1分钟做一次checkpoint。那么1分钟后开始进行图3中绿色的步骤,这个步骤会在这一分钟之内写入的脏数据压缩先后写入到OS cache中,OS Cache可能存有近2GB的数据。这2GB的sync刷到磁盘上的时间至少需要10 ~ 20秒,而且磁盘I/O是被这个同步刷盘的任务占用了。这个时候有可能发生几件事情:
这三种情况是因为阶段性I/O被耗光而造成读写事务挂起的。
在图3紫色步骤中,checkpoint事务开始后会先获得btree的独占排他访问方式,这意味这个btree对象上的page不能进行evict,如果这个btree索引正在进行高速写入,有可能让checkpoint过程中数据页的reconcile时间很长,从而耗光WT cache内存造成读写事务挂起现象,这个现象极为在测试中极为少见(碰见过两次)。要解决这几个问题只要解决内存和磁盘I/O不对等的问题就可以了。
引起写挂起问题的原因多种多样,但归根结底是因为内存和磁盘速度不对称的问题。因为WT的设计原则就是让数据尽量利用现代计算机的超大内存,可是内存中的脏数据在checkpoint时需要同步写入磁盘造成瞬间I/O很高,这是矛盾的。要解决这些问题个人认为有以下几个途径:
这些方法只能缓解读写事务挂起的问题,不能说彻底解决这个问题,WT引擎发展很快,开发团队正对WT eviction cache和checkpoint正在做优化,这个问题慢慢变得不再是问题,尤其是WT-2.8版本,大量的模型和代码优化都是集中在这个问题上。
WT的eviction cache可能有很多不完善的地方,也确实给我们在使用的过程造成了一些困挠,应该用中立的角度去看待它。可以说它的读写并发速度是其他数据库引擎不能比的,正是由于它很快,才会有写挂起的问题,因为磁盘的速度就那么快。以上的分析和建议或许对碰到类似问题的同学有用。
WT团队的研发速度也很快,每年会发布2 到3个版本,这类问题是他们正在重点解决的问题。在国内也有很多mongoDB这方面相关的专家,他们在解决此类问题有非常丰富的经验,也可以请求他们来帮忙解决这类问题。
链接:pdai.tech/md/db/nosql-mongo/mongo-y-introduce.html mongo-y-cache.html mongo-y-trans.html mongo-y-checkpoint.html mongo-y-page.html