前言
文章总结自参考资料, 部分图片和代码来自参考资料 ,非原创 ,文章为学习总结.
幻读的产生
不特别声明,以下事务都在可重复读事务隔离级别中进行.
begin; select * from t where d=5 for update; commit;
我们之前学习的时候知道了select .. for update
和 select .. in share mode
是给记录加X锁(写锁)和S锁(读锁) ,当有其他操作也操作该记录的时候会阻塞其他操作的进行 ,那么这个锁锁的是一行记录还是一批记录, 还是所有记录呢? 下面我们先看看关于幻读的产生
我们先建个表
CREATE TABLE `t` ( `id` int(11) NOT NULL, `c` int(11) DEFAULT NULL, `d` int(11) DEFAULT NULL, PRIMARY KEY (`id`), KEY `c` (`c`) ) ENGINE=InnoDB; insert into t values(0,0,0),(5,5,5), (10,10,10),(15,15,15),(20,20,20),(25,25,25);
幻读是什么
下面的阐述来自课程
现在,我们就来分析一下,如果只在id=5这一行加锁,而其他行的不加锁的话,会怎么样。 下面有三个 session ,分别在不同的时候开启事务并执行操作
可以看到,session A里执行了三次查询,分别是Q1、Q2和Q3。它们的SQL语句相同,都是select * from t where d=5 for update
。这个语句的意思你应该很清楚了,查所有d=5的行,而且使用的是当前读,并且加上写锁。现在,我们来看一下这三条SQL语句,分别会返回什么结果。
-
Q1只返回id=5这一行;
-
在T2时刻,session B把id=0这一行的d值改成了5,因此T3时刻Q2查出来的是id=0和id=5这两行;
-
在T4时刻,session C又插入一行(1,1,5),因此T5时刻Q3查出来的是id=0、id=1和id=5的这三行。
其中,Q3读到id=1这一行的现象,被称为“幻读”。也就是说,幻读指的是一个事务在前后两次查询同一个范围的时候,后一次查询看到了前一次查询没有看到的行。
这里,我需要对“幻读”做一个说明:
-
在可重复读隔离级别下,普通的查询是快照读,是不会看到别的事务插入的数据的。因此,幻读在“当前读”下才会出现。
-
上面session B的修改结果,被session A之后的select语句用“当前读”看到,不能称为幻读。幻读仅专指“新插入的行”。
我们从前面的学习知道按照之前分析可重复读隔离级别的可读性规则分析, 都是没问题的, 例如加了for update
因为这三个查询都是加了for update,都是当前读。而当前读的规则,就是要能读到所有已经提交的记录的最新值。并且,session B和sessionC的两条语句,执行后就会提交,所以Q2和Q3就是应该看到这两个事务的操作效果,而且也看到了,这跟事务的可见性规则并不矛盾。(我们前面讲了在可重复读事务隔离级别,假如是普通读那么是快照读,但是加了锁 ,例如 for update
和 in share mode
那么就是 当前读
了 , 既然是当前读
就可以读的到 session B 和 session C 的更改)
但是,这是不是真的没问题呢?
不,这里还真就有问题。
问题一 : 语义上不正确
session A在T1时刻就声明了,“我要把所有d=5的行锁住,不准别的事务进行读写操作”。而实际上,这个语义被破坏了。
如果现在这样看感觉还不明显的话,我再往session B和session C里面分别加一条SQL语句,你再看看会出现什么现象。
session B的第二条语句update t set c=5 where id=0,语义是“我把id=0、d=5这一行的c值,改成了5”。
由于在T1时刻,session A 还只是给id=5这一行加了行锁, 并没有给id=0这行加上锁。因此,session B在T2时刻,是可以执行这两条update语句的。这样,就破坏了 session A 里Q1语句要锁住所有d=5的行的加锁声明。
session C也是一样的道理,对id=1这一行的修改,也是破坏了Q1的加锁声明。
问题二 : 数据一致性的问题
我们知道,锁的设计是为了保证数据的一致性。而这个一致性,不止是数据库内部数据状态在此刻的一致性,还包含了数据和日志在逻辑上的一致性。
为了说明这个问题,我给session A在T1时刻再加一个更新语句,即:update t set d=100 where d=5。
update的加锁语义和select ...for update 是一致的,所以这时候加上这条update语句也很合理。session A声明说“要给d=5的语句加上锁”,就是为了要更新数据,新加的这条update语句就是把它认为加上了锁的这一行的d值修改成了100。
现在,我们来分析一下图3执行完成后,数据库里会是什么结果。
-
经过T1时刻,id=5这一行变成 (5,5,100),当然这个结果最终是在T6时刻正式提交的;
-
经过T2时刻,id=0这一行变成(0,5,5);
-
经过T4时刻,表里面多了一行(1,5,5);
-
其他行跟这个执行序列无关,保持不变。
这样看,这些数据也没啥问题,但是我们再来看看这时候binlog里面的内容。这个地方 ,我们先整理一下 , 数据库表里的值 : (5,5,100) , (0,5,5) , (1,5,5)
-
T2时刻,session B事务提交,写入了两条语句;
-
T4时刻,session C事务提交,写入了两条语句;
-
T6时刻,session A事务提交,写入了update t set d=100 where d=5 这条语句。
我统一放到一起的话,就是这样的:
-
T2时刻,session B事务提交,写入了两条语句;
-
T4时刻,session C事务提交,写入了两条语句;
-
T6时刻,session A事务提交,写入了update t set d=100 where d=5 这条语句。
update t set d=5 where id=0; /*(0,0,5)*/ update t set c=5 where id=0; /*(0,5,5)*/ insert into t values(1,1,5); /*(1,1,5)*/ update t set c=5 where id=1; /*(1,5,5)*/ update t set d=100 where d=5;/*所有d=5的行,d改成100*/
这三行的结果,都变成了 (0,5,100)、(1,5,100)和(5,5,100)。
也就是说,id=0和id=1这两行,发生了数据不一致。这个问题很严重,是不行的。
到这里,我们再回顾一下,这个数据不一致到底是怎么引入的?
我们分析一下可以知道,这是我们假设“select * from t where d=5 for update这条语句只给d=5这一行,也就是id=5的这一行加锁”导致的。
所以我们认为,上面的设定不合理,要改。
那怎么改呢?我们把扫描过程中碰到的行,也都加上写锁,再来看看执行效果。(由于d 字段不是聚集索引,所以select 过程是沿着主键索引id, 一路遍历下去的,直到找到对应的记录)
于session A把所有的行都加了写锁,所以session B在执行第一个update语句的时候就被锁住了。需要等到T6时刻session A提交以后,session B才能继续执行。
这样对于id=0这一行,在数据库里的最终结果还是 (0,5,5)。在binlog里面,执行序列是这样的:
insert into t values(1,1,5); /*(1,1,5)*/ update t set c=5 where id=1; /*(1,5,5)*/ update t set d=100 where d=5;/*所有d=5的行,d改成100*/ update t set d=5 where id=0; /*(0,0,5)*/ update t set c=5 where id=0; /*(0,5,5)*/
可以看到,按照日志顺序执行,id=0这一行的最终结果也是(0,5,5)。所以,id=0这一行的问题解决了。
但同时你也可以看到,id=1这一行,在数据库里面的结果是(1,5,5),而根据binlog的执行结果是(1,5,100),也就是说幻读的问题还是没有解决。为什么我们已经这么“凶残”地,把所有的记录都上了锁,还是阻止不了id=1这一行的插入和更新呢?
原因很简单。在T3时刻,我们给所有行加锁的时候,id=1这一行还不存在,不存在也就加不上锁。
也就是说,即使把所有的记录都加上锁,还是阻止不了新插入的记录,这也是为什么“幻读”会被单独拿出来解决的原因。
幻读的解决
现在你知道了,产生幻读的原因是,行锁只能锁住行,但是新插入记录这个动作,要更新的是记录之间的“间隙”。因此,为了解决幻读问题,InnoDB只好引入新的锁,也就是间隙锁(Gap Lock)。
顾名思义,间隙锁,锁的就是两个值之间的空隙。比如文章开头的表t,初始化插入了6个记录,这就产生了7个间隙。
我们前面都知道读写锁之间是有兼容性问题的, 当时间隙锁之间是没有的 , 以下面的例子为说明
这里session B并不会被堵住。因为表t里并没有c=7这个记录,因此session A加的是间隙锁(5,10)。而session B也是在这个间隙加的间隙锁。它们有共同的目标,即:保护这个间隙,不允许插入值。但,它们之间是不冲突的。
间隙锁和行锁合称next-key lock,每个next-key lock是前开后闭区间。也就是说,我们的表t初始化以后,如果用select * from t for update要把整个表所有记录锁起来,就形成了7个next-key lock,分别是 (-∞,0]、(0,5]、(5,10]、(10,15]、(15,20]、(20, 25]、(25, +suprenum]。
备注:这篇文章中,如果没有特别说明,我们把间隙锁记为开区间,把next-key lock记为前开后闭区间。
当然间隙锁也会带来一些问题 , 例如下面的例子 :
begin; select * from t where id=N for update; /*如果行不存在*/ insert into t values(N,N,N); /*如果行存在*/ update t set d=N set id=N; commit;
session A , 由于 id = 9 这一行根本不存在 ,所以会锁住 (5,10) 这个区间, session B 也一样会锁住这个区间, 但是当执行 insert 语句的时候由于该区间已经被 session A 锁住了, 所以两个线程就会互相锁住.
读提交隔离级别下
可重复读隔离级别下不存在幻读 , 但是存在间隙锁 , 而读提交隔离级别下是没有间隙锁的, 虽然它存在幻读现象 , 但是读提交隔离级别的并发度肯定是提升的 . 同时,你要解决可能出现的数据和日志不一致问题,需要把binlog格式设置为row。这,也是现在不少公司使用的配置组合。
小总结
-
幻读专指新增的数据行
-
InnoDB 解决幻读幻读问题是使用间隙锁
-
在读提交隔离级别下,解决数据和日志不一致问题,需要把binlog格式设置为row
参考资料
- <<MySQL 45讲-20讲幻读是什么>>
- <<MySQL 45讲-21讲为什么我只改一行的语句,锁这么多>>