mysql 的读写锁这个话题,老难了。
水太深,容易把握不住。
看书吧犯困。
但面试又绕不过去。
今天,我们面向一个例子学习,希望能让大家提起一点兴趣。
直接开始吧。
有这么一张表,建表 sql 如下。
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;
这里需要关注的是,
电话号码
这一列,是加了
普通索引
的。
mysql 有四层隔离级别,应对不同的事务并发处理能力。之前写的
《mysql 主库更新后,从库都读到最新值了,主库还有可能读到旧值吗?》
,里面用一个例子简单介绍了他们之间的区别。
假设我们现在用的是
可重复读隔离级别
。
当前数据表长这样。
注意,里面
没有 phone_no=2
。
现在代码里有这么一段逻辑。
select user where phone_no=2 for update // 查询sqlif (user 存在) { return} else { 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。
可重复读隔离级别下,
通过引入间隙锁,是为了解决幻读的问题。