MySQL之幻读问题
导读
在进入今天的主题之前必须先了解事务的四大特性ACID、MVCC、事务隔离级别(具体的自行查询),其中I(Isolation)隔离性所产生的问题涉及到的事务隔离分为不同级别,包括读未提交(read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(serializable)
- 读未提交:一个事务还没提交时,它做的变更就能被别的事务看到。
- 读提交:一个事务提交之后,它做的变更才会被其他事务看到。
- 可重复读: 一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。
- 串行化: 是对于同一行记录,写会加“写锁”,读会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。
SQL标准中规定,针对不同的隔离级别,并发事务可以发生不同严重程度的问题,具体情况如下:
隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
读未提交 | 可能 | 可能 | 可能 |
读提交 | 不可能 | 可能 | 可能 |
可重复读 | 不可能 | 不可能 | 可能 |
串行化 | 不可能 | 不可能 | 不可能 |
InnoDB默认使用的隔离级别就是可重复读(RR),这次着重说的就是RR级别下的幻读问题。
幻读定义
幻读MYSQL官方叫法是Phantom Rows,意为幻影行或幽灵行,请看官方定义:
The so-called phantom problem occurs within a transaction when the same query produces different sets of rows at different times. For example, if a [SELECT] is executed twice, but returns a row the second time that was not returned the first time, the row is a “phantom” row.
翻译出来就是:当同一查询在不同时间产生不同的行集时,就会在事务中出现所谓的幻影问题。例如,同一个事务中,同一个查询语句执行两次,第二次执行比第一次执行查出的结果多了一行,这一行就是幽灵行。
举例:第一个事务对一个表中的数据进行了修改,这种修改涉及到表中的多条记录。同时,第二个事务向表中插入一条新记录。那么,之后就会发生操作第一个事务的用户发现表中修改的数据记录数与原先不一致,就好象发生了幻觉一样。
幻读与不可重复读的区别
幻读和不可重复读着两个概念一不注意就可能混淆了,从官方的定义来看,幻读侧重的是多行记录,属于记录数的变化。而不可重复读侧重的是单挑记录的数据变化。
区分这两者的原因:幻读问题的处理需要使用间隙锁,而不可重复读的处理只需要锁住行记录。
案例
CREATE TABLE `user` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`tag` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
`age` int(10) unsigned NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
INSERT INTO `test`.`tag` (`id`, `tag`, `age`) VALUES (1, 'one', 20), (2, 'two', 20);
模拟并发事务时的幻读场景,两个Session的操作顺序如下:
时间线 | sessionA | sessionB |
---|---|---|
1 | set global transaction isolation level repeatable read; begin; select * from tag where age = 20; | |
2 | set global transaction isolation level repeatable read; begin; insert into tag VALUES(3, ‘three’, 20); | |
3 | // 验证B事务没提交能否被A事务拿到数据 select * from tag where age = 20; | |
4 | commit; | |
5 | // 验证B事务提交后A事务能否拿到最新数据 select * from tag where age = 20; update tag set tag = ‘test1’ where age = 20; // 更新操作,导致select结果拿到了B提交的数据 select * from tag where age = 20; commit; |
step1 SessionA
mysql> set global transaction isolation level repeatable read;
Query OK, 0 rows affected (0.00 sec)
mysql> use test;
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from tag where age = 20;
+----+-----+-----+
| id | tag | age |
+----+-----+-----+
| 1 | one | 20 |
| 2 | two | 20 |
+----+-----+-----+
2 rows in set (0.00 sec)
step2 同一时刻SessionB所在事务向同一个表中插入满足条件范围的数据
mysql> set global transaction isolation level repeatable read;
Query OK, 0 rows affected (0.00 sec)
mysql> use test;
mysql> begin;
Query OK, 0 rows affected (0.01 sec)
mysql> insert into tag VALUES(3, 'three', 20);
Query OK, 1 row affected (0.00 sec)
step3 SessionA 验证:再次查询验证B事务写入成功但是未提交事务的数据能否被A事务获取
mysql> select * from tag where age = 20;
+----+-----+-----+
| id | tag | age |
+----+-----+-----+
| 1 | one | 20 |
| 2 | two | 20 |
+----+-----+-----+
2 rows in set (0.00 sec)
step4 SessionB 提交事务
mysql> commit;
Query OK, 0 rows affected (0.01 sec)
step5 SessionA
- 验证:再次查询验证B事务提交后,A事务能否获取到最新的符合条件的数据
- 更新满足条件的数据,发现更新的数据行与查询的不一致
- 再次查询满足条件数据,发现“产生幻觉”
mysql> select * from tag where age = 20;
+----+-----+-----+
| id | tag | age |
+----+-----+-----+
| 1 | one | 20 |
| 2 | two | 20 |
+----+-----+-----+
2 rows in set (0.00 sec)
mysql> update tag set tag = 'test1' where age = 20;
Query OK, 3 rows affected (0.00 sec)
Rows matched: 3 Changed: 3 Warnings: 0
mysql> select * from tag where age = 20;
+----+-------+-----+
| id | tag | age |
+----+-------+-----+
| 1 | test1 | 20 |
| 2 | test1 | 20 |
| 3 | test1 | 20 |
+----+-------+-----+
3 rows in set (0.01 sec)
以上就是模拟幻读场景的sql操作(ps:我只是简单模拟两个事务交互操作,真实的事务中的写入操作不少于2条)。
幻觉产生的原因
由以上案例,定位出产生"幻觉"的地方是在update操作执行后。为什么update之后的select出来的结果是最新的已提交的数据?
这里先讲下两个涉及的概念
-
快照读:可重复读隔离级别下,普通的查询属于快照读,是不会看到别的事务插入的数据。
如:select * from table where ?; -
当前读:顾名思义就是读到当前最新的数据,幻读只有在“当前读”的情况下才会出现。加锁(锁的内容有机会再分享)、插入、更新、删除操作都属于当前读
如:
select * from table where ? lock in share mode;
select * from table where ? for update;
insert into table values (…);
update table set ? where ?;
delete from table where ?;
回到原因:是前面的update语句执行,会将当前记录存储的事务信息更新为当前的事务,而当前事务所做的任何更新,对本事务所有select查询都变为可见,因此最后查询到的结果是update更新了所有符合条件的当前事务的数据记录。(ps:此处是对mysql的innoDB源码的分析得出的结论,下次有机会再拿出来剖析)
问题处理
分析:update的快照读操作之后本事务select查询出来的是当前最新数据,但是因为业务我们又不能放弃update,那就只能从另一个事务的insert入手,有没有让事务A的更新操作提交之前阻止事务B插入数据的办法?或者说有没有能让本事务从头到尾select查询的记录一直是最新且不受其他事务影响,直到事务A提交?
- 行锁:select … where … for update(当前读)是将所有符合where条件的行加上行锁(innodb内会对该索引(where条件)加锁,即使当前不存在此数据)。
- 间隙锁(Gap Lock):顾名思义,间隙锁,锁的就是两个值之间的空隙,比如上面的案例数据,初始化插入2条记录,这就产生了2个间隙,3个间隙锁。区间范围:(-⚮,1) (1,2) (2,+⚮)。
如下图:
- 临键锁(Next-key Lock):可以简单理解为行锁+Gap锁,是行锁+间隙锁的组合,他的锁范围既包含索引记录,也包含索引区间。左开右闭的区间范围:(-⚮,1] (1,2] (2,+⚮]。
注意:临键锁主要是为了避免幻读。如果把事务的隔离级别降级为RC,临键锁则会失效。
行锁只能锁住满足条件的行记录,但是新插入记录,更新的是记录之间的“间隙”。因此,为了解决幻读问题,innoDB只好引入新的锁,也就是间隙锁。
根据上面的案例数据,当你执行 select * from tag where age = 20 for update 的时候,就不止是给数据库中已有的2条数据加上了行锁,同时还加了3个间隙锁。
这样就确保无法再插入新的记录,事务B在insert数据时,因为ID大于2,被间隙锁(2,+⚮)锁住,无法插入。
不仅给满足条件的记录加上了行锁,还给行两边的空隙加上了间隙锁。MySQL将行锁+间隙锁组合统称为临键锁,通过临键锁解决了幻读问题。
现在在事务A中查询时使用for update,在innoDB中会对索引加锁(即使当前数据不存在),于是事务B的insert会被阻塞直到事务Acommit成功后再执行。
step1 SessionA给记录加行锁
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from tag where age = 20 for update;
+----+-----+-----+
| id | tag | age |
+----+-----+-----+
| 1 | one | 20 |
| 2 | two | 20 |
+----+-----+-----+
2 rows in set (0.00 sec)
step2 SessionB
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
// 此时插入会报错提示锁等待
mysql> insert into tag values(3, 'test', 20);
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
step3 SessionA执行对应逻辑操作后提交事务
mysql> update tag set tag='test1' where age = 20;
Query OK, 2 rows affected (0.00 sec)
Rows matched: 2 Changed: 2 Warnings: 0
mysql> select * from tag where age = 20 for update;
+----+-------+-----+
| id | tag | age |
+----+-------+-----+
| 1 | test1 | 20 |
| 2 | test1 | 20 |
+----+-------+-----+
2 rows in set (0.00 sec)
mysql> commit;
Query OK, 0 rows affected (0.01 sec)
step4 SessionB此时可以成功插入数据了
mysql> insert into tag values(3, 'test', 20);
Query OK, 1 row affected (0.00 sec)
mysql> commit;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from tag;
+----+-------+-----+
| id | tag | age |
+----+-------+-----+
| 1 | test1 | 20 |
| 2 | test1 | 20 |
| 3 | test | 20 |
+----+-------+-----+
3 rows in set (0.00 sec)
MySQL事务默认隔离级别可重复读(RR),是事务安全与性能的折中,了解"幻读"后,便可以根据业务需求决定是否需要防止幻读。
PS:还有一种解决方案,但是不推荐使用,性能较差。
将事务隔离级别设置为串行化(serializable),串行化是悲观认为幻读肯定会发生,所以会隐式的对本事务所需资源加排它锁,让本事务保持安全,其他事务访问有关资源都会被阻塞等待,性能太差了。
我是六涛sheliutao,文章编写总结不易,转载注明出处,喜欢本篇文章的小伙伴欢迎点赞、关注,有问题可以评论区留言或者私信我,相互交流!!!
参考
- MySQL-Phantom Rows