目录
前言
幻读是什么?
幻读有什么问题?
如何解决幻读?
-
前言
- 为了便于说明问题,这一篇文章,就先使用一个小一点儿的表
- 建表和初始化语句如下:
- 这个表除了主键id外,还有一个索引c,初始化语句在表中插入了6行数据
- 下面的语句序列,是怎么加锁的,加的锁又是什么时候释放的呢?
- 比较好理解的是,这个语句会命中d=5的这一行,对应的主键id=5,因此在select 语句执行完成后,id=5这一行会加一个写锁,而且由于两阶段锁协议,这个写锁会在执行commit语句的时候释放
- 由于字段d上没有索引,因此这条查询语句会做全表扫描
- 那么,其他被扫描到的,但是不满足条件的5行记录上,会不会被加锁呢?
- InnoDB的默认事务隔离级别是可重复读,所以本文接下来没有特殊说明的部分,都是设定在可重复读隔离级别下
-
幻读是什么?
- 现在就来分析一下,如果只在id=5这一行加锁,而其他行的不加锁的话,会怎么样
- 下面先来看一下这个场景(注意:这是假设的一个场景):
- 可以看到,session A里执行了三次查询,分别是Q1、Q2和Q3
- 它们的SQL语句相同,都是select *fromt where d=5 for update
- 这个语句的意思应该很清楚了,查所有d=5的行,而且使用的是当前读,并且加上写锁
- 现在来看一下这三条SQL语句,分别会返回什么结果
- 1-Q1只返回id=5这一行
- 2-在T2时刻,session B把id=0这一行的d值改成了5,因此T3时刻Q2查出来的是id=0和id=5这两行
- 3-在T4时刻,session C又插入一行(1,1,5),因此T5时刻Q3查出来的是id=0、id=1和id=5的这三行
- 其中,Q3读到id=1这一行的现象,被称为“幻读”
- 也就是说,幻读指的是一个事务在前后两次查询同一个范围的时候,后一次查询看到了前一次查询没有看到的行
- 这里需要对“幻读”做一个说明:
- 1-在可重复读隔离级别下,普通的查询是快照读,是不会看到别的事务插入的数据的;因此,幻读在“当前读”下才会出现
- 2-上面session B的修改结果,被session A之后的select语句用“当前读”看到,不能称为幻读;幻读仅专指“新插入的行”
- 如果只从事务可见性规则来分析的话,上面这三条SQL语句的返回结果都没有问题
- 因为这三个查询都是加了for update,都是当前读
- 而当前读的规则,就是要能读到所有已经提交的记录的最新值
- 并且,session B和session C的两条语句,执行后就会提交,所以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 这条语句
- 统一放到一起的话,就是这样的:
- 这样应该看出问题了
- 这个语句序列,不论是拿到备库去执行,还是以后用binlog来克隆一个库,这三行的结果,都变成了 (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的这一行加锁”导致的
- 所以可以认为,上面的设定不合理,要改
- 那怎么改呢?
- 把扫描过程中碰到的行,也都加上写锁,再来看看执行效果
- 由于session A把所有的行都加了写锁,所以session B在执行第一个update语句的时候就被锁住了
- 需要等到T6时刻session A提交以后,session B才能继续执行
- 这样对于id=0这一行,在数据库里的最终结果还是(0,5,5)
- 在binlog里面,执行序列是这样的:
- 可以看到,按照日志顺序执行,id=0这一行的最终结果也是(0,5,5)
- 所以,id=0这一行的问题解决了
- 但同时也可以看到,id=1这一行,在数据库里面的结果是(1,5,5),而根据binlog的执行结果是(1,5,100),也就是说幻读的问题还是没有解决
- 为什么已经这么“凶残”地,把所有的记录都上了锁,还是阻止不了id=1这一行的插入和更新呢?
- 原因很简单
- 在T3时刻,给所有行加锁的时候,id=1这一行还不存在,不存在也就加不上锁
- 也就是说,即使把所有的记录都加上锁,还是阻止不了新插入的记录,这也是为什么“幻读”会被单独拿出来解决的原因
-
如何解决幻读?
- 现在知道了,产生幻读的原因是,行锁只能锁住行,但是新插入记录这个动作,要更新的是记录之间的“间隙”
- 因此,为了解决幻读问题,InnoDB只好引入新的锁,也就是间隙锁(GapLock)
- 顾名思义,间隙锁,锁的就是两个值之间的空隙
- 比如文章开头的表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-keylock
- 分别是(-∞,0]、(0,5]、(5,10]、(10,15]、(15,20]、(20, 25]、(25, +supremum]
- 这篇文章中,如果没有特别说明,把间隙锁记为开区间,把next-key lock记为前开后闭区间
- 那这个supremum从哪儿来的呢?
- 这是因为+∞是开区间
- 实现上,InnoDB给每个索引加了一个不存在的最大值supremum,这样才符合前面说的“都是前开后闭区间”
- 间隙锁和next-key lock的引入,帮我们解决了幻读的问题,但同时也带来了一些“困扰”
- 对应到这个例子的表来说,业务逻辑这样的:
- 任意锁住一行,如果这一行不存在的话就插入,如果存在这一行就更新它的数据
- 代码如下:
- 那这个不是insert …on duplicate key update 就能解决吗?
- 但其实在有多个唯一键的时候,这个方法是不能满足需求的
- 现在就只讨论这个逻辑
- 这个逻辑一旦有并发,就会碰到死锁
- 你一定也觉得奇怪,这个逻辑每次操作前用for update锁起来,已经是最严格的模式了,怎么还会有死锁呢?
- 这里用两个session来模拟并发,并假设N=9
- 可以看到,其实都不需要用到后面的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语句报错返回了
- 现在知道了,间隙锁的引入,可能会导致同样的语句锁住更大的范围,这其实是影响了并发度的
- 那为了解决幻读的问题,引入了这么一大串内容,有没有更简单一点的处理方法呢
- 在文章一开始就说过,如果没有特别说明,这里分析的问题都是在可重复读隔离级别下的,间隙锁是在可重复读隔离级别下才会生效的
- 所以如果把隔离级别设置为读提交的话,就没有间隙锁了
- 但同时要解决可能出现的数据和日志不一致问题,需要把binlog格式设置为row
- 这个配置到底合不合理
- 关于这个问题本身的答案是,如果读提交隔离级别够用,也就是说,业务不需要可重复读的保证,这样考虑到读提交下操作数据的锁范围更小(没有间隙锁),这个选择是合理的
- 但其实配置是否合理,跟业务场景有关,需要具体问题具体分析