幻读是什么?
只给id=5这一行加锁。而其他行不加锁
其中,Q3读到id=1这一行的现象,被称为“幻读”。也就是说,幻读指的是一个事务在前后两次查询同一个范围的时候,后一次查询看到了前一次查询没有看到的行。这里,对“幻读”做一个说明:
- 在可重复读隔离级别下,普通的查询是快照读,是不会看到别的事务插入的数据的。因此,幻读在“当前读”下才会出现。
- 上面session B的修改结果,被session A之后的select语句用“当前读”看到,不能称为幻读。幻读仅专指“新插入的行”。
但是,这是不是真的没问题呢?不,这里还真就有问题。
- 语义上的问题
- 数据一致性的问题
幻读有什么问题?
1. 只给d=5这一行,也就是id=5的这一行加锁
首先是语义上的。session A在T1时刻就声明了,“我要把所有d=5的行锁住,不准别的事务进行读写操作”。而实际上,这个语义被破坏了。如果现在这样看感觉还不明显的话,我再往session B和session C里面分别加一条SQL语句,你再看看会出现什么现象。
图 2 假设只在id=5这一行加行锁—语义被破坏
- 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。

图 3 假设只在id=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里面的内容。
- T2时刻,session B事务提交,写入了两条语句;
- T4时刻,session C事务提交,写入了两条语句;
- 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的这一行加锁”导致的。
所以我们认为,上面的设定不合理,要改。
2. 把扫描过程中碰到的行,也都加上写锁,再来看看执行效果。

- 这样对于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怎么解决幻读的问题。
产生幻读的原因是,行锁只能锁住行,但是新插入记录这个动作,要更新的是记录之间的“间隙”。
- 间隙锁:锁的就是两个值之间的空隙。比如文章开头的表t,初始化插入了6个记录,这就产生了7个间隙。
- 也就是说这时候,在一行行扫描的过程中,不仅将给行加上了行锁,还给行两边的空隙,也加上了间隙锁。

比如行锁,分成读锁和写锁。下图就是这两种类型行锁的冲突关系。
- 跟行锁有冲突关系的是“另外一个行锁”。
图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的引入,解决了幻读的问题,但也带来一些“困扰(死锁)”。
在前面的文章中,就有同学提到了这个问题。我把他的问题转述一下,对应到我们这个例子的表来说,业务逻辑这样的:任意锁住一行,如果这一行不存在的话就插入,如果存在这一行就更新它的数据,代码如下:
可能你会说,这个不是insert … on duplicate key update 就能解决吗?但其实在有多个唯一键的时候,这个方法是不能满足这位提问同学的需求的。至于为什么,我会在后面的文章中再展开说明。
死锁
- 两个session进入互相等待状态,形成死锁。当然,InnoDB的死锁检测马上就发现了这对死锁关系,让session A的insert语句报错返回了。
- 间隙锁的引入,可能会导致同样的语句锁住更大的范围,这其实是影响了并发度的。
图6 两种行锁间的冲突关系
图7 间隙锁之间不互锁
这里session B并不会被堵住。因为表t里并没有c=7这个记录,因此session A加的是间隙锁(5,10)。而session B也是在这个间隙加的间隙锁。它们有共同的目标,即:保护这个间隙,不允许插入值。但,它们之间是不冲突的。