MySQL底层原理-MVCC机制-《MySQL学习笔记》

admin 2025-11-03 23:40:24 数据库 来源:ZONE.CI 全球网 0 阅读模式
  • 1、MVCC机制概述
  • 2、MVCC版本链的形成
  • 3、ReadView(快照)
    • 3.1 ReadView的形成
    • 3.2 READ COMMITTED
    • 3.3 REPEATABLE READ
  • 4、MySQL是如何解决幻读的
    • 4.1 InnoDB解决快照读的幻读
    • 4.2 InnoDB解决当前读的幻读
  • 参考

    1、MVCC机制概述

    MVCCMulti-Version Concurrency Control),中文是多版本并发控制,是指在使用**READ COMMITTED****REPEATABLE READ**这两种隔离级别的事务在执行SELECT操作时访问记录的版本链的过程,从而在不加锁的前提下使不同事务的读写操作能够并发安全执行,提升系统性能。MVCC机制的核心是在做SELECT操作前会生产一个**ReadView**,通过这个ReadView可以确认版本链中哪个版本的数据对当前事务可见,通过READ COMMITTD隔离级别的事务在每次进行SELECT操作前都会成1个ReadView,REPEATABLE READ隔离级别的事务只在第1次进行SELECT操作前生成1个ReadView,之后的查询操作都重复使用这个ReadView。通过ReadView找到符合条件的记录版本(记录版本是由undo日志构建的),其实就像是在生成ReadView的那个时刻做了1次快照,因此利用MVCC机制读取数据又叫快照读,也叫一致性读。需要注意以下几点:

    • 之前介绍事务时提到过事务并发引起的四种异常场景:脏写、脏读、不可重复读和幻读。对于脏写MySQL是通过加锁的方式解决的,MVCC机制解决的是脏读、不可重复读和幻读;
    • READ COMMITTD隔离级别和REPEATABLE READ隔离级别可以通过MVCC机制保证,SERIALIZABLE隔离级别是通过加锁保证的,READ UNCOMMITTED隔离级别由于什么措施也没做,因此会允许脏读、不可重复和幻读发生。

      2、MVCC版本链的形成

      前面介绍行格式时提到过隐藏列,对于使用InnoDB存储引擎的表来说,它的聚簇索引记录中都包含两个必要的隐藏列:

    • trx_id:每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的事务id赋值给trx_id隐藏列。注意:只有在对表中的记录做**INSERT****DELETE****UPDATE**这些修改表中记录的操作时才会给事务分配事务id,且事务id的分配是递增的,一个只读事务的trx_id为0

    • roll_pointer:每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到undo日志中,roll_pointer就相当于一个指针,可以通过它来找到该记录修改前的信息。

    如果此时表中只有1条记录,且插入该记录的事务id为80,此时该记录的行格式简化版如下:image.png假设之后两个事务id分别为100200的事务对这条记录进行UPDATE操作,操作流程如下:image.png每次对记录进行改动,都会记录一条undo日志,每条undo日志也都有一个roll_pointer属性(INSERT操作对应的undo日志没有该属性,因为该记录并没有更早的版本),可以将这些undo日志串连起来形成一个链表,如下图:image.png对该记录的每次更新操作(UPDATE)都会将旧值放到一条undo日志中,即对该记录的一个历史版本,随着更新次数的增多产生的undo日志也增多,所有undo日志被roll_pointer属性连接成一个链表,这个链表就是版本链。关于版本链有以下点需要注意:

    • 版本链是针对某条记录的,即是一条用户记录的不同版本组成的链表;
    • 事务COMMIT之前对记录的修改也会放到undo日志,作为记录的一个历史版本组成版本链;
    • 在版本链中插入undo日志是遵循“头插法”,即每次都是将最近生成的undo日志插入在版本链的链表头部,即版本链头结点对应的记录版本是最新的;
    • 查询版本链时,也是从链表头部遍历,即从最新版本的undo日志记录向老版本的undo日志记录遍历查询。

      3、ReadView(快照)

      3.1 ReadView的形成

      对于使用READ UNCOMMITTED隔离级别的事务来说,由于允许读到未提交事务修改过的记录(脏读),因此直接读取版本链的最新版本就可以了;对于使用SERIALIZABLE隔离级别的事务来说,需要用加锁的方式保证事务的串行化执行;对于使用READ COMMITTEDREPEATABLE READ隔离级别的事务来说,都必须保证事务1已经修改了记录但是尚未提交,事务2不能直接读取到最新版本的记录,即事务1中尚未提交的记录修改对事务2是不可见的。为了保证READ COMMITTEDREPEATABLE READ隔离级别的事务,尚未提交的记录修改对其他事务不可见,InnoDB提出了ReadView的概念,ReadView主要由以下四部分组成:

    • m_ids:表示在生成ReadView时当前系统中“活跃”的读写事务的事务id列表,注意事务尚未提交时的状态为“活跃”状态

    • min_trx_id:表示在生成ReadView时当前系统中活跃的(尚未提交的)读写事务中最小的事务id,也就是m_ids中的最小值;
    • max_trx_id:表示生成ReadView时系统中应该分配给下一个事务的id值;
    • creator_trx_id:表示生成该ReadView的事务的事务id

      举例:现在有id为1,2,3这三个事务,之后id为3的事务提交了,一个新的读事务在生成ReadView时,m_ids就包括1和2,min_trx_id的值就是1,max_trx_id的值就是4。

    如何根据某个读事务生成的ReadView快照,判断版本链上的某个版本对该查询事务是否可见呢?遵循以下步骤:

    • 如果被访问版本的trx_id属性值与ReadView中的creator_trx_id值相同,意味着当前读事务在访问它自己修改过的记录,所以该版本对当前事务可见;
    • 如果被访问版本的trx_id属性值小于ReadView中的min_trx_id值,表明生成该版本的事务在当前事务生成ReadView前已经提交,所以该版本对当前事务可见;
    • 如果被访问版本的trx_id属性值大于或等于ReadView中的max_trx_id值,表明生成该版本的事务在当前事务生成ReadView后才开启,该版本对当前事务不可见;
    • 如果被访问版本的trx_id属性值在ReadViewmin_trx_idmax_trx_id之间,那就需要判断一下trx_id属性值是不是在m_ids列表中,如果在,说明创建ReadView时生成该版本的事务还是活跃的,该版本对当前事务不可见;如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本对当前事务可见。

    开头介绍MVCC机制概述时提到,读事务在生成ReadView时,在READ COMMITTEDREPEATABLE READ隔离级别下生成的时机是不同的,通过READ COMMITTD隔离级别的事务在每次进行SELECT操作前都会成1个ReadView,REPEATABLE READ隔离级别的事务只在第1次进行SELECT操作前生成1个ReadView,之后的查询操作都重复使用这个ReadView,下面结合例子具体介绍。

    3.2 READ COMMITTED

    比如现在系统里有两个事物id分别为100、200的事务在执行,记录初始时name值为“刘备”,如下:

    1. # 事务id为100的事务执行如下语句,注意还没有COMMIT,即事务id为100的事务处于“活跃”状态
    2. BEGIN;
    3. UPDATE hero SET name = '关羽' WHERE number = 1;
    4. UPDATE hero SET name = '张飞' WHERE number = 1;
    1. # 事务id为200的事务在对其他表进行操作,目的是让该事务能够分配到一个事务id
    2. BEGIN;
    3. # 更新了一些别的表的记录
    4. ...

    此刻,表heronumber1的记录得到的版本链表如下所示:假设现在有一个使用READ%20COMMITTED隔离级别的查询事务开始执行如下语句:

    #%20使用READ%20COMMITTED隔离级别的事务,事务id为0BEGIN;#%20SELECT1:Transaction%20100、200未提交#%20得到的列name的值为'刘备'SELECT%20*%20FROM%20hero%20WHERE%20number%20=%201;

    这个SELECT1的执行过程如下:

    • 在执行SELECT语句时会先生成一个ReadViewReadViewm_ids列表的内容就是[100,%20200]min_trx_id100max_trx_id201creator_trx_id0
    • 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列name的内容是'张飞',该版本的trx_id值为100,在m_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本;
    • 下一个版本的列name的内容是'关羽',该版本的trx_id值也为100,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本;
    • 下一个版本的列name的内容是'刘备',该版本的trx_id值为80,小于ReadView中的min_trx_id100,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name'刘备'的记录。

    之后,我们把事务id100的事务提交一下,就像这样:

    #%20Transaction%20100BEGIN;UPDATE%20hero%20SET%20name%20=%20'关羽'%20WHERE%20number%20=%201;UPDATE%20hero%20SET%20name%20=%20'张飞'%20WHERE%20number%20=%201;COMMIT;

    然后再到事务id200的事务中更新一下表heronumber1的记录,做如下UPDATE操作:

    #%20Transaction%20200BEGIN;#%20更新了一些别的表的记录...UPDATE%20hero%20SET%20name%20=%20'赵云'%20WHERE%20number%20=%201;UPDATE%20hero%20SET%20name%20=%20'诸葛亮'%20WHERE%20number%20=%201;

    此刻,表heronumber1的记录的版本链就长这样:然后再到刚才使用READ%20COMMITTED隔离级别的事务中继续查找这个number1的记录,如下:

    #%20使用READ%20COMMITTED隔离级别的事务BEGIN;#%20SELECT1:Transaction%20100、200均未提交SELECT%20*%20FROM%20hero%20WHERE%20number%20=%201;%20#%20得到的列name的值为'刘备'#%20SELECT2:Transaction%20100提交,Transaction%20200未提交SELECT%20*%20FROM%20hero%20WHERE%20number%20=%201;%20#%20得到的列name的值为'张飞'

    这个SELECT2的执行过程如下:

    • 在执行SELECT语句时会又会单独生成一个ReadView,该ReadViewm_ids列表的内容就是[200]事务id100的那个事务已经提交了,所以再次生成快照时就没有它了),min_trx_id200max_trx_id201creator_trx_id0;
    • 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列name的内容是'诸葛亮',该版本的trx_id值为200,在m_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。
    • 下一个版本的列name的内容是'赵云',该版本的trx_id值为200,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本。
    • 下一个版本的列name的内容是'张飞',该版本的trx_id值为100,小于ReadView中的min_trx_id200,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name'张飞'的记录。

    从上面过程可以总结出:使用READ%20COMMITTED隔离级别的事务在每次查询开始时都会生成一个独立的ReadView,且在READ%20COMMITTED隔离级别下,正是由于每次查询时事务都会生成一个最新的ReadView,这个ReadView太新了,导致每次查询出来的记录可能是不同的(比如select1查询出来的记录是“刘备”,select2查询出来的记录是“张飞”),因此READ%20COMMITTED隔离级别可以避免脏读,但不能避免不可重复读。

    3.3%20REPEATABLE%20READ

    比如现在系统里有两个事务id分别为100200的事务在执行:

    #%20Transaction%20100,尚未COMMITBEGIN;UPDATE%20hero%20SET%20name%20=%20'关羽'%20WHERE%20number%20=%201;UPDATE%20hero%20SET%20name%20=%20'张飞'%20WHERE%20number%20=%201; #%20Transaction%20200BEGIN;#%20更新了一些别的表的记录...

    此刻,表heronumber1的记录得到的版本链表如下所示:现在有一个使用REPEATABLE%20READ隔离级别的事务开始执行查询操作:

    #%20使用REPEATABLE%20READ隔离级别的事务执行select操作BEGIN;#%20SELECT1:Transaction%20100、200未提交SELECT%20*%20FROM%20hero%20WHERE%20number%20=%201;%20#%20得到的列name的值为'刘备'

    这个SELECT1的执行过程如下:

    • 在执行SELECT语句时会先生成一个ReadViewReadViewm_ids列表的内容就是[100,%20200]min_trx_id100max_trx_id201creator_trx_id0
    • 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列name的内容是'张飞',该版本的trx_id值为100,在m_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本;
    • 下一个版本的列name的内容是'关羽',该版本的trx_id值也为100,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本;
    • 下一个版本的列name的内容是'刘备',该版本的trx_id值为80,小于ReadView中的min_trx_id100,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name'刘备'的记录。

    之后,我们把事务id100的事务提交一下,就像这样:

    #%20Transaction%20100BEGIN;UPDATE%20hero%20SET%20name%20=%20'关羽'%20WHERE%20number%20=%201;UPDATE%20hero%20SET%20name%20=%20'张飞'%20WHERE%20number%20=%201;COMMIT;

    然后再到事务id200的事务中更新一下表heronumber1的记录:

    #%20Transaction%20200BEGIN;#%20更新了一些别的表的记录...UPDATE%20hero%20SET%20name%20=%20'赵云'%20WHERE%20number%20=%201;UPDATE%20hero%20SET%20name%20=%20'诸葛亮'%20WHERE%20number%20=%201;

    此刻,表heronumber1的记录的版本链就长这样:然后再到刚才使用REPEATABLE%20READ隔离级别的事务中继续查找这个number1的记录,如下:

    #%20使用REPEATABLE%20READ隔离级别的事务BEGIN;#%20SELECT1:Transaction%20100、200均未提交%20#%20得到的列name的值为'刘备'SELECT%20*%20FROM%20hero%20WHERE%20number%20=%201;#%20SELECT2:Transaction%20100提交,Transaction%20200未提交%20#%20得到的列name的值仍为'刘备'SELECT%20*%20FROM%20hero%20WHERE%20number%20=%201;

    这个SELECT2的执行过程如下:

    • 因为当前事务的隔离级别为**REPEATABLE%20READ**,而之前在执行**SELECT1**时已经生成过**ReadView**了,所以此时直接复用之前的**ReadView**,之前的ReadViewm_ids列表的内容就是[100,%20200]min_trx_id100max_trx_id201creator_trx_id0;
    • 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列name的内容是'诸葛亮',该版本的trx_id值为200,在m_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。
    • 下一个版本的列name的内容是'赵云',该版本的trx_id值为200,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本;
    • 下一个版本的列name的内容是'张飞',该版本的trx_id值为100,而m_ids列表中是包含值为100事务id的,所以该版本也不符合要求,同理下一个列name的内容是'关羽'的版本也不符合要求。继续跳到下一个版本;
    • 下一个版本的列name的内容是'关羽',该版本的trx_id值为100,而m_ids列表中是包含值为100事务id的,所以该版本也不符合要求,同理下一个列name的内容是'关羽'的版本也不符合要求。继续跳到下一个版本;
    • 下一个版本的列name的内容是'刘备',该版本的trx_id值为80,小于ReadView中的min_trx_id100,所以这个版本是符合要求的,最后返回给用户的版本就是这条列c'刘备'的记录。

    从上面过程可以总结出:使用REPEATABLE%20READ隔离级别的事务在查询时,仅会使用第一次select时生成的ReadView,相比READ%20COMMITTED隔离级别每次查询时都会生成一个ReadViewREPEATABLE%20READ隔离级别查询时使用的ReadView版本会没那么新,因此有些最新UPDATE并已经提交的事务对记录做的修改操作对查询事务就会不可见(避免了不可重复读现象的产生),因此REPEATABLE%20READ隔离级别可以同时避免脏读和不可重复读。

    4、MySQL是如何解决幻读的

    上面介绍了MySQL针对读事务是如何解决脏读和不可重复读,而且前面介绍了InnoDB存储引擎区别于SQL标准的是:RR事务隔离级别下幻读也不会发生,那是怎么做到的呢?先说结论:**RR**的隔离级别下,**InnoDB**使用**MVCC****next-key%20locks**解决幻读,**MVCC**解决的是普通读(快照读)的幻读,**next-key%20locks**解决的是当前读情况下的幻读。

    4.1%20InnoDB解决快照读的幻读

    RR事务隔离级别下,对一条记录进行增删改查操作对应如下:

    • **SELECT**:会从最新记录开始遍历版本链,遇到同时满足下面条件的undo记录会返回:
      • 版本链中undo记录的trx_id小于或者等于当前读事务的id
      • undo记录中的删除版本号为空或者删除版本号大于当前事务id
    • **INSERT**:将当前事务的id保存值undo日志的trx_id
    • **UPDATE**:会做以下两件事:
      • 新插入一行undo日志,并且新插入的undo日志的trx_id为当前事务的id,新插入的undo记录的值是更新后的;
      • 同时将原undo日志的记录行的删除版本号设置为当前事务的id
    • **DELETE**:将当前事务的id保存至undo日志对应的删除版本号中。

    举例:当前数据表中有如下2条记录:

    id name
    4 a
    5 b

    这两条记录分别对应的各自的版本链如下(事务id为2的事务插入的id=4的记录,事务id为5的事务插入的id=5的记录):id=4的记录对应的版本链:id=5的记录对应的版本链:id=5的版本链.png假设事务id为10的事务,对表做UPDATE操作,如下:

    1. update table set name="hh" where id > 3;

    则id=4的记录对应的版本链变为:id=4的版本链 (3).png则id=5的记录对应的版本链变为:id=5的版本链 (1).png另一个事务id为11的事务对表做如下INSERT语句:

    1. insert into table values(9, uu);

    该记录(9, uu)对应的版本链如下:事务id为11的版本链.png

    之后事务id为10的事务对表做查询操作,如下:

    1. select * from table where id > 3;

    则根据4.1小节开头介绍的select的2条规则,有如下记录的版本可以被作为查询语句的结果集返回给客户端:

    • id为4、name为hh的版本记录;
    • id为5、name为hh的版本记录。

    这样事务id为10的事务就没有读取到事务id为11的事务新插入的记录(id为9, name为uu),结果集中没有新增的幻影记录,避免了幻读的发生。

    4.2 InnoDB解决当前读的幻读

    所谓当前读,是指加锁(S锁或者X锁)的SELECTUPDATEDELETE等语句。在RR事务隔离级别下,InnoDB会使用行锁中的next-key locks来锁住本条记录以及间隙,避免其他事务插入新的记录。举例:RR事务隔离级别下,一个读事务加了X锁进行如下查询:

    1. SELECT * FROM t WHERE id > 3 FOR UPDATE;

    InnoDB存储引擎会将id=3这条记录和id>3的范围间隙加上next-key locks锁,锁住索引中该记录以及记录id>3的范围,避免其他事务修改当前记录或删除当前记录,避免其他事务在next-key locks范围区间插入新的记录,进而避免产生幻影记录。

    参考

    面试官:你说熟悉MySQL,那来谈谈InnoDB怎么解决幻读的?

    评论:0   参与:  14