背景
看到许多写select for update是行锁还是表锁的文章,但整篇文章的推论似乎都不太一样。同时,是行锁还是表锁的问题直接影响着系统的性能,所以特意为你们督查一番,也就有了本篇文章,一共为你们汇总验证了20个场景下的推论。
对于软件或框架来说,特别是在有大版本更新的情况下,脱离了具体版本的推论常常是无意义的。针对这个问题,网络上之所以有多个版本的答案,最主要的诱因就是脱离MySQL的版本以及事务隔离级别。
本文就基于两个MySQL版本(5.7.x、8.0.x)、两种常见事务隔离级别(读已递交、可重复读)来逐一验证。总共有四大类情况,20个小场景。最后,再给你们汇总一个结论性的验证结果。大家可以收藏,已备用到时查阅对照。
通过阅读本文,你除了能才能学到相关的推论,同时也提供了一套科学的实验方法论,个人认为前者对你们来说更为重要。
环境打算
在验证之前,我们先打算好具体的环境和数据。
建表句子:
CREATE TABLE `user` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`user_no` varchar(16) DEFAULT NULL COMMENT '用户编号',
`user_name` varchar(16) DEFAULT NULL COMMENT '用户名',
`age` int(3) DEFAULT NULL COMMENT '年龄',
`address` varchar(128) DEFAULT NULL COMMENT '地址',
PRIMARY KEY (`id`),
UNIQUE KEY `un_idx_user_no` (`user_no`),
KEY `idx_user_name` (`user_name`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;
复制代码
初始化数据:
insert into user values(null,'0001','user01',18,'北京');
insert into user values(null,'0002','user02',19,'上海');
insert into user values(null,'0003','user03',20,'广州');
insert into user values(null,'0004','user04',21,'深圳');
insert into user values(null,'0005','user05',22,'杭州');
复制代码
数据库版本:
版本一:
>select @@version;
5.7.22
版本二:
>select @@version;
8.0.18
复制代码
查询数据事务隔离级别:
>select @@transaction_isolation;
REPEATABLE-READ
复制代码
MySQL innodb支持的四种事务隔离级别:
设置全局隔离级别:
set global transaction isolation level REPEATABLE READ;
set global transaction isolation level READ COMMITTED;
复制代码
设置会话隔离级别:
set session transaction isolation level REPEATABLE READ;
set session transaction isolation level READ COMMITTED;
复制代码
关闭手动递交:
> set @@autocommit=0; //设置自动提交关闭
复制代码
在执行完锁句子以后,可执行commit命令进行事务递交。
commit;
复制代码
准备完以上数据,便可以开始每一个场景的验证了。每个场景都起了一个编号,比如:V5.x-RR-主键,表示在MySQL 5.7.x,事务隔离级别为RR(可重复读),条件数组为字段的场景下进行的实验。
场景1.1:V5.x-RR-主键
操作:使用外键ID作为条件查询,然后新开启一个事务去更新数据。
分析思路:一,如果更新数据被阻塞,则说明加锁成功;二,如果更新其他数据成功,则说明是行锁,如果更新其他数据失败则说明是表锁。三,部分场景会测试插入操作;后续所有操作基本雷同。
执行消极锁查询:
select * from user where id = 1 for update;
复制代码
执行更新操作:
update user set age = age +1 where id = 1;
复制代码
此处更新操作被阻塞,说明数据锁定成功。
在此场景下,来看一下数据库加的哪些锁。
当第二条句子被阻塞时,执行查看锁信息句子:
SELECT * FROM INFORMATION_SCHEMA.INNODB_LOCKS;
复制代码
注意,必须是正在执行第二条句子,且第二条句子处于阻塞状态下,上述句子就能查询到数据。
查询结果如下:
第二条记录为for update锁表句子,第一条记录为单纯的update句子。可以看出,此场景下,lock_mode为X,lock_type为RECORD,lock_data为1。
lock_mode为X(排他锁):即写锁,允许获得排他锁的事务更新数据,阻止其他事务取得相同数据集的共享读锁和排他写锁。
lock_type为RECORD,说是是行级锁,lock_data表示锁定了1条记录。
执行更新其他记录操作:
update user set age = age +1 where id = 2;
复制代码
执行成功。
结论:当查询条件为字段时,select for update为行级锁。
当我们执行完一个场景以后,我们须要执行commit命令将当前事物递交。
场景1.2:V5.x-RR-唯一索引
执行消极锁操作:
select * from user where user_no = '0001' for update;
复制代码
执行更新操作:
update user set age = age +1 where id = 1;
复制代码
此处更新操作被阻塞,说明数据锁定成功。
查询锁信息,同场景一的字段一致。
执行更新其他记录操作:
update user set age = age +1 where id = 2;
复制代码
执行成功。
结论:当查询条件为惟一索引时,select for update为行级锁。
场景1.3:V5.x-RR-普通索引
执行消极锁操作:
select * from user where user_name = 'user01' for update;
复制代码
执行更新操作:
update user set age = age +1 where id = 1;
复制代码
此处更新操作被阻塞,说明数据锁定成功。
查询锁信息:
此时,锁类型不仅仅是X排他锁,同时还添加了GAP(间隙锁),也就是说针对数据添加了排他间隙锁。
执行更新其他记录操作:
update user set age = age +1 where id = 2;
复制代码
执行成功。
此时再进行一笔插入操作:
insert into user values(null,'0006','user05',23,'重庆');
复制代码
执行成功。
由于存在了间隙锁,再执行一笔user_name与查询条件相同的插入操作:
insert into user values(null,'0008','user01',24,'成都');
复制代码
执行阻塞,说明此时有排他间隙锁的存在。
结论:当查询条件为普通索引时,select for update为行级锁,同时会有排他间隙锁存在,当插入数据满足锁句子查询条件(相等、范围等)时,会发生阻塞。
场景1.4:V5.x-RR-无索引
执行消极锁操作:
select * from user where address = '北京' for update;
复制代码
执行更新操作:
update user set age = age +1 where id = 1;
复制代码
此处更新操作被阻塞,说明数据锁定成功。
执行更新其他记录操作:
update user set age = age +1 where id = 2;
复制代码
执行被阻塞。
此时查询锁表信息展示如下:
这里比较奇怪是lock_type,很明显,上述锁操作早已锁住了整张表,但lock_type仍然为RECORD。出处暂时有些疑惑。
结论:当查询条件无索引时,select for update为表级锁。
场景1.5:V5.x-RR-索引-范围查询
执行消极锁操作:
select * from user where id > 1 for update;
复制代码
执行更新操作:
update user set age = age +1 where id = 1;
复制代码
执行成功,说明并没有锁定id为1的记录。
执行插入操作:
insert into user values(null,'0007','user07',24,'武汉');
复制代码
插入操作被阻塞。这是因为插入的数据生成的id满足小于1的条件,会被阻塞。
所信息如下:
此时,lock_type其实是RECORD,但是lock_data显示supremum pseudo-record ,这就是InnoDB为了解决幻读问题的临键锁(Next-key Lock),这里间隙锁和临键锁可以看做是一样的。
需要注意的是:supremum pseudo-record有可能是间隙锁,需要结合死锁日志里的heap no判定。heap no 1是间隙锁。
结论:当查询条件有索引且查询条件为范围时,select for update会采用间隙锁或临键锁,对指定范围内的数据进行加锁。当然,当查询条件无索引时,与场景1.4一致,为表锁。
场景2.1:V8.x-RR-主键
执行消极锁查询:
select * from user where id = 1 for update;
复制代码
执行更新操作:
update user set age = age +1 where id = 1;
复制代码
此处更新操作被阻塞,说明数据锁定成功。
查看数据库对应的锁:
SELECT * FROM performance_schema.data_locks;
复制代码
注意,在MySQL 8中,采用了performance_schema取代了MySQL5中基于INFORMATION_SCHEMA的锁查询方法。
上述查询结果中,有两条记录。lock_type数组展示锁范围,lock_mode数组展示了锁的类型。可以看见,该SQL句子先是在表范围上加了一把IX(意向排他锁,表锁)。然后,在记录(Record)范围上添加了一把X(排他锁),一把REC_NOT_GAP(行锁),综合上去就是对这条记录添加了行级排他锁,其他事务不能否再对其添加任何锁了。
这里,既然在表的层面上添加了IX(意向排他锁),为什么不是表锁呢?这是因为意向排他锁的作用仅仅表名意向的锁,当其他事务要对全表的数据进行加锁时,那么就不需要判定每一条数据是否被加锁了。
事务在给一行记录加排他锁前,必须先取得该表的IX锁,意向排他锁之间互相兼容,可以并行,不会形成冲突。意向排他锁存在的意义是为了更高效的获取表锁,主要目的是显示事务正在锁定某行或则企图锁定某行。
继续实验,执行更新其他记录操作:
update user set age = age +1 where id = 2;
复制代码
执行成功。
结论:当查询条件为字段时,select for update为行级锁。
场景2.2:V8.x-RR-唯一索引
执行消极锁操作:
select * from user where user_no = '0001' for update;
复制代码
执行更新操作:
update user set age = age +1 where id = 1;
复制代码
此处更新操作被阻塞,说明数据锁定成功。
查询锁信息:
此时,可以看见三把锁,一把表级别的IX锁,一把基于惟一索引的行级排他锁,一把基于字段的行级排他锁。
执行更新其他记录操作:
update user set age = age +1 where id = 2;
复制代码
执行成功。
结论:当查询条件为惟一索引时,select for update为行级锁。
场景2.3:V8.x-RR-普通索引
执行消极锁操作:
select * from user where user_name = 'user01' for update;
复制代码
执行更新操作:
update user set age = age +1 where id = 1;
复制代码
此处更新操作被阻塞,说明数据锁定成功。
查询锁信息:
此时,可以看见四把锁,一把表级别的IX锁,一把基于普通索引的X排他锁,一把基于字段的行级排他锁,一把基于普通索引的X,GAP排他间隙锁。
执行更新其他记录操作:
update user set age = age +1 where id = 2;
复制代码
执行成功,说明更新操作没有影响。
既然有排他间隙锁,此时需再测试一笔插入操作:
insert into user values(null,'0006','user05',23,'重庆');
复制代码
执行成功。
再执行一笔插入操作:
insert into user values(null,'0007','user01',24,'武汉');
复制代码
注意这儿插入的记录user_name与锁查询条件相同,发现操作被阻塞。
通过两笔插入操作可以看出,排他间隙锁会阻塞符合查询条件(user_name='user01')的数据的插入。
结论:当查询条件为普通索引时,select for update为行级锁,同时会多一把排他间隙锁,如果插入数据满足锁句子的查询条件(等于、范围条件等),则难以插入。
场景2.4:V8.x-RR-无索引
执行消极锁操作:
select * from user where address = '北京' for update;
复制代码
执行更新操作:
update user set age = age +1 where id = 1;
复制代码
此处更新操作被阻塞,说明数据锁定成功。
查询锁信息:
此时,数据库一共加了8把锁,一把表级别的IX意向排他锁,6把基于字段的针对数据记录(总共6条)的X锁,一把针对记录的supremum pseudo-record锁。
执行更新其他记录操作:
update user set age = age +1 where id = 2;
复制代码
执行被阻塞。
结论:当查询条件无索引时,select for update为表级锁。
场景2.5:V8.x-RR-索引-范围查询
执行消极锁操作:
select * from user where id > 1 for update;
复制代码
执行更新操作:
update user set age = age +1 where id = 1;
复制代码
执行成功,说明并没有锁定id为1的记录。
执行插入操作:
insert into user values(null,'0007','user07',24,'武汉');
复制代码
插入操作被阻塞。这是因为插入的数据生成的id满足小于1的条件,会被阻塞。
查询锁信息如下:
此时,锁信息对比场景2.4,少了一条不满足条件记录(id=1)的锁,其他符合条件的数据均被锁。
结论:当查询条件有索引且查询条件为范围时,select for update会采用间隙锁或临键锁,对指定范围内的数据进行加锁。
完成了前面针对RR事务隔离级别的验证,下面将数据库事务隔离级别切换为RC。
set global transaction isolation level READ COMMITTED;
复制代码
注意,此处可能须要重启数据库,如果通过命令配置无效,可通过数据库配置文件进行配置,重启。
另外,也可以通过在所有命令窗口执行session级别的设置,也可以达到疗效,设置完成以后注意须要进行验证。
场景3.1:V5.x-RC-主键
执行消极锁查询:
select * from user where id = 1 for update;
复制代码
执行更新操作:
update user set age = age +1 where id = 1;
复制代码
此处更新操作被阻塞,说明数据锁定成功。
锁信息与RR事务相同。
执行更新其他记录操作:
update user set age = age +1 where id = 2;
复制代码
执行成功。
结论:当查询条件为字段时,select for update为行级锁。
场景3.2:V5.x-RC-唯一索引
执行消极锁操作:
select * from user where user_no = '0001' for update;
复制代码
执行更新操作:
update user set age = age +1 where id = 1;
复制代码
此处更新操作被阻塞,说明数据锁定成功。
查询锁信息,与RR一致。
执行更新其他记录操作:
update user set age = age +1 where id = 2;
复制代码
执行成功。
结论:当查询条件为惟一索引时,select for update为行级锁。
场景3.3:V5.x-RC-普通索引
执行消极锁操作:
select * from user where user_name = 'user01' for update;
复制代码
执行更新操作:
update user set age = age +1 where id = 1;
复制代码
此处更新操作被阻塞,说明数据锁定成功。
查询锁信息如下:
再把RR场景下的锁信息贴下来:
可以看出,RC事务隔离级别时比RR事务隔离级别时少了一个GAP(间隙锁)。
执行更新其他记录操作:
update user set age = age +1 where id = 2;
复制代码
执行成功。
此时再进行一笔插入操作:
insert into user values(null,'0009','user01',24,'郑州');
复制代码
执行成功。
再验证下间隙锁是否真的不存在,执行一笔user_name与查询条件相同的插入操作:
insert into user values(null,'0008','user01',24,'成都');
复制代码
执行成功,说明此时间隙锁的不存在了。
结论:当查询条件为普通索引时,select for update为行级锁,无间隙锁。
场景3.4:V5.x-RC-无索引
执行消极锁操作:
select * from user where address = '北京' for update;
复制代码
执行更新操作:
update user set age = age +1 where id = 1;
复制代码
此处更新操作被阻塞,说明数据锁定成功。
锁信息如下:
显示基于字段的排他锁,这块挺出乎意料的,并没有进行表锁。
执行更新其他记录操作:
update user set age = age +1 where id = 2;
复制代码
执行成功。
再执行一笔插入操作,插入数据与查询条件address一致:
insert into user values(null,'0011','user01',24,'北京');
复制代码
执行成功。
结论:当查询条件无索引时,select for update为行级锁,也就说,在RC事务隔离级别下,即便无索引,也是只锁记录,与一般的直知觉不同。
原因:会出现上述情况的缘由是,本来假如锁条件上没有索引,MySQL会走聚簇(主键)索引进行全表扫描过滤,每条记录就会添加上X锁。但为了效率,MySQL会对扫描过程中不满足条件的记录进行解锁操作。
场景3.5:V5.x-RC-索引-范围查询
执行消极锁操作:
select * from user where id > 1 for update;
复制代码
执行更新操作:
update user set age = age +1 where id = 1;
复制代码
执行成功,说明并没有锁定id为1的记录。
执行更新操作:
update user set age = age +1 where id = 2;
复制代码
操作被阻塞。这是因为操作的数据的id满足小于1的条件,会被阻塞。
所信息如下:
结论:当查询条件有索引且查询条件为范围时,select for update对指定范围内的数据进行加锁。
场景4.1:V8.x-RC-主键
执行消极锁查询:
select * from user where id = 1 for update;
复制代码
执行更新操作:
update user set age = age +1 where id = 1;
复制代码
此处更新操作被阻塞,说明数据锁定成功。
锁信息同RR。
继续实验,执行更新其他记录操作:
update user set age = age +1 where id = 2;
复制代码
执行成功。
结论:当查询条件为字段时,select for update为行级锁。
场景4.2:V8.x-RC-唯一索引
执行消极锁操作:
select * from user where user_no = '0001' for update;
复制代码
执行更新操作:
update user set age = age +1 where id = 1;
复制代码
此处更新操作被阻塞,说明数据锁定成功。
锁信息同RR。
执行更新其他记录操作:
update user set age = age +1 where id = 2;
复制代码
执行成功。
结论:当查询条件为惟一索引时,select for update为行级锁。
场景4.3:V8.x-RC-普通索引
执行消极锁操作:
select * from user where user_name = 'user01' for update;
复制代码
执行更新操作:
update user set age = age +1 where id = 1;
复制代码
此处更新操作被阻塞,说明数据锁定成功。
查询锁信息:
对照一下RR场景下的锁信息:
可以看出RC场景下笔RR场景下少了一条行级间隙锁。
执行更新其他记录操作:
update user set age = age +1 where id = 2;
复制代码
执行成功,说明更新操作没有影响。
验证一下是否有排他间隙锁,此时需再测试一笔插入操作:
insert into user values(null,'0010','user05',23,'重庆');
复制代码
执行成功。
再执行一笔插入操作:
insert into user values(null,'0007','user01',24,'武汉');
复制代码
注意这儿插入的记录user_name与锁查询条件相同,执行成功,说明真的不存在X,GAP(排他间隙锁)。
结论:当查询条件为普通索引时,select for update为行级锁。
场景4.4:V8.x-RC-无索引
执行消极锁操作:
select * from user where address = '北京' for update;
复制代码
执行更新操作:
update user set age = age +1 where id = 1;
复制代码
此处更新操作被阻塞,说明数据锁定成功。
查询锁信息:
对照一下RR场景:
对于RR场景,RC场景下,只有一条排他行锁(X,REC_NOT_GAP)。
执行更新其他记录操作:
update user set age = age +1 where id = 2;
复制代码
执行成功。
结论:当查询条件无索引时,select for update为行级锁。这里的缘由与场景3.4一致。
场景4.5:V8.x-RC-索引-范围查询
执行消极锁操作:
select * from user where id > 1 for update;
复制代码
执行更新操作:
update user set age = age +1 where id = 1;
复制代码
执行成功,说明并没有锁定id为1的记录。
执行插入操作:
insert into user values(null,'0012','user12',24,'--');
复制代码
执行成功。
查询锁信息如下:
对照RR场景下的锁信息:
此时,RC场景下,少了临键锁,排他锁也变为了行级排他锁。
结论:当查询条件有索引且查询条件为范围时,select for update会对指定范围内的数据进行加锁,只会阻塞符合条件的记录,不影响插入操作。
场景及推论
完成了前面的实验以后,我们通过一个表格来总结一下所有的场景和推论。
版本字段惟一索引普通索引无索引范围查询
MySQL 5.7.x - RR
X:行锁
X,行锁
X,GAP:行锁,间隙锁,条件范围内会阻塞
表锁
指定范围加锁,insert阻塞
MySQL 8.0.x - RR
X,REC_NOT_GAP:行级排他锁
X,REC_NOT_GAP:行级排他锁
X;X,REC_NOT_GAP;X,GAP:行锁+排他间隙锁,阻塞范围内insert;
表锁,每条记录一个X锁
指定范围加锁,insert阻塞
MySQL 5.7.x - RC
X:行锁
X,行锁
X,行锁,无间隙锁;
行锁
指定范围加锁,更新、insert阻塞
MySQL 8.0.x - RC
X,REC_NOT_GAP:行级排他锁
X,REC_NOT_GAP:行级排他锁
X,REC_NOT_GAP:行锁,无间隙锁;
X,REC_NOT_GAP:行锁
指定范围加锁,不阻塞insert
从里面表中我们可以总结出以下推论(基于RR、RC两种事务隔离级别):
通过前面的推论,我们可以看出,并不是简单的说“有索引就是行锁,无索引就是表锁”,因为在事务隔离级别为RC时,无索引,同样表现(被优化)为行锁。
至于,根据范围条件(大于、小于、不等于、between、like等)查询、查询无结果等情况,大家可依据上述实验方式进行自行验证。
本文为你们提供了实验方式,并针对常见的场景给出了推论,希望才能帮到你,也希望你们就能点赞、转发、收藏,以备不时之需。