一个关于insert ignore死锁问题的记录
近日在线上系统的使用过程中发现一个死锁问题,场景如下:
- 存在一批string,可重复性,乱序
- 并发将此批string插入同一张表,表中该字段有二级唯一索引,主键为自增id
- 需要防重,防重采用insert ignore语句结合唯一索引进行防重
- 并发线程池批次为数千,并发量为5
- 数据库为mysql,innodb引擎
发生死锁,死锁日志如下:
|
|
基本分析
根据日志所述:
- 事务1在执行的过程中需要获取锁:gap before rec insert intention
- 事务2需要获取的锁:gap before rec insert intention
根据观察进行问题重现时发现一个事实,而根据每个事务执行的SQL,每个事务中欲获取的锁对应的记录并不在其相应的插入值列表中,而每个事务却要获取一个与其相关的GAP INSERT INTENTION LOCK(插入意向间隔锁)。
还有一个有趣的事实,每个事务准备获取的间隔锁涉及的记录在另一个事务中SQL中的存在两天重复数据并相邻排序(例如,123,aaa,aaa,232),根据undo entry分析,涉及的双方记录都已经进行插入。(MYSQL批量插入在底层同样拆分为单个操作进行,这里由于使用insert ignore语句,因此重复的记录并不会引发报错)
查证
下文中有关的实验使用的表信息
123456CREATE TABLE `t_1` (`id` int(11) NOT NULL AUTO_INCREMENT,`name` varchar(20) DEFAULT NULL,PRIMARY KEY (`id`),UNIQUE KEY `name_index` (`name`)) ENGINE=InnoDB DEFAULT CHARSET=utf8;根据mysql的官方对于这块的解释,insert在唯一索引下会对欲插入的记录索引与上一个索引加插入意向间隔锁,在多个事务对有交叉的间隔中加间隔锁不会发生互相等待,除非多个事务欲插入的记录均为同一个记录,例如,当前数据表table(name varchar)中存在记录a,m, 当前索引表中同样是2个记录(a,m)。参考
表情况 | 事务1 | 事务2 | 备注 |
---|---|---|---|
a,m | 当前索引[a,m] | ||
a,m | insert e | OK 持有意向锁(a,e] 当前索引[a,e,m] | |
a,m | insert e | OK 持有意向锁(a,c],意向锁之间不冲突 当前索引[a,c,e,m] | |
a,m | insert c | wait 插入同一条记录 与索引c冲突,等待 |
以上处理事务均为手动提交模式(start transaction).同时印证,在事务未提交之前,SQL操作也会在底层上修改索引记录。
进一步查证
随后进一步查找资料求解释,终于在一篇文章当中有提及,参考,其中关于insert的加锁中有些阐述:
INSERT
的加锁:
- 插入之前,对插入的间隙加插入意向GAP锁 ;
插入意向GAP锁表明将向某个间隙插入记录,如果该间隙已被加上了GAP Lock或Next-Key Lock,则加锁失败。
不同事务加的插入意向GAP锁互相兼容,否则就无法并发insert了。- 插入成功后,对插入的这条记录加X Record Lock;
- 如果违反唯一性约束导致插入失败,则对记录加S Next-Key Lock。这一点在并发插入时可能导致死锁。
根据3与1的综合判断,基本得出结论,产生死锁的两个事务分别获得了一个区间的间隔锁,并在数据的插入过程中违反了唯一性约束(重复数据),导致区间被加上了next-key锁,之后分别向对方请求区间锁导致循环等待。关于next-key锁的介绍可以参见
此处后来分析有疑问,测试中发现貌似真正导致后一个事务加不上插入意向锁的原因是因为第一个事务违反唯一索引冲突后对该记录加了S行锁导致事务2加意向GAP锁时候冲突,因为区间锁是左开右闭(x1,x2],而如果在一个事务中再次执行一个插入(比如初始a,m,插入g之后插入e),此时a,e之间无法插入元素的原因是事务1对a,e之间加了S级GAP锁,可以通过如下SQL查询锁的信息
由于线上情景涉及数据较多,现在以最简单方式进行重现:
仍然使用之间提到的测试表t_1:
现有数据:
开启mysql会话控制台,启动两个事务,事务1与事务2:
事务1中进行:
由于采用insert ignore批量插入,且发生了违反唯一索引的情况,故插入一行记录,由于有ignore关键字,重复的索引自行忽略,语句没有报错,
但是,mysql仍然对第二个’e’进行了尝试,以至于产生了duplicates 1,根据上文分析,此时事务1对于区间(a,e]拥有next-key锁。其他事务无法立即对此区域内的任何记录进行插入。如图所示:
可以看到,事务2执行:
由于等待事务1而无法立即插入,在不干预的情况下最终timeout.
此时只有单向等待,事务2等待事务1,事务1并没有等待事务2,因此尚未构成死锁。接下来,构造一个环境让事务1等待事务2.
事务2执行:
同样,事务2插入成功一行,同时产生了一个duplicate.此时事务2拥有区间(e,g]的next-key锁。
最后一步:
事务1执行:
事务2执行:
事务1想要插入的 f
属于事务2持有next-key锁的区间(e,g]
,事务1等待事务2;
事务2想要插入的 b
属于事务1持有next-key锁的区间(a,e]
,事务2等待事务1。
循环等待,立即死锁,最后mysql回滚了事务2,执行了事务1。此时两个事务均未提交.
根据SHOW ENGINE INNODB STATUS
查看此次试验的死锁日志:
回顾一下
此次死锁的几个条件:
- 使用了insert ignore ,dao层检测到重复的唯一索引没有及时中断,导致事务持有了区间next-key锁。
- 欲插入的数据没有进行预处理,去重与排序
(1)去重不会发生duplicate,从而不会导致事务持有某个区间的next-key锁,
(2)排序之后事务之间请求的区间不会交叉,从另一方面规避了相互等待区间next-key锁 - 并发对同一张表进行insert操作(线程数>=2)
- 1,2,3最根本的原因是内因3,去除1可以防止但会影响功能,2只能某种程度规避问题并不能解决,因为当前数据库中的数据不确定。