八股:MySQL 中的 MVCC 是什么?(初版)

23 阅读15分钟

Mysql中的MVCC是什么?

阅读大纲

画板

对话解析

话题:MVCC是什么?

大佬: 那么Mysql中的MVCC?你知道是啥不?

凯歌: 那个啊,是这几个单词的简写,multi-version-concurrency-control,翻译成中文就是多版本并发控制。是一种并发控制机制。当有多个事务同时对数据库进行读写的时候,有了这个机制就可以让他们一块执行,不用等待

话题:MVCC咋实现的,简单讲解

大佬: 哎呦?这么厉害?它咋实现的啊?加锁?

凯歌: 它呀,当多个事务同时读写数据库的时候,它给每个数据创建了一个数据快照,然后mysql那不会立即覆盖原有的数据,而是生成新的版本记录。每个记录都保留了对应的版本号和时间戳

凯歌: 多个版本之间串联起来,就形成一条版本链,这样不同时刻启动的事务,可以无锁的获得,不同版本的数据(普通读)。此时读(普通读)写操作不会阻塞。

凯歌: 写操作可以继续写,无非就是会创建新的数据版本,但是只有在事务提交后,新版本才会对其他事务可见,未提交的事务修改,不会影响其他事务的读取。记录中的历史版本可供已启动的事务读取。

凯歌: 老样子,画个简图,给你个看看。

图内容简介:

  1. 同一时刻,有三个事务,要对id=1的数据进行操作

  2. 第一个事务:新增id为1的数据,新增姓名为凯歌

  3. 第二个事务:修改id为1的数据,修改姓名为yes

  4. 第三个事务:修改id为1的数据,修改姓名为鱼皮

画板

话题:版本链是什么?

大佬: 你这说的也太简单了,能不能深入说说啊,比如这个多版本,这个版本链到底是咋实现的?

凯歌: 没问题,其实吧,这个MVCC里的多版本,并不是说真的存储了多个版本的的数据,只是借助Undo Log记录了每次写操作的反向操作,所以索引上对应的记录只会有一个版本,即最新版本。只不过可以根据Undo Log中的记录反向操作得到数据的历史版本,所以看起来是多个版本。

**凯歌:**上面这段说的有点多,我给你画个图,协助理解下。

画板

凯歌: 这一块,比较难理解**,**下面我拿上面的insert语句,再进行一个举例。

凯歌: 当我们执行insert语句成功后,在数据页中,会新增一条id=1,name=凯歌的记录。

凯歌: 在id=1,name=凯歌的时候,还会记录一些别的信息,我们称为隐藏字段,这里我们在insert成功的同时,就还存储了trx_idroll_pointer这两个隐藏字段。如下图:

  • trx_id:当前事务ID

  • roll_pointer:指向Undo Log的指针

画板

凯歌: 如上图,此时插入的事务ID=1,此时插入,会生成一条Undo Log,并且记录上的roll_pointer会指向这条Undo Log,而这条Undo Log是一个类型为TRX_UNDO_INSERT_REC的log,代表是insert生成的。

凯歌: 里面存储了主键的长度和值(其他值,暂时不提),所以InnoDB,可以根据undo log里面的主键值,找到这条记录,然后把他删除,来实现回滚(复原)的效果。

凯歌: 因此可以简单的理解undo log里面存储的就是当前操作的反向操作,可以认为里面存储了一个delete 1;

凯歌: 此时,事务ID=1的事务提交,然后,另一个事务ID=5的事务在执行update语句,更新id=1,name=yes,此时的记录和undo log 就如下图:

画板

凯歌: 正如上图,我们之前insert对应的undo log消失了。insert的事务提交了之后,对应的undo log就回收了。因此,再有事务来访问的时候,就不可能访问到比这个还要早的版本。

凯歌: 但是!update产生的undo log是不一样的,它的类型为,TRX_UNDO_UPD_EXIST_REC。

凯歌: 具体哪里不一样,我们继续往下看,当我们的事务ID=5,也就是刚才的update事务,提交后,来了另一个事务ID=11的事务来了,它要执行update语句,将id=1,name=yes的数据,改为id=1,name=鱼皮。那么他执行后现象,就如下图所示:

画板

凯歌: 正如上图,这条update语句执行之后,我们之前产生的 undo log没有删除,依旧保留,因为可能会有别的事务,需要访问之前的版本,所以不能删除。

凯歌: 这样就形成了一个版本链,可以看到记录本身id=1,name=鱼皮,外加两条undo log ,这条id=1的记录,一共有三个版本。

话题:ReadView是什么?

大佬: 哦,这版本链,我明白了,但是这么多版本,一个事务过来了,它咋知道用哪个版本啊?你这也没说啊?

凯歌: 说,这就说。要想知道,当前事务,需要的版本是哪个,我们就需要引入一个新的东西,叫ReadView。

凯歌: 这个ReadView吧,就是用来判断哪个版本对当前事务,是可见的。这里有四个概念,见下:

creator_trx_id:当前事务ID

m_ids:生成readView时,还不活跃的事务ID集合,也就是已经启动,但还未提交的事务列表。

min_trx_id:当前活跃ID之中的最小值。

max_trx_id:生成readView时,InnoDB将分配给,下一个事务的,ID的值。(事务ID是递增分配的,越后面申请的事务ID就越大)。

凯歌: 对于当前事务,可见版本的判断,是从最新版本开始,沿着版本链逐渐寻找老的版本,如果遇到符合条件的版本就返回。

凯歌: 判断条件如下:

TRX_ID(当前数据事务ID)是否可见原因
trx_id == creator_trx_id可见修改这条数据的事务,就是当前事务,所以可见
trx_id <min_trx_id可见修改这条数据的事务,在当前事务生成readView的时候,已经提交,所以可见
min_trx_id =< trx_id < max_trx_id 且 trx_id 在 m_ids中不可见说民修改这条数据的事务,还没提交
min_trx_id =< trx_id < max_trx_id 且 trx_id 不在 m_ids中可见说明修改这条数据的事务,已经提交
trx_id >= max_trx_id不可见说明修改这条数据的事务,在当前事务生成readView的时候,还没启动,所以不可见。(事务ID是递增的)

凯歌: 光看文字,没有效果,我们举个例子,练练手

举例:读已提交隔离级别下的MVCC

凯歌: 现在隔离级别是:读已提交

背景描述:

假设,此时上文的:

事务ID=1的事务,已提交

事务ID=5的事务,已执行

此时:

事务ID=6的事务来了,状态是:未提交

(trx_id=6的事务要执行update table set name = '哈哈' where id = 2;)

现在,最大事务ID是6

这时候:

有一个查询语句,开启了事务,语句为:select name from table where id = 1;

那么这个查询语句对应的上述四个概念的值,见下表

指标原因
creator_id0因为一个事务,只有当有修改操作的时候,才会被分配事务ID
m_ids[5,6]这两个事务都未提交,是活跃的
min_trx_id5
max_trx_id7因为当最大的事务ID是6,新来的时候,会递增,也就是7

凯歌: 由于,trx_id=7的事务,要查找的是id=1的数据,所以需要先找到id=1的这条记录,此时的版本,如下图:

画板

凯歌: 此时,id=1的数据的,最新版本记录上的trx_id=5,等于min_trx_id,且trx_id=5,在m_ids中,表明还是活跃的,未提交事务的,所以不可访问。

凯歌: 因为trx_id=5对应的数据,不可访问,所以,根据roll_pointer,我们找到了上图中的undo log

凯歌: 在undo log中,trx_id=1,比min_trx_id还要小,说明在生成readView之前,就已经提交了。所以可以访问。

凯歌: 因此,trx_id=7的事务,会获得name=凯歌的结果。

事务ID=7的事务,拿到结果后,事务ID=5的事务,提交了

此时,再次执行之前的SQL:select name from table where id = 1;

又会生成新的readView

同样,我们还是看那四个指标

指标原因
creator_id0没有修改操作,所以还是0
m_ids[6]此时5已经提交了,所以只剩6了
min_trx_id6
max_trx_id7因为没有新的事物进来,所以还是7

凯歌: 此时,根据SQL,我们还是查找的id=1的数据,所以当前版本还是跟刚才一样的图,如下:

画板

凯歌: 但是!这次数据中记录的的trx_id=5 小于min_trx_id,所以数据所在的最新版本,是可见的,所以,这次得到接结果是 name=yes!

凯歌: 这个,就是读已提交的MVCC,可以看到一个事务中的两次查询得到了不同的结果,所以也叫不可重复读。

凯歌: 这种两次读取得到的结果不一致的现象,我们称之为幻读。


举例:可重复读隔离级别下的MVCC

凯歌:现在隔离级别是:可重复读

凯歌: 可重复读和读已提交的MVCC版本判断的过程是一模一样的,我们就不再画了,这里说下两者之间的差别。

凯歌: 差别:生成readView

隔离级别READVIEW生成规则
读未提交每次查询,都会重新生成新的readView,每次查询的readView都用新的
可重复读第一次查询生成readView后,后面每次查询都用的第一次生成的

凯歌: 根据上述表格,我们可以知道,可重复读对比读已提交来说,差别就在第二次

凯歌: 读已提交,两次查询,四个指标

读已提交,第一次查询

指标原因
creator_id0因为一个事务,只有当有修改操作的时候,才会被分配事务ID
m_ids[5,6]这两个事务都未提交,是活跃的
min_trx_id5
max_trx_id7因为当最大的事务ID是6,新来的时候,会递增,也就是7

读已提价,第二次查询

指标原因
creator_id0没有修改操作,所以还是0
m_ids[6]此时5已经提交了,所以只剩6了
min_trx_id6
max_trx_id7因为没有新的事物进来,所以还是7

凯歌: 可重复读,两次次查询,四个指标

可重复读,两次查询使用的都是第一次生成的,也就是下表

指标原因
creator_id0因为一个事务,只有当有修改操作的时候,才会被分配事务ID
m_ids[5,6]这两个事务都未提交,是活跃的
min_trx_id5
max_trx_id7因为当最大的事务ID是6,新来的时候,会递增,也就是7

凯歌: 根据上述的分析过程,我们可以得到,可重复读,两次得到的查询结果都是一样的,均是:name=凯歌

话题:可重复读可以完全避免幻读的产生吗?

大佬: 哦哦哦,我明白了,读已提交,会产生幻读,而可重复读,不会产生幻读,所以我们应该用可重复读。

凯歌: 额,停,你说的有点问题,可重复读,其实并不能完全避免幻读。

大佬: 啊?我每次读都是第一个readView咋还会幻读?

凯歌: 唉,这个就得给你再展开说下了。

凯歌: 可重复读,其实分为两种实现,第一种是我们上面说的那种,也成快照读,每次都是读第一次的readview,另外一种,是当前读,就是我们读取的时候,要保证数据是最新的。

话题:当前读是什么?

凯歌: 快照读,我们上面说的很清楚了,下面我讲下当前读。

**凯歌:**在可重复读的隔离环境下,有时候,我们也需要保证我们的数据是最新的,需要保证实时更新,比如,当我们要更新一条数据的时候,需要先去查询一下数据存在不存在,对不对?如果数据都不存在了,我们总不能也还是事务执行成功吧。

凯歌: 所以,当我们在执行一些需要确保数据一致性的操作的时候,就不能使用快照读,需要使用当前读。

一些使用当前读的场景

  1. select ..... for update

  2. select .... lock in share mode

  3. 插入、更新、删除操作

  4. 其他需要确保数据一致性的操作,例如:create table ....like

大佬: 那当前读,是怎么保证数据一致的啊?加锁?

凯歌: 对的,当前读,会通过判断操作性质,给读取的数据加不同的锁,来保证数据的一致性,锁的一些例子,如下图:

锁类型适用操作解释
共享锁(S锁)select .... lock in share mode允许多个事务读取同一行,但阻止其他事物,对其进行修改
排他锁(X锁)select ... for update阻止其他事务读或者修改本行
间隙锁(Gap Lock)select ... where column > value锁定锁记录之间的间隙,而不是具体的行,防止其他事务,在这些间隙中插入新的记录
Next-keyselect ... where column > value行锁和间隙锁的结合体,不仅锁行记录,还锁间隙,可以防止其他事务,在该行之前或之后的间隙中插入新记录
等等

凯歌: 说了这么多,我们再举个例子,演示下,可重复读隔离级别下出现幻读的现象

画板

凯歌: 由上图我们可以看到,事务A,有两条SQL

隔离级别:可重复读

事务A:

第一条SQL:InnoDB判断操作类型,执行快照读

第一条SQL执行完毕,得到 1条数据,id=3

事务B:

执行insert语句,并提交事务

事务A:

第二条SQL:InnoDB判断操作类型,执行当前读

第二条SQL执行完毕,得到 2条数据,id=3和id=4

凯歌: 根据上述描述,我们可以看到,在可重复读的隔离级别下,依旧产生了幻读。

大佬: 那这我咋避免啊?

凯歌: 我们可以在事务A,开始执行的时候,直接执行 select ...for update。这样在事务A,开始的时候,就加了锁,其他事务也就新增不了,避免了幻读的产生。

大佬: 哦哦哦,明白了,但是感觉还有些细节,没太懂,你再给说下吧。

凯歌: 行,不过今天不行了,今天没时间了,在后续的文章中,我们再补充吧。

大佬: 你又来这招。

凯歌:以上,就是我们本次关于MySQL中MVCC是什么?的全过程的讲解了,若有错误,请帮忙指出,一定修改。