对数据的操作划分
S(共享)锁和 X(排他)锁
对于数据库中并发事务的读-读情况并不会引起什么问题。对于写-写、读-写或写-读这些情况可能会引起一些问题,需要使用MVCC或者加锁的方式来解决它们。
在讨论使用加锁的方式解决并发问题时,由于既要允许读-读情况不受影响,又要使写-写、读-写或写-读情况中的操作相互阻塞,所以 MySQL 实现一个由两种类型的锁组成的锁系统来解决。这两种类型的锁通常被称为共享锁和排他锁。
-
共享锁:用英文用S表示。针对同一份数据,多个事务的读操作可以同时进行而不会互相影响,相互不阻塞的。 -
排他锁:用英文用X表示。当前写操作没有完成前,它会阻断其他写锁和读锁。这样就能确保在给定的时间里,只有一个事务能执行写入,并防止其他用户读取正在写入的同一资源。
需要注意的是对于 InnoDB 引擎来说,读锁和写锁可以加在表上,也可以加在行上。
**举例(行级读写锁)∶**如果一个事务 T1 已经获得了某行的读锁,那么此时另外的一个事务 T2 是可以去获得这个行的读锁的,因为读取操作并没有改变这一行的数据;但是,如果某个事务 T3 想获得这一行的写锁,则它必须等待事务 T1、T2 释放掉这一行上的读锁才行。
总结: 这里的兼容是指对同一张表或同一行的锁的兼容性情况。
| X 锁 | S 锁 | |
|---|---|---|
| X 锁 | 不兼容 | 不兼容 |
| S 锁 | 不兼容 | 兼容 |
读操作
注意:对于读操作对应的数据(SELECT 语句查询到的数据),可以对该数据加 S 锁,也可以加 X 锁。
-
加 S 锁的方法:
mysqlSELECT ... FOR SHARE;在普通的 SELECT 语句后边加
FOR SHARE,如果当前事务执行了该语句,那么它会为读取到的记录加S锁,这样就允许别的事务继续获取该记录的 S 锁(多个 S 锁之间兼容),但是不能获取该记录的 X 锁。如果别的事务想要获取该记录的 X 锁,那么它们会阻塞,直到当前事务提交之后将该记录上的 S 锁释放掉。
-
加 X 锁的方法:
mysqlSELECT ... FOR UPDATE;在普通的 SELECT 语句后边加
FOR UPDATE,如果当前事务执行了该语句,那么它会为读取到的记录加X锁,这样既不允许别的事务获取该记录的 S 锁,也不允许获取该记录的 X 锁,更不允许直接修改该记录。如果别的事务想要获取该记录的 S 锁或 X 锁,那么它们就会阻塞,直到当前事务提交之后将该记录上的 X 锁释放掉。
MySQL8 新特性:
在 5.7 及之前的版本,如果获取不到锁,会一直等待,直到innodb_lock_wait_timeout超时。
在 MySQL8 版本中,在语句SELECT ... FOR SHARE;和SELECT ... FOR UPDATE;的后面可以添加NOWAIT或SKIP LOCKED语法,其作用是避免等待阻塞。
通过添加 NOWAIT、SKIP LOCKED 语法,能够立即返回。如果某行的数据已经加锁:
- 那么加上 NOWAIT 会立即返回并报错报;
- 而 SKIP LOCKED 也会立即返回,只是返回的结果中不包含被锁定的行。
写操作
平常所用到的写操作无非是DELETE、UPDATE、INSERT这三种。
-
DELETE: 对一条记录做 DELETE 操作的过程其实是先在
B+树中定位到这条记录的位置,然后获取这条记录的X锁,再执行 delete mark 操作。 -
UPDATE:
在对一条记录做 UPDATE 操作时分为三种情况。
下面提到的
键值的意思是:一个表的主键或者是作为查询索引的键。-
情况 1: 未修改该记录的
键值,并且被更新的列占用的存储空间在修改前后未发生变化。则先在
B+树中定位到这条记录的位置,然后再获取一下记录的X锁,之后在原记录的位置进行修改操作,最后释放 X 锁。 -
情况 2∶ 未修改该记录的
键值,并且至少有一个被更新的列占用的存储空间在修改前后发生变化。则先在 B+树中定位到这条记录的位置,然后获取一下该记录的
X锁,接着将该记录彻底删除掉,最后再插入一条新记录,新插入的记录需要由INSERT操作提供的隐式锁进行保护。 -
情况 3∶ 修改了该记录的键值。
则相当于在原记录上做 DELETE 操作之后再来一次 INSERT 操作,加锁操作就是先加上 X 锁(DELETE 操作),在加上隐式锁(INSERT 操作)。
-
-
INSERT :
一般情况下,新插入一条记录的操作并不加锁,通过一种称之为
隐式锁的结构来保护这条新插入的记录在本事务提交前不被别的事务访问。
对锁的粒度划分
为了尽可能提高数据库的并发度,每次锁定的数据范围越小越好,理论上每次只锁定当前操作的数据的方案会得到最大的并发度,但是管理锁是很耗资源的事情(涉及获取、检查、释放锁等动作)。因此数据库系统需要在高并响应和系统性能两方面进行平衡,因此就产生了锁粒度的概念。
按照锁对数据的操作粒度进行从大到小的顺序是:表级锁 > 页级锁 > 行级锁
表级锁
注意:由于
myISAM存储引擎不支持行级锁,只支持表级锁,所以表级锁一般在 myISAM 存储引擎中使用的比较多。对于 InnoDB 存储引擎,由于其支持更小粒度的锁,所以一般情况下,InnoDB 存储引擎是不会使用表级锁的。
表级锁概念:能锁定整张表,它是 MySQL 中最基本的锁策略,并不依赖于存储引擎(不管你是 MySQL 的什么存储引擎,对于表锁的策略都是一样的),并且表锁是开销最小的策略(因为粒度比较大)。
由于表级锁一次会将整个表锁定,所以可以很好的避免死锁问题。当然,锁的粒度大所带来最大的负面影响就是出现锁资源争用的概率也会最高,导致并发率大打折扣。
1. S 锁和 X 锁
在 InnoDB 存储引擎当中:
-
对某个表执行 SELECT、INSERT、DELETE、UPDATE 语句时,InnoDB 存储引擎是不会为这个表添加表级别的
S锁或者X锁的(因为 InnoDB 有更小粒度的锁可以使用)。 -
对某个表执行一些诸如
ALTER TABLE、DROP TABLE这类的DDL语句时,其他事务对这个表并发执行诸如 SELECT、INSERT、DELETE、UPDATE 的语句会发生阻塞。同理,某个事务中对某个表执行 SELECT、INSERT、DELETE、UPDATE 语句时,在其他会话中对这个表执行DDL语句也会发生阻塞。这个过程其实是通过使用一种称之为元数据锁(简称:MDL)的结构来实现的。
前面的两种情况都说明: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 的厉害之处还是实现了更细粒度的行锁。所以,关于 InnoDB 表级别的 S 锁和 X 锁大致了解一下就可以了。
在 MyISAM 存储引擎当中:
- 在执行查询语句前,会给涉及的所有表加读锁;在执行增删改操作前,会给涉及的表加写锁。
所以表级别的 S 锁和 X 锁在 MyISAM 存储引擎当中使用的比较多。
MySQL 的表级锁有两种模式(不管是 InnoDB 还是 MyISAM):
- 表共享读锁(Table Read Lock)
- 表独占写锁(Table Write Lock)
| 锁类型 | 自己可读 | 自己可写 | 自己可操作其他表 | 他人可读 | 他人可写 |
|---|---|---|---|---|---|
| 读锁 | 是 | 否 | 否 | 是 | 否,需等待 |
| 写锁 | 是 | 是 | 否 | 否,需等待 | 否,需等待 |
补充:解除表级别的 S 锁或 X 锁的 SQL 语句:unlock tables;
2. 意向锁
个人感觉直接理解意向锁的概念会比较抽象,所以先看下面一个问题。
现在有两个事务,分别是 T1 和 T2,其中 T2 试图在该表级别上去加 S 或 X 锁,如果没有意向锁存在,那么 T2 就需要去检查各个页或行是否存在锁(想象一下,如果该表有 1000 万行的数据,T2 事务一行行去检查,最后发现 T1 事务在某一行加了行锁,那么效率就特别低下)。如果存在意向锁,那么此时就会受到由 T1 控制的表级别意向锁的阻塞。T2 在锁定该表前不必检查各个页或行锁,而只需检查表上的意向锁。简单来说就是给更大一级别的空间示意里面是否已经上过锁。
在画个示意图方便以后理解:
意向锁:
InnoDB 支持多粒度锁,它允许行级锁与表级锁共存,而 意向锁 就是其中的一种表锁。
- 意向锁的存在是为了
协调行锁和表锁的关系,支持多粒度(表锁与行锁)的锁共存。 - 意向锁是一种
不与行级锁冲突表级锁,这一点非常重要。 - 意向锁表达的意向:某个事务对该表的某些行持有了行锁或该事务准备去持有行锁。
意向锁分为两种:
-
意向共享锁(IS):事务有意向对表中的某些行加 共享锁 (S 锁)
mysql-- 事务要获取某些行的 S 锁,必须先获得表的 IS 锁。SELECT column FROM table ... LOCK IN SHARE MODE; -
意向排他锁(IX):事务有意向对表中的某些行加 排他锁 (X 锁)
mysql-- 事务要获取某些行的 X 锁,必须先获得表的 IX 锁。SELECT column FROM table ... FOR UPDATE;
即:意向锁是由存储引擎自己维护的,用户无法手动操作意向锁,在为数据行加共享 / 排他锁之前,InooDB 会先获取该数据行所在数据表的对应意向锁。
意向锁之间的兼容情况:
| 意向共享锁(lS) | 意向排他锁(IX) | |
|---|---|---|
| 意向共享锁(IS) | 兼容 | 兼容 |
| 意向排他锁(IX) | 兼容 | 兼容 |
即意向锁之间是互相兼容的。
-
事务 A 获取了某一行的排他锁,并未提交:
sql# 事务ABEGIN;SELECT *FROM teacher WHERE id = 6 FOR UPDATE;此时 teacher 表存在两把锁:teacher 表上的意向排他锁与 id 为 6 的数据行上的排他锁。
-
事务 B 想要获取 teacher 表另一行的排他锁。
sql# 事务BBEGIN;SELECT *FROM teacher WHERE id = 5 FOR UPDATE;此时,事务 B 并不会阻塞。因为同一张表的 IX 锁和 IX 锁之间是兼容的,其次 id=5 和 id=6 这两个排他的行锁也是兼容的。所以事务 B 并不会阻塞。
意向锁和表级别的 S 锁以及 X 锁的兼容情况:
| 意向共享锁(lS) | 意向排他锁(IX) | |
|---|---|---|
| 表级别共享锁(S) | 兼容 | 互斥 |
| 表级别排他锁(X) | 互斥 | 互斥 |
-
事务 A 获取了某一行的排他锁,并未提交:
mysql# 事务ABEGIN;SELECT *FROM teacher WHERE id = 6 FOR UPDATE;此时 teacher 表存在两把锁:teacher 表上的意向排他锁与 id 为 6 的数据行上的排他锁。
-
事务 B 想要获取 teacher 表的共享锁。
mysql# 事务BBEGIN;LOCK TABLES teacher READ;此时事务 B 检测到事务 A 持有 teacher 表的意向排他锁,就可以得知事务 A 必然持有该表中某些数据行的排他锁。那么事务 B 对 teacher 表的加锁请求就会被排斥(阻塞),而无需去检测表中的每一行数据是否存在排他锁。
从上面的案例可以得到如下结论:
- InnoDB 支持
多粒度锁。特定场景下,行级锁可以与表级锁共存。- 意向锁之间互不排斥。但除了 IS 与 S 兼容外,
意向锁会与共享锁/排他锁互斥。- IX,IS 是
表级锁,不会和行级的X锁/S锁发生冲突,只会和表级的 X 锁/S 锁发生冲突。- 意向锁在保证并发性的前提下,实现了
行锁和表锁共存且满足事务隔离性的要求。
3. 自增锁
在使用 MySQL 过程中,我们可以为表的某个列添加AUTO_INCREMENT属性。举例:
CREATE TABLE `teacher` ( `id` int NOT NULL AUTO_INCREMENT, `name` varchar( 255 ) NOT NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;由于这个表的 id 字段声明了 AUTO_INCREMENT,意味着在书写插入语句时不需要为其赋值,SQL 语句修改如下所示。
INSERT INTO `teacher` (name) VALUES ('zhangsan'), ('lisi');上边的插入语句并没有为 id 列显式赋值,所以系统会自动为它赋上递增的值,结果如下所示。
mysql> select * from teacher;+----+----------+| id | name |+----+----------+| 1 | zhangsan || 2 | lisi |+----+----------+2 rows in set (0.00 sec)上面插入数据只是一种简单的插入模式,所有插入数据的方式总共分为三类,分别是:Simple inserts(简单插入)、Bulk inserts(批量插入)和Mixed-mode inserts(混合模式插入)。
-
Simple Inserts(简单插入)这类操作的特点是插入操作前,能够明确知道要插入的行数。它不涉及复杂的查询来确定插入的数据,因此,在执行插入之前,就已经确定了将要插入多少行数据。例如:
mysqlINSERT INTO teacher (name, age) VALUES ('Alice', 30), ('Bob', 35);这里一次插入了两行数据,插入行数在执行前是已知的。
对于自增列来说,这意味着 MySQL 可以在执行前就计算出需要分配的 AUTO_INCREMENT 值的范围,从而减少对自增锁的需求时间,提高插入效率。
-
Bulk Inserts(批量插入)批量插入指的是插入的行数在执行前无法确定,通常是因为插入操作依赖于另一个查询的结果。例如:
mysqlINSERT INTO teacher (name, age) SELECT student_name, student_age FROM students WHERE graduated = 1;在这个例子中,插入到
teacher表的行数取决于查询students表的结果,这个结果在执行插入操作前是未知的。对于自增列,MySQL 需要为每插入的行动态地分配 AUTO_INCREMENT 值,这可能导致自增锁在整个插入过程中被持有,以确保自增值的连续和唯一,这在大批量插入时可能会成为性能瓶颈。
-
Mixed-mode Inserts(混合模式插入)混合模式插入可以看作是简单插入的一种特殊情况,它涉及到部分指定自增列的值。例如:
mysqlINSERT INTO teacher (id, name) VALUES (1, 'Alice'), (NULL, 'Bob'), (5, 'Charlie'), (NULL, 'David');在这个命令中,
id是自增列,但部分行显式指定了id的值。对于未指定id值的行(使用NULL表示希望 MySQL 自动分配id),MySQL 需要为这些行分配 AUTO_INCREMENT 值。这种插入方式复杂度介于简单插入和批量插入之间。MySQL 需要处理显式指定的自增值,并为未指定的行动态分配 AUTO_INCREMENT 值,同时确保自增值的唯一性和连续性。此外,
INSERT ··· ON DUPLICATE KEY UPDATE(该语句的作用我简单概括为:不存在就插入;存在就修改)也被归类为混合模式插入,因为这类语句页可能涉及到对自增列的显式赋值。
对于上面数据插入的案例,MySQL 中采用了自增锁的方式来实现。然后自增锁的实现又有三种模式(通过给innodb_autoinc_lock_mode设置不同的值),直接说这三种模式的官方概念可能有点抽象,所以下面是一段类比生活中的例子。
想象你在一家快餐店点餐,店里有一个规则:每个订单都会被赋予一个递增的号码,确保每位顾客的订单号是唯一且连续的。这个过程就像 MySQL 处理自增列时获取的自增锁。
当你下单时,相当于 MySQL 执行了一条插入语句。店员(MySQL)会给你的订单(记录)一个唯一的号码(自增 ID),然后处理下一个顾客。在给你的订单号码的这段时间里,其他顾客(事务)必须等待,直到你的订单处理完毕并拿到号码。这保证了每个订单的号码都是按顺序来的,没有跳号或重复。
但问题来了,如果每次只处理一个订单,那么在繁忙时段,排队的顾客就会等得非常不耐烦。为了提高效率,快餐店(MySQL)可能会采用不同的策略来分配订单号,以加快处理速度和提高顾客满意度。比如,可以一次性为一批订单分配号码,或者在确保订单号唯一的前提下,允许某些特定情况下的跳号。
这些不同的策略,就像 MySQL 中
innodb_autoinc_lock_mode的不同设置。根据设置的不同,MySQL 可以调整自增锁的行为,以在保证数据一致性的同时,提升处理速度和并发性能。简而言之,就是在确保订单号(自增 ID)既唯一又连续的基础上,尽可能高效地处理更多的订单(插入操作)。
三种模式:
-
innodb_autoinc_lock_mode = 0(传统锁定模式)在传统锁定模式下,任何执行插入操作到包含自增列的表都会获得一个表级的自增锁,直到当前语句执行完毕才释放。这确保了同一时间只有一个事务可以分配自增值,从而保证了自增值的顺序连续性。
假设有两个事务同时向同一个包含 AUTO_INCREMENT 列的表 students 插入数据。
mysql# 事务A执行INSERT INTO students (name) VALUES ('Alice');mysql# 事务B执行INSERT INTO students (name) VALUES ('Bob');在当前模式下,事务 B 必须等待事务 A 完成插入并释放自增锁后,才能开始执行,确保 Alice 和 Bob 的 id 分配是连续的,没有其他事务可以介入这两个插入操作之间。
-
innodb_autoinc_lock_mode = 1(连续锁定模式)在 MySQL 8.0 之前,连续锁定模式是默认的。连续锁定模式优化了简单插入的性能,允许通过 mutex 而非表级锁快速分配自增值,但保留了批量插入时使用表级锁的行为。这减少了表级锁的争用,提高了并发性能,同时保证了自增值的连续性。
- 简单插入:若
INSERT INTO students (name) VALUES ('Charlie');在没有其他事务持有自增锁时执行,它会通过轻量级的锁定机制(mutex)快速分配自增值,提高效率。如果另一个事务持有自增锁,则当前事务的简单插入会等待自增锁。 - 批量插入:
INSERT INTO students (name) SELECT teacher_name FROM teachers;会获取表级自增锁,直到整个语句执行完毕,确保从 teachers 表中选择的每个 teacher_name 作为 students 表新记录的自增值是连续的。
- 简单插入:若
-
innodb_autoinc_lock_mode = 2(交错锁定模式)从 MySQL 8.0 开始,交错锁模式是默认设置。交错锁定模式进一步提高了并发性能,允许多个插入操作同时进行而不互相阻塞,但牺牲了自增值的绝对连续性,允许在批量插入中出现间隙。
- 事务 A 和事务 B 同时执行:两个事务都执行
INSERT INTO students (name) VALUES ('Daisy');,它们可以同时进行,因为不再使用表级锁。这意味着,尽管自增值仍然是唯一且递增的,但事务 A 和事务 B 插入的 id 之间可能存在其他事务插入的 id,或者在大批量插入时,由于并行执行,自增值可能在逻辑上不连续(即存在间隙)。
在
innodb_autoinc_lock_mode = 2(交错锁定模式)下,使用 MySQL 的主从复制确实有可能导致主库(master)和从库(slave)上相同表的自增 ID 不一致的情况出现。这种不一致主要是由两个因素引起的:- 并发插入导致的 ID 分配差异:在交错锁定模式下,多个事务可以并发执行插入操作到包含自增列的表中,而不会相互阻塞。这意味着,如果在主库上同时有多个事务并发插入,它们分配的自增 ID 虽然是唯一且递增的,但在逻辑上可能不连续(即,存在间隙),且每个事务分配的 ID 顺序与其他事务可能相交错。
- 基于语句的复制(SBR)的复制机制:在使用基于语句的复制模式时,主库执行的每条插入 SQL 语句都会在从库上重新执行。如果这些语句包含并发插入操作,由于从库一般是单线程顺序执行这些 SQL 语句,导致重新执行时不能完全模拟主库上的并发插入场景。因此,即便是相同的插入语句,由于执行环境(并发性和执行顺序)的差异,可能导致在从库上分配的自增 ID 与主库上不完全相同。
- 事务 A 和事务 B 同时执行:两个事务都执行
通过上述描述,可以看到innodb_autoinc_lock_mode的不同设置如何影响 InnoDB 处理含有AUTO_INCREMENT列的插入操作的性能和自增值的分配方式。模式 0 最保守,确保绝对顺序连续性;模式 1 是一种折中,提高了简单插入的性能而保持批量插入的连续性;模式 2 则优先考虑性能和并发,但在某些情况下可能导致自增值不连续。
4. 元数据锁
MySQL5.5 引入了 meta data lock,简称MDL锁,属于表锁范畴。MDL 的作用是,保证读写的正确性。比如:如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个 表结构做变更 ,增加了一列,那么查询线程拿到的结果跟表结构对不上,肯定是不行的。因此,当对一个表做增删改查操作的时候,加MDL读锁;当要对表做结构变更操作的时候,加MDL写锁。
行级锁
1. 记录锁
行锁也称为记录锁,顾名思义,就是锁住某一行。需要的注意的是,MySQL 服务器层并没有实现行锁机制,行级锁只在存储引擎层实现。
-
优点: 锁定力度小,
发生锁冲突概率低,可以实现的并发度高。 -
缺点: 对于
锁的开销比大,加锁会比较慢,容易出现死锁情况。
记录锁也就是仅仅把一条记录锁上,官方的类型名称为:LOCK_REC_NOT_GAP。比如我们把 id 值为 8 的那条记录加一个记录锁的示意图如图所示。仅仅是锁住了 id 值为 8 的记录,对周围的数据没有影响。
InnoDB 与 MylSAM 的最大不同有两点:一是支持事务;二是采用了行级锁。
记录锁是有 S 锁和 X 锁之分的,称之为S型记录锁和X型记录锁。
- 当一个事务获取了一条记录的 S 型记录锁后,其他事务也可以继续获取该记录的 S 型记录锁,但不可以继续获取 X 型记录锁;
- 当一个事务获取了一条记录的 X 型记录锁后,其他事务既不可以继续获取该记录的 S 型记录锁,也不可以继续获取 X 型记录锁。
| X 锁 | S 锁 | |
|---|---|---|
| X 锁 | 不兼容 | 不兼容 |
| S 锁 | 不兼容 | 兼容 |
2. 间隙锁
MySQL 在REPEATABLE READ隔离级别下是可以解决幻读问题的,解决方案有两种:一种是使用MVCC方案解决,另一种采用加锁方案解决。但是在使用加锁方案解决时有个大问题,就是事务在第一次执行读取操作时,那些幻影记录尚不存在,我们无法给这些幻影记录加上记录锁。
InnoDB 提出了一种称之为间隙锁,官方的类型名称为:LOCK_GAP。所以间隙锁的提出仅仅是为了防止插入幻影记录而提出的。
图中 id 值为 5 的记录加了间隙锁,意味着不允许别的事务在 id 值为 3 到 8 之间的记录间隙插入新记录,其实就是 id 列的值( 3 , 8 )这个区间的新记录是不允许立即插入的。
如果有另外一个事务再想插入一条 id 值为 4 的新记录,它定位到该条新记录的下一条记录的 id 值为 8 ,而这条记录上又有一个间隙锁,所以就会阻塞插入操作,直到拥有这个间隙锁的事务提交了之后,id 列的值在区间( 3 , 8 )中的新记录才可以被插入。
间隙锁的使用:
- 虽然有
共享间隙锁和独占间隙锁这样的说法,但是它们起到的作用是相同的。- 如果对一条记录加了间隙锁(不论是共享间隙锁还是独占间隙锁),并不会限制其他事务对这条记录加记录锁或者继续加间隙锁。
- 用户并不能直接操作间隙锁,而是通过加 X 锁或 S 锁去触发间隙锁。
# 触发独占间隙锁SELECT ··· FOR UPDATE;# 触发共享间隙锁SELECT ··· LOCK IN SHARE MODE;SELECT ··· FOR SHARE;实战:
# 事务1mysql> select * from student;+----+------+-------+| id | name | class |+----+------+-------+| 1 | 张三 | 一班 || 3 | 李四 | 一班 || 8 | 王五 | 二班 |+----+------+-------+3 rows in set (0.00 sec)mysql> begin;Query OK, 0 rows affected (0.00 sec)# 开启id为(3,8)之间的共享间隙锁mysql> select * from student where id=5 for share;Empty set (0.00 sec)# 事务2mysql> begin;Query OK, 0 rows affected (0.00 sec)mysql> select * from student;+----+------+-------+| id | name | class |+----+------+-------+| 1 | 张三 | 一班 || 3 | 李四 | 一班 || 8 | 王五 | 二班 |+----+------+-------+3 rows in set (0.00 sec)# 处于阻塞中,表明禁止在id为3到8中插入值mysql> INSERT INTO student VALUES (6, 'tom', '四班');多事务互相设置间隙锁导致死锁:
# 事务1mysql> begin;Query OK, 0 rows affected (0.00 sec)mysql> select * from student;+----+------+-------+| id | name | class |+----+------+-------+| 1 | 张三 | 一班 || 3 | 李四 | 一班 || 8 | 王五 | 二班 |+----+------+-------+3 rows in set (0.00 sec)# 加上间隙锁mysql> select * from student where id=5 for share;Empty set (0.00 sec)# 事务2mysql> select * from student;+----+------+-------+| id | name | class |+----+------+-------+| 1 | 张三 | 一班 || 3 | 李四 | 一班 || 8 | 王五 | 二班 |+----+------+-------+3 rows in set (0.00 sec)mysql> begin;Query OK, 0 rows affected (0.00 sec)# 加上间隙锁mysql> select * from student where id=5 for share;Empty set (0.00 sec)# 阻塞mysql> INSERT INTO student VALUES (7, '赵六', '一班');# 事务1# 报错了,系统显示检查到死锁mysql> INSERT INTO student VALUES (6, 'tom', '二班');ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transactionmysql> select * from student;+----+------+-------+| id | name | class |+----+------+-------+| 1 | 张三 | 一班 || 3 | 李四 | 一班 || 8 | 王五 | 二班 |+----+------+-------+3 rows in set (0.00 sec)# 事务2: 阻塞解除并且返回了结果Query OK, 1 row affected (17.00 sec)mysql> select * from student;+----+-------+-------+| id | name | class |+----+-------+-------+| 1 | 张三 | 一班 || 3 | 李四 | 一班 || 7 | 赵六 | 一班 || 8 | 王五 | 二班 |+----+-------+-------+4 rows in set (0.00 sec)下面有几个问题:
-
当两个事务对一张表的同一个区间加上间隙锁的时候,然后事务 2 执行插入发生阻塞,那么这个阻塞是事务 1 的间隙锁导致的还是事务 2 的间隙锁导致的?
答案:事务 1 的间隙锁导致的。
-
事务 2 阻塞后,在事务 1 中执行插入操作后,事务 1 没有因为事务 2 的间隙锁而阻塞。而是事务 1 中直接返回了一个错误,与此同时事务 2 的阻塞也被解除,并返回一条插入成功的消息。这个过程中底层发生了什么?
答案:因为当事务 1 执行插入操作后,MySQL 的死锁检测机制发现有死锁的产生,那么该机制就会主动回滚死锁链条中的某一个事务(持有最小行级排他锁的事务)。所以 mysql 让事务 1 进行了回滚,导致事务 1 设置的间隙锁释放,最后导致事务 2 执行成功。
3. 临键锁
有时候我们既想锁住某条记录,又想阻止其他事务在该记录前边的间隙插入新记录,所以 InnoDB 就提出了一种临键锁,官方名称为:LOCK_ORDINARY。临键锁是在存储引擎innoDB、事务级别在可重复读的情况下使用的数据库锁,innoDB 默认的锁就是临键锁。可以认为临键锁就是记录锁和间隙锁的组合体。
# 事务1mysql> select * from student;+----+------+-------+| id | name | class |+----+------+-------+| 1 | 张三 | 一班 || 3 | 李四 | 一班 || 8 | 王五 | 二班 |+----+------+-------+3 rows in set (0.00 sec)mysql> begin;Query OK, 0 rows affected (0.00 sec)mysql> select * from student where id>3 and id<=8 for share;+----+------+-------+| id | name | class |+----+------+-------+| 8 | 王五 | 二班 |+----+------+-------+1 row in set (0.00 sec)# 事务2mysql> begin;Query OK, 0 rows affected (0.00 sec)# 该语句没有阻塞是因为上面事务1的sql语句没有对于id=3的情况上临键锁mysql> select * from student where id=3 for update;+----+------+-------+| id | name | class |+----+------+-------+| 3 | 李四 | 一班 |+----+------+-------+1 row in set (0.00 sec)# 执行这条语句直接阻塞(记录锁的特性)mysql> select * from student where id=8 for update;# 执行这条语句同样阻塞(间隙锁的特性)mysql> INSERT INTO student VALUES (6, 'tom', '二班');4. 插入意向锁
我们说一个事务在插入一条记录时需要判断一下插入位置是不是被别的事务加了行锁,如果有的话,插入操作需要等待,直到拥有行锁的那个事务提交。但是 InnoDB 规定事务在等待的时候也需要在内存中生成一个锁结构 ,表明有事务想在某个间隙中插入新记录,但是现在在等待。InnoDB 就把这种类型的锁命名为插入意向锁,官方的类型名称为:LOCK_INSERT_INTENTION。插入意向锁是在插入一条记录行前,由 INSERT 操作产生的一种间隙锁。事实上 插入意向锁并不会阻止别的事务继续获取该记录上任何类型的锁。
页级锁
页锁就是在页的粒度上进行锁定,锁定的数据资源比行锁要多,因为一个页中可以有多个行记录。当我们使用页锁的时候,会出现数据浪费的现象,但这样的浪费最多也就是一个页上的数据行。 页锁的开销介于表锁和行锁之间,会出现死锁。锁定粒度介于表锁和行锁之间,并发度一般。
每个层级的锁数量是有限制的,因为锁会占用内存空间,锁空间的大小是有限的。当某个层级的锁数量超过了这个层级的阈值时,就会进行锁升级。锁升级就是用更大粒度的锁替代多个更小粒度的锁,比如 InnoDB 中行锁升级为表锁,这样做的好处是占用的锁空间降低了,但同时数据的并发度也下降了。
对锁的态度划分
从对待锁的态度来看锁的话,可以将锁分成乐观锁和悲观锁,从名字中也可以看出这两种锁是两种看待数据并发的思维方式 。需要注意的是,乐观锁和悲观锁并不是锁,而是锁的设计思想。
悲观锁
-
悲观锁是一种思想,顾名思义,就是很悲观,对数据被其他事务的修改持保守态度,会通过数据库自身的锁机制来实现,从而保证数据操作的排它性。
-
悲观锁总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会
阻塞直到它拿到锁。比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁,当其他线程想要访问数据时,都需要阻塞挂起。
乐观锁
乐观锁认为对同一数据的并发操作不会总发生,属于小概率事件,不用每次都对数据上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,也就是不采用数据库自身的锁机制,而是通过程序来实现。在程序上,可以采用版本号机制、时间戳机制或者CAS机制实现。
-
版本号机制:原理是在数据库表中增加一个
版本号字段,每次读取数据时同时获取数据的版本号。在后续的更新操作中,通过将这个版本号加一,并在更新条件中检查版本号是否仍然与最初读取时相同,来确保在此期间没有其他操作修改过数据。如果数据在这段时间内被其他事务更改了,版本号也会随之改变,导致更新失败,这样就可以探测到冲突。具体的伪代码可以是:text开始事务;// 1. 首先读取数据及其版本号SELECT data, version FROM table WHERE condition;将结果存储在变量中,例如 dataValue 和 versionValue;// 2. 对数据进行所需的修改dataValue = 修改后的数据;// 3. 更新数据,前提是版本号未变UPDATE tableSET data = dataValue, version = version + 1WHERE condition AND version = versionValue;// 4. 检查更新是否成功IF (更新影响的行数 > 0) 提交事务;ELSE 回滚事务; 可以选择重试或报告更新冲突; -
时间戳机制:与版本号机制类似,但这里使用的是时间戳来标记数据的版本。在更新数据前,首先记录下数据当前的时间戳,更新时检查数据库中的时间戳是否与之前记录的时间戳相同。如果相同,说明数据未被其他操作更改,可以安全地进行更新。否则,表示数据版本有冲突,更新操作应当失败。
-
CAS 机制(Compare And Swap):通过比较目标数据的当前值与预期值,只有在当前值与预期值相等时,才将其更新为新值。这一过程是原子操作,适用于处理如计数器、状态标记等简单数据的并发更新。
假设在一个多线程的计数器应用中,多个线程需要同时更新同一个计数器。可以使用 CAS 机制来确保计数器的正确更新:
- 初始计数器 counter 的值为 0。
- 线程 A 想要将其增加 1:
- 比较(Compare):A 读取 counter 的当前值,发现是 0,和预期值相同。
- 交换(Swap):A 就将计数器的值加 1,从 0 变为 1。
- 同时,线程 B 也想要将计数器增加 1:
- 比较(Compare):B 读取 counter 的当前值,发现是 1,不是预期值 0。
- 线程 B 将预期值更新为 1。
- 比较(Compare):B 读取 counter 的当前值,假设现在是 1,和 B 更新后的预期值相同。
- 交换(Swap):B 就将计数器的值加 1,从 1 变为 2。
只有一个线程的 CAS 操作会成功(假设是线程 A),因为 CAS 操作确保了更新的原子性。线程 B 的 CAS 操作将失败,因为在 B 尝试更新时,counter 的值已经不是 B 读取时的预期值 0 了。此时,线程 B 可以重新读取最新值,然后再次尝试 CAS 操作,直到成功。
如何选择
-
乐观锁适合读操作多的场景,相对来说写的操作比较少。它的优点在于程序实现, 不存在死锁问题,不过适用场景也会相对乐观,因为它阻止不了除了程序以外的数据库操作。 -
悲观锁适合写操作多的场景,因为写的操作具有排它性。采用悲观锁的方式,可以在数据库层面阻止其他事务对该数据的操作权限,防止读 - 写和写 - 写的冲突。
对加锁方式划分
隐式锁
为了更好的理解隐式锁,先看下面一个例子:
-
事务 1:开启一个事务,在 student 表中插入了一条数据,未提交。
mysqlmysql> select * from student;+----+------+-------+| id | name | class |+----+------+-------+| 1 | 张三 | 一班 || 3 | 李四 | 一班 || 8 | 王五 | 二班 |+----+------+-------+3 rows in set (0.03 sec)mysql> begin;Query OK, 0 rows affected (0.00 sec)mysql> insert INTO student VALUES(2,"赵六","二班");Query OK, 1 row affected (0.00 sec) -
事务 2:开启一个事务,查看 student 表中是否有锁。如果无锁,则获取 student 表的读锁。
mysqlmysql> begin;Query OK, 0 rows affected (0.00 sec)mysql> SELECT * FROM performance_schema.data_lock_waits;Empty set (0.00 sec)mysql> select * from student for share;当执行
select * from student for share;准备获取读锁的时候发现直接阻塞了,这是为什么? -
目前事务 2 处于阻塞当中,我另外再开启一个会话准备查看 student 表中是否有锁:
sqlmysql> SELECT * FROM performance_schema.data_lock_waits; *************************** 1. row *************************** ENGINE: INNODB REQUESTING_ENGINE_LOCK_ID: 2091812328200:36:4:11:2091801841352REQUESTING_ENGINE_TRANSACTION_ID: 96425 REQUESTING_THREAD_ID: 941 REQUESTING_EVENT_ID: 27REQUESTING_OBJECT_INSTANCE_BEGIN: 2091801841352 BLOCKING_ENGINE_LOCK_ID: 2091812327424:36:4:11:2091801837592 BLOCKING_ENGINE_TRANSACTION_ID: 96424 BLOCKING_THREAD_ID: 941 BLOCKING_EVENT_ID: 25 BLOCKING_OBJECT_INSTANCE_BEGIN: 2091801837592 1 row in set (0.00 sec)发现 student 表确实存在锁。但是为什么事务 2 查看 student 表没有发现锁,但是获取 student 表的读锁时,又出现锁了呢?
-
现在事务 2 由于阻塞时间超时,结束阻塞了,在事务 2 中查看 student 表是否有锁:
mysqlmysql> select * from student for share;ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transactionmysql> SELECT * FROM performance_schema.data_lock_waits;Empty set (0.00 sec)
通过上面的例子,可以知道这就是隐式锁。
隐式锁:
MySQL 中的隐式锁机制涉及到聚簇索引和二级索引的处理方式,它们各自如何响应数据修改和锁定请求有所不同,但本质上都是为了维护事务的一致性和隔离性。
聚簇索引中的隐式锁
当事务 1在聚簇索引中插入一条新记录时(在插入时不加任何锁),该数据记录会有一个隐藏的trx_id列,该列用于记录最后修改该记录的事务 ID。如果另一个事务 2尝试对这条新插入的记录加上共享锁(S 锁)或排他锁(X 锁),系统首先会检查该记录的 trx_id 是否属于一个活跃的事务:
- 如果是:MySQL 将给事务 1创建一个排他锁(X 锁),并标记为非等待状态(
is_waiting=false);并且还会给事务 2创建一个锁结构,并标记为等待状态(is_waiting=true)。 - 如果不是:什么锁都不会创建,事务 2成功对这条新插入的记录加上共享锁(S 锁)或排他锁(X 锁)。
二级索引中的隐式锁
二级索引记录本身不包含trx_id,但它们所在的页面有一个PAGE_MAX_TRX_ID属性,记录了对该页面最后一次修改的事务 ID。如果此 ID 小于当前所有活跃事务中最小的 ID,说明该页面的修改都已经被提交。否则,需要进行以下步骤:
- 定位到具体的二级索引记录;
- 回到聚簇索引找到相应记录;
- 然后再应用聚簇索引中的隐式锁处理方式。
通过这样的机制,MySQL 确保了即使在不直接锁定记录的情况下,也能够维护数据的一致性和事务的隔离性。
显式锁
通过特定的语句进行加锁,我们一般称之为显示加锁。
全局锁和死锁
全局锁
全局锁就是对整个数据库实例加锁。当你需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。全局锁的典型使用场景是:做全库逻辑备份。
全局锁的命令:
Flush tables with read lock死锁
死锁是指两个或多个事务在同一资源上相互占用,并请求锁定对方占用的资源,从而导致恶性循环。死锁示例(转账操作):
| 事务 1(A 给 B 转 10 元) | 事务 2(B 给 A 转 100 元) | |
|---|---|---|
| 1 | update account set money=money - 10 where name=‘A’; | |
| 2 | update account set money=money - 100 where name=‘B’; | |
| 3 | update account set money=money + 10 where name=‘B’; | |
| 4 | update account set money=money + 100 where name=‘A’; |
1. 产生死锁的必要条件
- 两个或者两个以上事务
- 每个事务都已经持有锁并且申请新的锁
- 锁资源同时只能被同一个事务持有或者不兼容
- 事务之间因为持有锁和申请锁导致彼此循环
等待死锁的关键在于:两个或两个以上的 session 加锁的顺序不一致。
2. 如何处理死锁
-
方式 1:直接进入等待,直到超时。
即当两个事务互相等待时,当一个事务等待时间超过设置的阈值时,就将其回滚,另外事务继续进行。这种方法简单有效,在 innodb 中,参数
innodb_lock_wait_timeout用来设置超时时间。mysqlmysql> show variables like 'innodb_lock_wait_timeout';+--------------------------+-------+| Variable_name | Value |+--------------------------+-------+| innodb_lock_wait_timeout | 50 |+--------------------------+-------+1 row in set, 1 warning (0.01 sec)缺点:对于在线服务来说,这个等待时间往往是无法接受的。那将此值修改短一些,比如 1s,0.1s 是否合适?不合适,容易误伤到普通的锁等待。
-
方式 2:使用死锁检测进行死锁处理
方式 1 检测死锁太过被动,innodb 还提供了
wait-for graph算法来主动进行死锁检测,每当加锁请求无法立即满足需要并进入等待时,wait-for graph算法都会被触发。这是一种较为主动的死锁检测机制,要求数据库保存锁的信息表和事务等待链表两部分信息。
基于这两个信息,可以绘制等待图:

死锁检测的原理是构建一个以事务为顶点、锁为边的有向图,判断有向图是否存在环,存在即有死锁。
一旦检测到回路、有死锁,这时侯 innoDB 存储引擎会选择
回滚undo量最小的事务,让其他事务继续执行。innodb_deadlock_detect设置为 on ,表示开启这个机制。mysqlmysql> show variables like 'innodb_deadlock_detect';+------------------------+-------+| Variable_name | Value |+------------------------+-------+| innodb_deadlock_detect | ON |+------------------------+-------+1 row in set, 1 warning (0.01 sec)缺点:每个新的被阻塞的线程,都要判断是不是由于自己的加入导致了死锁,这个操作时间复杂度是 O(N)。如果 100 个并发线程同时更新同一行,意味着检测 100*100 = 1 万次。
如何解决?
- 方式 1:关闭死锁检测,但意味着可能会出现大量的超时,会导致业务有损。
- 方式 2:控制并发访问的数量。比如在中间件中实现对于相同行的更新,在进入 MySQL 引擎执行之前进行排队,这样在 innoDB 内部就不会有大量的死锁检测工作。
3. 如何避免死锁
- 合理设计索引,使业务 SQL 尽可能过索引定位更少的行,减少锁竞争。
- 调整业务逻辑执行,避免 update/delete 长时间持有锁的 SQL 在事务的前面。
- 避免大事务,尽量将大事务拆成多个小事务来处理,小事务缩短锁定资源的时间,发生锁冲突的几率也更小
- 在并发比较高的系统中,不要显式加锁,特别是是在事务里显式加锁。如 select ··· for update 语句,如果是事务里运行了 start transaction 或设置了 autocommit 等于 0,那么就会锁定所查找到的记录。
- 降低隔离级别。如果业务允许,将隔离级别调低也是较好的选择。比如将隔离级别从 RR 调整为 RC,可以避免掉很多因为 gap 锁造成的死锁。
锁监控
关于 MySQL 锁的监控,我们一般可以通过检查InnoDB_row_lock等状态变量来分析系统上的行锁的争夺情况
mysql> show status like 'innodb_row_lock%';+-------------------------------+-------+| Variable_name | Value |+-------------------------------+-------+| Innodb_row_lock_current_waits | 0 || Innodb_row_lock_time | 60764 || Innodb_row_lock_time_avg | 30382 || Innodb_row_lock_time_max | 50249 || Innodb_row_lock_waits | 2 |+-------------------------------+-------+5 rows in set (0.02 sec)对各个状态量的说明如下:
- Innodb_row_lock_current_waits:当前正在等待锁定的数量;
- Innodb_row_lock_time:从系统启动到现在锁定总时间长度(等待总时长);
- Innodb_row_lock_time_avg:每次等待所花平均时间(等待平均时长);
- Innodb_row_lock_time_max:从系统启动到现在等待最常的一次所花的时间;
- Innodb_row_lock_waits:系统启动后到现在总共等待的次数(等待总次数);