MySQL进阶之(十三)MySQL锁机制

13.1 锁的引入

事务的隔离性由锁来实现。

锁是计算机协调多个线程或进程并发访问某一资源的机制。在程序开发中会存在多线程同步的问题,当多个线程并发访问某个数据的时候,尤其是针对一些敏感的数据(比如订单、金额等),就需要保证这个数据在任何时刻最多只有一个线程在访问,保证数据的完整性和一致性。在开发过程中加锁是为了保证数据的一致性,这个思想在数据库领域中同样非常重要。

在数据库中,除了传统的计算资源(比如CPU、RAM、I/O等)的争用以外,数据也是一种供许多用户共享的资源。为了保证数据的一致性,需要对并发操作进行控制,所以产生了锁。 同时,锁机制也为实现 MySQL 的各个隔离级别提供了保证。锁冲突也是影响数据库并发访问性能的一个重要因素。所以,锁对于数据库而言显得尤为重要,也更加复杂。

13.2 MySQL并发事务访问

并发事务访问相同记录的情况大致可以划分为以下 3 种:

  1. 读-读
  2. 写-写
  3. 读-写或写-读

13.2.1 读-读情况

读-读情况:也就是多个事务并发读取相同的记录

读操作本身并不涉及修改记录,所以不会对记录有任何影响,也不会引起什么问题,所以允许这种情况的发生。

13.2.2 写-写情况

写-写情况:也就是多个事务对相同的记录做出改动

这种情况会发生脏写,而任何一种隔离级别都不允许这种情况的发生。所以在多个未提交的事务同时对相同的记录进行改动时,需要进行排队执行,这个排队的过程其实就是通过锁来实现的

这个所谓的锁其实就是内存中的一个数据结构,与记录关联(但是一开始是没有锁结构与记录进行关联的)。

当一个事务想对某条记录做改动时,首先会查看该记录是否存在关联的锁结构。如果没有,就会创建一把锁并和当前记录关联,并设置 is_waiting = false。

★ 假如:事务 T1 要修改一条记录,就需要创建锁结构:
在这里插入图片描述

锁结构 里有很多信息,这里只列出了两个比较重要的属性

  • trx 信息:标识这个锁结构是由哪个事务生成的
  • is_waiting:标识当前事务是否需要等待

因为在事务 T1 之前并没有事务对当前记录加锁,所以 is_waiting = false,这个场景就称为 "获取锁成功",然后就可以继续执行后续的操作了。

★ 假如:在事务 T1 提交之前,事务 T2 也要对该记录进行改动,T2 发现当前记录已经被加锁了,已经有一个锁结构与之关联了,然后其也生成了一个锁结构与这条记录关联,不过锁结构的 is_waiting = true,表示当前事务需要等待,这个场景就称为 “获取锁失败” 或 “加锁失败”
在这里插入图片描述

★ 假如:这时事务 T1 提交了,就会把该事务生成的锁结构释放掉,然后看看还有没有别的事务在等待获取锁,发现事务 T2 还在等待获取锁,所以把事务 T2 对应的锁结构的 is_waiting 属性设置为 false,并唤醒 T2 线程,T2 线程醒来获取到锁后继续执行:
在这里插入图片描述

总结:

  1. 不加锁:不需要在内存中生成对应的锁结构,可以直接执行操作。
  2. 获取锁成功或加锁成功:在内存中生成了对应的锁结构,而且锁结构的 is_waiting = false,事务可以继续执行操作。
  3. 获取锁失败或加锁失败或未获取到锁:在内存中生成了对应的锁结构,但是锁结构的 is_waiting = true,事务需要等待,不能继续执行操作。

13.2.3 读-写或写-读情况

读-写或写-读:也就是一个事务进行写操作,另一个事务并发读取数据

这种情况下可能发生脏读、不可重复读、幻读问题。 但各个数据库厂商对 SQL 标准的实现和支持可能不一样。

13.2.4 并发问题解决方案

怎么解决脏读 、不可重复读、幻读这些问题?

01、读使用MVCC,写加锁

读操作使用 MVCC 写操作进行加锁。

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

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

  • 在 READ COMMITTED 隔离级别下,一个事务在执行过程中每次执行 select 操作时都会生成一个 ReadView,ReadView 的存在本身就保证了事务不可以读取到未提交的事务所做的更改,也就是避免了脏读现象。
  • 在 REPEATABLE READ 隔离级别下,一个事务在执行过程中只有第一次执行 select 操作才会生成一个 ReadView,之后的 select 操作都复用这个 ReadView,这样也就避免了不可重复读和幻读的问题。

02、读写都加锁

如果一些业务场景不允许读取记录的旧版本,而是每次都必须去读取记录的最新版本。

比如:在银行存款的事务中,要先把账户的余额读出来 ,然后将其加上本次存款的金额,最后再写到数据库中。

在将账户余额读取出来后,就不想让别的事务再访问该余额,直到本次存款事务执行完成,其他事务才可以访问账户的余额。这样再读取记录的时候就需要对其进行加锁操作,这样也就意味着读操作和写操作也像写-写操作那样排队执行。

03、总结

脏读的产生是因为当前事务读取了另一个事务未提交的记录,如果另一个事务再写记录的时候就给这条记录加锁,那么当前事务就无法继续读取该记录了,所以也就不会有脏读的问题产生了。

不可重复读的产生是因为当前事务先读取了一条记录,另外一个事务对该记录做了改动之后并提交,当前事务再次读取时会获得不同的值,如果在当前事务读取记录时就给该记录加锁,那么另一个事务就无法修改该记录,也就不会发生不可重复读了。

幻读的产生是因为当前事务读取了一个范围的记录,然后另外的事务向该范围内插入了新纪录,当前事务再次读取该单位的记录时发现了新插入的新纪录。采用加锁的方式解决幻读问题就有一些麻烦,因为当前事务在第一次读取记录时幻影记录并不存在,所以读取的时候并不知道要给谁加锁。

对比

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

一般情况下采用 MVCC 来解决读-写操作并发执行的问题,但是业务在某些特殊情况下,要求必须采用加锁的方式执行。

13.3 锁的不同角度分类

13.3.1 从数据的操作类型划分-读、写

在使用加锁的方式解决问题时,既要允许读-读情况不受影响,又要使写-写、读-写或写-读情况中的操作相互阻塞,所以 MySQL 实现一个由两种类型的锁组成的锁:读锁(read lock)和写锁(write lock),也叫共享锁(shared lock,S锁)和排他锁(exclusive lock,X锁)。

  • 读锁:共享锁,使用 S 锁表示。针对同一份数据,多个事务可以并发读,互不影响、不阻塞。
  • 写锁:排他锁 ,使用 X 锁表示。当前写操作没有完成之前,会阻断其他写锁和读锁。保证了同一时间只有一个事务写入,并且防止了其他用户读取当前正在写入的资源。

在 InnoDB 中读锁和写锁可以加在行上,也可以加在表上,只要出现写锁,不论使先读还是先写都会阻塞。

★ 假如:一个事务 T1 已经获得了某个行 r 的读锁,那么另一个事务 T2 是可以去获得这个行 r 的读锁的,因为读取操作并没有改变 r 行的数据;但是,如果事务 T3 想获得行 r 的写锁,则它必须等待事务 T1、T2 释放掉行 r 上的读锁才可以。

下面是对同一张表或记录的锁的兼容情况的总结:

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

01、锁定读

在采用加锁方式解决脏读、不可重复读、幻读这些问题时,读取一条记录时需要获取该记录的 S 锁,其实是不严谨的,有时候需要在读取记录时就获取记录的 X 锁,来禁止别的事务读写该记录,为此 MySQL 提出了两种比较特殊的 select 语句格式:

  • 对读取的记录加 S 锁

    select ... lock in share mode;
    # 或
    select ... for share; -- 8.0 新语法
    

    在普通的 select 语句后加 lock in share mode。如果当前事务执行了该语句,那么它会为读取到的记录加 S 锁,这样允许别的事务继续获取这些记录的 S 锁(比如别的事务也使用 select … lock in share mode 语句来读取这些记录),但是不能获取这些记录的X锁(比如使用 select … for update 语句来读取这些记录,或者直接修改这些记录)。如果别的事务想要获取这些记录的 X 锁,那么它们会阻塞,直到当前事务提交之后将这些记录上的 S 锁释放掉。

  • 对读取的记录加 X 锁

    select ... for update;
    

    在普通的 select 语句后边加 for update,如果当前事务执行了该语句,那么它会为读取到的记录加X 锁,这样既不允许别的事务获取这些记录的 S 锁(比如别的事务使用 select … lock in share mode 语句来读取这些记录),也不允许获取这些记录的 X 锁来读取这些记录(比如使用 select … for update 语句来读取这些记录或者直接修改这些记录)。如果别的事务想要获取这些记录的 S 锁或者 X 锁,那么它们会阻塞,直到当前事务提交之后将这些记录上的 X 锁释放掉。

MySQL 8.0 新特性
在 5.7 及之前的版本,select … for update 语句,如果不获取锁,则会一直等待,直到 innodb_lock_wait_timeout 超时。
在 8.0 版本中,select … for update;select … for share;添加 NOWAIT、SKIP LOCKED 语法,跳过锁等待,或者跳过锁定。

02、写操作

平常用到的写操作,无非就是 insert、delete 、update这三种 :

  • insert

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

  • delete

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

  • update

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

    • 情况1(X 锁):未修改该记录的键值,并且被更新的列占用的存储空间在修改前后未发生变化。则现在 B+Tree 中定位到这条记录的位置,然后再获取一下记录的 X 锁,最后在原记录的位置进行修改操作。可以把这个定位待修改记录在 B+Tree 中位置的过程看成是一个获取 X 锁的锁定读
    • 情况2(X 锁+隐式锁):未修改该记录的键值,并且至少有一个被更新的列占用的存储空间在修改前后发生变化。则先在 B+Tree 中定位到这条记录的位置,然后获取一下记录的 X 锁,将该记录彻底删除掉(就是把记录彻底移入垃圾链表),最后再插入一条新记录。这个定位待修改记录在 B+Tree 中位置的过程看成是一个获取 X 锁的锁定读,新插入的记录由 insert 操作提供的隐式锁进行保护。
    • 情况3(X 锁+update 具体操作):修改了该记录的键值,则相当于在原记录上做 delete 操作之后再来一次 insert 操作,加锁操作就需要按照 delete 和 insert 的规则进行了。

13.3.2 从数据操作的粒度划分-表、页、行

为了尽可能提高数据库的并发度,每次锁定的数据范围越小越好。理论上每次只锁定当前操作的数据的方案会得到最大的并发度,但是管理锁是很耗资源的事情(涉及获取、检查、释放锁等动作)。所以数据库系统需要在高并发响应和系统性能两方面进行平衡,这样就产生了 "锁粒度"的概念。

对一条记录加锁影响的也只是这条记录而已,这时的锁粒度是比较细的。其实一个事务也可以在表级别进行加锁,被称为表级锁或表锁,对一个表加锁影响整个表中的记录,就说这个锁的粒度比较粗。

锁的粒度主要分为表级锁、页级锁和行锁。

01、表锁

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

① 表级别的 S 锁、X 锁

在对某个表执行 select、insert、delete、update 语句时,InnoDB 存储引擎是不会为这个表添加表级别的 S 锁或 X 锁的。在对某个表执行一些 DDL(alter、drop) 语句时,其他事务对这个表并发执行 select、insert、delete、update 语句时会发生阻塞。同样,某个事务中对某个表执行 select、insert、delete、update 语句时,在其他会话中对这个表执行 DDL 语句也会发生阻塞。这个过程其实就是通过在 server 层使用 "元数据锁" 结构来实现的。

一般情况下,不会使用 InnoDB 存储引擎提供的表级别的 S 锁和 X 锁。 只会在一些特殊情况下,比如崩溃恢复过程中用到。

假如:在系统变量 autocommit = 0,innodb_table_locks = 1 时,手动获取 InnoDB 存储引擎提供的表 t 的 S 锁或 X 锁可以这么写:

  • LOCK TABLES t READ: InnoDB 存储引擎会对表t加表级别的 S锁
  • LOCK TABLES t WRITE: InnoDB 存储引警会对表t加表级别的 X 锁

但是,要尽量避免在使用 InnoDB 存储引擎的表上使用 lock tables 这样的手动锁表语句,它们并不会提供什么额外的保护,只是会降低并发性能而已。InnoDB 最主要的还是实现了更细粒度的行锁。

MyISAM 与 InnoDB 的对比:

  • MyISAM 在执行查询语句之前,会给涉及的所有表加读锁,在执行增删改操作前,会给涉及的表加写锁。

  • InnoDB 是不会为这个表添加表级别的读锁或写锁的。但是在进行 DDL 的时候 server 层会使用元数据锁。

MyISAM 存储引擎只支持表锁,这也是 MySQL 开始的几个版本中唯一支持的锁类型。随着应用、以及对事务完整性和并发性要求的不断提高,MySQL 才开始开发基于事务的存储引擎,后来慢慢出现了支持页锁的 BDB 存储引擎和支持行级锁的 InnoDB 存储引擎。但是,MyISAM 的表锁依然是使用广泛的锁类型。

想要查看表中是否有锁可以使用下面的语句:
show open tables where in_use > 0;
在这里插入图片描述

② 意向锁(intention lock)

InnoDB 支持多粒度锁,它允许行级锁与表级锁共存,而意向锁就是其中的一种表锁

  • 意向锁的存在是为了协调行锁和表锁的关系,支持多粒度(表锁与行锁)的锁并存。
  • 意向锁是一种不与行级锁冲突的表级锁。
  • 表明 “某个事务正在某些行持有锁或该事务准备去持有锁”。

意向锁要解决的问题:

假如现在有两个事务,分别是 T1 和 T2,其中 T2 试图在该表级别上应用共享锁或排他锁,如果没有意向锁存在,那么 T2 就要去检查各个页或行是否存在锁。极端的情况下,如果记录非常多,且只有一行记录加了行级别的 S/X 锁,T2 事务就要遍历所有的记录,看看是否加锁。这样做的性能是比较低的。

在数据表的场景中,如果我们给某一行数据加上了排他锁,数据库会自动给更大一级的空间(比如数据页或数据表)上加意向锁,告诉其他人这个数据页或数据表已经有人上过排他锁了,当其他人想要获取数据表排他锁的时候,只需要了解是否有人已经获取了这个数据表的意向排他锁即可 。

也就是说,如果引入了意向锁,当前事务就会受到由 T1 控制的表级别的意向锁的阻塞,T2 在锁定该表前无需检查各个页或行锁,而只需要检查表上的意向锁即可。

  • 如果事务想要获得数据表中某些记录的共享锁,就需要在数据表上添加意向共享锁

  • 如果事务想要获得数据表中某些记录的排他锁,就需要在数据表上添加意向排他锁

这时意向锁会告诉其他事务:已经有人锁定了表中的某些记录了。

但是需要注意的一点是:意向锁是由存储引擎自己维护的,用户无法手动操作意向锁。 在为数据行添加共享/排他锁之前,InnoDB 会先获取该数据行所在的表对应的意向锁。

意向锁分为两种:

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

    -- 事务要获取某些行的 S 锁,必须先获得表的 IS 锁。
    SELECT column FROM table ... LOCK IN SHARE MODE; 
    
  • 意向排他锁(intention exclusive lock, IX):事务有意向对表中的某些行加排他锁(X锁)

    -- 事务要获取某些行的 X 锁,必须先获得表的 IX 锁。
    SELECT column FROM table ... FOR UPDATE; 
    

意向锁之间的兼容互斥性:

意向共享锁(IS)意向排他锁(IX)
意向共享锁(IS)兼容兼容
意向排他锁(IX)兼容兼容

意向锁与普通的共享/互斥锁(表锁)的兼容互斥性:

意向共享锁(IS)意向排他锁(IX)
共享锁(S)兼容互斥
排他锁(X)互斥互斥

意向锁不会与行级别的共享/排他锁互斥,所以并不会影响到多个事务对不同数据行加排他锁的并发性。其在保证并发性的前提下,实现了行锁和表锁共存且满足事务隔离性的要求。

③ 自增锁(AUTO-INC 锁)

AUTO-INC 锁是表级锁,如果一张表中有自增的列(比如自增 ID),那么当向这张表插入数据时,InnoDB 会先获取这张表的 AUTO-INC 锁。

如果一个事务正在插入数据到有自增列的表时,其他事务会阻塞等待正在持有 AUTO-INC 锁的事务释放 AUTO-INC 锁。

★ 例如:有表 teacher

CREATE TABLE `teacher` (
    `id` int not null AUTO_INCREMENT,
    `name` varchar(32) NOT NULL,
    PRIMARY KEY (`id`)
)ENGINE = InnoDB;

其中,id 加了 AUTO_INCREMENT,意味着在插入语句时不需要为此列赋值,MySQL 会自动为其赋上递增的值:

insert into teacher (`name`)values('zhangsan'), ('lisi');

自增插入数据的方式总共分为三类:

  1. Simple inserts(简单插入):在插入前就能确定插入行数的语句。包括:没有嵌套子查询的单行和多行 insert … values() 和 replace 语句。
  2. Bulk inserts(批量插入):在插入前不能确定行数的语句。包括:insert … select,replace … select 和 load data 语句,但不包括纯 insert 语句。InnoDB 在处理每一行时,为 AUTO_INCREMENT 列分配一个新值。
  3. Mixed-mode inserts(混合模式插入):Simple inserts 语句但是指定一部分新行的自动递增值。另一种是使用 insert … on duplicate key update,使用锁来为自增列分配新的自增值,但是在更新中不会分配新的自增值。

AUTO-INC 锁是给含有 AUTO_INCREMENT 列的表中插入数据时需要获取的一种特殊的表级锁,在执行插入语句时就在表级别加一个 AUTO-INC 锁,然后为每条待插入记录的 AUTO_INCREMENT 列分配递增值,语句结束后再把 AUTO-INC 锁释放掉。

一个事务在持有 AUTO-INC 锁的过程中,其他事务的插入语句都要被阻塞,可以保证一个语句中分配的递增值是连续的,这样其实并发性并不高。当向一个有 AUTO_INCREMENT 的列插入数据时,每条语句都要对这个表锁进行竞争,这样的并发性是非常低的,所以 InnoDB 通过 innodb_autoinc_lock_mode的不同取值来提供不同的锁定机制,来提高 SQL 语句的可伸缩性和性能。

  1. innodb_autoinc_lock_mode = 0:传统锁定模式

    在此锁定模式下,所有类型的 insert 语句都会获得一个特殊的表级 AUTO-INC 锁,用于插入具有 AUTO_INCREMENT 列的表。即每当执行 insert 的时候,都会得到一个表级锁(AUTO-INC锁),使得语句中生成的 AUTO_INCREMENT 为顺序,且在 binlog 中重放的时候,可以保证 master 与 slave 中数据的 AUTO_INCREMENT 值是相同的。因为是表级锁,当在同一时间多个事务中执行 insert 的时候,对于 AUTO-INC 锁的争夺会限制并发能力。

  2. innodb_autoinc_lock_mode = 1:连续锁定模式(8.0之前默认)

    在这个模式下,“bulk inserts”(事先不知道插入多少行)仍然使用 AUTO-INC 表级锁,并保持到语句结束。这适用于所有 insert … select,replace … select 和 load data 语句。同一时刻只有一个语句可以持有 AUTO-INC 锁

    对于 “Simple inserts”(要插入的行数事先已知),则通过在 mutex(轻量锁) 的控制下获得所需数量的自动递增值来避免表级 AUTO-INC 锁,它只在分配过程的持续时间内保持,而不是直到语句完成。不使用表级 AUTO-INC 锁,除非 AUTO-INC 锁由另一个事务保持。如果另一个事务保持 AUTO-INC 锁,则 “Simple inserts” 等待 AUTO-INC 锁如同它是一个 “bulk inserts”。

  3. innodb_autoinc_lock_mode = 2:交错锁定模式(8.0默认)

    所有的 insert 语句都不会使用 AUTO-INC 锁,并可以同时执行多个语句,是最快和可扩展的模式,保证自增是单调递增且唯一的,但是无法保证连续(由于多个语句可以同时生成数字,即跨语句交叉编号)

④ 元数据锁(MDL 锁)

在 MySQL 5.5 版本中引入了 MDL(元数据锁),属于表锁。当对一个表做增删改查操作的时候,加 MDL 读锁;当要对表做结构变更操作的时候,加 MDL 写锁。

读锁之间不互斥,所以可以有多个线程同时对一张表增删改查。读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性,解决了 DML 和 DDL 操作之间的一致性问题,不需要显示使用,在访问表的时候 MySQL 会自动加上。

02、行锁

行锁,也称为记录锁,就是锁住某一行(某行记录 row)。需要注意的是,MySQL 服务器层并没有实现行锁机制,行级锁只在存储引擎层实现

  • 优点:锁的粒度小,发生锁冲突概率低,可以实现的并发度高

  • 缺点:对于锁的开销比较大,加锁会比较慢,容易出现死锁情况。

InnoDB 与 MyISAM 有两大不同点:第一是支持事务;第二是采用了行级锁。
InnoDB 的行级锁是通过给``索引上的索引项加锁```来实现的。

★ 例如,有如下表:

CREATE TABLE `student` (
    `id` int not null AUTO_INCREMENT,
    `name` varchar(32) NOT NULL,
    `class` varchar(32) NOT NULL,
    PRIMARY KEY (`id`)
)ENGINE=InnoDB;

insert into student (id, `name`, `class`) values
(1, '张三', '一班'),
(3, '李四', '二班'),
(8, '王五', '二班'),
(15, '赵六', '三班'),
(20, '田七', '三班');

把 B+Tree 的索引结构做一个简化,只把索引中的记录给拿出来如下图:
在这里插入图片描述

① 记录锁(Record locks)

记录锁就是给一条记录上锁,官方的类型名称为:LOCK_REC_NOT_GAP

假如给 id 为 8 的那条记录加一个记录锁,此时仅仅是锁住了 id 值为 8 的记录,对周围的数据没有影响:在这里插入图片描述
记录锁是有 S 锁和 X 锁之分的,称之为 S 型记录锁X 型记录锁

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

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

例如下面的语句:

SELECT * FROM `student` WHERE `id` = 1 FOR UPDATE;

它会在 id=1 的记录上加上记录锁,以阻止其他事务插入,更新,删除 id=1 这一行。

需要注意的是:

  • id 列必须为唯一索引列或主键列,否则上述语句加的锁就会变成临键锁(如果不懂什么是临键锁,请继续往下看)。
  • 同时查询语句必须为精准匹配(=),不能为 >、<、like等,否则也会退化成临键锁。

★ 例子

事务 T1 更新 id = 1 的记录,此时 id = 1 记录被加了 X 锁:

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> update student set name='张三1' where id = 1;
Query OK, 1 row affected (0.01 sec)

此时事务 T2 查询该记录:

mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from student where id = 1;
+----+--------+--------+
| id | name   | class  |
+----+--------+--------+
|  1 | 张三   | 一班   |
+----+--------+--------+
1 row in set (0.01 sec)

mysql> select * from student where id = 1 lock in share mode;

-- 一直等待直到T1提交

-- 因为T1加的是记录锁,其他记录不影响
mysql> select * from student where id = 3 lock in share mode;
+----+--------+--------+
| id | name   | class  |
+----+--------+--------+
|  3 | 李四   | 二班   |
+----+--------+--------+
1 row in set (0.00 sec)

-- 同样也可以为其他记录加锁
mysql> select * from student where id = 3 for update;
+----+--------+--------+
| id | name   | class  |
+----+--------+--------+
|  3 | 李四   | 二班   |
+----+--------+--------+
1 row in set (0.00 sec)

② 间隙锁(Gap locks)

MySQL 在 REPEATABLE READ 隔离级别(可重复读)下是可以解决幻读问题的,解决方案有两种,可以使用 MVCC 方案解决,也可以采用加锁方案解决。但是在使用加锁方案解决时有个大问题,就是事务在第一次执行读取操作时,那些幻影记录还不存在,无法给这些幻影记录加上记录锁

InnoDB 提出了一种称之为 Gap Locks 的锁,官方的类型名称为: LOCK_GAP,我们可以简称为 gap锁。

★ 比如,给 id 值为 8 的那条记录加一个 gap 锁:
在这里插入图片描述
给 id = 8 的记录加了 gap 锁,意味着不允许别的事务在 id = 8 的记录前面的间隙插入新纪录,也就是 (3,8)这个区间的新记录时不允许插入的。

比如,有另外一个事务再想插入一条 id 值为 4 的新记录,它定位到该条新记录的下一条记录的 id 值为 8,而这条记录上又有一个 gap 锁,所以就会阻塞插入操作,直到拥有这个 gap 锁的事务提交了之后,id 列的值在区间(3,8)中的新记录才可以被插入。

注意

  1. gap 间隙锁的提出仅仅是为了防止插入幻影记录而提出的。虽然有共享 gap 锁和独占 gap 锁这样的说法,但是它们起到的作用是相同的。而且如果对一条记录加了 gap 锁(不论是共享 gap 锁还是独占 gap 锁),并不会限制其他事务对这条记录加记录锁或者继续加 gap 锁

  2. InnoDB 的间隙锁只存在于 RR 隔离级别,所以希望禁用间隙锁,提升系统性能的时候,可以考虑将隔离级别降为 RC。

  3. 使用间隙锁锁住的是一个区间,而不仅仅是这个区间中的每一条数据。

例子1:两个事务都可以对同一间隙加锁

由于表里并没有 id = 5 这条记录,所以加的是间隙锁。

事务 T1 共享 gap 锁:

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from student where id = 5 lock in share mode;
Empty set (0.00 sec)

事务 T2 独占 gap 锁也不会阻塞

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from student where id = 5 for update;
Empty set (0.00 sec)

例子2:间隙阻塞另一个事务插入

事务 T1 给(3,8)区间加间隙锁:

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from student where id = 5 lock in share mode;
Empty set (0.00 sec)

事务 T2 在(3,8)之间插入数据:

mysql> insert into student(`id`, `name`, `class`)values(5, '测试', '七班');

-- 阻塞

例子3:间隙锁引起的死锁

事务 T1 给 id = 5 加间隙锁:

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from student where id = 5 lock in share mode;
Empty set (0.00 sec)

事务 T2 给 id = 6 加间隙锁:

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from student where id = 6 lock in share mode;
Empty set (0.00 sec)

事务 T1 插入 id = 5 的记录

mysql> insert into student(id,name,class)values(5, 'aaa','2班');
-- 会等待,T2也已经获取了间隙锁,下面第4步报错后才可以正常插入
Query OK, 1 row affected (12.08 sec)

事务 T2 插入 id = 6 的记录

mysql>  insert into student(id,name,class)values(6, 'bbb','2班');
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

例子4:间隙锁第一条之前和最后一条之后的间隙

事务 T1 给 id < 2 加间隙锁(假设当前 id = 1 的记录不存在):

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from student where id < 2 lock in share mode;
Empty set (0.00 sec)

事务 T2 给 id > 20 加间隙锁:

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from student where id > 20 lock in share mode;
Empty set (0.00 sec)

上述两个事务其实是给(0,2)和(20,+∞)加间隙锁,至于这个 +∞ 是多大呢?此时就可以联想到数据页中介绍的两条伪记录了:

  • Infimum:表示该页面中最小的记录
  • Supremum:表示该页面中最大的记录

在这里插入图片描述
这样就可以给 id = 20 的记录所在页的 Supremum 记录加上 gap 锁:
在这里插入图片描述

③ 临键锁(Next-key locks)

有时候既想锁住某条记录 ,又想 阻止其他事务在该记录前边的间隙插入新记录,所以 InnoDB 就提
出了一种称之为 Next-Key Locks 的锁,官方的类型名称为: LOCK_ORDINARY,也可以简称为
next-key锁 。

Next-Key Locks 是在存储引擎 InnoDB 、事务级别在可重复读的情况下使用的数据库锁,InnoDB 默认的锁就是临键锁( Next-Key locks)

★ 例子

给 id = 8 的记录加上临键锁:

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from student where id <= 8 and id > 3 for update;
Empty set (0.00 sec)

在这里插入图片描述

其中,(3,8)是间隙锁,id = 8 是记录锁,两个锁所组成的就叫临键锁。

④ 插入意向锁(Insert Intention locks)

一个事务在插入一条记录时需要判断一下插入位置是否被别的事务加了 gap 锁,如果有,插入操作需要等待,直到拥有 gap 锁的那个事务提交。但是 InnoDB 规定事务在等待的时候也需要在内存中生成一个锁结构,表明有事务想在某个间隙中插入新纪录,但是现在在等待。这种类型的锁就称为插入意向锁(Insert Intention locks),官方的类型名称为:LOCK_INSERT_INTENTION插入意向锁是在插入一条记录行前,由 insert 操作产生的一种间隙锁。

插入意向锁并不会阻止别的事务继续获取该记录上任何类型的锁。假设存在两条值分别为 4 和 7 的记录,两个不同的事务分别试图插入值为 5 和 6 的两条记录,每个事务在获取插入行上的排他锁前,都会获取 (4,7)之间的间隙锁,但是因为数据行之间并不冲突,所以两个事务之间并不会产生冲突(阻塞等待)。

插入意向锁的特性可以分成两部分:

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

注意:插入意向锁虽然包含意向锁三个字,但是它并不属于意向锁,而属于间隙锁,是一种行锁,而意向锁是一种表锁。

03、页锁

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

13.3.3 从对锁的态度划分-乐观、悲观

从对待锁的态度来看,可以将锁分成乐观锁和悲观锁,这两种锁是两种看待数据并发的思维方式。需要注意的是,乐观锁和悲观锁并不是锁,而是锁的设计思想。

在这里插入图片描述

01、悲观锁

顾名思义,就是对于数据的处理持悲观态度,总认为会发生并发冲突,获取和修改数据时,别人会修改数据。所以在整个数据处理过程中,需要将数据锁定。

悲观锁的实现,通常依靠数据库提供的锁机制实现,比如 MySQL 的排他锁:select … for update 来实现悲观锁。

在秒杀场景中,将商品库存数量 nums 字段类型设为 unsigned,保证在数据库层面不会发生负数的情况。悲观锁在并发控制上采取的是先上锁然后再处理数据的保守策略(这样做的前提是需要将要执行的 SQL 语句放在同一个事务中,否则达不到锁定数据行的目的),虽然保证了数据处理的安全性,但也降低了效率。

注意 :select … for update 语句执行过程中所有扫描的行都会被锁上,所以在 MySQL 中用悲观锁必须确定使用了索引,而不是全表扫描,否则会把整个表锁住。

02、乐观锁

顾名思义,就是对数据的处理持乐观态度,乐观的认为数据一般情况下不会发生冲突,只有提交数据更新时,才会对数据是否冲突进行检测。如果发现冲突了,则返回错误信息给用户,让用户自已决定如何操作。乐观锁的实现不依靠数据库提供的锁机制,需要我们自已实现,实现方式一般是记录数据版本,一种是通过版本号,一种是通过时间戳

给表加一个版本号或时间戳的字段,读取数据时,将版本号一同读出,数据更新时,将版本号加 1。当提交数据更新时,判断当前的版本号与第一次读取出来的版本号是否相等,如果相等,则予以更新;否则认为数据过期,拒绝更新,让用户重新操作。

乐观锁是基于程序实现的,所以不存在死锁的情况,适用于读多写少的应用场景。如果经常发生冲突,上层应用不断的让用户进行重新操作,这反而降低了性能,这种情况下悲观锁就比较适用。

03、乐观锁、悲观锁的适用场景

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

13.3.4 按照加锁的方式划分-显式、隐式

01、隐式锁

一个事务在执行 insert 操作时,如果即将插入的间隙已经被其他事务加了 gap 锁,那么本次 insert 操作会阻塞,并且当前事务会在该间隙上加一个插入意向锁,否则一般情况下 insert 操作是不加锁的。

隐式锁是 InnoDB 实现的一种延迟加锁机制,其特点是只有在可能发生冲突时才加锁,从而减少了锁的数量,提高了系统整体性能。另外,隐式锁是针对被修改的 B+Tree 记录,因此都是记录类型的锁,不可能是间隙锁或 Next-Key 类型。

InnoDB 在插入记录时,是不加锁的。如果事务 A 插入记录且未提交,这时事务 B 尝试对这条记录加锁,事务 B 会先去判断记录上保存的事务 id(trx_id) 是否活跃,如果活跃的话,那么就帮助事务 A 去建立一个锁对象,然后自身进入等待事务 A 状态,这就是所谓的隐式锁转换为显式锁

-- 事务A 插入一条不存在的记录
insert into student(id,name,class)values(4, 'aaa','2班');

-- 事务B 加锁尝试对此纪录加S锁
select * from student where id = 4 lock in share mode;

insert 执行过程:

  1. 执行 insert 语句,判断是否有和插入意向锁冲突的锁(gap 间隙锁),如果有,加插入意向锁,进入锁等待;如果没有,直接写数据,不加任何锁;
  2. 执行 select … lock in share mode 语句,判断记录上是否存在活跃的事务,如果存在,则为 insert 事务创建一个排他记录锁(X,REC_NOT_GAP),自己创建S锁(S,REC_NOT_GAP)加入到锁等待队列。

★ 例子

  1. 事务 T1 插入一条不存在的数据 10

    insert into student(id,name,class)values(10, 'aaa','2班');
    -- 查看此时锁信息 只有表级别的意向锁
    select * from performance_schema.data_locks where OBJECT_SCHEMA = 'db1' AND OBJECT_NAME = 'student';
    -- select * from performance_schema.data_lock_waits
    

    如下图:
    线程 ID = 85,事件 ID = 79,表级别的意向锁 X 锁在这里插入图片描述

  2. 事务 T2 插入一条不存在的数据 10

    insert into student(id,name,class)values(10, 'aaa','2班');
    -- 等待中...
    -- 查看此时锁信息 因为主键冲突,将事务1的隐式锁转换为显示锁,事务2则创建S锁并等待
    select * from performance_schema.data_locks where OBJECT_SCHEMA = 'db1' AND OBJECT_NAME = 'student';
    

    如下图:

    线程 ID = 94,事件 ID = 16
    在这里插入图片描述

    第一条记录:T2 新加的表级别的意向 X 锁(忽略) 。
    最后一条记录:T2 为记录 10 创建 X 记录锁(排他行锁),将 T1 的隐式锁转换为显示锁。
    第二条记录:T2 转换完 T1 显示锁后,自身创建 S 锁并等待

02、显式锁

通过特定的语句进行加锁 ,一般称之为显式加锁。

显式加共享锁:

select ... lock in share mode;

显式加排他锁:

select ... for update;

13.3.5 全局锁和死锁

01、全局锁

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

全局锁的典型使用场景是:做全库逻辑备份

全局锁的命令:

flush tables with read lock;

02、死锁

① 死锁产生的必要条件

  • 两个或两个以上事务
  • 每个事务都已经持有锁并且申请新的锁
  • 锁资源同时只能被同一个事务持有或者不兼容
  • 事务之间因为持有锁和申请锁导致彼此循环等待

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

② 如何处理死锁?

方式一:等待,直到超时(innodb_lock_wait_timeout = 50s) 4567890`456789

即当两个事务互相等待时,当一个事务等待时间超过设置的阈值时,就将其回滚,另外事务继续进行。这种方法简单有效,在 InnoDB 中,参数 innodb_lock_wait_timeout 用来设置超时时间。

show variables like 'innodb_lock_wait_timeout';

缺点:对于在线服务来说,这个等待时间往往是无法接受的。那将此值修改短一些,比如 1s,0.1s 是否合适? 不合适,容易误伤到普通的锁等待。

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

方式1检测死锁太过被动,InnoDB 还提供了 wait-for graph 算法来主动进行死锁检测,每当加锁请求无法立即满足需要并进入等待时,wait-for graph 算法都会被触发。

这是一种较为主动的死锁检测机制,要求数据库保存锁的信息链表事务等待链表两部分信息。

在这里插入图片描述
死锁检测的原理是构建一个以事务为顶点,锁为边的有向图,判断有向图是否存在环,存在即有死锁。

一旦检测到回路、有死锁,这时候 InnoDB 存储引擎会选择回滚 undo 量最小的事务,让其他事务继续执行(innodb_deadlock_detect = on 表示开启这个逻辑)。

缺点:每个新的被阻塞的线程,都要判断是不是由于自己的加入导致了死锁,这个操作时间复杂度是 O(n)。如果 100 个并发线程同时更新同一行,意味着要检测 100 * 100 = 1万次,1 万个线程就会有 1 千万次检测。

控制并发访问的数量,比如在中间件中实现对于相同行的更新,在进入引擎之前排队,这样在 InnoDB 内部就不会有大量的死锁检测工作。

③ 如何避免死锁?

  1. 合理设计索引,使业务 SQL 尽可能通过索引定位更少的行,减少锁竞争。

  2. 调整业务逻辑 SOL 执行顺序,避免 update/delete 长时间持有锁的 SOL 在事务前面避免大事务,尽量将大事务拆成多个小事务来处理,小事务缩短锁定资源的时间,发生锁冲突的几率也更小。

  3. 在并发比较高的系统中,不要显式加锁,特别是是在事务里显式加锁。如 select …for update 语句,如果是在事务里运行了 start transaction 或设置了 autocommit 等于0,那么就会锁定所查找到的记录。

  4. 降低隔离级别。如果业务允许,将隔离级别调低也是较好的选择,比如将隔离级别从 RR 调整为 RC,可以避免掉很多因为 gap 锁造成的死锁。

13.4 锁的内存结构

存储引擎的锁结构为:
在这里插入图片描述

  • 锁所在的事务信息

    不论是表锁还是行锁,都是在事务执行过程中生成的,哪个事务生成了这个锁结构,这里就记录这个事务的信息。

    锁所在的事务信息在内存结构中只是一个指针,通过指针可以找到内存中关于该事务的更多信息,比如事务 id 等。

  • 索引信息

    对于行锁来说,需要记录一下加锁的记录是属于哪个索引的,也是一个指针。

  • 表锁/行锁信息

    表锁结构和行锁结构在这个位置的内容是不同的:

    • 表锁

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

    • 行锁

      记载了三个重要的信息:Space ID(记录所在表空间),Page Number(记录所在页号),n_bits(对于行锁来说,一条记录就对应着一个比特位,一个页面中包含很多记录,用不同的比特位来区分到底是哪一条记录加了锁。所以在行结构的末尾放置了一堆比特位,这个 n_bits 属性代表使用了多少比特位。此值一般都比页面中记录条数多一些,主要是为了之后再页面中插入了新纪录后也不至于重新分配锁结构)

  • type _mode 信息

    这是一个 32 位的数,被分成了 lock_mode、lock_type 和 rec_lock_type 三部分:
    在这里插入图片描述

  • 其他信息

    为了更好的管理系统运行过程中生成的各种锁结构而设计了各种哈希表和链表。

  • 一堆比特位

    如果是行锁结构的话,在该结构末尾还放置了一堆比特位,比特位的数量是由上边提到的 n_bits 属性表示的。InnoDB 数据页中的每条记录在记录头信息中都包含一个 heap_no 属性,伪记录 Infimum 的 heap_no 值为 0 , Supremum 的 heap_no 值为 1 ,之后每插入一条记录, heap_no 值就增1。 锁结构最后的一堆比特位就对应着一个页面中的记录,一个比特位映射一个 heap_no,即一个比特位映射到页内的一条记录。

13.5 锁监控

关于 MySQL 锁的监控,我们一般可以通过检查 innodb_row_lock% 等状态变量来分析系统上的行锁的争夺情况:

mysql> show status like 'innodb_row_lock%';
+-------------------------------+--------+
| Variable_name                 | Value  |
+-------------------------------+--------+
| Innodb_row_lock_current_waits | 0      |
| Innodb_row_lock_time          | 762785 |
| Innodb_row_lock_time_avg      | 28251  |
| Innodb_row_lock_time_max      | 50984  |
| Innodb_row_lock_waits         | 27     |
+-------------------------------+--------+
5 rows in set (0.00 sec)

对各个状态量的说明如下:

  • 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 库中,涉及到三张表分别是 INNOEB_TX、INNODB_LOCKS、INNODB_LOCK_WAITS。

MySQL5.7 及之前,可以通过 information_schema.INNODB_LOCKS 查看事务的锁情况,但只能看到阻塞事务的锁;如果事务并未被阻塞,则在该表中看不到该事务的锁情况。

MysOL8.0 删除了 information_schema.INNODB_LOCKS,添加了 performance_schema.data_locks,可以通过 performance schema.data_locks 查看事务的锁情况,和 MySOL5.7 及之前不同,performance_schema.data_locks 不但可以看到阻塞该事务的锁,还可以看到该事务所持有的锁。

同时,information_schema.INNODB_LOCK_WAITS 也被 performance_schema.data_lock_waits 所代替。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值