前言
之前学习了MySQL的事务管理MySQL管理事务处理
在数据库处理高并发的时候需要涉及到事务管理和锁的机制问题这两块知识。对于锁的处理一直是一个老生常谈的话题,内容太过复杂。这次借助这篇文章分享下我对Innodb中的锁的机制的理解。
快照读与当前读
在了解锁的机制前我们得先了解快照读与当前读的区别。
一般我们常用的select * from ...
也是读,共享锁那也是读,它们之间有什么区别呢?其实MySQL中的读与事务隔离级别中的读是不同的读。
在 MVCC 并发控制中,读操作可以分成两类:快照读 (snapshot read)与当前读 (current read)。 快照读,读取的是记录的可见版本 (有可能是历史版本),不用加锁。当前读,读取的是记录的最新版本,并且,当前读返回的记录,都会加上锁,保证其他事务不会再并发修改这条记录。
-
我们常用的
select * from table where ...
后面没有加锁的话那就属于快照读。 -
当前读是一种特殊的读操作,像插入、更新、删除都属于当前读。
select * from table where ? lock in share mode;
select * from table where ? for update;
insert into table values(...);
update table set ? where ?;
delete from table where ?;
为什么insert、update、delete也属于当前读,高性能MySQL中有提到一个update的操作流程首先会把sql语句发送给MySQL,MySQL server会根据where条件查询到满足条件的第一条数据,然后InnoDB引擎会将该条数据返回并加锁。MySQL server收到这条加锁的数据后对其进行更新,再执行下一条数据。同样delete也是这种流程,只有insert流程会和它们不一样但是也会有当前读的环节。
锁的类型
从大分类来讲锁可以分为 乐观锁 和 悲观锁,
InnoDB为了实现多粒度锁的机制,将锁分为
- 共享锁(S锁)
- 独占锁(X锁)
- 意向共享锁(IS锁)
- 意向独占锁(IX锁)
什么是粒度,粒度分为 行锁 和 表锁 。关系还是蛮复杂的,接下来我们来一点一点捋清楚。
本篇文章主要是对MySQL的锁的机制做一个详解,所以接下来会把大量篇幅偏向于分析悲观锁
乐观锁
乐观锁是以数据版本来实现的机制。使用乐观锁的时候会在数据表中加一个版本号字段如version。当读取数据的时候也会读取version,如果其他事务更新了数据后version值会加1,根据version值来判断是否是最新的数据。
乐观锁与悲观锁最大的区别就是对于数据库性能开销上会节省很多,悲观锁中粒度越小开销越大。
悲观锁
悲观锁的实现主要是依靠数据库的锁机制,其核心目的为了保证事务的隔离性。在操作数据的时候会担心数据产生冲突,为了避免冲突常常需要获取锁来解决这类问题。
悲观锁中主要以两种形式来对数据进行加加锁,一种是表锁一种是行锁。顾名思义,表锁就是对整张表进行加锁,执行效率会比行锁快,但是并发能力会大大降低。而行锁就是对一行或多行进行加锁,执行效率会比表锁差,但是并发能力强。
共享锁(S锁)
悲观锁中的一种,用于对行数据加锁,允许其他事务读取数据,但是不允许其他事务对其修改直到释放该锁,否则会造成阻塞。想要解决阻塞必须等加锁的事务提交。
独占锁(X锁)
独占锁又称为写锁,意思就是如果一个事务对某一行数据加了X锁,其他事务将不能对该数据进行任何加锁操作,不能再加X锁也不能再加S锁,其他事务只能读取该数据。所以这种锁也有个说法叫排他锁,是一种非常悲观的锁。
到这里可能有个疑问,S锁与X锁是互相排斥的,那么它们之间是如何知道对方目标数据是否加了锁,加的又是何种锁?
冲突是如何产生的(意向锁)
在我们为某行数据申请了S锁或者X锁之后,InnoDB会对该数据所在的表申请意向锁,同时其他事务想对这张表申请X锁,这个时候意向锁告知了其他事务这张表中的某行数据加了某种锁,至此冲突就产生了。意向锁分为:
- 意向共享锁
- 意向独占锁
意向共享锁(IS锁)
所有意向锁都是一种不与行级产生冲突的锁,意向共享锁指的是事务有意向对表中的某些行加共享锁(S锁),在我们申请S锁的时候由InnoDB自动帮我们添加。这里我们可以理解为是一种标志,表示着该表中有些行被共享锁锁住了。
意向独占锁(IX锁)
事务有意向对表中的某些行加独占锁(X锁)。同意向共享锁,在我们申请X锁的时候InnoDB会自动帮我们添加这层锁,表示这个表中的某些行被锁上了独占锁。
意向锁的作用是什么
比如现在一个场景:事务A对数据表中的某行加上了S锁,然后事务B打算给数据表加上一个X锁,由于S锁与X锁本身存在冲突,所以事务B在加上X锁之前必须对整个表进行检查:
- 检查该表是否加上了S锁
- 检查该表中的某行数据是否加上了S锁
要知道这两项检查是十分吃性能的,特别是第二条需要遍历整个表才能知道是否存在某行数据加上了S锁。这个时候意向锁就起到了作用,它类似于给该数据表贴上了标签,在事务B打算给这个表添加X锁的时候告诉了事务B,数据表中的某行存在S锁麻烦等它解锁后再加上X锁,这个时候事务B就立即产生了阻塞,而不会像以前需要等一段时间才阻塞。如果不阻塞的话会出现不可重复的的情况,这样就违背了事务的隔离性。
所以产生冲突是因为意向锁与其他锁产生了冲突,而不是S锁与X锁直接的冲突
意向锁与S锁和X锁的冲突关系表
(S锁与X锁都是表锁的情况下)
锁类型 | S | X | IS | IX |
---|---|---|---|---|
S | 兼容 | 冲突 | 兼容 | 冲突 |
X | 冲突 | 冲突 | 冲突 | 冲突 |
IS | 兼容 | 冲突 | 兼容 | 兼容 |
IX | 冲突 | 冲突 | 兼容 | 兼容 |
如果S锁与X锁都是行锁的话,那意向锁将与它们互相兼容,因为InnoDB不会对行添加意向锁只会对该行所在的表添加意向锁。
行锁
行锁是基于索引
实现的,所以一旦某个加锁操作没有使用索引,那么该锁就会退化为表锁
。还有另外一种情况也会退化为表锁,当数据表数据太少或者当前锁的记录数接近数据表的记录数就会退化为表锁。
行锁有分三种算法:
- 记录锁
- 间隙锁
- 临键锁(Next-Key Lock)
记录锁
记录锁是对某行记录加锁,如SELECT c1 FROM t WHERE c1 = 10
,可以防止其他事务对该记录c1=10
的插入、更新和删除操作。其中c1
必须是主键或者唯一索引列,查询条件也必须是**=精准匹配,不能为<,>**,like等,否则会退化成临建锁(Next-Key Lock)
间隙锁
而间隙锁则与记录锁相反,它是基于非唯一索引的记录,它将锁定一定范围内的索引记录。
SELECT * FROM student WHERE score BETWEEN 90 AND 100 FOR UPDATE;
这段sql语句的作用可以理解为它能防止其他事务将score为95的记录插入进去,意思就是在(90,100]这个区间内是不能插入数据的,但是不包括90。
如果像下面这条sql在查询的时候没有设定范围,那么它的区间就是(1,10]。
SELECT * FROM student WHERE id = 10 FOR UPDATE;
如果,上面语句中id列没有建立索引或者是非唯一索引时,则语句会产生间隙锁。
如果,搜索条件里有多个查询条件(即使每个列都有唯一索引),也是会有间隙锁的。
临键锁
临键锁是记录锁和间隙锁(记录锁的防止插入、更改和删除 以及 间隙锁的索引前面间隙的区间锁定)的组合。因为间隙锁只能防止插入,结合了记录锁之后就能彻底的防止幻读了。(幻读并不是只有插入才产生幻读,删除操作也会造成幻读)。
InnoDB
执行行级锁定,以使其在搜索或扫描表索引时对遇到的索引记录设置共享或排他锁。因此,行级锁实际上是索引记录锁。
临键锁只与非唯一索引列
有关,在唯一索引列
(包括主键列
)上不存在临键锁。
以下面这张表为例:
id | name | t_id |
---|---|---|
3 | 四年一班 | 10 |
4 | 四年一班 | 22 |
5 | 四年一班 | 30 |
索引t_id
包含值10,22,30,t_id
被锁上临建锁之后潜在的区间为:
-
(-∞,10]
-
(10,22]
-
(22,30]
-
(30,+∞)
现在我们在事务A中执行(其中t_id是非唯一索引,id是唯一索引)
SELECT * FROM table WHERE t_id = 22 FOR UPDATE;
这个时候InnoDB会将t_id=22
这一行加上X锁,然后对它所在的区间以及下一个区间加上间隙锁。最终被锁住的区间为[10,30) 。
死锁
死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。
InnoDB 引擎采取的是 wait-for graph
等待图的方法来自动检测死锁,如果发现死锁会自动回滚一个事务。
step | 事务A | 事务B |
---|---|---|
1 | begin; | |
2 | begin; | |
3 | SELECT * FROM table WHERE id = 1 FOR UPDATE; | |
4 | SELECT * FROM table WHERE id=2 FOR UPDATE; | |
5 | (此时事务A并不知道id=2这条索引被加上了X锁) SELECT * FROM table WHERE id = 2 FOR UPDATE; (发生了阻塞) |
|
6 | 同样事务B也不知道事务A做了什么 SELECT * FROM table WHERE id = 1 FOR UPDATE; (造成了死锁) |
我们对每一步操作分析以下:
-
第三步开始我们在事务A中对索引
id
为1的记录加上了X型记录锁 -
第三步我们在事务B中对索引
id
为2的记录加上了X型记录锁 -
第五步在事务A中对索引
id
为2的记录加上X型记录锁,但是由于索引id
为2的记录已经被加上了X锁,又由于X锁的排他性导致无法对其加锁所以发生了阻塞。 -
第六步,这个时候事务B又想对
id
为1的记录加上X锁,由于id
为1的记录已经加上了X锁也造成了阻塞。 -
此时搞笑的事情就发生了,事务A在等待事务B中第四步的锁的释放,而事务B又在等待事务A在第三步加上的锁的释放,从而就产生循环等待导致了死锁。
-
死锁一旦发生后会被
MySQL
服务器的死锁检测机制检测到了,所以选择了一个事务进行回滚,并向客户端发送一条消息:ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
那么如何避免死锁呢?我会在下一篇文章中介绍死锁的分析思路以及如何解决死锁问题
总结
- InnoDB为记录加锁是基于索引的,如果索引是唯一索引(如主键)那么会为其加上记录锁。如果是非唯一索引则会给该记录加上间隙锁。如果是非索引列,则会退化为表锁。另外如果sql`语句中的查询范围接近于表中的总行数,那也会退化成表锁。
- 并不是间隙锁解决了幻读问题,而是间隙锁加上记录锁的组合(Next-Key Lock)才是真正解决幻读问题的方案。
- 在RR隔离级别下,不是只有插入操作才会造成幻读删除也会。这也是为什么很多人都错认为间隙锁解决了幻读问题。
参考文献
- 聊聊MVCC和Next-key Locks
- 详解 MySql InnoDB 中的三种行锁(记录锁、间隙锁与临键锁)
- 全面了解mysql锁机制(InnoDB)与问题排查
- Baron Scbwartz等 著,王小东等 译;高性能MySQL(High Performance MySQL);电子工业出版社,2010
- MySQL 8.0参考手册