MySQL-MVCC

185 阅读16分钟

前面在事务介绍的时候有说到,MySQL的事务默认隔离级别是可重复读,其实现原理是通过MVCC实现的,例外提一句这种方式只存在InnoDB因为MyISAM不支持事务。

MVCC介绍

介绍

MVCC(Multi-Version Concurrency Control)多版本并发控制。MVCC是一种版本管理机制,保证在并发情况下保证同一事务读取到的数据都是同一个版本,实现可重复读。

MVCC只工作在MySQL的读已提交和可重复读的事务隔离级别,因为读未提交总是读取的是最新行,而串行读总是加锁读取。

实现原理关键知识

快照读和当前读

在可重复读(RR)的事务隔离级别里面,读取数据存在两种方式,分别是快照读当前读

  • 快照读:RR的默认读取方式就是快照读,它的意思是在当前事务里面读取该记录的快照版本,不是数据库中最新的版本记录,从而来保证了可重复读的特性。
  • 当前读:当前读的意思可以等同于读已提交(RC)事务隔离级别读取数据,它读取的是当前记录最新的数据,破坏了可重复读的事务隔离特性,这种方式读取通常要在select 后面加锁,如SELECT ... FOR UPDATE;SELECT ... IN SHARE MODE

隐藏字段

上面说到了在InnoDB中每条记录都会存在多个版本这个称为版本链,它维护了一条记录的变更记录。那么在MySQL的InnoDB中具体是如何实现的呢?

在InnoDB中每条记录除了自身的字段以外还存在四个字段,分别是row_id、trx_id、roll_pointer、delete_bit。

  • row_id(6字节):相当于每条记录的唯一标识,非必须,只有两种情况才会有值:1、如果有数值型主键该值为主键值;2、如果有非空型数值唯一索引则改则该值为该索引值如果有多个非空型数值唯一索引则为第一个索引值。其它情况下该字段都为空,在符合以上两种条件的情况下可以通过在select中使用_rowid来查询到该字段的具体值。
  • trx_id(6字节):最近编辑该事务的Id。
  • roll_pointer(7字节):当前事务编辑前的一个事务版本,也就是要执行回滚的版本。
  • delete_bit(1字节):标识当前记录是否被删除了。

可以看到上面我们要关心的事务主要就是在trx_id和roll_pointer这两个字段。

事务版本号

简单来说,在InnoDB中,每条数据多个事务操作的时候存在多个版本,如以下操作:

insert into ts_user values(1,'张三',16); -- V1
update ts_user age = 15 where id = 1;  -- V2 事务1提交
update ts_user age = 18 where id = 1;  -- V3 事务2提交
update ts_user age = 16 where id = 1;  -- V3 事务3提交
id姓名年龄版本号
1张三16V1
1张三15V2
1张三18V3
1张三16V4

id为1的记录,在数据库中记录只有一条,但是在多事务操作的时候时候有多条记录,记录了多次变更;

当开启一个事务读取数据的时候会递增一个新的版本号,比如此时要在一个事务中获取id=1的记录,则版本号为V5,读取数据的时候,获取小于等于当前版本号的第一条记录即V6版本的记录(1,"张三",16),这个V5的整个版本里面进行读取id=1这条记录的使用永远是读取的<=V5的这条记录,如果当前事务对id=1的记录修改了,则版本变为V5。这就是MySQL实现可重复读的原理,也就是多版本并发控制的实现方式。

快照读规则 Read View

在版本链中我们发现一个事务要操作一条记录的时候需要获得两个版本号,一个是当前事务的版本号trx_id和需要回滚的版本号roll_pointer。事务是如何获取当前事务对记录的版本号呢?

ps:当多个事务操作同一条记录,对其编辑的时候会加悲观锁,因此不必考虑多事务对同一条记录编辑的问题,它们一定是交替执行的.

获取版本号的方式主要是采用一种Read View机制。

什么是Read View?

Read View是事务在执行SQL前产生的读视图,在这个视图中存在了SQL执行前记录的事务操作版本信息,可以在当前事务操作的时候对其做可见性判断。

Read View的重要属性:

  • m_ids:当前系统中那些活跃(未提交)的读写事务ID, 它数据结构为一个List。
  • min_limit_id:表示在生成Read View时,当前系统中活跃的读写事务中最小的事务id,即m_ids中的最小值。
  • max_limit_id:表示生成Read View时,系统中应该分配给下一个事务的id值。
  • creator_trx_id: 创建当前Read View的事务ID。

Read View对记录版本判断条件:

  • 如果被访问版本的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时生成该版本的事务已经被提交,该版本可以被访问

对不满条件不可见的版本会顺着版本链继续找寻下一个版本数据。

MySQL中,READ COMMITTEDREPEATABLE READ隔离级别的的一个非常大的区别就是它们生成ReadView的时机不同。RC是每次读取都会生成一个Read View,而RR使用的是记录同一个Read View这个Read View是在第一次读取的时候生成的。

ps(重要):读取一条记录的快照值取决于一个事务里面第一次读取该记录的时候,而不是begin开始事务的时候,例如事务1,2同时开启了事务事务2对记录A提交后,事务1再对A读取,此时就能够读取到事务2提交的A记录值。

具体验证如下:

设记录A的值为X

提前生成快照:

事务1事务2
begin;begin;
update A为Y;select A; 生成A的Read View,A=X
commit;
select A;读取的还是第二步Read Vew快照的值,A=X
commit;

其它事务提交后生成快照:

事务1事务2
begin;begin;
update A为Y;
commit;
select A; 生成A的Read View,A=Y。满足Read View对记录版本判断条件的第4条。

事务2对A记录读取的时候,事务1已经提交,因此读取到记录的版本号就是事务1已经提交的最新版本号,这个版本号不小于min_limit_id,不大于max_limit_id说明在最小和最大版本号之间,不等于creator_trx_id说明不是当前事务操作过的,不再m_ids中说明该事务不是活跃事务已经提交因此是被当前事务可见的。

undo log 回滚日志

undo log是存在于MySQL内存缓冲区的一段空间,也就是buffer pool中的一段空间,用来保存操作日志数据,最后刷新到磁盘中。

undo log的主要作用是用来做日志回滚的,每次操作都是在undo log这一块内存区域,如果发生了回滚则可以通滚undo log来还原数据。

当delete一条记录时,undo log 中会记录一条对应的insert记录,当update一条记录时,它记录一条对应相反的update记录。

如下图所示:

2022-09-30-21-58-00-image.png

当操作执行的时候,执行一次delete,会在undo log将delete的记录生成insert语句;执行一次update会生成要一条update之前值的语句;执行一次insert会生成一次delete语句;这样在执行回滚的时候,只需要依次执行一遍undo log从而完成了回滚操作。

undo log的主要用途就是做事务回滚,当然也会用于MVCC快照读。

版本链

什么是版本链:

在前面事务版本章节有介绍到了一条记录被多个事务进行操作的时候会形成多个版本号,新事务要操作这条记录则会在获取到这条记录最新的事务ID基础上进行加1,形成当前事务的ID。按照这个流程造成的结果是同一条记录会在内存undo log中存在多个版本,而这些版本之间都是通过了trx_id和roll_pointer这两个隐藏字段完成关联。

2022-10-05-20-48-30-image.png

处理过时版本:

看了版本链形成过程,试想如果N个事务交替操作是否会让同一个记录形成很长的版本链,而且在这个版本链中刚开始的很多记录版本都已经没有了存在的意义,比如上图中,在事务3开启的时候,事务2已经提交了,这时候事务1的版本也就是V1记录没有了意义,那么在MySQL中是怎么处理的?

在undo log中实际分为了两种,分别为:

  • insert undo log:这种类型的undo log只有在记录新增的时候才会产生,并且只有在回滚的时候需要,在事务提交后这条log将会被删除。上面例子中,事务1 commit后,在内存里面insert undo log就会被立马清理掉。
  • update undo log:事务运行中的update和delete产生的undo log,实际更新或删除记录的时候是将无效记录标记为delete_bit,并不是真删除。因此也可以看做是update操作,这种类型的undo log会由一个线程purge统一删除处理。

purge清除update undo log过时记录:

根据上面的undo log日志insert 和 update的分析,可以得出在对记录执行删除或者更新的时候会同时更新其过时记录的undo log,因此为了保证undo log不至于无限增长有一个purge线程专门来清理这些undo log。

为了节省磁盘空间,InnoDB 有专门的 purge 线程来清理 deleted_bit 为 true 的记录。为了不影响 MVCC 的正常工作,purge 线程自己也维护了一个read view(这个 read view 相当于系统中最老活跃事务的 read view );如果某个记录的 deleted_bit 为 true ,并且 DB_TRX_ID 相对于 purge 线程的 read view 可见,那么这条记录一定是可以被安全清除的。(来自:【MySQL笔记】正确的理解MySQL的MVCC及实现原理_SnailMann的博客-CSDN博客_mvcc

undo log数据量

每次执行SQL前都会产生读视图在undo log中,因此如果数据量会产生大量的undo log。

如:

mysql> CREATE TABLE innodb_table LIKE myisam_table; mysql> ALTER TABLE innodb_table ENGINE=InnoDB; mysql> INSERT INTO innodb_table SELECT * FROM myisam_table; 数据量不大的话,这样做工作得很好。如果数据量很大,则可以考虑做分批处理,针对每一段数据执行事务提交操作,以避免大事务产生过多的undo。假设有主键字段id,重复运行以下语句(最小值x 和最大值y 进行相应的替换)将数据导入到新表: mysql> START TRANSACTION; mysql> INSERT INTO innodb_table SELECT* FROM myisam_table -> WHERE id BETWEEN x AND y; mysql> COMMIT;

在这里因为 myisam_table表存在大量记录,针对每条记录产生undo log一次就会产生非常多,极大占用内存和增加检索负担,因此使用索引+分批插入能够提高性能。

MVCC

MVCC原理分析

在前面了解了关键知识后,将它串起来就是MVCC的实现原理,本质来说就是版本号+undo日志+Read View实现了一个多版本并发控制机制。

执行select操作的时候会生成Read View该View存放在undo log中,并生成当前事务的版本号,后续对该记录的读取都是读取Read View中的记录,小于等于当前事务版本号已提交的记录,从而达到了可重复读。具体读取规则参加上面Read View中trx_id读取规则。

MVCC为什么这么快

MVCC之所以快的原因就是使用乐观锁的思想来读取数据,版本号就是锁条件,本质上也是一个空间换时间的做法。MVCC牛逼的地方就是它读取记录的的时候永远不用加锁,但是丢失了强一致性(这里的一致性指的是和数据库最新记录保持一致,而不是同一个事务保持 记录保持一致)。

MVCC是否能够解决幻读

通过前面分析可以确定MVCC解决了可重复读的问题,但是对幻读是否能够解决。

首先网上受争议的幻读有两种定义:

  • 幻读只强调存在于insert操作中,分为两个步骤,1、读取到记录不存在;2、执行记录插入。在这中间有其它事务插入了记录,导致步骤2报错,这是幻读引起的问题。事务A执行插入ID=4操作未提交,事务B读取到事务A开启事务前的数据,事务B检测到未插入数据,则准备插入ID=4,此时A已经提交,B再插入导致duplicate key。

  • 幻读强调的是多次读取操作,只要在一个事务中,第二次select多出了row就算幻读。但是这种定义又和不可重复读的定义重复了。MySQL官方对幻读的解释就是这种:

    15.7.4 Phantom Rows

    The so-called phantom problem occurs within a transaction when the same query produces different sets of rows at different times. For example, if a SELECT is executed twice, but returns a row the second time that was not returned the first time, the row is a “phantom” row.

    原文:MySQL :: MySQL 8.0 Reference Manual :: 15.7.4 Phantom Rows

    译文:当同一个查询在不同的时间产生不同的行集时,就会在事务中出现所谓的幻象问题。例如,如果select执行了两次,但是第二次返回第一次没有返回的行,那么该行就是“幻影”行。

接下来我们对上面的问题分别分析:


RR在快照读情况下insert操作幻读:

事务A执行插入ID=4操作未提交,事务B读取到事务A开启事务前的数据,事务B检测到未插入数据,则准备插入ID=4,此时A已经提交,B再插入导致duplicate key。

2022-10-07-11-01-07-image.png

会话A使用默认事务隔离级别,执行不提交事务的语句

-- 事务A
mysql> select @@tx_isolation; -- 查看当前事务隔离级别
+-----------------+
| @@tx_isolation  |
+-----------------+
| REPEATABLE-READ |
+-----------------+
1 row in set, 1 warning (0.00 sec)

mysql>  begin;insert into ts_user values(4,'王5',11); -- 不提交

事务B读取ts_user表的id=4的数据,结果为Empty set,空集合。

-- 事务B
mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from ts_user where id = 4;
Empty set (0.03 sec)

此时A事务提交insert

-- 事务A
mysql> commit;
Query OK, 0 rows affected (0.00 sec)

事务B执行插入,出现了重复Key的错误

-- 事务B
mysql> insert into ts_user values(4,'王5',11);
ERROR 1062 (23000): Duplicate entry '4' for key 'PRIMARY'

以上测试样例的现象和不可重复读的现象一样,都是无法知道其它事务已经插入了这条id=4的数据,从而造成的异常,可重复读隔离级别也只能保证一个事务中多次读取的记录一致,但是并不能保证发现其它事务已经操作过的数据,因此无法解决insert定义下的幻读问题


RR在快照读情况下幻读:

事务A执行插入ID=4操作未提交,事务B读取到事务A开启事务前的数据,事务查询ID=4的记录为空,等待事务A提交后,B再查询ID=4的记录依旧为空。

2022-10-07-11-09-43-image.png

会话A使用默认事务隔离级别,执行不提交事务的语句

-- 事务A
mysql> select @@tx_isolation; -- 查看当前事务隔离级别
+-----------------+
| @@tx_isolation  |
+-----------------+
| REPEATABLE-READ |
+-----------------+
1 row in set, 1 warning (0.00 sec)

mysql>  begin;insert into ts_user values(4,'王5',11); -- 不提交

事务B读取ts_user表的id=4的数据,结果为Empty set,空集合。

-- 事务B
mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from ts_user where id = 4;
Empty set (0.03 sec)

此时A事务提交insert

-- 事务A
mysql> commit;
Query OK, 0 rows affected (0.00 sec)

事务B再次查询id=4的记录依旧为空。

-- 事务B
mysql> select * from ts_user where id = 4;
Empty set (0.03 sec)

以上测试用例,很好的证明了可重复读,也能够符合MySQL对幻读的基本定义,它就解决了幻读嘛?可以这样说,这种是在可重复读的基础上满足了最基本的幻读条件。为什么说最基本的满足,而不是充分满足,因为我们在这里只是很简单的进行了读取操作,中间没有涵盖其它更新操作。接下来看带更新的幻读问题。


RR在多次读之间执行更新下幻读:

RR在多事务两次快照读操作中间执行更新是否解决幻读:

事务A执行插入ID=4操作未提交

事务B读取到事务A开启事务前的数据,事务查询ID=4的记录为空

等待事务A提交后,B执行更新ID>=4的记录值

造成ID=4的记录被更新,同时B能够在事务结束前查询出来ID=4的记录

2022-10-07-11-31-42-image.png

会话A使用默认事务隔离级别,执行不提交事务的语句

-- 事务A
mysql> select @@tx_isolation; -- 查看当前事务隔离级别
+-----------------+
| @@tx_isolation  |
+-----------------+
| REPEATABLE-READ |
+-----------------+
1 row in set, 1 warning (0.00 sec)

mysql>  begin;insert into ts_user values(4,'王5',11); -- 不提交

事务B读取ts_user表的id=4的数据,结果为Empty set,空集合。

-- 事务B
mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from ts_user where id = 4;
Empty set (0.03 sec)

此时A事务提交insert

-- 事务A
mysql> commit;
Query OK, 0 rows affected (0.00 sec)

事务B再次查询id=4的记录依旧为空。

-- 事务B
mysql> updte ts_user set age = 100 where id>=4;
Query OK, 1 row affected (0.04 sec)  -- 事务B第一次查询结果为空,但是还是被更新到了
Rows matched: 1  Changed: 1  Warnings: 0
mysql> select * from ts_user where id = 4;-- 再次查询就有了id=4的记录,这时候事务B还没有commit或者rollback
+----+------+------+
| id | name | age  |
+----+------+------+
|  4 |5  |  100 |
+----+------+------+
1 row in set (0.01 sec)

以上测试用例,在事务B第一次和第二次读取的结果不一致,造成了幻读,这是因为更新操作并不存在快照的概念,更新就是针对条件更新数据库已提交的记录,更新的时候会将记录的Read View重新加载为当前事务更新结果,因此无法解决幻读问题

小总结:

从上面的三种情况分析,只有第二种,在快照读的情况下并且不对记录执行DML操作的情况下不会造成幻读问题,但是这种情况更加符合可重复读的定义。其它情况都有幻读问题,因此在我看来MVCC并不能解决幻读问题,要彻底解决幻读问题,还是得加锁,使用串行事务。