MVCC原理解读

发布时间 2023-09-27 19:30:52作者: 兔兔it资源

1.前导知识

1.1什么是MVCC

  • MVCC全称 Multi-Version Concurrency Control,即多版本并发控制,MVCC是一种并发控制的方法,一般在数据库管理系统中,实现对数据库的并发访问,在编程语言中实现事务内存。
  • 多版本控制:指的是一种提高并发的技术,最早的数据库系统,只有读读之间可以并发,读写、写读、写写之间都要阻塞,引入多版本之后,只有写写之间相互阻塞,其他三种操作都可以并行,这样大幅的提高了InnoDB的并发度,在内部实现中,InnoDB是通过undo(撤消) log实现的,通过undo(撤消) log可以找回数据的历史版本。找回的历史版本可以提供给用户读(按照隔离级别的定义,有些读请求只能看到比较老的数据版本),也可以在回滚的时候覆盖数据页上的数据。在InnoDB内部中,会记录一个全局的活跃读写事务数组,其主要用来判断事务的可见行。
  • 一句话概述MVCC在MySQL InnoDB中的实现主要是为了提高数据库并发性能,用更好的方式去处理读-写冲突,做到即使有读写冲突时,也能做到不加锁,做到非阻塞并发读

1.2当前读与快照读

  • 当前读

    像select lock(锁定) in share mode(模式)(共享锁); select for update, update,delete,insert(插入)(排它锁)这些操作就是一种当前读,因为它读取的是数据的最新版本,读取时还要保证其他事务不能修改当前记录,会对记录进行加锁

  • 快照读

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

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

1.3当前读和快照读与MVCC的关系

  • 准确的说,MVCC多版本并发控制指的是 “维护一个数据的多个版本,使得读写操作没有冲突” 这么一个概念,仅仅是一个理想状态
  • 而在MySQL中,实现这么一个MVCC理想概念,我们就需要MySQL提供具体的功能去实现,而快照读就是MySQL为我们实现MVCC理想模型的其中一个具体非阻塞读功能,而相对而言,当前读就是一个悲观锁的具体功能实现
  • 而要说的在细致一点,快照读本身也是一个抽象概念,在深入研究,MVCC模型在MySQL中的具体实现则是由三个隐式字段undo logRead View等去完成的。

1.4MVCC能解决什么问题

数据库并发场景有三种,分别是:

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

(第一类丢失更新:事务A回滚时,将已经提交的事务B的更新数据覆盖了;第二类丢失更新:事务A提交覆盖了事务B已经提交的数据,造成事务B所做的操作丢失)

MVCC带来的好处

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

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

总结:MVCC就是开发人员不满意只让数据库采用悲观锁(加锁)这样性能不佳的形式去解决读-写的问题,而提出的解决方案,所以在数据库中,因为有了MVCC,所以我们可以形成两个组和

  • MVCC + 悲观锁

    MVCC解决写-读冲突,悲观锁解决写写-冲突

  • MVCC + 乐观锁

    MVCC解决读写冲突,乐观锁解决写写冲突。

2.MVCC实现原理

2.1隐式字段

在一张表中,除了我们自定义的列,实际上MySQL会隐式的定义DB_TRX_ID(最后一次修改该记录的事务ID)DB_ROLL_PTR(指向这条记录的上一个版本)DB_ROW_ID(隐藏主键)等字段。

  • DB_TRX_ID
    6byte,最近修改(修改/插入)事务ID:记录创建这条记录/最后一次修改该记录的事务ID
  • DB_ROLL_PTR
    7byte,回滚指针,指向这条记录的上一个版本(存储于rollback segment里)
  • DB_ROW_ID
    6byte,隐含的自增ID(隐藏主键),如果数据表没有主键,InnoDB会自动以DB_ROW_ID产生一个聚簇索引
  • 实际还有一个删除flag隐藏字段, 既记录被更新或删除并不代表真的删除,而是删除flag(国旗)变了

如上图,DB_ROW_ID是数据库为改行记录生产的唯一隐式主键,DB_TRX_ID是当前操作该记录的事务ID,而DB_ROLL_PTR是一个回滚指针,用于配合undo(撤消)日志,指向上一个版本。

2.2undo log日志

undo(撤消) log主要被分为两种:

  • insert(插入) undo(撤消) log

    代表事务在insert新纪录时产生的undo_log,只在事务回滚时需要,并且在事务提交后可以被立即抛弃。

  • update undo(撤消) log

    事务在update或者delete时产生的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中旧记录链,它的执行流程如下:

一、 比如一个有个事务插入persion表插入了一条新记录,记录如下,name为Jerry, age为24岁,隐式主键是1,事务ID回滚指针,我们假设为NULL

二、 现在来了一个事务1对记录的name进行了修改,改为了tom

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

三、 又来了个事务2修改person表的同一个记录,将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,其实在事务提交之后可能就被删除丢失了,不过这里为了演示,所以还放在这里)。

2.3Read View

什么是Read View?

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

大致流程

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

RV遵循一个可见性算法,主要是将要被修改的数据的最新记录的DB_TRX_ID(即当前事务ID),与系统当前其他活跃事务的ID去对比(由RV维护),如果DB_TRX_ID跟RV的属性做了某些对比,不符合可见性,那么就由DB_ROLL_PTR回滚指针去取出undo(撤消) log中的DB_TRX_ID再比较,即遍历链表的DB_TRX_ID(从链表头到尾,即从最近的一次修改查起),直到找到满足特定条件的DB_TRX_ID,那么这个DB_TRX_ID所在的旧记录就是当前事务能看见的最新老版本

判断条件是什么?

我们可以将RV简单的理解为三个全局属性

1. trx_list 一个数值列表,用来维护RV生成时刻此时系统正活跃的事务ID
2. up_limit_id 记录trx_list中的最小的事务ID 
3. low_limit_id  RV生成时刻系统尚未分配的下一个事务ID,即目前(不一定是RV中)已经出现过的事务ID最大值+1

比较步骤

  • 首先比较DB_TRX_ID < up_limit_id, 如果小于,则当前事务能看到DB_TRX_ID 所在的记录,如果大于等于进入下一个判断
  • 接下来判断 DB_TRX_ID 大于等于 low_limit_id , 如果大于等于则代表DB_TRX_ID 所在的记录在Read View生成后才出现的,那对当前事务肯定不可见,如果小于则进入下一个判断
  • 判断DB_TRX_ID 是否在活跃事务之中,trx_list.contains(DB_TRX_ID),如果在,则代表我Read View生成时刻,你这个事务还在活跃,还没有Commit(犯),你修改的数据,我当前事务也是看不见的;如果不在,则说明,你这个事务在Read View生成之前就已经Commit(犯)了,你修改的结果,我当前事务是能看见的

2.4实现流程

MVCC总体实现流程

我们在了解了隐式字段undo log, 以及Read View的概念之后,就可以来看看MVCC实现的整体流程是怎么样了

整体的流程是怎么样的呢?我们可以模拟一下

  • 事务2对某行数据执行了快照读,数据库为该行数据生成一个Read View读视图,假设当前事务ID为2,此时还有事务1事务3在活跃中,事务4事务2快照读前一刻提交更新了,所以Read View记录了系统当前活跃事务1,3的ID,维护在一个列表上,假设我们称为trx_list
事务1 事务2 事务3 事务4
事务开始 事务开始 事务开始 事务开始
修改且已提交
进行中 快照读 进行中  
 
  • Read View不仅仅会通过一个列表trx_list来维护事务2执行快照读那刻系统正活跃的事务ID,还会有两个属性up_limit_id(记录trx_list列表中事务ID最小的ID),low_limit_id(记录trx_list列表中事务ID最大的ID,也有人说快照读那刻系统尚未分配的下一个事务ID也就是目前已出现过的事务ID的最大值+1,所以在这里例子中up_limit_id就是1,low_limit_id就是4 + 1 = 5,trx_list集合的值是1,3,Read View如下图

  • 我们的例子中,只有事务4修改过该行记录,并在事务2执行快照读前,就提交了事务,所以当前该行当前数据的undo log如下图所示;我们的事务2在快照读该行记录的时候,就会拿该行记录的DB_TRX_ID去跟up_limit_id,low_limit_id活跃事务ID列表(trx_list)进行比较,判断当前事务2能看到该记录的版本是哪个。

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

MVCC工作流程

3.MVCC相关问题

3.1RR是如何在RC的基础上解决不可重复读的?

表1

事务A 事务B
开启事务 开启事务
快照读(无影响)查询金额为500 快照读查询金额为500
更新金额为400  
提交事务  
  select 快照读金额为500
  select lock(锁定) in share mode(模式)当前读金额为400

在上表的顺序下,事务B的在事务A提交修改后的快照读是旧版本数据,而当前读是实时新数据400

表2

事务A 事务B
开启事务 开启事务
快照读(无影响)查询金额为500  
更新金额为400  
提交事务  
  select 快照读金额为400
  select lock(锁定) in share mode(模式)当前读金额为400

而在表2这里的顺序中,事务B在事务A提交后的快照读和当前读都是实时的新数据400,这是为什么呢?

  • 这里与上表的唯一区别仅仅是表1的事务B在事务A修改金额前快照读过一次金额数据,而表2的事务B在事务A修改金额前没有进行过快照读。

所以我们知道事务中快照读的结果是非常依赖该事务首次出现快照读的地方,即某个事务中首次出现快照读的地方非常关键,它有决定该事务后续快照读结果的能力,我们这里测试的是更新,同时删除更新也是一样的,如果事务B的快照读是在事务A操作之后进行的,事务B的快照读也是能读取到最新的数据的

3.2RC/RR级别下的InnoDB快照读有什么不同

正是由于生成Read View的时机不同,从而造成RC RR级别下快照读的结果的不同。

  • 在RR级别下的某个事务对某条记录进行的第一次快照读会创建一个快照Read View,此后在调用快照读的时候,使用的还是同一个ReadView,所以只要当前事务在其他事务提交更新之前使用过快照读,那么之后的快照读使用的都是同一个Read View,所以对之后的修改不可见.
  • 而在RC隔离级别下,事务中每次快照都会生成一个快照和ReadView,这就是我们在RC级别下的事务中可以额看到别的事务提交更新的原因。

总之在RC隔离级别下,每次快照读都会生成最新的ReadView;而在RR级别下,则是同一个事务中的第一个快照读才会创建ReadView,之后的快照读获取的都是同一个ReadView

3.MySQL如何解决幻读与不可重复读

参考