MySQL的锁机制,你真的理解了吗?
我们都知道事务并发有可能导致脏写,脏读、不可重复读,幻读等问题,而这类问题归结起来可以分为以下三类(经典读写问题):
锁是一种内存结构,在事务执行之前是不存在锁的,换句话说就是,一开始并没有锁结构和记录相关联。
当一个事务想要对某条记录进行操作前,需要先查看内存中是否有锁结构和该记录相关联,若没有,则在内存中生成一个锁结构与其关联(此时称为加锁成功):
trx信息指的是当前加锁成功的事务信息,is_waiting为false表示不用等待,反之,则等候。
若在事务A之前,事务B已经加锁成功了,则事务A也生成一个锁结构和该记录关联,只不过is_waiting:true,事务A需等待(加锁失败)。
**注意:**无论加锁成功与否,事务都会在内存生成对应的锁结构和该记录关联,只是is_waiting值的区别。
现在先通过图整体了解一下MySQL锁,下面会细聊的,稍安勿躁。
经过上面的讲述,我们知道“读-读”是不会产生任何并发问题的,而“写-写”、“读-写,写-读”都可能会引发一系列问题,为了兼顾以上两类情况,MySQL中锁被分成了两类:
例子:
对即将读取的记录加S锁:
SELECT ... LOCK IN SHARE MODE;
对即将读取的记录加X锁:
SELECT ... FOR UPDATE;
S锁和X锁并不能单纯地认为一个是“读锁”,一个是“写锁”;因为读取数据的时候,可根据实际需要来选择加S锁或者X锁。
加了S锁的记录允许后来事务获取S锁,但是不允许获取X锁;加了X锁的记录不允许后来事务获取S锁和X锁(只有获取锁成功事务才会继续执行,否则阻塞等待)。
我们平时用到的写操作无非是一下三种:
DELETE操作并不是实实际际地把某条记录删掉,而是在B+树中找到该记录的位置,修改上面delete mark标志位,对记录进行逻辑删除。
在修改delete mark标志位之前该事务需要获取该记录的X锁,当然在B+树中定位该记录的过程,也以看作是X锁的一个锁定读过程。
一般情况下,INSERT操作不需要额外加锁, 它受一种隐式锁保护,保护该记录在当前事务提交前不被其他事务访问。
UPDATE操作比DELETE操作要复杂一些,具体情况又可以细分为以下三种:
记录键值不变,待修改列的存储空间修改前后不变:
这种情况是最简单的,我们不需要太多额外的操作,只需要在B+树中找到该记录的位置,获取该记录的X锁,最后修改即可;当然在B+树中定位该记录的过程,也以看作是X锁的一个锁定读过程。
记录键值不变,待修改列的存储空间修改前后发生变化:
这种情况下,我们需要在B+树中找到该记录的位置,获取该记录的X锁,然后删除该记录(注意这一次不是修改delete mark标志位,而是真正物理上的删除,将记录移进垃圾链表);最后再执行INSERT操作,重新插入一条记录(INNSERT操作提供隐式锁保护,保护该记录在当前事务提交前不被其他事务访问)
记录键值发生变化:
这种情况就相当于在原记录上做 DELETE 操作之后再来一次 INSERT 操作,加锁操作就
需要按照 DELETE 和 INSERT 的规则进行了。
前面提到的都是针对某条单一记录的锁(行级锁),根据粒度的大小,MySQL中将锁又分为了表级锁和行级锁,表级锁的粒度大(粗)
和行级锁一样,表级锁也主要是S锁和X锁两种,除了粒度不同之外,它们的性质和兼容性都是一致的,即:
例子:
图书馆6层,内若有读者,则写者不可进,但读者是可进的;内若有写者,则读者和其他写者都不可进。
一个读者进入了图书馆就想关于给整个图书馆加了S锁,后面的读者可获取S锁,而写者无法获取X锁而阻塞;
同理,一个写者进入了图书馆就想关于给整个图书馆加了X锁,后面的读者不可获取S锁,写者无法获取X锁,都阻塞。
针对上述图书馆的例子,现在问题来了,有一天,一个读者来了,他怎么知道图书馆里面有没有写者呢?同理,若来的是写者,他又怎么知道里面有没有读者呢?难道是一层层去遍历查看吗?这显然不现实,毕竟遍历的效率太低了。
为了解决这个问题,InnoDB中提出了一个新的概念——意向锁。
有了意向锁的存在,上述图书馆的问题就可以轻松解决了:
当读者准备进入图书馆的时候,先在图书馆大门口防置“读者阅读,写者勿进”(加IS锁),这样后来的写者看到之后就会自觉止步,而后来的读者还是可以进入的。
当写者准备进入图书馆的时候,先在图书馆大门口防置“写者撰写,禁止入内”(加IS锁),这样后来的读者和写者看到之后就会自觉止步。
其实对于意向锁我们可以有以下粗略的理解:
意向锁只是一个标志性的东西,它和我们上面提到的S锁和X锁不一样,意向锁的出现只是为了避免用遍历的方式去查看某表是否存在S锁或者X锁;IS锁和IX锁兼容。
我们都知道在创建表的时候可以给某列带上AUTO_INCREMENT 属性,实现其值递增,在后面执行INSERT操作的时候若是没有显式指明该列的值,系统会自动赋予它一个递增的值。
创建表:
CREATE TABLE
emp(
idint NOT NULL AUTO_INCREMENT,
namevarchar(255) NOT NULL, PRIMARY KEY (
id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
插入数据:
INSERT INTO emp (name) VALUES ('A'), ('B');
查看数据:其中id号是系统自动添加的递增值。
将数据插入分为三大类:
INSERT...VALUES()
和 REPLACE
INSERT ... SELECT
, REPLACE ... SELECT
和 LOAD DATA
INSERT INTO emp (id,name) VALUES (1,'a'), (NULL,'b'), (5,'c'), (NULL,'d');
INSERT ... ON DUPLICATE KEY UPDATE ;
讲了数据的三种插入方式之后,我们再来谈谈InnoDB中是如何保证AUTO_INCREMENT修饰的列递增赋值的:
InnoDB中有一个叫innodb_autoinc_lock_mode 的系统变量,它的取值决定着用何种方式保证AUTO_INCREMENT修饰的列递增赋值。
innodb_autoinc_lock_mode = 0
:
这是一种“传统的锁定模式”,在这种情况下,一律采用采用AUTO-INC锁:
每次执行插入语句时,都会在表级别加AUTO-INC锁,然后为待插入记录中AUTO_INCREMENT修饰的列分配递增的值, 在整个插入过程中,其他事务受当前事务的AUTO-INC锁影响而阻塞,从而保证了一个语句中分配的递增值是连续的。
这里有一点需要特别注意的是:AUTO-INC锁的作用范围只是单个插入语句,插入完成,AUTO-INC锁就释放了,并不需要等到事务提交。
innodb_autoinc_lock_mode = 1
:
此时,若待插入记录的数量确定,则使用AUTO-INC锁的方式;若待插入记录的数量确定,则采用一个轻量级的锁 :在为AUTO_INCREMENT修饰的列生成递增值的过程中产生,和AUTO-INC锁不一样的是,这个轻量级锁在生成值之后就释放了,并不需要等整个插入语句执行完。
**在 MySQL 8.0 之前 ,默认innodb_autoinc_lock_mode = 1
**
innodb_autoinc_lock_mode = 2
:
在这种情况下,一律采用轻量级锁 ,自动递增值保证在所有并发执行的所有类型的insert语句中是 唯一且 单调递增 的。但是可能会造成不同事务中的插入语句为AUTO_INCREMENT修饰的列生成的值是交叉的,在有主从复制的场景中是不安全的 。
官方的类型名称为: LOCK_REC_NOT_GAP ,记录锁顾名思义就是对一条记录上锁(对一行数据上锁),其他范围不受影响。
比如下图,若对id为2的记录加记录锁,则锁住的范围就是图中蓝色的范围:
Gap Locks是使得MySQL在REPEATABLE READ 隔离级别下可以解决幻读问题的方案之一。在幻读情况下加锁的最尴尬的问题是幻影记录是新插入的在插入前无法加锁(事务在第一次执行读取操作时,那些幻影记录尚不存在,我们无法给这些 幻影记录加上记录锁 ),而Gap Locks的出现正好可以解决这个问题:
幻影记录的产生正是像上图的情况,将一条记录(6)从无到有,而Gap Locks是如何解决这个问题的呢?
很简单,像上面的情况,只需要给记录5和8之间加锁,不给插入即可(其他情况同理)
在id(5~8)之间加间隙锁:SELECT * FROM emp WHERE id = 6 LOCK IN SHARE MODE;
或者 SELECT * FROM emp WHERE id = 6 FOR UPDATE;
即共享gap锁 和 独占gap锁 等效。
在8到正无穷范围内加锁:SELECT * FROM emp WHERE id = 9 LOCK IN SHARE MODE;
(其他区间类似)
在(a,b)区间加gap锁:SELECT * FROM 表名 WHERE 列 = x LOCK IN SHARE MODE;
其中x的取值范围为(a,b)。
这是一种特殊的间隙锁,前面Gap Locks的范围是开区间,而这里的Next-Key Locks 是前开后闭区间,即包括又端点记录(右端点记录同时被锁定)
Next-Key Locks是在存储引擎 innodb 、事务级别在 可重复读 的情况下使用的数据库锁,innodb默认的锁就是Next-Key locks。
给(5,8]区间范围内加锁:
begin; select * from emp where id <=8 and id > 5 for update;
插入意向锁本质上是一种Gap锁,它不是意向锁
在要向表中插入新记录的时候,需要对相应的间隙进行检查判断是否存在Gap锁(包括Next-Key Locks ),如果该间隙存在Gap锁,则插入操作需要阻塞等待直到Gap锁被释放后。但是InnoDB中规定事务在等待的时候也需要在内存中生成一个锁结构,以表明有事务正等待插入。
这个和我们在第一部分“何为锁”中讲的其实是差不多的,只是这时候多了点信息:
乐观和悲观是对待事情的一种主观态度,对于锁来说同样也如此:
就悲观锁而言,它是很没有安全感的,时刻觉得在自己读取数据或者怎样的时候,别人会对其进行干扰;所以每次在拿数据的时候都会上锁 ,比如行锁,表锁等;读锁,写锁等,都是在做操作之前先上锁,当其他事务想要访问同一数据时,都会阻塞等待。
**悲观锁的方式适合 写操作多的场景 ,可以在数据库层面阻止其他事务对该数据的操作权限,防止 读 - 写 和 写 - 写 的冲突。 **
乐观锁是很积极向上的,它认为同一数据被并发操作是小概率事件,很少发生的,所以它不采用数据库自身的锁机制,而是采用一定的程序机制来判断数据前后是否发生变化。
乐观锁 适合 读操作多 的场景,它不加锁,不会引发死锁问题。
悲观锁和乐观锁这部分谈的有点少,如果下次有机会的话,我们再深入地了解吧~
作权限,防止 读 - 写 和 写 - 写 的冲突。 **
乐观锁是很积极向上的,它认为同一数据被并发操作是小概率事件,很少发生的,所以它不采用数据库自身的锁机制,而是采用一定的程序机制来判断数据前后是否发生变化。
乐观锁 适合 读操作多 的场景,它不加锁,不会引发死锁问题。
悲观锁和乐观锁这部分谈的有点少,如果下次有机会的话,我们再深入地了解吧~