2

作者:名扬

前言

阅读本文后你将收获:

  • 1.对MySql中的锁有更加全面的认识。
  • 2.了解什么是幻读,以及如何避免幻读 。
  • 3.InnoDb 引擎关于行级锁的实现方式。
  • 4.死锁产生的条件、实例及如何避免死锁。
  • 5.本文中的sql语句均可直接在MySql中执行,方便自己做实验,这点很重要,只有自己动手实验过才会记忆更加深刻。

另外,由于本人水平及时间有限,文中若有纰漏,欢迎批评指正,感激不尽,当然有任何疑问也欢迎评论区留言,一起学习讨论共同进步。

相信许多同学对于MySQL锁的概念并不陌生,但又感觉理解地不是很透彻,总像蒙着一层纱。那么今天我们就一起通过实际操作来捋一捋 Mysql 的锁,读本文之前建议你先了解 隔离级别、当前读、快照读 等概念 。注:本文的操作均是基于 Mysql 8.0.22 版本 InnoDB 引擎进行的,在其他大版本下执行,结果可能存在差异。(注意📢:本文均是在(repeatable-read)隔离级别下进行的操作)
接下来我们将从 MySQL有哪些锁以及为什么要加锁展开讨论。

1.MySql 有哪些锁?

MySQL 不同的存储引擎支持不同的锁机制,所有的存储引擎都以自己的方式实现锁,服务器层无需了解存储引擎锁的具体实现。根据锁的不同粒度,MySQL 的锁可分为:全局锁、表级锁、行级锁(InnoDB)及其他(自增ID锁)。本文将重点讨论行级锁。其他只做简要描述,感兴趣的可自行搜索。
image.png

1.1 全局锁

Flush tables with read lock
顾名思义是锁整个数据库,全局锁的典型使用场景是数据库备份时,为了保持数据的一致性,会对数据库加全局锁,但是当数据库表使用的引擎为InnoDB时,一般使用mysqldump …… --single-transaction。如果使用MyISAM引擎进行数据备份时,则只能加全局锁了。

如果数据备份时不加全局锁,会产生怎样的数据不一致呢?这里举个栗子:数据库中含有: Wallet(用户钱包)表 及 stock (商品库存)表,当购买商品时需要同时扣减 用户钱包 及 商品库存。在数据库备份过程中,恰好发生了商品购买。而且恰好是在 wallet 表备份完成后 及 stock 备份前,发生了商品购买。由于wallet表已经完成了备份,所以此次的钱包扣减操作并没有被记录到wallet的备份文件中。但扣减库时,stock表还未备份,所以此次库存扣减记录在备份文件中, 这就导致使用备份文件恢复出来的数据库数据不一致了,钱包没有扣钱,但是库存扣减了。

由于InnoDB引擎支持快照读,所以如果在数据库备份时打一个快照(--single-transaction),则就算不加全局锁,也不会有数据不一致的问题。

1.2 表级锁:

表锁(Lock tables read)
一般MyISAM和MEMORY存储引擎会采用表锁解决并发(这两个引擎不支持行级锁),而InnoDB引擎则同时支持表锁与行锁,并通过行级锁来提高并发性(另外,行锁使用不当时,InnoDB的行锁也会退化成表级锁,后面会介绍)。另外BDB存储引擎使用的是页面锁,页面锁的粒度介于表级锁与行级锁之间。

MDL锁(meta data lock)
在 MySQL 5.5 版本之后引入了 MDL锁,当对一个表做增删改查操作的时候,加 MDL 读锁;当要对表做结构变更操作DDL的时候,加 MDL 写锁。以防止在数据查询或数据更新时有表结构的变更,进而导致查询或更新的结果与预期的不一致。

设想一下,如果DDL操作 与 CURD操作之间不加锁,可以同时进行,那么在查询数据时,你的查询条件列被删除了,或者你要更新的列被删除了,是不是不论从 MySql的语句执行方面 及业务查询数据的结果来看,都会怪怪的。所以MySql 引入了MDL锁的概念。

意向锁(Intention Locks)
为了允许行锁和表锁共存,实现多粒度锁机制,InnoDB 还有两种内部使用的意向锁(Intention Locks),这两种意向锁都是表级别锁:

  • 意向共享锁(IS):事务在给数据行加读锁前,必须先获取该表的 IS 锁。
  • 意向排他锁(IX):事务在给数据行加写锁前,必须先获取该表的 IX 锁。

意向锁与锁之间的兼容关系如下:

Tips: X :eXclusive 排它锁, S: Share 共享锁, IX : Intention eXclusive 意向排它锁, IS :Intention Share 意向共享锁。
XIXSIS
IS冲突兼容兼容兼容
IX冲突兼容冲突兼容
S冲突冲突兼容兼容
X冲突冲突冲突冲突

一句话:意向锁的作用是 在加表锁时,可以快速的发现,是否存在行锁,而无需遍历整个索引后才知道表已经加了行锁,已无法再加表级锁。举个例子:

CREATE TABLE `wallet` (
  `user` VARCHAR(32) NOT NULL ,
  `balance` int(11) DEFAULT 0,
  PRIMARY KEY (`user`)
) ENGINE=InnoDB;
 
INSERT INTO wallet VALUES ("Tom",1000);
session1session2
T1BEGIN;
select * from wallet where user = 'tom' for update;
加了行锁 及 IX 锁
T2 BEGIN;
LOCK TABLES wallet READ;
加表锁时 block

session 1 在 T1 时刻加行锁时,引擎自动在 wallet 表上加了 IX。 session 2 在 T2 在表读锁之前,引擎自动加了 IS 通过, 但在加 S 锁时 与 sesion1 加的 IX 冲突了,所以进入阻塞状态。 通过这种方式无需遍历所有索引是否存在行级锁,大幅提高了MySql并发的效率。

1.3 行级锁:

InnoDB存储引擎为了提高并发度,引入了行级锁概念,行级锁有涉及 排他锁、共享锁、行锁、间隔锁、The next key lock 等概念,后续会一一介绍。 注意📢: 后文中提到的行锁实则为行级锁。 而将只锁一行的锁叫做典型行锁

1.4 自增ID锁

当表含有自增ID字段时(AUTO_INCREMENT),为了防止并发事务申请到相同的自增ID,所以MySQL在事务申请自增ID时加了锁。但自增ID锁不是事务性(换句话说就是锁不会在整个事务过程当中一直持有),而是在获取到自增ID后立即释放

2.为什么要加锁?

2.1 保证数据一致性

下面举一个 因不加锁而导致数据不一致的场景。假设有一个存储用户钱包余额的wallet 表,建表语句如下:

CREATE TABLE `wallet` (
  `user` VARCHAR(32) NOT NULL ,
  `balance` int(11) DEFAULT 0,
  PRIMARY KEY (`user`)
) ENGINE=InnoDB;
-- 插入记录
INSERT INTO wallet VALUES ("Tom",1000);

场景:用户 Tom 钱包中有 1000 元。现要购买一个1000元的商品,但由于某种原因(客户端、网关重试或恶意攻击 等原因),服务器 同时收到了 两条 Tom 的购买请求
假设业务的执行逻辑是:

  • 1.查询数据库 ;
  • 2.验证余额是否充足 ;
  • 3.如果充足,则执行扣款 ;
  • 4.如果不充足,则返回扣款失败 ;

(注意📢:本文均是在(repeatable-read)隔离级别下进行的操作)

假设业务逻辑是按照如下时序执行的,那么会出现什么问题呢? 可以先思考下黑色的执行结果如何?

session1session2
T1BEGIN;
select * from wallet where user = 'tom';
T2业务代码 校验余额是否充足
余额为1000 充足
BEGIN;
select * from wallet where user = 'tom';
T3 业务代码 校验余额是否充足
余额为1000 充足
T4UPDATE wallet set balance = balance - 1000 WHERE user = 'tom';
T5 select * from wallet where user = 'tom';
balance = ?
T6 UPDATE wallet set balance = balance - 1000 WHERE user = 'tom';
是否会block?
T7select * from wallet where user = 'tom';
balance = ?
T8commit;
T9select * from wallet where user = 'tom';
balance = ?
T10 select * from wallet where user = 'tom';
balance = ?
T11 commit

下表是执行结果:

session1session2
T1BEGIN;
select * from wallet where user = 'tom';
T2业务代码 校验余额是否充足
余额为1000 充足
BEGIN;
select * from wallet where user = 'tom';
T3 业务代码 校验余额是否充足
余额为1000 充足
T4UPDATE wallet set balance = balance - 1000 WHERE user = 'tom';
T5 select * from wallet where user = 'tom';
balance = 1000

如果不了解为何是1000, 可自行搜索 可重复读隔离级别相关内容
T6 UPDATE wallet set balance = balance - 1000 WHERE user = 'tom';
是否会block?

是,因为update语句会默认加写锁,由于session1已经加了锁,所以session 2 要等待
T7select * from wallet where user = 'tom';
balance = 0
T8commit;当session 1 提交后, update语句将不再阻塞,并执行成功,此时 balance 变为 -1000
T9select * from wallet where user = 'tom';
balance = -1000
T10 select * from wallet where user = 'tom';
balance = -1000


即使在可重复读隔离级别下,也是可以看到自己的提交的,所以是 -1000,而不是 0
T11 commit

大家可以看到,如果不加锁的情况下会出现余额为负的情况,导致数据不一致。
同样的场景,如果加了锁会怎样呢?

session1session2
T1BEGIN;
select * from wallet where user = 'tom' for update;
T2业务代码 校验余额是否充足
余额为1000 充足
BEGIN;
select * from wallet where user = 'tom' for update;
阻塞
T3UPDATE wallet set balance = balance - 1000 WHERE user = 'tom';
T4commit;session 1 提交后,session 2 不再阻塞,读到余额为0
T5 业务代码 校验余额是否充足
余额为0 返回失败

如上,同样的场景下,加锁可以避免数据的不一致。

2.2 避免产生幻读

现有表student。表结构与表中记录如下所示:

CREATE TABLE `student` (
  `student_id` int NOT NULL,
  `name` VARCHAR(32) NOT NULL ,
  `age` int(11) DEFAULT 0,
  PRIMARY  KEY (`student_id`),
    KEY `age`(`age`) USING BTREE
) ENGINE=InnoDB;
INSERT INTO student VALUES(6,'Tom',6),(11,'Jerry',11),(18,'Jamey',18),(25,'Marvin',25);

下面的操作与演示何为幻读。

session1session2
T1BEGIN;
SELECT * from student where student_id > 6 and student_id < 20;
rows 2
11 Jerry
18 Jamey
T2 INSERT into student VALUES(16,'Lucy',16);
T3SELECT * from student where student_id > 6 and student_id < 20;
rows 2
11 Jerry
18 Jamey
T4INSERT into student VALUES(16,'Lucy',16);
Duplicate entry '16' for key 'student.PRIMARY'
T5SELECT * from student where student_id > 6 and student_id < 20;
rows 2
11 Jerry
18 Jamey
T6commit;

在可重复读(RR)隔离级别下,执行select为快照读,所以在T3时刻并无法读取到T2时刻插入的记录,但T4时刻插入student_id=16的记录时,却又出现了重复,而且就算T5时刻再次读取也无法读取数据库中的student_id=16的记录。就像产生了幻觉一样,明明查不到数据,但插入时却冲突,这种现象就称为幻读。解决幻读需要设置 Serializable 隔离级别或读数据时使用锁(当前读)。下面将讨论如何通过加锁解决幻读。

3.行级锁

3.1 For Update 锁

本节将讨论,为何行级锁可以解决幻读?又是如何解决的?下面先看一下如下语句的执行结果。依然是使用student表,表中有4行数据,建表及插入语句如下。

CREATE TABLE `student` (
  `student_id` int NOT NULL,
  `name` VARCHAR(32) NOT NULL ,
  `age` int(11) DEFAULT 0,
  PRIMARY  KEY (`student_id`),
    KEY `age`(`age`) USING BTREE
) ENGINE=InnoDB;
INSERT INTO student VALUES(6,'Tom',6),(11,'Jerry',11),(18,'Jamey',18),(25,'Marvin',25);
session1session2
T1BEGIN;
SELECT * from student where student_id > 6 and student_id < 20 FOR UPDATE;
T2 SELECT * from student where age > 6 and age < 20;
block?
no
T3 INSERT into student VALUES(17,'Lily',17);
block?
yes
T4 INSERT into student VALUES(21,'Lily',21);
block?
yes
T5 NSERT into student VALUES(26,'Lily',26);
block?
No

行锁是通过对索引项加锁,而不是作用于真实记录的。所以只有通过索引查询才会使用行锁(The next key lock),没有索引则会是锁全表(退化成表级锁)。

为什么T3 与 T4 会阻塞,而T5不会? 这就就涉及到了一个新概念 gap lock(注:gap lock 是RR级别独有的。RC级别只锁行,不锁空隙,文末会简单介绍)。间隙锁,锁的是两个值之间的空隙。如
SELECT * from student where student_id > 6 and student_id < 20 FOR UPDATE 不仅会锁满足条件的行(student_id = 11 与 student_id = 18),而且还会锁住他们之间空隙。如下图所示:

image.png

下面是InnoDB加锁的两个原则与两个优化(此处借鉴丁奇老师的MySQL 45 讲)。这里涉及到Next-key lock概念,Next-key lock是由 gap lock 与 典型行锁组成的。如上图 在 索引 11 处 加的 Next-key lock 就是由 6-11 的gap lock 加上 11 的典型行锁 组成的,注意next-key lock 是左开右闭的,用数学标识法 类似: ( 6 , 11 ] 。下面来看一下RR级别下 InnoDb引擎加锁的原则是:

  • 原则 1:加锁的基本单位是 next-key lock。next-key lock 是左开右闭的 。
  • 原则 2:查找过程中访问到的对象才会加锁。
  • 优化 1:索引上的等值查询,给唯一索引加锁的时候,next-key lock 退化为行锁(典型行锁)。
  • 优化 2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock 退化为间隙锁。

我们可以参考2个原则与2个优化来验证下上图的加锁逻辑。
student_id = 6 未加锁 的原因是原则2。因为条件是>6 ,所以并未访问行6,所以行6并未加锁。且也满足原则1,即next-key lock 是左开右闭的。
student_id = 25未加锁的原因是优化2。next-key lock 退化为gap lock,所以25并未加锁。
student_id = 11与 student_id = 18 加锁也是因为 原则 1与原则2。

现在我们可以回答小节开篇的问题了,为何行级锁可以解决幻读?

    1. 因为加了锁(for update 或 lock in share mode)之后,是当前读,会读取最新的已提交的记录,所以不会产生幻觉。
    1. 因为在索引间加了间隙锁,所以其他事物无法插入新的记录,也就不会产生幻读了。

补充知识1:只有执行计划真正使用了索引,才能使用行锁:即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的,如果 MySQL 认为全表扫描效率更高,比如对一些很小的表,它就不会使用索引,这种情况下 InnoDB 将使用表锁,而不是行锁。因此,在分析锁冲突时,别忘了检查 SQL 的执行计划(可以通过 explain 检查 SQL 的执行计划),以确认是否真正使用了索引。
补充知识2:更新语句默认加锁,且是当前读。在 InnoDB 事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这就是两阶段锁协议。

3.2 Lock In share mode 锁

Lock In share mode 锁也可以实现当前读, 且加锁后其他session不可以写。但当前session 可以写。 Lock In share mode 与其他事务的 for update 锁是互斥的,但与其他事务的Lock In share mode 是共享的。
如下语句的执行结果解释了上述结论。

session1session2
T1BEGIN;
T2 INSERT into student VALUES(10,'Lily',17);
T3SELECT * from student where student_id > 6 and student_id < 20
能否查到刚才插入的数据?
能,因为仅仅是begin,并未真的去获取快照,而是执行sql之后,才会生成快照。(感兴趣的可以了解下快照读)
T4 INSERT into student VALUES(12,'Lily',17);
T5SELECT * from student where student_id > 6 and student_id < 20
能否查到刚才插入的数据?
不能,在RR级别下,此刻是快照读
T6SELECT * from student where student_id > 6 and student_id < 20 LOCK IN SHARE MODE;
能否查到刚才插入的数据?
能,因为 share mode 是当前读
T7 INSERT into student VALUES(19,'Lucy',21)
是否block?
是,与Session 1 的锁冲突。
T8INSERT into student VALUES(15,'Lucy',21);
是否block?
否,同一session 申请写锁时,不会与自己申请的读锁冲突,但一般不这样做,这样容易产生死锁,下面内容会描述

4.死锁

4.1 死锁的产生与避免

既然加了锁,就难免产生死锁,下面回顾下产生死锁的四个必要条件? 

  • (1) 互斥条件:一个资源每次只能被一个进程使用。
  • (2) 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
  • (3) 不剥夺条件:进程已获得的资源,在末使用完之前,不能强行剥夺。
  • (4) 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

只要上面四个必要条件任一被打破,死锁就不会发生。在我们日常做开发使用锁时,避免死锁的方式通常是打破条件2 与条件 4。
打破2:申请锁失败后,要将之前申请的锁释放掉。不可以等待锁的同时再申请锁。
打破4:避免形成环路等待,一种简单的做法是将锁进行编号。如: 锁1 、锁2、 锁3。 申请锁时秉承的原则是 按序号递增的去申请锁,比如 线程A 需要 锁1 与 锁3, 要先申请1 再申请3,而不能申请3 再 申请 1 ,线程B 需要 锁 1与锁2,要先申请锁1再申请锁2,这样 线程A 与 线程B 申请的锁就不会形成环路了 。
那么MySql是如何解决死锁问题的呢,实际上MySql 是有死锁检测的,也就是说当产生死锁时MySql是可以检测出来的,当多个事务产生死锁时,MySql会选择一个代价较小的事务进行回滚。死锁检测会消耗一部分Mysql的性能,可以通过参数设置关掉,但不建议这样做。死锁检测关掉后,MySql可以通过锁超时机制变向解决死锁问题,当锁等待时间超出设定的阈值时会自动回滚事务。锁超时设置方法见附录。

下面就简单列举几种MySql 会产生死锁的场景。

4.2 死锁的几种场景

4.2.1 由于Share Mode 使用不当导致的死锁

如下语句使用的依然是student表。

session1session2
T1BEGIN;
SELECT * from student where student_id > 6 and student_id < 20 LOCK IN SHARE MODE;
BEGIN;
T2 SELECT * from student where student_id > 6 and student_id < 20 LOCK IN SHARE MODE;
T3 INSERT into student VALUES(19,'Lucy',21);
T4INSERT into student VALUES(19,'Lucy',21);
dead lock

死锁的原因:
session 1 在T1时刻申请了读锁,session 2 在T2 时刻也申请了读锁。session 2 在T3时刻申请写锁时,要等待session 1的读锁。session 1在T4时刻申请写锁时,要等待session2的读锁。形成了环路等待,导致了死锁。

所以 使用Lock in share mode 时,不建议更新记录,很容易造成死锁并产生回滚。

4.2.2 由于更新顺序不当引起的死锁
CREATE TABLE `t` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `v` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB;
session1session2
T1BEGIN;
update t set v = v+1 where id = 1;
T2 begin;
update t set v = v+1 where id = 2;
T3update t set v = v+1 where id = 2;
T4 update t set v = v+1 where id = 1;
deadlock

死锁的原因:
session 1 在 T1 时刻 将表 t 的 id=1行加了 典型行锁。 session 2 在 T2 时刻 将表t的 id = 2行加了典型行锁。 T3 时刻 session 1要申请 id= 2行的写锁,此事因为 id=2行的写锁已经被session 2 申请了,所以session 1 要等待session 2释放 id=2的写锁。T4时刻 session 2 要申请 id=1的行锁,但id=1的行锁被session 1 占用着。所以session 1与session2形成了环路等待,进而导致了死锁。

4.2.3 并发争抢产生的死锁
CREATE TABLE `t` (
  `c` int(11) DEFAULT NULL,
  `d` int(11) DEFAULT NULL,
  PRIMARY KEY (`c`)
) ENGINE=InnoDB;
session1session2session3
T1BEGIN;
INSERT into t VALUES(5,5);
T2 INSERT into t VALUES(5,5);
block
T3 INSERT into t VALUES(5,5);
block
T4ROLLBACK;Affected rows: 1Deadlock found

关于这种情况的死锁,说实话,我并未找到合理的原因解释。如果你有合理的解释,欢迎评论区解惑。

5.总结

回顾下本篇内容,首先介绍了MySql中有哪些锁及这些锁的基本概念,让大家对锁有个全局的认知。其次,通过数据一致性与幻读场景引出为什么要加锁,以及InnoDB中的行级锁是如何工作的。最后介绍了几种产生死锁的场景。本文中的Sql 语句均可直接拷贝至MySql运行,所以建议大家都能亲自动手执行下,这样印象会更加深刻。

最后再次强调,以上实验均为RR级别。如果隔离级别改为 读已提交RC(read committed),则 for update ,in share mode 等行级锁,则退化成典型行锁,即不会加 gap lock。 如果在RC 隔离级别下执行 3.1 节例子,则会由不一样的结果,感兴趣的你不妨试一下 。

session1session2
T1BEGIN;
SELECT * from student where student_id > 6 and student_id < 20 FOR UPDATE;
T2 SELECT * from student where age > 6 and age < 20;
block?
no
T3 INSERT into student VALUES(17,'Lily',17);
block?
no
T4 INSERT into student VALUES(21,'Lily',21);
block?
no
T5 NSERT into student VALUES(26,'Lily',26);
block?
No

附录

查看隔离级别

 show variables like 'transaction%';

设置隔离级别

 --可重复读
 set global transaction isolation level repeatable read;
 --读已提交
 set global transaction isolation level read committed;

查看mysql版本

select version();

设置锁超时时间

 SHOW GLOBAL VARIABLES LIKE 'innodb_lock_wait_timeout';
 SET GLOBAL innodb_lock_wait_timeout=3600;

image.png


猴赛雷技术交流
81 声望29 粉丝