Mysql默认的隔离级别是 “可重复读” ,在该级别下解决了脏读和不可重复读问题。今天我们就深入了解一下幻读到底是什么,到底如何解决。在此之前我们 Innodb 中两种不同读取数据的方式。
一致性非锁定读
一致性非锁定读,也称快照读。正常情况下如果某数据行正在执行 update 或 delete 操作,会给该数据行加上一个X锁。此时如果要读取该数据行,需要等待锁的释放。而一致性非锁定读是基于多版本控制的方式去读取某行记录,读取记录时,不需要等待锁的释放,而是读取某行记录的快照数据。Innodb在可重复读级别下采用该方式读取数据。
什么是快照数据?
快照数据是指该行记录之前的版本,该实现是通过 undo 段实现的(undolog中记录用于事务回滚的数据)。由于一行记录可能有多个版本,因此带来的并发控制,称之为多版本并发控制(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加了索引
CREATE TABLE `table1` (
`id` int(11) NOT NULL,
`a` int(11) DEFAULT NULL,
`b` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `a` (`a`)
) ENGINE=InnoDB;
插入一些数据
insert into table1 values(0,0,0),(5,5,5),(10,10,10),(15,15,15),(20,20,20),(25,25,25);
可以看到,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; |
- T1 时刻查询只返回 id=5 这一行
- 在 T2 时刻,session B 把 id=0 这一行的 b 值改成了 5,因此 T3 时刻查出来的是 id=0 和 id=5 这两行
- 在 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 个间隙
间隙锁只对间隙加锁,而不对记录本身加锁。如果对 (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 这条记录
InnoDB 给每个索引加了一个不存在的最大值 supremum,这样才符合我们前面说的“都是前开后闭区间”
特别注意的是,Innodb中对辅助索引下一个键值会加上 Gap Lock
select * from table where b = 5 for update
执行上述语句,不仅形成一个 (0, 5] 的临键锁,还会加上 (5, 10) 的间隙锁,执行 “insert into table value (8,8,8)” 也会阻塞