网站首页 文章专栏 到底什么是幻读?Mysql到底能不能解决?怎么解决的
众所周知,mysql有四个隔离级别,分别可以解决不同场景的问题,mysql默认隔离界别为RR(Repeatable Read)可重复读。
事务隔离级别 | 脏读 | 不可重复读 | 幻读 |
读未提交(read-uncommitted) | 是 | 是 | 是 |
不可重复读(read-committed) | 否 | 是 | 是 |
可重复读(repeatable-read) | 否 | 否 | 是 |
串行化(serializable) | 否 | 否 | 否 |
上图应该是我们见过最多的图了,四种隔离级别可解决脏读,不可重复读等问题,但是对于幻读,就有争议了,有的文章说RR能解决,有的文章说不能,到底行不行呢,看下文!
不可重复读:
不可重复读:就是一个事务读到另一个事务修改后并提交的数据(update)。在同一个事务中,对于同一组数据读取到的结果不一致。比如,事务B 在 事务A 提交前读到的结果,和在 事务A 提交后读到的结果可能不同 |
幻读:
幻读:事务A 按照一定条件进行数据读取, 期间事务B 插入了相同搜索条件的新数据,事务A再次按照原先条件进行读取时,发现了事务B 新插入的数据 称为幻读 |
InnoDB中通过MVCC机制来解决不可重复读,每一行数据都会有隐藏的事务id等属性,记录数据的版本,在开始事务时,会创建一个事务id,且是按照申请时间严格自增的,每行数据也是有多个版本的,
每次事务更新数据的时候,都会生成一个新的数据版本,并且把 transaction id 赋值给这个数据版本的事务 ID,记为 row trx_id。同时,旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它。
也就是说,数据表中的一行记录,其实可能有多个版本 (row),每个版本有自己的 row trx_id。
如上图:每行数据可能有很多个版本,V1-V4,各个事务启动的时候创建一个自己的版本,语句的update操作会在undo log中记录下来,实际上V1,V2,V3数据实际并不存在,要想获得V1数据,可以通过V4版本,结合undo log向前计算得出V1。
按照可重复读的语义,每次在创建一个事务的时候,第一次查询会查询一个read view,该read view中只会看到比自己事务id小版本的数据,然后后续的查询就会从这个read view中获取数据,这么做的话,后续事务的操作也就不会对本次的查询造成影响,也就是多次查询的结构是一致的,也就是可重复读。
准备数据库
CREATE TABLE `test` ( `id` int(12) unsigned NOT NULL AUTO_INCREMENT, `name` varchar(16) DEFAULT NULL, `age` int(12) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
插入两条原始数据:
INSERT INTO test VALUES(1,'星尘',18),(2,'星尘',19);
RR隔离界别下,有如下3个事务分别操作数据:
时间顺序 | 事务A | 事务B | 事务C |
1 | start transaction with consistent snapshot; | ||
2 | start transaction with consistent snapshot; | ||
3 | UPDATE `test` SET age = age + 1 WHERE id = 1; | ||
4 | SELECT * FROM `test` WHERE id = 1; UPDATE `test` SET age = age + 1 WHERE id = 1; SELECT * FROM `test` WHERE id = 1; | ||
5 | SELECT * FROM `test` WHERE id = 1; commit; | ||
6 | commit; |
注意:begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个操作InnoDB表语句的时候,事务才真正启动。
如果想马上启动一个事务,可以使用start transaction with consistent snapshot 命令。
在上面的例子里面,事务C没有显示地使用begin/commit,表示这个update语句本身就是一个事务,事务完成的时候会自动提交。事务B在更新了之后查询;事务A在一个只读事务中查询,查询时间是在事务B的查询之后。
我们先看写结果是什么:
事务A:第5步,读取结果为(1,"星尘",18)
事务B:第4步,第一次查询结果:(1,"星尘",18),第二次查询结果为(1,"星尘",20)
想一想符合我们预期吗?
RR的隔离级别下,应该是快照读,每次查询读取事务开始创建的快照,那么事务A的结果是符合预期的,因为他的事务id最小,后续的事务B,事务C的操作怎么样与他无关,所以读取的是最早的初始化数据(1,"星尘",18)。
事务B的第一次查询结果也是符合预期的,因为事务C的操作也与他无关,但是执行了一次update后为什么就变为20了呢,如果不受事务C影响的话,自增一次应该是19才对,但是实际是20,说明受到了事务C的影响。
实际上update语句是当前读的操作,他在更新数据必须拿到最新的数据再去更新,不然就出现事务C的更新丢失了,所以事务B的update操作是在事务C的age为19的基础上,再update的,这里用到一条规则:更新数据都是先读后写的,而这个读,只能读当前的值,称为当前读(current read)。
总结:普通查询语句是一致性读,一致性读会根据 row trx_id和一致性视图确定数据版本的可见性。
- 对于可重复读,查询只承认在事务启动前就已经提交完成的数据
- 对于读提交,查询只承认在查询语句启动前就已经提交完成的数据
- 而当前读,总是读取已经提交完成的最新版本
通过上面的测试,可以看出,MVCC的机制保证了RR级别下的可重复读。那么为什么还会存在幻读呢?
先明确说一下,RR是能解决幻读的,我们先调整到RC的级别下,看下什么是幻读。
时间顺序 | 事务A | 事务B |
1 | set session transaction isolation level read committed; | set session transaction isolation level read committed; |
2 | start transaction with consistent snapshot; | |
3 | start transaction with consistent snapshot; | |
4 | select * from test where id > 0; 结果为3条:(1,"星尘",20)(2,"星尘",19)(3,"星尘",21) | |
5 | INSERT INTO test VALUES(NULL,'星尘',22); commit; | |
6 | select * from test where id > 0; 结果为4条:(1,"星尘",20)(2,"星尘",19)(3,"星尘",21)(4,"星尘",22) commit; |
第一步,先开启两个窗口,并且都设置会话隔离级别为RC
如上表可见,事务A的两次相同查询条件,却出现了不同数量的结果,这就是幻读。
我们切回RR,再用同样的流程再试一次;
时间顺序 | 事务A | 事务B |
1 | set session transaction isolation level repeatable read; | set session transaction isolation level repeatable read; |
2 | start transaction with consistent snapshot; | |
3 | start transaction with consistent snapshot; | |
4 | select * from test where id > 0; 结果为4条:(1,"星尘",20)(2,"星尘",19)(3,"星尘",21)(4,"星尘",22) | |
5 | INSERT INTO test VALUES(NULL,'星尘',23); commit; | |
6 | select * from test where id > 0; 结果为4条:(1,"星尘",20)(2,"星尘",19)(3,"星尘",21)(4,"星尘",22) | |
7 | update test set age = 18 where id >0; 显示:Query OK, 5 rows affected,影响了5行 | |
8 | select * from test where id > 0; 结果为5条:(1,"星尘",18)(2,"星尘",18)(3,"星尘",18)(4,"星尘",18)(5,"星尘",18) commit; |
先设置为RR,再开启两个事务,事务A先查询 id > 0 的数据,结果为4条,此时事务B插入一条符合事务A查询条件的数据并commit;
第6步,事务A查询结果仍然是4条,对比RC的结果,发现确实幻读没出现,符合预期。
第7步,事务A执行了一次update,结果就出人意料了,影响了5行,明明上面查询到4条,说明事务B插入的数据也被修改了,再执行相同的查询语句,发现结果为5条,而这就又出现幻读了。
其实这应该就是为什么有的文章说RR能解决幻读,有的文章说不能解决幻读,至于原因是什么,从上面不可重复读的例子我们也能看出,update语句是当前读,普通的select。。。where。。。就是快照读,如果都是快照读的情况下,那么是不会产生幻读的,当出现update的当前读后,读取了最新的数据再update,后续的查询因为发现版本号没变,是自己的更新,那么就会出现这种情况下的幻读。
其实还有其他的当前读,这些当前读也会产生所谓的幻读,比如:
select * from ... where ... for update
select * from .... where ... lock in share mode
update .... set .. where ...
delete from. . where ..
如果事务中都使用快照读,那么就不会产生幻读现象,但是快照读和当前读混用就会产生幻读。
如果都是用当前读呢?
时间顺序 | 事务A | 事务B |
1 | set session transaction isolation level repeatable read; | set session transaction isolation level repeatable read; |
2 | start transaction with consistent snapshot; | |
3 | start transaction with consistent snapshot; | |
4 | select * from test where id > 0 for update; -- 此处为当前读 结果为5条:(1,"星尘",18)(2,"星尘",18)(3,"星尘",18)(4,"星尘",18)(5,"星尘",18) | |
5 | INSERT INTO test VALUES(NULL,'星尘',23); 此时会被阻塞,等待锁。。。 | |
6 | select * from test where id > 0 for update; -- 此处为当前读 结果为5条:(1,"星尘",18)(2,"星尘",18)(3,"星尘",18)(4,"星尘",18)(5,"星尘",18) 结果还是5条 | |
7 | commit; | |
8 | 此时得到锁,插入成功 commit; |
可以看到事务B 被阻塞了。需要等到事务A提交事务后才能完成。当我们在事务中每次读取都使用当前读,也就是人工把InnoDB变成了串行化。一定程度上降低了并发性,但是也同样避免了幻读的情况。
实际上,mysql使用的GAP锁 + 行锁,形成了next-key lock临间锁,锁住查询条件的区间,来保证不会出现幻读,在本例中也就是锁住了 [0,+ ∞ ),注意gap锁,和next-key锁都可能会锁住不存在的id。
再来回顾我们最早的问题,mysql RR解决了幻读了吗,实际上解决了,但是某些当前读的操作,会导致再次出现幻读。
1). mysql通过MVCC解决不可重复读
2). 通过next-key lock解决幻读
3). 可重复读隔离级别下,一个事务中只使用当前读,或者只使用快照读都能避免幻读。
4). 当前读,与快照读的区别,分别会带来什么影响
本人拙见,欢迎一块讨论
版权声明:本文由星尘阁原创出品,转载请注明出处!