0%

mysql的mvcc实现原理

1. 基础

1.1 MVCC概念

MVCC,全称Multi-Version Concurrency Control,即多版本并发控制。用更好的方式去处理读-写冲突,做到即使有读写冲突时,也能做到不加锁,非阻塞并发读。

InnoDB通过undo log保存每条数据的多个版本,并且能够找回数据历史版本提供给用户读,每个事务读到的数据版本可能是不一样的。在同一个事务中,用户只能看到该事务创建快照之前已经提交的修改和该事务本身做的修改。

MVCC只在 Read Committed 和 Repeatable Read两个隔离级别下工作。

1.2 MVCC解决问题

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

  • 读-读:不存在任何问题,也不需要并发控制

  • 读-写:有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读,幻读,不可重复读

  • 写-写:有线程安全问题,可能会存在更新丢失问题

多版本并发控制(MVCC)是一种用来解决读-写冲突的无锁并发控制,在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能 同时还可以解决脏读,幻读,不可重复读等事务隔离问题,但不能解决更新丢失问题。

2. 当前度和快照读【锁定和非锁定一致性读】

2.1 当前读(增,改,删,加锁)

像select lock in share mode(共享锁), select for update ; update, insert ,delete(排他锁)这些操作都是一种当前读。

为什么叫当前读?就是它读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。

  • 注意,共享锁也是悲观锁。乐观锁和悲观锁是一种编程策略,并不是数据库具有悲观锁和乐观锁。

2.2 快照读(普通select)

像不加锁的select操作就是快照读,即不加锁的非阻塞读;快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读;

之所以出现快照读的情况,是基于提高并发性能的考虑,快照读的实现是基于多版本并发控制,即MVCC。可以认为MVCC是行锁的一个变种,但它在很多情况下,避免了加锁操作,降低了开销;既然是基于多版本(mysql读取undo log历史版本) ,即快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本。

3. 快照读

3.1 快照读和MVCC的关系

MVCC多版本并发控制指的是 “维持一个数据的多个版本,使得读写操作没有冲突” 这么一个概念。仅仅是一个理想概念。

而在MySQL中,实现这么一个MVCC理想概念,我们就需要MySQL提供具体的功能去实现它,而快照读就是MySQL为我们实现MVCC理想模型的其中一个具体非阻塞读功能。而相对而言,当前读就是悲观锁的具体功能实现。

要说的再细致一些,快照读本身也是一个抽象概念,再深入研究。MVCC模型在MySQL中的具体实现则是由 4个隐式字段,undo日志,Read View 等去完成的。

头脑风暴:mvcc是一个概念,快照读是为了实现它。

3.2 RC 和 RR 快照读有什么不同

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

  1. Read Committed级别, 事务在begin之后,执行每条select(读操作)语句时,快照会被重置,即会重新创建一个快照(read view)。

  2. Repeatable Read级别, 只有事务在begin之后,执行第一条select(读操作)时, 才会创建一个快照(read view),将当前系统中活跃的其他事务记录起来;并且事务之后都是使用的这个快照,不会重新创建,直到事务结束。

3.3 快照读的select时机

快照读的结果是非常依赖该事务首次出现快照读的地方,即某个事务中首次出现快照读的地方非常关键,它有决定该事务后续快照读结果的能力。

  • 查询时机1
image-20230830140616956
  • 查询时机2
image-20230830140625481

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

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

执行第一条select(读操作)时, 才会创建一个快照(read view)。

4. 实现

MVCC的目的就是多版本并发控制,在数据库中的实现,就是为了解决读写冲突,它的实现原理主要是依赖记录中的 4个隐式字段,undo日志 ,Read View 来实现的。

4.1 隐藏字段

InnoDB存储引擎在每行数据的后面添加了隐藏字段:

  • DB_ROW_ID

    6byte, 隐含的自增ID(隐藏主键),如果数据表没有主键,InnoDB会自动以DB_ROW_ID产生一个聚簇索引。(这个DB_ROW_ID跟MVCC关系不大)。

  • DB_TRX_ID

    6byte, 最近修改(修改/插入)事务ID:记录创建这条记录/最后一次修改该记录的事务ID。

  • DB_ROLL_PTR

    7byte, 回滚指针,指向这条记录的上一个版本(存储于rollback segment里)。

  • DELETED_BIT

    1byte, 记录被更新或删除并不代表真的删除,而是删除flag变了

image-20230830133933752

如上图,DB_ROW_ID是数据库默认为该行记录生成的唯一隐式主键;DB_TRX_ID是当前操作该记录的事务ID; 而DB_ROLL_PTR是一个回滚指针,用于配合undo日志,指向上一个旧版本;delete flag没有展示出来。

4.2 undo log

InnoDB把这些为了回滚而记录的这些东西称之为undo log。这里需要注意的一点是,由于查询操作(SELECT)并不会修改任何用户记录,所以在查询操作执行时,并不需要记录相应的undo log。undo log主要分为3种。

  • Insert undo log :插入一条记录时,至少要把这条记录的主键值记下来,之后回滚的时候只需要把这个主键值对应的记录删掉就好了。

  • Update undo log:修改一条记录时,至少要把修改这条记录前的旧值都记录下来,这样之后回滚时再把这条记录更新为旧值就好了。

  • Delete undo log:删除一条记录时,至少要把这条记录中的内容都记下来,这样之后回滚时再把由这些内容组成的记录插入到表中就好了。

删除操作都只是设置一下老记录的DELETED_BIT,并不真正将过时的记录删除。

为了节省磁盘空间,InnoDB有专门的purge线程来清理DELETED_BIT为true的记录。为了不影响MVCC的正常工作,purge线程自己也维护了一个read view(这个read view相当于系统中最老活跃事务的read view);如果某个记录的DELETED_BIT为true,并且DB_TRX_ID相对于purge线程的read view可见,那么这条记录一定是可以被安全清除的。

4.3 undo log 链

1. 插入

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

image-20230830134355265

2. 修改1

现在来了一个事务1对该记录的name做出了修改,改为Tom

image-20230830134456119
  • 在事务1修改该行(记录)数据时,数据库会先对该行加排他锁

  • 然后把该行数据拷贝到undo log中,作为旧记录,即在undo log中有当前行的拷贝副本

  • 拷贝完毕后,修改该行name为Tom,并且修改隐藏字段的事务ID为当前事务1的ID, 我们默认从1开始,之后递增,回滚指针指向拷贝到undo log的副本记录,即表示我的上一个版本就是它。

  • 事务提交后,释放锁

3. 修改2

又来了个事务2修改person表的同一个记录,将age修改为30岁

image-20230830134628379
  • 在事务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,其实在事务提交之后可能就被删除丢失了,不过这里为了演示,所以还放在这里)

4.4 Read View

执行Select 语句时,对该记录创建一个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(从链首到链尾,即从最近的一次修改查起),直到找到满足特定条件的DB_TRX_ID, 那么这个DB_TRX_ID所在的旧记录就是当前事务能看见的最新老版本。

Read View 算法

我们可以把Read View简单的理解成有三个全局属性

  • trx_list 未提交事务ID列表,用来维护Read View生成时刻系统正活跃的事务ID

  • up_limit_id 记录trx_list列表中事务最小的ID

  • low_limit_id ReadView生成时刻系统尚未分配的下一个事务ID,也就是目前已出现过的事务ID的最大值+1

1
2
3
4
5
6
7
8
9
10
11
12
13
1. 如果 DB_TRX_ID < up_limit_id, 那么表明最新修改该行的事务在当前事务创建快照之前就提交了,所以该记录行的值对当前事务是可见的。跳到步骤5。

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

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

3.1 如果在活跃事务列表trx_ids中能找到 id 为 DB_TRX_ID 的事务,表明①在当前事务创建快照前,该记录行的值被id为trx_id的事务修改了,但没有提交;或者②在当前事务创建快照后,该记录行的值被id为trx_id的事务修改了(不管有无提交);这些情况下,这个记录行的值对当前事务都是不可见的,跳到步骤4;

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

4. 在该记录行的 DB_ROLL_PTR 指针所指向的undo log回滚段中,取出最新的的旧事务号DB_TRX_ID, 将它赋给trx_id,然后跳到步骤1重新开始判断。

5. 将该可见行的值返回。

4.5 整体流程

事务1事务2事务3事务4
事务开始事务开始事务开始事务开始
修改且已提交
进行中开始快照读进行中
image-20230830135842844

当事务2对某行数据执行了快照读,数据库为该行数据生成一个Read View读视图,假设当前事务ID为2,此时还有事务1和事务3在活跃中,事务4在事务2快照读前一刻提交更新了,所以Read View记录了系统当前活跃事务1,3的ID,维护在一个列表上,我们称为trx_list。

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

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

5. 参考资料

给作者打赏,可以加首页微信,咨询作者相关问题!