暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

MySQL学习,幻读会有什么问题?

码农网络 2021-06-08
540

一、表结构

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

      如下图所示


      三、什么是幻读?

      注:其中 for update 表示在查询的时候加上一个写锁,并且是当前读两阶段锁协议 规则是在 InnoDB 事务中,行锁是在需要的时候才加上的,要等到事务结束时才释放。


      假设只在id=5这行加行锁

      可以看到,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这一行的现象,被称为“幻读”。也就是说,幻读指的是一个事务在前后两次查询同一个范围的时候,后一次查询看到了前一次查询没有看到的行。


          这里,需要对“幻读”做一个说明:

      1. 在可重复读隔离级别下,普通的查询是快照读,是不会看到别的事务插入的数据的。因此,幻读在“当前读”下才会出现。

      2. 上面session B的修改结果,被session A之后的select语句用“当前读”看到,不能称为幻读。幻读仅专指“新插入的行”。

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

      四、幻读会有什么问题?

          

      首先语义被破坏。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。

      现在,我们来分析一下上图执行完成后,数据库里会是什么结果。

      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)*/
        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*/
        复制

        上面的 binlog 日志逻辑,如果是从库用来复制,或者备课用来恢复,得到的数据逻辑就会和原主库主库不一致,如果只在加行锁的情况下,即使把所有的记录都加上锁,还是阻止不了新插入的记录。这也是为什么“幻读”会被单独拿出来解决的原因。


        五、如何解决幻读

            

        现在知道了,产生幻读的原因是,行锁只能锁住行,但是新插入记录这个动作,要更新的是记录之间的“间隙”。因此,为了解决幻读问题,InnoDB只好引入新的锁,也就是间隙锁(Gap Lock)。


        顾名思义,间隙锁,锁的就是两个值之间的空隙。比如文章开头的表t,初始化插入了6个记录,这就产生了7个间隙。

        当你执行 select * from t where d=5 for update的时候,就不止是给数据库中已有的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记为前开后闭区间。

        这个suprenum是因为+∞是开区间。实现上,InnoDB给每个索引加了一个不存在的最大值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;
          复制

          这个逻辑一旦有并发,就会碰到死锁。你一定也觉得奇怪,这个逻辑每次操作前用for update锁起来,已经是最严格的模式了,怎么还会有死锁呢?


          其实都不需要用到后面的update语句,就已经形成死锁了。我们按语句执行顺序来分析一下:

          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语句报错返回了。

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

              注:间隙锁是在可重复读隔离级别下才会生效的,如果把隔离级别设置为读提交的话,就没有间隙锁了。

          文章转载自码农网络,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

          评论