- 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 语句并没有开启一个事务,实际上是对数据进行了增删改查等操作后才开启了一个事务。