为什么 MySQL 插入不存在的数据会产生死锁?

科技   2024-08-05 08:45   北京  

mysql 的读写锁这个话题,老难了。

水太深,容易把握不住。

看书吧犯困。

但面试又绕不过去。

今天我们面向一个例子学习,希望能让大家提起一点兴趣。

直接开始吧。

有这么一张表,建表 sql 如下。

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

这里需要关注的是,电话号码这一列,是加了普通索引的。

mysql 有四层隔离级别,应对不同的事务并发处理能力。之前写的《mysql 主库更新后,从库都读到最新值了,主库还有可能读到旧值吗?》,里面用一个例子简单介绍了他们之间的区别。

假设我们现在用的是可重复读隔离级别

当前数据表长这样。

注意里面没有 phone_no=2

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

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

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

目的是保证 phone=2 的数据唯一。

注意跟平时的 select 查询不一样,select 语句后面还有个 for update ,是为了对 phone_no=2 进行加锁,不懂没关系,待会还会提到。

现在有两个线程同时并发跑上面的逻辑。

结果竟然提示死锁

Deadlock found when trying to get lock; try restarting transaction

为什么呢?

为了解释这个问题,我们就从 mysql 锁的话题开始聊起。

mysql 锁的话题很大了,这个细细聊起来又是一篇长文了。

我们简单说一下。mysql 中,我们现在用的引擎几乎都是 innodb,我们以此为默认条件展开话题。

常见的锁有两种,一种是表锁,一种是行锁

当你更新一条数据时,如果你不走索引,那会锁表,否则,锁行。

表锁是在你什么索引都没有的时候,为了保证数据一致性,迫不得已加上去的,锁的对象当然就是整个数据表了。也就是说,你在对数据表加锁之后,再对表进行读写操作,结束之后对表解锁。在此期间,其他对这张表的写操作都得等你操作完。

是的,干等,哪怕你操作的是第 1 行的数据,其他人要操作的是第 100 行的数据,也得干等。

为了提升效率,设计 mysql 的大佬们又把锁的粒度给减小了,粒度从锁表变成锁行。

也就是说,你先锁住这个行,在读写完这一行之后,再解锁,期间其他人如果要操作这一行,那要等着,如果操作其他行,那就不用等了。这样并发量就提上去了。

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

select * from user where phone_no =2 for update;

update 语句会加锁比较好理解,就是你要更新某一行了,防止别人这时候也要更新,所以加锁。后者 select for update 就是告诉别人,你读的这一行接下来是要拿来做更新的操作的for update),在你更新完成前,谁也不能更新它。

上面这种锁,主要是为了写的时候加入的,叫写锁,也就是 X 锁

写锁跟写锁之间是互斥的。意思是不能同时对某一行加两个写锁,凡事讲究先来后到,后面加写锁的线程会阻塞等待前面的线程解锁完。

既然有写锁,当然有读锁,也叫 S 锁

像下面这样在 select 语句后面加上lock in share mode,就能加入读锁。

select * from user where phone_no =2 lock in share mode;

读锁和读锁之间就不互斥,也就是两个线程可以对同一行数据同时加读锁,不会阻塞。

死锁

简单解释下死锁,一个数据表里有那么多行,我们写代码的时候,会执行各种 sql 语句,期间完全可以锁住多行。

当一个线程先锁 A 行,再锁 B 行时,另外一个线程反过来,先锁 B 行,再锁 A 行。就有可能发生两个线程在已经持有一个锁的同时,死等对方持有的另外一个锁释放的情况。

双方都想拿对方的锁,且自己的锁也死死不松手,逻辑就都跑不下去了,这就是死锁

间隙锁

那么我们回到文章开头的话题上。

如果我能保证,对 id=2 的那一行加锁,写结束前都不释放,期间别人都没法写,这样岂不是保证数据唯一了?

道理是这么个道理没错,但是现在的关键是,phone_no=2 这一行并不存在

select user where phone_no=2 for update

这一行 sql 一执行,牢牢锁住了空气?

开个玩笑。

是不是什么也没锁住,这个要看隔离级别了。

phone_no 是加了索引的,且因为数据库索引里,数据是排好序的,phone_no=1 和 phone_no=3 都存在,他们之间没有数据,如果有 phone_no=2 这条数据的话,那也理应出现在他们中间。

那么现在的问题是,有没有办法锁住 1 和 3 之间的缝隙

有的,有个间隙锁,这个锁,在读未提交读已提交里都没有,它在可重复读这个隔离级别下被引入。

而且,间隙锁和间隙锁之间是不互斥的

记住上面这句话,老关键了。

于是乎,我们回到文章开头的问题里,这次我加上注释。

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

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

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

但由于线程 2 前面已经加过间隙锁了。所以线程 1 会等线程 2 释放间隙锁。

但线程 2,不仅不释放间隙锁,反而又打算加一个写锁。

哦吼。

相当于两个线程在持有一个锁的同时,还等着对方释放锁。

这就妥妥死锁了。

这下,文章开头死锁的问题,就解释完了。

那么问题又来了。

为什么可重复读要引入间隙锁?

可重复读最关键的一个点是,我开了一个事务,在这个事务里,不管我读多少次,我读到的数据都要是一样的,这才是可重复读。如果 mysql 不存在间隙锁,那么就有可能出现下面的情况。

在一个事务里,读多次数据,发现每次数据都不同。就好像出现幻觉一样,所以又叫幻读

这就跟可重复读的定义违背了。

通过加入间隙锁,线程 1 在第一次执行 select for update 后,线程 2 如果再尝试去写数据,就会被阻塞,直到线程 1 执行 commit 后,线程 2 阻塞结束然后执行 insert。

可重复读隔离级别下,通过引入间隙锁,是为了解决幻读的问题。

总结

  • • mysql 锁从粒度上分为行锁和表锁,从行为上又分为读锁和写锁,也就是 S 锁和 X 锁。

  • • 两个线程在持有锁的同时,又想等待对方把锁释放掉,则会发生死锁。

  • • 两个间隙锁之间不会互斥。

  • • 在可重复读隔离级别下,通过间隙锁解决了幻读。

参考资料

《MYSQL 内核:INNODB 存储引擎 卷 1》

最后

这篇文章只是想通过一个例子讲讲锁的内容。并不是希望通过这样的方式来保证并发写入唯一数据。

如果只是想在并发写时保证数据唯一的话,加个唯一索引吧,别搞上面这些花里胡哨的。

几张图就能整理一个知识点出来,本文的画图经验也整理在了我的付费专栏《程序员的全能画图课》

别看叫画图课,其实大部分内容是教大家工作中接到项目后,无论规模大小怎么对项目做需求拆解和分析,画图只是这些过程的高效管理和价值体现。

感兴趣的可以订阅,专栏已经更新完成,未来会再加更一些总结。 

订阅方式一:公众号专栏订阅入口:《程序员的全能画图课》

订阅方式二:扫描下方海报二维码订阅

苹果手机推荐扫码订阅,不需要AppStore充值费率更低。


网管叨bi叨
分享软件开发和系统架构设计基础、Go 语言和Kubernetes。
 最新文章