线上mysql插入数据产生死锁了

309 阅读5分钟

之前在项目中遇到一个经典的死锁问题,为了说明本质,我们使用一个简单的例子来了解这个死锁问题。

我们先创建一张表:

CREATE TABLE `user` (
    `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
    `name` varchar(100) NOT NULL DEFAULT '' COMMENT '名字',
    `phone_no` int(10) NOT NULL DEFAULT '0' COMMENT '电话号码',
    PRIMARY KEY (`id`),
    KEY `idx_phone_no` (`phone_no`)
) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8;

其中phone_no加了索引,否则where条件包含phone_no的非查询语句会加表锁。

然后我们插入两条数据:

insert into user values(1, 'bai', 1), (3, 'hei', 3);

现在代码里有这么一段逻辑。

select user where phone_no=2 for update  // 查询sqlif (user 存在) {        returnelse {  insert user;   // 插入sql}

逻辑比较简单,就是去查一下 phone_no=2 的数据存不存在。不存在的话,就插入一条到数据库里, 伪代码如下:

-- 查询用户信息并锁定数据行
SELECT user
WHERE phone_no = 2
FOR UPDATE;

-- 如果用户存在,则返回;否则插入新用户信息
IF (user exists) THEN
    RETURN;
ELSE
    insert sql

这个逻辑在两个线程并发的情况下提示死锁:

ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transactio

在分析这个死锁出现的原因之前,我们先来了解一些前置知识。

mysql 默认引擎是 innodb,它的锁有两种,一种是表锁,一种是行锁。当你更新一条数据时,如果你不走索引,那会锁表,否则,锁行。 锁行是mysql的设计者为了提升效率,把锁的粒度减少了,这是一种常见的优化锁的思路。

加锁除了 update, insert 这类写类型的语句会加之外,还可以在 select 语句的最后加入for update,这样也能加锁。比如

select * from user where phone_no =2 for update;

这条语句一般用在事务中,否则加锁完立即解锁,没有任何意义。

死锁

我们在写代码的时候,会执行各种sql, 期间可以锁住多行。当一个线程T1先锁住A行,另一个线程T2先锁住B行,接着T1想锁住B行,T2想锁住A行,这样就出现了两个线程在各自持有一把锁的同时,死等对方持有的另外一把锁释放的情况。双方都想拿对方的锁,且自己的锁也死死不松手,逻辑就都跑不下去了,这就是死锁

间隙锁

我们知道加锁的本质是为了保持数据的一致性,但是如果要加锁的数据还不存在怎么办?比如下面这句sql:

select user where phone_no=2 for update;

锁了个寂寞?这时候就得看隔离级别。phone_no 是加了索引的,且因为数据库索引里,数据是排好序的,phone_no=1 和 phone_no=3 都存在,他们之间没有数据,如果有 phone_no=2 这条数据的话,那也理应出现在他们中间。那么现在的问题是,有没有办法锁住 1 和 3 之间的缝隙

有的,有个间隙锁,这个锁,在读未提交读已提交里都没有,它在可重复读这个隔离级别下被引入。而且,间隙锁和间隙锁之间是不互斥的

于是,我们来回答开头出现死锁的问题。

image.png

线程 1在可重复读这个隔离级别下,通过 for update ,可以在 1 和 3 之间,加上间隙锁

线程 2 也一样,也在 1 和 3 之间加上间隙锁,因为间隙锁和间隙锁之间是不互斥的,所以也能加锁成功。

这时候线程 1 尝试去插入数据,插入数据的时候也会加一个特殊的锁,专业点,叫插入意向锁插入意向锁跟间隙锁是互斥的。

由于线程 2 前面已经加过间隙锁了。所以线程 1 会等线程 2 释放间隙锁。但线程 2,不仅不释放间隙锁,反而又打算加一个插入意向锁。相当于两个线程在持有一个锁的同时,还等着对方释放锁。 这就造成了死锁。

InnoDB检测到死锁后,会选择了一个事务进行回滚,从而释放其持有的锁。这样另外一个事务获得了所需的锁,因此它的插入操作能够成功。

间隙锁会阻塞自身事务吗

如果只有线程1,线程1放置了间隙锁(Gap Lock),然后执行插入操作是能够成功的。

这是因为间隙锁是预防其他事务在该范围内插入记录,但不会阻止自身事务的插入操作。当线程1在一个空的间隙上放置了间隙锁,并尝试在同一个间隙内插入新的记录时,线程1可以成功地获取到插入意向锁,并最终成功插入新记录。

为什么RR级别要引入间隙锁

这是可重复读的定义决定的: 不管读多少次,读到的数据了都必须是一样的。如果没有间隙锁:

T1T2
begin;
select * from user where phone_no>=1 for update; (查到2条数据,phone_no = 1 和 3)
insert into user values(2,'hb', 2); (假设没有间隙锁,插入成功)
select * from user where phone_no>=1 for update; (查到3条数据,phone_no = 1,2, 3)
commit;

在一个事务里,读多次数据,发现每次数据都不同。就好像出现幻觉一样,所以又叫幻读。这就跟可重复读的定义违背了。所以,可重复读隔离级别下,通过引入间隙锁,是为了解决幻读的问题。