MySQLPHP经验分享数据结构和算法分析

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

2020-03-27  本文已影响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),前提是要确定当前所有的事务快照不会再有机会用到(到达)该版本的数据了。

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

3. 总结

上一篇下一篇

猜你喜欢

热点阅读