修复一个由并发操作导致的数据库死锁问题
摘要:在高并发系统中,数据库的并发控制是保证数据一致性和系统稳定性的核心。本文详细记录了一次因高并发下更新操作顺序不一致,导致的生产环境数据库频繁出现“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
往往只是问题的表象,其背后可能有两种深层原因:
长事务:一个事务长时间持有锁,导致其他需要相同资源的事务长时间等待,最终超时。
死锁(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])
事务A启动。
执行
UPDATE ... WHERE id=101
,成功,获取id=101的行锁。执行
UPDATE ... WHERE id=205
,尝试获取id=205的行锁,但此时该锁可能已被事务B持有,事务A进入等待。
请求B:同时调用
batchUpdateStatus([205, 101])
事务B启动。
执行
UPDATE ... WHERE id=205
,成功,获取id=205的行锁。执行
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从小到大的顺序来更新数据,从而请求行锁。这样一来,事务之间请求锁的方向永远是一致的,彻底消除了产生循环等待的可能性。
五、总结与反思
此次数据库死锁问题的排查与解决,带来了几点深刻的体会:
死锁是应用逻辑问题:绝大多数的死锁问题,其根源不在于数据库本身,而在于应用层代码的并发逻辑设计不当。因此,排查死锁必须深入到业务代码中。
统一加锁顺序是“金科玉律”:在需要对多条记录进行加锁操作的业务场景中,始终保持一致的加锁顺序是预防死锁最基本、也是最有效的原则。
善用数据库诊断工具:
SHOW ENGINE INNODB STATUS;
是诊断InnoDB存储引擎问题的强大工具。在遇到锁相关的疑难杂症时,它提供的详细信息是定位问题的关键线索。并发意识:在编写涉及数据库修改的业务代码时,必须时刻保持并发意识,思考在多线程/多进程环境下,代码的执行是否会产生竞态条件或资源争抢问题。