一、前言
最近在项目中需要设计一个关于MySQL的selectOrInsert操作场景。于是为了提高操作性能,我便采用了DCL(Double Check Lock)的思想。系统上线运行了很长一段时间都没有问题,但偶然间却发现其抛出了死锁异常。接下来,我就和大家一起来分析下该场景,以及为什么会出现死锁。
二、场景说明
在实际项目中有这么一个场景,我们需要将第三方提供的某个字符串类型的ID,转换成一个全局唯一的数字类型的ID。于是当时设计了一张表t_id_mapper。其包含两个字段:自增类型的ID(数字类型),以及字符串类型的O_ID(Other ID)字段,该o_id字段为唯一索引。业务场景大致如下:
获取到三方字符串类型ID:strId
在数据库t_id_mapper中判断该strId是否存在
如果存在,则直接返回对应的数字类型id
如果不存在,则将strId插入到表t_id_mapper,并返回其对应的自增字段id
于是我写了类似如下的操作:
首先执行select * from t_id_mapper where o_id = :strId
如果存在则直接返回对应的ID字段。
如果不存在则执行select * from t_id_mapper where o_id = :strId for update(利用for update加锁, 此时不提交事务)
然后再次判断记录是否存在,如果存在则返回对应ID字段。
如果不存在,则执行insert into 语句向t_id_mapper中插入strId内容,然后提交事务。并返回本次自增序列ID,该ID即为strId对应的数字类型ID。
上述操作的伪代码如下:
public int selectOrInsert(String strId) {id = doSelect(strId);//无锁查询if (null == id) {transaction {//开启事务id = doSelectForUpdate(strId); //加锁查询if (id == null) {id = doInsert(strId); //执行插入}}//提交事务}return id;}
整个MySQL版本的DCL流程如下:
整个流程看看起来并没有任何问题,且也完全符合DCL的思想,第一次无锁判断。第二次加锁判断,然后再持有锁期间(事务内)完成insert操作,防止了并发写入。
但是运行一段时间后,程序却在图中insert的位置抛出了死锁异常。

由此可见,这个DCL模式在MySQL里面应该有什么不合适的地方。下面让我们来进一步分析具体原因。
三、过程分析
1、死锁日志分析
遇到死锁异常,我们首先应该想到的就是去查看死锁日志。于是,我们首先从DBA处拿到了如下死锁日志。
通过死锁日志我们可以看到,事务1在等待主键上的插入意向锁。

事务2则持有一个间隙锁:

同时session2需要等待获取插入意向锁:

在分析上面场景出现的原因之前我们首先要明确下面几个知识点:
2、MySQL锁相关只是点
a、MySQL中各种锁之间的兼容关系
通过下表我们可以知道,当一个位置被加了间隙锁之后,该区域还可以继续加间隙锁和next-key锁,但是不能加插入意向锁。
即间隙锁和间隙锁与next-key锁之间兼容,但是间隙锁和插入意向锁之间互斥。

b、for update的基本加锁机制
假设id是唯一主键,则select * from update where id = 3,如果id=3这条记录存在,则这条语句只会锁住这条记录,即加记录锁。
如果id=3这条记录不存在,则会在加一个间隙锁。比如数据库只存在id=1和id=6的记录,则此时会见间隙锁(1,6)。
PS:这里我们只是列举了部分for update的加锁场景,for update语句还有很多其他的加锁场景。
3、死锁重现
接下来,我们继续分析上述问题。在知道上诉两个知识点之后,那么如果我们在查询一个全新的strId的时候(即表中不存在),此时就和上述我们讨论的for update查询记录不存在的场景一样,即其会加间隙锁。因此我们可以将上述问题整理成如下执行流程。

通过上述表格,我们就可以清晰的看出为什么我们这种DCL场景会出现死锁。下面我们详细解说每个时间点的执行操作。
首先t1和t2时间点都为无锁查询(即快照读),所以肯定是可以执行的。
t3的时候,session1加锁查询记录,由于记录不存在,此时会加间隙锁。
t4的时候,session2加锁查询,也会由于记录不存在而加间隙锁。t3的时候已经加了间隙锁了,此时为何能加锁成功呢?因为根据上面的知识点我们知道,间隙锁和间隙之间是互相兼容的。
t5的时候,session1执行插入操作,需要加插入意向锁。由于插入意向锁和已经存在的间隙锁互斥,所以此时不能加锁成功,session1必须等待。
t6的时候,session2执行插入操作,也需要加插入意向锁,同样由于该锁和已存在的间隙锁互斥,所以此时必须等待。此时变出现了互相等待(循环依赖)的场景,即session1持有间隙锁,等到插入意向锁,session2也持有间隙锁,等到插入意向锁。因此出现了session1和session2互相等待的场景,即死锁。在这种场景下,MySQL会抛出死锁异常,并选择一个事务进行回滚。
到此,我们就了解了为什么MySQL中DCL的模式会导致死锁了。
四、解决方法
其实通过上述的分析我们可以看出,在这种场景下,我们无法使用DCL模式。我们可以不使用加锁的方式执行这个selectOrInsert场景。即首先执行无锁的select查询,如果发现无记录,则执行执行insert操作。由于o_id字段是唯一索引,所以如果出现冲突,后执行insert操作的线程则会抛出DuplicateKey异常。当我们得到DuplicateKey异常之后,我们再重新执行无锁查询即可查询到已经存在的o_id对应的数字类型的ID(由另外一个线程插入的)了。对应的伪代码如下:
public int selectOrInsert(String strId) {id = doSelect(strId); //无锁查询if (id == null) {try {id = doInsert(strId); //直接插入}catch (DuplicateKey e) {id = doSelect(strId); //重复key异常后,再次无锁查询}}return id;}
五、惯例
如果你喜欢这篇文章,请点击右下角的“在看”图标支持下。
如果你对本文有任何疑问或者高见,欢迎添加公众号"Life of Coder"共同交流探讨(添加公众号可以获得”Java高级架构“上10G的视频和图文资料哦)。





