MySQL深度剖析-全局锁、表锁、行锁
一、锁的基本概念
事务与锁是不同的。事务具有ACID( 原子性、一致性、隔离性和持久性),锁是用于解决隔离性的一种机制。事务的隔离级别通过锁的机制来实现。
锁机制是为了解决数据库的并发控制问题而产生的。如在同一时刻,客户端对同一个表做更新或查询操作,为了保证数据的一致性,必须对并发操作进行控制。同时,锁机制也为实现 MySQL 的各个隔离级别提供了保证
那么MySQL并发读写下,会产生什么样的问题呢?
(1)读-读
即并发事务相继读取相同的记录,因为没涉及到数据的更改,所以不会有并发安全问题,允许这种情况发生
(2)写-写
即并发事务对相同记录进行修改,会出现脏写问题
脏写:脏写是指一个事务覆盖了另一个事务尚未提交的数据。如果后续未提交的事务被回滚,那么第一个事务写入的数据就是无效的,可能导致数据不一致。
(3)读-写 或 写-读
即一个事务进行读取操作,另一个进行改动操作。这种情况下可能发生脏读 、不可重复读 、幻读的问题。
脏读: 脏读是指一个事务读取到了另一个事务尚未提交的数据。如果后续该未提交的事务被回滚,那么第一个事务读取到的数据就是无效的。
不可重复读: 不可重复读是指在一个事务内多次读取同一数据时,由于其他事务的修改,导致读取的结果不一致。这会导致同一个事务在不同时间点读取到的数据不一致,从而影响事务的逻辑正确性。
幻读:幻读是指在一个事务内多次执行相同的查询,但由于其他事务的插入或删除操作,导致查询结果集的行数发生变化。这会导致同一个事务在不同时间点查询到的数据集不一致。
可以使用两种方式解决(都离不开锁):
- 读写都采用加锁的方式,读写也需要排队执行,性能较差
- 写操作加锁,读操作利用MVVC多版本并发控制,读取历史记录,性能更高
基于此,MySQL提供一系列锁机制
下面分别介绍这些锁
二、锁-从数据操作的类型分类
- 读锁/共享锁(Shared Lock,S锁):针对同一份数据,多个事务的读操作可以同时进行而不会互相影响,相互不阻塞的。
- 写锁/排它锁(Exclusive Lock,X锁):允许事务对某些数据进行删除或更新。如果当前操作还没完成,其他事务的S和X锁是会被阻塞的,确保在多个事务中,对同一资源,只有一个事务能写入,并防止其他用户读取正在写入的资源。
读操作
共享锁称为读锁,但不是读一定获取共享锁。正常情况下,select某一条记录时,只需要获取该记录的共享锁。但是,在有些情况下可能select记录时就获取记录的排他锁,来禁止别的事务来读取该记录,为此,MySQL提供了两种特殊的select语句:
- 对读取的记录加S锁(sql中如何加)
SELECT ... LOCK IN SHARE MODE;
--或者
SELECT ... FOR SHARE [NOWAIT|SKIP LOCKED]; -- 8.0新特性
-- 8.0新特性,NOWAIT表示不等待直接报错,
-- SKIP LOCKED表示立即返回,但返回的结果不包含被锁定的行
加S锁,此时允许其他事务读取该记录(给该记录加S锁),但是不允许其他事务给该记录加X锁,需要阻塞等待当前事务提交后获取锁。
- 对读取的记录加X锁
SELECT ... FOR UPDATE;
该select语句会被视为获取X锁,如果当前事务执行了该语句,会给记录加上X锁,不允许其他事务获取该记录的S锁和X锁。
写操作
平常所用到的写操作无非是 DELETE、UPDATE、INSERT这三种。读操作可以加共享锁或者排它锁,而写操作是必须加排它锁的。
三、锁-从数据操作的粒度划分
表锁
该锁会锁定整张表,它是MySQL中最基本的锁策略,并不依赖于存储引擎,并且表锁是开销最小的策略(因为粒度比较大)。由于表级锁一次会将整个表锁定,所以可以很好的避免死锁问题。
当然,锁的粒度大所带来最大的负面影响就是出现锁资源争用的概率也会最高,导致并发率大打折扣。
表锁又可分为:表级别的S锁和X锁、意向锁、元数据锁、自增锁
1、表级别的S锁、X锁
一般情况下,不会使用到InnoDB中提供的表级别的S锁和X锁,只会在一些特殊情况下,比方说崩溃恢复过程中用到;而在MyISM比较常用。
手动 获取 InnoDB存储引擎提供的表t 的 S锁 或者 X锁:
LOCK TABLES t READ :InnoDB存储引擎会对表 t 加表级别的 S锁 。
LOCK TABLES t WRITE :InnoDB存储引擎会对表 t 加表级别的 X锁 。
-- 解锁
UNLOCK TABLES; 应尽量避免在InnoDB存储引擎的表上使用 LOCK TABLES 这样的手动锁表语句
2、意向锁
- 意向锁的作用就是加快表锁的检查过程。
意向锁是由存储引擎自己维护的 ,用户无法手动获取,在为数据行加共享/排他锁之前,InooDB会先获取该数据所在表的对应意向锁。意向锁可分为:
- 意向共享锁(intention shared lock, IS):在使用 InnoDB 引擎的表里对某些记录加上「共享锁」之前,需要先在表级别加上一个「意向共享锁」;
-- 事务要获取某些行的 S 锁,必须先获得表的 IS 锁。
SELECT column FROM table ... LOCK IN SHARE MODE;
- 意向排他锁(intention exclusive lock, IX):在使用 InnoDB 引擎的表里对某些纪录加上「独占锁」之前,需要先在表级别加上一个「意向独占锁」;
-- 事务要获取某些行的 X 锁,必须先获得表的 IX 锁。
SELECT column FROM table ... FOR UPDATE;
演示
步骤:事务1首先获取行级别的共享锁(在这之前会先获取该表的意向共享锁),事务2想获取该表的表级别的共享锁,先检查该表的意向锁,发现是意向共享锁,因为是兼容的,所以加表锁成功。
步骤:事务1获取行级别的排他锁(在这之前会先获取该表的意向排他锁),事务2想获取该表的表级别的共享锁,先检查该表的意向锁,发现是意向排他锁,读锁和写锁不兼容,所以加表锁失败,阻塞等待。
总结:意向锁的目的是为了快速判断表里是否有记录被加锁。
3、自增锁
自增锁是一种特殊的表级别锁,数据库中一些自增字段的在插入数据时,如何自增就是靠这个锁实现的。
AUTO-INC 锁是特殊的表锁机制,锁不是再一个事务提交后才释放,而是再执行完插入语句后就会立即释放。
在插入数据时,会加一个表级别的 AUTO-INC 锁,然后为被 AUTO_INCREMENT
修饰的字段赋值递增的值,等插入语句执行完成后,才会把 AUTO-INC 锁释放掉。
那么,一个事务在持有 AUTO-INC 锁的过程中,其他事务的如果要向该表插入语句都会被阻塞,从而保证插入数据时,被 AUTO_INCREMENT
修饰的字段的值是连续递增的。
4、元数据锁
我们不需要显示的使用 MDL,因为当我们对数据库表进行操作时,会自动给这个表加上 MDL:
- 对一张表进行 CRUD 操作时,加的是 MDL 读锁;
- 对一张表做结构变更操作的时候,加的是 MDL 写锁;
总结:MDL是为了保证当用户对表执行CRUD操作时,防止其他线程对这个表结构做了变更
MDL 是在事务提交后才会释放,这意味着事务执行期间,MDL 是一直持有的。
行锁
行锁(Row Lock)也称为记录锁,顾名思义,就是锁住某一行(某条记录row)。需要的注意的是,MySQL server层并没有实现 行锁机制,行级锁只在存储引擎层实现。
优点:锁定粒度小,发生锁冲突概率低,可以实现的并发度高。
缺点:对于锁的开销比较大,加锁会比较慢,容易出现死锁情况。
InnoDB与MyISAM的最大不同有两点:一是支持事务; 二是采用了行级锁。
假设现在有student这张表,其结构如下
我们来看行锁有哪些分类
1、记录锁
记录锁就是行级别的X锁和S锁,仅仅锁住一行记录,分S型记录锁和X型记录锁,和前面的规则一样,官方的类型名称为: LOCK_REC_NOT_GAP。
需要特别注意的是加锁的执行过程中所有扫描到的行都会被锁上,因此必须确定条件使用了索引,这样才能精准锁定,而如果没有索引,会进行全表扫描,那么就会锁住无关紧要的数据。
比如:
总结:
(1)针对唯一索引进行检索时,对已存在的记录进行等值匹配时,将会自动优化为行锁。
(2)INNODB的行锁是针对于索引加的锁,不通过索引条件检索数据,那么INNODB将对表中的所有记录加锁,此时就会升级为表锁。
2、间隙锁
间隙锁主要是解决幻读的,先来看下图什么是幻读现象
加锁方式有点尴尬,幻影记录还未出现,给谁加锁呢?可以加间隙锁
比如,把id值为8的那条记录加一个gap锁的示意图如下。
图中id值为8的记录加了gap锁,意味着不允许别的事务在(5,8)之间插入新记录。比如,有另外一个事务再想插入一条id值为6的新记录,它定位到该条新记录的下一条记录的id值为8,而这条记录上又有一个gap锁,所以就会阻塞插入操作,直到拥有这个gap锁的事务提交了之后,id列的值在区间(5, 8)中的新记录才可以被插入。
gap锁的提出仅仅是为了防止插入幻影记录而提出的,没有额外其他功能。
但是间隙锁会产生死锁
比如:事务1和事务2都有某个记录的间隙锁,此时事务2因为插入记录而被阻塞(阻塞原因是事务1的间隙锁),所以事务2需要等待事务1提交,然而事务1试图插入记录,插入的记录在事务2中被间隙锁锁住了,所以事务1会去等待事务2提交,这也就出现了死锁,互相持有对方的锁。
3、临键锁
有时候我们既想锁住某条记录 ,又想阻止其他事务在该记录前边的间隙插入新记录,所以InnoDB就提出了一种称之为Next-Key Locks的锁 。官方的类型名称为: LOCK_ORDINARY。我们也可以简称为 next-key锁
临键锁是在存储引擎InnoDB、事务级别在可重复读 的情况下使用的数据库锁, InnoDB默认的锁就是Next-Key locks。
临键锁 = 记录锁 + 间隙锁
-- 给id小于等于8的所有记录加上临键锁
select * from student where id<=8 for update;
-- 给id为[6,8]的记录加上临键锁
select * from student where id<=8 and id>6 for update;
4、插入意向锁
一个事务在插入一条记录时需要判断一下插入位置是不是被别的事务加了间隙锁,如果有的话,插入操作需要等待,直到有间隙锁的那个事务提交。但是InnoDB规定事务在等待的时候也需要在内存中生成一个锁结构,表明有事务想在某个间隙中插入新记录,但是现在在等待。InnoDB就把这种类型的锁命名为插入意向锁 。插入意向锁是一种Gap锁,不是意向锁,在insert操作时产生。
插入意向锁是在插入一条记录行前,由INSERT操作产生的一种间隙锁 。 事实上插入意向锁并不会阻止别的事务继续获取该记录上任何类
全局锁
全局锁就是对 整个数据库实例 加锁。当你需要让整个库处于 只读状态 的时候,可以使用这个命令,之后 其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结 构等)和更新类事务的提交语句。
全局锁的典型使用 场景 是:做全库逻辑备份。
Flush tables with read lock
unlock tables
备份数据库数据的时候,使用全局锁会影响业务,那有什么其他方式可以避免?
有的,如果数据库的引擎支持的事务支持可重复读的隔离级别,那么在备份数据库之前先开启事务,会先创建 Read View(相当于一个快照),然后整个事务执行期间都在用这个 Read View,而且由于 MVCC 的支持,备份期间业务依然可以对数据进行更新操作。
因为在可重复读的隔离级别下,即使其他事务更新了表的数据,也不会影响备份数据库时的 Read View,这就是事务四大特性中的隔离性,这样备份期间备份的数据一直是在开启事务时的数据。
备份数据库的工具是 mysqldump,在使用 mysqldump 时加上 –single-transaction
参数的时候,就会在备份数据库之前先开启事务。这种方法只适用于支持「可重复读隔离级别的事务」的存储引擎。
四、锁-从锁的态度分类
分为悲观锁和乐观锁。需要注意的是,乐观锁和悲观锁并不是锁,而是锁的设计思想 。
悲观锁
假设最坏的情况,每次操作数据都会加上锁,如行锁、表锁等,都是在做操作之前先上锁,当其他线程想要访问数据时,都需要阻塞挂起。Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。
案例:
商品秒杀过程中,库存数量的减少,避免出现超卖的情况。比如,商品表中有一个字段为quantity表示当前该商品的库存量。如果不使用锁的情况下,操作方法如下所示
#1.查出商品库存
select quantity from items where id=1001;
#2.如果库存大于0,则根据商品信息生成订单
insert into orders(item_id) values(1001);
#3.修改商品的库存,num表示购买数量
update items set quantity=quantity-1 where id=1001;
高并发可能会产生问题:
其主要原因是查询时不会加锁,可以同时进行。来做一个模拟,如下:
使用悲观锁来解决问题:当查询库存时就把数据给锁定,保证同时只能有一个事务查询到库存,其他事务必须等他将库存减去后才能查询到库存。
#读取时需要获取x锁
select quantity from items where id=1001 for update;
insert into orders(item_id) values(1001);
update items set quantity=quantity-1 where id=1001;
乐观锁
乐观锁认为对同一数据的并发操作不会总发生,属于小概率事件,不用每次都对数据上锁,它不采用数据库自身的锁机制,而是通过程序来实现。
在程序上,我们可以采用版本号机制或者CAS机制实现。乐观锁适用于多读和冲突不激烈的应用类型,这样可以提高吞吐量。在Java中通过CAS实现的。
比如:
在表中增加一个版本字段version,对数据进行更新时会执行UPDATE ... SET version=version+1 WHERE version=xx。如果已经有事务对这条数据进行了更新,则不会成功。