afumu
afumu
发布于 2022-05-01 / 0 阅读
0
0

修复一个由并发操作导致的数据库死锁问题

修复一个由并发操作导致的数据库死锁问题

摘要:在高并发系统中,数据库的并发控制是保证数据一致性和系统稳定性的核心。本文详细记录了一次因高并发下更新操作顺序不一致,导致的生产环境数据库频繁出现“Lock wait timeout exceeded”(锁等待超时)异常的排查过程。通过对MySQL InnoDB引擎状态的深入分析,我们最终定位到问题根源为事务间的循环等待(死锁),并通过规范应用层的加锁顺序,彻底解决了该问题。

一、问题现象:偶发的“锁等待超时”

在一次版本上线后的高并发时段,我们的监控系统捕获到大量数据库异常日志,错误信息为:com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Lock wait timeout exceeded; try restarting transaction

这个错误指向了warn_baseline_result表的更新操作。从字面意思看,是某个事务在等待获取行锁时超过了innodb_lock_wait_timeout设定的阈值(默认为50秒),最终被数据库强制回滚。

问题的特点是:

  • 偶发性:并非所有请求都失败,只在系统负载较高时集中出现。

  • 非特定数据:错误并非由特定记录引发,而是随机发生在不同的数据行上。

这些特征表明,这并非一个简单的慢查询或索引失效问题,而是一个典型的并发竞争问题。

二、排查与分析:从超时到死锁的定位

Lock wait timeout exceeded 往往只是问题的表象,其背后可能有两种深层原因:

  1. 长事务:一个事务长时间持有锁,导致其他需要相同资源的事务长时间等待,最终超时。

  2. 死锁(Deadlock):两个或多个事务相互持有对方需要的锁,形成循环等待,谁也无法继续执行。InnoDB引擎会自动检测到死锁,并选择一个“代价”最小的事务作为牺牲品进行回滚,从而让其他事务得以继续。被回滚的事务,在应用层面看到的异常就是“锁等待超时”。

考虑到我们的更新事务逻辑非常简短,不太可能形成长事务,因此我们将排查重点放在了死锁 的可能性上。

为了证实这一点,我们使用了MySQL InnoDB引擎提供的诊断利器:SHOW ENGINE INNODB STATUS;

在问题复现时,执行该命令,我们在返回结果的LATEST DETECTED DEADLOCK 部分找到了确凿的证据(以下为根据当时情景简化的日志):

------------------------
LATEST DETECTED DEADLOCK
------------------------
2023-06-18 10:30:00 0x7f8c1c00
*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 5 sec
UPDATE `warn_baseline_result` SET `status`='fixed' WHERE `id`=101;
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 58 page no 4 n bits 72 index `PRIMARY` of table `db_name`.`warn_baseline_result` trx id 12345 lock_mode X locks rec but not gap waiting
*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 8 sec
UPDATE `warn_baseline_result` SET `status`='fixed' WHERE `id`=205;
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 58 page no 4 n bits 72 index `PRIMARY` of table `db_name`.`warn_baseline_result` trx id 12346 lock_mode X locks rec but not gap
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 58 page no 3 n bits 72 index `PRIMARY` of table `db_name`.`warn_baseline_result` trx id 12346 lock_mode X locks rec but not gap waiting
*** WE ROLL BACK TRANSACTION (1)

这段日志清晰地描述了一次死锁事件:

  • 事务(1)(ID: 12345) 正在执行UPDATE ... WHERE id=101,但它正在等待 获取另一行(可能是id=205)的X锁。

  • 事务(2)(ID: 12346) 已经持有了id=205行的锁,但它正在等待 获取id=101行的锁。

事务(1)等待事务(2),事务(2)又在等待事务(1),形成了经典的“循环等待”,即死锁。最终,InnoDB选择了事务(1)作为牺牲品进行了回滚。

三、根源剖析:不一致的加锁顺序

定位到死锁后,下一步就是分析应用代码,找出导致循环等待的业务逻辑。

经过对代码的深入分析,我们发现问题出在一个批量处理基线结果的业务方法中。该方法接收一个包含多个资产ID的列表,然后在一个事务内,循环遍历这个列表,逐个更新warn_baseline_result表中对应资产的状态。

问题代码逻辑(伪代码):

@Transactional
public void batchUpdateStatus(List<Long> assetIds) {
    // assetIds 的顺序是不确定的,取决于上游调用
    for (Long id : assetIds) {
        baselineResultMapper.updateStatusById(id, "fixed");
        // 这行SQL相当于: UPDATE warn_baseline_result SET status='fixed' WHERE id=?;
    }
}

当两个并发的请求调用了这个方法,并且传入的assetIds列表内容相同但顺序相反 时,死锁就发生了:

  • 请求A:调用batchUpdateStatus([101, 205])

    1. 事务A启动。

    2. 执行UPDATE ... WHERE id=101,成功,获取id=101的行锁

    3. 执行UPDATE ... WHERE id=205,尝试获取id=205的行锁,但此时该锁可能已被事务B持有,事务A进入等待

  • 请求B:同时调用batchUpdateStatus([205, 101])

    1. 事务B启动。

    2. 执行UPDATE ... WHERE id=205,成功,获取id=205的行锁

    3. 执行UPDATE ... WHERE id=101,尝试获取id=101的行锁,但该锁已被事务A持有,事务B进入等待

至此,事务A等待事务B释放id=205的锁,而事务B等待事务A释放id=101的锁,死锁形成。

四、解决方案:规范加锁顺序,消除循环等待

要从根本上解决死锁问题,必须破坏其产生的四个必要条件之一,最常用且有效的就是破坏“循环等待”条件。具体到这个场景,就是确保所有事务都以相同的、确定的顺序来请求资源(行锁)

我们的解决方案非常简单直接:在进入事务处理逻辑之前,对传入的assetIds 列表进行排序。

修复后的代码逻辑(伪代码):

@Transactional
public void batchUpdateStatus(List<Long> assetIds) {
    // 在处理前,对ID列表进行排序
    Collections.sort(assetIds);
    // 现在,所有并发的调用都将以相同的顺序(从小到大)请求行锁
    for (Long id : assetIds) {
        baselineResultMapper.updateStatusById(id, "fixed");
    }
}

通过增加一行Collections.sort(assetIds);,我们保证了无论上游传入的列表顺序如何,所有并发事务都将以ID从小到大的顺序来更新数据,从而请求行锁。这样一来,事务之间请求锁的方向永远是一致的,彻底消除了产生循环等待的可能性。

五、总结与反思

此次数据库死锁问题的排查与解决,带来了几点深刻的体会:

  1. 死锁是应用逻辑问题:绝大多数的死锁问题,其根源不在于数据库本身,而在于应用层代码的并发逻辑设计不当。因此,排查死锁必须深入到业务代码中。

  2. 统一加锁顺序是“金科玉律”:在需要对多条记录进行加锁操作的业务场景中,始终保持一致的加锁顺序是预防死锁最基本、也是最有效的原则。

  3. 善用数据库诊断工具SHOW ENGINE INNODB STATUS; 是诊断InnoDB存储引擎问题的强大工具。在遇到锁相关的疑难杂症时,它提供的详细信息是定位问题的关键线索。

  4. 并发意识:在编写涉及数据库修改的业务代码时,必须时刻保持并发意识,思考在多线程/多进程环境下,代码的执行是否会产生竞态条件或资源争抢问题。


评论