MVCC取自Multiversion Concurrency Control,意为多版本并发控制
MVCC是通过数据行的多个版本管理来实现数据库的并发控制,主要解决的是读 —- 写冲突,保证InnoDB事务隔离级别下的一致性操作,就是为了查询一些正在被另一个事务更新的行,并且可以看到它们被更新之前的值,这样在做查询的时候不用等待另一个事务释放锁
MVCC依赖于:隐藏字段,undo日志,ReadView,这是MVCC中最重要的三个概念

1. 快照读与当前读

1.1 快照读

快照读指的是不加锁的简单的select操作

  1. select * from student where id = 1; # 这就是一个简单的快照读

1.2 当前读

当前读读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁

  1. select * from student where id = 1 lock in share mode; #加上共享锁
  2. select * from student where id = 1 for update; #加上排他锁

2. 隐藏字段与UNDO日志

2.1 隐藏字段

对于InnoDB存储引擎来说,它的聚簇索引记录中都包含了两个必要的隐藏列:

  • trx_id:记录是哪一个事务对这个记录进行了修改操作,每次一个事务对记录进行修改时,都会把事务的id赋给trx_id隐藏列
  • roll_pointer:每次对某条记录进行修改时,都会把旧的版本(即修改记录之前该记录的值)写道undo日志中,这个隐藏列就相当于指针,可以通过它来找到记录修改前的信息

比如student表中有一条数据[1, ‘张三’, ‘一班’]
之前某个事务对数据表执行了insert into student (id, name, class) values(1, ‘张三’, ‘一班’)操作,并且假设事务id为8,那么张三这条记录的结构就是这样的
MVCC - 图1
insert undo就是数据被修改之前的状态

2.2 undo日志

UNDO LOG:回滚日志,回滚记录到某一个特定的版本,用来保证事务的原子性和一致性
undo日志记录的是逻辑操作:

  • 如果插入一条数据,记录插入数据的主键值,生成一条delete语句,在回滚时删除插入的数据
  • 如果删除了一条记录,生成一条insert语句,回滚时将删除的记录重新插入
  • 如果修改了一条记录,就生成一条与修改记录相反操作的update语句,回滚的时候再改回去
  • undo日志回滚时是逻辑层面的,是表面上看起来事务没有执行过,其实底层的一些存储结构已经改变了

现在假设另有一个事务对student表进行了修改操作,假设事务id为10:

  1. begin;
  2. update student set name = '李四' where id = 1;
  3. update student set name = '王五' where id = 1;

那么日志中就应该是:总之,修改一条记录之后,它记录中的隐藏字段roll_pointer需要指向修改之前记录的值
MVCC - 图2

3. ReadView

3.0 ReadView基本介绍

ReadView就是事务在使用MVCC机制进行快照读操作时产生的读视图,它帮我们解决了数据行的可见性问题
当事务启动时,会生成数据库系统当前的一个快照,InnoDB为每个事务构造了一个数组,用来记录并维护系统当前活跃事务的ID

  • 活跃事务指的是已经启动但还未提交的事务

在MySQL数据库的Read Committed和Repeatable Read的隔离级别下,都必须保证读到已经提交了的事务修改过的记录,假如另一个事务已经修改了记录但是尚未提交,是不能直接读取最新版本的记录的,核心问题就是需要判断一下版本链中哪个版本是当前事务可见的,这就是ReadView要解决的主要问题

3.1 ReadView的结构

  • creator_trx_id:创建这个ReadView事务的ID,一个事务对应一个ReadView

需要注意的是,只有对表中数据进行修改时(update,insert,delete操作)才会为事务分配事务id,否则在一个只读事务中(只事务中只有select操作)的事务id值都默认为0

  • trx_ids:在生成ReadView时,当前系统中活跃事务的id列表
  • up_limit_id:活跃事务中最小的事务id,比如活跃事务的id为[8,10,20],那么up_limit_id = 8
  • low_limit_id:是系统中最大的事务id值,要注意的是,是系统中的所有事务,而不仅仅是活跃事务,比如系统中有三个事务8,10,20,但现在20号事务已经提交了,此时这个low_limit_id的值可以理解为20

    3.2 ReadView的规则

  • 如果被访问数据的trx_id属性值与ReadView中的creator_trx_id值相同,意味着当前事务在访问自己修改过的记录,所以该版本可以被当前事务访问

  • 如果被访问数据的trx_id属性值小于ReadView中的up_limit_id值,意味着生成数据的事务在当前事务生成ReadView之前已经提交了,所以当前事务可以访问数据
  • 如果被访问数据的trx_id属性值大于或等于ReadView中的low_limit_id值,表明生成该数据的事务在当前事务创建ReadView之后才开启,所以当前事务不能访问数据
  • 如果被访问数据的trx_id属性值在ReadView的up_limit_id和low_limit_id之间,那么就需要判断一下trx_id属性值是不是在trx_ids列表中

(1)如果在,则说明在ReadView创建时,生成数据的事务还是活跃的,当前事务不能访问数据
(2)如果不在,说明在ReadView创建之前,生成数据的事务已经提交,当前事务就可以访问数据

接下来就通过示例来走一遍流程:现在有一张学生表的数据如下
屏幕截图 2022-03-29 153245.jpg
有两个事务:在Read Committed的隔离级别下

  1. #事务10
  2. BEGIN;
  3. #在当前事务中更新id = 1的学生姓名
  4. UPDATE student SET `name` = '张二' WHERE id = 1;
  5. #自己查询的时候已经是修改之后的信息
  6. SELECT * FROM student WHERE id = 1;
  7. #事务20
  8. BEGIN;
  9. #修改其他表
  10. SELECT * FROM student WHERE id = 1;

MVCC - 图4
查询操作是这样的:
当事务20进行select操作时,会生成一个ReadView,它的结构如下:

  • creator_trx_id:20
  • trx_ids:10,20
  • up_limit_id:10
  • low_limit_id:21(20 + 1)可以理解为20

当查询id = 1的学生记录时,先比较第一条undo日志中记录的第一条日志,它的trx_id是10,与ReadView中的up_limit_id和low_limit_id进行比较,发现10号事务在这两个数之间,并且是活跃事务,所以20号事务不能访问当前这条记录,那么获取下一条日志进行比较
下一条日志的trx_id = 8比ReadView中up_limit_id值小,说明这个事务在生成这个ReadView之前已经提交了,所以可以访问这条数据,那么20号事务执行select操作后,返回的数据就是[1 张三 一班],这样就解决了脏写的问题
需要注意的是:

  • 在Read Committed的隔离级别下,当前事务每次进行select操作都会生成一个ReadView,记录当前读操作时,系统中活跃的事务,这样,如果后续有事务往表中插入数据并提交,当前事务察觉不到其他事务的操作,就会出现幻读的问题
  • 在Repeatable Read的隔离级别下,当前事务只有在第一次select操作时,会生成一个ReadView,那么这样后续事务向表中的插入操作,当前事务就不会进行读取了,这也就是为什么MySQL在Repeatable Read的隔离级别下能够防止幻读发生的原因,可以参考如下示例:

依然是那张学生表,在Repeatable Read的隔离级别下,现在事务10向表中插入一条数据并提交

  1. #事务10
  2. BEGIN;
  3. ... #之前的更新操作
  4. #在当前事务中向表里插入一条数据
  5. INSERT INTO student(id, `name`, class) VALUES(7, 'Jery', '三班');
  6. #自己查询时,可以查出数据的信息
  7. SELECT * FROM student WHERE id = 7;
  8. #将事务提交
  9. COMMIT;
  10. #事务20
  11. BEGIN;
  12. #修改其他表
  13. SELECT * FROM student WHERE id = 1;
  14. #事务20在事务10提交之后进行查询操作
  15. SELECT * FROM student WHERE id = 7;

MVCC - 图5
由于在Repeatable Read的隔离级别下,只有在第一次的查询操作才会生成ReadView,所以在进行select * from student where id = 7时,使用的是上一次的ReadView,其结构依然是

  • creator_trx_id:20
  • trx_ids:10,20
  • up_limit_id:10
  • low_limit_id:21(20 + 1)可以理解为20

所以,在查询的时候,比较id = 7这条记录在undo日志中trx_id = 10,发现包含在trx_ids中,说明这个事务认为10号事务是活跃事务,注意,这时使用的是第一次查询时创建的ReadView,在当时创建时,事务10还没有提交,所以就不会查询出id = 7这条记录,这就解决了幻读问题