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

  在本文中,我们主要针对WT引擎的事务来展开分析,来看看它的事务是如何实现的。说到数据库事务,必然先要对事务这个概念和ACID简单的介绍。

一、多文档事务的基本概念

1、多文档事务

  多文档事务,可以理解为关系型数据库的多行事务。在关系型的事务支持中,大家几乎无一例外支持同一事务内操作的原子性,即要么全部提交,要么全部回滚。这个同一事务内可以有多个操作,针对于多个表,或者是同一个表内的多行数据。

2、多文档分布式事务

  传统的关系型数据库的事务是针对单节点的,而分布式系统有多个节点,一个事务里可能会操作多个节点

二、事务与复制集以及存储引擎之间的关系

1、事务与复制集

  复制集配置下,MongoDB 整个事务在提交时,会记录一条 oplog(oplog 是一个普通的文档,所以目前版本里事务的修改加起来不能超过文档大小 16MB的限制),包含事务里所有的操作,备节点拉取oplog,并在本地重放事务操作。事务 oplog 包含了事务操作的 lsid,txnNumber,以及事务内所有的操作日志(applyOps字段)。

2、事务与存储引擎

  WiredTiger 很早就支持事务,在 3.x 版本里,MongoDB 就通过 WiredTiger 事务,来保证一条修改操作,对数据、索引、oplog 三者修改的原子性。但实际上 MongoDB 经过多个版本的迭代,才提供了事务接口,核心难点就是时序问题

  MongoDB 通过 oplog 时间戳来标识全局顺序,而 WiredTiger 通过内部的事务ID来标识全局顺序,在实现上,2者没有任何关联。这就导致在并发情况下, MongoDB 看到的事务提交顺序与 WiredTiger 看到的事务提交顺序不一致。

  为解决这个问题,WiredTier 3.0 引入事务时间戳(transaction timestamp)机制,应用程序可以通过 WT_SESSION::timestamp_transaction 接口显式的给 WiredTiger 事务分配 commit timestmap,然后就可以实现指定时间戳读(read “as of” a timestamp)。有了 read “as of” a timestamp 特性后,在重放 oplog 时,备节点上的读就不会再跟重放 oplog 有冲突了,不会因重放 oplog 而阻塞读请求,这是4.0版本一个巨大的提升。

/*
 * __wt_txn_visible --
 *  Can the current transaction see the given ID / timestamp?
 */
static inline bool
__wt_txn_visible(
    WT_SESSION_IMPL *session, uint64_t id, const wt_timestamp_t *timestamp)
{
    if (!__txn_visible_id(session, id))
        return (false);

    /* Transactions read their writes, regardless of timestamps. */
    if (F_ISSET(&session->txn, WT_TXN_HAS_ID) && id == session->txn.id)
        return (true);

#ifdef HAVE_TIMESTAMPS
    {
    WT_TXN *txn = &session->txn;

    /* Timestamp check. */
    if (!F_ISSET(txn, WT_TXN_HAS_TS_READ) || timestamp == NULL)
        return (true);

    return (__wt_timestamp_cmp(timestamp, &txn->read_timestamp) <= 0);
    }
#else
    WT_UNUSED(timestamp);
    return (true);
#endif
}

  从上面的代码可以看到,在引入事务时间戳之后,在可见性判断时,还会额外检查时间戳,上层读取时指定了时间戳读,则只能看到该时间戳以前的数据。而 MongoDB 在提交事务时,会将 oplog 时间戳跟事务关联,从而达到 MongoDB Server 层时序与 WiredTiger 层时序一致的目的。

三、WiredTiger事务的实现原理

1、WT事务的构造

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

wt_transaction{
	transaction_id:    本次事务的全局唯一的ID,用于标示事务修改数据的版本号
	snapshot_object:   当前事务开始或者操作时刻其他正在执行且并未提交的事务集合,用于事务隔离
	operation_array:   本次事务中已执行的操作列表,用于事务回滚。
	redo_log_buf:      操作日志缓冲区。用于事务提交后的持久化
	State:             事务当前状态
}

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

3、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方式隔离的基本原理。

4、全局事务管理器

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

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

四、WiredTiger事务过程

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

五、WiredTiger的事务隔离

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

1、Read-Uncommited

  Read-Uncommited(未提交读)隔离方式的事务在读取数据时总是读取到系统中最新的修改,哪怕是这个修改事务还没有提交一样读取,这其实就是一种脏读。WT引擎在实现这个隔方式时,就是将事务对象中的snap_object.snap_array置为空即可,那么在读取MVCC list中的版本值时,总是读取到MVCC list链表头上的第一个版本数据。举例说明,在上图中,如果T0/T3/T5的事务隔离级别设置成Read-Uncommited的话,那么T1/T3/T5在T5时刻之后读取系统的值时,读取到的都是14。一般数据库不会设置成这种隔离方式,它违反了事务的ACID特性。可能在一些注重性能且对脏读不敏感的场景会采用,例如网页cache。

2、Read-Commited

  Read-Commited(提交读)隔离方式的事务在读取数据时总是读取到系统中最新提交的数据修改,这个修改事务一定是提交状态。这种隔离级别可能在一个长事务多次读取一个值的时候前后读到的值可能不一样,这就是经常提到的“幻象读”。在WT引擎实现Read-Commited隔离方式就是事务在执行每个操作前都对系统中的事务做一次截屏,然后在这个截屏上做读写。还是来看上图,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。

3、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截屏来实现。这样做的目的是在保证事务隔离的情况下又能提高系统事务并发的能力

六、WiredTiger的事务日志

  通过上面的分析可以知道WT在事务的修改都是在内存中完成的,事务提交时也不会将修改的MVCC list当中的数据刷入磁盘,那么WT是怎么保证事务提交的结果永久保存呢?WT引擎在保证事务的持久可靠问题上是通过redo log(重做操作日志)的方式来实现的,在本文的事务执行和事务提交阶段都有提到写操作日志。WT的操作日志是一种基于K/V操作的逻辑日志,它的日志不是基于btree page的物理日志。说的通俗点就是将修改数据的动作记录下来,例如:插入一个key= 10,value= 20的动作记录在成:

{
Operation = insert,(动作)
Key = 10,
Value = 20
};

在这里插入图片描述

1、日志格式

  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的关系结构图如下:
在这里插入图片描述

2、WAL与日志写并发

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

七、总结

  可以说WT在事务的实现上另辟蹊径,整个事务系统的实现没有用繁杂的事务锁,而是使用snapshot和MVCC这两个技术轻松的而实现了事务的ACID,这种实现也大大提高了事务执行的并发性。除此之外,WT在各个事务模块的实现多采用无锁并发,充分利用CPU的多核能力来减少资源竞争和I/O操作,可以说WT在实现上是有很大创新的。

  • 4
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值