MYSQL事务学习-锁机制

一、概述

在多个线程并发访问某个数据尤其是敏感的数据的时候,我们需要保证这个数据在任何时候都是最多只有一个线程在访问,从而保证数据的完整性和一致性。而锁就是计算机协调多个进程或线程并发访问某一资源的机制。锁机制为实现MySQL的各个隔离级别提供了保证,而锁冲突也是影响数据库并发访问性能的一个重要因素。

二、事务并发情况分类

1. 读-读情况

即并发事务相继读取相同的记录。读取操作本身不会对记录有任何影响,不会有什么问题,所以是允许这种情况的

2. 写-写情况

即并发事务相继修改相同的记录。这种情况下会发生脏写的问题,脏写的问题是无论如何都不允许出现的。所以在多个未提交事务相继对一个记录做改动是,需要让它们排队执行,这个排队的过程其实就是通过锁来实现的。
这个锁其实就是一个内存结构,在事务执行时才会由事务生成,也就是说一开始是没有锁结构和记录关联的。锁结构主要包含两个部分:

  • trx信息:代表了锁是由哪个事务生成的
  • is_waiting:代表当前事务是否在等待

加锁流程:
假设有一个记录A

  1. 当前没有事务来改动时,内存中没有锁结构和记录A来关联
  2. 当事务T1改动记录A前,先判断有没有锁结构和记录A关联,然后就生成一个锁结构来与记录A关联,因为此前没有任何事务给记录A加锁,所以事务T1生成的锁结构is_waiting就是false,这就叫获取锁成功,或者叫加锁成功
  3. 在事务T1提交之前,另一个事务T2也想对记录A进行改动,事务T2先判断一下是否有锁结构和记录A关联,再去生成一个锁结构和记录A关联,而因为此时已经有一个锁结构和记录A关联了,所以事务T2生成的锁结构is_waiting值为true,这就叫获取锁失败,或者叫加锁失败,此时事务T2就开始等待事务T1锁的释放
  4. 在事务T1提交之后,就会把自己生成的锁结构释放掉,然后看看有没有别的事务在等待获取锁,此时就发现事务T2还在等待获取锁,所以把事务T2对应的锁结构里的is_waiting改为false,然后把事务T2对应的线程唤醒,让它继续执行,此时事务T2就算获取到锁了

3.读-写或写-读情况

即一个事务进行读取操作,另一个进行写操作。这种情况下就可能发生脏读、不可重复读、幻读的问题。
对于脏读、不可重复读、幻读的问题,有两种解决方案:

方案一:读操作使用多版本并发控制(MVCC),写操作进行加锁

多版本并发控制(MVCC),就是生成一个ReadView,通过ReadView找到符合条件的记录版本(历史版本通过undo日志构建)。查询语句只能读取到生成ReadView之前已经提交的事务所做的更改,在生成ReadView之前未提交的事务或者之后才开启的事务所做的更改是看不到的。而写操作肯定是针对最新版本的记录,读记录的历史版本和改动记录的最新版本本身并不冲突,也就是说采用MVCC时,读-写操作并不冲突

方案二 :读、写操作都采用加锁的方式

在一些对数据敏感的业务场景下,是不允许读取数据的历史版本的,而是每次都必须去读取记录的最新版本,例如银行中的余额读取。这样的情况下在读取记录的时候就要对其进行加锁操作,也就意味着读操作和写操作也需要排队执行
读、写操作都加锁的情况下,对脏读、不可重复读、幻读的问题的解决和读操作使用MVCC有所不同:

  • 脏读:脏读的产生是因为一个事务读取了另一个未提交事务写的记录,如果事务在写的时候就给记录加锁,那么别的事务就无法继续读取这条记录,脏读的问题也就不会发生了
  • 不可重复读:不可重复读的产生是因为一个事务A先读取了记录,这时另外一个事务B对这个记录进行了修改并提交,此后事务A又再次读取记录,结果就获取到了不同的值。如果事务A在读时就给记录加锁,那么事务B需要在事务A释放锁之后才能对记录进行操作,自然就不会有不可重复读的问题了
  • 幻读:幻读的产生是因为事务A先读取了一个范围的记录,这是事务B向这个范围内插入了新的记录,事务A再次读取时就会发现多了记录。采用加锁的方式解决幻读的问题就会有些麻烦,因为在事务A第一次读取记录时幻影记录并不存在,这时想加锁也不知道给谁加。

小结

对比两种方案可以发现:

  • 采用MVCC的方式,读写操作彼此并不冲突,性能更高
  • 采用都加锁的方式,读写操作彼此需要排队执行,影响性能

一般情况下更多使用MVCC的方式解决并发执行的问题,但是在某些特殊情况下,就要求必须采用加锁的方式执行。

三、锁的分类

1.从数据操作的类型划分:读锁、写锁

在使用加锁的方式解决并发问题时,由于即要允许读-读的情况不受影响,又要使写-写、读-写或写-读情况中的操作互相阻塞,所以MySQL实现一个由两个类型的锁组成的所系统来解决。这两种类型的锁通常被称为共享锁(Shared Lock,S Lock)和排他锁(Exclusive Lock,X Lock),也叫读锁(read lock)和写锁(write lock)。

  • 读锁:也被称为共享锁、英文用S表示。针对同一份数据,多个事务的读操作可以同时进行而不会互相影响,相互是不阻塞的。
  • 写锁:也被称为排他锁、英文用X表示。当前写操作没有完成前,它会阻塞其他写锁和读锁。这样就能确保在给定的时间里,只有一个事务能执行写入,并防止其他用户读取正在写入的同一资源。

对于InnoDB引擎来说,读锁和写锁可以加在表上,也可以加在行上。如下为同一记录锁(行级读写锁)的兼容性情况:

X锁S锁
X锁不兼容不兼容
S锁不兼容兼容

1.1 锁定读

需要注意的是读锁和写锁其实更应该称呼为共享锁和排他锁,对数据的修改只能加排他锁也就是X锁,但是select命令可以加共享锁也可以加排他锁,以下是给select分别加两种锁的区别:

  • 加共享锁:
SELECT .... LOCK IN SHARE MODE;
#8.0新增语法
SELECT .... FOR SHARE;

在普通的select语句后面加上LOCK IN SHARE MODE,如果当前事务执行了该语句,那么它会为读取到的记录加S锁,这样允许别的事务继续获取这些记录的S锁(比方说别的事务使用SELECT … LOCK IN SHARE MODE语句来读取这些记录),但不允许别的事务获取这些记录的X锁(比方说别的事务使用SELECT … FOR UPDATE语句来读取这些记录,或者直接修改这些记录)。如果别的事务想要获取这些记录的X锁,那么它们会阻塞,直到当前事务提交之后将这些记录上的S锁释放掉。

  • 加排他锁:
SELECT .... FOR UPDATE;

在普通的select语句后面加上FOR UPDATE,如果当前事务执行了该语句,那么他会为读取到的记录加X锁,这样既不允许别的事务获取这些记录的S锁(比方说别的事务使用SELECT … LOCK IN SHARE MODE语句来读取这些记录),也不允许获取这些记录的X锁(比方说别的事务使用SELECT … FOR UPDATE语句来读取这些记录,或者直接修改这些记录)。如果别的事务想获取这些记录的S锁或者X锁,那么它们会阻塞,直到当前事务提交之后将这些记录上的X锁释放掉。
MySQL8.0新特性
在MySQL5.7及之前的版本,SELECT … FOR UPDATE,如果获取不到锁,会一直等待,直到innodb_lock_wait_timeout超时。
在8.0版本中,SELECT … FOR UPDATE,SELECT … FOR SHARE通过添加NOWAIT、SKIP LOCKED后缀,跳过锁等待,或者跳过锁定。如果查询的行已经加锁:

  • 那么NOWAIT会立即报错返回
  • 而SKIP LOCKED也会立即返回,只是返回的结果中不包括被锁定的行
    注:SELECT … FOR UPDATE语句执行过程中所有扫描的行都会被锁上,因此如果没有索引,直接全表扫描的话,会导致整个表被锁上
SELECT .... FOR UPDATE NOWAIT;
SELECT .... FOR UPDATE SKIP LOCKED;

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

1.2 写操作

写操作一定是加的排他锁,写操作指的就是delete、insert、update三种:

  • delete :对一条记录做delete操作的过程其实先在B+树中定位到这条记录的位置,然后获取这条记录的X锁,再执行delete mark操作。我们也可以把这个定位待删除记录在B+树位置的过程看成是一个获取X锁的锁定读

  • update:在对一条记录进行update操作时分为三种情况:

    • 情况1:未修改该记录的键值,并且被更新的列占用的存储空间在修改前后没有发生变化。那么InnoDB会执行以下步骤:

      1. 定位待修改记录在B+树中的位置:首先,通过索引查找到待修改记录在B+树中的节点位置。这可以通过使用索引的搜索算法(如二分查找)来实现。
      2. 获取X锁:一旦找到待修改记录的位置,InnoDB会对该记录加X锁。X锁是一种排他锁,表示当前事务拥有该记录的独占访问权,其他事务无法读取或修改该记录。
      3. 修改记录,一旦记录被锁定,InnoDB就会在原记录的位置上进行修改操作。由于被更新的列在修改前后占用的存储空间没有发生变化,因此不需要移动记录或重新插入新记录,也不会影响B+树的结构稳定性。这种情况下,记录的修改是原地进行的,不涉及删除和重新插入的操作,这与修改后存储空间发生变化的情况不同,后者需要删除旧记录并插入新记录以反映变化。

      总的来说,这种原地更新的方式提高了更新操作的效率,因为它避免了复杂的删除和插入操作,减少了B+树叶子节点的分裂或合并,也减少了页的分裂和合并,从而提高了性能。同时,由于只对一条记录加锁,而不是对整个表加锁,因此可以提高并发性能,尤其是在高并发的场景下。

    • 情况2:未修改该记录的键值,并且至少有一个被更新的列占用的存储空间在修改前后发生变化。那么InnoDB会执行以下步骤:

      1. 定位待修改记录在B+树中的位置:首先,通过索引查找到待修改记录在B+树中的节点位置。这可以通过使用索引的搜索算法(如二分查找)来实现。
      2. 获取X锁:一旦找到待修改记录的位置,InnoDB会对该记录加X锁。X锁是一种排他锁,表示当前事务拥有该记录的独占访问权,其他事务无法读取或修改该记录。
      3. 删除记录:接下来,InnoDB会将该记录彻底删除掉,即将其从B+树中移除,并将其放入垃圾链表中。这个过程涉及到释放记录所占用的存储空间,并确保其他事务无法再访问该记录。
      4. 插入新记录:最后,InnoDB会插入一条新的记录来替换被删除的记录。这个新记录由INSERT操作提供的隐式锁进行保护,以确保其他事务无法同时修改该记录。

      在这个过程中,定位待修改记录在B+树中的位置可以看作是一个获取X锁的锁定读操作。锁定读操作是为了确保在读取数据时能够获取到最新的数据版本,并且防止其他事务在此期间修改数据。通过加锁,可以保证数据的一致性和并发控制。

    • 情况3: 修改了记录的键值,则相当于在原纪录上做delete操作之后再来一次insert操作,加锁操作就需要按照delete操作和insert操作的规则进行了

  • insert:一般情况下,新插入一条记录的操作并不加锁,而是通过一种称之为隐式锁的结构来保护这条新插入的记录在本事务提交前不被别的事务访问。

2.从数据操作的粒度划分:表级锁、页级锁、行锁

从提高数据库的并发度的角度来看,每次锁定的数据范围越小越好,理论上来说每次只锁定当前操作的数据的方案能得到最大的并发度,但是管理锁是一件很耗费资源的事情(涉及到获取、检查以及释放锁等动作)。因此数据库需要在高并发响应系统性能两方面进行平衡,因此就有了**锁粒度(Lock granularity)**的概念。锁的粒度主要分为表级锁、页级锁和行锁。

2.1 表锁(Table Lock)

该锁会锁定整张表,它是MySQL中最基本的锁策略,并不依赖于存储引擎(不管是什么存储引擎,对于表锁的策略都是一样的),并且表锁是开销最小的锁(因为粒度比较大)。由于表锁一次会将整个表锁定,所以可以很好的避免死锁的问题。当然锁的粒度大的负面影响就是出现锁资源争用的概率会最高,导致并发率大打折扣。

2.1.1 表级别的S锁、X锁

在对某个表执行select、insert、update以及delete操作时,InnoDB存储引擎是不会为这个表添加表级别的S锁或者X锁的。在对某个表执行一些诸如ALTER TABLE、DROP TABLE这类的DDL语句时,其他事务对这个表并发执行诸如select、insert、update以及delete的语句会发生阻塞,同理某个事务对某个表执行select、insert、update以及delete的语句时,在其他会话中对这个表执行DDL语句也会发生阻塞。这个过程其实是通过server层使用一种称之元数据锁的结构来实现的。
一般情况下,不会使用InnoDB存储引擎提供的表级别的S锁和X锁。只会在一些特殊情况下,比方说崩溃恢复过程中用到。对于InnoDB来说,它实现了更细粒度的行锁,而且在InnoDB中使用表级别的S锁和X锁并不会提供什么额外的保护,所以以MyISAM引擎下的表锁来举例(InnoDB也可以使用):

# 1. 查看加上表级S锁或X锁的表
show open tables where in_use > 0;
# 2. 给表加锁
# 2.1 加读锁(表级别的共享锁)
LOCK TABLES t READ;
# 2.2 加写锁(表级别的排他锁)
LOCK TABLES t WRITE;
# 3. 释放锁(释放当前加锁的表)
unlock tables;

如下为表级S锁和X锁的兼容情况:

锁类型自己可读自己可写自己可以操作其他表他人可读他人可写
读锁否(阻塞)
写锁否(阻塞)否(阻塞)
2.1.2 意向锁 (intention lock)

InnoDB支持多粒度锁(multiple granularity locking),它允许表级锁和行级锁共存,而意向锁就是其中的一种表锁。意向锁分为以下两种:

  • 意向共享锁(intention shared lock,IS):事务有意向对表中的某些行加共享锁
  • 意向排他锁(intention exclusive lock,IX):事务有意向对表中的某些行加排他锁

场景举例:
当前有两个事务,分别为T1和T2,其中T2试图在该表上加表级的共享锁或排他锁,如果没有意向锁的存在,那么T2就需要去检查各个页或行是否存在锁,在数据量庞大的表中这样的方式是效率极低的;如果存在意向锁,那么T2试图加锁时就会受到由T1控制的表级别意向锁的阻塞,T2在锁定该表前就不必检查各个页或行,而只需检查表上的意向锁。简单来所就是给更大一级别的空间示意里面是否已经上过锁。
在数据表的场景中,如果我们给某一行数据加上了排他锁,数据库会自动给更大一级的空间,比如数据页或数据表加上意向锁,告诉其他人这个数据页或数据表已经有人上过排他锁了,这样当其他事务想要获取数据表的排他锁的时候,只需要了解是否有人已经获取了这个数据表的意向排他锁即可,共享锁同理。

  1. 意向锁的存在是为了协调行锁和表锁的关系,支持多粒度(行锁与表锁)的锁并存
  2. 意向锁是一种不与行级锁冲突的锁
  3. 意向锁是为了表明“某个事务正在某些行持有了锁或该事务准备去持有锁”
    兼容性分析:
意向共享锁意向排他锁
意向共享锁兼容兼容
意向排他锁兼容兼容
意向共享锁意向排他锁
表级共享锁兼容互斥
表级排他锁互斥互斥

意向锁之间是互相兼容的,但是会与表级别的排他/共享锁互斥,但是不会与行级的排他/共享锁互斥。

结论:

  1. InnoDB支持多粒度锁,特定场景下,行级锁可以和表级锁共存
  2. 意向锁之间互不排斥,但除了IS与表级S锁兼容外,意向锁与表级S/X锁互斥
  3. IX,IS是表级锁,不会和行级的X/S锁发生冲突,只会和表级别的X/S锁发生冲突
  4. 意向锁在保证并发性的前提下,实现了行锁与表锁共存且满足事务隔离性的要求
2.1.3 自增锁(AUTO-INC锁)

在我们创建表的时候我们可以为表的某一列添加AUTO_INCREMENT属性。而添加上这个属性之后就意味着插入数据时不需要为这个列赋值,系统可以自动为它附上递增的值。一般来说插入数据的方式分为三类,分别是:

  1. 简单插入(Simple inserts):
  2. 批量插入(Bulk inserts)
  3. 混合模式插入(Mixed-mode inserts)

在插入数据时,对于自增列,MySQL采用了自增锁的方式来实现,AUTO-INC锁是当向使用含有AUTO_INCREMENT列的表中插入数据时需要获取的一种特殊的表级锁,在执行插入语句时就在表级别加一个AUTO-INC锁,然后为每条待插入记录的自增列分配递增的值,在该插入语句执行结束后,再把AUTO-INC锁释放掉。一个事务在持有AUTO-INC锁的过程中,其他事务的插入语句都要被阻塞,这样可以保证一个语句中分配的递增值时连续的

2.1.4 元数据锁(MDL锁)

MySQL5.5引入了meta data lock,简称MDL锁,属于表锁范畴。MDL的作用是,保证读写的正确性。比如如果一个事务正在执行查询语句遍历一个表中的数据,而执行期间另一个事务对这个表的结构做变更,增加了一列,那么查询事务拿到的结果就和表结构对不上,这样肯定是不行的。
因此,当对一个表做增删改查操作时,加MDL读锁;当要对表结构做变更操作的时候,加MDL写锁。
MDL读锁之间不互斥,因此可以有多个事务对同一张表增删改查。读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性,解决了DML和DDL操作之间的一致性问题。MDL锁不需要显示使用,在对表做增删改查时会被自动加上MDL读锁,对表结构做变更操作的时候自动加上MDL写锁。

2.2 行锁(Row Lock)

行锁也被称为记录锁,顾名思义,就是锁住某一行(某条记录row)。需要注意的是,MySQL服务器层并没有实现锁机制,行级锁只在存储引擎层实现
优点: 锁定粒度小,发生锁冲突的概率低,可以实现的并发度高
缺点:对于锁的开销比较大,加锁会比较慢,容易出现死锁情况
InnoDB和MyISAM最大的两点不同:一是支持事务;而是采用行级锁。

下面以一张student表来举例说明各种行锁。

#表结构
CREATE TABLE student(
id INT,
name VARHAR(20),
class varchar(10),
PRIMARY KEY(id)
)Engine=InnoDB CHARSET=utf8;
#插入几条记录
INSERT INTO student VALUES
(1,'张三','一班'),
(3,'李四','一班'),
(8,'王五','二班'),
(15,'赵六','二班'),
(20,'钱七','三班');

此时表中聚簇索引叶子节点中数据如下(简化版):

id列1381520
name列张三李四王五赵六钱七
class列一班一班二班二班三班
2.2.1 记录锁(Record Lock)

记录锁也就是仅仅把一条记录锁上,官方的类型名称为:LOCK_REC_NOT_GAP。例如,给student表中id为8的数据加一个记录锁,就仅仅是锁住了id值为8的记录。
记录锁分为S型记录锁和X型记录锁。

  • 当一个事务获取了一条记录的S型记录锁后,其他事务也可以继续获取这条记录的S型记录锁,但不可以获取X型记录锁;
  • 当一个事务获取了一条记录的X型记录锁后,其他事务既不可以继续获取这条记录的S型记录锁,也不可以获取X型记录锁。
2.2.2 间隙锁

MySQL在REPEATABLE READ隔离级别下是可以解决幻读问题的,解决方案有两种,可以使用MVCC方案解决,也可以采用加锁方案解决。但是在使用加锁方案解决时有个大问题,就是事务在第一次执行读取操作时,那些幻影记录尚不存在,我们无法给这些幻影记录加上记录锁。InnoDB为此提供了一种称之为Gap Locks的锁,官方的类型名称为:LOCK_GAP,我们可以简称为gap锁。
gap锁的提出仅仅是为了防止插入幻影记录而提出的,仅在可重复读隔离级别下生效。虽有共享gap锁和独占gap锁这样的说法,但是它们起到的作用是相同的。即如果对一条记录加了gap锁(不论是共享gap锁还是独占gap锁),并不会限制其他事务对这条记录加记录锁或者加gap锁
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
当有事务获取(3,8)之间的记录的共享锁或者排他锁时,数据库自动会给事务创建一个gap锁,如果有另一事务也需要获取(3,8)之间的记录的共享锁或者排他锁,gap锁不会互相阻塞,此时另一个事务试图往(3,8)之间插入一条id=7的数据,此时插入事务就会被阻塞,直到gap锁被释放。
当有事务准备获取id=25的记录时,此时没有这条记录,数据库会在id =20 和数据最大记录之间加间隙锁即(20,supermum)之间加锁实现(20,∞)之间的间隙锁。

2.2.3 临键锁

临键锁的官方名称为LOCK_ORDINARY,简称为next-key锁。本质上就是一个记录锁和一个间隙锁的合体。例如:

begin;
select * from student where id >3 and id <= 8 for update;

这样从sql上看获取的就是(3,8]之间的临键锁。既要保护id= 8 的记录,又阻止别的事务往(3,8)的间隙插入数据。临键锁是InnoDB在可重复读的隔离级别下使用的数据库锁,是默认使用的。所以上面记录锁和间隙锁的实现效果其实都是临键锁完成的。

2.2.4 插入意向锁

当一个事务给(8,15)的记录之间加了一个间隙锁,此时另一个事务想要往(8,15)之间插入记录,那么此时就需要等待,但是按InnoDB规定事务在等待的时候也是需要在内存中生成一个锁结构的。因此就有了表明有事务想往某个间隙中插入记录,但此时正在等待的锁结构,它被称为Insert Intention Lock,官方名称为LOCK_INSERT_INTENTION,中文名称即为插入意向锁。
插入意向锁是一种Gap锁,不是意向锁(意向锁是表锁而插入意向锁是行锁),在insert操作时产生。该锁用以表示插入意向,当多个事务在同一区间(Gap)插入位置不同的多条数据时,只有数据行之间不冲突,则事务之间不会互相等待。
总结如下:

  1. 插入意向锁是一种特殊的间隙锁——间隙锁可以锁定开区间内的部分记录
  2. 插入意向锁之间互不排斥,所以即使多个事务在同一区间插入多条记录,只要记录本身(主键、唯一索引)不冲突,那么事务之间就不会出现冲突。

2.3 页锁

页锁就是在页的粒度进行锁定,锁定的数据资源比行锁要多,因为一个页中可以有多个行记录。当我们使用页锁的时候,会出现数据浪费的现象,但这样的浪费最多也就是一个页上的数据行。页锁的开销介于表锁和行锁之间,会出现死锁。锁定粒度介于表锁和行锁之间,并发度一般。
每个层级的锁数量是有限制的,因为锁会占用内存空间,锁空间的大小是有限的。当某个层级的锁数量超过了这个层级的阈值时,就会进行锁升级。锁升级就是用更大粒度的锁替代多个更小粒度的锁,比如 InnoDB 中行锁于级为表锁,这样做的好处是占用的锁空间降低了,但同时数据的并发度也下降了。

3. 从对待锁的态度划分:乐观锁、悲观锁

从对待锁的态度来看锁的话,可以将锁分成乐观锁和悲观锁,主要是区分在数据并发的思维方式。乐观锁和悲观锁并不是锁,而是锁的设计思想。

3.1 悲观锁(Pessimistic Locking)

悲观锁是一种思想,顾名思义,就是很悲观,对数据被其他事务的修改持保守态度,会通过数据库自身的锁机制来实现,从而保证数据操作的排它性。
悲观锁总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会 阻塞 直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁,当其他线程想要访问数据时,都需要阻塞挂起。Java中synchronizedReentrantLock等独占锁就是悲观锁思想的实现。

3.2 乐观锁

乐观锁认为对同一数据的并发操作不会总发生,属于小概率事件,不用每次都对数据上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,也就是不采用数据库自身的锁机制,而是通过程序来实现。在程序上,我们可以采用 版本号机制 或者 CAS机制 实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量。

  1. 乐观锁的版本号机制
    在表中设计一个版本字段 version,第一次读的时候,会获取 version 字段的取值。然后对数据进行更新或删除操作时,会执行UPDATE…SET version=version + 1 WHERE version=version。此时如果已经有事务对这条数据进行了更改,修改就不会成功。
    这种方式类似我们熟悉的 SVN、CVS 版本管理系统修改了代码进行提交时,首先会检查当前版本号与服务器上的版本号是否一致,如果一致就可以直接提交,如果不一致就需要更新服务器上的最新代码,然后再进行提交。
  2. 乐观锁的时间戳机制
    时间戳和版本号机制一样,也是在更新提交的时候,将当前数据的时间戳和更新之前取得的时间戳进行比较,如果两者一致则更新成功否则就是版本冲突。

注意,如果数据表是读写分离的表,当master表中写入的数据没有及时同步到slave表中时,会造成更新一直失败的问题。此时需要强制读取master表中的数据(即将select语句放到事务中即可,这时候査询的就是master主库了。)

3.3 两种锁的适用场景

基于这两种锁的设计思想,可总结出乐观锁和悲观锁的适用场景:

  1. 乐观锁适合读操作多的场景,相对来说写的操作比较少。它的优点在于"程序实现",不存在死锁 问题,不过适用场景也会相对乐观,因为它阻止不了除了程序以外的数据库操作。
  2. 悲观适合写操作多的场景,因为写的操作具有排它性。采用悲观锁的方式,可以在数据库层面阻止其他事务对该数据的操作权限,防止读-写和写 -写的冲突。

4.按加锁的方式划分:显示锁、隐式锁

4.1 隐式锁

一个事务执行insert操作时,如果即将插入数据的间隙已经被其他事务获取了间隙锁,那么当前事务insert会获取插入意向锁,然后等待间隙锁的释放,再执行插入操作;如果没有其他事务获取间隙锁时,那么插入完成,即使事务没有提交,但是内存中的数据页其实已经有了这条记录,如果没有锁的保护的话,那么这条记录就会被其他事务获取到锁结构(获取到S锁就是脏读,获取到X锁就是脏写),而这是不允许的,因此InnoDB基于这种情况,提供了隐式锁的机制:

  1. 当事务插入一条记录时,该记录上的隐藏列DB_TRX_ID就会记录当前事务的id

  2. 当另一个事务试图获取该记录的锁时会判断记录对应的事务是否已经提交,根据聚簇索引和二级索引还分为以下两种判断方式:

    • 聚簇索引:根据记录DB_TRX_ID上的事务id,判断对当前记录最后修改的事务是否为活跃事务
    • 二级索引:根据数据页中页头中的PAGE_MAX_TRX_ID中数据页最大修改事务id和当前最小的活跃事务id进行比较,如果比最小的活跃事务id小则对当前数据页操作的事务都已经提交,如果没有的话则需要先回表然后再完成聚簇索引中的判断
  3. 如果事务已经提交就直接获取锁结构

  4. 如果没有则会先为插入数据的事务获取一个X锁,然后自己获取锁失败进入等待

# 查询数据库中的锁结构
SELECT * FROM performance_schema.data_lock_waits\G;

4.2 显示锁

通过特定的语句进行加锁,我们一般称之为显示加锁,例如:
显示加共享锁:

# 查询数据库中的锁结构
SELECT ... from lock in share mode;

显示加排他锁:

SELECT .... FOR UPDATE;

5.其他锁

5.1 全局锁

全局锁就是对整个数据库实例加锁。当你需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。全局锁的典型使用场景是:做全库逻辑备份。

# 全局锁命令
flush tables with read lock;

5.2 死锁

5.2.1 概念

两个事务都持有对方需要的锁,并且都在等待对方释放锁,且不会释放自己持有的锁。例如:

事务1事务2
1start transaction;
update account set money = 1000 where id =1;
start transaction;
2update account set money = 1000 where id =2;
3update account set money = 200 where id =2;
4update account set money = 1000 where id =1;
5.2.2 产生死锁的必要条件
  1. 两个或两个以上的事务
  2. 每个事务都持有锁并且在申请新的锁
  3. 锁资源只能被同一事务持有或者不兼容
  4. 事务之间因为持有锁和申请锁导致循环等待

注:死锁的关键在于两个(或以上)事务的加锁的顺序不一致

5.2.3 解决方案
5.2.3.1 方式一: 等待超时

当两个事务互相等待时,当一个事务等待的时间超过数据库设置的阈值时,就将其回滚,其他的事务继续进行。在innodb中,我们可以通过参数INNODB_LOCK_WAIT_TIMEOUT来设置超时时间。
缺点:这个等待的时间太长,对于在线服务来说是无法接受的

5.2.3.2 方式二:使用死锁检测进行死锁处理

方式一太过被动,因此InnoDB还提供wait-for graph算法来主动进行死锁检测,每当加锁请求无法立即满足需要并进入等待时,wait-for graph算法就会被触发。
wait-for graph算法是一种主动的死锁检测机制,要求数据库保存锁的信息链表和事务等待链表两部分信息,
在这里插入图片描述
基于这两部分信息可以绘制wait-for graph(等待图)。
在这里插入图片描述
死锁的检测原理就是构建一个以事务为顶点,锁为边的有向图,判断有向图是否存在环,存在即有死锁。
缺点:每个新的被阻塞的线程,都要判断是不是由于自己的加入导致了死锁,这个操作时间复杂度是0(n)。如果100个并发线程同时更新同一行,意味着要检测100*100=1万次,1万个线程就会有1千万次检测。
缺点如何解决:

  • 方式一:关闭死锁检测,但意味着可能会出现大量的超时
  • 方式二:控制并发访问的数量,比如在中间件中实现对于相同行的更新,在进入引擎前先排队,这样在innodb内部就不会有大量的死锁检测工作。
    更进一步的思路:可以考虑通过将一行转换成逻辑上的多行来减少锁冲突。比如,连锁超市账户总额的记录,可以考虑放到多条记录上。账户总额等于这多个记录的值的总和。
5.2.4 如何避免死锁
  • 合理设计索引,使得业务执行的SQL尽可能锁定更少的行,减少锁竞争
  • 调整业务逻辑SQL的执行顺序,避免update/delete长时间持有锁的SQL在前面
  • 避免大事务,可以将一个大事务拆分成多个小事务来处理,小事务缩短了锁定资源的时间,发生所冲突的概率也更小
  • 在并发较高的系统里,不要显示加锁。
  • 降低隔离级别。如果业务允许,将隔离级别调低也是较好的选择,比如将隔离级别从RR调整为RC,可以避免掉很多因为gap锁造成的死锁。

四、锁的内存结构

事务对一个记录加锁的本质就是在内存中创建一个锁结构与记录关联,而一个事务对多个记录加锁时,并不是每个记录都创建一个锁结构,InnoDB会将满足条件的记录都用一个锁结构去关联。条件如下:

  • 同一个事务中的加锁操作
  • 被加锁的记录在同一个数据页中
  • 加锁的类型是一样的
  • 锁的等待状态是一样的

InnoDB中的锁结构如下:

锁结构
锁所在的事务信息
索引信息
表锁/行锁信息
type_mode
其他信息
一堆比特位

四、锁的内存结构

事务对一个记录加锁的本质就是在内存中创建一个锁结构与记录关联,而一个事务对多个记录加锁时,并不是每个记录都创建一个锁结构,InnoDB会将满足条件的记录都用一个锁结构去关联。条件如下:

  • 同一个事务中的加锁操作
  • 被加锁的记录在同一个数据页中
  • 加锁的类型是一样的
  • 锁的等待状态是一样的

InnoDB中的锁结构如下:

锁结构
锁所在的事务信息
索引信息
表锁/行锁信息
type_mode
其他信息
一堆比特位
  1. 锁所在的事务信息:
    记录的是指向内存中事务信息的一个指针,通过指针可以找到内存中事务的更多信息。

  2. 索引信息:对于行锁来说,需要记录一下加锁的记录属于哪个索引。这里也是一个指针。

  3. 表锁/行锁信息

    • 表锁:记载着是对哪个表加的锁,还有一些其他的信息

    • 行锁:记录了三个重要的信息。

      • Space ID:记录所在的表空间
      • Page Number:记录所在的页号
      • n_bits:对于行锁来说,一条记录对应着一个比特位,一个页面中包含很多记录,用不同的比特位来区分到底是哪一条记录加了锁。为此在行锁的末尾放置了一堆比特位,这个n_bits属性代表使用了多少比特位。n_bits的值一般比数据页中的记录条数大一些,主要是为了页面在插入新记录后不至于要重新生成锁结构
  4. type_mode:这是一个32位的数,被分成了lock_mode、lock_type、rec_lock_type三个部分。

  • lock_mode:锁的模式,占用低4位,可选的值如下:
    • LOCK_IS:表示共享意向锁
    • LOCK_IX:表示独占意向锁
    • LOCK_S:表示共享锁
    • LOCK_X:表示独占锁
    • LOCK_AUTO_INC:表示自增锁

在innodb中LOCK_IS、LOCK_IX、LOCK_AUTO_INC都算是表级锁的模式,LOCK_S和LOCK_X既可以算是表级锁的模式,也可以算是行锁的模式

  • lock_type:锁的类型,用于区分是行锁还是表锁
    • LOCK_TABLE:表级锁
    • LOCK_REC:行级锁
  • rec_lock_type:行锁的具体类型,只有在lock_type的值为LOCK_REC时,也就是只有在该锁是行级锁时,才会被细分为更多的类型。
    • LOCK_ORDINARY:表示临键锁
    • LOCK_GAP:表示间隙锁
    • LOCK_REC_NOT_GAP:表示记录锁
    • LOCK_INSERT_INTENTION:表示插入意向锁
  • LOCK_WAIT:锁的等待状态。为了节省空间,所以把is_waiting属性放到了type_mode中。当第9个比特位的值为1时,表示is_waiting值为true。为0时则表示is_waiting值为false。
  1. 其他信息:为了更好的管理系统运行过程中的各种锁结构生成而设计了各种哈希表和链表
  2. 一堆比特位:如果是行锁结构的话,在该结构的末尾还放置了一堆比特位,比特位的数量由n_bits表示。InnoDB数据页中的每条记录在记录头信息中都有一个heap_no属性,最小记录Infimum的heap_no值为0,最大记录Supremum的heap_no的值为1,之后每插入一条记录,heap_no的值就加一。锁结构的一堆比特位就对应着一个页面中的记录,一个比特位映射一个heap_no,即一个比特位映射到页面内的一条记录,用0和1来表示对应的一条记录是否加锁

五、锁监控

# 查看锁状态
show status like 'innodb_row_lock%';

各个状态量说明:

  • innodb_row_lock_current_waits:当前正在等待锁定的数量
  • innodb_row_lock_time:从系统启动到现在锁定总时长
  • innodb_row_lock_time_avg:每次等待的平均花费时间
  • innodb_row_lock_time_max:从系统启动到现在锁定最长的时间
  • innodb_row_lock_waits:从系统启动到现在总共等待的次数
    其他的监控方法:
    MySQL把事务和锁的信息记录在了information_schema库中,涉及到的三张表分别是 INNODB_TRX、INNODB_LOCKS和INNODB_LOCK_WAITS 。
    MySQL5.7及之前,可以通过information_schema.INNODB_LOCKS查看事务的锁情况,但只能看到阻塞事务的锁;如果事务并未被阻塞,则在该表中看不到该事务的锁情况。
    MySQL8.0删除了information_schema.INNODB_LOCKS,添加了performance_schema.data_locks,可以通过performance_schema.data_locks查看事务的锁情况,和MySQL5.7及之前不同,performance_schema.data_locks不但可以看到阻塞该事务的锁,还可以看到该事务所持有的锁。
    同时,information_schema.INNODB_LOCK_WAITS也被performance_schema.data_lock_waits 所代替。

MVCC

MVCC(Multi Version Concurrency Control)即多版本并发控制,是通过数据行的多个版本管理来实现数据库的并发控制的技术。通过这项技术InnoDB的事务隔离级别下的一致读有了保证,即当查询一些正在被更新的数据行时,可以查看到它被更新之前的值,这样就不需要等待另一个事务释放锁之后才能查询。
MVCC的实现主要就是依靠ReadView、行记录中的DB_TRX_ID和DB_ROLL_PTR来实现的。ReadView就是MVCC机制实现快照读操作时产生的读视图

ReadView的数据结构

  • creator_trx_id:当前ReadView的事务id,只有对表中记录做改动时(insert、update和delete这些语句)才会为事务分配id,否则在一个只读事务中的事务id值都默认为0
  • trx_ids:表示在生成当前ReadView时系统中正在活跃(已启动但未提交)的事务的id列表
  • up_limit_id:ReadView中的事务id列表中最小的活跃事务的id
  • low_limit_id:表示生成ReadView时系统应该分配给下一个事务的id值。low_limit_id是系统当前创建的最大的事务id。

ReadView的规则

当有了一个ReadView,这样在访问某条记录时,只需要按照下边的步骤判断记录的某个版本是否可见。

  • 如果被访问版本的trx_id属性值与ReadView中的 creator_trx_id 值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。
  • 如果被访问版本的trx id属性值小于ReadView中的 up_limit_id值,表明生成该版本的事务在当前事务生成ReadView前已经提交,所以该版本可以被当前事务访问。
  • 如果被访问版本的trx_id属性值大于或等于ReadView中的 low_limit_id 值,表明生成该版本的事务在当前事务生成Readview后才开启,所以该版本不可以被当前事务访问。
  • 如果被访问版本的trx_id属性值在ReadView的 up_limit_id和 low_limit_id之间,那就需要判断一下trx_id属性值是不是在 trx_ids 列表中。
    • 如果在,说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问。
    • 如果不在,说明创建ReadView时生成该版本的事务已经提交,该版本可以被访问。

MVCC整体操作流程

  • 获取事务id:事务获取自己的版本号,也就是事务id
  • 获取ReadView
    • 创建事务快照:当事务开始时,InnoDB存储引擎会创建一个快照,这个快照就是ReadView的一部分。它包含了当前系统所有活跃的事务ID。
    • 记录活跃事务ID:ReadView中维护了一个活跃事务列表,这个列表包含了所有在当前事务开始时刻尚未提交的事务ID。这些事务ID用于后续的数据版本可见性判断。
  • 查询得到的数据,和ReadView中的事务id进行比较
    • 数据版本选择:在进行数据读取操作时,会根据ReadView中的活跃事务列表、最小活跃事务id以及最大事务id来选择可见的数据版本。通过三个属性来判断数据的版本是否可见。
    • Undo Log的应用:如果一个数据版本不可见,InnoDB会通过Undo Log来找到该数据的历史版本,直到找到一个可见的版本或者确定该行数据在事务开始前就被删除了。
    • 隔离级别的影响:在不同的隔离级别下,ReadView的行为会有所不同。例如,在REPEATABLE READ隔离级别下,事务在整个执行期间都会使用同一个ReadView,确保了数据的一致性。
  • 返回符合规则的数据
  • 事务提交:当一个事务提交时,它的事务ID会从ReadView的活跃事务列表中移除。这意味着随后开始的新事务将能够看到这个已提交事务所做的更改。

注:每当一个新的事务开始,都会创建一个新的ReadView,以确保每个事务都有自己的数据视图,不受其他并发事务的影响。

不同隔离级别,ReadView的生成策略

普通select语句在READ COMMITTED和REPEATABLE READ隔离级别下会使用到MVCC读取记录。

  • READ COMMITTED隔离级别下,一个事务在执行过程中每次执行select操作时都会生成一个ReadView,ReadView的存在本身就保证了事务不可以读取到未提交的事务所做的更改,也就是避免了脏读问题
  • REPEATABLE READ隔离级别下,一个事务在执行过程中只有第一次执行select操作时才会生成一个ReadView,之后的select操作都是复用这个ReadView,这样也就避免了不可重复读和幻读的问题
  • 7
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值