在之前的一次开发需求中使用了 for update 实现悲观锁,最后导致出现了很多的 MySQL 死锁报警,现记录下死锁产生的原因。
为什么使用 for update
业务中需要维护数据状态(例如进行中、失败、成功),但是这个状态是通过多条子任务最终的结果决定的,场景如下
- 如果记录结果有一个失败的,这个任务就是失败的
- 如果记录都成功了,这个任务最终就是成功状态
根据上面场景可以想到,更新数据分为两个步骤
- 查询子任务最终的状态
- 修改任务的状态
为了保证数据在并发情况下的正确性,当时想到的是保证查询和修改是一个原子性操作,所以决定在查询时使用 for update 对查询到的数据加锁。根据查询的结果修改任务的状态。但是后来发现这个修改逻辑造成 MySQL 死锁。
死锁原因分析
造成死锁的原因主要和 for update 对数据加锁的过程有些关系,加锁过程描述:
MySQL innodb 存储引擎默认的隔离级别时 RR 级别,而RR隔离级别,默认是使用Next-key Lock,Next-Key Lock加锁有下面几个规则。
- Next-Key Lock是前开后闭区间。
- 查找过程中访问到的对象才会加锁。
- 索引上的等值查询,给唯一索引加锁的时候,next-key lock 退化为 record lock。
- 索引上的等值查询,向右遍历时且最后一个值不满足等值条件时,next-key lock 退化为 gap lock。
具体案例分析
表结构
代码语言:javascript复制mysql> show create table user;
CREATE TABLE `user` (
`id` int NOT NULL,
`score` int DEFAULT NULL,
KEY `socre` (`score`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci |
1 row in set (0.00 sec)
当前表结构,只有一个普通索引score。我们下面的测试都是在普通索引score的基础上完成的。
插入数据
复现案例
分析上图的两个事务的操作,在A事务加锁之后,实际的加锁范围是[10,30),sql查询流程如下:
- 通过score索引,找到第一个值,score=10,不满足条件,继续向下寻找。
- 找下一个节点score=20,符合条件,会加上行锁和间隙锁。此时的加锁状态是(10,20];但是根据规则2,查找过程访问到的上一个记录score=10,所以会将10也锁住,加锁范围变成[10,20]。
- score不是唯一索引,所以会继续向下寻找,下一个节点为30,满足规则2,所以会加上(20,30],但是30不满足等值查询score=20,所以根据第4条规则next-key lock退化为gap lock(20,30)。
- 至此,事务加锁结束,最终的加锁范围是[10,30)
间隙锁细节总结
- 间隙锁锁定的是索引记录之前和之后的一个间隙范围。
- 可以对同一个间隙重复加间隙锁。
- 间隙锁可能造成死锁。
- 间隙锁是RR隔离级别下的。
- 间隙锁只影响一般索引,对于唯一索引或者主键,如果查询的结果包含这个记录,那么另外的会话插入该记录前后,不会产生间隙锁;如果查询结果不包含这个记录,另外的会话插入该记录前后的间隙,会产生间隙锁。
经过上面的流程可以知道 for update 不仅会锁住查询到的数据, 也会锁住不满足查询条件的数据,当查询不到数据的时候甚至可能演变成表锁,因为不同事务的间隙锁可以重复加锁,所以当两个事务同时锁住某些相同的数据,并对这部分数据进行修改时就会出现死锁的情况
参考文章
MySQL 锁类型总结
MySQL 间隙锁,锁过程详解