你真的懂幻读吗?

本文深入探讨了在可重复读隔离级别下,幻读产生的原因及其对数据一致性的影响。幻读发生于当前读查询时,读取到了其他事务新插入的数据。为解决幻读,MySQL采用了间隙锁机制,锁定数据行间的间隙以防止插入,但这也可能导致死锁和性能下降。文章通过实例详细解释了间隙锁的工作原理和避免幻读的策略。
摘要由CSDN通过智能技术生成

1.什么是幻读:

在可重复读的数据隔离级别下,在同一个事务中,使用当前读,读到了其他事务新插入的数据的现象叫做幻读。这里有几个定语:可重复读的事务隔离级别,当前读,插入的新数据。只有在这些定语的约束下​才能形成幻读。​

我们知道可重复读的数据隔离级别下,一个事务无法查询到另外一个事务对数据表进行的变更,不过,这个结论的前提是,这个查询是一个普通查询,也就是快照读,如果查询的方式是当前读(select * from table for update),那么就可以查询到另外一个事务的变更结果的。但是"幻读"的定义,对"变更"又做了更严格的限制,幻读,只仅仅针对 "insert" 的变更,而对 "update" 的变更,虽然查询也可以感知到,但这不会被称为幻读。虽然这里说的有点啰嗦,但是幻读的定义就是那么严格,所以我要多强调一遍。

2.幻读为什么会产生:

在讨论幻读产生前,我们先来讨论一个加锁的问题。为了方便讨论我们先建立如下表结构:

CREATE TABLE `game_test` (
  `id` int(11) not null,
  `owner` int(11) not null comment '游戏所属者',
  `usercnt` int(11) not null comment '游戏玩家数',
   key `owner` (`owner`)
) ENGINE=InnoDB

insert into game_test values (0,0,0),(4,4,4);

建立了一个表名为 game_test 的表,表中有三个字段,主键id,索引字段 owner,以及一个普通字段 usercnt。同时插入两行数据(0,0,0)和(4,4,4);

当我们执行如下语句:

begin
select* from game_test where usercnt = 0 for update
commit

当前两条语句执行完毕,第三条语句执行前,我们知道带有 for update 的查询语句属于当前读,会对 usercnt=0 这一行加写锁,但是由于usercnt不是索引字段,该条语句为了获取 所有usercnt=0 的数据行,需要进行全表扫描。那问题来了,这条“当前读”的查询,除了会对 usercnt=0 的这一数据行,添加行锁,会对扫描过的其他数据行添加行锁吗?

其实这个问题,很简单。你可以直接在mysql中验证一下,结论是:会对全表中所有数据行添加写锁。

结论不重要,重要的是为什么加锁的粒度会这么大?关于这个问题,我们可以采用反证法的方式来验证。

假如说

select * from game_test where usercnt = 0 for update

这条语句只会对 usercnt = 0 的数据行加锁,会产生哪些问题呢?

1."只锁usecnt=0的数据行"的语义被破坏

按照假设 "select * from game_test where usercnt = 0 for update" 只会锁 usecnt=0 的数据行,如果按照这个假设话,假设本身都是不成立的。
试想这样一个场景:

 

sessionA 在 t1时刻 执行 

begin
select * from game_test where usercnt = 0 for update
update game_test set usercnt = 100 where usercnt=0

sessionB 在t2时刻 执行

update game_test set usercnt = 0 where id = 4
update game_test set owner = 0 where id = 4

sessionA 在t3时刻 执行 commit 完成进行事务的提交。
按照假设 select * from game_test where usercnt=0 for update "只会锁 usercnt=0 的数据行",因为在t2时刻,更新的是 id=4 数据行,所以不会加锁,但是t2时刻执行第二条语句时,id=4 的数据行,usercnt已经为0了,所以 假设 select * from game_test where usercnt = 0 for update 只会锁 usecnt=0 的数据行本身就不成立。

2.数据不一致

按照上文中设想的场景,如果只会锁 usercnt=0的数据行,那么 sessionB 在t2时刻执行的语句可以顺利执行,那么当 sessionA在t3时刻将事务提交之后表中的数据应该是:

0    0    100
4    0    0

接下来,我们看一下,两个session执行完后,生成的binlog的内容:
sessionB在t2时刻生成了两条日志:

update game_test set usercnt = 0 where id = 4
update game_test set owner = 0 where id = 4

sessionA在t3时刻生成了一条日志

update game_test set usercnt = 100 where usercnt=0

按照binlog将game_test同步到备库中,在备库中生成的数据如下:

0    0    100
4    0    100

最终结果:同步到备库中的数据和在主库中的数据是不一致的。
产生不一致的原因在于:在"假设select * from game_test where usercnt = 0 for update 只会锁 usecnt=0 的数据行"的情况下,两个 session 中执行sql语句的顺序 和生成binlong的日志的顺序是不一致的。

因为在sessionA中,binlog是在t3时刻生成的,也就是在sessionB的t2时刻后。而sessionA中更新语句的执行,是在t1时刻的,也就是在sessionB的t2时刻前。最终导致了数据的不一致。
因此上面的假设,是不成立,也是自向矛盾的,而如果 执行 select * from game_test where usercnt = 0 for update 会锁住该sql语句扫描到的数据行(因为usercnt不是索引字段,该sql会扫描表中所有行),就可以避免这个问题。
如下图所示:

sessionB在t2时刻,执行第一条语句时,因为无法获取id=4数据行的行锁而被阻塞,只有等到 sessionA中t3时刻,事务提交并释放锁后,才能获取到写锁,然后sql语句得以执行。
这样可以保证,sessionB中语句的执行和生成binlog的顺序都在 sessionA中t3时刻之后,从而保证了数据的一致性。

"select * from game_test where usercnt = 0 for update",会将全表中所有数据行都会锁住,这种锁全表数据行的操作,会很大程度上降低数据库的读写性能。但是即使这样大粒度的锁,仍然无法避免出现幻读的现象(结合"幻读"的定义,你可以先想一下,为什么锁住全表的数据行,仍然无法避免"幻读")。

上面说了那么多,接下来正式引入幻读,设想下面一个场景:

sessionA 在 t1时刻 执行 

begin
select * from game_test where usercnt = 0 for update
update game_test set usercnt = 100 where usercnt=0

sessionB 在t2时刻 执行

insert into game_test values (6,6,0);

sessionA 在t3时刻 执行

select * from game_test where usercnt = 0 for update
commit 

在t1时刻,执行完第一条语句后,查询的结果只有一条

0 0 0

该条语句执行完之后,整个表中的数据行都会加上行锁,无法对表中的任何数据行进行写操作,
但是t2时刻的插入操作却可以顺利执行,因为sessionA加锁的时候,t2时刻需要插入数据行还不存在,自然不会被锁住。

在sessionA中t3时刻的查询语句返回的结果

0 0 100
6 6 0

到这里,幻读就产生了。

3.幻读产生的影响

当幻读的现象产生后,也会带来数据不一致的问题。
我们可以分析一下sessionA和sessionB执行完之后数据表中数据结果是:

0 0 100
6 6 0

而生成的binlog是怎样的呢?

sessionC 在t2时刻生成一条日志

insert into game_test values (6,6,0)

sessionA 在t3时刻生成一条日志

update game_test set usercnt = 100 where usercnt=0

按照binlog将game_test同步到备库中,在备库中生成的数据如下:

0    0    100
6    6    100

也就是,同步到备库中的数据和在主库中的数据是不一致的。产生不一致的原因和上面 "假设select * from game_test where usercnt = 0 for update 只会锁 usecnt=0 的数据行" 产生不一致的原因相同:两个 session 中执行sql语句的顺序 和生成binlong的日志的顺序是不一致的。

要想保证生成binlog的日志顺序和session中sql语句执行顺序一致,sessionB中sql语句的执行顺序要在sessionA事务提交之后,也就是"增强" select * from game_test where usercnt = 0 for update 的加锁粒度,使该语句加的锁,可以阻塞sessionB中t2时刻插入语句的执行,只有sessionA在t3时刻把锁释放后,sessionB才能执行。这样就能保证 sessionB中t2时刻的插入语句,在sessionA中t3时刻后执行。

4.怎样避免幻读

为了避免幻读的现象,mysql对 "select * from game_test where usercnt = 0 for update" 语句的加锁粒度进行了增强,除了会把表中的所有数据行锁住外,还会对各个数据行之间的 "间隙" 进行加锁,对"间隙"进行加锁,就是为了防止向"间隙"中插入数据,从而来避免幻读的问题。这个防止向间隙中插入数据的锁我们称之为间隙锁

间隙锁:

需要注意的是,多个间隙锁之间并不会产生冲突,和间隙锁存在冲突的是向这个间隙中插入数据的"操作"。

如下图所示:

这里sessionB不会被锁,因为表game_test没有owner=2的数据,所以sessionA只会增加间隙锁(0,4),而sessionB也是同样操作,也是增加了一个间隙锁(0,4),这两个间隙锁都是为了保护这个间隙,防止有数据插入到这个间隙中,所以两者不会冲突。

但是如果执行的sql语句是这样的话:

sessionB会被锁住。因为表game_test中存在owner=4的数据,sessionA产生间隙锁为(0,4]和(4,+supremum)也就是除了(0,4)和(4,+supremum)之外,还会在索引 owner=4的这个行上增加行锁,而sessionB同样也会给索引c增加 (0,4)和(4,+supremum)的间隙锁,也会在 owner=4的这个行上增加行锁,所以 两个session在 owner=4的这一行上增加行锁产生冲突。

间隙锁产生死锁问题

虽然两个session的产生的间隙锁不会产生冲突,但是各自的间隙锁会与对方的插入操作产生冲突,最终导致死锁。如下的示例:


sessionA在t1时刻 执行 select * from game_test where owner=4 for update  产生间隙锁 (0,4]
sessionB在t2时刻 执行 select * from game_test where owner=4  会先产生 (0,4)的间隙锁,然后添加 owner=4的 行锁时被阻塞。
sessionA在t3时刻 执行 insert into game_test values(3,3,3) 会被 sessionB产生的 (0,4)间隙锁阻塞,此时就是产生死锁。
不过mysql 会很快检测到这个 死锁,然后让sessionB释放其持有的间隙锁,让sessionA得到执行。

总结一下:

幻读是可重复读事务隔离级别下,才会产生的一种现象,间隙锁是为了解决这一现象的一个锁增强手段,间隙锁虽然解决了幻读的现象,但是带了的代价,就是增加了锁的粒度,降低了数据库的写操作的并发,同时增大了死锁的可能性。尤其是对where条件不是索引字段的update或者delete语句,会将这个表的所有数据行全部锁住,同时,对该表的所有间隙增加间隙锁,极大的降低了该表的读写并发。本篇文章,我给你介绍了,幻读产生的原因,幻读产生的影响,以及mysql对幻读问题的解决方案。希望看完本篇文章能给你带来帮助。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值