20-幻读是什么,幻读有什么问题?
# 幻读是什么?
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);
2
3
4
5
6
7
8
9
10
如果只在 d = 5 这一行加锁其他的行不加锁。假设如下的场景,只是说明,如下场景在真实 Mysql 中无法复现
Session A | Session B | Session C |
---|---|---|
begin; | ||
select * from t where d = 5 for update;//(5,5,5) | update t set d =5 where id = 0; | |
select * from t where d = 5 for update;//(0,0,5),(5,5,5) | ||
insert into t values(1,1,5); | ||
select * from t where d = 5 for update;//(0,0,5),(5,5,5),(1,1,5) | ||
commit; |
会发现,在次读取后 Session A 的结果都会变多。这就是幻读:一个事务在前后多次查询同一范围的数据,看到了上次查询没看到的行。
注意
- 在可重复度的隔离前提下,默认的查询是快照读,是无法看到其他事务提交的数据的,幻读只会在当前读的前提下发生。
- 幻读特指的是新增的数据,而删除和更新指的是不可重复读。
# 幻读有什么问题?
# 语义上的问题
首先 Session A 在一开始就声明了我把所有 d=5 的行锁住,其他的事务都不能来进行读写。而实际上这个语义被破坏了。
Session A | Session B | Session C |
---|---|---|
begin; | ||
select * from t where d = 5 for update;//(5,5,5) | ||
update t set d =5 where id = 0; update t set c =5 where id = 0; | ||
select * from t where d = 5 for update;//(0,0,5),(5,5,5) | ||
insert into t values(1,1,5); update t set c = 5 where id =1 ; | ||
select * from t where d = 5 for update;//(0,0,5),(5,5,5),(1,1,5) | ||
commit; |
Session B 将 id=0 的 d 改成了 5,理应被锁住,但是紧接着又将 c 的值改成了 5 故这个语义被破坏了。
# 数据一致性问题
Session A | Session B | Session C |
---|---|---|
begin; | ||
select * from t where d = 5 for update;//(5,5,5) update t set d = 100 where d = 5 ; | ||
update t set d =5 where id = 0; update t set c =5 where id = 0; | ||
select * from t where d = 5 for update;//(0,0,5),(5,5,5) | ||
insert into t values(1,1,5); update t set c = 5 where id =1 ; | ||
select * from t where d = 5 for update;//(0,0,5),(5,5,5),(1,1,5) | ||
commit; |
分析一下:最后的结果为多少?
- Session A 将 d 改为 100,id=5 这一行变成 (5,5,100),但是这个是在最后一步提交的。
- Session B 将 id = 0 这行变成 (0,5,5);
- Session C 插入一行 (1,1,5),有将这行改成 (1,5,5)
根据这个看看 binlog 日志(binlog 的写入是在 commit 之后)
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的都给更新成100了**/
2
3
4
5
如果这个 binlog 日志拿去恢复,会造成严重的数据不一致问题。
将扫描的行全部加上写锁
Session A | Session B | Session C |
---|---|---|
begin; | ||
select * from t where d = 5 for update; update t set d = 100 where d = 5 ; | ||
update t set d =5 where id = 0;// 从这里开始就会加锁阻塞。 update t set c =5 where id = 0; | ||
select * from t where d = 5 for update; | ||
insert into t values(1,1,5); update t set c = 5 where id =1 ; | ||
select * from t where d = 5 for update; | ||
commit; |
这里可以说明的是加的锁并不会导致新增的行被阻塞,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; /**(1,5,100)**/ /**(5,5,100)**/
update t set d = 5 where id = 0; /**(0,0,5)**/
update t set c =5 where id = 0; /**(0,5,5)**/
2
3
4
5
可以发现 id = 0 的最后结果已经是 (0,5,5) 与预期结果一致,但是 id = 1 的在数据库中为 (1,5,5),而 binlog 中为 (1,5,100),即使在锁住全部数据的情况下还是无法阻止幻读的产生。但是 InnoDB 解决了这个问题。
# 如何解决这个问题?
# 间隙锁
InnoDB 为了解决幻读引入了新的锁:间隙锁。
- 间隙锁
- 间隙锁锁的是两个值之间的空隙,但是并不会锁住两个值,可以理解为开区间。
- 间隙锁之间有冲突的只是在同一个间隙内插入这个动作,而间隙锁之间没有冲突。
锁冲突如何理解?比如行锁:除了读读不冲突,读写,写写之间都存在冲突。然而,如果两个事务试图在不同的间隙内插入新行,则它们不会发生冲突。
Session A | Session B |
---|---|
begin; select * from t where c =7 lcok in share mode; | |
begin; select * from t where c =7 lcok in share mode; |
间隙锁在 (5,10),Session A 查询的这个记录不存在,Session B 也同样,但是两者都是同一个任务:保护区间内不被插入新的值,所以两者不冲突。
只有在可重复读的隔离级别下,才会有间隙锁。读已提交的隔离级别下不会有间隙锁。
# 优点
在上面说了间隙锁并不会对当前的值进行加锁,那么 InnoDB 用 next-key lock 来解决这个问题,next-key lock 是间隙锁的一种变体,可以理解为行锁 + 间隙锁,锁的是间隙 + 右边界行(左开右闭),对上面插入来说就是 (-∞,0]、(0,5]、(5,10]、(10,15]、(15,20]、(20, 25]、(25, +supremum]。这个 supremum 是 InnoDB 为每个索引创建了一个不存在的最大值。来保证这个左开右闭。
# 弊端
next-key lock 确实解决了间隙锁的问题,但是也引入了一些困扰,例如。如果一个事务获得了对某个间隙的 next-key lock,则它可以新增更新或删除该间隙内的任何行。然而,如果另一个事务随后在该间隙中插入新行,则第一个事务的新增更新或删除操作可能会失败。
Session A | Session B |
---|---|
begin; select * from t where id = 9 for update ; | |
begin; select * from t where id = 9 for update ; | |
insert into t values(9,9,9); blocking | |
insert into t values(9,9,9); 死锁 |
这里就印证了上面说的两个间隙锁是同一个任务的时候并不会冲突,Session A 锁住了 (5,10) 防止有插入,Session B 也是,AB 都获取到了间隙锁,然后都在该间隙中插入新行,B 需要等待 A,A 需要等待 B,所以就死锁了。