Mysql默认的隔离级别是 “可重复读” ,在该级别下解决了脏读和不可重复读问题。今天我们就深入了解一下幻读到底是什么,到底如何解决。在此之前我们 Innodb 中两种不同读取数据的方式。

一致性非锁定读

一致性非锁定读,也称快照读。正常情况下如果某数据行正在执行 update 或 delete 操作,会给该数据行加上一个X锁。此时如果要读取该数据行,需要等待锁的释放。而一致性非锁定读是基于多版本控制的方式去读取某行记录,读取记录时,不需要等待锁的释放,而是读取某行记录的快照数据。Innodb在可重复读级别下采用该方式读取数据。

什么是快照数据?

快照数据是指该行记录之前的版本,该实现是通过 undo 段实现的(undolog中记录用于事务回滚的数据)。由于一行记录可能有多个版本,因此带来的并发控制,称之为多版本并发控制(MVCC)。

MVCC机制可以阅读《MVCC多版本并发控制》

一致性锁定读

Innodb在可重复读级别下采取的是一致性非锁定读。但是在某些情况下,我们需要显示的对数据库读取操作进行加锁,已保证数据逻辑的一致性。这时需要数据库支持加锁语句,即使是对于SELECT的只读操作。

  • SELECT… FOR UPDATE

SELECT… FOR UPDATE 相当于对读取的数据行加一个X锁,其他事务不能对已锁定的数据行加上任何锁。

  • SELECT… LOCK IN SHARE MODE

SELECT… LOCK IN SHARE MODE 相当于对数据行加一个S锁,其他事务可以对该行记录加上S锁,而不能加X锁。

一致性锁定读能够读到所有已经提交的记录的最新值,所以也叫当前读

幻读有什么问题?


首先我们创建一张表,表中除了主键id外,还给字段a加了索引

  1. CREATE TABLE `table1` (
  2. `id` int(11) NOT NULL,
  3. `a` int(11) DEFAULT NULL,
  4. `b` int(11) DEFAULT NULL,
  5. PRIMARY KEY (`id`),
  6. KEY `a` (`a`)
  7. ) ENGINE=InnoDB;

插入一些数据

insert into table1 values(0,0,0),(5,5,5),(10,10,10),(15,15,15),(20,20,20),(25,25,25);

微信截图_20210815162703.png

可以看到,session A 里执行了三次查询,它们的 SQL 语句相同,查所有 b=5 的行记录,而且使用的是当前读,并且加上写锁


Session A Session B Session C


T1
begin
select from table where b = 5 for update
*result(5,5,5)


T2
update set table b = 5 where id = 0
update set table a = 5 where id = 0

T3 select from table where b = 5 for update
*result (0,5,5) (5,5,5)


T4

insert into tabel value
(1,1,5)
update set table a = 5 where id = 1
T5 select from table where b = 5 for update
*result (0,0,5) (1,5,5) (5,5,5)
T6 commit;
  1. T1 时刻查询只返回 id=5 这一行
  2. 在 T2 时刻,session B 把 id=0 这一行的 b 值改成了 5,因此 T3 时刻查出来的是 id=0 和 id=5 这两行
  3. 在 T4 时刻,session C 又插入一行(1,1,5),因此 T5 时刻查出来的是 id=0、id=1 和 id=5 的这三行。

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

语义一致性

session A 在 T1 时刻就声明了,“我要把所有 b = 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 b=100 where b=5


Session A Session B Session C


T1
begin
select from table where b = 5 for update
*result(5,5,5)

update table set b = 100 where b = 5


T2
update set table b = 5 where id = 0
update set table a = 5 where id = 0

T3 select from table where b = 5 for update
*result (0,5,5) (5,5,5)


T4

insert into tabel value
(1,1,5)
update set table a = 5 where id = 1
T5 select from table where b = 5 for update
*result (0,0,5) (1,5,5) (5,5,5)
T6 commit;

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

从上面数据来看好像没啥问题,但是我们来看一看写入 binlog 的顺序

  • T2 时刻,session B 事务提交,写入了两条语句
  • T4 时刻,session C 事务提交,写入了两条语句
  • T6 时刻,session A 事务提交,写入了 update t set b=100 where d=5 这条语句 ```java //Session B update t set b=5 where id=0; /(0,0,5)/ update t set a=5 where id=0; /(0,5,5)/

//Session C insert into table values(1,1,5); /(1,1,5)/ update t set a=5 where id=1; /(1,5,5)/

//Session A update t set b=100 where b=5;/*

如果恢复数据的时候按照这个语序来执行,最后所有 b=5 的记录全都修改为 b=100,也就是说 id=0 和 id=1 这两条记录出现了**数据不一致**。

对于 id=0 这条记录来说,我们可以通过将锁升级为表锁来解决数据不一致问题。由于 session A 把所有的行都加了写锁,所以 session B 在执行第一个 update 语句的时候就被锁住了。需要等到 T6 时刻 session A 提交以后,session B 才能继续执行。
```java
//Session C
insert into table values(1,1,5); /*(1,1,5)*/
update t set a=5 where id=1; /*(1,5,5)*/

//Session A
update t set b=100 where b=5;/*

//Session B
update t set b=5 where id=0; /*(0,0,5)*/
update t set a=5 where id=0; /*(0,5,5)*/

但是对于 Session C 来说,数据一致性的问题还是没有解决。因为即使把所有的记录都加上锁,还是阻止不了新插入的记录

间隙锁

产生幻读的原因是,行锁只能锁住行,但是新插入记录这个动作,要更新的是记录之间的”间隙”。为了解决幻读问题,InnoDB 只好引入新的锁,也就是间隙锁 (Gap Lock),间隙锁是在可重复读隔离级别下才会生效的

如开头创建的表 table,初始化插入了 6 个记录,对于字段 b 来讲,这就产生了 7 个间隙
微信截图_20210815232915.png
间隙锁只对间隙加锁,而不对记录本身加锁。如果对 (5,10) 这个间隙加上锁,此时执行一条插入语句:

insert into table value (7,7,7)

执行该语句时会阻塞,原因是间隙锁与“往这个间隙中插入一个记录”这个操作存在冲突关系,即间隙锁的目标是保护这个间隙,不允许插入值(间隙锁之间是不冲突的)

临键锁

临键锁(Next-Key Lock)是间隙锁与行锁的结合。即不仅锁记录本身,还锁上记录的间隙(形成一个左开右闭的区间)

执行 “select * from table where id = 5 for update” ,就会形成了 1 个 next-key lock, (0,5],即锁的范围是 (0, 5) 的间隙和 id=5 这条记录
微信截图_20210816000430.png
InnoDB 给每个索引加了一个不存在的最大值 supremum,这样才符合我们前面说的“都是前开后闭区间”

特别注意的是,Innodb中对辅助索引下一个键值会加上 Gap Lock

select * from table where b = 5 for update

执行上述语句,不仅形成一个 (0, 5] 的临键锁,还会加上 (5, 10) 的间隙锁,执行 “insert into table value (8,8,8)” 也会阻塞