• MySQL可重复读隔离级别为何没有解决幻读(MVCC原理简介)
  • 一.MVCC简介
  • 二.可重复读隔离级别能解决幻读?
  • 三.什么是当前读和快照读?
  • 四.MVCC的实现原理
  • 五.RC,RR级别下的InnoDB快照读有什么不同?
  • 六.如何解决幻读
  • 七.事务是么时候开始

MySQL可重复读隔离级别为何没有解决幻读(MVCC原理简介)

一.MVCC简介

多版本并发控制(Multi-Version Concurrency Control, MVCC)是MySQL中基于乐观锁理论实现隔离级别的方式,在mysql的innodb中,在读已提交和可重复读取隔离级别会使用mvcc来提升并发。

二.可重复读隔离级别能解决幻读?

先说答案,可重复读没有完美的解决幻读,对于select(快照读)不会产生幻读,但对于update(当前读)会产生幻读,下面通过案例进行分析。

测试:

准备工作:

  • 1.创建一张测试用的book表:
DROP TABLE IF EXISTS `book`;

CREATE TABLE `book` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `book_name` varchar(256) COLLATE utf8_bin NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin;

insert into book values (1, 'java');
  • 2.设置事务隔离级别为可重复读:
    set session transaction isolation level 事务隔离级别
//设置read uncommitted级别:
set session transaction isolation level read uncommitted;

//设置read committed级别:
set session transaction isolation level read committed;

//设置repeatable read级别:
set session transaction isolation level repeatable read;

//设置serializable级别:
set session transaction isolation level serializable;
  • 3.查看当前事务隔离级别:
    SELECT @@tx_isolation;
mysql> select @@session.tx_isolation;
+------------------------+
| @@session.tx_isolation |
+------------------------+
| REPEATABLE-READ        |
+------------------------+
1 row in set, 1 warning (0.00 sec)

测试1

事务1

事务2

begin

begin

select * from book;

\

\

insert into book values (2, ‘python’);

\

commit

select * from book;

\

commit

\

事务1

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from book;
+----+-----------+
| id | book_name |
+----+-----------+
|  1 | java      |
+----+-----------+
1 row in set (0.00 sec)

mysql> select * from book;
+----+-----------+
| id | book_name |
+----+-----------+
|  1 | java      |
+----+-----------+
1 row in set (0.00 sec)

mysql> commit;
Query OK, 0 rows affected (0.00 sec)

事务2

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> insert into book values (2, 'python');
Query OK, 1 row affected (0.01 sec)

mysql> commit;
Query OK, 0 rows affected (0.00 sec)

根据上面的流程执行,预期来说应该是事物1的第一条select查询出一条数据,第二个select查询出两条数据(包含事物2提交的数据)。

但是在实际测试中发现第二条select实际上也只查询处理到一条数据。

从上面的测试结果来看,貌似在MySQL中通过MVCC就解决了幻读的问题,那既然这样串行化读貌似就没啥意义了,带着疑问继续测试。

测试2

测试前数据:

mysql> select * from book;
+----+-----------+
| id | book_name |
+----+-----------+
|  1 | java      |
+----+-----------+

事务1

事务2

begin

begin

select * from book;

\

\

insert into book values (2, “go”);

\

commit

update book set book_name = “python”(工作中如果不想被辞退一定要写where条件)

\

commit

\

事务1

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from book;
+----+-----------+
| id | book_name |
+----+-----------+
|  1 | java      |
+----+-----------+
1 row in set (0.00 sec)

mysql> update book set book_name = "python";
Query OK, 2 rows affected (0.00 sec)
Rows matched: 2  Changed: 2  Warnings: 0

mysql> commit;
Query OK, 0 rows affected (0.01 sec)

事务2

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> insert into book values (2, "go");
Query OK, 1 row affected (0.00 sec)

mysql> commit;
Query OK, 0 rows affected (0.00 sec)

根据上面的结果我们期望的结果是这样的:

+----+-----------+
| id | book_name |
+----+-----------+
|  1 | python    |
+----+-----------+
|  2 | go        |
+----+-----------+

但是实际上我们的经过是:

+----+-----------+
| id | book_name |
+----+-----------+
|  1 | python    |
|  2 | python    |
+----+-----------+

本来我们希望得到的结果只是第一条数据的book_name改为python,但是结果却是两条数据都被修改了。这种结果告诉我们其实在MySQL可重复读的隔离级别中并不是完全解决了幻读的问题,而是解决了读数据情况下的幻读问题。而对于修改的操作依旧存在幻读问题,就是说MVCC对于幻读的解决时不彻底的。

备注

可以通过开启两个mysql client 来模拟以上测试

三.什么是当前读和快照读?

在学习MVCC多版本并发控制之前,我们必须先了解一下,什么是MySQL InnoDB下的当前读和快照读?

  • 当前读

像select lock in share mode(共享锁), select for update(排他锁); update, insert ,delete这些操作都是一种当前读,为什么叫当前读?就是它读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁

  • 快照读

像不加锁的select操作就是快照读,即不加锁的非阻塞读;快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读;之所以出现快照读的情况,是基于提高并发性能的考虑,快照读的实现是基于多版本并发控制,即MVCC,可以认为MVCC是行锁的一个变种,但它在很多情况下,避免了加锁操作,降低了开销;既然是基于多版本,即快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本

四.MVCC的实现原理

MVCC的目的就是多版本并发控制,在数据库中的实现,就是为了解决读写冲突,它的实现原理主要是依赖记录中的 3个隐式字段,undo日志 ,Read View 来实现的。所以我们先来看看这个三个point的概念

隐式字段

每行记录除了我们自定义的字段外,还有数据库隐式定义的DB_TRX_ID,DB_ROLL_PTR,DB_ROW_ID等字段

  • DB_TRX_ID
    6byte,最近修改(修改/插入)事务ID:记录创建这条记录/最后一次修改该记录的事务ID
  • DB_ROLL_PTR
    7byte,回滚指针,指向这条记录的上一个版本(存储于rollback segment里)
  • DB_ROW_ID
    6byte,隐含的自增ID(隐藏主键),如果数据表没有主键,InnoDB会自动以DB_ROW_ID产生一个聚簇索引
  • 实际还有一个删除flag隐藏字段, 既记录被更新或删除并不代表真的删除,而是删除flag变了

undo日志

undo log主要分为两种:

  • insert undo log
    代表事务在insert新记录时产生的undo log, 只在事务回滚时需要,并且在事务提交后可以被立即丢弃
  • update undo log
    事务在进行update或delete时产生的undo log; 不仅在事务回滚时需要,在快照读时也需要;所以不能随便删除,只有在快照读或事务回滚不涉及该日志时,对应的日志才会被purge线程统一清除

对MVCC有帮助的实质是update undo log

Read View(读视图)

什么是Read View,说白了Read View就是事务进行快照读操作的时候生产的读视图(Read View),在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的ID(当每个事务开启时,都会被分配一个ID, 这个ID是递增的,所以最新的事务,ID值越大)

所以我们知道 Read View主要是用来做可见性判断的, 即当我们某个事务执行快照读的时候,对该记录创建一个Read View读视图,把它比作条件用来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的undo log里面的某个版本的数据。

Read View遵循一个可见性算法,主要是将要被修改的数据的最新记录中的DB_TRX_ID(即当前事务ID)取出来,与系统当前其他活跃事务的ID去对比(由Read View维护),如果DB_TRX_ID跟Read View的属性做了某些比较,不符合可见性,那就通过DB_ROLL_PTR回滚指针去取出Undo Log中的DB_TRX_ID再比较,即遍历链表的DB_TRX_ID(从链首到链尾,即从最近的一次修改查起),直到找到满足特定条件的DB_TRX_ID, 那么这个DB_TRX_ID所在的旧记录就是当前事务能看见的最新老版本

五.RC,RR级别下的InnoDB快照读有什么不同?

正是Read View生成时机的不同,从而造成RC,RR级别下快照读的结果的不同

  • 在RR级别下的某个事务的对某条记录的第一次快照读会创建一个快照及Read View, 将当前系统活跃的其他事务记录起来,此后在调用快照读的时候,还是使用的是同一个Read View,所以只要当前事务在其他事务提交更新之前使用过快照读,那么之后的快照读使用的都是同一个Read View,所以对之后的修改不可见;
  • 即RR级别下,快照读生成Read View时,Read View会记录此时所有其他活动事务的快照,这些事务的修改对于当前事务都是不可见的。而早于Read View创建的事务所做的修改均是可见
  • 而在RC级别下的,事务中,每次快照读都会新生成一个快照和Read View, 这就是我们在RC级别下的事务中可以看到别的事务提交的更新的原因

总之在RC隔离级别下,是每个快照读都会生成并获取最新的Read View;而在RR隔离级别下,则是同一个事务中的第一个快照读才会创建Read View, 之后的快照读获取的都是同一个Read View。

六.如何解决幻读

很明显可重复读的隔离级别没有办法彻底的解决幻读的问题,如果我们的项目中需要解决幻读的话也有两个办法:

1.使用串行化读的隔离级别
2.MVCC + 间隙锁

mysql的间隙所是基于索引的,对于唯一索引innode会把间隙所降级为行锁,非唯一索引的话就需要用到间隙锁(也叫范围锁)

id

number

1

1

10

2

13

3

23

3

31

11

40

40

事务一:select * from test where number = 3 for update
对于number索引可以分为多个范围
(无穷小,1)(1,2)(2,3)(3,3)(3,11)(11,40)(40,无穷大)
这时候锁住的是(3,3)区间,对应的临界记录是(id=10, number=2)、(id=31, number=11),对于这范围内的数据都是被锁住的。

事务二:insert into test(id, number) value(9, 2) //插入成功(number值一样,但是id = 9 < 10所以在锁区间外)
事务三:insert into test(id, number) value(11, 2) //阻塞
事务四:insert into test(id, number) value(5, 3) //阻塞
事务五:insert into test(id, number) value(25, 4) //阻塞
事务六:insert into test(id, number) value(35, 4) //阻塞
事务七:insert into test(id, number) value(30, 11) //阻塞
事务八:insert into test(id, number) value(35, 11) //插入成功 (number值一样,但是id 35>31所以在锁区间外)
事务九:insert into test(id, number) value(22, 12) //插入成功 (因为12>11所以在锁区间外)

七.事务是么时候开始

BEGIN 语句并没有开启一个事务,实际上是对数据进行了增删改查等操作后才开启了一个事务。