美文网首页MySQLPHP经验分享数据结构和算法分析
MVCC基本实现原理以及与事务隔离级别的关联

MVCC基本实现原理以及与事务隔离级别的关联

作者: 路过的猪 | 来源:发表于2020-03-27 15:58 被阅读0次

    1. 基础知识

    1.1 常规读和带锁读

    1. 带锁读(当前读):如select .. lock in share modeselect .. for update、以及隐含当前读的insertupdatedelete等(读出来才能进行更新/删除/唯一索引判断等)
    2. 常规读(一致性读):如常用的select ...

    【带锁读】通过加锁的方式保证事务隔离特性(有无脏读/不可重复读/幻读等);
    【常规读】则是通过 多版本并发控制机制(MVCC,Multi-Version Concurrency Control)实现。

    插入/更新/删除等写操作时:既会加锁保证【带锁读】的隔离特性;也会备份之前版本的数据用于MVCC,以保证【常规读】的隔离特性(详见本文第二节)。

    1.2 事务隔离级别

    隔离级别 脏读
    (Dirty Read)
    不可重复读
    (Non-Repeatable Read)
    幻读
    (Phantom Read)
    未提交读
    (UNCOMMITTED)
    提交读
    (READ COMMITTED)
    -
    可重复读
    (REPEATABLE READ)
    - - -
    串行化
    (SERIALIZABLE)
    - - -

    值得注意的是:MySQL InnoDB中默认的隔离级别【可重复读】下,是不存在幻读问题的。


    本事务读到其他事务尚未提交的数据时,称之为【脏读】。
    这里的【脏】指的是【未提交的数据】,这个和读到【过期的数据】是不同的。

    比如某个时刻,a=1 已经被其他事务更新成 a=2 且提交了,而我这个事务还是读到a=1,这就是读到过期数据了,可以称之为【过期读】。
    而如果其他事务更新 a=2 尚未提交,我这个事务就读到了a=2,这个就是【脏读】了。

    脏读通常是不可容忍的,除非有特殊要求,否则隔离级别一般不会设置为【未提交读】


    同一个事务中,同样的SQL,多次查询,查询结果不一样时,称之为【不可重复读】。
    例如 同一个事务中,第一次查询 [name=zhangsan] 但是第二次查就变为了:[name=lisi]

    相反地,如果同一个事务中,每次查询结果都不会变时,自然就是【可重复读】了。
    【可重复读】隔离级别下,读到的数据有可能是过期的,但不会是脏读。

    类似select * from t where id=1select * from where id=1 t for update并不属于同样的SQL。所以哪怕是在【可重复读】的隔离级别下,同一个事务中,这两条SQL查询结果不一样也是正常的。


    同一个事务中,同样的SQL,多次查询,查询的结果集不一样时,称之为【幻读
    例如 同一个事务中,第一次查询结果为一行,但是第二次查询就变成两行了。

    【不可重复读】关注的是某行内容是否发生变化,而【幻读】则关注行数量是否发生变化。

    注:本文幻读的含义主要参考MySQL官网文档:14.7.4 Phantom Rows

    2. MVCC

    2.1 多个版本的行数据

    InnoDB中记录数据的基本单位为页(InnoDB Page,默认16KB),页的类型有有多种的,比如存储当前数据的数据页(B-Tree Node)、存储逻辑回滚/备份数据的undo 页(Undo Log Page)等。

    当执行insert/update/delete写操作时,除了要修改对应数据页之外,还会对之前的数据进行备份(记录至undo页中)。如果事务需要回滚,找到对应的undo 记录进行应用回滚即可。
    注意:哪怕事务尚未提交,写操作也会立即修改当前的数据页。所以回滚要到undo log中找。

    显然,行数据是会有多个版本的(当前数据页 + undo页),为了区分各个版本的数据,每一行记录都会额外多出一个隐藏的版本号字段(trx_id),trx_id即对应写操作的事务id。

    每个事务都能分配到一个全局递增的事务id(trx_id),当该事务进行写操作时,会将该值一并写入行记录中(见下例)。


    例一:当前事务id=10,插入:[id=1, name=zhangsan]

    1. 找到可以插入的数据页;
    2. 写入记录:[id=1, name=zhangsan, trx_id=10]
      同时生成undo log:[log_type="insert", id=1]

    *回滚*时:找到undo log进行应用,删除id=1的记录(插入的反操作)。

    例二:当前事务id=20,更新:[set name=lisi where id = 1]

    1. 找到对应记录的所在记录页;
    2. 修改记录为:[id=1, name=lisi, trx_id=20]
      同时生成undo log:[log_type="update", id=1, name=zhangsan, trx_id=10]

    *回滚*时:找到undo log进行应用,反向更新数据回 [id=1, name=zhangsan, trx_id=10]

    注:两条undo log记录可能不在同一个undo页中

    例三:当前事务id=30,删除:[id=1]

    1. 找到对应记录的所在记录页;
    2. 修改记录为:[id=1, name=lisi, trx_id=30, delete_flag=1]
      同时生成undo log:[log_type="update", id=1, name=lisi, trx_id=20, delete_flag=0]

    执行删除SQL时,并不是直接将记录从数据页中抹掉,而是通过一个删除位(delete_flag)来进行标识,将该字段置为1即标识这行数据已经被删除了;同时和其他写一样会记录操作事务的trx_id。

    *回滚*时:找到undo log进行应用,反向更新数据回 [id=1, name=lisi, trx_id=20, delete_flag=0]

    undo log的具体记录字段可以稍微了解下:

    1. insert into... :含主键;
    2. delete .. :含所有字段的之前的值。
    3. update .. :含需要更新字段的之前的值;
      如果是更新主键,等同于将之前行记录的删除,然后再插入,将产生两条undo log。

    undo log除了用于备份数据支持事务回滚之外,其数据多版本的特性与事务快照结合之后,将可以用于支持事务隔离的相关特性(比如避免脏读/不可重复读/幻读等)。

    2.2 事务快照

    大家应该拍过照片,按下快门,我们就可以将当前时刻的景物记录到一张小小的照片,尽管时光荏苒,岁月变迁,照片中的景物也不会发生变化。

    如果我们给数据库中的事务拍一张照片的话,我们会看到:在拍照的那一瞬间,有的事务已经提交,有的正在运行中,有的事务尚未开始

    事务快照,黑色表示事务已提交

    就如上图中的快照,trx_id小于15的事务都已经提交了,大于等于31的则尚未开始;中间的15/25还在跑,而20/30已经提交。

    如果你现在的事务id为25,当隔离级别为【可重复读】时:你能到哪些事务修改的数据呢?答案是显然的,已经提交的则看得见(图中黑色),还没提交的自然就看不见,否则就是脏读了。

    可时间是会变化的,假设后来15进行了提交,那我们能否看得见该事务的修改记录呢(比如 a=1 修改为了 a=2)?这个也是应该看不见的,因为如果事务15提交前我们看到的是a=1,而提交后变为a=2了,这就出现了不可重复读了,这显然和【可重复读】相悖了。
    事实上,正如前面所说时间会变但照片不变一样,一旦我们拍下事务快照之后,id=15的事务对于咱们来讲,“它一直都是处于未提交的”(除非我们重新拍过另外一张快照)。

    在【可重复读】隔离级别下,一旦触发快照后,这个快照会一直存在,直至事务结束。哪些事务已提交,哪些没提交,也会在这一瞬间定格。这也就保证了我们永远都在同一张照片里面“找”数据,从而保证了【可重复读】。
    接下来我们来看一下怎样基于事务快照来“找”数据。

    注:【可重复读】隔离级别下,事务快照的触发时机主要有:

    • 开启事务后(begin/start transaction;),执行第一条常规读SQL(select)时;
    • 开启事务时,直接开启快照:start transaction with consistent snapshot.

    2.3 MVCC查询基本流程

    基于数据快照和多版本数据,查询的大概过程为:

    1. 触发事务快照
    2. 根据查询条件找到的数据页中的记录,获取该数据的版本号(即写入该记录的事务trx_id
    3. 基于快照,判断这个写入记录的事务(trx_id)对于快照来讲是否可见
      3.1 如果可见,则返回结果;
      3.2 如果不可见,继续找下一个版本的数据。

    我们可以用一个简单的数据结构(Read View)来记录事务快照(建议结合上节的事务快照图看):

    Read_View {
      // 最小的事务id,数据版本号 < min_id 表示可见
      long min_id;      
    
      // 最大的事务id,数据版本号 >= max_id 表示不可见
      long max_id;      
    
      // 中间还在跑的事务id,数据版本号在里面则表示不可见(排除本事务,自己肯定看得到自己修改的记录)
      long[] running_ids;   
    
      // 是否可见
      bool canSee(long data_version_trx_id) {
        return data_version_trx_id < min_id || !running_ids.contains(data_version_trx_id);
      }
    }
    

    假设时间上有那么三个写操作,

    1. 插入记录:[id=1, name=zhangsan] ,操作的事务trx_id = 10
    2. 更新记录为:[id=1, name=lisi],操作的事务trx_id = 20
    3. 删除该记录:操作的事务trx_id = 30

    都执行后,其数据多版本的一个呈现如下图:

    如果期间有其他事务有触发过快照,基于【可重复读】的隔离级别,快照之后读到的数据都是一样的(同一个事务中)。我们来分析一下,等上面三个操作均执行完成之后,我们是怎么追溯回快照时刻的数据的。


    例一:假设本事务在某个时刻建立了快照:[min_trx_id=40, max_trx_id=50, running_ids=[40]],而后在某个时刻发起查询select * from t where id=1

    快照时刻,事务10/20/30均已经提交了,所以最新的修改记录就是事务30将这条记录给删了,这个“删除”的修改对于快照是可见的,所以结果返回空了。


    例二:假设本事务在某个时刻建立了快照:[min_trx_id=15, max_trx_id=28, running_ids=[15]],而后在某个时刻发起查询select * from t where id=1

    快照时刻,事务10/20已经提交,而事务30尚未开始,所以能看到所有已经提交中最新的记录,即事务20:更新记录为[id=1, name=lisi]


    例三:假设本事务在某个时刻建立了快照:[min_trx_id=10, max_trx_id=28, running_ids=[10, 20]],而后在某个时刻发起查询select * from t where id=1;

    快照时刻,事务30尚未开始,事务10/20均在运行中,均属于未提交;插入的事务(10)都尚未提交,所以都看不见,最终返回空。

    关于undo log的清除:

    1. 对于运行中事务引用到的undo log,不可以清除,因为可能要用于回滚;
    2. 对于插入产生的undo log,在对应写事务结束后便可以删除了;因为对于"insert"类型的undo对于其他事务来讲等同于空。
    3. 对于其他类型的undo log,将对被定期清除(Purge),前提是要确定当前所有的事务快照不会再有机会用到(到达)该版本的数据了。

    可以看到,事务快照不变时,看到的数据将始终停留在某一个版本的

    • 隔离级别为【可重复读】时,一旦获取快照后,会一直用这个快照,从而保证不会出现【不可重复读】。基于快照,如果插入数据的事务尚未提交,也是不可见的,这也就避免了【幻读】。
    • 隔离级别为【提交读】时,每次常规读(select)都会创建一个新的事务快照,所以每次读到的都是最新快照时刻的数据;这也就导致了【不可重复读】。
    • 隔离级别为【未提交读】时,并没有使用快照,而是无论事务有无提交,直接读数据页中的行记录作为结果(undo页的数据都不管),从而导致【脏读】。

    3. 总结

    • 带锁读通过加锁保证事务特性,而常规读通过MVCC实现;
    • 当执行写SQL时,除了写数据页,还会记录undo log;undo log可用于以前版本数据的回溯;
    • 同一个事务快照,常规读 读到的数据 一直都是一致的;
    • 针对不同的隔离级别,常规读时:
      • 未提交读:直接读取当前数据页的数据(可能脏读);
      • 提交读:每次读都建立新快照,会读到已提交的、最新的数据(无脏读、可能不可重复读)
      • 可重复读:只会在第一次读时(或start transaction with consistent snapshot)建立快照,之后的常规读均基于该快照(无脏读、无不可重复读、无幻读)
      • 串行化:主动开启事务查询时,会将常规select将被转换为select .. lock in share mode,通过加锁的方式(参考可重复读)保证事务特性(无脏读、无不可重复读、无幻读)

    相关文章

      网友评论

        本文标题:MVCC基本实现原理以及与事务隔离级别的关联

        本文链接:https://www.haomeiwen.com/subject/ptbquhtx.html