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

本文分析了一个在MySQL中使用DCL(DoubleCheckLock)思想处理selectOrInsert操作导致死锁的场景。问题源于在无记录时,两个并发事务分别加锁查询并尝试插入,因插入意向锁与间隙锁互斥而引发死锁。解决方案是避免使用加锁查询,改为直接尝试插入,捕获DuplicateKey异常后再进行无锁查询。
摘要由CSDN通过智能技术生成

一、前言

最近在项目中需要设计一个关于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处拿到了如下死锁日志。

------------------------
LATEST DETECTED DEADLOCK
------------------------
200526 17:49:17
*** (1) TRANSACTION:
TRANSACTION 7892ECEC4, ACTIVE 50 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 1248, 2 row lock(s), undo log entries 1
MySQL thread id 41920347, OS thread handle 0x7f8fe5598700, query id 26349859046 10.174.41.70 panda_rw update
insert into  
        tm_***_center
    ( 
    id, template_id, template_type, modify_time, create_time
   ) 
      values(null, '***', 2, now(), now())
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
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
Record lock, heap no 168 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
 0: len 30; hex 393064383166343162336330343833343930353432383962313863663932; asc 90d81f41b3c048349054289b18cf92; (total 32 bytes);
 1: len 4; hex 000000f5; asc ;;

*** (2) TRANSACTION:
TRANSACTION 7892F00C6, ACTIVE 35 sec inserting, thread declared inside InnoDB 500
mysql tables in use 1, locked 1
4 lock struct(s), heap size 1248, 2 row lock(s), undo log entries 1
MySQL thread id 41920345, OS thread handle 0x7f8ff7041700, query id 26349859047 10.174.41.70 panda_rw update
insert into  
        tm_***_center
    ( 
    id, template_id, template_type, modify_time, create_time
   ) 
      values(null, '***', 2, now(), now())
*** (2) HOLDS THE LOCK(S):
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
Record lock, heap no 168 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
 0: len 30; hex 393064383166343162336330343833343930353432383962313863663932; asc 90d81f41b3c048349054289b18cf92; (total 32 bytes);
 1: len 4; hex 000000f5; asc ;;

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
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
Record lock, heap no 168 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
 0: len 30; hex 393064383166343162336330343833343930353432383962313863663932; asc 90d81f41b3c048349054289b18cf92; (total 32 bytes);
 1: len 4; hex 000000f5; asc ;;

*** 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;
}

五、惯例

如果你对本文有任何疑问或者高见,欢迎添加公众号共同交流探讨(添加公众号可以获得”Java高级架构“上10G的视频和图文资料哦)。

 

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

跳小闹成长记-跳爸

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值