一、为什么需要锁
从踏入程序员的圈子伊始,就伴随着各"锁"事。乐观锁vs悲观锁,本地锁vs分布式锁,重入锁vs不可重入锁,公平锁vs非公平锁,运气差些偶尔还可以在线上遇到个死锁。这些锁面试问,工作用,因为出现的频次高,难免会让人提高关注度,那为什么会有这么多锁的名词定义呢?
试想一下,你在路边找到一辆共享单车,开锁后开心的骑着,享受着眼中美好的一切,突然来了几个大汉掰开你的手就要抢车,拉扯过程中谁也骑不了车;又或者正在上手工课的你,正用着班上唯一的一把剪刀裁纸,偏偏在你即将完成的时候,同桌开始跟你争抢这把剪刀,最后谁也没有用成。这种无序混乱的生活,会让每个人都无法合理的享受资源。
锁的出现,就是为了解决共享资源的并发使用。在MySQL的InnoDB存储引擎中,如果是RC或者RR模式,会通过MVCC(多版本并发控制)更好地解决多个事务的并发【读+写】问题;但是如果在多个事务并发【写+写】的情况下,就必须要用到锁了,一般情况下,数据库的锁都是在有数据库操作的过程中自动添加的。
二、MYSQL中索引的结构与锁的关系
mysql的锁都加在表上或者索引上,可能是主键/非主键索引,唯一/非唯一索引。
从B+树视角看如下:
从记录视角看如下:
三、InnoDB中的锁分类
刚入行或者入行多年的同学,最好还是养成上官网翻文档的习惯,权威且不会出现多次转载导致的信息偏差,原文可见(官网原文)。本文基于mysql5.6.36-82.1-log版本进阐述,不同版本间略有不同,以实际运行版本为准。
3.1 Shared and Exclusive Locks
从高维来分类,共享锁和排他锁,冲突情况如下:
X | IX | S | IS | |
---|---|---|---|---|
X | ❌ | ❌ | ❌ | ❌ |
IX | ❌ | ✅ | ❌ | ✅ |
S | ❌ | ❌ | ✅ | ✅ |
IS | ❌ | ✅ | ✅ | ✅ |
3.2 Next-Key Locks
A next-key lock is a combination of a record lock on the index record and a gap lock on the gap before the index record.
Next-Key是record锁和gap锁的组合。RC级别下没有,在RR级别下才有,如下:(-∞, 15],(15, 18],(18, 50],(50, +∞),前3个都是Next-Key锁,最后一个是gap锁。
3.3 Insert Intention Locks
An insert intention lock is a type of gap lock set by INSERT operations prior to row insertion. This lock signals the intent to insert in such a way that multiple transactions inserting into the same index gap need not wait for each other if they are not inserting at the same position within the gap
插入意向锁是一种特殊的间隙锁(简写成 II GAP)表示插入的意向,只有在INSERT的时候才会有这个锁。
3.4 Record Locks
A record lock is a lock on an index record. For example, SELECT c1 FROM t WHERE c1 = 10 FOR UPDATE; prevents any other transaction from inserting, updating, or deleting rows where the value of t.c1 is 10.
记录锁是最简单的行锁,无需赘述
3.5 Gap Locks
A gap lock is a lock on a gap between index records, or a lock on the gap before the first or after the last index record
间隙锁是一种加在两个索引之间的锁,或者加在第一个索引之前,或最后一个索引之后的间隙。
四、不同场景下加锁情况
我们先来看下不同类型锁的冲突情况:
持有的锁\待加的锁 | gap | insert intention | record | next-key |
---|---|---|---|---|
gap | ✅ | ❌ | ✅ | ✅ |
insert intention | ✅ | ✅ | ✅ | ✅ |
record | ✅ | ✅ | ❌ | ❌ |
next-key | ✅ | ❌ | ❌ | ❌ |
总结如下:
- Insert Intention Locks不影响其他任何事务加其他任何锁,一个事务已经获得了Insert Intention Locks,对别的事务加锁是没有影响的
- Insert Intention Locks、Gap Locks、Record Locks其实就是就是InnoDB为了解决在写-写场景的幻读引入的(RR隔离级别才有)
接下来我们开始实操下命中不同索引场景下,不同锁类型的实际表现。为了展示的更清晰,实操中的图解均只绘制了叶子结点。
开始前先将事务隔离级别设为RR,然后我们先创建一个测试表如下:
create table trx_test_v2
(
id bigint unsigned auto_increment comment 'id' primary key,
staff_code varchar(32) default '' not null comment '员工工号',
age int default 0 not null comment '年龄',
job varchar(32) null comment '工作岗位',
create_time timestamp default CURRENT_TIMESTAMP not null comment '创建时间',
update_time timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '修改时间',
constraint uniq_age unique (age),
index idx_staff_code (staff_code)
) comment '事务锁测试表';
其中id是主键,age是唯一索引,staff_code是普通索引,job无索引,我们初始化了3条记录:
然后我们分别看下不同索引类型(列),在不同命中条件(行)下,索引加锁情况
4.1、主键索引 & 等值命中
# 事务1
begin;
select * from trx_test where id = 1 for update;
# 事务2
begin;
select * from trx_test where id = 1 for update;
事务2获取锁超时,事务1的next-key锁退化为record锁
(tips:只会作用在主键索引上)
4.2、主键索引 & 等值未命中
# 事务1
begin;
delete from trx_test where id = 2;
# 事务2
begin;
insert into trx_test(id,staff_code,age) values (2,'1005',16);
事务2获取锁超时,事务1因为删除记录不存在,所以next-key锁退化为gap锁
,因为事务1持有gap,所以事务2无法获得insert intention锁(tips:只会作用在主键索引上)
4.3、主键索引 & 范围命中
# 事务1
begin;
select * from trx_test where id > 1 and id <= 5 for update;
# 事务2
begin;
select * from trx_test where id = 5 for update;
# 事务3
begin;
insert into trx_test(id,staff_code,age) values (6,'1005',16);
事务2、事务3都会被阻塞,事务2好理解,因为id=3上持有next-key锁
,但是事务3为什么会被阻塞?这是因为在MySQL的5.x系列<=5.7.24,8.0系列 <=8.0.13的版本中,其中的"一个bug",就是索引上的范围查询会访问到不满足条件的第一个值为止
,也就是在上面的例子中虽然扫描到了id=5的索引,还要继续向后扫描,所以还要对正无穷加next-key锁
4.4、主键索引 & 范围未命中
# 事务1
begin;
select * from trx_test where id > 1 and id < 3 for update;
# 事务2
begin;
insert into trx_test(id,staff_code,age) values (2,'1005',16);
# 事务3
begin;
insert into trx_test(id,staff_code,age) values (4,'1005',16);
事务2会被阻塞,事务3不会被阻塞,如图会加一把next-key锁
4.5、唯一索引 & 等值命中
# 事务1
begin;
select * from trx_test where age=30 for update;
# 事务2
begin;
select * from trx_test where id=3 for update;
事务2会被阻塞,因为在主键索引和唯一索引上都上了一把record锁
,但是额外注意的是,age字段现在是数字类型,如果是字符串类型,再用数字类型进行匹配,MySQL为了避免直接报错会尝试进行隐式转换,把数据库中的age列使用函数转换为和sql中的类型一致的数据再进行等值判断,而对索引列使用函数时查询不走索引,可以用explain验证执行计划(表字段是数字,查询用字符串,索引生效;表字段是字符串,查询是数字,索引失效
)
4.6、唯一索引 & 等值未命中
# 事务1
begin;
select * from trx_test where age = 25 for update;
# 事务2
begin;
insert into trx_test(id,staff_code,age) values (2,'1005',26);
事务2会被阻塞,因为在唯一索引上了一把gap锁
,但是并没有影响主键索引
4.7、唯一索引 & 范围命中
# 事务1
begin;
select * from trx_test where age > 10 and age < 25 for update;
# 事务2
begin;
select * from trx_test where age = 30 for update;
# 事务3
begin;
select * from trx_test where id = 5 for update;
# 事务4
begin;
select * from trx_test where id = 3 for update;
事务2和事务3会被阻塞,事务4不会被阻塞,因为在唯一索引上加了如图next-key锁
,同时在对应的主键索引上也上了record锁,可以发现id=5的主键上了record锁,但是id=3没有上,说明主键索引只有在二级索引真正命中的时候才会上锁
4.8、唯一索引 & 范围未命中
# 事务1
begin;
select * from trx_test where age > 13 and age < 18 for update;
# 事务2
begin;
select * from trx_test where age = 20 for update;
# 事务3
begin;
select * from trx_test where id = 5 for update;
事务2会被阻塞,因为唯一索引加上了如图的next-key
,但是事务3不会被阻塞,说明主键索引没有受影响
4.9、普通索引 & 等值命中
# 事务1
begin;
select * from trx_test where staff_code = '1005' for update;
# 事务2
begin;
select * from trx_test where id = 5 for update;
# 事务3
begin;
insert into trx_test(id,staff_code,age) values (4,'1008',19);
# 事务4
begin;
insert into trx_test(id,staff_code,age) values (4,'1002',19);
# 事务5
begin;
select * from trx_test where staff_code = '1005' for update;
事务2、3、4、5都会被阻塞,在二级索引会上next-key锁和gap锁
,同时在会给对应记录的主键索引上record锁
4.10、普通索引 & 等值未命中
# 事务1
begin;
select * from trx_test where staff_code = '1006' for update;
# 事务2
begin;
insert into trx_test(id,staff_code,age) values (4,'1008',19);
事务2会被阻塞,因为在二级索引会上gap锁
,主键索引不会加锁
4.11、普通索引 & 范围命中
# 事务1
begin;
select * from trx_test where staff_code > '1003' and staff_code < '1007' for update;
# 事务2
begin;
select * from trx_test where id = 5 for update;
# 事务3
begin;
select * from trx_test where staff_code = '1010' for update;
# 事务4
begin;
insert into trx_test(id,staff_code,age) values (4,'1002',19);
# 事务5
begin;
insert into trx_test(id,staff_code,age) values (4,'1008',19);
# 事务6
begin;
select * from trx_test where id = 3 for update;
事务2、3、4、5会被阻塞、事务6不会被阻塞,会在二级索引上加next-key锁
,在主键索引上加record锁
4.12、普通索引 & 范围未命中
# 事务1
begin;
select * from trx_test where staff_code > '1002' and staff_code < '1004' for update;
# 事务2
begin;
insert into trx_test(id,staff_code,age) values (4,'1003',19);
# 事务3
select * from trx_test where staff_code = '1010' for update;
事务2、3会被阻塞,会在二级索引上加next-key锁
,不会在主键索引上加锁
4.13、不走索引
全索引全记录上next-key锁
,看起来跟锁表的效果是一样的
4.14、不同场景下加锁总结
- 唯一索引和普通索引如果命中记录,都会先锁二级索引,再锁主键索引
- 唯一索引和普通索引的范围查询,只有真实命中纪录的,才会锁主键
- 唯一索引比普通索引更严谨,所以有机会从next-key退化成gap or record,普通索引不可以
- 不同索引树之间不会相互影响,除非都命中相同的主键
索引类型\命中条件 | 等值命中 | 等值未命中 | 范围命中 | 范围未命中 |
---|---|---|---|---|
主键索引 | 命中主键加record | 退化为gap | 命中加record 未命中上界加next-key | next-key |
唯一索引 | 命中主键加record 命中唯一索引加record | 退化为gap | 命中加record(主键&唯一索引) 未命中上界加next-key | next-key |
普通索引 | 命中主键加record 命中普通索引加next-key | 退化为gap | 命中加record(主键&普通索引) 未命中上界加next-key | next-key |
不走索引 | 全索引next-key | 全索引next-key | 全索引next-key | 全索引next-key |
五、波谲云诡的死锁
搞清楚何时上锁,上什么锁,哪些锁相互兼容or冲突后,分析死锁问题就容易多了,不过在举例子之前,我们同样要先复习下引起死锁的四个条件:
- 资源互斥
- 请求保持
- 不可剥夺
- 循环依赖
下面我们举个栗子来看下最常见的先删后插引起的死锁:
# 事务1
begin;
delete from trx_test where age = 15; # 时间1
insert into trx_test(id,staff_code,age) values (99,'1003',15);# 时间3
# 事务2
begin;
delete from trx_test where age = 15;# 时间2
insert into trx_test(id,staff_code,age) values (100,'1003',16);# 时间4
有事务1和事务2,发生时间顺序如上图所示,事务1和事务2都拥有gap锁,但是二者在插入的时想获取insert intention时发现都在等对方事务释放gap,引发死锁,其他死锁同理分析。 如何解决呢?
- 降低你的隔离级别,从RR降低到RC。
- 先查主键id,再删除,将影响索引树的个数,和影响记录范围降到最低。
六、总结
- MYSQL的锁作用在表和索引树上,不同索引树(主键索引、唯一索引、普通索引)之间不会相互影响,除非命中相同的主键索引。
- 普通索引约束性最弱,所以加锁加的越蛮横,其次是唯一索引,再者主键索引。
- 想玩儿转MYSQL的锁,需要从他解决什么问题,结合什么样的数据结构,并亲自动手验证来加深自己的理解,很多技术工作者容易浅尝辄止,除非你天天都会用到,否则很容易过几年就忘记原理,所以不要怕麻烦,尽可能深挖。