暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

MySQL版的DCL(Double Check Lock)导致死锁的案例分析(MySQL死锁日志解读)

Life of Coder 2020-10-17
422

一、前言

最近在项目中需要设计一个关于MySQL的selectOrInsert操作场景。于是为了提高操作性能,我便采用了DCL(Double Check Lock)的思想。系统上线运行了很长一段时间都没有问题,但偶然间却发现其抛出了死锁异常。接下来,我就和大家一起来分析下该场景,以及为什么会出现死锁。

二、场景说明

在实际项目中有这么一个场景,我们需要将第三方提供的某个字符串类型的ID,转换成一个全局唯一的数字类型的ID。于是当时设计了一张表t_id_mapper。其包含两个字段:自增类型的ID(数字类型),以及字符串类型的O_ID(Other ID)字段,该o_id字段为唯一索引。业务场景大致如下:

  1. 获取到三方字符串类型ID:strId

  2. 在数据库t_id_mapper中判断该strId是否存在

  3. 如果存在,则直接返回对应的数字类型id

  4. 如果不存在,则将strId插入到表t_id_mapper,并返回其对应的自增字段id

于是我写了类似如下的操作:

  1. 首先执行select * from t_id_mapper where o_id = :strId

  2. 如果存在则直接返回对应的ID字段。

  3. 如果不存在则执行select * from t_id_mapper where o_id = :strId for update(利用for update加锁, 此时不提交事务)

  4. 然后再次判断记录是否存在,如果存在则返回对应ID字段。

  5. 如果不存在,则执行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. LATEST DETECTED DEADLOCK

  3. ------------------------

  4. 200526 17:49:17

  5. *** (1) TRANSACTION:

  6. TRANSACTION 7892ECEC4, ACTIVE 50 sec inserting

  7. mysql tables in use 1, locked 1

  8. LOCK WAIT 4 lock struct(s), heap size 1248, 2 row lock(s), undo log entries 1

  9. MySQL thread id 41920347, OS thread handle 0x7f8fe5598700, query id 26349859046 10.174.41.70 panda_rw update

  10. insert into  

  11.         tm_***_center

  12.     ( 

  13.     id, template_id, template_type, modify_time, create_time

  14.    ) 

  15.       values(null, '***', 2, now(), now())

  16. *** (1) WAITING FOR THIS LOCK TO BE GRANTED:

  17. RECORD LOCKS space id 1326 page no 4 n bits 256 index `UNIQ_IDX_TEMPLATE` of table `panda`.`tm_***_center` trx id 7892ECEC4 lock_mode X locks gap before rec insert intention waiting

  18. Record lock, heap no 168 PHYSICAL RECORD: n_fields 2; compact format; info bits 0

  19.  0: len 30; hex 393064383166343162336330343833343930353432383962313863663932; asc 90d81f41b3c048349054289b18cf92; (total 32 bytes);

  20.  1: len 4; hex 000000f5; asc ;;


  21. *** (2) TRANSACTION:

  22. TRANSACTION 7892F00C6, ACTIVE 35 sec inserting, thread declared inside InnoDB 500

  23. mysql tables in use 1, locked 1

  24. 4 lock struct(s), heap size 1248, 2 row lock(s), undo log entries 1

  25. MySQL thread id 41920345, OS thread handle 0x7f8ff7041700, query id 26349859047 10.174.41.70 panda_rw update

  26. insert into  

  27.         tm_***_center

  28.     ( 

  29.     id, template_id, template_type, modify_time, create_time

  30.    ) 

  31.       values(null, '***', 2, now(), now())

  32. *** (2) HOLDS THE LOCK(S):

  33. RECORD LOCKS space id 1326 page no 4 n bits 256 index `UNIQ_IDX_TEMPLATE` of table `panda`.`tm_***_center` trx id 7892F00C6 lock_mode X locks gap before rec

  34. Record lock, heap no 168 PHYSICAL RECORD: n_fields 2; compact format; info bits 0

  35.  0: len 30; hex 393064383166343162336330343833343930353432383962313863663932; asc 90d81f41b3c048349054289b18cf92; (total 32 bytes);

  36.  1: len 4; hex 000000f5; asc ;;


  37. *** (2) WAITING FOR THIS LOCK TO BE GRANTED:

  38. RECORD LOCKS space id 1326 page no 4 n bits 256 index `UNIQ_IDX_TEMPLATE` of table `panda`.`tm_***_center` trx id 7892F00C6 lock_mode X locks gap before rec insert intention waiting

  39. Record lock, heap no 168 PHYSICAL RECORD: n_fields 2; compact format; info bits 0

  40.  0: len 30; hex 393064383166343162336330343833343930353432383962313863663932; asc 90d81f41b3c048349054289b18cf92; (total 32 bytes);

  41.  1: len 4; hex 000000f5; asc ;;


  42. *** WE ROLL BACK TRANSACTION (2)


通过死锁日志我们可以看到,事务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场景会出现死锁。下面我们详细解说每个时间点的执行操作。

  1. 首先t1和t2时间点都为无锁查询(即快照读),所以肯定是可以执行的。

  2. t3的时候,session1加锁查询记录,由于记录不存在,此时会加间隙锁。

  3. t4的时候,session2加锁查询,也会由于记录不存在而加间隙锁。t3的时候已经加了间隙锁了,此时为何能加锁成功呢?因为根据上面的知识点我们知道,间隙锁和间隙之间是互相兼容的。

  4. t5的时候,session1执行插入操作,需要加插入意向锁。由于插入意向锁和已经存在的间隙锁互斥,所以此时不能加锁成功,session1必须等待。

  5. 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的视频和图文资料哦)。


文章转载自Life of Coder,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论