【MongoDb事务原理】我对比了 Mysql 的实现,你总该明白了吧!

247 阅读12分钟

前言

最近想了解一下 Mongo 的事务实现,发现和 Mysql 的实现机制很像,所以对比着 Mysql 的 InnodDb 来比较并总结 Mongo 的WT 引擎事务实现方式。

事务的ACID

原子性 (Atomicity) :在多文档事务中,所有操作要么全部成功,要么全部失败。事务中的所有操作都可以看作一个单一的原子操作。

一致性 (Consistency) :事务的执行必须使数据库从一个一致的状态转变到另一个一致的状态。所有的约束、规则和关系都必须在事务执行前后保持一致。

隔离性 (Isolation) :MongoDB 的事务是隔离的,意味着一个事务的执行不会受到其他事务的影响。

持久性 (Durability) :一旦事务成功提交,其对数据库的所有更改都是永久性的,即使系统崩溃或发生故障,这些更改也不会丢失。

  • Tips
  1. MongoDB在3.2之前使用的是“读未提交”,这种情况下会出现“脏读”。但在MongoDB 3.2开始已经调整为“读已提交”。
  2. Mongo 只有三种隔离级别: Read Uncommitted(读未提交),Read Committed(读已提交),Snapshot Isolation(快照隔离)

回顾MySql如何实现事务的

之所以要介绍 mysql 的事务实现机制,是为了后面更好的进行对比理解,相信很多人都对 mysql 的 mvcc 机制很了解,这里再回顾一下。下文都是参照 mysql 的 innodb 引擎进行的讲述。

先明确几个概念:

MVCC

同一条记录在系统中可以存在多个版本,就是数据库的多版本并发控制(MVCC) 。 针对 MVCC通俗的讲,数据库中同时存在多个版本的数据,并不是整个数据库的多个版本,而是某一条记录的多个版本同时存在,在某个事务对其进行操作的时候,需要查看这一条记录的隐藏列事务版本id,比对事务id并根据事物隔离级别去判断读取哪个版本的数据。

事务ID

每个事务在开始时都会被分配一个唯一的事务 ID。InnoDB 维护一个全局的事务 ID 计数器(next_id),每当一个新的事务开始时,这个计数器就会自增,并分配一个唯一的事务 ID。

隐藏列

每行数据除了数据本身还有一些隐藏的字段。

  • DB_ROW_ID: 隐藏的字段ID,当没有主键或者非空唯一索引时,我们的主键所以基于这个递增字段建立。
  • DB_TRX_ID: 更新这行数据的事务ID。
  • DB_ROLL_PTR : 回滚指针,被改动前的undolog日志指针。

版本链

多个事务并行操作某一行数据时,不同事务对该行数据的修改会产生多个版本,然后通过回滚指针(roll_pointer),连成一个链表,这个链表就称为版本链。如下:

image.png

undo log

undo log,回滚日志,用于记录数据被修改前的信息。在表记录修改之前,会先把数据拷贝到undo log里,如果事务回滚,即可以通过undo log来还原数据。

可以这样认为,当delete一条记录时,undo log 中会记录一条对应的insert记录,当update一条记录时,它记录一条对应相反的update记录。

一致性快照(read view)

在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的ID。

这里的快照其实不是真正意义的将数据存储了一份,而是一个readView的数据结构保存了某些信息,然后通过对这些信息的判断来达到不会读到最新数据的目的。

其结构如下

class ReadView {

..................

trx_id_t m_low_limit_id; //如果大于等于这个值的事务不可见,也称高水位线

trx_id_t m_up_limit_id; // 所有小于这个值的事务的值都可见,也称低水位线,其实是m_ids里面的最小值

trx_id_t m_creator_trx_id; // 当前的事务ID

ids_t m_ids; //存活的事务ID,就是在创建readView 没有提交的事务的ID集合

}
  • m_low_limit_id: 当前系统里面已经创建过的事务 ID 的最大值加 1,记为高水位。
  • m_up_limit_id: 所有存活的(没有提交的)事务ID中最小值,即低水位。
  • m_creator_trx_id:创建这个readView的事务ID。
  • m_ids: 创建readView时,所有存活的事务ID列表,活跃的意思是启动了还没有提交。

实现方式

根据上面的几个概念可以实现通过事务 Id 在一致性视图中判断是否可读当前版本的行记录,判断规则如下:

  1. 如果数据的DB_TRX_ID < m_up_limit_id, 都小于存活的事务ID了,那么肯定不存活了,说明在创建ReadView的时候已经提交了,可见。
  2. 如果数据的DB_TRX_ID >=m_low_limit_id, 大于等于我即将分配的事务ID, 那么表明修改这条数据的事务是在创建了ReadView之后开启的,不可见。
  3. 如果 m_up_limit_id<= DB_TRX_ID< m_low_limit_id, 表明修改这条数据的事务在第一次快照之前就创建好了,但是不确定提没提交,判断有没有提交,直接可以根据活跃的事务列表 m_ids判断
    1. DB_TRX_ID如果在m_ids中,表明在创建ReadView之时还没提交,不可见
    2. DB_TRX_ID如果不在m_ids,表明在创建ReadView之时已经提交,可见 transaction with consistent snapshot, 这个在事务开启的时候就会创建一个read view。

事务隔离

InnoDB在实现 MVCC 时用到的一致性读视图,即 consistent read view,用于支持RC(Read Committed,读提交)和RR(Repeatable Read,可重复读)隔离级别的实现。

在RC隔离级别下,则每次一致性读都会创建一个新的快照。

在RR隔离级别下,则在第一次一致性读的时候,创建快照。如果不在第一个select创建快照,也可以用 start transaction with consistent snapshot, 这个在事务开启的时候就会创建一个read view。

MongoDb 如何实现事务的

文档级别的原子性。

在 Mongo 设计之初,就是单文档级别的原子性。单文档的更新不需要事务支持

单文档级别 :同一个文档中多个字段的更新操作视为原子的,要么都更新,要么都不更新。相当于关系型数据库的同一行记录的多个字段要么都更新,要么都不更新。

mysql 是怎么做的

在 mysql 中多个字段的更新使用的是行级锁,保证在更新时其他事务不会对这一行进行更新。同时一个 update 语句会被视为一个原子操作,如果正在执行时的某一个操作失败就认为整个 update 语句失败,从而不进入持久化日志。

Mongo是怎么做的

在 Mongo 中也是会使用文档级锁,保证多个并发操作无法修改同一个文档。如果对单文档的操作失败,Mongo 会撤销对该文档的更新,恢复到之前的状态。如果是客户端能够检测到的错误,比如更新文档时,提供了无效的数据,MongoDb 会拒绝这些操作。可以用来保证此操作的原子性和一致性。

多文档的事务操作

先说明,下文基本上都是基于WT 引擎来介绍的,这是因为 WT在 MongoDB 3.2 版本中开始作为默认存储引擎,在 4.0 版本时就支持多文档的事务了。

在WT支持的事务隔离级别只有三种:读未提交(Read-Uncommited)、读已提交(Read-Commited)和一种叫做快照隔离(snapshot-Isolation),这三种都是使用 snapshot 实现的,类似于 mysql 中的 read view。所以说本文将两者进行对比来更好的去理解 Mongo 事务实现方式。

WT的事务构造

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

wt_transaction{

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

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

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

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

    state:             事务当前状态

}

MVCC

WT中的MVCC是基于key/value中value值的链表,这个链表单元中存储有当先版本操作的事务ID和操作修改后的值。描述如下:

wt_mvcc{
    transaction_id:    本次修改事务的ID
    value:             本次修改后的值
}

对比一下 上述介绍的mysql,会发现其实是非常类似的。不同之处在于此结构中是没有回滚指针的, 那当前事务出现问题时,如何进行回滚来保证事务的一致性呢?这个问题后面会回答。

版本链

每次开启一个事务时都会创建一个上面的结构 wt_mvcc, 多个此事务会组成一个链表。

image.png 说明:

  1. 上面是操作的版本链,下面是并发事务的列表,只有写操作才会加入此版本链。
  2. 版本链右边是队头,每次的操作会队头插入,也就是右边插入。
  3. 读取是从版本链队头开始读,也就是右边开始读。
  4. 读取时会比较版本链上的事务 id 和当前的事务 id。这里会根据隔离级别分成多种情况
    1. 读未提交,那么 T5 会直接读取 T4 的值,也就是 14。
    2. 读已提交,那么 T5 会直接读取 T1 的值,也就是 11.
    3. 快照隔离,也会读取 T1 的值。

怎么知道比我事务 id 小的事务此时已经提交还是没有提交呢?

MongoDB 提供了事务的 快照一致性,这意味着在某个事务开始时,它会获得一个数据的 "快照" 视图,来记录事务开始时,哪些事务已提交,哪些未提交。这样即使在事务执行过程中其他事务修改了数据,当前事务也将始终看到事务开始时的一致数据视图。

事务快照snapshot

事务快照其实非常类似于 mysql 中的一致性读视图,也包含类似于 mysql 中的低水位,高水位。比如下图的例子

image.png 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的修改,因为T2 已经提交了,所以 snap_array 中是不存在 T2 的。 这里着重说明,T2 是在开始事务之前就提交了,所以区间中没有 T2。如果T1在建立snapshot之后提交了,T6也是不能访问到T1的修改

换句话说,凡是出现在snap_array中 且已提交或者事务ID大于snap_max的事务的修改对事务T6是不可见的。这个就是snapshot方式隔离的基本原理。

此处和 mysql 的 mvcc+readView 基本上一模一样。

事务隔离

Read-uncommited(读未提交): WT 将事务对象的snap_object.snap_array置为空即可,这样即可读所有已经开启事务的操作。

Read-Commited(读已提交): WT 会在事务执行过程中的每个操作都生成一个截屏,也就是生成一个事务对象。

Snapshot- Isolation(快照读):WT 会在事务开始时,生成一个snapshot,这个截屏会一直沿用到事务提交或者回滚。

回滚

Mysql(innodb)怎么回滚

Mysql 是通过版本链和 undoLog 日志实现的回滚,一旦发生异常或者手动回滚,就会通过版本链指向上一个版本的 undoLog 日志并执行,达到回滚的目的。

Mongo(WT)怎么回滚

遍历上文所说的 WT 事务构造中的整个operation_array,对每个数组单元对应update的事务id设置以为一个WT_TXN_ABORTED(= uint64_max), 表示mvcc 对应的修改单元值被回滚,在其他读事务进行mvcc读操作的时候,跳过这个放弃的值即可。整个过程是一个无锁操作,高效、简洁

事务日志

既然是事务那么就要保证事务的持久性。

WiredTiger利用预写日志(Journaling)+检查点(checkpoint)实现数据持久性存储!!

这个介绍起来内容篇幅太多,建议看一下这篇文章 Mongodb之预写日志(Journaling)

参考