MySQL的MVCC
一,MySQL的MVCC
1.1介绍
在介绍MVCC之前首先了解一下事务并发问题:
问题产生原因:同时运行多个事务,当这些事务访问数据库中相同数据时,会产生各种并发问题;
- 读-读问题:读读并发不存在问题。
- 写-写问题:写写会产生脏写问题,两个事务没提交的状况下,都修改统一条数据,结果一个事务回滚了,把另外一个事务修改的值也撤销了,所谓脏写就是两个事务没提交状态下修改同一个值。
- 读-写/写-读:写读或读写会造成脏读、幻读、不可重复读的问题。
- 脏读:事务B读到事务A修改后并未提交的数据;
- 不可重复读:事务A多次查询得到的结果不一致
- 幻读:A读取表,B插入一条数据,A再次读,得到的结果集条数不同
注意区分:脏读针对更新数据,幻读针对插入数据。
读读是不存在问题的;写写问题产生脏写问题,mysql的任何事务隔离级别都是可以解决的,因为mysql的任何隔离级别都不允许,在两个事务没提交的状况下,同时修改同一个值,只有一个事务能修改的值,另一个事务如果修改同一个值会进入阻塞状态,所以不会存在两个事务没提交状态下修改同一个值的情况;读写问题也可以通过设置事务的隔离级别可以解决,事务的隔离级别的读已提交和可重复读的底层就是使用MVCC实现的,使用MVCC好处,在于MVCC是一种用来解决读-写冲突的无锁并发控制.
MVCC 是为了实现读-写冲突不加锁,而这个读指的就是快照读, 而非当前读。
快照读:读取的是快照版本,就像简单的select操作(当然不包括 select … lock in share mode, select … for update),即不加锁的非阻塞读,快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读。之所以出现快照读的情况,是基于提高并发性能的考虑,快照读的实现是基于多版本并发控制(MVCC)。所以我们可以认为MVCC是行锁的一个变种,但MVCC在很多情况下它避免了加锁,降低了开销,既然是基于多版本的,所以快照读不一定读到的就是最新版本的记录,而是可能为之前的历史版本。
当前读:读取的是最新版本,就像update、delete、insert、 select lock in share mode(共享锁),select for update(排他锁);这些操作都是一种当前读,为什么叫当前读?因为它读取的记录都是目前数据库中最新的版本,读取时还要保证其它并发事务不能修改当前记录,所以会对读取数据加锁。
1.2实现原理
MVCC 的实现原理主要是依赖记录中的 3个隐式字段,undo日志 ,Read View 来实现的。
1.2.1 3个隐式字段

每行记录除了我们自定义的字段外,还有数据库隐式定义的DB_TRXID, DB_ROLL, DB_ROW_ID等字段
- DB_ROW_ID: 数据库默认为该行记录生成的唯一隐式主键
- DB_TRX_ID: 当前操作该记录的事务ID
- DB_ROLL_PTR: 一个回滚指针,用于配合 undo日志,指向上一个旧版本
1.2.2undo日志
InnoDB把这些为了回滚而记录的这些东西称之为 undo log。
值得注意的是,由于查询操作(SELECT)并不会修改任何用户记录,所以在查询操作时,并不需要记录相应的 undo log。
undo log 主要分为以下三种:
- insert undo log:插入一条记录时,至少把这条记录的主键记录下来,之后回滚的时候只需要把主键对应的记录删除即可。
- update undo log:修改一条记录时,至少要把修改这条记录前的旧值都记录下来,在回滚的时候再把这条记录的值更新为旧值就好了。
- delete undo log:删除一条记录时,至少要把这条记录中的全部内容都记录下来,这样在之后回滚的时候再重新将这些内容组成的记录插入到表中就好了。为了节省磁盘空间,InnoDB有专门的 purge(清除)线程来清理 DELETED_BIT 为 true 的记录。
对 MVCC 有实质上帮助的是 update undo log,undo log 实际上就是存在于 rollback segment 中的旧纪录链:
①,假设最开始有一条就有一条记录,name列 = 大大 ,隐式主键 = 1,事务ID=0,回滚指针都假设为 NULL;

②,现在来了事务1对该记录的 name 做出了修改,改为 小1:
- 事务1 修改该行记录数据的同时,数据库会先对该行加排他锁(InnoDB引擎会自动对DML语言影响的记录上写锁|独占锁)。
- 上锁完毕后,将该行数据拷贝到 undo log 中,作为旧记录,即在 undo log 中有当前行的拷贝副本。
- 拷贝完毕后,修改该行的 name 为 小1,并且修改隐藏字段的事务ID 为当前事务1的ID,这里我们默认是从1开始递增,回滚指针指向拷贝到 undo log 的副本记录,即表示我的上一个版本就是他。
- 事务提交后,释放锁。

③,又来了一个事务2又对该记录的 name 做出了修改,改为 小2;
在事务2修改该行数据之前,数据库继续给他上排他锁。
上锁完毕之后,把该行数据拷贝到 undo log 中,作为旧记录,发现操作的这行记录已经有undo log 的记录了,那么最新的旧数据作为链表的表头,插在这行记录的 undo log 日志的最前面。
修改该行name 为小2,并且修改隐藏字段的事务ID为当前事务2的ID,回滚指针指向刚刚拷贝到 undo log 的副本记录。
事务提交,释放锁。

image-20230113163330894
从上面例子可以看出,不同事务或者相同事务对同一个记录的修改,会导致该记录的 undo log 成为一条版本记录链
1.2.3Read View
Read View 主要是用来做可见性判断的, 即某个事务执行快照读的时候,对该记录创建一个Read View视图,把创建的Read View视图作为对比条件和一条记录中隐藏字段DB_TRX_ID对比,来判断这条记录对于事务是否可见。
①,Read View的生成时机,对于Read View的生成时机,也非常关键,正是因为生成时机的不同,造成了事务的隔离级别读已提交和可重复读两种隔离级别的不同可见性:
- 事务隔离级别是读已提交时,每当执行查询语句时会生成一个ReadView视图;
- 事务隔离级别是可重复读时,只有第一次执行查询语句时会生成ReadView视图,后续不会改变.
②,Read View的结构,Read View的结构包含两部分未提交事务ID的集合和已创建的最大事务id。

③,对比规则
Read View遵循一个可见性算法,主要是将要被修改的数据的最新记录中的 DB_TRX_ID(即当前事务 ID )取出来,与当前执行查询的事务创建一个Read View视图对比。
根据Read View的结构,可以将Read View视图划分为三部分:已提交的事务,已提交和未提交的事务,未开始的事务

版本链比对规则:
如果 一条记录 的 事务Id 落在灰色部分(事务Id<小于未提交事务id集合里最小的id)表示这个版本是已提交的事务生成的,这个数据是可见的.
如果 一条记录 的 事务Id 落在红色部分( 事务Id>已创建的最大事务id ),表示这个版本是由将来启动的事务生成的,是不可见的(或者 这条事务的id就是当前执行事务的查询id是可见)。
如果 一条记录 的 事务Id 落在绿色部分(未提交事务id集合里最小的id<= 事务Id <= 已创建的最大事务id),那就包括两种情况:
- 若 一条记录 的 事务Id 在未提交的事务id集合中,表示这个版本是由还没提交的事务生成的,不可见 (或者这条事务的id就是当前执行事务的查询id是可见)。
- 若 一条记录 的 事务Id 不在未提交的事务id集合中,表示这个版本是已经提交了的事务生成的,可见。
1.3实例演示
1.3.1可重复读:
注意:事务隔离级别是可重复读时,只有第一次执行查询语句时会生成ReadView视图,后续不会改变.

假设原始结果如下:

事务3(黑色背景)的第1次查询(第④步)第一次查询会产生一个ReadView:【 [1,2,3],3】并且事务隔离级别是可重复读时,生成ReadView视图,后续不会改变。根据版本链比对规则,该条记录的DB_TRX_ID=0,落在灰色部分(事务Id<小于未提交事务id集合里最小的id)是可见的,所以读出结果是大大。
事务1(绿色背景)对该记录的 name 做出了修改(第⑤步),改为小1,产生的版本链:

事务3(黑色背景)的第2次查询(第⑥步)使用第一次产生ReadView:【 [1,2,3],3】不变。根据版本链比对规则,该版本链的最新数据的DB_TRX_ID=1,落在绿色部分(未提交事务id集合里最小的id<= 事务Id <= 已创建的最大事务id) 并且 在未提交的事务id集合中 并且 记录的DB_TRX_ID不等于当前查询事务3的id,是不可见,根据日志回滚指针判断下一条记录DB_TRX_ID=0,落在灰色部分(事务Id<小于未提交事务id集合里最小的id)是可见的,所以读出结果是大大。
事务3(黑色背景)的第3次查询(第⑧步)使用第一次产生ReadView:【 [1,2,3],3】不变。根据版本链比对规则,该版本链的最新数据的DB_TRX_ID=1,落在绿色部分(未提交事务id集合里最小的id<= 事务Id <= 已创建的最大事务id) 并且 在未提交的事务id集合中 并且 记录的DB_TRX_ID=1不等于当前查询事务3的id,所以是不可见,根据日志回滚指针判断下一条数据的DB_TRX_ID=0,落在灰色部分(事务Id<小于未提交事务id集合里最小的id)是可见的,所以读出结果是大大。
事务2(白色背景)的第1次查询(第⑨步)第一次查询会产生一个ReadView:【 [2,3],3】。根据版本链比对规则,该版本链的最新数据的DB_TRX_ID=1,落在灰色部分(事务Id<小于未提交事务id集合里最小的id)是可见的,所以读出结果是小1。
事务2(白色背景)对该记录的 name 做出了修改(第⑩步),改为小2,产生的版本链:

事务3(黑色背景)的第4次查询(第11步)使用第一次产生ReadView:【 [1,2,3],3】不变。根据版本链比对规则,该版本链的最新数据的DB_TRX_ID=2,落在绿色部分(未提交事务id集合里最小的id<= 事务Id <= 已创建的最大事务id) 并且 在未提交的事务id集合中 并且 记录的DB_TRX_ID=2不等于当前查询事务3的id,所以是不可见,根据日志回滚指针判断下一条数据的DB_TRX_ID=1,落在绿色部分(未提交事务id集合里最小的id<= 事务Id <= 已创建的最大事务id) 并且 在未提交的事务id集合中 并且 记录的DB_TRX_ID=2不等于当前查询事务3的id,所以是不可见,根据日志回滚指针判断下一条数据的DB_TRX_ID=0落在灰色部分(事务Id<小于未提交事务id集合里最小的id)是可见的,所以读出结果是大大。
事务3(黑色背景)的第5次查询(第13步)使用第一次产生ReadView:【 [1,2,3],3】不变。根据版本链比对规则,该版本链的最新数据的DB_TRX_ID=2,落在绿色部分(未提交事务id集合里最小的id<= 事务Id <= 已创建的最大事务id) 并且 在未提交的事务id集合中 并且 记录的DB_TRX_ID=2不等于当前查询事务3的id,所以是不可见,根据日志回滚指针判断下一条数据的DB_TRX_ID=1,落在绿色部分(未提交事务id集合里最小的id<= 事务Id <= 已创建的最大事务id) 并且 在未提交的事务id集合中 并且 记录的DB_TRX_ID=2不等于当前查询事务3的id,所以是不可见,根据日志回滚指针判断下一条数据的DB_TRX_ID=0落在灰色部分(事务Id<小于未提交事务id集合里最小的id)是可见的,所以读出结果是大大。
1.3.2读已提交:
注意:事务隔离级别是读已提交时,每当执行查询语句时会生成一个新的ReadView视图;

假设原始结果如下:

事务3(黑色背景)的第1次查询(第④步)第一次查询会产生一个ReadView:【 [1,2,3],3】。根据版本链比对规则,该条记录的DB_TRX_ID=0,落在灰色部分(事务Id<小于未提交事务id集合里最小的id)是可见的,所以读出结果是大大。
事务1(绿色背景)对该记录的 name 做出了修改(第⑤步),改为小1,产生的版本链:

事务3(黑色背景)的第2次查询(第⑥步)会产生一个新的ReadView:【 [1,2,3],3】。根据版本链比对规则,该版本链的最新数据的DB_TRX_ID=1,落在绿色部分(未提交事务id集合里最小的id<= 事务Id <= 已创建的最大事务id) 并且 在未提交的事务id集合中 并且 记录的DB_TRX_ID不等于当前查询事务3的id,是不可见,根据日志回滚指针判断下一条记录DB_TRX_ID=0,落在灰色部分(事务Id<小于未提交事务id集合里最小的id)是可见的,所以读出结果是大大。
事务3(黑色背景)的第3次查询(第⑧步)会产生一个新的ReadView:【 [2,3],3】不变。根据版本链比对规则,该版本链的最新数据的DB_TRX_ID=1,落在灰色部分(事务Id<小于未提交事务id集合里最小的id)是可见的,所以读出结果是小1。
事务2(白色背景)的第1次查询(第⑨步)第一次查询会产生一个新的ReadView:【 [2,3],3】。根据版本链比对规则,该版本链的最新数据的DB_TRX_ID=1,落在灰色部分(事务Id<小于未提交事务id集合里最小的id)是可见的,所以读出结果是小1。
事务2(白色背景)对该记录的 name 做出了修改(第⑩步),改为小2,产生的版本链:

事务3(黑色背景)的第4次查询(第11步)会产生一个新的ReadView:【 [2,3],3】。根据版本链比对规则,该版本链的最新数据的DB_TRX_ID=1,落在灰色部分(事务Id<小于未提交事务id集合里最小的id)是可见的,所以读出结果是小1.
事务3(黑色背景)的第5次查询(第13步)使用第一次产生新的ReadView:【 [3],3】不变。根据版本链比对规则,该版本链的最新数据的DB_TRX_ID=2,落在灰色部分(事务Id<小于未提交事务id集合里最小的id)是可见的,所以读出结果是小2。