目录
3.2.3 元数据锁(meta data lock,MDL)
1. 锁的分类
MySQL 中的锁有很多种,我们可以对它们进行分类,分类的角度不同,分类得出的结果也不相同,下面我们就从分类的角度分析这些所的实现原理。
1.1 从操作类型划分,分为读锁和写锁;
1.2 从锁的粒度划分,分为全局锁,表锁,页锁,行锁;
1.3 从锁的态度划分,分为乐观锁和悲观锁;
因为数据库常常在业务中会承受高并发的访问,所以加锁的粒度越细,并发能力越高,所以对于全局锁,表锁,页锁,行锁需要深刻理解。
2. 读锁和写锁
在数据库并发事务的时候,就会产生读和写的各种操作.以两个事务为例,就会有"写-写","写-读","读-读"这三种情况发生,自然这些操作之间也会产生冲突。由此就有了读锁和写锁,读锁(read lock)也被称为共享锁(Share Lock,也叫S锁),写锁(write lock)也被称为排它锁(Exclusive Lock,也叫X锁)来解决这种冲突情况的发生。
2.1 读锁(S锁)
顾名思义,就是一个事务进行读取数据操作,未对数据做修改,所以多个获取读锁的事务可以共同读取同一份数据而且不会互相影响,读锁也被称为共享锁,读锁与读锁可以兼容;
数据库中加读锁有两种方式,如下代码
# 加读锁方式一:SELECT ... LOCK IN SHARE MODE
SELECT * FROM tb_user LOCK IN SHARE MODE;
# 加读锁方式二:SELECT ... FOR SHARE
SELECT * FROM tb_user FOR SHARE;
2.2 写锁(X锁)
一个事务对数据进行写操作,修改数据。如果没有写锁,有另外一个事务来读取数据,就有可能产生脏读,所以没有写锁是会影响其他事务的操作结果的,为了避片此种情况,写锁就会阻塞其他事务的操作,不管该事务要进行读操作还是写操作,都会被阻塞,因此写锁也被称为排它锁;
数据库中加写锁方式如下代码
# 加写锁:SELECT ... FOR UPDATE
SELECT * FROM tb_user FOR UPDATE;
2.3 MySQL 8.0锁新特性
在 MySQL5.7 之前,使用 "SELECT ... FOR UPDATE" 加写锁之后,如果获取不到锁,线程会一直等待,直到超时;
在MySQL8.0 之后,我们可以在 "SELECT ... FOR SHARE","SELECT ... FOR UPDATE" 后面添加 NOWAIT 或 SKIP LOCKED 跳过锁等待;
NOWAIT:加了 NOWAIT 不会等待其他线程释放锁,直接返回错误;
SKIP LOCKED:返回不报错,但返回的值中不包含被锁定的行数据,返回数据不一定完整;
3. 全局锁,表锁,页锁,行锁(重点掌握)
全局锁,表锁,页锁,行锁它们锁的粒度是由大到小的。锁的粒度越小,并发能力越高。每一种锁它们的粒度不同,使用场景也略有偏差。
3.1 全局锁
3.1.1 全局锁的使用场景
全局锁通常应用于全库的逻辑备份,此时为了保证数据的准确性,我们需要对整个数据库进行加锁,防止其它线程修改数据库中的数据导致我们备份的数据有偏差。在有全局锁的时候,其他事务只能读取数据库中的数据,不能写数据。
3.1.2 全局锁的使用语法
# 全局锁,锁住整个数据库中的表
FLUSH TABLES WITH READ LOCK;
# 解除全局锁
UNLOCK TABLES;
3.1.3 全局锁使用实示例
全局锁使用展示,以 cloud_user 数据库为例,对该数据库进行加锁,执行如下,OK表示加锁成功;
加完锁之后,我们插入一条数据,如下图所示,显示无法插入成功,下方提示
我们执行 SELECT 查询操作,就可以查询成功,如下所示
我们再将全局锁解除,重新插入数据,就可以插入成功了,如下图所示
然后我们刷新 tb_user 表,张三那一条数据就已经被添加进去了
3.1.4 全局锁的缺点
通过上面的演示,全局锁的缺点其实也是非常明显。
(1)当使用全局锁进行数据备份时,备份期间不能进行数据更新的操作,会导致我们的业务暂停;
(2)数据库备份期间不能执行主库同步过来的二进制日志,会导致主从延迟;
3.2 表锁
表锁在 InnoDB,MyISAM 等引擎中均有所使用,是最基本的锁策略。由于表级锁每次操作会锁住整张表不让其他事务操作,所以可以很好的避免死锁问题,但并发能力也大大的降低。
表锁是一个笼统的概念,它里面还可以细分为 S锁X锁,元数据锁,意向锁,自增所三类。
3.2.1 表级S锁X锁
这里的S锁与X锁与上面分类说的读锁写锁非常类似,只不过是在表层面上的S锁X锁,不需要做过多详细的说明;
使用语法如下
# 表级S锁X锁
lock tables tb_user READ/WRITE;
# 释放锁
UNLOCK TABLES;
读锁与写锁在并发上是有所区别的,若我们添加的是读锁,不会阻塞其他客户端的读操作,但会阻塞其他客户端的写操作;若我们添加的是写锁,既会阻塞其他客户端的读操作,又会阻塞其他客户端的写操作,总结为下面这幅图。
有一点需要特别注意,在不同的存储引擎中,表级S锁X锁的默认添加规则是不一样的,以 InnoDB 和 MyISAM 引擎为例。MyISAM 存储引擎在执行SELECT 操作时,会自动给表加上读锁,在执行增删改操作时,会自动给表加上写锁;而 InnoDB 存储引擎不会给表添加读锁和写锁,会添加粒度更细的行锁,下面我会详细说到行锁。
3.2.2 意向锁
意向锁我直接解释可能会不好理解,我给同学们说一个场景各位就懂了。
假设现在有两个事务T1和T2,同时操作用户user表。T1事务要给 user 表添加行锁(这里我还没有说到行锁,如果不明白的可以先往下看行锁,行锁懂了之后再回来看这个意向锁),T2事务要给user 表添加表锁。假设T1事务先执行,它将 user 表中的某一行数据进行锁定,当T2事务要给 user 表加上表锁的时候,它需要先判断 user 表是否有其他事务添加过比表锁更细粒度的锁,例如页锁和行锁。如果有添加过,则T2事务添加表锁的操作就不能成功,因为会产生冲突。而且T2事务判断是否有页锁或行锁的过程是有讲究的,如果T1事务添加的是行锁,那么T2事务在判断的时候,它就要去进行全表扫描,判断每个行数据是否添加过行锁,如果这样做的话,性能是非常非常差的。因此为了提高性能,就有了意向锁这一说,简单来说就是,当T1事务添加了行锁之后,可以理解为它会自动在行的上一级存储单元 页上或者整张表做一个标记,标记该表中已经有细粒度的锁,当有其他人想要添加表锁时,不能添加成功,要阻塞等待。
因此说白了,意向锁可以把它理解为一个标记,不算是一个真正的锁。当某个表中有行锁时,底层会自动给该表添加意向锁(即做一个标记,表示有细粒度的锁正在操作该表),添加表锁会阻塞等待。主要就是为了解决添加表锁时全表扫描判断是否有行锁而设计的,提高了表锁加锁时的性能。
这个意向锁也可以分为IS和IX锁,意向锁与意向锁之间都是兼容的,因为它们本身可以说只是一个标记型锁。
如果两个事务都是添加行锁,那么都会添加意向锁,由于意向锁是互相兼容的,所以我们还需要进一步判断两个行锁操作的数据是否冲突。若不产生冲突,则行锁可以添加成功;若冲突,则阻塞等待;
意向锁可以大致总结为以下两点:
(1)意向锁的存在是为了协调行锁和表锁的关系,支持多粒度锁的并存;
(2)意向锁是一种不与行级锁冲突的表级锁,它的作用就是对表做一个标记,当有其他线程试图添加所的时候,要先去做判断,省去了全表扫描的时间;
3.2.3 元数据锁(meta data lock,MDL)
元数据锁是在 MySQL5.5 之后引入的,也是属于表锁的范畴之内的。
它的主要目的是为了保证表的结构的准确性,怎么理解这句话呢?
假设有两个事务,一个事务进行查询操作,另一个事务要改变表的结构,那么它们之间就可能产生冲突,导致查询操作查询到的数据不准确,为了避免发生此类问题,就引入了元数据锁。
当对一张表进行增删改查的时候,会加元数据读锁,当对表的结构进行变更操作的时候,会加元数据写锁。 元数据读锁和元数据写锁是互斥的,当我们正在进行表结构的变更时,其他事务是不能进行查询操作的,会阻塞等待;同理,当有事务在进行查询操作的时候,其他事务不能进行表结构的变更操作,也会阻塞等待。不过,数据库表的结构在业务涉及的初期基本就已经需要确定好,但是也不能避免此种情况的发生,所以元数据锁的存在还是有一定的使用场景的。
MDL加锁过程是系统自动控制的,无法显式使用,在访问同一张表的时候会自动添加元数据锁。元数据锁的主要作用是维护表元数据的数据一致性,元数据读锁与元数据读锁之间是兼容的,元数据读写锁之间是互斥的。
3.2.4 自增锁(AUTO-INC锁)
自增锁其实很简单,这里作为简单了解即可,数据库的自增长我们应该都是清楚的。有些时候,我们会为主键设置一个自增长,自增锁可以说就是服务于自增长这一属性的,当有多个事务同时对同一张表执行INSERT插入操作时,或者进行批量插入数据操作时,我们的自增锁就会起作用,让它们逐个添加,防止发生线程安全问题。
3.3 页锁
了解数据库底层数据存储结构的同学都知道,一张表可以存储成百上千万条数据,而数据库底层是以页为单位的,一页大小为16KB,所以一张数据库的表有可能会有很多很多页的数据。
页锁是介于表锁和行锁之间的一种锁,锁定的资源比行多比表少,并发能力一般。
而且页锁可能会产生死锁问题,假设 user 表现在有两页数据,事务A需要先处理第一页的数据再处理第二页的数据,事务B需要先处理第二页的数据再处理第一页的数据,那么事务A就会先锁定第一页,事务B就会先锁定第二页,处理完成之后,事务A等待事务B释放第二页的锁,事务B等待事务A释放第一页的锁,双方互相等待,就会造成死锁问题。
在实际开发过程中,如果我们选择的是 InnoDB 存储引擎,通常会选用行锁提高并发能力,如果选择的是 MyISAM 存储引擎,通常会选用默认的表锁即可,所以页锁很少使用,作为了解即可,面试中页锁一般也不是重点会去问的。
3.4 行锁
行锁听名字也能知道,它每次上锁只会锁住某一行的数据,这里需要注意的是,MySQL的行锁没有在服务器层实现,只有在存储引擎中得以实现,而在数据库中多存储引擎中,只有 InnoDB 存储引擎支持行锁。
行锁的优点:锁的粒度非常小,锁冲突的概率也很小,数据库并发能力极高。
行锁的缺点:行锁的开销非常大,加锁会比较慢,容易出现死锁的情况。
行锁其实还可以继续细分,分为记录锁,间隙锁,临键锁,插入意向锁。
3.4.1 记录锁
如下图所示,是一个简单的 user 表
记录锁中的记录说的就是表中的一条条记录,当我们的事务操作某一条记录的时候,可以获取它的读锁(S锁)或者写锁(X锁)的,若是读事务获取的就是读锁,写事务获取的就是写锁。
行级别的读锁(S锁)与读锁(S锁)可以兼容,读锁(S锁)与写锁(X锁)互斥不兼容,写锁(X锁)与写锁(X锁)也互斥不兼容。
3.4.2 间隙锁
间隙锁的主要目的是为了解决并发情况下产生的幻读问题,比如事务A来读取数据,事务B插入数据,事务A前后两次读取到的数据不一致,第二次读取到了事务B插入之后的数据,产生了幻读。因此我们就需要用到间隙锁。
如下图所示,假设我们要在id = 3和id = 8之间添加一条数据,这两条数据之间就是有间隙的,我们需要对这个间隙进行加锁防止其他事务添加数据导致产生幻读。
假设我们要操作(8,王五,二班)这条数据,那么当我们想要在李四和王五之间插入数据或想要在王五和赵六之间插入数据时,是不能插入成功的;
间隙锁默认锁的是当前数据与他相邻的两侧的两条数据之间的间隙,锁定开区间内的部分记录;
当我们操作(3,李四,一班)时,无法在id = 1和 id = 8之间插入数据,因为有间隙锁;
当我们操作(15,赵六,二班)时,无法在 id = 8和 id = 20之间插入数据,因为有间隙锁;
当我们操作(20,找七,三班)时,无法在 id = 15 和 正无穷之间插入数据,如果操作的是最大的那条数据,右侧区间就是正无穷,当我们操作的是最小的那条数据,左侧区间就是系统给默认的最小值;
虽然间隙锁也有共享锁和排它锁之分,但它们起到的作用可以说是完全相同的, 如果一个事务对一条数据加了间隙锁,并不会影响其它事务对这条记录继续添加间隙锁。
3.4.3 临键锁
临键锁,其实就是记录锁和间隙锁的结合体,也是 InnoDB 存储引擎默认的锁,它的隔离级别为可重复读,除了解决不了幻读问题,可以解决脏写,脏读,不可重复读三种并发情况。
当我们操作(8,王五,二班)这条数据时,其他事务不仅无法操作当前这条数据,也无法在 id = 3 和 id = 15 之间插入数据。
3.4.4 插入意向锁
刚才我说到了间隙锁,其实插入意向锁是一种极为特殊的间隙锁,当我们想要向数据库的表中添加一条数据时,会先判断要插入的位置是否已经被就添加了间隙锁。如果有,插入操作就需要等待,直到间隙锁的事务提交将锁释放才能进行插入,在 InnoDB 存储引擎中规定事务在等待的时候也需要在内存中生成一个锁结构,表明有事务想要在某个位置添加数据但目前处于等待状态,InnoDB 就把这种状态下的锁称为插入意向锁,插入意向锁是一种极为特殊的间隙锁,在执行 INSERT 操作时产生,与表锁中的意向锁完全不是一个东西,不要混肴。
假设现在 user 表中已经有id = 4和id = 7的两条记录,两个事务分别要插入id = 5和id = 6的两条记录时,两个事务除了要获取5和6两行数据的排它锁之外,还要获取对应的间隙锁,又因为要添加的数据并不冲突,所以两个事务可以同时运行,不会产生阻塞。
所以也就是说,插入意向锁之间并不互相排斥,所以就算有多个事务要同时进行插入操作,他们的插入间隙锁也相同,但只要它们插入的主键值不同,就能同时进行插入操作而且不会互相阻塞。
4. 乐观锁与悲观锁
乐观锁和悲观锁从名字上就大致能看得出在面对数据并发的思维方式与态度。这里需要注意的是,乐观锁和并悲观本身并不是真正的锁,而是锁的设计思想。
4.1 乐观锁(Optimistic Locking)
乐观锁认为对同一数据的并发操作访问不会总是发生,属于小概率事件,对于修改数据操作成功的结果比较乐观。
乐观锁在修改数据时不会上锁,但是会在更新数据时判断一下在此期间是否有其他人修改过当前数据,它不会去利用数据库自身的锁机制,而是靠程序员写代码的方式来实现的。在程序上,通常会利用版本号机制或CAS自旋的方式来实现乐观锁。
乐观锁通常适用于读操作较多的业务中,这样可以提高数据的吞吐量。它的优点是程序实现,不存在死锁问题。
下面我来简单的说一下版本号的方式实现乐观锁。
通常在程序中实现乐观锁,我们会在对应要操作的表中添加一个版本号字段,每当一个线程去操作表中的某条数据或某张表后,就让该版本号字段++自增一的操作。
在并发情况之下,假设有一个线程来操作数据,读数据的时候,它会获取版本号,在即将要进行操作数据的时候,它会再获取一次版本号,判断两次获取的版本号是否相同,若相同,说明在此期间没有其他线程来修改数据;若不同,说明在此期间有其它线程修改过数据,那么它就要放弃执行到一半的本次操作,重新执行此次操作,一直循环,直到操作执行成功。
实现乐观锁的方式有很多种,除了版本号的方式,也可以使用时间戳的方式;
4.2 悲观锁(Pessimistic Locking)
悲观锁是一种设计思想,就是很悲观,对于数据被其他事务修改持有保守态度,会通过数据库自身的锁机制来实现,从而保证数据操作的排他性。
悲观锁总是假设最坏的情况,每次去拿数据的时候都会认为别人会修改数据。为了不让别人修改,所以在每次拿数据的时候都会去上锁,别人再想要拿数据的时候,就会被阻塞直到它已经完成了对数据的操作并将锁释放。
悲观锁适合于写操作较多的业务场景下,防止两个写操作事务产生冲突。
悲观锁说白了有点像 Java 里面的 Synchronized ,它会将共享资源每次只给一个线程进行使用,其他线程会阻塞,当前面的线程用完之后将锁释放才能让别的线程去操作。数据库中的读锁,写锁,行锁,表锁都是在操作之前先进行上锁,其他线程想要操作受到阻塞。
5. MySQL锁的升级
在数据库中,虽然有这么多中锁,但每个层级的锁数量都是有限制的,因为锁也会占用内存空间,所空间的大小是有限制的。当某个层级的锁超过了这个层级锁对应的阈值之后,底层就会进行一个锁升级的过程,就是用更大粒度的锁代替小粒度的锁,比如在 InnoDB 存储引擎中行锁就有升级为表锁的过程,升级所的好处就是锁占用的空间变小了,但同时带来的影响就是数据并发能力降低了。