记一次工作中数据库死锁问题定位与解决
最近在工作中遇到了几次类似的数据库死锁的问题,通过比对死锁日志文件和代码,最终分析出了死锁的原因,并将之解决,因此写下此文作简单的经验记录。
一、问题背景
最近在工作中遇到了几次类似的数据库死锁的问题,比如这条告警,提示在更新某张表的时候出现了死锁。
为了定位和解决这个问题,于是找 DBA 要死锁日志进行分析。在此之前,先回顾什么是死锁,死锁是怎么产生的,以及可以怎么解决。
二、快速认识死锁
在操作系统中,如果系统中只有一个进程,当然不会产生死锁。如果每个进程仅需求一种系统资源,也不会产生死锁。不过这只是理想状态,在现实中是可遇不可求的。
死锁的四个条件是:
- 禁止抢占(no preemption):系统资源不能被强制从一个进程中退出。
- 持有和等待(hold and wait):一个进程可以在等待时持有系统资源。
- 互斥(mutual exclusion):资源只能同时分配给一个行程,无法多个行程共享。
- 循环等待(circular waiting):一系列进程互相持有其他进程所需要的资源。
死锁只有在四个条件同时满足时发生,预防死锁必须至少破坏其中一项。在数据库中,可以认为产生死锁是由于不同的线程间竞争资源且满足类似上述的四个条件。
三、死锁日志分析
为了不提及公司相关信息,我对场景进行了迁移和还原。在一个仓储自动化系统中,为了提升发货效率,很基本地,出库作业会主要应用集分思想,即多个订单集中在一个作业区域拣货,然后再按照订单或包裹等其他维度进行分发,而多个订单一起作业期间往往会设置一个作业号,用于标记整个作业流程,当然子作业流程也会有相应的标识。另外,作业期间涉及很多作业容器,比如拣集到的商品会放到一个箱子里面去,然后分发时再从这个箱子里面的商品分到多个不同的小箱子里面去,然后再进行后续的打包发货流程,如果想深入了解可以查阅相关资料。我们假设有这么两张表:
|
|
在这个已知条件下我们开始分析死锁文件,首先看到有这么一个事务,事务 ID 是 24680,它在等待 pick_box
表上的一行记录的释放:
|
|
另外有一个事务,事务 ID 是 24679,它持有了 pick_box
上的主键锁,同时它又尝试通过全局作业流水号去把整个作业流水下的分发箱全部更新成完成,但是发现有记录被锁住了,需要授权(GRANTED)拿到锁才能进行操作。
|
|
现在问题大致清晰了,上述死锁产生的场景很可能如下面的时序图所示:
事务 1(24680) | 事务 2(24679) | 备注 |
---|---|---|
(1) ? | 事务 2 持有 pick_box 的主键锁 |
|
(2)? | 根据产生死锁的四个必要条件,事务 1 必然锁住了 dispersal_box 的某些数据 |
|
(3)UPDATE pick_box SET dispersal_num = dispersal_num + 1 WHERE id = 1 AND is_delete = 0; |
事务 1 等待事务 2 加在 pick_box 上 id 为 1 的记录的行锁释放 |
|
(4) UPDATE dispersal_box SET dispersal_box_status = 2, dispersal_end_time = now() WHERE (dispersal_box_status = 1 AND global_flow_code = 'GFC2021040501' AND is_delete = 0); |
事务 2 等待事务 1 在 dispersal_box 上加的锁,成环,死锁产生 |
根据产生死锁的四个必要条件,现在需要确认(1)和(2)处的 SQL 执行了什么操作,初步估计
- (1)处事务 2 根据主键对
pick_box
表进行更新并拿到锁; - (2)处事务 1 根据主键对
dispersal_box
表进行更新并拿到锁; - (3)处事务 2 尝试通过波次号更新
dispersal_box
的多条记录,但其中某条记录在(2)处被事务 1 锁住,所以事务 2 开始等待事务 1 释放锁; - (4)处事务 1 尝试获取
pick_box
的主键锁,但是(1)处已经锁住了其中的某条记录,所以事务 1 开始等待事务 2 释放锁,于是陷入死锁。
四、代码定位
根据上述的分析和告警的日志记录的 trace 信息,我发现下面这两个事务的这两行代码的嫌疑很大,为了不泄露公司代码,只给出大概的伪码,其中一个事务(很可能就是上述的事务 1)根据 pickBoxId
去更新 pick_box 这张表,同时前面有更新 dispersal_box
的操作。
|
|
另外一个事务则先更新 pick_box
的数据,然后根据全局作业流水号去更新 dispersal_box
,这样看这个事务很可能就是事务 2。
|
|
结合代码和死锁文件,现在已经很清晰了,上面的时序图可以填完完整了:
事务 1(24680) | 事务 2(24679) | 备注 |
---|---|---|
(1)UPDATE pick_box SET pick_box_status = 4 AND dispersal_end_time = now() AND is_done = 1 WHERE id = 1 AND is_delete = 0 AND pick_box_status = 3; |
事务 2 持有 pick_box 的主键锁 |
|
(2)UPDATE dispersal_box SET dispersal_num = dispersal_num + 1 WHERE id = 2; |
根据产生死锁的四个必要条件,事务 1 必然锁住了 dispersal_box 的某些数据 |
|
(3)UPDATE pick_box SET dispersal_num = dispersal_num + 1 WHERE id = 1 AND is_delete = 0; |
事务 1 等待事务 2 加在 pick_box 上 id 为 1 的记录的行锁释放 |
|
(4) UPDATE dispersal_box SET dispersal_box_status = 2, dispersal_end_time = now() WHERE (dispersal_box_status = 1 AND global_flow_code = 'GFC2021040501' AND is_delete = 0); |
事务 2 等待事务 1 在 dispersal_box 上加的锁,成环,死锁产生 |
五、场景复现
尽管如此,上面的场景也仅仅是我的猜测而已,那么实际上这样是不是真的会出现死锁呢?百看不如一干,我们不妨来造个数据复现一下:
|
|
通过命令 SET autocommit=0;
开启手动提交事务:然后开启两个连接进行测试,连接 1 开启事务,按顺序执行下面的 SQL,执行完第三步,先不提交。
|
|
连接 2 开启事务,按顺序执行下面的 SQL 语句:
|
|
执行完第四步,死锁复现。
六、问题解决
说到底就是破坏死锁的四个必要条件中的一个,首先想到的是可以对事务中更新数据的顺序进行调整,比如将事务 1 的(2)和(3)处调换顺序,那么如果事务 1 先执行,那么事务 2 的(1)处将需要等待(2)处的锁释放才能进行(4)处的操作;同理如果事务 2 先执行,那么事务 1 也需要先等待事务 2 执行完(1)才能进行(3)处的操作,这就相当于破除了死锁的四个必要条件中的 “持有和等待” 以及 “循环等待” 这两点。
事务 1(24680) | 事务 2(24679) | 备注 |
---|---|---|
(1)UPDATE pick_box SET pick_box_status = 4 AND dispersal_end_time = now() AND is_done = 1 WHERE id = 1 AND is_delete = 0 AND pick_box_status = 3; |
事务 2 持有 pick_box 的主键锁 |
|
(2)UPDATE pick_box SET dispersal_num = dispersal_num + 1 WHERE id = 1 AND is_delete = 0; |
根据产生死锁的四个必要条件,事务 1 必然锁住了 dispersal_box 的某些数据 |
|
(3)UPDATE dispersal_box SET dispersal_num = dispersal_num + 1 WHERE id = 2; |
事务 1 等待事务 2 加在 pick_box 上 id 为 1 的记录的行锁释放 |
|
(4) UPDATE dispersal_box SET dispersal_box_status = 2, dispersal_end_time = now() WHERE (dispersal_box_status = 1 AND global_flow_code = 'GFC2021040501' AND is_delete = 0); |
事务 2 等待事务 1 在 dispersal_box 上加的锁,成环,死锁产生 |
另外的方法是可以与产品协商是否可以在程序中加分布式锁控制,同一时刻只能允许上述两个业务中的一个进行操作,这样其实相当于在一个事务中把两个资源作为一个进行操作,而且事务不并发。