前言
MySQL 锁机制比较显而易见,其最显著的特点是不同的存储引擎支持不同的锁机制
比如在 MyISAM、Memory 存储引擎采用的是表级锁(table- level locking)InnoDB 存储引擎既支持行级锁(row-level locking)也支持表级锁,但默认情况下是采用行级锁
- 表锁:开销小、加锁快,不会发生死锁,锁定的粒度大,发生锁冲突的概率最高,并发度最低
- 行锁:开销大、加锁慢,会发生思索,锁定的粒度最小,发生锁冲突的概率最小,并发度最高
从锁的角度来看,表锁更适合以查询为主,只有少量按索引条件更新数据的应用,如 PC Web 后台应用;行级锁则更适合有大量按索引条件并发更新少量的数据,同时也有并发查询的应用,如在线事务处理(OLTP)系统、小程序 C 端、App C 端系统等
并发事务问题
并发事务处理能大大增加数据库资源的利用率,提高数据库系统的事务吞吐量,从而可以支持更多用户的并发操作,但与此同时,会发生脏读、不可重复读、幻读问题
解决这种问题一般有两种可选的方案,如下:
- 读操作 MVCC,写操作进行加锁 > 事务利用 MVCC 进行读取称之为一致性读或快照读、无锁读,一致性读并不会对表中任何记录作加锁操作,其他事务可以自由对表中记录作改动
采用 MVCC 方式,读、写操作彼此不冲突,性能更高,采用加锁的方式,读、写操作彼此需要排队执行,从而影响性能;但是在某些情况下,还是需要采用加锁的方式去执行,比如:
要通过间隙锁来解决不可重复读隔离级别的幻读问题
- 读、写操作进行加锁 > 适用场景:业务场景不允许读取记录的旧版本,每次都必须读取记录的最新版本
比如在银行存款事务中,需要先把账户余额读取出来,然后再将其加上本次存款的金额,最后再写入到数据库中。再将账户余额读取出来的这个过程,不想让其他的事务再访问此余额,直到本次存款事务执行完成,其他事务才可以访问账户的余额。
此场景意味着读取数据时也要进行加锁操作,读、写操作并行执行,也要像写写操作那样依此排队执行
关于 MySQL MVCC 机制更多内容,可以阅读此文章:基于 MySQL 事务、隔离级别及 MVCC 机制详细剖析
锁分类
共享锁:Shared Locks,简称 S 锁,属于行锁
排它锁:Exclusive Locks,简称 X 锁,属于行锁
意向锁:Intension Locks,意向共享锁+意向排它锁的组合
意向共享锁:Intension Shared Locks,简称 IS 锁,属于表锁
意向排它锁:Intension Exclusive Locks,简称 IX 锁,属于表锁
自增锁:AUTO-INC Locks,在处理自增长列时的锁定行为
临键锁:Next-Key Locks,记录锁+间隙锁的组合
记录锁:Record Locks,仅仅把一条记录上锁
间隙锁:Gap Locks,对索引前后的间隙上锁,不对索引本身上锁,简称 Gap 锁
锁定读
锁定读(LockingReads)也称为当前读 LBCC(基于锁的并发控制 > Lock-Based Concurrency Control)读取的是最新版本,对读取的记录加锁,阻塞其他事务同时改动相同的记录,避免数据安全问题
共享锁:lock in share mode、排它锁:for update、update、delete
通过以下 SQL 语句,进行共享锁、排它锁的案例演示:
CREATE TABLE student ( id INT ( 10 ) PRIMARY KEY auto_increment, `name` VARCHAR ( 20 ) ) ENGINE = INNODB; INSERT INTO student VALUES ( 1, 'vnjohn' ), ( 2, 'zhangsan' ), ( 3, 'lisi' ), ( 4, 'wangwu' );
共享锁
多个事务对同一条数据可以共享一把锁,但只能读不能写
会话 vnjohn-transaction1:
begin; select * from student where id=1 lock in share mode;
会话 vnjohn-transaction2:
begin; # 读取数据没有问题 select * from student where id=1; # 注意:无法修改会卡死, # 当会话 1 commit 提交事务之后,会立刻修改成功 update student set name ='vnjohn' where id=1;
当会话 1 执行查询 + 了共享锁,会话 2 对该条记录进行更新操作,会阻塞住,直到会话 1 事务提交,才会立刻修改成功,假如会话 1 出现慢 SQL 数据一直查询不出来,那么就会出现错误: ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
总之,共享锁,读、读操作不互斥,读、写操作互斥
排它锁
排它锁不能与其他锁并存,若一个事务获取一个数据行的排它锁,其他事务就不能再次获取该行的其他锁,只有获取了该数据行的排它锁所在事务才能对数据进行读取、写入操作
update、delete 语句默认就是排它锁
会话 vnjohn-transaction1:
begin; select * from student where id=1 for update
会话 vnjohn-transaction2:
begin; select * from student where id=1 for update; select * from student where id=1 lock in share mode;
当会话 1 执行查询 + 了排它锁,会话 2 对该条记录加排它锁、共享锁操作,都会被阻塞住,直到会话 1 事务提交,才会加锁成功
意向排它、意向共享锁
意向锁是一种粒度更粗的锁,用于协调并发事务对表和表中行的锁定。它们并不直接锁定行,而是指示事务在某个层次上有意向获取特定类型的锁。意向锁的引入可以减少冲突,提高并发性能
意向排它锁:表示事务有意向在某个表或表分区上获取排它锁(Exclusive Lock)一个事务在获取某个表的排它锁之前,必须先获取该表的意向排它锁
意向共享锁:表示事务有意向在某个表或表分区上获取共享锁(Shared Lock)多个事务可以同时持有同一个表的意向共享锁,但在获取某个表的排它锁之前,必须先释放该表的意向共享锁
意向锁的引入有助于优化锁定算法,避免了不必要的冲突,提高了并发性能。在事务操作过程中,当需要获取某个表的排它锁或共享锁时,先检查是否存在对应的意向锁,以减少对其他事务的干扰
意向锁是隐式获取和释放的,并不需要显式的锁定语句来处理,它们是由 InnoDB 存储引擎自动管理的
自增锁
Auto-Increment:自增长列的特殊锁机制,通过 innodb_autoinc_lock_mode 参数配置自增长列的锁定模式,它决定了在插入数据时,如何对自增长序列进行锁定
innodb_autoinc_lock_mode 该参数有几个可选值,如下:
- 0(Traditional): 表示使用传统的自增锁定方式,在插入数据时,会对整个表进行排它锁定,以防止并发插入导致自增值的冲突
插入语句在执行前不可以确定具体要插入多少条记录(无法预计即将插入记录的数量),比方说使用 INSERT … SELECT、REPLACE … SELECT 或者 LOAD DATA 这种插入语句,一般是使用 AUTO-INC 锁为 AUTO_INCREMENT 修饰的列生成对应的值
- 1(Consecutive):表示使用连续模式的自增锁定,在插入数据时,只会对自增长索引的最后一个插入行进行排它锁定,而不是整个表
- 2(Interleaved):表示使用交错模式的自增锁定,在插入数据时,会对自增长索引的最后一个插入行进行共享锁定,而不是排它锁定。这允许多个事务并发地插入数据,提高并发性能
使用交错模式时,会导致自增值的顺序会被打乱,虽然提高了事务的并发性,但自增列的值顺序可能会被打乱,因为插入行的锁定顺序可能不是它们实际插入时的顺序
在主从复制场景下时,当 binlog_format 配置为 statement 以语句的方式存储,会造成 slave 同步 master 节点数据回放时产生错乱
在 innodb_autoinc_lock_mode 参数中,传统模式(Traditional)使用排它锁(Exclusive Lock)对整个表进行锁定;连续模式(Consecutive)只对自增长索引的最后一个插入行进行排它锁定;交错模式(Interleaved)则使用共享锁(Shared Lock)对自增长索引的最后一个插入行进行锁定
一般该参数默认值为 1:连续模式,既保证了自增值的顺序性,在插入性能上面又高于 0 传统模式
show variables like 'innodb_autoinc_lock_mode' ;
记录锁
官方类型名称:LOCK_REC_NOT_GAP,记录锁,只对一条记录进行上锁,比方说:
select * from student where id=1 for update; select * from student where id=1 lock in share mode;
隐私锁定:delete from student where id=1、update student set name = ‘’ where id=1
记录锁也是有 S、X 锁之分的,当一个事务获取了一条记录的 S 锁后,其他事务仍然可以继续获取该记录的 S 锁,但不可以获取该记录的 X 锁;当一个事务获取一条记录的 X 锁后,其他事务既不可以获取该记录的 S 锁,也不可以获取该记录的 X 锁
间隙锁
Gap Lock 为了防止其他事务在一个范围内插入新的记录而引入的一种锁机制。通过生成间隙锁,可以确保其他事务无法在已有记录之间插入新记录,从而维护数据的一致性和完整性
生成间隙锁对于维护数据的一致性、避免幻读等问题非常重要
会话 vnjohn-transaction1:
begin; select * from student where id between 4 and 6 for update;
会话 vnjohn-transaction2:
begin; insert into student values(5,'wangwu');
当会话 1 执行查询使用了索引间隙锁 > 主键索引:4~6,会话 2 插入一条主键为 5 的数据,会被阻塞住,直到会话 1 事务提交,会话 2 才会插入成功
InnoDB 行锁模式及加锁方法
InnoDB 行锁通过给索引上的索引项加锁来实现的,Oracle 是通过在数据块中相应数据行加锁来实现的,而 MySQL 则不同,只有通过索引条件检索数据,InnoDB 才使用行级别锁,否则,InnoDB 会使用表级别锁
在不通过索引条件查询时,InnoDB 使用的是表锁而不是行锁,用以下建表语句举例:
# 建立一张无索引的表 create table tab_no_index( id int, name varchar(10) ) engine=innodb; # 插入表数据 insert into tab_no_index values (1,'1'), (2,'2'), (3,'3'), (4,'4');
会话 vnjohn-transaction1 | 会话 vnjohn-transaction2 |
begin; select * from tab_no_index where id=1; |
begin; select * from tab_no_index where id=2; |
select * from tab_no_index where id=1 for update; | |
select * from tab_no_index where id=2 for update; |
当会话 1 只给其中一行数据加了排它锁,但是会话 2 在请求其他行的排它锁时,会出现锁等待;原因是在没有索引的情况下,InnoDB 只能使用表锁
更新、删除操作无须手动加锁,默认会给数据+上排它锁,一样会出现锁等待。
通过带索引条件查询时,InnoDB 使用的是行锁,用以下建表语句举例:
create table tab_with_index( id int, name varchar(10) ) engine=innodb; # 建立 id 列索引 alter table tab_with_index add index id(id); # 插入数据 insert into tab_with_index values (1,'1'), (2,'2'), (3,'3'), (4,'4');
会话 vnjohn-transaction1 | 会话 vnjohn-transaction2 |
begin; select * from tab_with_index where id=1; |
begin; select * from tab_with_index where id=2; |
select * from tab_with_index where id=1 for update; | |
select * from tab_with_index where id=2 for update; |
当会话 1 只给其中一行数据加了排它锁,但是会话 2 在请求其他行的排它锁时,不会出现锁等待;原因是在有索引的情况下,InnoDB 会使用行锁
行锁是针对索引加锁,而不是针对记录加锁,仍然以 tab_with_index 表为例,插入一条 id 列相同的数据
insert into tab_with_index values(1,'4');
会话 vnjohn-transaction1 | 会话 vnjohn-transaction2 |
begin; select * from tab_with_index where id = 1; |
begin; |
select * from tab_with_index where id = 1 and name=‘1’ for update; | |
select * from tab_with_index where id = 1 and name=‘4’ for update; |
会话1、会话2 虽然访问是不同行记录,但是使用了相同的索引键,是会出现锁冲突的,所以会话 2 会出现等待锁的情况
死锁
以 student 表为例,演示两个会话之间,在需要互相获取对方的资源情况下,产生的死锁
会话 vnjohn-transaction1 | 会话 vnjohn-transaction2 |
begin; select * from student where id = 1 for update; |
begin; select * from student where id = 2 for update; |
select * from student where id = 2 for update; | |
select * from student where id = 1 for update; |
当执行到第一步 SQL select * from student where id = 2 for update;
时,会看到会话 1 一直会阻塞住,当会话2 执行 SQL select * from student where id = 1 for update;
时,MySQL 检测到了死锁,立即结束了会话2 中事务的执行,此时,会话1 发现原本阻塞的语句立马执行完成了
通过: show engine innodb status\G
命令可以看到死锁的详细情况,一般情况下看不到是哪个事务对那些记录加了什么锁,需要调整系统变量:innodb_status_output_locks
(MySQL 5.6.16 引入)缺省值是 OFF
show variables like 'innodb_status_output_locks'; set global innodb_status_output_locks = ON;
开启以后,再次执行上述语句流程后,执行查看死锁详细情况的命令,效果如上图所示,
MySQL 检测到了死锁的发生,最终争抢锁之下,MySQL 自动回滚了会话2 所在的事务
总结
该篇博文讲解了 MySQL 中各种会发生的锁,包括显式、隐式的锁,共享锁、排它锁、意向锁、自增锁、间隙锁,说明了解决并发事务的问题的两种方案,以及通过间隙锁如何解决可重复读隔离级别下出现幻读的问题,阐述了 InnoDB 存储引擎行锁模式及加锁方法,最后,通过实际的小案例演示了死锁的发生以及如何通过 MySQL 自带的命令查看死锁解决的一个过程。
MySQL 专栏高质量博文如下:
构建优化之城:MySQL 数据建模、数据类型优化与索引常识全面解析
MySQL 数据结构优化与索引细节解析:打造高效数据库的优化秘笈
MySQL 数据访问与查询优化:提升性能的实战策略和解耦优化技巧
深度解析 MySQL 事务、隔离级别和 MVCC 机制:构建高效并发的数据交响乐
MySQL 日志体系解析:保障数据一致性与恢复的三位英雄:Redo Log、Undo Log、Bin Log
如果觉得博文不错,关注我 vnjohn,后续会有更多实战、源码、架构干货分享!
推荐专栏:Spring、MySQL,订阅一波不再迷路
大家的「关注❤️ + 点赞👍 + 收藏⭐」就是我创作的最大动力!谢谢大家的支持,我们下文见!