前言
1、记录锁和间隙锁需要理解索引底层结构,推荐B站博主蓝不过海呀动画讲解视频:MySQL索引底层使用的B+树结构
2、数据库事务并发调度:数据库事务是并发的,同一时刻可以有多个事务共同执行
3、可以先看MVCC是怎么实现的,MVCC是无锁实现并发控制
锁分类
以操作类型划分
- 写锁(排他锁/X锁):写数据前给表加锁
- 读锁(共享锁/S锁):读数据前给表加锁
以锁粒度划分
- 全局锁:锁定数据库中所有的表
- 表级锁:锁定数据库中指定的一张表
- 表锁:写锁或者读锁
- 元数据锁:锁定表结构,在锁定期间,不允许进行
CRUD操作 - 意向锁:使用意向锁可以支持多粒度锁,写锁和读锁同时存在且不用整表检查是否加锁,减小加锁解锁开销,提高并发度
- 意向排他锁(IX)
- 意向共享锁(IS)
- 行级锁:锁定数据库表中某一行或者多行记录
- 记录锁(行锁):锁定一行记录,
RR,RC隔离级别下支持 - 间隙锁:锁定某个范围内的行记录,比如主键ID存在1,3,5,6;查找ID为4的数据时,会将ID(3,5)的记录加锁,防止在读取过程中其他事务执行
insert出现幻读现象,RR隔离级别下支持 - 临键锁:临键锁=记录锁+间隙锁,是
RR隔离级别下MVCC为了防止幻读所使用的锁
- 记录锁(行锁):锁定一行记录,
以实现思想划分
- 乐观锁:认为一定不会发生冲突,在操作数据前不加锁,如果发现冲突,就重试或者根据调用方的冲突解决方法去执行
- 悲观锁:认为一定会发生冲突,再操作数据前先加锁,MySQL中的锁类型都可以认为是悲观锁的体现
以加锁形式划分
- 显示锁:通过SQL语句
for update,for share进行加锁 - 隐式锁:不同数据库引擎的隔离级别下,执行
update,select等SQL语句默认加锁
封锁协议
仅看锁的实现可以跳过这里
封锁协议:数据库封锁协议和两段锁协议
下面是介绍锁的具体实现
如果是显示加锁,请记得一定要手动解锁,不然事务会持续占有锁,导致后续事务无法执行,消耗CPU资源,隐式锁在事务执行commit时会自动解锁
全局锁
对整个数据库进行加锁,一般用于数据库备份期间禁止其他事务执行SQL语句,防止备份过程中出现数据不一致
实现示例
# 加全局锁
flush tables with read lock;
# 数据备份,物理备份
mysqldump -u 用户名 -p 数据库名 > C:\Users\yun\Desktop\数据库名.sql;
# 释放全局锁
unlock tables;
mysqldump命令是在安装MySQL的bin目录下有一个mysqldump.exe,可以将这个命令添加到环境变量,然后win环境下在cmd窗口执行
表级锁
表锁
读锁
lock tables 表名 read
写锁
lock tables 表名 write
示例
- 事务A:先对user表加读锁,查询user表信息
- 事务B:查询user表信息,修改ID5的age为20,获取写锁
1、事务A先加了读锁,加完后事务A和事务B读操作都不会被阻塞,事务B再执行写操作,发现回车后一直是空白,表示当前执行被阻塞了
2、事务A释放读锁后,事务B写操作才执行成功
3、事务B获取写锁,事务A执行查询和更新操作,可以看到事务A的读写都被阻塞,同样是释放锁之后,事务A执行成功
元数据锁
元数据锁主要是锁表的结构,防止在进行数据库备份或者表备份时执行了CRUD操作导致的数据不一致问题,日常使用可能没有太多感觉,这里不做介绍,感兴趣可以跳转到目录参考文章查看
意向锁
为什么需要意向锁
在对数据库表加表锁时,需要遍历表中每一行记录看看是不是加了行锁,如果已加行锁需要进行等待,当表数据量达到一定程度时,遍历的时间会加长,同时,如果遍历的过程中,已遍历的数据出现了加锁现象(数据库事务是并发执行的),那这种情况是不是又要重新遍历一遍,如此循环反复,那就出现了死循环了
引入意向锁后,在加行锁前,先获取到对应的意向锁;对表加表锁时,只需要判断是否存在冲突的意向锁,存在就阻塞,就不用遍历了
加锁方式
- 意向共享锁IS
select * from user lock in share mode;
- 意向排他锁IX
# 所有的更新操作语句都是:
insert into user ...;
update user set ...;
delete from user ...;
# select 语句写法
select * from user for update;
不同意向锁之间的共存
| 已持有锁 \ 请求锁 | 共享锁(S) | 排他锁(X) | 意向共享锁(IS) | 意向排他锁(IX) |
|---|---|---|---|---|
| 共享锁(S) | 兼容 | 不兼容 | 兼容 | 不兼容 |
| 排他锁(X) | 不兼容 | 不兼容 | 不兼容 | 不兼容 |
| 意向共享锁(IS) | 兼容 | 不兼容 | 兼容 | 兼容 |
| 意向排他锁(IX) | 不兼容 | 不兼容 | 兼容 | 兼容 |
意向锁之间共存,只有在加完后确定要加X或者S锁时才会发生冲突触发检查
示例
示例一:
- 事务A:对user表加意向共享锁,读数据
- 事务B:对user表加意向排他锁
这里为什么事务B加意向排他锁会被阻塞,不是意向锁之间共存吗?
原因是因为这里查询的是一整张表,事务A在对表user加意向共享锁IS后,查询数据时对表user加了S锁,事务B在对表加意向排他锁IX之后,由于S锁和意向排他锁IX不兼容,所以进行了阻塞等待,再往下看另外一个例子
示例二:
- 事务A:对表user中ID5的数据加意向共享锁,查询数据
- 事务B:对表user中ID4的数据加意向共享锁,查询数据,对ID3加意向排他锁同时修改年龄为22,修改ID5的数据
在user表中,由于使用了where查询,且id为主键,使用到了聚簇索引,在此索引基础上,使用意向锁时,加的S锁或者X锁就是行级S锁、行级X锁;在事务A、B并发执行过程中,查询时先校验是否可以获取到IS锁,在写入时判断是否可以获取到IX锁,具体执行过程中还需要对对应数据加S锁或者X锁
注意在事务B中,先对ID3加了意向共享锁,后加了意向排他锁,为什么IX没有被阻塞,因为同一个事务中,数据库引擎会将锁优化成意向排他锁,意向锁更像是告诉别的事务,我可能做什么,而自身事务中,不会出现修改冲突
S锁和X锁可以是表级别,也可以是行级别,看查询语句是什么样的,这里更深层次的原因其实是索引树的原理,感兴趣可以查看索引的底层结构,当查询全表时,那S锁就是表级别,当查询的是具体的一条记录时,比如使用主键进行查询,以MySQL为例,索引树的叶子节点存储的是主键所在的地址,那就可以回表进行查询,这时候S锁就是行级别
行级锁
记录锁
其实可以理解为S锁和X锁,只不过这里锁定的是指定的行记录而不是整张表
# 行级S锁
select * from user where id = 5 lock in share mode;
# 行级X锁
update user set age = 18 where id = 5;
示例
- 事务A:对ID5记录加写锁
- 事务B:对ID3记录加读锁
事务A对ID5加排他锁后,事务B想要再加排他锁时会阻塞,但是对ID3加共享锁可以,对ID2加排他锁也可以
间隙锁
间隙锁相当于给区间加锁,以下面这张图为例,数据库中存在主键1,3,5,7,9,索引树底层叶子节点如下,注意此时表中没有ID为4的记录,那如果现在有两个事务要插入ID为4的记录怎么办?两个事务并发插入会触发主键冲突
这时候引入的间隙锁,事务A把ID为3的记录到ID为5的记录一整个区间(3,5)进行锁定注意这里左右取开区间,然后插入ID为4的记录,此时事务B再插入ID4的记录由于没有获取到锁就会失败
示例
表user现在有ID从1-5的记录,删除记录3,记录4
- 事务A:给2-5记录加间隙锁,插入记录4
- 事务B:插入记录3,记录4
事务A给ID4的记录加锁后,由于记录不存在,锁优化成了间隙锁(2,5)区间加锁,如果加的不是间隙锁,事务B在进行ID3记录插入时应该是成功的而不是失败,因为在事务A中我们执行的是ID4的记录加锁
临键锁(Next-Key Lock)
间隙锁的升级版本,也是MVCC解决幻读问题所使用的一种方式,由记录锁和间隙锁共同构成,临键锁锁定的区间是左开右闭(2,5],其实现方式和间隙锁相同
乐观锁和悲观锁
乐观锁是无锁的实现方式,基于CAS思想,MySQL通过在数据库表中加version字段实现,每一次写操作需要对比version是否为原先读取的值,同时SQL执行成功后需要对version+1
CAS思想
CAS(Compare And Swap,比较并交换)思想,是一种无锁并发控制思想,实现逻辑是通过预留值进行比较,新值等于预期旧值认为是没有被更改过,可以执行
仅当内存中变量V的实际值 等于 预期旧值A时,才将V更新为目标新值B;如果不相等,说明变量已被其他线程 / 事务修改,本次更新直接放弃或者重试(不阻塞、不报错)
示例
数据库表:
create table user (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`username` varchar(20) DEFAULT NULL COMMENT '用户名',
`age` int DEFAULT NULL COMMENT '年龄',
`sex` int DEFAULT NULL COMMENT '性别:0-男,1-女',
`address` varchar(15) DEFAULT NULL COMMENT '地址',
`version` bigint NOT NULL COMMENT '版本号',
PRIMARY KEY (`id`),
);
初始记录:
| id | username | age | sex | address | version |
|---|---|---|---|---|---|
| 1 | xiaoming | 18 | 0 | 广东省 | 0 |
1、事务A读取ID1的version为0
select * from user;
2、事务B读取ID1的version为0
select * from user;
3、事务B更新age为20,每一次写操作需要比较version是否为一开始读取的值,成功匹配执行之后version需要加1
update user set age = 20, version = version + 1 where id = 1 and version = 0;
4、注意此时version为1,记录变更为:
| id | username | age | sex | address | version |
|---|---|---|---|---|---|
| 1 | xiaoming | 20 | 0 | 广东省 | 1 |
5、当事务A更改age为22时,会更新失败,因为此时version不再是0
update user set age = 22, version = version + 1 where id = 1 and version = 0;
悲观锁
加锁的的形式都是悲观锁的体现,不管是哪种类型的锁
总结
由于乐观锁需要额外维护一个字段,且当冲突发生时需要不断重试,在高并发场景会比较消耗资源,因此应用场景为读操作比较多的,大部分场景还是使用的悲观锁,即各种锁的类型
参考文章
文章参考自MySQL锁、加锁机制(超详细)—— 锁分类、全局锁、共享锁、排他锁;表锁、元数据锁、意向锁;行锁、间隙锁、临键锁;乐观锁、悲观锁,这篇文章讲解更加详细,同时也感谢此篇文章的作者,数据库锁的种类繁多,文章对于锁的介绍很详细,受益匪浅。