MySQL RC隔离级别惊现间隙锁:是bug吗?
在MySQL的默认事务隔离级别——读已提交(Read Committed, RC)中,开发者普遍认为不会出现间隙锁(Gap Lock)。这一认知源于RC级别的设计原则:仅通过行锁确保已提交数据的可见性,而将幻读问题交由应用层处理。然而,近期多个生产环境却报告了反常现象——RC级别下竟触发了Gap Lock!这不仅导致事务意外阻塞,更动摇了我们对隔离级别底层行为的理解。
问题来源
“mysql RC 隔离级别也会出现gap 锁吗?我们生产环境是RC 隔离级别,出现了gap 锁导致死锁咯 ”? 上一周同事突如其来的问题彻底把我问懵了。
其实一直以来我也是认为只有RR隔离级别才会出现的,甚至我找到曾有人就这个问题给mysql 团队提了个bug ,并且mysql 团队也认为是个bug。那为啥现在RC 也会出现,甚至RU 级别也会出现呢。。我来盘下到底是怎么回事
问题背景
事情经过是这样子的,上周我们生产环境报了个死锁的问题,是并发执行insert into ... on duplicate key update 的时候报的一个死锁。死锁的日志是这样子的
trx id 679250 lock_mode X locks gap before rec insert intention waiting
gap lock 阻塞了各自的的insert intention lock 。一眼看去这个是老生常谈的死锁现象了。慢着,我们生产环境是RC 隔离级别,一连串的疑问冒出来:RC 隔离级别怎么会出现gap lock 呢?gap lock 不是在RR隔离级别下为了解决幻读而存在的吗?直接颠覆了我的认知,不死心查几次生产环境的事务隔离级别确认都是RC 隔离级别。
难道我记错了吗?登录mysql官网,下面是官网的描述:
虽然说gap lock 可以通过改变为RC 进行禁用,但是依然在外键和唯一键的时候会用到gap lock。 um um 确实是官方说是会产生的,但是依然不明白为啥会用gap lock 呢?
网上找到一个网友跟我一样,因为这个问题曾经给mysql 提了一个bug bug73170
神奇的是mysql 当时还当成bug 修复了,但是导致二级索引的唯一键失效,又revert 掉这个fix bug 68021
似乎看起来,mysql 团队也曾经认为这是一个bug ,只是由于实现难度解决不了还是继续使用了gap lock
Anway 先重现死锁,再一步一步分析为啥唯一键的检查需要用到gap lock,为什么mysql 团队没有去掉它
问题重现
根据我同事提供的重现sql.
第一步:
CREATE TABLE `test2` ( `id` int(11) NOT NULL AUTO_INCREMENT, `code` int(11) NOT NULL, `other` int(11) DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `code` (`code`) ) ENGINE=InnoDB ; TRUNCATE table test2; insert ignore into test2 (id,code, other) values(1,1,1),(3,3,3),(5,5,5);
现在数据库的数据是这样子的
id | code | other |
---|---|---|
1 | 1 | 1 |
3 | 3 | 3 |
5 | 5 | 5 |
第二步session1
begin; insert into test2(code, other) values (3, 4) on duplicate key update other = VALUES(other); 数据库已经存在一个3的值,所以这里会冲突并且执行更新(这里的冲突很重要,是重现问题的关键)
第三步session2:
begin; insert into test2(code, other) values (5, 6) on duplicate key update other = VALUES(other); 数据库已经存在一个5的值,所以这里会冲突并且执行更新(这里的冲突很重要,是重现问题的关键)
以上正常执行,但是问题来了,当我在session2 继续插入(2,2)的时候,被锁阻塞住了,why? 数据库中有没有这条记录和我冲突,为啥要给我锁住,而且我是RC 隔离级别,可以允许幻读的存在,为啥给我加个gap lock?
session2: insert into test2(code, other) values (2, 2) on duplicate key update other = VALUES(other); 这个时候这个语句被阻塞了
第四步session1:
insert into test2(code, other) values (4, 4) on duplicate key update other = VALUES(other)
这个时候死锁产生了,是不是觉得很奇怪,不管是session 2 插入的(2,2)还是session1 插入的(4,4)他们在数据库中都不存在,理论上都应该可以正常执行,但实际上却阻塞了,这不是导致并发性能下降了吗?锁粒度过大了呢?
执行show engine innodb status; 看下死锁日志
从死锁日志可以看到插入意向锁是被对方的gap lock 阻塞住导致的死锁
问题分析
二级唯一索引的
俗话说的好,你要解决一个问题,你得先去了解它,我们看看二级索引的唯一键是怎么实现的
find the B-tree page in the secondary index you want to insert the value to assert the B-tree page is latched equal-range = the range of records in the secondary index which conflict with your value if(equal-range is not empty){ release the latches on the B-tree and start a new mini-transaction for each record in equal-range lock gap before it, and the record itself (this is what LOCK_S does) also lock the gap after the last(equal-range) also (before Bug #32617942 was fixed) lock the record after last(equal-range) once you are done with all of the above, find the B-tree page again and latch it again } insert the record into the page and release the latch on the B-tree page.
可以看到在二级唯一索引插入record 的时候, 分成了两个阶段
- 判断当前的物理记录上是否有冲突的record(delete mark 为不冲突)
- 如果没有冲突, 那么可以执行插入操作
这里在第一步 和 第二步 之间必须有锁来保证, 否则第一步 判断没有冲突可以插入的时候, 但是在第一步和第二步 之间另外一个事务插入了一个冲突的record, 那么第二步 再插入的时候其实是冲突了.
所以当前的实现如果gap 上存在至少一个相同的record(包括删除但是还没被回收的记录,因为删除只是做了个删除的墓碑标识,后面再回收), 那么需要给整个range 都加上gap X lock, 加了gap X lock 以后就可以禁止其他事务在这个gap 区间插入数据, 也就是通过lock 来保证第一步和第二步的原子性.
假设在code 这个唯一索引的数据是这样子的,总共有两个数据页,page1通过point 指针指向下一个数据页page2。红色带有delete mark 是代表这个数据已经被删除了,由于还没有给purge线程回收,因此还是在page 上,只是做了个删除的墓碑标识。绿色代表是正常的数据
现在我们有两个线程分别执行以下语句
sesson1: insert into test2(id,code, other) values (4,3, 4) on duplicate key update other = VALUES(other);
sesson2: insert into test2(id,code, other) values (12,3, 12) on duplicate key update other = VALUES(other);
session 1 执行第一个步判断code 唯一索引上找到有相同value 的记录 <3,3 delete mark>,<3,10 delete mark>,<3,11 delete mark>,<3,18 delete mark>,然后分别给他们加上next-key 锁。
最后还得再<5,5> 上增加gap 锁(假如这个5,5变成很大,那么意味着锁的gap 会非常大,影响并发性能),以防止<3,19>之后的数据被插入。到这一步是没有冲突的,因为这些值都是已经被删除的,插进去后不会违背唯一索引
如果在session 1 在第一阶段和第二阶段中间,session2 并发执行,那么第一阶段也会跟session 1 一样执行,都加上gap lock。因此后面他们两个人的插入都会失败,成功避免他们都成功来导致最终的数据不一致的问题。当然也就引入了死锁的
这个时候,你可能会说,如果我只是加数据上加锁S的行锁不是更好吗?这样就可以避免锁的范围太大,导致并发低下的问题。
session 1 执行第一个步判断code 唯一索引上找到有相同value 的记录 <3,3 delete mark>,<3,10 delete mark>,<3,11 delete mark>,<3,18 delete mark>,然后分别给他们加上s锁。
session 2 并发执行,也分别给他们加上s 锁,这个时候是不冲突的。他们两个都认为数据库没有这个记录,最后他们都插入成功。最终就违反了数据库的唯一键规则,这也就是mysql 团队修复了之前说bug 后带来的问题,因此有立马revert 了,直到今天都一直保留这个RC 隔离级别依然出现gap lock 的方式来保证唯一的正确性