MongoDB内容分享(四):MongoDB WT引擎

目录

原理和WiredTiger引擎

存储引擎及常见数据结构

典型B-Tree数据结构

WiredTiger数据文件在磁盘上的数据结构

WiredTiger内存上的基础数据结构

page的其它数据结构

为什么要了解Page生命周期

Page的生命周期

Page的各种状态

Page的大小参数

为什么要理解Checkpoint

Checkpoint包含的关键信息

Checkpoint执行的完整流程

Checkpoint执行的触发时机

WT的事务构造

WT的多版本并发控制

WT事务snapshot

全局事务管理器

事务ID

WT的事务过程

事务开启

事务执行

事务提交

事务回滚

WT的事务隔离

Read-uncommited

Read-Commited

Snapshot- Isolation

WT的事务日志

日志格式

WAL与日志写并发

WT的事务恢复

后记


原理和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模型,充分利用了内存容量的同时又能拥有很高的事务读写并发。

存储引擎及常见数据结构

存储引擎要做的事情无外乎是将磁盘上的数据读到内存并返回给应用,或者将应用修改的数据由内存写到磁盘上。如何设计一种高效的数据结构和算法是所有存储引擎要考虑的根本问题,目前大多数流行的存储引擎是基于B-Tree或LSM(Log Structured Merge) Tree这两种数据结构来设计的。

  • B-Tree

像Oracle、SQL Server、DB2、MySQL (InnoDB)和PostgreSQL这些传统的关系数据库依赖的底层存储引擎是基于B-Tree开发的;

  • 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为核心来分析MongoDB是如何将文档数据在磁盘和内存间进行流传以及WiredTiger存储引擎的其它高级特性。

典型B-Tree数据结构

B-Tree是为磁盘或其它辅助存储设备而设计的一种数据结构,目的是为了在查找数据的过程中减少磁盘I/O的次数。

一个典型的B-Tree结构如下图所示:

在整个B-Tree中,从上往下依次为Root结点、内部结点和叶子结点,每个结点就是一个Page,数据以Page为单位在内存和磁盘间进行调度,每个Page的大小决定了相应结点的分支数量,每条索引记录会包含一个数据指针,指向一条数据记录所在文件的偏移量。

如上图,假设每个结点100个分支,那么所有叶子结点合起来可以包含100万个键值(等于100100100)。通常情况下Root结点和内部结点的Page会驻留在内存中,所以查找一条数据可能只需2次磁盘I/O。但随着数据不断的插入、删除,会涉及到B-Tree结点的分裂、位置提升及合并等操作,因此维护一个B-Tree的平衡也是比较耗时的。

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也会在内存里面维护其它几种数据结构,如下图所示:

上图是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变量以跳转链表的形式构成。

下图是一个跳转链表的插入示例:

假如现在插入一个16,最终结果如下:

如果是一个普通的链表,寻找合适的插入位置时,需要经过:

开始结点->2->5->8->10->20的比较;

对于跳转链表来说只需经过:开始结点->5->10->20的比较,可以看到比在普通链表上寻找插入位置时需要的比较步骤少,所以,通过跳转链表的数据结构能够提升插入操作的效率

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的生命周期、状态以及相关参数的配置,这对后续MongoDB的性能调优和故障问题的定位和解决有帮助。

Page的生命周期

Page的典型生命周期如下图所示:

  • 第一步: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被淘汰出去。

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_size4KB磁盘上最小分配单元
memory_page_max5MB内存中允许的最大page值
internal_page_max4KB磁盘上允许的最大internal page值
leaf_page_max32KB磁盘上允许的最大leaf page值
internal_key_max1/10*internal_pageinternal page上允许的最大key值
leaf_key_max1/10*leaf_pageleaf page上允许的最大key值
leaf_key_value1/2*leaf_pageleaf page上允许的最大value值
split_pct75%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的大小。

为什么要理解Checkpoint

总的来说,Checkpoint主要有两个目的:

  • 一是将内存里面发生修改的数据写到数据文件进行持久化保存,确保数据一致性;
  • 二是实现数据库在某个时刻意外发生故障,再次启动时,缩短数据库的恢复时间,WiredTiger存储引擎中的Checkpoint模块就是来实现这个功能的。

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)

详细字段信息描述如下:

  • 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是数据库中一个比较耗资源的操作,何时触发执行以及以什么样的流程执行是本节要研究的内容,如下所述:

执行流程:

一个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的错误。

WT的事务构造

知道了基本的事务概念和ACID后,来看看WT引擎是怎么来实现事务和ACID的。要了解实现先要知道它的事务的构造和使用相关的技术,WT在实现事务的时使用主要是使用了三个技术:snapshot(事务快照)MVCC (多版本并发控制)redo log(重做日志),为了实现这三个技术,它还定义了一个基于这三个技术的事务对象和全局事务管理器。事务对象描述如下

wt_transaction{

    transaction_id:    本次事务的**全局唯一的ID**,用于标示事务修改数据的版本号

    snapshot_object:   当前事务开始或者操作时刻其他正在执行且并未提交的事务集合,用于事务隔离

    operation_array:   本次事务中已执行的操作列表,用于事务回滚。

    redo_log_buf:      操作日志缓冲区。用于事务提交后的持久化

    state:             事务当前状态

}

WT的多版本并发控制

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基本原理。

WT事务snapshot

上面多次提及事务的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 ,而是采用了上图的一个无锁并发设计,这种设计遵循了我们开始说的并发设计原则。

事务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原子操作完成的无锁过程。

WT的事务过程

一般事务是两个阶段:事务执行事务提交。在事务执行前,我们需要先创建事务对象并开启它,然后才开始执行,如果执行遇到冲突和或者执行失败,我们需要回滚事务(rollback)。如果执行都正常完成,最后只需要提交(commit)它即可。从上面的描述可以知道事务过程有:创建开启执行提交回滚。那么从这几个过程中来分析WT是怎么实现这几个过程的。

事务开启

WT事务开启过程中,首先会为事务创建一个事务对象并把这个对象加入到全局事务管理器当中,然后通过事务配置信息确定事务的隔离级别和redo log的刷盘方式并将事务状态设为执行状态,最后判断如果隔离级别是ISOLATION_SNAPSHOT(snapshot级的隔离),在本次事务执行前创建一个系统并发事务的snapshot截屏。至于为什么要在事务执行前创建一个snapshot,在后面WT事务隔离章节详细介绍。

事务执行

事务在执行阶段,如果是读操作,不做任何记录,因为读操作不需要回滚和提交。如果是写操作,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当中。

示意图如下:

事务提交

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读操作的时候,跳过这个放弃的值即可。整个过程是一个无锁操作,高效、简洁。

WT的事务隔离

传统的数据库事务隔离分为:Read-Uncommited(未提交读)Read-Commited(提交读)Repeatable-Read(可重复读)Serializable(串行化),WT引擎并没有按照传统的事务隔离实现这四个等级,而是基于snapshot的特点实现了自己的Read-Uncommited、Read-Commited和一种叫做snapshot-Isolation(快照隔离)的事务隔离方式。在WT中不管是选用的是那种事务隔离方式,它都是基于系统中执行事务的快照截屏来实现的。那来看看WT是怎么实现上面三种方式的。

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特性。可能在一些注重性能且对脏读不敏感的场景会采用,例如网页cache。

Read-Commited

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

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的事务日志

通过上面的分析可以知道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(检查点)位置重演磁盘上这个磁盘文件来恢复已经提交的事务来保证事务的持久性。根据上面的描述,有几个问题需要搞清楚:

  • 操作日志格式怎么设计?

  • 在事务并发提交时,各个事务的日志是怎么写入磁盘的?

  • 日志是怎么重演的?它和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在刷入磁盘之前会进行空间压缩,那么磁盘上的长度和内存中的长度就不一样了。压缩是根据系统配置可选的。

WAL与日志写并发

WT引擎在采用WAL(Write-Ahead Log)方式写入日志,WAL通俗点说就是说在事务所有修改提交前需要将其对应的操作日志写入磁盘文件。在事务执行的介绍小节中我们介绍是在什么时候写日志的,这里我们来分析事务日志是怎么写入到磁盘上的,整个写入过程大致分为下面几个阶段:

  • 事务在执行第一个写操作时,先会在事务对象(wt_transaction)中的redo_log_buf的缓冲区上创建一个logrec对象,并将logrec中的事务类型设置成LOGREC_COMMIT。

  • 然后在事务执行的每个写操作前生成一个logop对象,并加入到事务对应的logrec中。

  • 在事务提交时,把logrec对应的内容整体写入到一个全局log对象的slot buffer中并等待写完成信号。

  • Slot buffer会根据并发情况合并同时发生的提交事务的logrec,然后将合并的日志内容同步刷入磁盘(sync file),最后告诉这个slot buffer对应所有的事务提交刷盘完成。

  • 提交事务的日志完成,事务的执行结果也完成了持久化。

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。他们合并和写入的过程如下:

  • T1事务在提交时,先会从全局的log对象中的active_slot发起一次JION操作,JION过程就是向active_slot申请自己的合并位置和空间,logrec1_size + slot_start_offset < slot_size并且slot处于ready状态,那T1事务的合并位置就是active_slot[0, 1KB],slot_group_size = 1KB

  • 这是T2同时发生提交也要合并logrec,也重复第1部JION操作,那么它申请到的位置就是active_slot[1KB, 3KB], slot_group_size = 3KB。

  • 在T1事务JION完成后,它会判断自己是第一个JION这个active_slot的事务,判断条件就是返回的写入位置slot_offset=0。如果是第一个它立即会将active_slot的状态从ready状态置为done状态,并未后续的事务从slot_pool中获取一个空闲的active_slot_new来顶替自己合并数据的工作。

  • 与此同时T2事务JION完成之后,它也是进行这个过程的判断,T2发现自己不是第一个,那么它将会等待T1将active_slot置为done.

  • T1和T2都获取到了自己在active_slot中的写入位置,active_slot的状态置为done时,T1和T2分别将自己的logrec写入到对应buffer位置。加入在这里T1比T2先将数据写入完成。那么T1就会等待一个slot_buffer完全刷入磁盘的信号,而T2写入完成后会将slot_buffer中的数据写入log文件,并对log文件做sync刷入磁盘的操作,最高发送信号告诉T1同步刷盘完成,T1和T2各自返回,事务提交过程的日志刷盘操作完成。

那这里有几种其他的情况,假如在第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访问,通过无锁的操作,减少全局日志缓冲区的竞争。

WT的事务恢复

从上面关于事务日志和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和内存相关的并发编程有了新的理解,很多的设计模式和并发程序架构可以直接借鉴到现实中的项目和产品中。

  • 25
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

之乎者也·

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值