ps:因为周末有事情,所以本周的踩坑总结提前发啦~
我们在开启多线程对数据库进行操作的时候,先批量对数据进行删除,然后再新增,本来想着是考虑到不走更新,性能会提升,但是执行的时候发现报错,执行的sql等待超时,阻塞了进程,dbcp连接池被打满,数据库表访问不可用。针对这个问题,我们进行了深入的挖掘,逐渐解开了问题的真相。
复现准备工作
业务问题这里就不细讲了,为了方便讲解,操作直接简化。
数据库表a有唯⼀索引t1,业务字段t2,主键id。数据库表a的定义如下:
现在导⼊⼀批数据A的集合,A的定义如下所示:
接下来复现问题操作
根据t1的值查询表a中有没有对应的记录
如果有值,则更新t2的值
如果没查到结果,则执行insert插入操作
这里批量操作我们采用了多线程的方式来执行
不走更新操作,先删除,后插入,保证只有2次数据库操作。
从业务的⻆度考虑,表中只有id,t1,t2字段,且客户端实际也只关⼼t2字段。因此决定先根据A的t1字段去数据库⾥将对应的值删全部除,然后进⾏批量插⼊操作,这样的话,⽆论更新还是插⼊,都只有两次数据库操作,提高了性能。为了避免插⼊失败,导致数据被删除,在handle⽅法上添加事务。
报错如下:
Deadlock found when trying to get lock; try restarting transaction; nested exception is
com.ibatis.common.jdbc.exception.NestedSQLException...
可以看到,发⽣了数据库死锁。于是开始排查问题
查询相关资料得知,引起死锁的原因是MYSQL的间隙锁。
间隙锁(Gap Lock)是Innodb在可重复读提交下为了解决幻读问题时引⼊的锁机制,幻读的问题存在是因为新增或者更新操作,这时如果进⾏范围查询的时候(加锁查询),会出现不⼀致的问题,这时使⽤不同的⾏锁已经没有办法满⾜要求,需要对⼀定范围内的数据进⾏加锁,间隙锁就是解决这类问题的。在可重复读隔离级别下,数据库是通过⾏锁和间隙锁共同组成的(next-key lock)来实现的。
⾏锁和间隙锁的定义如下所示:
record lock:⾏锁,也就是仅仅锁着单独的⼀⾏。
gap lock:间隙锁,仅仅锁住⼀个区间(注意这⾥的区间都是开区间,也就是不包括边界值)。
next-key lock:record lock+gap lock,所以next-key lock也就半开半闭区间,且是下界开,上界闭。
加锁规则特性
加锁规则有⼀些特性,其中我们需要关注的有:
加锁的基本单位是(next-key lock),他是前开后闭原则
查找过程中访问的对象会增加锁
间隙锁仅阻⽌其他事务插⼊间隙。在删除数据的时候,会去加间隙锁,但是多个事务是可以同时对⼀个间隙去加锁的,⽽如果需要对该间隙进⾏插⼊,则需要等待锁释放。
间隙锁复现死锁
下⾯可以做个实验,去复现死锁问题。
step1- ⾸先插⼊测试数据
表a数据初始化后,这时候那么可能的next-key lock的包括:
(⽆穷⼩, 10],(10,11],(11,13],(13,20],(20, ⽆穷⼤)
step2- 我们开启两个窗⼝去模拟死锁。
Session1:
Session2:
此时,Session 1和Session 2都会对区间(20, ⽆穷⼤)加锁, 因为间隙锁只是⽤来防⽌其他事务在区间中插⼊数据。
step3- Session1继续插⼊操作:
此时Session1阻塞(因为Session2持有间隙锁)。
step4- 紧接着Session2继续插⼊操作:
此时Session2死锁,因为Session1持有间隙锁。⽽我们的代码⾥⾯,因为涉及到多线程在事务⾥进⾏先删除后插⼊的操作,就会发⽣死锁。
1、将事务隔离级别将为read commit.
间隙锁只存在于可重复读的隔离级别下,因为要防⽌幻读。这个⽅法不现实,不可能为了这个问题
把整个线上数据库隔离级别给改掉。
2、避免先删除后插⼊的操作.
修改代码,避免先删除后插⼊的操作。牺牲性能,在业务中,先根据唯⼀索引查出存在的记录,然后对存在的记录进⾏根据主键Id在循环中更新,对于不存在的记录进⾏批量插⼊。