MySQL - 幻读

目录

幻读定义

幻读有什么问题

如何解决幻读

间隙锁带来的困扰


幻读定义

        下表包含三个字段,表除了主键id外,还有⼀个索引c,初始化语句在表中插⼊了6行数据:

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);

        表的结构是这样的:

         现在有如下场景:

         session A⾥执行了三次查询,分别是Q1、Q2和Q3。它们的SQL语句相同,都是select * from t where d=5 for update。这个语句的意思: 查所有d=5的行,并且使用的是当前读,加上写锁。

        三条SQL语句,分别返回的结果在图中红色部分也进行了标注:

1. Q1只返回id=5这一行 ;

2. 在T2时刻,事务B把id=0这一行的d值改成了5,因此T3时刻Q2查出来的是id=0和id=5这两行;

3. 在T4时刻,事务C又插⼊一行(1,1,5),因此T5时刻Q3查出来的是id=0、id=1和id=5的这三行。

         其中,Q3读到id=1这一行的现象,被称为幻读。

        幻读 :指的是⼀个事务在前后两次查询同⼀个范围的时候,后一次查询看到了前⼀次查询没有看到的行。

补充一下:

  • 在可重复读隔离级别下,普通的查询是快照读,是不会看到别的事务插⼊的数据的。因此,幻读在“当前读”下才会出现。
  • 上面事务的修改结果,被事务A之后的select语句用“当前读”看到,不能称为幻读。幻读仅专指“新插⼊的行”。

        

        因为这三个查询都是加了for update 也就是写锁,都是当前读。当前读的规则,就是要能读到所有已经提交的记录的最新值。并且,session B和sessionC的两条语句,执行后就会提交,所以Q2和Q3就是应该看到这两个事务的操作效果,这跟事务的可见性规则并不矛盾。


 幻读有什么问题

        1、语义上问题

        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的加锁声明。

        2、数据一致性问题

        锁的设计是为了保证数据的一致性。这个⼀致性,不止是数据库内部数据状态在此刻的⼀致性,还包含了数据和日志在逻辑上的一致性。

        给session A在T1时刻再加⼀个更新语句,即:update t set d=100 where d=5:

         update的加锁语义和select ...for update 是⼀致的,所以这时候加上这条update语句也很合理。

        session A声明说“要给d=5的语句加上锁,就是为了要更新数据,新加的这条update语句就是把它认为加上了锁的这一行的d值修改成了100。

        分析一下上图执行完后,数据库是什么结果:

1. 经过T1时刻,id=5这一行变成 (5,5,100),当然这个结果最终是在T6时刻正式提交的;

2. 经过T2时刻,id=0这一行变成(0,5,5);

3. 经过T4时刻,表里多了一行(1,5,5);

4. 其他行跟这个执行序列无关,保持不变。

        这样看,这些数据没什么问题,但是我们再来看看这时候binlog里的内容:

1. T2时刻,session B事务提交,写入了两条语句;

2. T4时刻,session C事务提交,写入了两条语句;

3. T6时刻,session A事务提交,写入了update t set d=100 where d=5 这条语句。

        统一放到一起就是:

update t set d=5 where id=0; /*(0,0,5)    先提交的事务B */
update t set c=5 where id=0; /*(0,5,5)*/    

insert into t values(1,1,5); /*(1,1,5)    先提交的事务C */
update t set c=5 where id=1; /*(1,5,5)*/

update t set d=100 where d=5;/*所有d=5的⾏,d改成100    最后提交的事务A */

        这个语句序列,不论是拿到备库去执行,还是以后用binlog来克隆⼀个库,这三行的结果,都变成了 (0,5,100)、(1,5,100)和(5,5,100)。

        比对数据库的结果和binlog的结果 ,id=0和id=1这两行,发生了数据不⼀致。

        这个数据不⼀致到底是怎么引⼊的?分析⼀下可以知道,这是我们假设“select * from t where d=5 for update这条语句只给d=5这一行,也就是id=5的这一行加 锁”导致的。

        接下来假设,把扫描过程中碰到的行,也都加上写锁,再来看看执行效果:

         由于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)   事务A还没提交,所以先走事务C*/
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)    事务A提交了才到事务B*/
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个间隙:

         当你执行 select * from t where d=5 for update的时候,就不止是给数据库中已有的6个记录加上了行锁,还同时加了7 个间隙锁,这样就确保了无法再插入新的记录。
        也就是说这时候,在一行行扫描的过程中,不仅将给行加上了行锁,还给行两边的空隙,也加上了间隙锁。

        

        对于普通的行锁,跟行锁有冲突关系的是“另外⼀个行锁”:

         但是间隙锁不⼀样,跟间隙锁存在冲突关系的,是“往这个间隙中插入⼀个记录”这个操作。间隙锁之间都不存在冲突关系。

        例如以下执行事务,还是针对于表t:

         这⾥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]:

         你可能会问说,这个suprenum从哪来的呢?

        这是因为+∞是开区间。实现上,InnoDB给每个索引加了⼀个不存在的最大值suprenum,这样才符合我们前面说的“都是前开后闭区间”。


间隙锁带来的困扰

        业务逻辑这样的:任意锁住一行,如果这一行不存在的话就插入,如果存在这一行就更新它的数据,代码如下:

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;

        这个逻辑⼀旦有并发,就会碰到死锁。这个逻辑每次操作前⽤for update锁起来,已经是最严格的模式了,怎么还会有死锁呢?

        模拟一下并发,假设 N = 9:

         分析一下语句:

        1. session A 执行select ... for update语句,由于id=9这一行并不存在,因此会加上间隙锁(5,10);

        2. session B 执行select ... for update语句,同样会加上间隙锁(5,10),间隙锁之间不会冲突,因此这个语句可以执行成功;

        3. session B 试图插⼊一行(9,9,9),被session A的间隙锁挡住了,只好进入等待;

        4. session A试图插⼊一行(9,9,9),被session B的间隙锁挡住了。

        ⾄此,两个session进⼊互相等待状态,形成死锁。当然,InnoDB的死锁检测马上就发现了这对死锁关系,让session A的 insert语句报错返回了。 

        间隙锁的引入,可能会导致同样的语句锁住更大的范围,这其实是影响了并发度的。

        

        间隙锁是在可重复读隔离级别下才会生效的。所以,你如果把隔离级别设置为读提交的话,就没有间隙锁了。但同时,你要解决可能出现的数据日志不⼀致问题,需要把binlog格式设置为row。这,也是现在不少公司使⽤的配置组合。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值