MVCC及实现原理

概念

MVCC

MVCC是一种并发控制的方法,一般在数据管理系统中,实现对数据库的并发访问,在编程语言中实现事物内存。
MVCC在MySQL InnoDB中的实现主要是为了提高数据库并发性能,用更好的方式去处理读-写冲突,做到即使有读写冲突时,也能做到不加锁,非阻塞并发读。

当前读和快照度

当前读

读取的是记录的最新版本,读取时还要保证其他并发事物不能修改当前记录,会对读取的记录进行加锁。
使用Next-Key Lock行锁和间隙锁)进行加锁来保证不出现幻读,Next-Key Lock 是行锁(Record Lock)和间隙锁(Gap Lock)的结合,行锁只能锁住已经存在的行,为了避免插入新行,需要依赖间隙锁。
例子:共享锁和排他锁都是一种当前读

快照读

快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读;之所以出现快照读的情况,是基于提高并发性能的考虑,快照读的实现是基于多版本并发控制,即MVCC,可以认为MVCC是行锁的一个变种,但它在很多情况下,避免了加锁操作,降低了开销;既然是基于多版本,即快照读可能督导的并不一定是数据的最新版本。
例子:不加锁的select操作就是快照读,即不加锁的非阻塞读

总结:MVCC就是为了实现读-写冲突不加锁,而这个读指的就是快照读,而非当前读,当前读实际上是一种加锁的操作,是悲观锁的实现。

当前读、快照度和MVCC之间的关系

MVCC多版本并发控制是**【维持一个数据的多个版本,使得读写操作没有冲突】的概念,只是一个抽象概念,并非实现。
因为MVCC只是一个抽象概念,要实现这么一个概念,MYSQL就需要提供具体的功能去实现它,
【快照读就是MySQL实现MVCC理想模型的其中一个非阻塞读功能】。而相对而言,当前读就是悲观锁的具体功能实现
深入研究,快照读本身也是一个抽象概念,MVCC模型在MYSQL中的具体实现是由
3个隐式字段,undo日志,Read View**等去完成的。

MVCC能解决什么问题,好处是?

数据库并发场景:

  • 读-读:不存在任何问题,也不需要并发控制
  • 读-写:有线程安全问题,可能会造成事物隔离性问题,可能遇到脏读,幻读,不可重复读
  • 写-写:有线程安全问题,可能会存在更新丢失问题,比如第一类更新丢失,第二类更新丢失

MVCC带来的好处是?
多版本并发控制 是一种用来解决 读-写冲突无锁并发控制,也就是为事物分配单向增长的时间戳,为每个修改保存一个版本,版本与事务时间戳关联,读操作只读该事务开始前的数据库的快照。所以MVCC可以为数据库解决以下问题:

  • 在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能
  • 同时还可以解决脏读,幻读,不可重复度等事务隔离问题 ,但不能解决更新丢失问题

MVCC形成的两个组合:

  • MVCC + 悲观锁
    MVCC结局读写冲突,悲观锁解决写写冲突
  • MVCC + 乐观锁
    MVCC解决读写冲突,乐观锁解决写写冲突

MVCC实现原理

隐式字段

隐式字段: 每行记录除了我们自定义的字段外,还有数据隐式定义的 DB_TRX_ID,DB_ROLL_PTR,DB_ROW_ID等字段

  • DB_TRX_ID
    6字节,最近修改(修改、插入)事务ID:记录创建这条记录、最后一次修改该记录的事务ID。此外delete操作在内部被视为更新,只不过会在记录头Record Header中的deleted_flag字段将其标记为已删除。(下面purge线程会讲到)

  • DB_ROLL_PTR
    7字节,回滚指针,指向这条记录的上一个版本(undo log)(存储于 rollback segment 里),如果该行未被更新,则为空

  • DB_ROW_ID
    6字节,隐含的自增ID(隐藏主键),如果数据表没有主键且该表没有唯一非空索引时,InnoDB会自动以DB_ROW_ID产生一个聚簇索引

实际上还有一个删除flag隐藏字段,既记录被更新或删除并不代表真的删除,而是删除flag变了
在这里插入图片描述
如上图,DB_ROW_ID 是数据库默认为该行记录生成的唯一隐式主键,DB_TRX_ID 是当前操作该记录的事务 ID ,而 DB_ROLL_PTR 是一个回滚指针,用于配合 undo日志,指向上一个旧版本

undo日志

作用:

当事务回滚时用于将数据恢复到修改前的样子

另一个作用是MVCC,当读取记录时,若该记录被其他事务占有或当前版本对该事物不可见,则可以通过 undo log 读取之前的版本数据,以此实现非锁定读

undo log主要分为两种:

  • insert undo log
    代表事务在insert新纪录时产生的 undo log,因为insert 操作的记录只对事务本身可见,对其他事物不可见,故该 undo log 可以在事务提交后直接删除。不需要进行 purge操作
  • update undo log
    事务在进行 updatedelete 时产生的undo log;不仅在事务回滚时需要,在快照读时也需要;所以不能随便删除,只有在快速读或事务回滚不涉及该日志时,对应的日志才会在提交时放入 undo log 链表,等待 purge线程 进行最后的删除

purge
从前面的分析可以看出,为了实现InnoDB 的MVCC机制,更新或者删除操作都只是设置 了一下老记录的 deleted_bit,并不真正将过时的记录删除。
为了节省磁盘空间,InnoDB有专门的purge 线程来清理deleted_bit 为true的记录。为了不影响MVCC的正常工作,purge线程自己也维护了一个read view(这个read view相当于系统中最老活跃事务的read view);如果某个记录的deleted_bit为true,并且DB_TRX_ID相对于purge线程的read view可见,那么这条记录一定是可以被安全清除的。

对MVCC有帮助的实质是 update undo log,undo log实际上就是存在rollback segment中旧记录链,它的执行流程如下:
一、person表的某条记录
在这里插入图片描述
二、事务1对该记录的name做出修改改为Tom

  • 事务1 修改该行(记录)数据时,数据库会先对该行加 排他锁(写锁)
  • 然后把该行数据拷贝到 undo log中,作为旧记录,既在undo log中有当前行的拷贝副本
  • 拷贝完毕后,修改该行name为Tom,并且修改隐藏字段的事务ID为当前事务1 的ID,我们默认从1 开始,之后递增,回滚指针拷贝到undo log的副本记录,既表示我的上一个版本就是它
  • 事务提交后,释放锁

在这里插入图片描述
三、事务2 修改表中同一记录,将age修改为 30岁

  • 在事务2修改该行数据时,数据库也先为该行加锁
  • 然后把该行数据拷贝到 undo log 中,作为旧记录,发现该行记录已经有undo log了,那么最新的旧数据作为链表的表头,插在该行记录的undo log最前面
  • 修改该行 age为30岁,并且修改隐藏字段的事务ID为当前事务2 的ID,那就是2,回滚指针指向刚刚拷贝到undo log的副本记录
  • 事务提交,释放锁
    在这里插入图片描述

总结:
不同事务或者相同事务对同一记录的修改,会导致该记录的undo log成为一条记录版本线性表,即链表,undo log的链首就是最新的旧记录,链尾就是最早的旧记录(当然就像之前说的改undo log的节点可能是会purge线程清除掉,向图中的第一条 insert undo log ,其实在事务提交之后可能就被删除丢失了,不过这里为了演示,所以还放在这里

Read View

概念

Read View就是事务进行快照读操作时生产的 读视图(Read View),在该事物执行快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的ID(当每个事务开启时,都会被分配一个ID,这个ID是递增的 ,所以最新的事务,ID值最大

所以我们知道Read view主要是用来做==可见性判断==的,即当我们某个事物执行快照读的时候,对该记录创建一个Read View读视图,把它比作条件用来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的 undo log里面某个版本的数据

Read View遵循一个可见性算法(下面会讲),注意是将要被修改的数据的最新记录中的DB_TRX_ID(即当前事务ID)提取出来,与系统当前其他活跃事务的ID去对比(由Read View维护),如果DB_TRX_ID跟Read View的属性做了某些比较,不符合可见性,那就通过DB_ROLL_PTR回滚指针取出 undo log中的DB_TRX_ID再比较,即遍历链表的DB_TRX_ID()

比较的判断条件:
class ReadView {
  /* ... */
private:
  trx_id_t m_low_limit_id;      /* 大于等于这个 ID 的事务均不可见 */

  trx_id_t m_up_limit_id;       /* 小于这个 ID 的事务均可见 */

  trx_id_t m_creator_trx_id;    /* 创建该 Read View 的事务ID */

  trx_id_t m_low_limit_no;      /* 事务 Number, 小于该 Number 的 Undo Logs 均可以被 Purge */

  ids_t m_ids;                  /* 创建 Read View 时的活跃事务列表 */

  m_closed;                     /* 标记 Read View 是否 close */
}

Read View主要是用来做可见性判断,里面保存了“当前对本事务不可见的其他活跃事务”
主要有以下字段:

m_low_limit_id:目前出现过的最大的事务id+1(Read View生成时刻系统尚未分配的下一个事务ID,即 +1)

m_up_limit_id:活跃事务列表m_ids 中最小的事务,如果m_ids为空,则m_up_limit_id 为 m_low_limit_id 。小于这个ID的数据版本均可见

m_ids:Read View创建时其他未提交的活跃事务ID列表。创建Read View时,将当前未提交事务ID记录下来,后续即使它们修改了记录行的值,对于当前事务也是不可见的。m_ids 不包括当前事务自己和已提交的事务(正在内存中)

事务可见性示意图

在这里插入图片描述

整体流程

此处图片摘至SnailMann
模拟:
假设有4个事务:
事务1 正在进行中
事务2 快照读
事务3 进行中
事务4 修改已提交 (在事务2快照读前一刻提交更新了)

可知:Read View记录了系统当前活跃事务1 和3 的ID,维护在了一个列表 m_ids
还有两个属性:m_up_limit_id(m_ids列表中事务ID最小的ID) 为 1m_low_limit_id(快照读时刻系统尚未分配的下一个事务ID)4+1 =5 ;而m_ids中的值为1,3

因为事务4修改并提交过该行记录,所以当前该行当前数据的undo log如下图所示;当事务2 执行快照读时就会拿该行记录的DB_TRX_ID去和 m_up_limit_id,m_low_limit_id和活跃事务ID列表m_ids那行比较
在这里插入图片描述

所以先拿该记录 DB_TRX_ID 字段记录的事务 ID 4 去跟 Read View 的 m_up_limit_id 、m_low_limit_id、m_ids比较,看 4 是否小于 m_up_limit_id( 1 ),所以不符合条件,继续判断 4 是否大于等于 m_low_limit_id( 5 ),也不符合条件,最后判断 4 是否处于 m_ids 中的活跃事务, 最后发现事务 ID 为 4 的事务不在当前活跃事务列表中, 符合可见性条件,所以事务 4修改后提交的最新结果对事务 2 快照读时是可见的,所以事务 2 能读到的最新数据记录是事务4所提交的版本,而事务4提交的版本也是全局角度上最新的版本
在这里插入图片描述

数据可见性算法

在 InnoDB 存储引擎中,创建一个新事务后,执行每个 select 语句前,都会创建一个快照(Read View),快照中保存了当前数据库系统中正处于活跃(没有 commit)的事务的 ID 号。其实简单的说保存的是系统中当前不应该被本事务看到的其他事务 ID 列表(即 m_ids)。当用户在这个事务中要读取某个记录行的时候,InnoDB 会将该记录行的 DB_TRX_ID 与 Read View 中的一些变量及当前事务 ID 进行比较,判断是否满足可见性条件
在这里插入图片描述

1.如果记录DB_TRX_ID < m_up_limit_id,那么表明最新修改该行的事务(DB_TRX_ID)在当前事务创建快照之前就提交了,所以该记录行的值对当前事务是可见的

2.如果DB_TRX_ID >= m_low_limit_id,那么表明最新修改该行的事务(DB_TRX_ID)在当前事务创建快照读之后才修改该行,所以该记录行的值对当前事务不可见,跳到步骤5

3.m_ids为空,则表明在当前事务创建快照之前,修改该行的事务就已经提交了,所以该记录行的值对当前事务是可见的

4.如果m_up_limit_id <= DB_TRX_ID < m_low_limit_id,表明最新修改该行的事务(DB_TRX_ID)在当前事务创建快照的时候可能处于“活动状态”或者“已提交状态”;多以就要对活跃事务列表m_ids进行查找(源码中是用的二分查找,因为是有序的)

如果在活跃事务列表m_ids中能找到DB_TRX_ID,表明: 1.在当前事务创建快照时,改记录行的值被事务ID为DB_TRX_ID的事务修改了,但没有提交;或者 2. 在当前事务创建快照后,该记录行的值被事务ID为DB_TRX_ID 的事务修改了。这些情况下,这个记录行的值对当前事务都是不可见的。跳到步骤5

在活跃事务列表中找不到,则表明“id为trx_id的事务”在修改“该记录行的值”后,在“当前事务”创建快照前就已经提交了,所以记录行对当前事务可见

5.在该记录行的DB_ROLL_PTR 指针所指向的 undo log 取出快照记录,用快照记录的 DB_TRX_ID 跳到步骤1 重新开始判断,直到找到满足的快照版本或返回空

RC和RR隔离级别下的MVCC的差异

在事务隔离级别 RC(读已提交) 和 RR(可重复读 InnoDB存储引擎的默认事务隔离级别)下,InnoDB存储引擎使用MVCC,但它们生成 Read View 的时机却不相同。

在 RC 隔离级别下的 每次select 查询都生成一个 Read View (m_ids列表)

在RR 隔离级别下只在事务开始后 第一次select 数据前生成一个 Read View (m_ids列表)

解决不可重复读问题

虽然 RC 和 RR 都通过 MVCC 来读取快照数据,但由于 生成 Read View 时机不同,从而在 RR 级别下实现可重复读
例:
在这里插入图片描述

在RC下 Read View生成情况

1. 假设时间线来到 T4 ,那么此时数据行 id = 1 的版本链为:
在这里插入图片描述

由于 RC 级别下每次查询都会生成 Read View ,并且事务 101、102并未提交,此时 103 事务生成的 Read View 中活跃 的事务m_ids 为:【101,102】m_low_limit_id 为:104 ,m_up_limit_id 为:101 ,m_creator_trx_id 为: 103

此时最新记录的 DB_TRX_ID 为 101, m_up_limit_id <= 101 < m_low_limit_id,所以要在 m_ids 列表中查找,发现 DB_TRX_ID 存在
列表中,那么这个记录不可见
根据 DB_ROOL_PTR 找到 undo log 中的上一版本记录,上一条记录的 DB_TRX_ID 还是 101,不可见
继续找上一条 DB_TRX_ID 为1 ,满足 1< m_up_limit_id ,可见,所以事务103 查询到数据为 name = 菜花

2. 时间线来到 T6 ,数据的版本链为:
在这里插入图片描述
因为在 RC级别下 ,重新生成 Read View 。这时事务 101 已经提交,102 并未提交,所以此时 Read View 中活跃的事务 m_ids:【102】,m_low_limit_id 为: 104,m_up_limit_id 为:102 ,m_create_trx_id 为: 103

此时最新记录的 DB_TRX_ID 为 102,m_up_limit_id <= 102 < m_low_limit_id,所以要在m_ids 列表中查找,发现 DB_TRX_ID存在列表中,
那么这个记录不可见

根据DB_ROLL_PTR 找到undo log 中的上一版本记录,上一条记录的 DB_TRX_ID 为101,满足 101< m_up_limit_id ,记录可见,所以在 T6 时
间点查询到数据为name = 李四,与时间T4查询到的结果不一致,**不可重复读**!

3. 时间线来到 T9 ,数据的版本链为:
在这里插入图片描述

重新生成 Read View ,这是事务 101 和102都已经提交,所以m_ids 为空,则 m_up_limit_id = m_low_limit_id = 104,最新版本事务 ID为 102,满足 102 < m_up_limit_id,可见,查询结果为 name=赵六

总结:在RC隔离级别下,事务在每次查询开始时都会生成并设置新的 Read View,所以导致不可重复读

在RR下ReadView生成情况

在可重复读级别下,只会在事务开始后第一次读取数据时生成一个 Read View (m_ids列表)
1. 在 T4 情况下的版本链为:
在这里插入图片描述
在当前执行 select 语句时生成一个 Read View,此时 m_ids:[101,102] ,m_low_limit_id为:104,m_up_limit_id为:101,m_creator_trx_id 为:103

此时和 RC 级别下一样:

此时最新记录的 DB_TRX_ID 为 101, m_up_limit_id <= 101 < m_low_limit_id,所以要在 m_ids 列表中查找,发现 DB_TRX_ID 存在列表
中,那么这个记录不可见
根据 DB_ROOL_PTR 找到 undo log 中的上一版本记录,上一条记录的 DB_TRX_ID 还是 101,不可见
继续找上一条 DB_TRX_ID 为1 ,满足 1< m_up_limit_id ,可见,所以事务103 查询到数据为 name = 菜花

2. 时间点 T6 情况下:
在这里插入图片描述

在 RR级别下只会生成一次Read View,所以此时依然沿用 m_ids:【101,102】,m_low_limit_id 为:104,m_up_limit_id 为 :101,m_creator_trx_id 为:103

最新记录的 DB_TRX_ID 为 102,m_up_limit_id <= 102 < m_low_limit_id,所以要在 m_ids 列表中查找,发现 DB_TRX_ID存在列表中,那么这个记录不可见
继续根据 DB_ROLL_PTR 找到 undo log 中的上一版本记录,上一记录的 DB_TRX_ID 还是 101,不可见
继续找上一条 DB_TRX_ID 为1 ,满足1 < m_up_limit_id ,可见,所以事务103 查询到数据为 name = 菜花

3. 时间点 T9 情况下:
在这里插入图片描述

此时情况跟 T6 完全一样,由于已经生成了 Read View,此时依然沿用 m_ids :[101,102] ,所以查询结果依然是 name = 菜花

MVCC➕Next-key-Lock 防止幻读

InnoDB 存储引擎在RR级别下通过 MVCC 和 Next-Key Lock来解决幻读问题:

1.执行普通 select ,此时会以MVCC 快照读 的方式读取数据

在快照读的情况下,RR隔离级别只会在事务开启后的第一次查询生成 Read View,并使用至事务提交。所以在生成Read View 之后其它事务所做的更新、插入记录版本对当前事务并不可见,实现了可重复读和防止快照读下的“幻读”

2、执行 select…for update/lock in share mode、insert、update、delete 等当前读

在当前读下,读取的都是最新的数据,如果其它事务有插入新的记录,并且刚好在当前事务查询范围内,就会产生幻读! InnoDB 使用 Next-Key Lock行锁和间隙锁) 来防止这种情况。当执行当前读时,会锁定读取到 的记录的同时,锁定它们的间隙,防止其它事务在查询范围内插入数据。只要我不让你插入,就不会产生幻读。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值