社区所有版块导航
Python
python开源   Django   Python   DjangoApp   pycharm  
DATA
docker   Elasticsearch  
aigc
aigc   chatgpt  
WEB开发
linux   MongoDB   Redis   DATABASE   NGINX   其他Web框架   web工具   zookeeper   tornado   NoSql   Bootstrap   js   peewee   Git   bottle   IE   MQ   Jquery  
机器学习
机器学习算法  
Python88.com
反馈   公告   社区推广  
产品
短视频  
印度
印度  
Py学习  »  DATABASE

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

Life_of_Coder • 2 年前 • 233 次点击  
阅读 62

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

这是我参与8月更文挑战的第17天,活动详情查看:8月更文挑战

一、前言

最近在项目中需要设计一个关于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的视频和图文资料哦)。

Python社区是高质量的Python/Django开发社区
本文地址:http://www.python88.com/topic/118717
 
233 次点击